From 779ebbe81730890f4c728901fdd22f1b693bb3cc Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:24:02 +0800 Subject: [PATCH 1/4] Surface TQQQ risk controls in notifications --- application/execution_service.py | 19 ++++- decision_mapper.py | 48 ++++++++++++ notifications/renderers.py | 37 +++++++++ notifications/telegram.py | 8 +- tests/test_decision_mapper.py | 30 ++++++++ tests/test_notifications.py | 87 ++++++++++++++++++++++ tests/test_rebalance_service.py | 124 ++++++++++++++++++++++++++++++- tests/test_strategy_runtime.py | 2 +- 8 files changed, 346 insertions(+), 9 deletions(-) diff --git a/application/execution_service.py b/application/execution_service.py index e50230b..2f3fdc7 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -164,7 +164,7 @@ def apply_small_account_cash_compatibility( for symbol, price in dict(prices or {}).items() } notes = [] - if safe_haven_substituted: + if substituted: for symbol in substituted: target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0)) price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0)) @@ -518,14 +518,27 @@ def _apply_small_account_whole_share_compatibility( adjusted_targets = compatibility.targets substituted = compatibility.whole_share_substituted_symbols safe_haven_substituted = compatibility.safe_haven_cash_substituted_symbols + cash_substitution_notes = tuple(compatibility.cash_substitution_notes or ()) + if substituted and not cash_substitution_notes: + cash_substitution_notes = tuple( + { + "symbol": symbol, + "target_value": max(0.0, float(target_values.get(symbol, 0.0) or 0.0)), + "price": max(0.0, float(quote_prices.get(symbol, 0.0) or 0.0)), + "cash_symbols": tuple(safe_haven_substituted), + } + for symbol in substituted + if max(0.0, float(target_values.get(symbol, 0.0) or 0.0)) > 0.0 + and max(0.0, float(quote_prices.get(symbol, 0.0) or 0.0)) > 0.0 + ) adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets} adjusted_allocation.pop("small_account_whole_share_cash_notes", None) 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) - if compatibility.cash_substitution_notes: - adjusted_allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes) + 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: adjusted_plan["allocation"] = adjusted_allocation diff --git a/decision_mapper.py b/decision_mapper.py index d3dfb45..214b3ff 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -32,6 +32,28 @@ "snapshot_manifest_source_refresh_run_id", "snapshot_manifest_source_refresh_generated_at", ) +_TQQQ_RISK_CONTROL_EXECUTION_FIELDS = ( + "dual_drive_volatility_delever_enabled", + "dual_drive_volatility_delever_window", + "dual_drive_volatility_delever_threshold_mode", + "dual_drive_volatility_delever_threshold", + "dual_drive_volatility_delever_exit_threshold", + "dual_drive_volatility_delever_dynamic_threshold", + "dual_drive_volatility_delever_dynamic_sample_count", + "dual_drive_volatility_delever_dynamic_lookback", + "dual_drive_volatility_delever_dynamic_percentile", + "dual_drive_volatility_delever_dynamic_min_periods", + "dual_drive_volatility_delever_dynamic_floor", + "dual_drive_volatility_delever_dynamic_cap", + "dual_drive_volatility_delever_metric", + "dual_drive_volatility_delever_triggered", + "dual_drive_volatility_delever_entry_triggered", + "dual_drive_volatility_delever_hysteresis_triggered", + "dual_drive_volatility_delever_trigger_reason", + "dual_drive_volatility_delever_applied", + "dual_drive_volatility_delever_vetoed", + "dual_drive_volatility_delever_redirect_symbol", +) def _build_portfolio_inputs( @@ -133,6 +155,27 @@ def _attach_snapshot_diagnostics( execution[field] = value +def _attach_tqqq_risk_control_execution_fields( + plan: dict[str, Any], + *, + decision: StrategyDecision, + runtime_metadata: Mapping[str, Any] | None, +) -> None: + if _resolve_canonical_profile(str(plan.get("strategy_profile") or "")) != "tqqq_growth_income": + return + execution = plan.get("execution") + if not isinstance(execution, dict): + return + diagnostics = {**dict(runtime_metadata or {}), **dict(decision.diagnostics)} + annotations = diagnostics.get("execution_annotations") + if isinstance(annotations, Mapping): + diagnostics = {**diagnostics, **dict(annotations)} + for field in _TQQQ_RISK_CONTROL_EXECUTION_FIELDS: + value = diagnostics.get(field) + if value not in (None, ""): + execution[field] = value + + def _apply_reserved_cash_policy( annotations: ValueTargetExecutionAnnotations, *, @@ -562,4 +605,9 @@ def map_strategy_decision_to_plan( decision=normalized_decision, runtime_metadata=runtime_metadata, ) + _attach_tqqq_risk_control_execution_fields( + plan, + decision=normalized_decision, + runtime_metadata=runtime_metadata, + ) return plan diff --git a/notifications/renderers.py b/notifications/renderers.py index f1048b3..de437d1 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -193,6 +193,41 @@ def _build_benchmark_lines(execution, *, translator): ] +def _format_percent(value) -> str: + try: + return f"{float(value) * 100:.1f}%" + except (TypeError, ValueError): + return "n/a" + + +def _build_risk_control_lines(execution, *, translator): + if _is_truthy(execution.get("dual_drive_volatility_delever_applied")): + redirect_symbol = str(execution.get("dual_drive_volatility_delever_redirect_symbol") or "QQQ").strip().upper() + window = str(execution.get("dual_drive_volatility_delever_window") or "5").strip() + if str(execution.get("dual_drive_volatility_delever_trigger_reason") or "").strip() == "hysteresis_hold": + return [ + translator( + "risk_control_tqqq_volatility_delever_hysteresis", + window=window, + volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")), + exit_threshold=_format_percent(execution.get("dual_drive_volatility_delever_exit_threshold")), + source_symbol="TQQQ", + redirect_symbol=redirect_symbol or "QQQ", + ) + ] + return [ + translator( + "risk_control_tqqq_volatility_delever_applied", + window=window, + volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")), + threshold=_format_percent(execution.get("dual_drive_volatility_delever_threshold")), + source_symbol="TQQQ", + redirect_symbol=redirect_symbol or "QQQ", + ) + ] + return [] + + def _format_dashboard_text(text, *, translator=None) -> str: lines = [] for raw_line in str(text or "").splitlines(): @@ -320,6 +355,7 @@ def _append_status_lines(lines, *, execution, translator, signal_key): if signal_display: _append_labeled_text(lines, signal_key, signal_display, translator=translator, value_key="msg") + lines.extend(_build_risk_control_lines(execution, translator=translator)) lines.extend(_build_benchmark_lines(execution, translator=translator)) @@ -341,6 +377,7 @@ def _append_compact_status_lines(lines, *, execution, translator, signal_key): if signal_summary: lines.append(translator(signal_key, msg=signal_summary)) + lines.extend(_build_risk_control_lines(execution, translator=translator)) def _append_strategy_line(lines, *, strategy_display_name, translator): name = str(strategy_display_name or "").strip() diff --git a/notifications/telegram.py b/notifications/telegram.py index f758196..4c51919 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -63,7 +63,9 @@ "buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单", "buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入", "buy_deferred_small_target_gap": "{symbol} 目标差额 ${diff} 未超过 1 股价格 ${price};为避免超过目标仓位,本轮不买入", - "buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,小账户本轮保留现金,不回补 {cash_symbols}", + "buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,本轮保留现金(现金替代:{cash_symbols})", + "risk_control_tqqq_volatility_delever_applied": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于 {threshold},{source_symbol} 转向 {redirect_symbol}", + "risk_control_tqqq_volatility_delever_hysteresis": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold},维持 {source_symbol} 转向 {redirect_symbol}", "buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})", "buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", "buy_deferred_cash_sweep_cash_limit": "{symbol} 剩余可投资现金 ${investable},预算可回补 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用", @@ -185,7 +187,9 @@ "buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle", "buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying", "buy_deferred_small_target_gap": "{symbol} target gap ${diff} does not exceed the 1-share price ${price}; skipped to avoid exceeding the target allocation", - "buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this small account keeps cash this cycle and does not rebuy {cash_symbols}", + "buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this cycle keeps cash (cash substitute: {cash_symbols})", + "risk_control_tqqq_volatility_delever_applied": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} is above {threshold}; {source_symbol} redirects to {redirect_symbol}", + "risk_control_tqqq_volatility_delever_hysteresis": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} remains above the exit threshold {exit_threshold}; keep {source_symbol} redirected to {redirect_symbol}", "buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}", "buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", "buy_deferred_cash_sweep_cash_limit": "{symbol} residual investable cash ${investable}, budget supports {budget_qty} tail-rebuy shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds", diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index cd4b9c2..8a70b57 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -112,6 +112,21 @@ def test_maps_hybrid_decision_from_snapshot_source(self): "benchmark_price": 500.0, "long_trend_value": 480.0, "exit_line": 470.0, + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_window": 5, + "dual_drive_volatility_delever_metric": 0.312, + "dual_drive_volatility_delever_threshold_mode": "rolling_percentile", + "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_exit_threshold": 0.24, + "dual_drive_volatility_delever_dynamic_threshold": 0.28, + "dual_drive_volatility_delever_dynamic_sample_count": 252, + "dual_drive_volatility_delever_dynamic_lookback": 252, + "dual_drive_volatility_delever_dynamic_percentile": 0.90, + "dual_drive_volatility_delever_dynamic_min_periods": 126, + "dual_drive_volatility_delever_dynamic_floor": 0.24, + "dual_drive_volatility_delever_dynamic_cap": 0.36, + "dual_drive_volatility_delever_trigger_reason": "entry_threshold", + "dual_drive_volatility_delever_redirect_symbol": "QQQ", } }, ) @@ -139,6 +154,21 @@ def test_maps_hybrid_decision_from_snapshot_source(self): self.assertEqual(plan["execution"]["benchmark_price"], 500.0) self.assertEqual(plan["execution"]["long_trend_value"], 480.0) self.assertEqual(plan["execution"]["exit_line"], 470.0) + self.assertIs(plan["execution"]["dual_drive_volatility_delever_applied"], True) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_window"], 5) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_metric"], 0.312) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_threshold_mode"], "rolling_percentile") + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_threshold"], 0.28) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_exit_threshold"], 0.24) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_threshold"], 0.28) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_sample_count"], 252) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_lookback"], 252) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_percentile"], 0.90) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_min_periods"], 126) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_floor"], 0.24) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_cap"], 0.36) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_trigger_reason"], "entry_threshold") + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_redirect_symbol"], "QQQ") self.assertEqual(plan["portfolio"]["market_values"]["TQQQ"], 5000.0) def test_translates_weight_decision_for_snapshot_strategy(self): diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 3ea1305..0e8f94a 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -147,6 +147,93 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self): self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text) self.assertNotIn("longbridge_candlesticks", rendered.compact_text) + def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self): + zh_rendered = render_heartbeat_notification( + execution={ + "signal_display": "🚀 入场信号", + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_window": 5, + "dual_drive_volatility_delever_metric": 0.312, + "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_redirect_symbol": "QQQ", + }, + skip_logs=(), + note_logs=(), + translator=build_translator("zh"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="TQQQ 增长收益", + dry_run_only=False, + ) + en_rendered = render_heartbeat_notification( + execution={ + "signal_display": "Entry signal", + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_window": 5, + "dual_drive_volatility_delever_metric": 0.312, + "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_redirect_symbol": "QQQ", + }, + skip_logs=(), + note_logs=(), + translator=build_translator("en"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="TQQQ Growth Income", + dry_run_only=False, + ) + + self.assertIn("🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于 28.0%,TQQQ 转向 QQQ", zh_rendered.compact_text) + self.assertIn( + "🛡️ Risk control: QQQ 5d annualized volatility 31.2% is above 28.0%; TQQQ redirects to QQQ", + en_rendered.compact_text, + ) + + def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self): + zh_rendered = render_heartbeat_notification( + execution={ + "signal_display": "🚀 入场信号", + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_window": 5, + "dual_drive_volatility_delever_metric": 0.262, + "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_exit_threshold": 0.24, + "dual_drive_volatility_delever_trigger_reason": "hysteresis_hold", + "dual_drive_volatility_delever_redirect_symbol": "QQQM", + }, + skip_logs=(), + note_logs=(), + translator=build_translator("zh"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="TQQQ 增长收益", + dry_run_only=False, + ) + en_rendered = render_heartbeat_notification( + execution={ + "signal_display": "Entry signal", + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_window": 5, + "dual_drive_volatility_delever_metric": 0.262, + "dual_drive_volatility_delever_threshold": 0.28, + "dual_drive_volatility_delever_exit_threshold": 0.24, + "dual_drive_volatility_delever_trigger_reason": "hysteresis_hold", + "dual_drive_volatility_delever_redirect_symbol": "QQQM", + }, + skip_logs=(), + note_logs=(), + translator=build_translator("en"), + separator="━━━━━━━━━━━━━━━━━━", + strategy_display_name="TQQQ Growth Income", + dry_run_only=False, + ) + + self.assertIn( + "🛡️ 风控: QQQ 5 日年化波动率 26.2% 仍高于退出阈值 24.0%,维持 TQQQ 转向 QQQM", + zh_rendered.compact_text, + ) + self.assertIn( + "🛡️ Risk control: QQQ 5d annualized volatility 26.2% remains above the exit threshold 24.0%; keep TQQQ redirected to QQQM", + en_rendered.compact_text, + ) + def test_heartbeat_localizes_strategy_diagnostics_and_source_input_status(self): rendered = render_heartbeat_notification( execution={ diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index e318de8..a04049a 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -1030,8 +1030,8 @@ def test_strategy_target_keeps_cash_when_only_risk_target_is_unbuyable(self): self.assertEqual(len(sent_messages), 1) self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("SOXX.US 目标金额 $163.14 低于 1 股价格 $504.60", sent_messages[0]) - self.assertIn("小账户本轮保留现金", sent_messages[0]) - self.assertIn("不回补 BOXX.US", sent_messages[0]) + self.assertIn("本轮保留现金", sent_messages[0]) + self.assertIn("现金替代:BOXX.US", sent_messages[0]) self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0]) self.assertIn("市价卖出] BOXX: 6股", sent_messages[0]) self.assertNotIn("市价买入] SOXX", sent_messages[0]) @@ -1092,10 +1092,128 @@ def test_small_account_cash_substitution_note_is_not_duplicated_after_sell_refre self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("限价卖出] SOXL: 4股", sent_messages[0]) self.assertEqual(sent_messages[0].count("[买入说明] SOXX.US"), 1) - self.assertEqual(sent_messages[0].count("小账户本轮保留现金"), 1) + self.assertEqual(sent_messages[0].count("本轮保留现金"), 1) self.assertIn("SOXX.US 目标金额 $220.19 低于 1 股价格 $601.80", sent_messages[0]) self.assertNotIn("SOXX.US 目标金额 $219.67 低于 1 股价格 $601.80", sent_messages[0]) + def test_tqqq_delevered_qqq_target_below_one_share_reports_cash_reason(self): + plan = _build_plan( + strategy_profile="tqqq_growth_income", + strategy_symbols=("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + risk_symbols=("TQQQ", "QQQ"), + income_symbols=("SCHD", "DGRO", "SGOV", "SPYI", "QQQI"), + safe_haven_symbols=("BOXX",), + targets={ + "TQQQ": 0.0, + "QQQ": 507.87, + "BOXX": 45.14, + "SCHD": 0.0, + "DGRO": 0.0, + "SGOV": 0.0, + "SPYI": 0.0, + "QQQI": 0.0, + }, + market_values={symbol: 0.0 for symbol in ("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI")}, + sellable_quantities={symbol: 0 for symbol in ("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI")}, + quantities={symbol: 0 for symbol in ("TQQQ", "QQQ", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI")}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=553.01, + market_status="", + deploy_ratio_text="", + income_ratio_text="", + income_locked_ratio_text="", + signal_message="🚀 入场信号", + available_cash=564.30, + total_strategy_equity=564.30, + portfolio_rows=(("TQQQ", "QQQ", "BOXX"), ("SCHD", "DGRO", "SGOV", "SPYI", "QQQI")), + benchmark_symbol="QQQ", + benchmark_price=715.86, + long_trend_value=621.61, + exit_line=621.61, + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={ + "TQQQ.US": 80.0, + "QQQ.US": 715.86, + "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("⚠️ 本轮没有可执行订单", sent_messages[0]) + self.assertIn("QQQ.US 目标金额 $507.87 低于 1 股价格 $715.86", sent_messages[0]) + self.assertIn("现金替代:现金", sent_messages[0]) + self.assertNotIn("✅ 无需调仓", sent_messages[0]) + + def test_tqqq_delevered_qqqm_target_is_executable_for_small_account(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": 0.0, + "QQQM": 507.87, + "BOXX": 45.14, + "SCHD": 0.0, + "DGRO": 0.0, + "SGOV": 0.0, + "SPYI": 0.0, + "QQQI": 0.0, + }, + market_values={symbol: 0.0 for symbol in ("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI")}, + sellable_quantities={symbol: 0 for symbol in ("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI")}, + quantities={symbol: 0 for symbol in ("TQQQ", "QQQM", "BOXX", "SCHD", "DGRO", "SGOV", "SPYI", "QQQI")}, + current_min_trade=100.0, + trade_threshold_value=100.0, + investable_cash=553.01, + market_status="", + deploy_ratio_text="", + income_ratio_text="", + income_locked_ratio_text="", + signal_message="🚀 入场信号", + available_cash=564.30, + total_strategy_equity=564.30, + portfolio_rows=(("TQQQ", "QQQM", "BOXX"), ("SCHD", "DGRO", "SGOV", "SPYI", "QQQI")), + benchmark_symbol="QQQ", + benchmark_price=715.86, + long_trend_value=621.61, + exit_line=621.61, + ) + + sent_messages, _, _ = self._run_strategy( + plan, + prices={ + "TQQQ.US": 80.0, + "QQQM.US": 320.0, + "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("限价买入] QQQM: 1股", sent_messages[0]) + self.assertNotIn("QQQM.US 目标金额 $507.87 低于 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"), diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 6c38eca..8eed1aa 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -22,7 +22,7 @@ class _TqqqEntrypoint: required_inputs=frozenset({"benchmark_history", "portfolio_snapshot"}), default_config={ "benchmark_symbol": "QQQ", - "managed_symbols": ("TQQQ", "QQQ", "BOXX", "SPYI", "QQQI"), + "managed_symbols": ("TQQQ", "QQQM", "BOXX", "SPYI", "QQQI"), "income_threshold_usd": 1_000_000_000.0, "qqqi_income_ratio": 0.5, }, From 1ed94fde1958d5ad4d82f4e63b44e13d8e328338 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:26:47 +0800 Subject: [PATCH 2/4] Bump shared strategy dependencies --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 84ec3e5..217c2df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3b6a0a9bedde72773e188041e0dc48516b38aadc -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@8278048366f1cd83e29e0c921e4048e7e25ae227 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@e0f760255232b62481444a8c1d6637546ba2c07e +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c5126e0d69b97e2a8b481b8709595c42406f8d4a hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b690fcfd1e26648840723a5ab8b12c873f038b9b pandas requests From 882d03188410eb028747d896aa3efa56d7a06c67 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:35:22 +0800 Subject: [PATCH 3/4] Bump UsEquityStrategies dependency --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 217c2df..d19ec3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@e0f760255232b62481444a8c1d6637546ba2c07e -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c5126e0d69b97e2a8b481b8709595c42406f8d4a +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5fe430699e532ee444e6c2370b34da3dc8b01b06 hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b690fcfd1e26648840723a5ab8b12c873f038b9b pandas requests From 4c97dd82528782f6bea08eebe3c01feab546f302 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:38:37 +0800 Subject: [PATCH 4/4] Bump HkEquityStrategies dependency --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d19ec3f..87552ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@e0f760255232b62481444a8c1d6637546ba2c07e us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5fe430699e532ee444e6c2370b34da3dc8b01b06 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b690fcfd1e26648840723a5ab8b12c873f038b9b +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@8d539aeef707b3594af4073f4cd4c3b13140b73f pandas requests pytz