Skip to content

Commit 28430a2

Browse files
authored
Apply small-account whole-share compatibility (#71)
1 parent 14c62fa commit 28430a2

2 files changed

Lines changed: 191 additions & 4 deletions

File tree

application/execution_service.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,42 @@ def should_sell_cash_sweep_to_fund_whole_share_buy(
6868
if current_buying_power + sweep_capacity >= quote_price:
6969
return True
7070
return False
71+
try:
72+
from quant_platform_kit.common.small_account_compatibility import (
73+
project_unbuyable_value_targets_to_cash,
74+
)
75+
except ImportError: # pragma: no cover - compatibility with older pinned shared wheels
76+
def project_unbuyable_value_targets_to_cash(
77+
target_values,
78+
prices,
79+
*,
80+
symbols=None,
81+
quantity_step=1.0,
82+
):
83+
adjusted = {
84+
str(symbol or "").strip().upper(): float(value or 0.0)
85+
for symbol, value in dict(target_values or {}).items()
86+
}
87+
step = max(0.0, float(quantity_step or 0.0))
88+
if step <= 0.0:
89+
return adjusted, ()
90+
candidate_symbols = (
91+
tuple(adjusted)
92+
if symbols is None
93+
else tuple(dict.fromkeys(str(symbol or "").strip().upper() for symbol in symbols))
94+
)
95+
normalized_prices = {
96+
str(symbol or "").strip().upper(): float(price or 0.0)
97+
for symbol, price in dict(prices or {}).items()
98+
}
99+
substituted = []
100+
for symbol in candidate_symbols:
101+
target_value = max(0.0, float(adjusted.get(symbol, 0.0) or 0.0))
102+
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
103+
if price > 0.0 and 0.0 < target_value < (price * step):
104+
adjusted[symbol] = 0.0
105+
substituted.append(symbol)
106+
return adjusted, tuple(dict.fromkeys(substituted))
71107
from quant_platform_kit.common.quantity import (
72108
floor_to_quantity_step,
73109
format_quantity,
@@ -219,6 +255,49 @@ def safe_quote_last_price(symbol, *, market_data_port, notify_issue):
219255
return None
220256

221257

258+
def _apply_small_account_whole_share_compatibility(
259+
*,
260+
plan,
261+
allocation,
262+
strategy_assets,
263+
market_data_port,
264+
notify_issue,
265+
) -> tuple[dict, dict]:
266+
target_values = dict(allocation.get("targets") or {})
267+
candidate_symbols = tuple(
268+
dict.fromkeys(
269+
tuple(allocation.get("risk_symbols", ()))
270+
+ tuple(allocation.get("income_symbols", ()))
271+
)
272+
)
273+
if not candidate_symbols:
274+
safe_haven_symbols = set(allocation.get("safe_haven_symbols", ()))
275+
candidate_symbols = tuple(
276+
symbol for symbol in target_values if symbol not in safe_haven_symbols
277+
)
278+
quote_prices = {}
279+
for symbol in candidate_symbols:
280+
try:
281+
price = float(market_data_port.get_quote(f"{symbol}.US").last_price)
282+
except Exception:
283+
continue
284+
if price > 0.0:
285+
quote_prices[symbol] = price
286+
adjusted_targets, substituted = project_unbuyable_value_targets_to_cash(
287+
target_values,
288+
quote_prices,
289+
symbols=candidate_symbols,
290+
quantity_step=1.0,
291+
)
292+
adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets}
293+
if substituted:
294+
adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted
295+
adjusted_plan = dict(plan or {})
296+
if substituted:
297+
adjusted_plan["allocation"] = adjusted_allocation
298+
return adjusted_plan, adjusted_allocation
299+
300+
222301
def estimate_cash_buy_quantity_safe(
223302
trade_context,
224303
symbol,
@@ -314,8 +393,15 @@ def execute_rebalance_cycle(
314393
allocation=allocation,
315394
threshold_usd=safe_haven_cash_substitute_threshold_usd,
316395
)
317-
target_values = dict(allocation["targets"])
318396
cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper()
397+
plan, allocation = _apply_small_account_whole_share_compatibility(
398+
plan=plan,
399+
allocation=allocation,
400+
strategy_assets=strategy_assets,
401+
market_data_port=market_data_port,
402+
notify_issue=notify_issue,
403+
)
404+
target_values = dict(allocation["targets"])
319405
available_cash = float(portfolio["liquid_cash"])
320406
cash_by_currency = _normalize_cash_by_currency(portfolio.get("cash_by_currency"))
321407
investable_cash = float(execution["investable_cash"])
@@ -589,6 +675,13 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
589675
allocation=allocation,
590676
threshold_usd=safe_haven_cash_substitute_threshold_usd,
591677
)
678+
plan, allocation = _apply_small_account_whole_share_compatibility(
679+
plan=plan,
680+
allocation=allocation,
681+
strategy_assets=tuple(allocation["strategy_symbols"]),
682+
market_data_port=market_data_port,
683+
notify_issue=notify_issue,
684+
)
592685
threshold_value = float(execution["trade_threshold_value"])
593686
limit_order_symbols = set(
594687
allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ())

tests/test_rebalance_service.py

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,101 @@ def test_safe_haven_target_below_cash_substitute_threshold_stays_cash(self):
211211
self.assertEqual(submitted_orders, [])
212212
self.assertEqual(result.allocation["targets"]["BOXX"], 0.0)
213213

214+
def test_small_account_whole_share_layer_sells_unbuyable_soxx_sleeve(self):
215+
submitted_orders = []
216+
prices = {"SOXL": 191.15, "SOXX": 536.88, "BOXX": 100.0}
217+
plan = _build_plan(
218+
strategy_symbols=("SOXL", "SOXX", "BOXX"),
219+
risk_symbols=("SOXL", "SOXX"),
220+
safe_haven_symbols=("BOXX",),
221+
targets={"SOXL": 541.58, "SOXX": 154.74, "BOXX": 77.37},
222+
market_values={"SOXL": 0.0, "SOXX": 536.88, "BOXX": 0.0},
223+
sellable_quantities={"SOXL": 0, "SOXX": 1, "BOXX": 0},
224+
quantities={"SOXL": 0, "SOXX": 1, "BOXX": 0},
225+
current_min_trade=7.74,
226+
trade_threshold_value=7.74,
227+
investable_cash=213.60,
228+
market_status="Risk on",
229+
deploy_ratio_text="90.0%",
230+
income_ratio_text="0.0%",
231+
income_locked_ratio_text="0.0%",
232+
signal_message="SOXX above gate",
233+
available_cash=236.81,
234+
total_strategy_equity=773.69,
235+
portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)),
236+
)
237+
refreshed_plan = _build_plan(
238+
strategy_symbols=("SOXL", "SOXX", "BOXX"),
239+
risk_symbols=("SOXL", "SOXX"),
240+
safe_haven_symbols=("BOXX",),
241+
targets={"SOXL": 541.58, "SOXX": 154.74, "BOXX": 77.37},
242+
market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 0.0},
243+
sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0},
244+
quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0},
245+
current_min_trade=7.74,
246+
trade_threshold_value=7.74,
247+
investable_cash=750.48,
248+
market_status="Risk on",
249+
deploy_ratio_text="90.0%",
250+
income_ratio_text="0.0%",
251+
income_locked_ratio_text="0.0%",
252+
signal_message="SOXX above gate",
253+
available_cash=773.69,
254+
total_strategy_equity=773.69,
255+
portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)),
256+
)
257+
258+
result = execute_rebalance_cycle(
259+
trade_context=object(),
260+
plan=plan,
261+
portfolio=plan["portfolio"],
262+
execution=plan["execution"],
263+
allocation=plan["allocation"],
264+
fetch_replanned_state=lambda: (
265+
refreshed_plan,
266+
refreshed_plan["portfolio"],
267+
refreshed_plan["execution"],
268+
refreshed_plan["allocation"],
269+
),
270+
market_data_port=CallableMarketDataPort(
271+
quote_loader=lambda symbol: QuoteSnapshot(
272+
symbol=symbol,
273+
as_of="2026-05-22",
274+
last_price=prices[str(symbol).replace(".US", "")],
275+
)
276+
),
277+
estimate_max_purchase_quantity=lambda *_args, **_kwargs: 10,
278+
execution_port=CallableExecutionPort(
279+
lambda order_intent: (
280+
submitted_orders.append(order_intent),
281+
ExecutionReport(
282+
symbol=order_intent.symbol,
283+
side=order_intent.side,
284+
quantity=order_intent.quantity,
285+
status="accepted",
286+
broker_order_id=f"order-{len(submitted_orders)}",
287+
),
288+
)[-1]
289+
),
290+
notify_issue=lambda _title, _detail: None,
291+
translator=build_translator("zh"),
292+
with_prefix=lambda message: message,
293+
limit_sell_discount=1.0,
294+
limit_buy_premium=1.0,
295+
safe_haven_cash_substitute_threshold_usd=1000.0,
296+
)
297+
298+
self.assertTrue(result.action_done)
299+
self.assertEqual(result.allocation["targets"]["SOXX"], 0.0)
300+
self.assertEqual(
301+
result.allocation["small_account_whole_share_substituted_symbols"],
302+
("SOXX",),
303+
)
304+
self.assertEqual([(order.symbol, order.side, order.quantity) for order in submitted_orders], [
305+
("SOXX.US", "sell", 1),
306+
("SOXL.US", "buy", 2),
307+
])
308+
214309
def test_run_strategy_prefers_portfolio_port_runtime_path(self):
215310
sent_messages = []
216311
observed = {}
@@ -629,13 +724,12 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):
629724

630725
self.assertEqual(len(sent_messages), 1)
631726
self.assertIn("🔔 【调仓指令】", sent_messages[0])
632-
self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0])
633-
self.assertIn("SOXX.US 目标差额 $163.14 未超过 1 股价格 $504.60", sent_messages[0])
727+
self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0])
634728
self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0])
635729
self.assertNotIn("市价卖出] BOXX", sent_messages[0])
636730
self.assertNotIn("市价买入] SOXX", sent_messages[0])
637731
self.assertIn("市价买入] BOXX: 10股", sent_messages[0])
638-
self.assertIn("买入说明", sent_messages[0])
732+
self.assertIn("BOXX.US 目标差额 $524.92", sent_messages[0])
639733
self.assertNotIn("限价买入] SOXX", sent_messages[0])
640734

641735
def test_target_gap_below_one_share_does_not_report_cash_shortage(self):

0 commit comments

Comments
 (0)