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
19 changes: 16 additions & 3 deletions application/execution_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def apply_small_account_cash_compatibility(
for symbol, price in dict(prices or {}).items()
}
notes = []
if safe_haven_substituted:
if substituted:
for symbol in substituted:
target_value = max(0.0, float(normalized_targets.get(symbol, 0.0) or 0.0))
price = max(0.0, float(normalized_prices.get(symbol, 0.0) or 0.0))
Expand Down Expand Up @@ -518,14 +518,27 @@ def _apply_small_account_whole_share_compatibility(
adjusted_targets = compatibility.targets
substituted = compatibility.whole_share_substituted_symbols
safe_haven_substituted = compatibility.safe_haven_cash_substituted_symbols
cash_substitution_notes = tuple(compatibility.cash_substitution_notes or ())
if substituted and not cash_substitution_notes:
cash_substitution_notes = tuple(
{
"symbol": symbol,
"target_value": max(0.0, float(target_values.get(symbol, 0.0) or 0.0)),
"price": max(0.0, float(quote_prices.get(symbol, 0.0) or 0.0)),
"cash_symbols": tuple(safe_haven_substituted),
}
for symbol in substituted
if max(0.0, float(target_values.get(symbol, 0.0) or 0.0)) > 0.0
and max(0.0, float(quote_prices.get(symbol, 0.0) or 0.0)) > 0.0
)
adjusted_allocation = {**dict(allocation or {}), "targets": adjusted_targets}
adjusted_allocation.pop("small_account_whole_share_cash_notes", None)
if substituted:
adjusted_allocation["small_account_whole_share_substituted_symbols"] = substituted
if safe_haven_substituted:
adjusted_allocation["small_account_safe_haven_cash_substituted_symbols"] = tuple(safe_haven_substituted)
if compatibility.cash_substitution_notes:
adjusted_allocation["small_account_whole_share_cash_notes"] = tuple(compatibility.cash_substitution_notes)
if cash_substitution_notes:
adjusted_allocation["small_account_whole_share_cash_notes"] = cash_substitution_notes
adjusted_plan = dict(plan or {})
if substituted or safe_haven_substituted:
adjusted_plan["allocation"] = adjusted_allocation
Expand Down
48 changes: 48 additions & 0 deletions decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@
"snapshot_manifest_source_refresh_run_id",
"snapshot_manifest_source_refresh_generated_at",
)
_TQQQ_RISK_CONTROL_EXECUTION_FIELDS = (
"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_redirect_symbol",
)


def _build_portfolio_inputs(
Expand Down Expand Up @@ -133,6 +155,27 @@ def _attach_snapshot_diagnostics(
execution[field] = value


def _attach_tqqq_risk_control_execution_fields(
plan: dict[str, Any],
*,
decision: StrategyDecision,
runtime_metadata: Mapping[str, Any] | None,
) -> None:
if _resolve_canonical_profile(str(plan.get("strategy_profile") or "")) != "tqqq_growth_income":
return
execution = plan.get("execution")
if not isinstance(execution, dict):
return
diagnostics = {**dict(runtime_metadata or {}), **dict(decision.diagnostics)}
annotations = diagnostics.get("execution_annotations")
if isinstance(annotations, Mapping):
diagnostics = {**diagnostics, **dict(annotations)}
for field in _TQQQ_RISK_CONTROL_EXECUTION_FIELDS:
value = diagnostics.get(field)
if value not in (None, ""):
execution[field] = value


def _apply_reserved_cash_policy(
annotations: ValueTargetExecutionAnnotations,
*,
Expand Down Expand Up @@ -562,4 +605,9 @@ def map_strategy_decision_to_plan(
decision=normalized_decision,
runtime_metadata=runtime_metadata,
)
_attach_tqqq_risk_control_execution_fields(
plan,
decision=normalized_decision,
runtime_metadata=runtime_metadata,
)
return plan
37 changes: 37 additions & 0 deletions notifications/renderers.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,41 @@ def _build_benchmark_lines(execution, *, translator):
]


def _format_percent(value) -> str:
try:
return f"{float(value) * 100:.1f}%"
except (TypeError, ValueError):
return "n/a"


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()
if str(execution.get("dual_drive_volatility_delever_trigger_reason") or "").strip() == "hysteresis_hold":
return [
translator(
"risk_control_tqqq_volatility_delever_hysteresis",
window=window,
volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")),
exit_threshold=_format_percent(execution.get("dual_drive_volatility_delever_exit_threshold")),
source_symbol="TQQQ",
redirect_symbol=redirect_symbol or "QQQ",
)
]
return [
translator(
"risk_control_tqqq_volatility_delever_applied",
window=window,
volatility=_format_percent(execution.get("dual_drive_volatility_delever_metric")),
threshold=_format_percent(execution.get("dual_drive_volatility_delever_threshold")),

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 Show the effective dynamic delever threshold

When the TQQQ profile runs in rolling_percentile mode, the strategy emits both the fixed fallback dual_drive_volatility_delever_threshold and the effective dual_drive_volatility_delever_dynamic_threshold; this line always renders the fixed fallback. In cycles where the rolling p90 threshold differs from 28%, the alert will say the volatility crossed the wrong threshold even though the effective threshold is already copied into execution, so the risk-control notification can mislead operators about why the delever happened.

Useful? React with 👍 / 👎.

source_symbol="TQQQ",
redirect_symbol=redirect_symbol or "QQQ",
)
]
return []


def _format_dashboard_text(text, *, translator=None) -> str:
lines = []
for raw_line in str(text or "").splitlines():
Expand Down Expand Up @@ -320,6 +355,7 @@ def _append_status_lines(lines, *, execution, translator, signal_key):
if signal_display:
_append_labeled_text(lines, signal_key, signal_display, translator=translator, value_key="msg")

lines.extend(_build_risk_control_lines(execution, translator=translator))
lines.extend(_build_benchmark_lines(execution, translator=translator))


Expand All @@ -341,6 +377,7 @@ def _append_compact_status_lines(lines, *, execution, translator, signal_key):
if signal_summary:
lines.append(translator(signal_key, msg=signal_summary))

lines.extend(_build_risk_control_lines(execution, translator=translator))

def _append_strategy_line(lines, *, strategy_display_name, translator):
name = str(strategy_display_name or "").strip()
Expand Down
8 changes: 6 additions & 2 deletions notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@
"buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单",
"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}",
"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_hysteresis": "🛡️ 风控: QQQ {window} 日年化波动率 {volatility} 仍高于退出阈值 {exit_threshold},维持 {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 Down Expand Up @@ -185,7 +187,9 @@
"buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle",
"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 small account keeps cash this cycle and does not rebuy {cash_symbols}",
"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_hysteresis": "🛡️ Risk control: QQQ {window}d annualized volatility {volatility} remains above the exit threshold {exit_threshold}; 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 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@e0f760255232b62481444a8c1d6637546ba2c07e
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@5fe430699e532ee444e6c2370b34da3dc8b01b06
hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@8d539aeef707b3594af4073f4cd4c3b13140b73f
pandas
requests
pytz
Expand Down
30 changes: 30 additions & 0 deletions tests/test_decision_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ def test_maps_hybrid_decision_from_snapshot_source(self):
"benchmark_price": 500.0,
"long_trend_value": 480.0,
"exit_line": 470.0,
"dual_drive_volatility_delever_applied": True,
"dual_drive_volatility_delever_window": 5,
"dual_drive_volatility_delever_metric": 0.312,
"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.28,
"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": "entry_threshold",
"dual_drive_volatility_delever_redirect_symbol": "QQQ",
}
},
)
Expand Down Expand Up @@ -139,6 +154,21 @@ def test_maps_hybrid_decision_from_snapshot_source(self):
self.assertEqual(plan["execution"]["benchmark_price"], 500.0)
self.assertEqual(plan["execution"]["long_trend_value"], 480.0)
self.assertEqual(plan["execution"]["exit_line"], 470.0)
self.assertIs(plan["execution"]["dual_drive_volatility_delever_applied"], True)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_window"], 5)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_metric"], 0.312)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_threshold_mode"], "rolling_percentile")
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_threshold"], 0.28)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_exit_threshold"], 0.24)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_threshold"], 0.28)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_sample_count"], 252)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_lookback"], 252)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_percentile"], 0.90)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_min_periods"], 126)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_floor"], 0.24)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_dynamic_cap"], 0.36)
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_trigger_reason"], "entry_threshold")
self.assertEqual(plan["execution"]["dual_drive_volatility_delever_redirect_symbol"], "QQQ")
self.assertEqual(plan["portfolio"]["market_values"]["TQQQ"], 5000.0)

def test_translates_weight_decision_for_snapshot_strategy(self):
Expand Down
87 changes: 87 additions & 0 deletions tests/test_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,93 @@ def test_heartbeat_signal_snapshot_localizes_price_source(self):
self.assertIn("📊 市场状态: 🚀 风险开启(SOXX+SOXL)", rendered.compact_text)
self.assertNotIn("longbridge_candlesticks", rendered.compact_text)

def test_heartbeat_renders_tqqq_volatility_delever_risk_control(self):
zh_rendered = render_heartbeat_notification(
execution={
"signal_display": "🚀 入场信号",
"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_redirect_symbol": "QQQ",
},
skip_logs=(),
note_logs=(),
translator=build_translator("zh"),
separator="━━━━━━━━━━━━━━━━━━",
strategy_display_name="TQQQ 增长收益",
dry_run_only=False,
)
en_rendered = render_heartbeat_notification(
execution={
"signal_display": "Entry signal",
"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_redirect_symbol": "QQQ",
},
skip_logs=(),
note_logs=(),
translator=build_translator("en"),
separator="━━━━━━━━━━━━━━━━━━",
strategy_display_name="TQQQ Growth Income",
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",
en_rendered.compact_text,
)

def test_heartbeat_renders_tqqq_volatility_delever_hysteresis_risk_control(self):
zh_rendered = render_heartbeat_notification(
execution={
"signal_display": "🚀 入场信号",
"dual_drive_volatility_delever_applied": True,
"dual_drive_volatility_delever_window": 5,
"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_trigger_reason": "hysteresis_hold",
"dual_drive_volatility_delever_redirect_symbol": "QQQM",
},
skip_logs=(),
note_logs=(),
translator=build_translator("zh"),
separator="━━━━━━━━━━━━━━━━━━",
strategy_display_name="TQQQ 增长收益",
dry_run_only=False,
)
en_rendered = render_heartbeat_notification(
execution={
"signal_display": "Entry signal",
"dual_drive_volatility_delever_applied": True,
"dual_drive_volatility_delever_window": 5,
"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_trigger_reason": "hysteresis_hold",
"dual_drive_volatility_delever_redirect_symbol": "QQQM",
},
skip_logs=(),
note_logs=(),
translator=build_translator("en"),
separator="━━━━━━━━━━━━━━━━━━",
strategy_display_name="TQQQ Growth Income",
dry_run_only=False,
)

self.assertIn(
"🛡️ 风控: QQQ 5 日年化波动率 26.2% 仍高于退出阈值 24.0%,维持 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",
en_rendered.compact_text,
)

def test_heartbeat_localizes_strategy_diagnostics_and_source_input_status(self):
rendered = render_heartbeat_notification(
execution={
Expand Down
Loading