Skip to content

Commit 9efe268

Browse files
committed
Improve vetoed opportunity notifications
1 parent 7f0ad06 commit 9efe268

6 files changed

Lines changed: 312 additions & 54 deletions

docs/market-regime-control-plan.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,20 @@ allowlist:
134134
- `since_version`: records the runner schema version where the permission
135135
became effective.
136136

137+
Permission boundaries live in documentation and machine-readable fields, not in
138+
the human notification body:
139+
140+
- The plugin repository only writes artifacts and notifications. It does not
141+
call broker APIs or directly mutate account allocation.
142+
- Automated position impact happens only when the strategy side explicitly
143+
consumes `position_control`, and only when `position_control_allowed = true`
144+
and `evidence_status = automation_approved`.
145+
- `notification_only`, TACO, panic reversal, AI audit, and general notification
146+
targets are for manual review only.
147+
- Human notification copy should contain only the situation and suggested
148+
action; it should not display internal governance fields such as
149+
`position_control_allowed`, `execution_controls`, route codes, or veto codes.
150+
137151
SOXL/SOXX is not in the strategy-level `market_regime_control` consumption
138152
registry. It receives broad market-regime context through the general
139153
`notification_targets.market_regime_notification` artifact. That notification

docs/market-regime-control-plan.zh-CN.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@
8686
- `evidence_status`:记录该策略/插件组合是 `automation_approved``notification_only` 还是 `deprecated_compatibility`
8787
- `since_version`:记录该消费权限从哪个 runner schema 开始生效。
8888

89+
权限边界写在文档和机器字段里,不重复写进人工通知正文:
90+
91+
- 插件仓库只生成 artifact 和通知,不调用券商接口,也不直接改账户配置。
92+
- 自动仓位影响只发生在策略侧显式消费 `position_control` 时,并且必须同时满足
93+
`position_control_allowed = true``evidence_status = automation_approved`
94+
- `notification_only`、TACO、panic reversal、AI audit 和通用通知只用于人工查看。
95+
- 人工通知正文只写“情况说明”和“建议操作”,不展示 `position_control_allowed`
96+
`execution_controls`、route code 或 veto code 等内部治理字段。
97+
8998
SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中;它通过
9099
`notification_targets.market_regime_notification` 接收通用通知。通用通知不是
91100
strategy,不允许进入策略 runtime metadata,也不能影响仓位,避免配置误用把通知

src/quant_strategy_plugins/market_regime_control_plugin.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,21 @@ def _reason_codes(payload: Mapping[str, Any] | None) -> tuple[str, ...]:
140140
return ()
141141

142142

143+
def _opportunity_summary(component: str, payload: Mapping[str, Any] | None, veto: str) -> dict[str, Any] | None:
144+
if not isinstance(payload, Mapping):
145+
return None
146+
return {
147+
"component": component,
148+
"profile": _optional_text(payload.get("plugin") or payload.get("profile")),
149+
"as_of": _optional_text(payload.get("as_of")),
150+
"canonical_route": _normalized_route(payload),
151+
"suggested_action": _normalized_action(payload),
152+
"reason_codes": _reason_codes(payload),
153+
"veto": veto,
154+
"manual_review_required": _as_bool(payload.get("manual_review_required"), default=False),
155+
}
156+
157+
143158
def _blocked(payload: Mapping[str, Any] | None) -> bool:
144159
if not isinstance(payload, Mapping):
145160
return False
@@ -274,6 +289,7 @@ def build_market_regime_control_signal(
274289
crisis_defense_required = False
275290
blocked_actions: tuple[str, ...] = ()
276291
vetoes: list[str] = []
292+
vetoed_opportunities: list[dict[str, Any]] = []
277293
reason_codes: list[str] = []
278294

279295
if crisis_active:
@@ -288,9 +304,17 @@ def build_market_regime_control_signal(
288304
blocked_actions = ("increase_leverage", "increase_risk", "taco_rebound_veto", "panic_reversal_veto")
289305
reason_codes.extend(f"crisis:{code}" for code in _reason_codes(crisis) or ("true_crisis",))
290306
if taco_active:
291-
vetoes.append("crisis_blocks_taco")
307+
veto = "crisis_blocks_taco"
308+
vetoes.append(veto)
309+
summary = _opportunity_summary(COMPONENT_TACO, taco, veto)
310+
if summary:
311+
vetoed_opportunities.append(summary)
292312
if panic_reversal_active:
293-
vetoes.append("crisis_blocks_panic_reversal")
313+
veto = "crisis_blocks_panic_reversal"
314+
vetoes.append(veto)
315+
summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto)
316+
if summary:
317+
vetoed_opportunities.append(summary)
294318
elif macro_active and macro_route == "crisis":
295319
final_route = ROUTE_RISK_OFF
296320
suggested_action = ACTION_DEFEND
@@ -302,9 +326,17 @@ def build_market_regime_control_signal(
302326
blocked_actions = ("increase_leverage", "increase_risk", "taco_rebound_veto", "panic_reversal_veto")
303327
reason_codes.extend(f"macro:{code}" for code in _reason_codes(macro) or ("crisis",))
304328
if taco_active:
305-
vetoes.append("macro_crisis_blocks_taco")
329+
veto = "macro_crisis_blocks_taco"
330+
vetoes.append(veto)
331+
summary = _opportunity_summary(COMPONENT_TACO, taco, veto)
332+
if summary:
333+
vetoed_opportunities.append(summary)
306334
if panic_reversal_active:
307-
vetoes.append("macro_crisis_blocks_panic_reversal")
335+
veto = "macro_crisis_blocks_panic_reversal"
336+
vetoes.append(veto)
337+
summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto)
338+
if summary:
339+
vetoed_opportunities.append(summary)
308340
elif macro_active:
309341
final_route = ROUTE_RISK_REDUCED
310342
suggested_action = ACTION_DELEVER
@@ -316,9 +348,17 @@ def build_market_regime_control_signal(
316348
blocked_actions = ("increase_leverage", "taco_rebound_veto", "panic_reversal_veto")
317349
reason_codes.extend(f"macro:{code}" for code in _reason_codes(macro) or ("delever",))
318350
if taco_active:
319-
vetoes.append("macro_delever_blocks_taco")
351+
veto = "macro_delever_blocks_taco"
352+
vetoes.append(veto)
353+
summary = _opportunity_summary(COMPONENT_TACO, taco, veto)
354+
if summary:
355+
vetoed_opportunities.append(summary)
320356
if panic_reversal_active:
321-
vetoes.append("macro_delever_blocks_panic_reversal")
357+
veto = "macro_delever_blocks_panic_reversal"
358+
vetoes.append(veto)
359+
summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto)
360+
if summary:
361+
vetoed_opportunities.append(summary)
322362
elif blocked:
323363
final_route = ROUTE_BLOCKED
324364
suggested_action = ACTION_BLOCKED
@@ -355,6 +395,8 @@ def build_market_regime_control_signal(
355395
"route_source": route_source,
356396
"reason_codes": tuple(dict.fromkeys(reason_codes)),
357397
"vetoes": tuple(vetoes),
398+
"vetoed_opportunities": tuple(vetoed_opportunities),
399+
"opportunity_vetoed_should_notify": bool(vetoed_opportunities),
358400
}
359401
position_control = {
360402
"allowed": True,

src/quant_strategy_plugins/strategy_plugin_runner.py

Lines changed: 116 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,47 @@ class PluginNotificationTargetPolicy:
326326
},
327327
}
328328

329+
OPPORTUNITY_REVIEW_STATUS_LABELS: dict[str, dict[str, str]] = {
330+
"blocked": {"en-US": "blocked", "zh-CN": "阻断状态"},
331+
"crisis": {"en-US": "crisis state", "zh-CN": "危机状态"},
332+
"delever": {"en-US": "de-risking state", "zh-CN": "降风险状态"},
333+
"no_action": {"en-US": "normal state", "zh-CN": "正常观察状态"},
334+
"opportunity_watch": {"en-US": "opportunity watch", "zh-CN": "机会观察状态"},
335+
"panic_reversal": {"en-US": "panic-reversal review", "zh-CN": "恐慌反转复核状态"},
336+
"risk_off": {"en-US": "defensive state", "zh-CN": "防守状态"},
337+
"risk_reduced": {"en-US": "de-risking state", "zh-CN": "降风险状态"},
338+
"taco_rebound": {"en-US": "event-rebound review", "zh-CN": "事件反弹复核状态"},
339+
"true_crisis": {"en-US": "crisis state", "zh-CN": "危机状态"},
340+
"watch": {"en-US": "watch state", "zh-CN": "观察状态"},
341+
}
342+
343+
OPPORTUNITY_REVIEW_VETO_LABELS: dict[str, dict[str, str]] = {
344+
"crisis_blocks_panic_reversal": {
345+
"en-US": "crisis defense takes priority over the VIX panic-reversal signal",
346+
"zh-CN": "危机防守信号优先于 VIX 恐慌反转",
347+
},
348+
"crisis_blocks_taco": {
349+
"en-US": "crisis defense takes priority over the TACO rebound signal",
350+
"zh-CN": "危机防守信号优先于 TACO 事件反弹",
351+
},
352+
"macro_crisis_blocks_panic_reversal": {
353+
"en-US": "macro crisis signal takes priority over the VIX panic-reversal signal",
354+
"zh-CN": "宏观危机信号优先于 VIX 恐慌反转",
355+
},
356+
"macro_crisis_blocks_taco": {
357+
"en-US": "macro crisis signal takes priority over the TACO rebound signal",
358+
"zh-CN": "宏观危机信号优先于 TACO 事件反弹",
359+
},
360+
"macro_delever_blocks_panic_reversal": {
361+
"en-US": "macro de-risking signal takes priority over the VIX panic-reversal signal",
362+
"zh-CN": "宏观降风险信号优先于 VIX 恐慌反转",
363+
},
364+
"macro_delever_blocks_taco": {
365+
"en-US": "macro de-risking signal takes priority over the TACO rebound signal",
366+
"zh-CN": "宏观降风险信号优先于 TACO 事件反弹",
367+
},
368+
}
369+
329370

330371
PluginRunner = Callable[[Mapping[str, Any], str], PluginRunResult]
331372
PluginPayloadBuilder = Callable[[pd.DataFrame, Mapping[str, Any]], dict[str, Any]]
@@ -808,6 +849,14 @@ def _localized_reason_labels(reason_codes: Sequence[str], locale: str) -> tuple[
808849
return tuple(_localized_reason_label(reason_code, locale) for reason_code in reason_codes)
809850

810851

852+
def _localized_opportunity_status(route: str, locale: str) -> str:
853+
return _localized_label(OPPORTUNITY_REVIEW_STATUS_LABELS, route, locale)
854+
855+
856+
def _localized_opportunity_veto_labels(vetoes: Sequence[str], locale: str) -> tuple[str, ...]:
857+
return tuple(_localized_label(OPPORTUNITY_REVIEW_VETO_LABELS, veto, locale) for veto in vetoes)
858+
859+
811860
def _payload_should_notify(payload: Mapping[str, Any], route: str) -> bool:
812861
notification = _nested_mapping(payload, "notification")
813862
if "should_notify" in notification:
@@ -858,6 +907,20 @@ def _active_panic_payload(payload: Mapping[str, Any], plugin: str) -> Mapping[st
858907
return {}
859908

860909

910+
def _vetoed_opportunity_components(payload: Mapping[str, Any]) -> frozenset[str]:
911+
notification = _nested_mapping(payload, "notification")
912+
raw = notification.get("vetoed_opportunities")
913+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)):
914+
return frozenset()
915+
components: set[str] = set()
916+
for item in raw:
917+
if isinstance(item, Mapping):
918+
component = str(item.get("component") or "").strip().lower()
919+
if component:
920+
components.add(component)
921+
return frozenset(components)
922+
923+
861924
def _active_taco_payload(payload: Mapping[str, Any], plugin: str) -> Mapping[str, Any]:
862925
if plugin == PLUGIN_TACO_REBOUND_SHADOW or "rebound_confirmation" in payload:
863926
return payload
@@ -917,7 +980,6 @@ def _append_panic_review_lines(lines: list[str], panic_payload: Mapping[str, Any
917980
if locale == "zh-CN":
918981
lines.extend(
919982
[
920-
"触发原因:",
921983
(
922984
"- VIX 曾达到恐慌区间:"
923985
f"{int(_as_float_or_none(thresholds.get('vix_high_lookback_days')) or 5)} 日高点 "
@@ -947,7 +1009,6 @@ def _append_panic_review_lines(lines: list[str], panic_payload: Mapping[str, Any
9471009
return
9481010
lines.extend(
9491011
[
950-
"Trigger evidence:",
9511012
(
9521013
"- VIX reached panic territory: "
9531014
f"{int(_as_float_or_none(thresholds.get('vix_high_lookback_days')) or 5)}-day high "
@@ -1025,77 +1086,87 @@ def _format_manual_review_notification_message(
10251086
as_of: str,
10261087
route: str,
10271088
) -> str | None:
1028-
if _payload_action(payload) != "notify_manual_review":
1089+
vetoed_components = _vetoed_opportunity_components(payload)
1090+
is_vetoed_opportunity_notice = bool(vetoed_components)
1091+
if _payload_action(payload) != "notify_manual_review" and not is_vetoed_opportunity_notice:
10291092
return None
10301093
panic_payload = _active_panic_payload(payload, plugin)
10311094
taco_payload = _active_taco_payload(payload, plugin)
1095+
if "panic_reversal" not in vetoed_components and is_vetoed_opportunity_notice:
1096+
panic_payload = {}
1097+
if "taco" not in vetoed_components and is_vetoed_opportunity_notice:
1098+
taco_payload = {}
10321099
if not panic_payload and not taco_payload:
10331100
return None
10341101

1035-
position_control = _nested_mapping(payload, "position_control")
1036-
vetoes = _message_reason_codes(position_control.get("vetoes") or _nested_mapping(payload, "arbiter").get("vetoes"))
1102+
vetoes = _message_reason_codes(_nested_mapping(payload, "arbiter").get("vetoes"))
10371103
attack_symbol = _review_attack_symbol(panic_payload, taco_payload) or target_label
10381104
source_title = _manual_review_source_title(panic_payload=panic_payload, taco_payload=taco_payload, locale=locale)
10391105
if locale == "zh-CN":
1106+
route_status = _localized_opportunity_status(route, locale)
1107+
veto_text = _message_join(_localized_opportunity_veto_labels(vetoes, locale), locale)
1108+
card_prefix = "机会被拦截" if is_vetoed_opportunity_notice else "机会复核"
1109+
situation_lines = ["- 机会信号已触发。"]
1110+
if is_vetoed_opportunity_notice:
1111+
situation_lines.append(f"- 当前仍处于{route_status}。")
1112+
if vetoes:
1113+
situation_lines.append(f"- {veto_text}。")
1114+
else:
1115+
situation_lines.append(f"- 当前状态:{route_status}。")
1116+
guidance_first = (
1117+
"- 人工复核恐慌是否已缓和,以及策略侧是否已按自身风控处理。"
1118+
if is_vetoed_opportunity_notice
1119+
else "- 结合策略自身风控、持仓状态和最新基本面判断是否需要干预。"
1120+
)
10401121
lines = [
1041-
f"【机会复核{attack_symbol}{source_title}】",
1122+
f"【{card_prefix}{attack_symbol}{source_title}】",
10421123
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-
"执行权限:只通知;不下单;不修改仓位。",
1124+
"情况说明:",
1125+
*situation_lines,
10491126
]
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) + "。")
10601127
if panic_payload:
10611128
_append_panic_review_lines(lines, panic_payload, locale=locale)
10621129
if taco_payload:
10631130
_append_taco_review_lines(lines, taco_payload, locale=locale)
10641131
lines.extend(
10651132
[
1066-
"人工复核建议:",
1067-
"- 只评估是否停止继续降风险或恢复观察,不作为自动买入信号。",
1068-
"- 若 crisis/macro 后续转为 risk_reduced 或 risk_off,本机会信号自动失效。",
1133+
"建议操作:",
1134+
guidance_first,
1135+
"- 若后续 VIX 或价格确认反转,本信号需要重新评估。",
10691136
]
10701137
)
10711138
return "\n".join(lines)
10721139

1140+
route_status = _localized_opportunity_status(route, locale)
1141+
veto_text = _message_join(_localized_opportunity_veto_labels(vetoes, locale), locale)
1142+
card_prefix = "Opportunity Vetoed" if is_vetoed_opportunity_notice else "Opportunity Review"
1143+
situation_lines = ["- Opportunity signal triggered."]
1144+
if is_vetoed_opportunity_notice:
1145+
situation_lines.append(f"- Current state is still {route_status}.")
1146+
if vetoes:
1147+
situation_lines.append(f"- {veto_text}.")
1148+
else:
1149+
situation_lines.append(f"- Current state: {route_status}.")
1150+
guidance_first = (
1151+
"- Manually review whether panic has eased and whether the strategy-side risk controls have already handled it."
1152+
if is_vetoed_opportunity_notice
1153+
else "- Consider strategy-side risk controls, current exposure, and latest fundamentals before intervening."
1154+
)
10731155
lines = [
1074-
f"[Opportunity Review | {attack_symbol} | {source_title}]",
1156+
f"[{card_prefix} | {attack_symbol} | {source_title}]",
10751157
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.",
1158+
"Situation:",
1159+
*situation_lines,
10791160
]
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) + ".")
10901161
if panic_payload:
10911162
_append_panic_review_lines(lines, panic_payload, locale=locale)
10921163
if taco_payload:
10931164
_append_taco_review_lines(lines, taco_payload, locale=locale)
10941165
lines.extend(
10951166
[
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.",
1167+
"Suggested action:",
1168+
guidance_first,
1169+
"- Reassess this signal if VIX or price confirmation later reverses.",
10991170
]
11001171
)
11011172
return "\n".join(lines)
@@ -1394,7 +1465,9 @@ def _build_market_regime_control_payload(price_history: pd.DataFrame, plugin_con
13941465
if _as_bool(plugin_config.get("taco_enabled"), default=True):
13951466
components["taco"] = _build_taco_rebound_payload(price_history, plugin_config)
13961467
if _as_bool(plugin_config.get("panic_reversal_enabled"), default=False):
1397-
components["panic_reversal"] = _build_panic_reversal_payload(price_history, plugin_config)
1468+
panic_config = dict(plugin_config)
1469+
panic_config.setdefault("suppress_when_price_crisis_guard_active", False)
1470+
components["panic_reversal"] = _build_panic_reversal_payload(price_history, panic_config)
13981471
return build_market_regime_control_signal(
13991472
components,
14001473
strategy_policy=str(plugin_config.get("strategy_policy", "levered_growth_income_v1")).strip(),

tests/test_market_regime_control_plugin.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ def test_market_regime_control_macro_delever_blocks_panic_reversal() -> None:
147147
assert payload["suggested_action"] == "delever"
148148
assert payload["position_control"]["panic_reversal_allowed"] is False
149149
assert "macro_delever_blocks_panic_reversal" in payload["arbiter"]["vetoes"]
150+
assert payload["notification"]["opportunity_vetoed_should_notify"] is True
151+
assert payload["notification"]["vetoed_opportunities"][0]["component"] == "panic_reversal"
152+
assert payload["notification"]["vetoed_opportunities"][0]["veto"] == "macro_delever_blocks_panic_reversal"
150153

151154

152155
def test_market_regime_control_blocked_component_blocks_taco_opportunity() -> None:

0 commit comments

Comments
 (0)