diff --git a/application/signal_snapshot.py b/application/signal_snapshot.py index 992e23f..77d04cf 100644 --- a/application/signal_snapshot.py +++ b/application/signal_snapshot.py @@ -13,6 +13,45 @@ "benchmark_price", "long_trend_value", "exit_line", + "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", "active_risk_asset", "allocation_mode", "trend_symbol", @@ -37,6 +76,33 @@ "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", + "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/notifications/renderers.py b/notifications/renderers.py index 9f141bb..7d24e3a 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -338,6 +338,112 @@ def _build_timing_audit_lines(signal_metadata, *, 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(signal_metadata, *, prefix: str): + mode = str(signal_metadata.get(f"{prefix}_threshold_mode") or "").strip().lower() + dynamic_threshold = signal_metadata.get(f"{prefix}_dynamic_threshold") + if mode == "rolling_percentile" and _present(dynamic_threshold): + return dynamic_threshold + return signal_metadata.get(f"{prefix}_threshold") + + +def _format_volatility_delever_threshold_detail(signal_metadata, *, prefix: str, translator) -> str: + mode = str(signal_metadata.get(f"{prefix}_threshold_mode") or "").strip().lower() + fixed_threshold = signal_metadata.get(f"{prefix}_threshold") + dynamic_threshold = signal_metadata.get(f"{prefix}_dynamic_threshold") + if mode == "rolling_percentile": + kwargs = { + "percentile": _format_percentile(signal_metadata.get(f"{prefix}_dynamic_percentile")), + "lookback": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_lookback")), + "min_periods": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_min_periods")), + "sample_count": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_sample_count")), + "floor": _format_percent(signal_metadata.get(f"{prefix}_dynamic_floor")), + "cap": _format_percent(signal_metadata.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(signal_metadata, *, translator) -> list[str]: + prefix = "dual_drive_volatility_delever" + if not _is_truthy(signal_metadata.get(f"{prefix}_applied")): + return [] + redirect_symbol = str(signal_metadata.get(f"{prefix}_redirect_symbol") or "QQQ").strip().upper() + window = str(signal_metadata.get(f"{prefix}_window") or "5").strip() + threshold = _effective_volatility_delever_threshold(signal_metadata, prefix=prefix) + threshold_detail = _format_volatility_delever_threshold_detail( + signal_metadata, + prefix=prefix, + translator=translator, + ) + if str(signal_metadata.get(f"{prefix}_trigger_reason") or "").strip() == "hysteresis_hold": + return [ + translator( + "risk_control_tqqq_volatility_delever_hysteresis_dynamic", + window=window, + volatility=_format_percent(signal_metadata.get(f"{prefix}_metric")), + exit_threshold=_format_percent(signal_metadata.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(signal_metadata.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 "" @@ -367,15 +473,17 @@ def _strategy_dashboard_text(signal_metadata, *, translator) -> str: metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {} raw_annotations = metadata.get("execution_annotations") annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {} + risk_source = {**metadata, **annotations} dashboard_text = _format_dashboard_text( annotations.get("dashboard_text") or metadata.get("dashboard_text") or metadata.get("dashboard") or "" ) + risk_control_lines = _build_tqqq_risk_control_lines(risk_source, translator=translator) timing_lines = _build_timing_audit_lines(metadata, translator=translator) snapshot_line = _format_signal_snapshot_line(metadata.get("signal_snapshot"), translator=translator) - audit_lines = [*timing_lines, *([snapshot_line] if snapshot_line else [])] + audit_lines = [*risk_control_lines, *timing_lines, *([snapshot_line] if snapshot_line else [])] if not audit_lines: return dashboard_text if not dashboard_text: diff --git a/notifications/telegram.py b/notifications/telegram.py index b4ed1d5..a3b96ce 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -78,6 +78,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}", @@ -224,6 +228,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 db534ac..5621f54 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ flask gunicorn -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3b6a0a9bedde72773e188041e0dc48516b38aadc -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@8278048366f1cd83e29e0c921e4048e7e25ae227 -hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b690fcfd1e26648840723a5ab8b12c873f038b9b +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@023641c88506c732624a7329e48b51b9dbbe3c2a +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@7d35772d1125b534d0bcca557cb6dbaf28914719 +hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@2e0075004239e7ede7ba256763a3441d4ec4ca73 pandas numpy requests diff --git a/tests/test_notifications.py b/tests/test_notifications.py index ad0e3a5..2c24355 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -1,3 +1,4 @@ +from notifications.renderers import build_dashboard from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message from strategy_registry import SUPPORTED_STRATEGY_PROFILES @@ -75,6 +76,45 @@ def test_build_translator_supports_chinese(): ) == "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" ) + assert ( + 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" + ) + assert ( + 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" + ) assert ( translate( "strategy_plugin_line", @@ -140,6 +180,40 @@ def test_supported_strategy_profiles_have_translated_names(): assert en_name(profile) != profile +def test_dashboard_renders_tqqq_volatility_delever_risk_control(): + dashboard = build_dashboard( + positions={}, + account_values={"equity": 10000.0, "buying_power": 1000.0}, + signal_desc="Entry signal", + status_desc="Entry signal", + strategy_profile="tqqq_growth_income", + strategy_display_name="TQQQ Growth Income", + signal_metadata={ + "dashboard_text": "📌 Strategy account overview", + "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", + }, + translator=build_translator("en"), + separator="━━━━━━━━━━━━━━━━━━", + ) + + assert ( + "🛡️ 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" + ) in dashboard + + def test_send_telegram_message_logs_non_200_response(capsys): class FakeResponse: status_code = 401 diff --git a/tests/test_signal_snapshot.py b/tests/test_signal_snapshot.py index fb6959b..d3be491 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="ibkr", + 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"