Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions application/signal_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
)


Expand Down
110 changes: 109 additions & 1 deletion notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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 [])]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include risk diagnostics in compact notifications

When run_strategy_cycle publishes trade/no-op notifications, the user-facing Telegram copy comes from compact_text, whose dashboard_text is the separately computed application/rebalance_service._strategy_dashboard_text (application/rebalance_service.py lines 647 and 778/718), not this updated renderer helper; NotificationPublisher sends compact_text to Telegram. As a result, with dual_drive_volatility_delever_applied true, the new TQQQ risk line is present only in the detailed log dashboard and is omitted from the actual Telegram message, so the diagnostics this change is meant to surface still do not reach users in the main cycle path.

Useful? React with 👍 / 👎.

if not audit_lines:
return dashboard_text
if not dashboard_text:
Expand Down
8 changes: 8 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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}",
Expand Down
6 changes: 3 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
74 changes: 74 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/test_signal_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"