Skip to content

Commit a90124f

Browse files
committed
Improve manual review notification cards
1 parent 897c3af commit a90124f

3 files changed

Lines changed: 321 additions & 2 deletions

File tree

src/quant_strategy_plugins/market_regime_control_plugin.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,17 @@ def _compact_signal(payload: Mapping[str, Any] | None) -> dict[str, Any]:
177177
):
178178
if key in payload:
179179
compact[key] = payload.get(key)
180-
for key in ("data_freshness", "data_quality", "event_quality", "audit_summary"):
180+
for key in (
181+
"data_freshness",
182+
"data_quality",
183+
"event_quality",
184+
"panic_reversal_quality",
185+
"audit_summary",
186+
"metrics",
187+
"rebound_confirmation",
188+
"reversal_confirmation",
189+
"selected_event",
190+
):
181191
value = payload.get(key)
182192
if isinstance(value, Mapping):
183193
compact[key] = dict(value)

src/quant_strategy_plugins/strategy_plugin_runner.py

Lines changed: 293 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,290 @@ def _payload_should_notify(payload: Mapping[str, Any], route: str) -> bool:
817817
return route != "no_action"
818818

819819

820+
def _as_float_or_none(value: Any) -> float | None:
821+
try:
822+
result = float(value)
823+
except (TypeError, ValueError):
824+
return None
825+
return result if pd.notna(result) else None
826+
827+
828+
def _format_number(value: Any, *, digits: int = 2) -> str:
829+
number = _as_float_or_none(value)
830+
return "n/a" if number is None else f"{number:.{digits}f}"
831+
832+
833+
def _format_pct(value: Any, *, digits: int = 1, signed: bool = False) -> str:
834+
number = _as_float_or_none(value)
835+
if number is None:
836+
return "n/a"
837+
sign = "+" if signed and number > 0 else ""
838+
return f"{sign}{number * 100:.{digits}f}%"
839+
840+
841+
def _component_payload(payload: Mapping[str, Any], component: str) -> Mapping[str, Any]:
842+
components = _nested_mapping(payload, "component_signals")
843+
value = components.get(component)
844+
return value if isinstance(value, Mapping) and value.get("available", True) else {}
845+
846+
847+
def _active_panic_payload(payload: Mapping[str, Any], plugin: str) -> Mapping[str, Any]:
848+
if plugin == PLUGIN_PANIC_REVERSAL_SHADOW or "reversal_confirmation" in payload:
849+
return payload
850+
component = _component_payload(payload, "panic_reversal")
851+
if not component:
852+
return {}
853+
if _as_bool(component.get("manual_review_required"), default=False) or _as_bool(
854+
component.get("panic_reversal_context_active"),
855+
default=False,
856+
):
857+
return component
858+
return {}
859+
860+
861+
def _active_taco_payload(payload: Mapping[str, Any], plugin: str) -> Mapping[str, Any]:
862+
if plugin == PLUGIN_TACO_REBOUND_SHADOW or "rebound_confirmation" in payload:
863+
return payload
864+
component = _component_payload(payload, "taco")
865+
if not component:
866+
return {}
867+
if _as_bool(component.get("manual_review_required"), default=False) or _as_bool(
868+
component.get("rebound_context_active"),
869+
default=False,
870+
):
871+
return component
872+
return {}
873+
874+
875+
def _review_attack_symbol(*sources: Mapping[str, Any]) -> str:
876+
for source in sources:
877+
for container_key in ("metrics", "rebound_confirmation"):
878+
container = source.get(container_key)
879+
if isinstance(container, Mapping):
880+
symbol = str(container.get("attack_symbol") or "").strip().upper()
881+
if symbol:
882+
return symbol
883+
return ""
884+
885+
886+
def _manual_review_source_title(
887+
*,
888+
panic_payload: Mapping[str, Any],
889+
taco_payload: Mapping[str, Any],
890+
locale: str,
891+
) -> str:
892+
panic_active = bool(panic_payload)
893+
taco_active = bool(taco_payload)
894+
if locale == "zh-CN":
895+
if panic_active and taco_active:
896+
return "事件缓和 + VIX 恐慌反转共振"
897+
if panic_active:
898+
return "VIX 恐慌反转"
899+
if taco_active:
900+
return "TACO 事件反弹"
901+
return "机会观察"
902+
if panic_active and taco_active:
903+
return "event de-escalation + VIX panic reversal"
904+
if panic_active:
905+
return "VIX panic reversal"
906+
if taco_active:
907+
return "TACO event rebound"
908+
return "opportunity watch"
909+
910+
911+
def _append_panic_review_lines(lines: list[str], panic_payload: Mapping[str, Any], *, locale: str) -> None:
912+
metrics = _nested_mapping(panic_payload, "metrics")
913+
confirmation = _nested_mapping(panic_payload, "reversal_confirmation")
914+
thresholds = _nested_mapping(confirmation, "thresholds")
915+
benchmark = str(metrics.get("benchmark_symbol") or "benchmark").upper()
916+
attack = str(metrics.get("attack_symbol") or "attack").upper()
917+
if locale == "zh-CN":
918+
lines.extend(
919+
[
920+
"触发原因:",
921+
(
922+
"- VIX 曾达到恐慌区间:"
923+
f"{int(_as_float_or_none(thresholds.get('vix_high_lookback_days')) or 5)} 日高点 "
924+
f"{_format_number(metrics.get('vix_lookback_high'))},阈值 "
925+
f"{_format_number(thresholds.get('min_vix_high'))}"
926+
),
927+
(
928+
"- VIX 已从高点回落:"
929+
f"当前 {_format_number(metrics.get('vix'))},较高点回落 "
930+
f"{_format_pct(metrics.get('vix_pullback_from_high'))}"
931+
),
932+
(
933+
"- VIX 继续下降:"
934+
f"前值 {_format_number(metrics.get('vix_previous'))},当前 {_format_number(metrics.get('vix'))}"
935+
),
936+
(
937+
"- VIX/VIX3M = "
938+
f"{_format_number(metrics.get('vix_vix3m_ratio'))},用于确认恐慌结构仍可观测"
939+
),
940+
(
941+
f"- {benchmark} 3 日收益 {_format_pct(metrics.get('benchmark_3d_return'), signed=True)},"
942+
f"从近 5 日低点反弹 {_format_pct(metrics.get('benchmark_rebound_from_recent_low'), signed=True)}"
943+
),
944+
f"- {attack} 从近 5 日低点反弹 {_format_pct(metrics.get('attack_rebound_from_recent_low'), signed=True)}",
945+
]
946+
)
947+
return
948+
lines.extend(
949+
[
950+
"Trigger evidence:",
951+
(
952+
"- VIX reached panic territory: "
953+
f"{int(_as_float_or_none(thresholds.get('vix_high_lookback_days')) or 5)}-day high "
954+
f"{_format_number(metrics.get('vix_lookback_high'))}; threshold "
955+
f"{_format_number(thresholds.get('min_vix_high'))}"
956+
),
957+
(
958+
"- VIX has pulled back from the high: "
959+
f"current {_format_number(metrics.get('vix'))}, pullback "
960+
f"{_format_pct(metrics.get('vix_pullback_from_high'))}"
961+
),
962+
(
963+
"- VIX is still falling: "
964+
f"previous {_format_number(metrics.get('vix_previous'))}, current {_format_number(metrics.get('vix'))}"
965+
),
966+
f"- VIX/VIX3M = {_format_number(metrics.get('vix_vix3m_ratio'))}",
967+
(
968+
f"- {benchmark} 3-day return {_format_pct(metrics.get('benchmark_3d_return'), signed=True)}; "
969+
f"rebound from recent low {_format_pct(metrics.get('benchmark_rebound_from_recent_low'), signed=True)}"
970+
),
971+
f"- {attack} rebound from recent low {_format_pct(metrics.get('attack_rebound_from_recent_low'), signed=True)}",
972+
]
973+
)
974+
975+
976+
def _append_taco_review_lines(lines: list[str], taco_payload: Mapping[str, Any], *, locale: str) -> None:
977+
event = _nested_mapping(taco_payload, "selected_event")
978+
confirmation = _nested_mapping(taco_payload, "rebound_confirmation")
979+
benchmark = str(confirmation.get("benchmark_symbol") or "benchmark").upper()
980+
attack = str(confirmation.get("attack_symbol") or "attack").upper()
981+
if locale == "zh-CN":
982+
lines.extend(
983+
[
984+
"事件:",
985+
f"- 类型:{event.get('kind') or 'n/a'} / 区域:{event.get('region') or 'n/a'}",
986+
f"- 日期:{event.get('event_date') or 'n/a'}",
987+
f"- 标题:{event.get('title') or 'n/a'}",
988+
f"- 来源:{event.get('source') or 'n/a'}",
989+
"价格确认:",
990+
f"- 事件后已过 {confirmation.get('trading_days_after_event', 'n/a')} 个交易日",
991+
(
992+
f"- {benchmark} 3 日收益 {_format_pct(confirmation.get('benchmark_3d_return'), signed=True)},"
993+
f"从近 5 日低点反弹 "
994+
f"{_format_pct(confirmation.get('benchmark_rebound_from_recent_low'), signed=True)}"
995+
),
996+
f"- {attack} 从近 5 日低点反弹 {_format_pct(confirmation.get('attack_rebound_from_recent_low'), signed=True)}",
997+
]
998+
)
999+
return
1000+
lines.extend(
1001+
[
1002+
"Event:",
1003+
f"- Type: {event.get('kind') or 'n/a'} / region: {event.get('region') or 'n/a'}",
1004+
f"- Date: {event.get('event_date') or 'n/a'}",
1005+
f"- Title: {event.get('title') or 'n/a'}",
1006+
f"- Source: {event.get('source') or 'n/a'}",
1007+
"Price confirmation:",
1008+
f"- {confirmation.get('trading_days_after_event', 'n/a')} trading days after the event",
1009+
(
1010+
f"- {benchmark} 3-day return {_format_pct(confirmation.get('benchmark_3d_return'), signed=True)}; "
1011+
f"rebound from recent low "
1012+
f"{_format_pct(confirmation.get('benchmark_rebound_from_recent_low'), signed=True)}"
1013+
),
1014+
f"- {attack} rebound from recent low {_format_pct(confirmation.get('attack_rebound_from_recent_low'), signed=True)}",
1015+
]
1016+
)
1017+
1018+
1019+
def _format_manual_review_notification_message(
1020+
payload: Mapping[str, Any],
1021+
*,
1022+
locale: str,
1023+
target_label: str,
1024+
plugin: str,
1025+
as_of: str,
1026+
route: str,
1027+
) -> str | None:
1028+
if _payload_action(payload) != "notify_manual_review":
1029+
return None
1030+
panic_payload = _active_panic_payload(payload, plugin)
1031+
taco_payload = _active_taco_payload(payload, plugin)
1032+
if not panic_payload and not taco_payload:
1033+
return None
1034+
1035+
position_control = _nested_mapping(payload, "position_control")
1036+
vetoes = _message_reason_codes(position_control.get("vetoes") or _nested_mapping(payload, "arbiter").get("vetoes"))
1037+
attack_symbol = _review_attack_symbol(panic_payload, taco_payload) or target_label
1038+
source_title = _manual_review_source_title(panic_payload=panic_payload, taco_payload=taco_payload, locale=locale)
1039+
if locale == "zh-CN":
1040+
lines = [
1041+
f"【机会复核|{attack_symbol}{source_title}】",
1042+
f"日期:{as_of or '未知日期'}",
1043+
"结论:触发人工复核,不自动加仓。",
1044+
(
1045+
f"仲裁:{plugin} = {route};"
1046+
f"{'crisis/macro 未 veto' if not vetoes else '存在 veto:' + _message_join(vetoes, locale)}。"
1047+
),
1048+
"执行权限:只通知;不下单;不修改仓位。",
1049+
]
1050+
if position_control:
1051+
scalar_bits = []
1052+
if "taco_size_scalar" in position_control:
1053+
scalar_bits.append(f"taco_size_scalar = {_format_number(position_control.get('taco_size_scalar'))}")
1054+
if "panic_reversal_size_scalar" in position_control:
1055+
scalar_bits.append(
1056+
f"panic_reversal_size_scalar = {_format_number(position_control.get('panic_reversal_size_scalar'))}"
1057+
)
1058+
if scalar_bits:
1059+
lines.append("仓位权限:" + ";".join(scalar_bits) + "。")
1060+
if panic_payload:
1061+
_append_panic_review_lines(lines, panic_payload, locale=locale)
1062+
if taco_payload:
1063+
_append_taco_review_lines(lines, taco_payload, locale=locale)
1064+
lines.extend(
1065+
[
1066+
"人工复核建议:",
1067+
"- 只评估是否停止继续降风险或恢复观察,不作为自动买入信号。",
1068+
"- 若 crisis/macro 后续转为 risk_reduced 或 risk_off,本机会信号自动失效。",
1069+
]
1070+
)
1071+
return "\n".join(lines)
1072+
1073+
lines = [
1074+
f"[Opportunity Review | {attack_symbol} | {source_title}]",
1075+
f"Date: {as_of or 'unknown date'}",
1076+
"Conclusion: manual review triggered; no automatic position increase.",
1077+
f"Arbiter: {plugin} = {route}; {'crisis/macro did not veto' if not vetoes else 'vetoes: ' + _message_join(vetoes, locale)}.",
1078+
"Execution: notify only; no broker orders; no allocation mutation.",
1079+
]
1080+
if position_control:
1081+
scalar_bits = []
1082+
if "taco_size_scalar" in position_control:
1083+
scalar_bits.append(f"taco_size_scalar = {_format_number(position_control.get('taco_size_scalar'))}")
1084+
if "panic_reversal_size_scalar" in position_control:
1085+
scalar_bits.append(
1086+
f"panic_reversal_size_scalar = {_format_number(position_control.get('panic_reversal_size_scalar'))}"
1087+
)
1088+
if scalar_bits:
1089+
lines.append("Position authority: " + "; ".join(scalar_bits) + ".")
1090+
if panic_payload:
1091+
_append_panic_review_lines(lines, panic_payload, locale=locale)
1092+
if taco_payload:
1093+
_append_taco_review_lines(lines, taco_payload, locale=locale)
1094+
lines.extend(
1095+
[
1096+
"Manual review guidance:",
1097+
"- Review only whether to stop further de-risking or return to watch; this is not an automatic buy signal.",
1098+
"- If crisis/macro later moves to risk_reduced or risk_off, this opportunity signal is invalidated.",
1099+
]
1100+
)
1101+
return "\n".join(lines)
1102+
1103+
8201104
def _format_notification_message(
8211105
*,
8221106
locale: str,
@@ -898,7 +1182,15 @@ def _build_localized_messages(
8981182
locale: list(_localized_reason_labels(reason_codes, locale)) for locale in SUPPORTED_MESSAGE_LOCALES
8991183
}
9001184
notification_messages = {
901-
locale: _format_notification_message(
1185+
locale: _format_manual_review_notification_message(
1186+
payload,
1187+
locale=locale,
1188+
target_label=target_label,
1189+
plugin=plugin,
1190+
as_of=as_of,
1191+
route=route,
1192+
)
1193+
or _format_notification_message(
9021194
locale=locale,
9031195
target_label=target_label,
9041196
target_type=target_type,

tests/test_strategy_plugin_runner.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,13 @@ def test_strategy_plugin_runner_can_enable_panic_reversal_inside_market_regime_c
399399
assert payload["position_control"]["panic_reversal_size_scalar"] == 0.0
400400
assert payload["position_control"]["taco_allowed"] is False
401401
assert "panic_reversal:panic_reversal" in payload["position_control"]["reason_codes"]
402+
zh_notification = payload["notification"]["localized_messages"]["zh-CN"]
403+
assert "【机会复核|TQQQ|VIX 恐慌反转】" in zh_notification
404+
assert "结论:触发人工复核,不自动加仓。" in zh_notification
405+
assert "VIX 曾达到恐慌区间" in zh_notification
406+
assert "VIX 已从高点回落" in zh_notification
407+
assert "QQQ 3 日收益" in zh_notification
408+
assert "panic_reversal_size_scalar = 0.00" in zh_notification
402409

403410

404411
def test_strategy_plugin_runner_runs_general_market_regime_notification(tmp_path) -> None:
@@ -704,6 +711,12 @@ def test_strategy_plugin_runner_runs_taco_rebound_notification_mount_for_tqqq(tm
704711
assert latest["rebound_confirmation"]["confirmed"] is True
705712
assert latest["would_trade_if_enabled"] is False
706713
assert "sleeve_suggestion" not in latest
714+
zh_notification = latest["localized_messages"]["notification"]["zh-CN"]
715+
assert "【机会复核|TQQQ|TACO 事件反弹】" in zh_notification
716+
assert "结论:触发人工复核,不自动加仓。" in zh_notification
717+
assert "事件:" in zh_notification
718+
assert "价格确认:" in zh_notification
719+
assert "人工复核建议:" in zh_notification
707720

708721

709722
def test_strategy_plugin_runner_can_enable_taco_ai_audit_without_api_key(tmp_path, monkeypatch) -> None:
@@ -817,6 +830,10 @@ def test_strategy_plugin_runner_runs_panic_reversal_notification_mount_for_tqqq(
817830
assert latest["execution_controls"]["position_control_allowed"] is False
818831
assert latest["execution_controls"]["consumption_evidence_status"] == EVIDENCE_NOTIFICATION_ONLY
819832
assert latest["localized_messages"]["labels"]["canonical_route"]["zh-CN"] == "恐慌反转"
833+
zh_notification = latest["notification"]["localized_messages"]["zh-CN"]
834+
assert "【机会复核|TQQQ|VIX 恐慌反转】" in zh_notification
835+
assert "执行权限:只通知;不下单;不修改仓位。" in zh_notification
836+
assert "TQQQ 从近 5 日低点反弹" in zh_notification
820837

821838

822839
def test_strategy_plugin_runner_rejects_panic_reversal_for_soxl_strategy_mount(tmp_path) -> None:

0 commit comments

Comments
 (0)