From a8f37847d078f042d9d6d34b5962cc81c7b3be21 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:56:56 +0800 Subject: [PATCH] Sync TQQQ risk diagnostics --- application/signal_snapshot.py | 66 ++++++++++++++++++ decision_mapper.py | 66 ++++++++++++++++++ notifications/renderers.py | 122 +++++++++++++++++++++++++++++++++ notifications/telegram.py | 8 +++ requirements.txt | 4 +- tests/test_decision_mapper.py | 28 ++++++++ tests/test_notifications.py | 78 +++++++++++++++++++++ tests/test_signal_snapshot.py | 41 +++++++++++ 8 files changed, 411 insertions(+), 2 deletions(-) diff --git a/application/signal_snapshot.py b/application/signal_snapshot.py index 992e23f..6a0c79e 100644 --- a/application/signal_snapshot.py +++ b/application/signal_snapshot.py @@ -37,6 +37,72 @@ "blend_gate_volatility_delever_dynamic_cap", "blend_gate_volatility_delever_metric", "blend_gate_volatility_delever_triggered", + "blend_gate_volatility_delever_retention_ratio", + "blend_gate_volatility_delever_redirect_symbol", + "blend_gate_volatility_delever_removed_ratio", + "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_veto_reason", + "dual_drive_volatility_delever_taco_veto_enabled", + "dual_drive_volatility_delever_taco_rebound_context_active", + "dual_drive_volatility_delever_true_crisis_active", + "dual_drive_volatility_delever_redirect_symbol", + "dual_drive_volatility_delever_removed_value", + "dual_drive_macro_risk_governor_enabled", + "dual_drive_macro_risk_governor_found", + "dual_drive_macro_risk_governor_route", + "dual_drive_macro_risk_governor_active", + "dual_drive_macro_risk_governor_applied", + "dual_drive_macro_risk_governor_leverage_scalar", + "dual_drive_macro_risk_governor_risk_asset_scalar", + "dual_drive_macro_risk_governor_removed_value", + "dual_drive_macro_risk_governor_redirected_to_unlevered", + "dual_drive_crisis_defense_enabled", + "dual_drive_crisis_defense_triggered", + "dual_drive_crisis_defense_applied", + "dual_drive_crisis_defense_destination", + "dual_drive_crisis_defense_removed_value", + "market_regime_control_enabled", + "market_regime_control_found", + "market_regime_control_source", + "market_regime_control_schema_version", + "market_regime_control_route", + "market_regime_control_route_source", + "market_regime_control_active", + "market_regime_control_applied", + "market_regime_control_route_allowed", + "market_regime_control_risk_scalar", + "market_regime_control_risk_budget_scalar", + "market_regime_control_leverage_scalar", + "market_regime_control_risk_asset_scalar", + "market_regime_control_taco_allowed", + "market_regime_control_local_delever_veto_allowed", + "market_regime_control_crisis_defense_required", + "market_regime_control_blocked_actions", + "market_regime_control_vetoes", + "market_regime_control_reason_codes", + "market_regime_control_removed_weight", + "market_regime_control_removed_ratio", + "market_regime_control_redirected_to_unlevered_ratio", + "market_regime_control_safe_haven", + "market_regime_control_risk_symbols", ) diff --git a/decision_mapper.py b/decision_mapper.py index 7b7a21b..8d3dd1d 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -251,6 +251,72 @@ def map_strategy_decision_to_plan( "blend_gate_volatility_delever_dynamic_cap", "blend_gate_volatility_delever_metric", "blend_gate_volatility_delever_triggered", + "blend_gate_volatility_delever_retention_ratio", + "blend_gate_volatility_delever_redirect_symbol", + "blend_gate_volatility_delever_removed_ratio", + "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_veto_reason", + "dual_drive_volatility_delever_taco_veto_enabled", + "dual_drive_volatility_delever_taco_rebound_context_active", + "dual_drive_volatility_delever_true_crisis_active", + "dual_drive_volatility_delever_redirect_symbol", + "dual_drive_volatility_delever_removed_value", + "dual_drive_macro_risk_governor_enabled", + "dual_drive_macro_risk_governor_found", + "dual_drive_macro_risk_governor_route", + "dual_drive_macro_risk_governor_active", + "dual_drive_macro_risk_governor_applied", + "dual_drive_macro_risk_governor_leverage_scalar", + "dual_drive_macro_risk_governor_risk_asset_scalar", + "dual_drive_macro_risk_governor_removed_value", + "dual_drive_macro_risk_governor_redirected_to_unlevered", + "dual_drive_crisis_defense_enabled", + "dual_drive_crisis_defense_triggered", + "dual_drive_crisis_defense_applied", + "dual_drive_crisis_defense_destination", + "dual_drive_crisis_defense_removed_value", + "market_regime_control_enabled", + "market_regime_control_found", + "market_regime_control_source", + "market_regime_control_schema_version", + "market_regime_control_route", + "market_regime_control_route_source", + "market_regime_control_active", + "market_regime_control_applied", + "market_regime_control_route_allowed", + "market_regime_control_risk_scalar", + "market_regime_control_risk_budget_scalar", + "market_regime_control_leverage_scalar", + "market_regime_control_risk_asset_scalar", + "market_regime_control_taco_allowed", + "market_regime_control_local_delever_veto_allowed", + "market_regime_control_crisis_defense_required", + "market_regime_control_blocked_actions", + "market_regime_control_vetoes", + "market_regime_control_reason_codes", + "market_regime_control_removed_weight", + "market_regime_control_removed_ratio", + "market_regime_control_redirected_to_unlevered_ratio", + "market_regime_control_safe_haven", + "market_regime_control_risk_symbols", ): if field_name in diagnostics: execution[field_name] = diagnostics[field_name] diff --git a/notifications/renderers.py b/notifications/renderers.py index f50f55e..796c06e 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -185,6 +185,112 @@ def _build_timing_audit_lines(execution, *, translator) -> list[str]: return [f"{label}: {value}"] +def _format_percent(value) -> str: + try: + return f"{float(value) * 100:.1f}%" + except (TypeError, ValueError): + return "n/a" + + +def _format_percentile(value) -> str: + try: + percentile = float(value) * 100 + except (TypeError, ValueError): + return "p?" + if float(percentile).is_integer(): + return f"p{int(percentile)}" + return f"p{percentile:.1f}" + + +def _format_sample_count(value) -> str: + try: + count = float(value) + except (TypeError, ValueError): + return "n/a" + if float(count).is_integer(): + return str(int(count)) + return f"{count:.1f}" + + +def _present(value) -> bool: + return value not in (None, "") + + +def _is_truthy(value) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "y"} + + +def _effective_volatility_delever_threshold(execution, *, prefix: str): + mode = str(execution.get(f"{prefix}_threshold_mode") or "").strip().lower() + dynamic_threshold = execution.get(f"{prefix}_dynamic_threshold") + if mode == "rolling_percentile" and _present(dynamic_threshold): + return dynamic_threshold + return execution.get(f"{prefix}_threshold") + + +def _format_volatility_delever_threshold_detail(execution, *, prefix: str, translator) -> str: + mode = str(execution.get(f"{prefix}_threshold_mode") or "").strip().lower() + fixed_threshold = execution.get(f"{prefix}_threshold") + dynamic_threshold = execution.get(f"{prefix}_dynamic_threshold") + if mode == "rolling_percentile": + kwargs = { + "percentile": _format_percentile(execution.get(f"{prefix}_dynamic_percentile")), + "lookback": _format_sample_count(execution.get(f"{prefix}_dynamic_lookback")), + "min_periods": _format_sample_count(execution.get(f"{prefix}_dynamic_min_periods")), + "sample_count": _format_sample_count(execution.get(f"{prefix}_dynamic_sample_count")), + "floor": _format_percent(execution.get(f"{prefix}_dynamic_floor")), + "cap": _format_percent(execution.get(f"{prefix}_dynamic_cap")), + "fixed_threshold": _format_percent(fixed_threshold), + } + if _present(dynamic_threshold): + return translator("blend_gate_volatility_threshold_detail_dynamic", **kwargs) + return translator("blend_gate_volatility_threshold_detail_dynamic_fallback", **kwargs) + return translator( + "blend_gate_volatility_threshold_detail_fixed", + threshold=_format_percent(fixed_threshold), + ) + + +def _build_tqqq_risk_control_lines(execution, *, translator) -> list[str]: + prefix = "dual_drive_volatility_delever" + if not _is_truthy(execution.get(f"{prefix}_applied")): + return [] + redirect_symbol = str(execution.get(f"{prefix}_redirect_symbol") or "QQQ").strip().upper() + window = str(execution.get(f"{prefix}_window") or "5").strip() + threshold = _effective_volatility_delever_threshold(execution, prefix=prefix) + threshold_detail = _format_volatility_delever_threshold_detail( + execution, + prefix=prefix, + translator=translator, + ) + if str(execution.get(f"{prefix}_trigger_reason") or "").strip() == "hysteresis_hold": + return [ + translator( + "risk_control_tqqq_volatility_delever_hysteresis_dynamic", + window=window, + volatility=_format_percent(execution.get(f"{prefix}_metric")), + exit_threshold=_format_percent(execution.get(f"{prefix}_exit_threshold")), + threshold=_format_percent(threshold), + threshold_detail=threshold_detail, + source_symbol="TQQQ", + redirect_symbol=redirect_symbol or "QQQ", + ) + ] + return [ + translator( + "risk_control_tqqq_volatility_delever_applied_dynamic", + window=window, + volatility=_format_percent(execution.get(f"{prefix}_metric")), + threshold=_format_percent(threshold), + threshold_detail=threshold_detail, + source_symbol="TQQQ", + redirect_symbol=redirect_symbol or "QQQ", + ) + ] + + def _format_signal_snapshot_line(snapshot, *, translator) -> str: if not isinstance(snapshot, Mapping): return "" @@ -261,6 +367,7 @@ def _build_compact_trade_message( signal_display, timing_lines, signal_snapshot_line, + risk_control_lines, trade_logs, ) -> str: lines = [ @@ -284,6 +391,7 @@ def _build_compact_trade_message( status_summary = _first_detail_line(status_display) if status_summary: lines.append(_format_market_status_line(status_summary, translator=translator)) + lines.extend(risk_control_lines) signal_summary = _first_detail_line(signal_display) if signal_summary: lines.append(f"🎯 {translator('signal_label')}: {signal_summary}") @@ -307,6 +415,7 @@ def _build_compact_heartbeat_message( signal_display, timing_lines, signal_snapshot_line, + risk_control_lines, ) -> str: lines = [ translator("heartbeat_header"), @@ -330,6 +439,7 @@ def _build_compact_heartbeat_message( status_summary = _first_detail_line(status_display) if status_summary: lines.append(_format_market_status_line(status_summary, translator=translator)) + lines.extend(risk_control_lines) signal_summary = _first_detail_line(signal_display) if signal_summary: lines.append(f"🎯 {translator('signal_label')}: {signal_summary}") @@ -357,12 +467,16 @@ def render_trade_notification( execution.get("signal_snapshot"), translator=translator, ) + risk_control_lines = _build_tqqq_risk_control_lines(execution, translator=translator) separator = str(execution["separator"]) status_line = ( "\n".join(_split_labeled_text(_format_market_status_line(status_display, translator=translator))) + "\n" if status_display else "" ) + risk_control_block = "\n".join(risk_control_lines) + if risk_control_block: + risk_control_block += "\n" dashboard_block = f"{dashboard_text}\n{separator}\n" if dashboard_text else "" trade_signal_lines = _format_label_value_lines(f"🎯 {translator('signal_label')}", signal_display) trade_signal_block = "\n".join(trade_signal_lines) @@ -376,6 +490,7 @@ def render_trade_notification( f"{chr(10).join(timing_lines) + chr(10) if timing_lines else ''}" f"{signal_snapshot_line + chr(10) if signal_snapshot_line else ''}" f"{status_line}" + f"{risk_control_block}" f"{trade_signal_block}\n\n" f"{dashboard_block}" + "\n".join(trade_logs) @@ -392,6 +507,7 @@ def render_trade_notification( signal_display=signal_display, timing_lines=timing_lines, signal_snapshot_line=signal_snapshot_line, + risk_control_lines=risk_control_lines, trade_logs=trade_logs, ) return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text) @@ -416,6 +532,7 @@ def render_heartbeat_notification( execution.get("signal_snapshot"), translator=translator, ) + risk_control_lines = _build_tqqq_risk_control_lines(execution, translator=translator) separator = str(execution["separator"]) total_equity = float(portfolio["total_equity"]) portfolio_rows = tuple(portfolio["portfolio_rows"]) @@ -425,6 +542,9 @@ def render_heartbeat_notification( if status_display else "" ) + risk_control_block = "\n".join(risk_control_lines) + if risk_control_block: + risk_control_block += "\n" dashboard_block = f"{dashboard_text}\n{separator}\n" if dashboard_text else "" benchmark_lines = _format_benchmark_lines(execution, translator=translator) benchmark_block = "\n".join(benchmark_lines) + "\n" if benchmark_lines else "" @@ -449,6 +569,7 @@ def render_heartbeat_notification( f"{chr(10).join(timing_lines) + chr(10) if timing_lines else ''}" f"{signal_snapshot_line + chr(10) if signal_snapshot_line else ''}" f"{status_line}" + f"{risk_control_block}" f"{heartbeat_signal_block}\n" f"{benchmark_block}" f"{separator}\n" @@ -467,5 +588,6 @@ def render_heartbeat_notification( signal_display=signal_display, timing_lines=timing_lines, signal_snapshot_line=signal_snapshot_line, + risk_control_lines=risk_control_lines, ) return RenderedNotification(detailed_text=detailed_text, compact_text=compact_text) diff --git a/notifications/telegram.py b/notifications/telegram.py index 4fe3b4f..27ddcf9 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -55,6 +55,10 @@ "signal_blend_gate_defensive": "{trend_symbol} 跌破门槛线,防守持有 SOXX {soxx_ratio}", "market_status_blend_gate_overlay_capped": "🧯 风控降档({asset})", "signal_blend_gate_overlay_capped": "{trend_symbol} 仍在 {window} 日门槛线上方,但触发风控降档({reasons}),目标仓位 {allocation_text}", + "risk_control_tqqq_volatility_delever_applied": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于 {threshold},{source_symbol} 转向 {redirect_symbol}", + "risk_control_tqqq_volatility_delever_applied_dynamic": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于实际阈值 {threshold}({threshold_detail}),{source_symbol} 转向 {redirect_symbol}", + "risk_control_tqqq_volatility_delever_hysteresis": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold},维持 {source_symbol} 转向 {redirect_symbol}", + "risk_control_tqqq_volatility_delever_hysteresis_dynamic": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold};入场实际阈值 {threshold}({threshold_detail}),维持 {source_symbol} 转向 {redirect_symbol}", "blend_gate_reason_rsi_cap": "RSI 超阈值", "blend_gate_reason_bollinger_cap": "突破布林上轨", "blend_gate_reason_volatility_delever": "{symbol} {window} 日年化波动率 {volatility} 高于 {threshold},SOXL 转向 {redirect_symbol}", @@ -164,6 +168,10 @@ "signal_blend_gate_defensive": "{trend_symbol} below gated entry, hold defensive SOXX {soxx_ratio}", "market_status_blend_gate_overlay_capped": "🧯 RISK-CAP ({asset})", "signal_blend_gate_overlay_capped": "{trend_symbol} stays above the {window}d gate, but risk cap ({reasons}) cuts exposure to {allocation_text}", + "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_applied_dynamic": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} is above effective threshold {threshold} ({threshold_detail}); {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}", + "risk_control_tqqq_volatility_delever_hysteresis_dynamic": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} remains above exit threshold {exit_threshold}; entry effective threshold {threshold} ({threshold_detail}); keep {source_symbol} redirected to {redirect_symbol}", "blend_gate_reason_rsi_cap": "RSI over threshold", "blend_gate_reason_bollinger_cap": "price above upper band", "blend_gate_reason_volatility_delever": "{symbol} {window}d annualized volatility {volatility} is above {threshold}; redirect SOXL to {redirect_symbol}", diff --git a/requirements.txt b/requirements.txt index a7bca35..f75e65f 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@023641c88506c732624a7329e48b51b9dbbe3c2a +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@7d35772d1125b534d0bcca557cb6dbaf28914719 pandas requests pandas_market_calendars diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index e6d4cd2..0860d48 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -36,6 +36,20 @@ def test_maps_hybrid_growth_decision_to_execution_plan(self): "exit_line": 360.0, "real_buying_power": 20000.0, "total_equity": 120000.0, + "dual_drive_volatility_delever_threshold_mode": "rolling_percentile", + "dual_drive_volatility_delever_dynamic_threshold": 0.30, + "dual_drive_volatility_delever_dynamic_sample_count": 252, + "dual_drive_volatility_delever_metric": 0.312, + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_veto_reason": "taco_rebound_context", + "dual_drive_volatility_delever_taco_veto_enabled": True, + "dual_drive_volatility_delever_removed_value": 4500.0, + "dual_drive_volatility_delever_redirect_symbol": "QQQM", + "dual_drive_macro_risk_governor_applied": True, + "dual_drive_macro_risk_governor_route": "risk_reduced", + "dual_drive_crisis_defense_destination": "BOXX", + "market_regime_control_route": "risk_reduced", + "market_regime_control_reason_codes": ("macro:vix_crisis_level",), }, ) @@ -52,6 +66,20 @@ def test_maps_hybrid_growth_decision_to_execution_plan(self): self.assertEqual(plan["portfolio"]["cash_sweep_symbol"], "BOXX") self.assertEqual(plan["portfolio"]["portfolio_rows"], (("TQQQ", "BOXX"), ("QQQI", "SPYI"))) self.assertEqual(plan["execution"]["trade_threshold_value"], 1200.0) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_threshold_mode"], "rolling_percentile") + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_threshold"], 0.30) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_sample_count"], 252) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_metric"], 0.312) + self.assertIs(plan["execution"]["dual_drive_volatility_delever_applied"], True) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_veto_reason"], "taco_rebound_context") + self.assertIs(plan["execution"]["dual_drive_volatility_delever_taco_veto_enabled"], True) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_removed_value"], 4500.0) + self.assertEqual(plan["execution"]["dual_drive_volatility_delever_redirect_symbol"], "QQQM") + self.assertIs(plan["execution"]["dual_drive_macro_risk_governor_applied"], True) + self.assertEqual(plan["execution"]["dual_drive_macro_risk_governor_route"], "risk_reduced") + self.assertEqual(plan["execution"]["dual_drive_crisis_defense_destination"], "BOXX") + self.assertEqual(plan["execution"]["market_regime_control_route"], "risk_reduced") + self.assertEqual(plan["execution"]["market_regime_control_reason_codes"], ("macro:vix_crisis_level",)) self.assertNotIn("strategy_symbols", plan) self.assertNotIn("sell_order_symbols", plan) self.assertNotIn("buy_order_symbols", plan) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 6ee3f95..23b3f1c 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -110,6 +110,45 @@ def test_build_translator_supports_chinese(self): ), "SOXX 10d annualized volatility 61.0% is above effective threshold 60.0% (dynamic p95, 252d lookback, bounded 50.0%-75.0%, samples 252); redirect SOXL to SOXX", ) + self.assertEqual( + translate( + "risk_control_tqqq_volatility_delever_applied_dynamic", + window=5, + volatility="31.2%", + threshold="30.0%", + threshold_detail=translate( + "blend_gate_volatility_threshold_detail_dynamic", + percentile="p90", + lookback="252", + floor="24.0%", + cap="36.0%", + sample_count="252", + ), + source_symbol="TQQQ", + redirect_symbol="QQQM", + ), + "🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),TQQQ 转向 QQQM", + ) + self.assertEqual( + en_translate( + "risk_control_tqqq_volatility_delever_hysteresis_dynamic", + window=5, + volatility="26.2%", + exit_threshold="24.0%", + threshold="30.0%", + threshold_detail=en_translate( + "blend_gate_volatility_threshold_detail_dynamic", + percentile="p90", + lookback="252", + floor="24.0%", + cap="36.0%", + sample_count="252", + ), + source_symbol="TQQQ", + redirect_symbol="QQQM", + ), + "🛡️ Risk control: QQQ 5d annualized volatility 26.2% remains above exit threshold 24.0%; entry effective threshold 30.0% (dynamic p90, 252d lookback, bounded 24.0%-36.0%, samples 252); keep TQQQ redirected to QQQM", + ) self.assertEqual( translate( "small_account_warning_note", @@ -162,6 +201,45 @@ def test_heartbeat_signal_snapshot_localizes_price_source_and_status_label(self) self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text) self.assertNotIn("schwab_daily_history_with_live_quote_overlay", rendered.compact_text) + def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self): + rendered = render_heartbeat_notification( + translator=build_translator("en"), + strategy_display_name="TQQQ Growth Income", + dry_run_only=False, + extra_notification_lines=(), + execution={ + "dashboard_text": "", + "separator": "━━━━━━━━━━━━━━━━━━", + "status_display": "Entry signal", + "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_threshold_mode": "rolling_percentile", + "dual_drive_volatility_delever_dynamic_threshold": 0.30, + "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_redirect_symbol": "QQQM", + }, + portfolio={ + "total_equity": 10000.0, + "portfolio_rows": (("TQQQ", "QQQM"),), + "market_values": {"TQQQ": 0.0, "QQQM": 7000.0}, + }, + account_label="demo", + ) + + self.assertIn( + "🛡️ Risk control: QQQ 5d annualized volatility 31.2% is above effective threshold 30.0% " + "(dynamic p90, 252d lookback, bounded 24.0%-36.0%, samples 252); TQQQ redirects to QQQM", + rendered.compact_text, + ) + def test_build_signal_text_formats_icon_and_label(self): signal_text = build_signal_text(build_translator("en")) self.assertEqual(signal_text("hold"), "💎 Trend Hold") diff --git a/tests/test_signal_snapshot.py b/tests/test_signal_snapshot.py index 2cf6800..f4f0c7c 100644 --- a/tests/test_signal_snapshot.py +++ b/tests/test_signal_snapshot.py @@ -21,3 +21,44 @@ def test_includes_soxl_dynamic_volatility_fields(): assert indicators["blend_gate_volatility_delever_dynamic_threshold"] == 0.60 assert indicators["blend_gate_volatility_delever_dynamic_sample_count"] == 252 assert indicators["blend_gate_volatility_delever_triggered"] is True + + +def test_includes_tqqq_volatility_delever_fields(): + snapshot = build_signal_snapshot( + platform="schwab", + strategy_profile="tqqq_growth_income", + execution={ + "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.30, + "dual_drive_volatility_delever_dynamic_sample_count": 252, + "dual_drive_volatility_delever_dynamic_percentile": 0.90, + "dual_drive_volatility_delever_metric": 0.312, + "dual_drive_volatility_delever_applied": True, + "dual_drive_volatility_delever_veto_reason": "taco_rebound_context", + "dual_drive_volatility_delever_taco_veto_enabled": True, + "dual_drive_volatility_delever_removed_value": 4500.0, + "dual_drive_macro_risk_governor_applied": True, + "dual_drive_macro_risk_governor_route": "risk_reduced", + "dual_drive_crisis_defense_destination": "BOXX", + "market_regime_control_route": "risk_reduced", + "market_regime_control_reason_codes": ("macro:vix_crisis_level",), + "dual_drive_volatility_delever_redirect_symbol": "QQQM", + }, + ) + + indicators = snapshot["indicators"] + assert indicators["dual_drive_volatility_delever_threshold_mode"] == "rolling_percentile" + assert indicators["dual_drive_volatility_delever_dynamic_threshold"] == 0.30 + assert indicators["dual_drive_volatility_delever_dynamic_sample_count"] == 252 + assert indicators["dual_drive_volatility_delever_applied"] is True + assert indicators["dual_drive_volatility_delever_veto_reason"] == "taco_rebound_context" + assert indicators["dual_drive_volatility_delever_taco_veto_enabled"] is True + assert indicators["dual_drive_volatility_delever_removed_value"] == 4500.0 + assert indicators["dual_drive_macro_risk_governor_applied"] is True + assert indicators["dual_drive_macro_risk_governor_route"] == "risk_reduced" + assert indicators["dual_drive_crisis_defense_destination"] == "BOXX" + assert indicators["market_regime_control_route"] == "risk_reduced" + assert indicators["market_regime_control_reason_codes"] == ["macro:vix_crisis_level"] + assert indicators["dual_drive_volatility_delever_redirect_symbol"] == "QQQM"