From 03a05a0c8e71a0e2027f58e92ec9cd806c2420df 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 | 106 +++++++++++++++++++++++ notifications/telegram.py | 124 +++++++++++++++++++++++++++ pyproject.toml | 4 +- requirements.txt | 4 +- tests/test_decision_mapper.py | 70 +++++++++++++++ tests/test_notifications_telegram.py | 46 ++++++++++ tests/test_signal_snapshot.py | 41 +++++++++ 8 files changed, 457 insertions(+), 4 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 2b71e71..efc42b3 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -21,6 +21,92 @@ _INCOME_SYMBOLS = frozenset({"QQQI", "SPYI"}) _DEFAULT_MIN_TRADE_FLOOR = 100.0 _DEFAULT_REBALANCE_THRESHOLD_RATIO = 0.01 +_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_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", +) +_SOXL_RISK_CONTROL_EXECUTION_FIELDS = ( + "blend_gate_volatility_delever_enabled", + "blend_gate_volatility_delever_symbol", + "blend_gate_volatility_delever_window", + "blend_gate_volatility_delever_threshold_mode", + "blend_gate_volatility_delever_threshold", + "blend_gate_volatility_delever_dynamic_threshold", + "blend_gate_volatility_delever_dynamic_sample_count", + "blend_gate_volatility_delever_dynamic_lookback", + "blend_gate_volatility_delever_dynamic_percentile", + "blend_gate_volatility_delever_dynamic_min_periods", + "blend_gate_volatility_delever_dynamic_floor", + "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_EXECUTION_FIELDS = ( + "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", +) def _symbol_role(symbol: str) -> str | None: @@ -331,4 +417,24 @@ def map_strategy_decision_to_plan( cash_by_currency = metadata.get("cash_by_currency") if isinstance(cash_by_currency, Mapping): plan["portfolio"]["cash_by_currency"] = dict(cash_by_currency) + diagnostics = { + **dict(runtime_metadata or {}), + **dict(decision.diagnostics), + **dict(normalized_decision.diagnostics), + } + for source in ( + (runtime_metadata or {}).get("execution_annotations"), + decision.diagnostics.get("execution_annotations"), + normalized_decision.diagnostics.get("execution_annotations"), + ): + if isinstance(source, Mapping): + diagnostics.update(source) + execution = plan.setdefault("execution", {}) + for field_name in ( + *_MARKET_REGIME_CONTROL_EXECUTION_FIELDS, + *_TQQQ_RISK_CONTROL_EXECUTION_FIELDS, + *_SOXL_RISK_CONTROL_EXECUTION_FIELDS, + ): + if field_name in diagnostics: + execution[field_name] = diagnostics[field_name] return plan diff --git a/notifications/telegram.py b/notifications/telegram.py index e6e459a..2f6c390 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -203,6 +203,10 @@ def format_small_account_cash_substitution_notes( "signal_blend_gate_risk_on": "{trend_symbol} 站上 {window} 日门槛线,持有 SOXL {soxl_ratio} + SOXX {soxx_ratio}", "signal_blend_gate_defensive": "{trend_symbol} 跌破门槛线,防守持有 SOXX {soxx_ratio}", "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}", "market_status_risk_on": "🚀 风险开启({asset})", "market_status_delever": "🛡️ 降杠杆({asset})", "signal_risk_on": "SOXL 站上 {window} 日均线,持有 SOXL,交易层风险仓位 {ratio}", @@ -322,6 +326,10 @@ def format_small_account_cash_substitution_notes( "signal_blend_gate_risk_on": "{trend_symbol} is above the {window}-day gate; hold SOXL {soxl_ratio} + SOXX {soxx_ratio}", "signal_blend_gate_defensive": "{trend_symbol} is below the gate; hold SOXX {soxx_ratio}", "signal_blend_gate_overlay_capped": "{trend_symbol} remains above the {window}-day gate, but risk cap is active ({reasons}); target {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}", "market_status_risk_on": "Risk on ({asset})", "market_status_delever": "Delever ({asset})", "signal_risk_on": "SOXL is above the {window}-day average; hold SOXL at risk sleeve {ratio}", @@ -702,12 +710,128 @@ def _detail_lines(value: Any, *, translator: Callable[..., str]) -> list[str]: return details +def _format_percent(value: Any) -> str: + try: + return f"{float(value) * 100:.1f}%" + except (TypeError, ValueError): + return "n/a" + + +def _format_percentile(value: Any) -> 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: Any) -> 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: Any) -> bool: + return value not in (None, "") + + +def _is_truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "y"} + + +def _effective_volatility_delever_threshold(execution: Mapping[str, Any], *, prefix: str) -> Any: + 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: Mapping[str, Any], + *, + prefix: str, + translator: Callable[..., str], +) -> 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 _format_tqqq_risk_control_lines( + execution: Mapping[str, Any], + *, + translator: Callable[..., str], +) -> 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_lines(execution: Mapping[str, Any], *, translator: Callable[..., str]) -> list[str]: status = _first_summary(execution.get("status_display"), translator=translator) signal = _first_summary(execution.get("signal_display"), translator=translator) lines = [] if status and status != signal: lines.append(translator("market_status_line", status=status)) + lines.extend(_format_tqqq_risk_control_lines(execution, translator=translator)) if signal: lines.append(translator("signal_line", signal=signal)) lines.extend(f" - {line}" for line in _detail_lines(execution.get("signal_display"), translator=translator)) diff --git a/pyproject.toml b/pyproject.toml index 1581604..35f10e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,8 @@ authors = [ ] dependencies = [ "firstrade==0.0.39", - "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", "google-cloud-storage", "requests", ] diff --git a/requirements.txt b/requirements.txt index c0e888e..d6d7240 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ flask gunicorn firstrade==0.0.39 -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 google-cloud-storage requests pytest diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index 75ad1c4..fea80ba 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -43,6 +43,20 @@ def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(): decision = StrategyDecision( positions=(PositionTarget(symbol="AAA", target_value=5000.0),), diagnostics={ + "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",), "execution_annotations": { "trade_threshold_value": 100.0, "reserved_cash": 1200.0, @@ -70,6 +84,62 @@ def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(): assert plan["execution"]["reserved_cash"] == 1200.0 assert plan["execution"]["investable_cash"] == 1800.0 + assert plan["execution"]["dual_drive_volatility_delever_threshold_mode"] == "rolling_percentile" + assert plan["execution"]["dual_drive_volatility_delever_dynamic_threshold"] == 0.30 + assert plan["execution"]["dual_drive_volatility_delever_dynamic_sample_count"] == 252 + assert plan["execution"]["dual_drive_volatility_delever_metric"] == 0.312 + assert plan["execution"]["dual_drive_volatility_delever_applied"] is True + assert plan["execution"]["dual_drive_volatility_delever_veto_reason"] == "taco_rebound_context" + assert plan["execution"]["dual_drive_volatility_delever_taco_veto_enabled"] is True + assert plan["execution"]["dual_drive_volatility_delever_removed_value"] == 4500.0 + assert plan["execution"]["dual_drive_volatility_delever_redirect_symbol"] == "QQQM" + assert plan["execution"]["dual_drive_macro_risk_governor_applied"] is True + assert plan["execution"]["dual_drive_macro_risk_governor_route"] == "risk_reduced" + assert plan["execution"]["dual_drive_crisis_defense_destination"] == "BOXX" + assert plan["execution"]["market_regime_control_route"] == "risk_reduced" + assert plan["execution"]["market_regime_control_reason_codes"] == ("macro:vix_crisis_level",) + + +def test_maps_soxl_dynamic_volatility_fields_to_execution(): + decision = StrategyDecision( + positions=(PositionTarget(symbol="SOXL", target_value=5000.0),), + diagnostics={ + "blend_gate_volatility_delever_threshold_mode": "rolling_percentile", + "blend_gate_volatility_delever_threshold": 0.60, + "blend_gate_volatility_delever_dynamic_threshold": 0.60, + "blend_gate_volatility_delever_dynamic_sample_count": 252, + "blend_gate_volatility_delever_dynamic_percentile": 0.95, + "blend_gate_volatility_delever_metric": 0.61, + "blend_gate_volatility_delever_triggered": True, + "blend_gate_volatility_delever_redirect_symbol": "SOXX", + "blend_gate_volatility_delever_removed_ratio": 0.70, + "execution_annotations": { + "trade_threshold_value": 100.0, + "reserved_cash": 100.0, + }, + }, + ) + snapshot = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=10000.0, + buying_power=3000.0, + positions=(), + ) + + plan = map_strategy_decision_to_plan( + decision, + snapshot=snapshot, + strategy_profile="soxl_soxx_trend_income", + ) + + assert plan["execution"]["blend_gate_volatility_delever_threshold_mode"] == "rolling_percentile" + assert plan["execution"]["blend_gate_volatility_delever_dynamic_threshold"] == 0.60 + assert plan["execution"]["blend_gate_volatility_delever_dynamic_sample_count"] == 252 + assert plan["execution"]["blend_gate_volatility_delever_dynamic_percentile"] == 0.95 + assert plan["execution"]["blend_gate_volatility_delever_metric"] == 0.61 + assert plan["execution"]["blend_gate_volatility_delever_triggered"] is True + assert plan["execution"]["blend_gate_volatility_delever_redirect_symbol"] == "SOXX" + assert plan["execution"]["blend_gate_volatility_delever_removed_ratio"] == 0.70 def test_value_decision_without_threshold_uses_platform_default(): diff --git a/tests/test_notifications_telegram.py b/tests/test_notifications_telegram.py index 43c1841..1590992 100644 --- a/tests/test_notifications_telegram.py +++ b/tests/test_notifications_telegram.py @@ -50,3 +50,49 @@ def test_render_cycle_summary_dashboard_text_does_not_hide_account_overview(): assert "Buying power: $100.00" not in message assert "📌 Strategy portfolio" not in message assert message.count("🎯 Signal:") == 1 + + +def test_render_cycle_summary_includes_tqqq_volatility_delever_risk_control(): + message = render_cycle_summary( + { + "account": "****1234", + "strategy_profile": "tqqq_growth_income", + "strategy_display_name": "TQQQ Growth Income", + "dry_run_only": False, + "portfolio": { + "total_equity": 10000.0, + "liquid_cash": 1000.0, + "portfolio_rows": (("TQQQ", "QQQM"), ("BOXX", "QQQI")), + "market_values": {"TQQQ": 0.0, "QQQM": 7000.0, "BOXX": 2000.0, "QQQI": 0.0}, + "quantities": {"TQQQ": 0, "QQQM": 60, "BOXX": 20, "QQQI": 0}, + }, + "allocation": {"targets": {"QQQM": 7000.0, "BOXX": 2000.0, "QQQI": 0.0}}, + "execution": { + "reserved_cash": 1000.0, + "investable_cash": 0.0, + "signal_display": "Entry signal", + "status_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", + }, + "submitted_orders": [], + "skipped_orders": [], + }, + lang="en", + ) + + 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 message diff --git a/tests/test_signal_snapshot.py b/tests/test_signal_snapshot.py index 81568dd..cbb297c 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="firstrade", + 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"