@@ -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
330371PluginRunner = Callable [[Mapping [str , Any ], str ], PluginRunResult ]
331372PluginPayloadBuilder = 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+
811860def _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+
861924def _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 (),
0 commit comments