diff --git a/application/execution_service.py b/application/execution_service.py index 30cac3b..b1cfc1b 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -253,6 +253,7 @@ class ExecutionCycleResult: DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 SMALL_ACCOUNT_SAFE_HAVEN_CASH_SUBSTITUTE_LIMIT_USD = 2000.0 +SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS = frozenset({"TQQQ", "SOXL"}) def _noop_sleep(_seconds): @@ -520,12 +521,30 @@ def _apply_small_account_whole_share_compatibility( continue if price > 0.0: quote_prices[symbol] = price + retained_symbols = [] + portfolio = dict((plan or {}).get("portfolio") or {}) + quantities = { + str(symbol or "").strip().upper(): float(quantity or 0.0) + for symbol, quantity in dict(portfolio.get("quantities") or {}).items() + } + compatibility_targets = { + str(symbol or "").strip().upper(): float(value or 0.0) + for symbol, value in target_values.items() + } + for symbol in candidate_symbols: + if symbol not in SMALL_ACCOUNT_EXISTING_WHOLE_SHARE_RETENTION_SYMBOLS: + continue + target_value = max(0.0, float(compatibility_targets.get(symbol, 0.0) or 0.0)) + price = max(0.0, float(quote_prices.get(symbol, 0.0) or 0.0)) + if price > 0.0 and 0.0 < target_value < price and quantities.get(symbol, 0.0) >= 1.0: + compatibility_targets[symbol] = price + retained_symbols.append(symbol) safe_haven_symbols = _safe_haven_cash_symbols( - portfolio=dict((plan or {}).get("portfolio") or {}), + portfolio=portfolio, allocation=allocation, ) compatibility = apply_small_account_cash_compatibility( - target_values, + compatibility_targets, quote_prices, candidate_symbols=candidate_symbols, safe_haven_cash_symbols=safe_haven_symbols, @@ -554,10 +573,14 @@ def _apply_small_account_whole_share_compatibility( 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) + if retained_symbols: + adjusted_allocation["small_account_existing_whole_share_retained_symbols"] = tuple( + dict.fromkeys(retained_symbols) + ) if cash_substitution_notes: adjusted_allocation["small_account_whole_share_cash_notes"] = cash_substitution_notes adjusted_plan = dict(plan or {}) - if substituted or safe_haven_substituted: + if substituted or safe_haven_substituted or retained_symbols: adjusted_plan["allocation"] = adjusted_allocation return adjusted_plan, adjusted_allocation diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 315e152..2eeab63 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -1355,6 +1355,75 @@ def test_tqqq_delevered_qqqm_target_is_executable_for_small_account(self): self.assertIn("限价买入] QQQM: 1股", sent_messages[0]) self.assertNotIn("QQQM.US 目标金额 $507.87 低于 1 股价格", sent_messages[0]) + def test_existing_tqqq_retention_below_one_share_keeps_min_whole_share(self): + plan = _build_plan( + strategy_profile="tqqq_growth_income", + strategy_symbols=("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + risk_symbols=("TQQQ", "QQQM"), + income_symbols=("SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + safe_haven_symbols=("BOXX",), + targets={ + "TQQQ": 60.94, + "QQQM": 320.00, + "BOXX": 0.0, + "SCHD": 0.0, + "DGRO": 0.0, + "SGOV": 0.0, + "SPYI": 0.0, + "QQQI": 0.0, + }, + market_values={ + "TQQQ": 541.31, + "QQQM": 0.0, + "BOXX": 0.0, + "SCHD": 0.0, + "DGRO": 0.0, + "SGOV": 0.0, + "SPYI": 0.0, + "QQQI": 0.0, + }, + sellable_quantities={"TQQQ": 7, "QQQM": 0, "BOXX": 0, "SCHD": 0, "DGRO": 0, "SGOV": 0, "SPYI": 0, "QQQI": 0}, + quantities={"TQQQ": 7, "QQQM": 0, "BOXX": 0, "SCHD": 0, "DGRO": 0, "SGOV": 0, "SPYI": 0, "QQQI": 0}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=528.91, + market_status="", + deploy_ratio_text="", + income_ratio_text="", + income_locked_ratio_text="", + signal_message="🚀 入场信号", + available_cash=539.70, + total_strategy_equity=539.70, + portfolio_rows=(("TQQQ", "QQQM", "BOXX"), ("SCHD", "DGRO", "SGOV", "SPYI", "QQQI")), + benchmark_symbol="QQQ", + benchmark_price=722.05, + long_trend_value=626.87, + exit_line=626.87, + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={ + "TQQQ.US": 77.33, + "QQQM.US": 297.19, + "BOXX.US": 116.95, + "SCHD.US": 80.0, + "DGRO.US": 65.0, + "SGOV.US": 100.0, + "SPYI.US": 52.0, + "QQQI.US": 52.0, + }, + estimate_max_purchase_quantity_value=10, + strategy_display_name="TQQQ 增长收益", + ) + + self.assertEqual(len(sent_messages), 1) + self.assertIn("🔔 【调仓指令】", sent_messages[0]) + self.assertIn("限价卖出] TQQQ: 6股 @ $76.94", sent_messages[0]) + self.assertNotIn("限价卖出] TQQQ: 7股", sent_messages[0]) + self.assertIn("限价买入] QQQM: 1股 @ $298.68", sent_messages[0]) + self.assertNotIn("TQQQ.US 目标金额 $60.94 低于 1 股价格", sent_messages[0]) + def test_target_gap_below_one_share_does_not_report_cash_shortage(self): plan = _build_plan( strategy_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"),