diff --git a/application/execution_service.py b/application/execution_service.py index 678c151..68a54b6 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -125,6 +125,7 @@ class ExecutionCycleResult: DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 +SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 def _noop_sleep(_seconds): @@ -143,6 +144,16 @@ def _safe_haven_cash_symbols(*, portfolio: dict, allocation: dict) -> tuple[str, return tuple(dict.fromkeys(symbols)) +def _positive_target_total(targets: dict) -> float: + total = 0.0 + for value in dict(targets or {}).values(): + try: + total += max(0.0, float(value or 0.0)) + except (TypeError, ValueError): + continue + return total + + def _apply_safe_haven_cash_substitution( *, plan, @@ -266,14 +277,20 @@ def _apply_small_account_whole_share_compatibility( target_values = dict(allocation.get("targets") or {}) candidate_symbols = tuple( dict.fromkeys( - tuple(allocation.get("risk_symbols", ())) + str(symbol or "").strip().upper() + for symbol in tuple(allocation.get("risk_symbols", ())) + tuple(allocation.get("income_symbols", ())) + if str(symbol or "").strip() ) ) if not candidate_symbols: - safe_haven_symbols = set(allocation.get("safe_haven_symbols", ())) + safe_haven_symbols = set( + _safe_haven_cash_symbols(portfolio=dict((plan or {}).get("portfolio") or {}), allocation=allocation) + ) candidate_symbols = tuple( - symbol for symbol in target_values if symbol not in safe_haven_symbols + str(symbol or "").strip().upper() + for symbol in target_values + if str(symbol or "").strip().upper() not in safe_haven_symbols ) quote_prices = {} for symbol in candidate_symbols: @@ -289,11 +306,32 @@ def _apply_small_account_whole_share_compatibility( symbols=candidate_symbols, quantity_step=1.0, ) + safe_haven_symbols = _safe_haven_cash_symbols( + portfolio=dict((plan or {}).get("portfolio") or {}), + allocation=allocation, + ) + remaining_non_safe_targets = [ + symbol + for symbol in candidate_symbols + if float(adjusted_targets.get(str(symbol or "").strip().upper(), 0.0) or 0.0) > 0.0 + ] + safe_haven_substituted: list[str] = [] + if ( + substituted + and not remaining_non_safe_targets + and _positive_target_total(adjusted_targets) <= SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD + ): + for symbol in safe_haven_symbols: + if float(adjusted_targets.get(symbol, 0.0) or 0.0) > 0.0: + adjusted_targets[symbol] = 0.0 + safe_haven_substituted.append(symbol) adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets} if substituted: adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted + if safe_haven_substituted: + adjusted_allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted) adjusted_plan = dict(plan or {}) - if substituted: + if substituted or safe_haven_substituted: adjusted_plan["allocation"] = adjusted_allocation return adjusted_plan, adjusted_allocation @@ -830,10 +868,17 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): **note_kwargs, ) + cash_sweep_substituted_to_cash = bool( + allocation.get("small_account_safe_haven_cash_substituted_symbols") + ) if ( not cash_sweep_sold_this_cycle and cash_sweep_symbol and cash_sweep_symbol in strategy_assets + and ( + float(target_values.get(cash_sweep_symbol, 0.0) or 0.0) > 0.0 + or not cash_sweep_substituted_to_cash + ) ): cash_sweep_price = safe_quote_last_price( f"{cash_sweep_symbol}.US", diff --git a/strategy_registry.py b/strategy_registry.py index fb341a7..91040f6 100644 --- a/strategy_registry.py +++ b/strategy_registry.py @@ -22,8 +22,11 @@ ) LONGBRIDGE_PLATFORM = "longbridge" +NASDAQ_SP500_SMART_DCA_PROFILE = "nasdaq_sp500_smart_dca" -LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() +LONGBRIDGE_ROLLOUT_ALLOWLIST = get_runtime_enabled_profiles() - frozenset( + {NASDAQ_SP500_SMART_DCA_PROFILE} +) PLATFORM_SUPPORTED_DOMAINS: dict[str, frozenset[str]] = { LONGBRIDGE_PLATFORM: frozenset({US_EQUITY_DOMAIN}), @@ -54,7 +57,7 @@ profile, platform_id=LONGBRIDGE_PLATFORM, ), -) +) - frozenset({NASDAQ_SP500_SMART_DCA_PROFILE}) LONGBRIDGE_ENABLED_PROFILES = derive_enabled_profiles_for_platform( STRATEGY_CATALOG, capability_matrix=PLATFORM_CAPABILITY_MATRIX, diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 032d676..9d96386 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -695,7 +695,7 @@ def test_buy_skip_without_orders_is_sent_in_single_heartbeat_message(self): self.assertIn("可投资现金", sent_messages[0]) self.assertIn("SOXX.US", sent_messages[0]) - def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self): + def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): plan = _build_plan( strategy_symbols=("SOXL", "SOXX", "BOXX"), risk_symbols=("SOXL", "SOXX"), @@ -726,10 +726,10 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self): self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertNotIn("SOXX.US 目标差额 $163.14", sent_messages[0]) self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0]) - self.assertNotIn("市价卖出] BOXX", sent_messages[0]) + self.assertIn("市价卖出] BOXX: 6股", sent_messages[0]) self.assertNotIn("市价买入] SOXX", sent_messages[0]) - self.assertIn("市价买入] BOXX: 10股", sent_messages[0]) - self.assertIn("BOXX.US 目标差额 $524.92", sent_messages[0]) + self.assertNotIn("市价买入] BOXX", sent_messages[0]) + self.assertNotIn("BOXX.US 目标差额 $524.92", sent_messages[0]) self.assertNotIn("限价买入] SOXX", sent_messages[0]) def test_target_gap_below_one_share_does_not_report_cash_shortage(self):