diff --git a/application/signal_snapshot.py b/application/signal_snapshot.py index 8ef9130..77041b1 100644 --- a/application/signal_snapshot.py +++ b/application/signal_snapshot.py @@ -82,7 +82,11 @@ "dual_drive_volatility_delever_retention_context_found", "dual_drive_volatility_delever_retention_reason_codes", "dual_drive_volatility_delever_redirect_symbol", + "dual_drive_volatility_delever_source_value", + "dual_drive_volatility_delever_retained_value", "dual_drive_volatility_delever_removed_value", + "dual_drive_volatility_delever_retained_ratio", + "dual_drive_volatility_delever_redirected_ratio", "dual_drive_macro_risk_governor_enabled", "dual_drive_macro_risk_governor_found", "dual_drive_macro_risk_governor_route", diff --git a/decision_mapper.py b/decision_mapper.py index e79960c..50fb675 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -63,7 +63,11 @@ "dual_drive_volatility_delever_retention_context_found", "dual_drive_volatility_delever_retention_reason_codes", "dual_drive_volatility_delever_redirect_symbol", + "dual_drive_volatility_delever_source_value", + "dual_drive_volatility_delever_retained_value", "dual_drive_volatility_delever_removed_value", + "dual_drive_volatility_delever_retained_ratio", + "dual_drive_volatility_delever_redirected_ratio", "dual_drive_macro_risk_governor_enabled", "dual_drive_macro_risk_governor_found", "dual_drive_macro_risk_governor_route", diff --git a/notifications/renderers.py b/notifications/renderers.py index 7f00b8b..ee674f8 100644 --- a/notifications/renderers.py +++ b/notifications/renderers.py @@ -200,6 +200,13 @@ def _format_percent(value) -> str: return "n/a" +def _as_float_or_none(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + def _format_percentile(value) -> str: try: percentile = float(value) * 100 @@ -255,6 +262,27 @@ def _format_volatility_delever_threshold_detail(execution, *, prefix: str, trans ) +def _format_tqqq_volatility_delever_allocation_detail( + execution, + *, + prefix: str, + redirect_symbol: str, + translator, +) -> str: + retained_ratio = _as_float_or_none(execution.get(f"{prefix}_retained_ratio")) + redirected_ratio = _as_float_or_none(execution.get(f"{prefix}_redirected_ratio")) + if retained_ratio is None: + retained_ratio = _as_float_or_none(execution.get(f"{prefix}_retention_ratio")) + if redirected_ratio is None and retained_ratio is not None: + redirected_ratio = max(0.0, min(1.0, 1.0 - retained_ratio)) + return translator( + "tqqq_volatility_delever_allocation_detail", + retained_ratio=_format_percent(retained_ratio), + redirected_ratio=_format_percent(redirected_ratio), + redirect_symbol=redirect_symbol or "QQQ", + ) + + 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() @@ -268,6 +296,12 @@ def _build_risk_control_lines(execution, *, translator): prefix="dual_drive_volatility_delever", translator=translator, ) + allocation_detail = _format_tqqq_volatility_delever_allocation_detail( + execution, + prefix="dual_drive_volatility_delever", + redirect_symbol=redirect_symbol or "QQQ", + translator=translator, + ) if str(execution.get("dual_drive_volatility_delever_trigger_reason") or "").strip() == "hysteresis_hold": return [ translator( @@ -279,6 +313,7 @@ def _build_risk_control_lines(execution, *, translator): threshold_detail=threshold_detail, source_symbol="TQQQ", redirect_symbol=redirect_symbol or "QQQ", + allocation_detail=allocation_detail, ) ] return [ @@ -290,6 +325,7 @@ def _build_risk_control_lines(execution, *, translator): threshold_detail=threshold_detail, source_symbol="TQQQ", redirect_symbol=redirect_symbol or "QQQ", + allocation_detail=allocation_detail, ) ] return [] diff --git a/notifications/telegram.py b/notifications/telegram.py index 1095eb3..2e94052 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -71,10 +71,19 @@ "buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入", "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}", + "risk_control_tqqq_volatility_delever_applied": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于 {threshold},{source_symbol} 转向 {redirect_symbol}({allocation_detail})", + "risk_control_tqqq_volatility_delever_applied_dynamic": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 高于实际阈值 {threshold}({threshold_detail}),{source_symbol} 转向 {redirect_symbol}({allocation_detail})", + "risk_control_tqqq_volatility_delever_hysteresis": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold},维持 {source_symbol} 转向 {redirect_symbol}({allocation_detail})", + "risk_control_tqqq_volatility_delever_hysteresis_dynamic": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold};入场实际阈值 {threshold}({threshold_detail}),维持 {source_symbol} 转向 {redirect_symbol}({allocation_detail})", + "tqqq_volatility_delever_allocation_detail": "杠杆仓位:TQQQ 保留 {retained_ratio},{redirect_symbol} {redirected_ratio}", + "tqqq_signal_reason_entry_trend": "原因:QQQ 高于 MA200,MA20 斜率为正", + "tqqq_signal_reason_entry_pullback": "原因:QQQ 低于 MA200,但站上 MA20 且回撤反弹确认", + "tqqq_signal_reason_hold_trend": "原因:已持有风险仓位,QQQ 仍高于 MA200", + "tqqq_signal_reason_exit_ma200": "原因:QQQ 跌破 MA200 退出线", + "tqqq_signal_reason_idle_waiting": "原因:等待 QQQ 站上 MA200 且 MA20 斜率转正", + "tqqq_signal_reason_macro_delever": "原因:宏观风控降低杠杆", + "tqqq_signal_reason_macro_defense": "原因:宏观风控转入防守", + "tqqq_signal_reason_crisis_defense": "原因:危机防御转入避险仓位", "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;可能有未完成挂单、结算或购买力占用", @@ -219,10 +228,19 @@ "buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying", "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}", + "risk_control_tqqq_volatility_delever_applied": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} is above {threshold}; {source_symbol} redirects to {redirect_symbol} ({allocation_detail})", + "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} ({allocation_detail})", + "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} ({allocation_detail})", + "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} ({allocation_detail})", + "tqqq_volatility_delever_allocation_detail": "leveraged sleeve: TQQQ retained {retained_ratio}, {redirect_symbol} {redirected_ratio}", + "tqqq_signal_reason_entry_trend": "reason: QQQ is above MA200 and MA20 slope is positive", + "tqqq_signal_reason_entry_pullback": "reason: QQQ is below MA200 but above MA20 with a confirmed pullback rebound", + "tqqq_signal_reason_hold_trend": "reason: existing risk sleeve remains active while QQQ stays above MA200", + "tqqq_signal_reason_exit_ma200": "reason: QQQ fell below the MA200 exit line", + "tqqq_signal_reason_idle_waiting": "reason: waiting for QQQ to reclaim MA200 with positive MA20 slope", + "tqqq_signal_reason_macro_delever": "reason: macro risk governor reduced leverage", + "tqqq_signal_reason_macro_defense": "reason: macro risk governor moved the strategy defensive", + "tqqq_signal_reason_crisis_defense": "reason: crisis defense moved the strategy to the safe sleeve", "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", diff --git a/requirements.txt b/requirements.txt index 463942d..133edb8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@2a711adf60b585ca02932bab9ee1bac7ce1df7c6 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@085f6010883b3f7c66a1c16f96749c0251410f93 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@fdd39ef0313181bee9083319b87b7175c32b364d hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@02af62bc7af7b8ffdbe8575421434e455ab00d66 pandas requests diff --git a/tests/test_notifications.py b/tests/test_notifications.py index ee86819..a2b9e1e 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -194,6 +194,8 @@ def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self): "dual_drive_volatility_delever_dynamic_floor": 0.24, "dual_drive_volatility_delever_dynamic_cap": 0.36, "dual_drive_volatility_delever_redirect_symbol": "QQQ", + "dual_drive_volatility_delever_retained_ratio": 0.25, + "dual_drive_volatility_delever_redirected_ratio": 0.75, }, skip_logs=(), note_logs=(), @@ -218,6 +220,8 @@ def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self): "dual_drive_volatility_delever_dynamic_floor": 0.24, "dual_drive_volatility_delever_dynamic_cap": 0.36, "dual_drive_volatility_delever_redirect_symbol": "QQQ", + "dual_drive_volatility_delever_retained_ratio": 0.25, + "dual_drive_volatility_delever_redirected_ratio": 0.75, }, skip_logs=(), note_logs=(), @@ -228,11 +232,11 @@ def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self): ) self.assertIn( - "🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),TQQQ 转向 QQQ", + "🛡️ 风控: QQQ 5 日年化波动率 31.2% 高于实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),TQQQ 转向 QQQ(杠杆仓位:TQQQ 保留 25.0%,QQQ 75.0%)", 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", + "🛡️ 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 (leveraged sleeve: TQQQ retained 25.0%, QQQ 75.0%)", en_rendered.compact_text, ) @@ -255,6 +259,8 @@ def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self) "dual_drive_volatility_delever_dynamic_cap": 0.36, "dual_drive_volatility_delever_trigger_reason": "hysteresis_hold", "dual_drive_volatility_delever_redirect_symbol": "QQQM", + "dual_drive_volatility_delever_retained_ratio": 0.0, + "dual_drive_volatility_delever_redirected_ratio": 1.0, }, skip_logs=(), note_logs=(), @@ -281,6 +287,8 @@ def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self) "dual_drive_volatility_delever_dynamic_cap": 0.36, "dual_drive_volatility_delever_trigger_reason": "hysteresis_hold", "dual_drive_volatility_delever_redirect_symbol": "QQQM", + "dual_drive_volatility_delever_retained_ratio": 0.0, + "dual_drive_volatility_delever_redirected_ratio": 1.0, }, skip_logs=(), note_logs=(), @@ -291,11 +299,11 @@ def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self) ) self.assertIn( - "🛡️ 风控: QQQ 5 日年化波动率 26.2% 仍高于退出阈值 24.0%;入场实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),维持 TQQQ 转向 QQQM", + "🛡️ 风控: QQQ 5 日年化波动率 26.2% 仍高于退出阈值 24.0%;入场实际阈值 30.0%(动态 p90,252日窗口,范围 24.0%-36.0%,样本 252),维持 TQQQ 转向 QQQM(杠杆仓位:TQQQ 保留 0.0%,QQQM 100.0%)", zh_rendered.compact_text, ) self.assertIn( - "🛡️ 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", + "🛡️ 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 (leveraged sleeve: TQQQ retained 0.0%, QQQM 100.0%)", en_rendered.compact_text, )