diff --git a/notifications/renderers.py b/notifications/renderers.py index de437d1..7f00b8b 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -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", ) diff --git a/notifications/telegram.py b/notifications/telegram.py index 4c51919..29f0284 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -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;可能有未完成挂单、结算或购买力占用", @@ -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": "减仓信号", @@ -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", @@ -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", diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 0e8f94a..689bcab 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -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", @@ -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=(), @@ -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=(), @@ -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, ) @@ -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", }, @@ -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", }, @@ -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, )