Skip to content

Commit de94a49

Browse files
authored
Sync TQQQ risk diagnostics
1 parent 332ea32 commit de94a49

6 files changed

Lines changed: 301 additions & 4 deletions

File tree

‎application/signal_snapshot.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,45 @@
1313
"benchmark_price",
1414
"long_trend_value",
1515
"exit_line",
16+
"dual_drive_volatility_delever_enabled",
17+
"dual_drive_volatility_delever_window",
18+
"dual_drive_volatility_delever_threshold_mode",
19+
"dual_drive_volatility_delever_threshold",
20+
"dual_drive_volatility_delever_exit_threshold",
21+
"dual_drive_volatility_delever_dynamic_threshold",
22+
"dual_drive_volatility_delever_dynamic_sample_count",
23+
"dual_drive_volatility_delever_dynamic_lookback",
24+
"dual_drive_volatility_delever_dynamic_percentile",
25+
"dual_drive_volatility_delever_dynamic_min_periods",
26+
"dual_drive_volatility_delever_dynamic_floor",
27+
"dual_drive_volatility_delever_dynamic_cap",
28+
"dual_drive_volatility_delever_metric",
29+
"dual_drive_volatility_delever_triggered",
30+
"dual_drive_volatility_delever_entry_triggered",
31+
"dual_drive_volatility_delever_hysteresis_triggered",
32+
"dual_drive_volatility_delever_trigger_reason",
33+
"dual_drive_volatility_delever_applied",
34+
"dual_drive_volatility_delever_vetoed",
35+
"dual_drive_volatility_delever_veto_reason",
36+
"dual_drive_volatility_delever_taco_veto_enabled",
37+
"dual_drive_volatility_delever_taco_rebound_context_active",
38+
"dual_drive_volatility_delever_true_crisis_active",
39+
"dual_drive_volatility_delever_redirect_symbol",
40+
"dual_drive_volatility_delever_removed_value",
41+
"dual_drive_macro_risk_governor_enabled",
42+
"dual_drive_macro_risk_governor_found",
43+
"dual_drive_macro_risk_governor_route",
44+
"dual_drive_macro_risk_governor_active",
45+
"dual_drive_macro_risk_governor_applied",
46+
"dual_drive_macro_risk_governor_leverage_scalar",
47+
"dual_drive_macro_risk_governor_risk_asset_scalar",
48+
"dual_drive_macro_risk_governor_removed_value",
49+
"dual_drive_macro_risk_governor_redirected_to_unlevered",
50+
"dual_drive_crisis_defense_enabled",
51+
"dual_drive_crisis_defense_triggered",
52+
"dual_drive_crisis_defense_applied",
53+
"dual_drive_crisis_defense_destination",
54+
"dual_drive_crisis_defense_removed_value",
1655
"active_risk_asset",
1756
"allocation_mode",
1857
"trend_symbol",
@@ -37,6 +76,33 @@
3776
"blend_gate_volatility_delever_dynamic_cap",
3877
"blend_gate_volatility_delever_metric",
3978
"blend_gate_volatility_delever_triggered",
79+
"blend_gate_volatility_delever_retention_ratio",
80+
"blend_gate_volatility_delever_redirect_symbol",
81+
"blend_gate_volatility_delever_removed_ratio",
82+
"market_regime_control_enabled",
83+
"market_regime_control_found",
84+
"market_regime_control_source",
85+
"market_regime_control_schema_version",
86+
"market_regime_control_route",
87+
"market_regime_control_route_source",
88+
"market_regime_control_active",
89+
"market_regime_control_applied",
90+
"market_regime_control_route_allowed",
91+
"market_regime_control_risk_scalar",
92+
"market_regime_control_risk_budget_scalar",
93+
"market_regime_control_leverage_scalar",
94+
"market_regime_control_risk_asset_scalar",
95+
"market_regime_control_taco_allowed",
96+
"market_regime_control_local_delever_veto_allowed",
97+
"market_regime_control_crisis_defense_required",
98+
"market_regime_control_blocked_actions",
99+
"market_regime_control_vetoes",
100+
"market_regime_control_reason_codes",
101+
"market_regime_control_removed_weight",
102+
"market_regime_control_removed_ratio",
103+
"market_regime_control_redirected_to_unlevered_ratio",
104+
"market_regime_control_safe_haven",
105+
"market_regime_control_risk_symbols",
40106
)
41107

42108

‎notifications/renderers.py‎

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,112 @@ def _build_timing_audit_lines(signal_metadata, *, translator) -> list[str]:
338338
return [f"{label}: {value}"]
339339

340340

341+
def _format_percent(value) -> str:
342+
try:
343+
return f"{float(value) * 100:.1f}%"
344+
except (TypeError, ValueError):
345+
return "n/a"
346+
347+
348+
def _format_percentile(value) -> str:
349+
try:
350+
percentile = float(value) * 100
351+
except (TypeError, ValueError):
352+
return "p?"
353+
if float(percentile).is_integer():
354+
return f"p{int(percentile)}"
355+
return f"p{percentile:.1f}"
356+
357+
358+
def _format_sample_count(value) -> str:
359+
try:
360+
count = float(value)
361+
except (TypeError, ValueError):
362+
return "n/a"
363+
if float(count).is_integer():
364+
return str(int(count))
365+
return f"{count:.1f}"
366+
367+
368+
def _present(value) -> bool:
369+
return value not in (None, "")
370+
371+
372+
def _is_truthy(value) -> bool:
373+
if isinstance(value, bool):
374+
return value
375+
return str(value or "").strip().lower() in {"1", "true", "yes", "y"}
376+
377+
378+
def _effective_volatility_delever_threshold(signal_metadata, *, prefix: str):
379+
mode = str(signal_metadata.get(f"{prefix}_threshold_mode") or "").strip().lower()
380+
dynamic_threshold = signal_metadata.get(f"{prefix}_dynamic_threshold")
381+
if mode == "rolling_percentile" and _present(dynamic_threshold):
382+
return dynamic_threshold
383+
return signal_metadata.get(f"{prefix}_threshold")
384+
385+
386+
def _format_volatility_delever_threshold_detail(signal_metadata, *, prefix: str, translator) -> str:
387+
mode = str(signal_metadata.get(f"{prefix}_threshold_mode") or "").strip().lower()
388+
fixed_threshold = signal_metadata.get(f"{prefix}_threshold")
389+
dynamic_threshold = signal_metadata.get(f"{prefix}_dynamic_threshold")
390+
if mode == "rolling_percentile":
391+
kwargs = {
392+
"percentile": _format_percentile(signal_metadata.get(f"{prefix}_dynamic_percentile")),
393+
"lookback": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_lookback")),
394+
"min_periods": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_min_periods")),
395+
"sample_count": _format_sample_count(signal_metadata.get(f"{prefix}_dynamic_sample_count")),
396+
"floor": _format_percent(signal_metadata.get(f"{prefix}_dynamic_floor")),
397+
"cap": _format_percent(signal_metadata.get(f"{prefix}_dynamic_cap")),
398+
"fixed_threshold": _format_percent(fixed_threshold),
399+
}
400+
if _present(dynamic_threshold):
401+
return translator("blend_gate_volatility_threshold_detail_dynamic", **kwargs)
402+
return translator("blend_gate_volatility_threshold_detail_dynamic_fallback", **kwargs)
403+
return translator(
404+
"blend_gate_volatility_threshold_detail_fixed",
405+
threshold=_format_percent(fixed_threshold),
406+
)
407+
408+
409+
def _build_tqqq_risk_control_lines(signal_metadata, *, translator) -> list[str]:
410+
prefix = "dual_drive_volatility_delever"
411+
if not _is_truthy(signal_metadata.get(f"{prefix}_applied")):
412+
return []
413+
redirect_symbol = str(signal_metadata.get(f"{prefix}_redirect_symbol") or "QQQ").strip().upper()
414+
window = str(signal_metadata.get(f"{prefix}_window") or "5").strip()
415+
threshold = _effective_volatility_delever_threshold(signal_metadata, prefix=prefix)
416+
threshold_detail = _format_volatility_delever_threshold_detail(
417+
signal_metadata,
418+
prefix=prefix,
419+
translator=translator,
420+
)
421+
if str(signal_metadata.get(f"{prefix}_trigger_reason") or "").strip() == "hysteresis_hold":
422+
return [
423+
translator(
424+
"risk_control_tqqq_volatility_delever_hysteresis_dynamic",
425+
window=window,
426+
volatility=_format_percent(signal_metadata.get(f"{prefix}_metric")),
427+
exit_threshold=_format_percent(signal_metadata.get(f"{prefix}_exit_threshold")),
428+
threshold=_format_percent(threshold),
429+
threshold_detail=threshold_detail,
430+
source_symbol="TQQQ",
431+
redirect_symbol=redirect_symbol or "QQQ",
432+
)
433+
]
434+
return [
435+
translator(
436+
"risk_control_tqqq_volatility_delever_applied_dynamic",
437+
window=window,
438+
volatility=_format_percent(signal_metadata.get(f"{prefix}_metric")),
439+
threshold=_format_percent(threshold),
440+
threshold_detail=threshold_detail,
441+
source_symbol="TQQQ",
442+
redirect_symbol=redirect_symbol or "QQQ",
443+
)
444+
]
445+
446+
341447
def _format_signal_snapshot_line(snapshot, *, translator) -> str:
342448
if not isinstance(snapshot, Mapping):
343449
return ""
@@ -367,15 +473,17 @@ def _strategy_dashboard_text(signal_metadata, *, translator) -> str:
367473
metadata = signal_metadata if isinstance(signal_metadata, Mapping) else {}
368474
raw_annotations = metadata.get("execution_annotations")
369475
annotations = raw_annotations if isinstance(raw_annotations, Mapping) else {}
476+
risk_source = {**metadata, **annotations}
370477
dashboard_text = _format_dashboard_text(
371478
annotations.get("dashboard_text")
372479
or metadata.get("dashboard_text")
373480
or metadata.get("dashboard")
374481
or ""
375482
)
483+
risk_control_lines = _build_tqqq_risk_control_lines(risk_source, translator=translator)
376484
timing_lines = _build_timing_audit_lines(metadata, translator=translator)
377485
snapshot_line = _format_signal_snapshot_line(metadata.get("signal_snapshot"), translator=translator)
378-
audit_lines = [*timing_lines, *([snapshot_line] if snapshot_line else [])]
486+
audit_lines = [*risk_control_lines, *timing_lines, *([snapshot_line] if snapshot_line else [])]
379487
if not audit_lines:
380488
return dashboard_text
381489
if not dashboard_text:

‎notifications/telegram.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@
7878
"signal_blend_gate_defensive": "{trend_symbol} 跌破门槛线,防守持有 SOXX {soxx_ratio}",
7979
"market_status_blend_gate_overlay_capped": "🧯 风控降档({asset})",
8080
"signal_blend_gate_overlay_capped": "{trend_symbol} 仍在 {window} 日门槛线上方,但触发风控降档({reasons}),目标仓位 {allocation_text}",
81+
"risk_control_tqqq_volatility_delever_applied": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于 {threshold},{source_symbol} 转向 {redirect_symbol}",
82+
"risk_control_tqqq_volatility_delever_applied_dynamic": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于实际阈值 {threshold}({threshold_detail}),{source_symbol} 转向 {redirect_symbol}",
83+
"risk_control_tqqq_volatility_delever_hysteresis": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold},维持 {source_symbol} 转向 {redirect_symbol}",
84+
"risk_control_tqqq_volatility_delever_hysteresis_dynamic": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold};入场实际阈值 {threshold}({threshold_detail}),维持 {source_symbol} 转向 {redirect_symbol}",
8185
"blend_gate_reason_rsi_cap": "RSI 超阈值",
8286
"blend_gate_reason_bollinger_cap": "突破布林上轨",
8387
"blend_gate_reason_volatility_delever": "{symbol} {window} 日年化波动率 {volatility} 高于 {threshold},SOXL 转向 {redirect_symbol}",
@@ -224,6 +228,10 @@
224228
"signal_blend_gate_defensive": "{trend_symbol} below gated entry, hold defensive SOXX {soxx_ratio}",
225229
"market_status_blend_gate_overlay_capped": "🧯 RISK-CAP ({asset})",
226230
"signal_blend_gate_overlay_capped": "{trend_symbol} stays above the {window}d gate, but risk cap ({reasons}) cuts exposure to {allocation_text}",
231+
"risk_control_tqqq_volatility_delever_applied": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} is above {threshold}; {source_symbol} redirects to {redirect_symbol}",
232+
"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}",
233+
"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}",
234+
"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}",
227235
"blend_gate_reason_rsi_cap": "RSI over threshold",
228236
"blend_gate_reason_bollinger_cap": "price above upper band",
229237
"blend_gate_reason_volatility_delever": "{symbol} {window}d annualized volatility {volatility} is above {threshold}; redirect SOXL to {redirect_symbol}",

‎requirements.txt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
flask
22
gunicorn
3-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@3b6a0a9bedde72773e188041e0dc48516b38aadc
4-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@8278048366f1cd83e29e0c921e4048e7e25ae227
5-
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@b690fcfd1e26648840723a5ab8b12c873f038b9b
3+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@023641c88506c732624a7329e48b51b9dbbe3c2a
4+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@7d35772d1125b534d0bcca557cb6dbaf28914719
5+
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@2e0075004239e7ede7ba256763a3441d4ec4ca73
66
pandas
77
numpy
88
requests

‎tests/test_notifications.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from notifications.renderers import build_dashboard
12
from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message
23
from strategy_registry import SUPPORTED_STRATEGY_PROFILES
34

@@ -75,6 +76,45 @@ def test_build_translator_supports_chinese():
7576
)
7677
== "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"
7778
)
79+
assert (
80+
translate(
81+
"risk_control_tqqq_volatility_delever_applied_dynamic",
82+
window=5,
83+
volatility="31.2%",
84+
threshold="30.0%",
85+
threshold_detail=translate(
86+
"blend_gate_volatility_threshold_detail_dynamic",
87+
percentile="p90",
88+
lookback="252",
89+
floor="24.0%",
90+
cap="36.0%",
91+
sample_count="252",
92+
),
93+
source_symbol="TQQQ",
94+
redirect_symbol="QQQM",
95+
)
96+
== "🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),TQQQ 转向 QQQM"
97+
)
98+
assert (
99+
en_translate(
100+
"risk_control_tqqq_volatility_delever_hysteresis_dynamic",
101+
window=5,
102+
volatility="26.2%",
103+
exit_threshold="24.0%",
104+
threshold="30.0%",
105+
threshold_detail=en_translate(
106+
"blend_gate_volatility_threshold_detail_dynamic",
107+
percentile="p90",
108+
lookback="252",
109+
floor="24.0%",
110+
cap="36.0%",
111+
sample_count="252",
112+
),
113+
source_symbol="TQQQ",
114+
redirect_symbol="QQQM",
115+
)
116+
== "🛡️ 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"
117+
)
78118
assert (
79119
translate(
80120
"strategy_plugin_line",
@@ -140,6 +180,40 @@ def test_supported_strategy_profiles_have_translated_names():
140180
assert en_name(profile) != profile
141181

142182

183+
def test_dashboard_renders_tqqq_volatility_delever_risk_control():
184+
dashboard = build_dashboard(
185+
positions={},
186+
account_values={"equity": 10000.0, "buying_power": 1000.0},
187+
signal_desc="Entry signal",
188+
status_desc="Entry signal",
189+
strategy_profile="tqqq_growth_income",
190+
strategy_display_name="TQQQ Growth Income",
191+
signal_metadata={
192+
"dashboard_text": "📌 Strategy account overview",
193+
"dual_drive_volatility_delever_applied": True,
194+
"dual_drive_volatility_delever_window": 5,
195+
"dual_drive_volatility_delever_metric": 0.312,
196+
"dual_drive_volatility_delever_threshold": 0.28,
197+
"dual_drive_volatility_delever_threshold_mode": "rolling_percentile",
198+
"dual_drive_volatility_delever_dynamic_threshold": 0.30,
199+
"dual_drive_volatility_delever_dynamic_sample_count": 252,
200+
"dual_drive_volatility_delever_dynamic_lookback": 252,
201+
"dual_drive_volatility_delever_dynamic_percentile": 0.90,
202+
"dual_drive_volatility_delever_dynamic_min_periods": 126,
203+
"dual_drive_volatility_delever_dynamic_floor": 0.24,
204+
"dual_drive_volatility_delever_dynamic_cap": 0.36,
205+
"dual_drive_volatility_delever_redirect_symbol": "QQQM",
206+
},
207+
translator=build_translator("en"),
208+
separator="━━━━━━━━━━━━━━━━━━",
209+
)
210+
211+
assert (
212+
"🛡️ Risk control: QQQ 5d annualized volatility 31.2% is above effective threshold 30.0% "
213+
"(dynamic p90, 252d lookback, bounded 24.0%-36.0%, samples 252); TQQQ redirects to QQQM"
214+
) in dashboard
215+
216+
143217
def test_send_telegram_message_logs_non_200_response(capsys):
144218
class FakeResponse:
145219
status_code = 401

‎tests/test_signal_snapshot.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,44 @@ def test_includes_soxl_dynamic_volatility_fields():
2121
assert indicators["blend_gate_volatility_delever_dynamic_threshold"] == 0.60
2222
assert indicators["blend_gate_volatility_delever_dynamic_sample_count"] == 252
2323
assert indicators["blend_gate_volatility_delever_triggered"] is True
24+
25+
26+
def test_includes_tqqq_volatility_delever_fields():
27+
snapshot = build_signal_snapshot(
28+
platform="ibkr",
29+
strategy_profile="tqqq_growth_income",
30+
execution={
31+
"dual_drive_volatility_delever_threshold_mode": "rolling_percentile",
32+
"dual_drive_volatility_delever_threshold": 0.28,
33+
"dual_drive_volatility_delever_exit_threshold": 0.24,
34+
"dual_drive_volatility_delever_dynamic_threshold": 0.30,
35+
"dual_drive_volatility_delever_dynamic_sample_count": 252,
36+
"dual_drive_volatility_delever_dynamic_percentile": 0.90,
37+
"dual_drive_volatility_delever_metric": 0.312,
38+
"dual_drive_volatility_delever_applied": True,
39+
"dual_drive_volatility_delever_veto_reason": "taco_rebound_context",
40+
"dual_drive_volatility_delever_taco_veto_enabled": True,
41+
"dual_drive_volatility_delever_removed_value": 4500.0,
42+
"dual_drive_macro_risk_governor_applied": True,
43+
"dual_drive_macro_risk_governor_route": "risk_reduced",
44+
"dual_drive_crisis_defense_destination": "BOXX",
45+
"market_regime_control_route": "risk_reduced",
46+
"market_regime_control_reason_codes": ("macro:vix_crisis_level",),
47+
"dual_drive_volatility_delever_redirect_symbol": "QQQM",
48+
},
49+
)
50+
51+
indicators = snapshot["indicators"]
52+
assert indicators["dual_drive_volatility_delever_threshold_mode"] == "rolling_percentile"
53+
assert indicators["dual_drive_volatility_delever_dynamic_threshold"] == 0.30
54+
assert indicators["dual_drive_volatility_delever_dynamic_sample_count"] == 252
55+
assert indicators["dual_drive_volatility_delever_applied"] is True
56+
assert indicators["dual_drive_volatility_delever_veto_reason"] == "taco_rebound_context"
57+
assert indicators["dual_drive_volatility_delever_taco_veto_enabled"] is True
58+
assert indicators["dual_drive_volatility_delever_removed_value"] == 4500.0
59+
assert indicators["dual_drive_macro_risk_governor_applied"] is True
60+
assert indicators["dual_drive_macro_risk_governor_route"] == "risk_reduced"
61+
assert indicators["dual_drive_crisis_defense_destination"] == "BOXX"
62+
assert indicators["market_regime_control_route"] == "risk_reduced"
63+
assert indicators["market_regime_control_reason_codes"] == ["macro:vix_crisis_level"]
64+
assert indicators["dual_drive_volatility_delever_redirect_symbol"] == "QQQM"

0 commit comments

Comments
 (0)