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
73 changes: 70 additions & 3 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,27 +200,94 @@ def _format_percent(value) -> str:
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 _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_risk_control_lines(execution, *, translator):
if _is_truthy(execution.get("dual_drive_volatility_delever_applied")):
redirect_symbol = str(execution.get("dual_drive_volatility_delever_redirect_symbol") or "QQQ").strip().upper()
window = str(execution.get("dual_drive_volatility_delever_window") or "5").strip()
threshold = _effective_volatility_delever_threshold(
execution,
prefix="dual_drive_volatility_delever",
)
threshold_detail = _format_volatility_delever_threshold_detail(
execution,
prefix="dual_drive_volatility_delever",
translator=translator,
)
if str(execution.get("dual_drive_volatility_delever_trigger_reason") or "").strip() == "hysteresis_hold":
return [
translator(
"risk_control_tqqq_volatility_delever_hysteresis",
"risk_control_tqqq_volatility_delever_hysteresis_dynamic",
window=window,
volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")),
exit_threshold=_format_percent(execution.get("dual_drive_volatility_delever_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",
"risk_control_tqqq_volatility_delever_applied_dynamic",
window=window,
volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")),
threshold=_format_percent(execution.get("dual_drive_volatility_delever_threshold")),
threshold=_format_percent(threshold),
threshold_detail=threshold_detail,
source_symbol="TQQQ",
redirect_symbol=redirect_symbol or "QQQ",
)
Expand Down
12 changes: 12 additions & 0 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@
"buy_deferred_small_target_gap": "{symbol} 目标差额 ${diff} 未超过 1 股价格 ${price};为避免超过目标仓位,本轮不买入",
"buy_deferred_small_account_cash_substitution": "{symbol} 目标金额 ${diff} 低于 1 股价格 ${price};为避免超过目标仓位,本轮保留现金(现金替代:{cash_symbols})",
"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}",
"buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})",
"buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
"buy_deferred_cash_sweep_cash_limit": "{symbol} 剩余可投资现金 ${investable},预算可回补 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
Expand All @@ -92,6 +94,10 @@
"blend_gate_reason_rsi_cap": "RSI 超阈值",
"blend_gate_reason_bollinger_cap": "突破布林上轨",
"blend_gate_reason_volatility_delever": "{symbol} {window} 日年化波动率 {volatility} 高于 {threshold},SOXL 转向 {redirect_symbol}",
"blend_gate_reason_volatility_delever_dynamic": "{symbol} {window} 日年化波动率 {volatility} 高于实际阈值 {threshold}({threshold_detail}),SOXL 转向 {redirect_symbol}",
"blend_gate_volatility_threshold_detail_dynamic": "动态 {percentile},{lookback}日窗口,范围 {floor}-{cap},样本 {sample_count}",
"blend_gate_volatility_threshold_detail_dynamic_fallback": "动态样本不足,回退固定 {fixed_threshold}(样本 {sample_count}/{min_periods},{percentile})",
"blend_gate_volatility_threshold_detail_fixed": "固定阈值 {threshold}",
"signal_hold": "趋势持有",
"signal_entry": "入场信号",
"signal_reduce": "减仓信号",
Expand Down Expand Up @@ -189,7 +195,9 @@
"buy_deferred_small_target_gap": "{symbol} target gap ${diff} does not exceed the 1-share price ${price}; skipped to avoid exceeding the target allocation",
"buy_deferred_small_account_cash_substitution": "{symbol} target ${diff} is below the 1-share price ${price}; to avoid exceeding the target allocation, this cycle keeps cash (cash substitute: {cash_symbols})",
"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}",
"buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}",
"buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds",
"buy_deferred_cash_sweep_cash_limit": "{symbol} residual investable cash ${investable}, budget supports {budget_qty} tail-rebuy shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds",
Expand All @@ -216,6 +224,10 @@
"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}",
"blend_gate_reason_volatility_delever_dynamic": "{symbol} {window}d annualized volatility {volatility} is above effective threshold {threshold} ({threshold_detail}); redirect SOXL to {redirect_symbol}",
"blend_gate_volatility_threshold_detail_dynamic": "dynamic {percentile}, {lookback}d lookback, bounded {floor}-{cap}, samples {sample_count}",
"blend_gate_volatility_threshold_detail_dynamic_fallback": "dynamic warm-up, fallback fixed {fixed_threshold} (samples {sample_count}/{min_periods}, {percentile})",
"blend_gate_volatility_threshold_detail_fixed": "fixed threshold {threshold}",
"signal_hold": "Trend Hold",
"signal_entry": "Entry Signal",
"signal_reduce": "Reduce Signal",
Expand Down
62 changes: 58 additions & 4 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ def test_build_translator_supports_chinese(self):
),
"SOXX 10 日年化波动率 55.0% 高于 50.0%,SOXL 转向 SOXX",
)
self.assertEqual(
translate(
"blend_gate_reason_volatility_delever_dynamic",
symbol="SOXX",
window=10,
volatility="61.0%",
threshold="60.0%",
threshold_detail=translate(
"blend_gate_volatility_threshold_detail_dynamic",
percentile="p95",
lookback="252",
floor="50.0%",
cap="75.0%",
sample_count="252",
),
redirect_symbol="SOXX",
),
"SOXX 10 日年化波动率 61.0% 高于实际阈值 60.0%(动态 p95,252日窗口,范围 50.0%-75.0%,样本 252),SOXL 转向 SOXX",
)
self.assertEqual(
translate(
"strategy_plugin_line",
Expand Down Expand Up @@ -155,6 +174,14 @@ def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self):
"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": "QQQ",
},
skip_logs=(),
Expand All @@ -171,6 +198,14 @@ def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self):
"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": "QQQ",
},
skip_logs=(),
Expand All @@ -181,9 +216,12 @@ def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self):
dry_run_only=False,
)

self.assertIn("🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于 28.0%,TQQQ 转向 QQQ", zh_rendered.compact_text)
self.assertIn(
"🛡️ Risk control: QQQ 5d annualized volatility 31.2% is above 28.0%; TQQQ redirects to QQQ",
"🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),TQQQ 转向 QQQ",
zh_rendered.compact_text,
)
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 QQQ",
en_rendered.compact_text,
)

Expand All @@ -196,6 +234,14 @@ def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self)
"dual_drive_volatility_delever_metric": 0.262,
"dual_drive_volatility_delever_threshold": 0.28,
"dual_drive_volatility_delever_exit_threshold": 0.24,
"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_trigger_reason": "hysteresis_hold",
"dual_drive_volatility_delever_redirect_symbol": "QQQM",
},
Expand All @@ -214,6 +260,14 @@ def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self)
"dual_drive_volatility_delever_metric": 0.262,
"dual_drive_volatility_delever_threshold": 0.28,
"dual_drive_volatility_delever_exit_threshold": 0.24,
"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_trigger_reason": "hysteresis_hold",
"dual_drive_volatility_delever_redirect_symbol": "QQQM",
},
Expand All @@ -226,11 +280,11 @@ def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self)
)

self.assertIn(
"🛡️ 风控: QQQ 5 日年化波动率 26.2% 仍高于退出阈值 24.0%,维持 TQQQ 转向 QQQM",
"🛡️ 风控: QQQ 5 日年化波动率 26.2% 仍高于退出阈值 24.0%;入场实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),维持 TQQQ 转向 QQQM",
zh_rendered.compact_text,
)
self.assertIn(
"🛡️ Risk control: QQQ 5d annualized volatility 26.2% remains above the exit threshold 24.0%; keep TQQQ redirected to 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",
en_rendered.compact_text,
)

Expand Down