Skip to content

Commit f521131

Browse files
authored
Keep safe haven cash for unbuyable small accounts (#95)
1 parent 51543db commit f521131

3 files changed

Lines changed: 58 additions & 10 deletions

File tree

application/execution_service.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ class ExecutionCycleResult:
125125

126126

127127
DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0
128+
SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0
128129

129130

130131
def _noop_sleep(_seconds):
@@ -143,6 +144,16 @@ def _safe_haven_cash_symbols(*, portfolio: dict, allocation: dict) -> tuple[str,
143144
return tuple(dict.fromkeys(symbols))
144145

145146

147+
def _positive_target_total(targets: dict) -> float:
148+
total = 0.0
149+
for value in dict(targets or {}).values():
150+
try:
151+
total += max(0.0, float(value or 0.0))
152+
except (TypeError, ValueError):
153+
continue
154+
return total
155+
156+
146157
def _apply_safe_haven_cash_substitution(
147158
*,
148159
plan,
@@ -266,14 +277,20 @@ def _apply_small_account_whole_share_compatibility(
266277
target_values = dict(allocation.get("targets") or {})
267278
candidate_symbols = tuple(
268279
dict.fromkeys(
269-
tuple(allocation.get("risk_symbols", ()))
280+
str(symbol or "").strip().upper()
281+
for symbol in tuple(allocation.get("risk_symbols", ()))
270282
+ tuple(allocation.get("income_symbols", ()))
283+
if str(symbol or "").strip()
271284
)
272285
)
273286
if not candidate_symbols:
274-
safe_haven_symbols = set(allocation.get("safe_haven_symbols", ()))
287+
safe_haven_symbols = set(
288+
_safe_haven_cash_symbols(portfolio=dict((plan or {}).get("portfolio") or {}), allocation=allocation)
289+
)
275290
candidate_symbols = tuple(
276-
symbol for symbol in target_values if symbol not in safe_haven_symbols
291+
str(symbol or "").strip().upper()
292+
for symbol in target_values
293+
if str(symbol or "").strip().upper() not in safe_haven_symbols
277294
)
278295
quote_prices = {}
279296
for symbol in candidate_symbols:
@@ -289,11 +306,32 @@ def _apply_small_account_whole_share_compatibility(
289306
symbols=candidate_symbols,
290307
quantity_step=1.0,
291308
)
309+
safe_haven_symbols = _safe_haven_cash_symbols(
310+
portfolio=dict((plan or {}).get("portfolio") or {}),
311+
allocation=allocation,
312+
)
313+
remaining_non_safe_targets = [
314+
symbol
315+
for symbol in candidate_symbols
316+
if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0
317+
]
318+
safe_haven_substituted: list[str] = []
319+
if (
320+
substituted
321+
and not remaining_non_safe_targets
322+
and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD
323+
):
324+
for symbol in safe_haven_symbols:
325+
if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0:
326+
adjusted_targets[symbol] = 0.0
327+
safe_haven_substituted.append(symbol)
292328
adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets}
293329
if substituted:
294330
adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted
331+
if safe_haven_substituted:
332+
adjusted_allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted)
295333
adjusted_plan = dict(plan or {})
296-
if substituted:
334+
if substituted or safe_haven_substituted:
297335
adjusted_plan["allocation"] = adjusted_allocation
298336
return adjusted_plan, adjusted_allocation
299337

@@ -830,10 +868,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
830868
**note_kwargs,
831869
)
832870

871+
cash_sweep_substituted_to_cash = bool(
872+
allocation.get("small_account_safe_haven_cash_substituted_symbols")
873+
)
833874
if (
834875
not cash_sweep_sold_this_cycle
835876
and cash_sweep_symbol
836877
and cash_sweep_symbol in strategy_assets
878+
and (
879+
float(target_values.get(cash_sweep_symbol, 0.0) or 0.0) > 0.0
880+
or not cash_sweep_substituted_to_cash
881+
)
837882
):
838883
cash_sweep_price = safe_quote_last_price(
839884
f"{cash_sweep_symbol}.US",

strategy_registry.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@
2222
)
2323

2424
LONGBRIDGE_PLATFORM = "longbridge"
25+
NASDAQ_SP500_SMART_DCA_PROFILE = "nasdaq_sp500_smart_dca"
2526

26-
LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles()
27+
LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() - frozenset(
28+
{NASDAQ_SP500_SMART_DCA_PROFILE}
29+
)
2730

2831
PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = {
2932
LONGBRIDGE_PLATFORM: frozenset({US_EQUITY_DOMAIN}),
@@ -54,7 +57,7 @@
5457
profile,
5558
platform_id=LONGBRIDGE_PLATFORM,
5659
),
57-
)
60+
) - frozenset({NASDAQ_SP500_SMART_DCA_PROFILE})
5861
LONGBRIDGE_ENABLED_PROFILES = derive_enabled_profiles_for_platform(
5962
STRATEGY_CATALOG,
6063
capability_matrix=PLATFORM_CAPABILITY_MATRIX,

tests/test_rebalance_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self):
695695
self.assertIn("可投资现金", sent_messages[0])
696696
self.assertIn("SOXX.US", sent_messages[0])
697697

698-
def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):
698+
def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self):
699699
plan = _build_plan(
700700
strategy_symbols=("SOXL", "SOXX", "BOXX"),
701701
risk_symbols=("SOXL", "SOXX"),
@@ -726,10 +726,10 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):
726726
self.assertIn("🔔 【调仓指令】", sent_messages[0])
727727
self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0])
728728
self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0])
729-
self.assertNotIn("市价卖出] BOXX", sent_messages[0])
729+
self.assertIn("市价卖出] BOXX: 6股", sent_messages[0])
730730
self.assertNotIn("市价买入] SOXX", sent_messages[0])
731-
self.assertIn("市价买入] BOXX: 10股", sent_messages[0])
732-
self.assertIn("BOXX.US 目标差额 $524.92", sent_messages[0])
731+
self.assertNotIn("市价买入] BOXX", sent_messages[0])
732+
self.assertNotIn("BOXX.US 目标差额 $524.92", sent_messages[0])
733733
self.assertNotIn("限价买入] SOXX", sent_messages[0])
734734

735735
def test_target_gap_below_one_share_does_not_report_cash_shortage(self):

0 commit comments

Comments
 (0)