Skip to content

Commit 486c025

Browse files
authored
Improve strategy plugin consumption notifications
1 parent ab1f35f commit 486c025

4 files changed

Lines changed: 276 additions & 11 deletions

File tree

src/quant_platform_kit/common/notification_localization.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,15 @@
2929
"strategy_plugin_alert_locale": "zh-CN",
3030
"strategy_plugin_alert_reason_joiner": "、",
3131
"strategy_plugin_alert_reason_none": "未提供明确原因",
32-
"strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}",
32+
"strategy_plugin_line": "🧩 插件:{plugin} | 启用:{enabled} | 状态:{route} | 提醒:{action}",
33+
"strategy_plugin_enabled_true": "是",
34+
"strategy_plugin_enabled_false": "否",
35+
"strategy_plugin_consumption_auto": "🧩 插件消费:已按策略规则参与本轮仓位计算",
36+
"strategy_plugin_consumption_auto_defend": "🧩 插件消费:已按策略规则参与本轮仓位计算;风险仓位按防守规则处理",
37+
"strategy_plugin_consumption_auto_delever": "🧩 插件消费:已按策略规则参与本轮仓位计算;杠杆仓位按降档规则缩放",
38+
"strategy_plugin_consumption_loaded_not_applied": "🧩 插件消费:已加载但未改写仓位;当前策略未启用该状态的自动消费",
39+
"strategy_plugin_consumption_review_only": "🧩 插件消费:仅通知复核,未参与自动仓位计算",
40+
"strategy_plugin_consumption_unavailable": "🧩 插件消费:未消费插件信号",
3341
"strategy_plugin_error_line": "⚠️ 插件信号未加载:{reason};本轮使用策略内置规则",
3442
"strategy_plugin_error_reason_JSONDecodeError": "插件配置格式错误",
3543
"strategy_plugin_error_reason_FileNotFoundError": "插件信号文件不存在",
@@ -119,7 +127,15 @@
119127
"strategy_plugin_alert_locale": "en-US",
120128
"strategy_plugin_alert_reason_joiner": ", ",
121129
"strategy_plugin_alert_reason_none": "no explicit reason provided",
122-
"strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}",
130+
"strategy_plugin_line": "🧩 Plugin: {plugin} | enabled: {enabled} | status: {route} | notice: {action}",
131+
"strategy_plugin_enabled_true": "yes",
132+
"strategy_plugin_enabled_false": "no",
133+
"strategy_plugin_consumption_auto": "🧩 Plugin consumption: included in this cycle's position calculation under strategy rules",
134+
"strategy_plugin_consumption_auto_defend": "🧩 Plugin consumption: included in this cycle's position calculation; risk exposure follows defensive rules",
135+
"strategy_plugin_consumption_auto_delever": "🧩 Plugin consumption: included in this cycle's position calculation; leveraged exposure follows de-risking rules",
136+
"strategy_plugin_consumption_loaded_not_applied": "🧩 Plugin consumption: loaded but did not rewrite positions; this strategy does not enable automatic consumption for this state",
137+
"strategy_plugin_consumption_review_only": "🧩 Plugin consumption: review-only notice, not used for automatic position calculation",
138+
"strategy_plugin_consumption_unavailable": "🧩 Plugin consumption: no plugin signal consumed",
123139
"strategy_plugin_error_line": "⚠️ Plugin signal failed to load: {reason}; this run falls back to built-in strategy rules",
124140
"strategy_plugin_error_reason_JSONDecodeError": "invalid plugin mount JSON",
125141
"strategy_plugin_error_reason_FileNotFoundError": "plugin signal file not found",
@@ -283,7 +299,7 @@
283299
def merge_strategy_plugin_i18n(
284300
i18n: dict[str, dict[str, str]],
285301
*,
286-
shared_wins: bool = True,
302+
shared_wins: bool = False,
287303
) -> dict[str, dict[str, str]]:
288304
merged = {str(locale): dict(values) for locale, values in i18n.items()}
289305
for locale, shared_values in STRATEGY_PLUGIN_I18N.items():

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -892,8 +892,110 @@ def extract_strategy_plugin_localized_message(
892892
default_locale = _optional_string(notification.get("default_locale"))
893893
if default_locale:
894894
localized = _optional_string(notification_messages.get(default_locale))
895-
if localized:
896-
return localized
895+
if localized:
896+
return localized
897+
return None
898+
899+
900+
def _strategy_plugin_enabled_label(
901+
signal: StrategyPluginSignal,
902+
*,
903+
translator: Callable[..., str] | None = None,
904+
) -> str:
905+
enabled = _as_bool(getattr(signal, "enabled", True), default=True)
906+
return _translate(
907+
translator,
908+
f"strategy_plugin_enabled_{str(enabled).lower()}",
909+
fallback="yes" if enabled else "no",
910+
)
911+
912+
913+
def _strategy_plugin_execution_controls(signal: StrategyPluginSignal) -> Mapping[str, Any]:
914+
controls = getattr(signal, "execution_controls", {}) or {}
915+
return controls if isinstance(controls, Mapping) else {}
916+
917+
918+
def _strategy_plugin_consumption_policy(signal: StrategyPluginSignal) -> Mapping[str, Any]:
919+
payload = getattr(signal, "payload", {}) or {}
920+
if not isinstance(payload, Mapping):
921+
return {}
922+
policy = payload.get("consumption_policy") or {}
923+
return policy if isinstance(policy, Mapping) else {}
924+
925+
926+
def _strategy_plugin_consumption_status(signal: StrategyPluginSignal) -> str:
927+
controls = _strategy_plugin_execution_controls(signal)
928+
status = _optional_string(controls.get("consumption_evidence_status"))
929+
if status is None:
930+
status = _optional_string(_strategy_plugin_consumption_policy(signal).get("evidence_status"))
931+
return _normalize_strategy_plugin_field(status)
932+
933+
934+
def _strategy_plugin_policy_allows_position_control(signal: StrategyPluginSignal) -> bool:
935+
controls = _strategy_plugin_execution_controls(signal)
936+
if "position_control_allowed" in controls:
937+
return _as_bool(controls.get("position_control_allowed"), default=False)
938+
return _as_bool(
939+
_strategy_plugin_consumption_policy(signal).get("position_control_allowed"),
940+
default=False,
941+
)
942+
943+
944+
def _strategy_plugin_is_notification_only(signal: StrategyPluginSignal) -> bool:
945+
controls = _strategy_plugin_execution_controls(signal)
946+
policy = _strategy_plugin_consumption_policy(signal)
947+
if _strategy_plugin_consumption_status(signal) == "notification_only":
948+
return True
949+
if str(controls.get("capital_impact") or policy.get("capital_impact") or "").strip().lower() == "notification_only":
950+
return True
951+
if str(controls.get("notification_profile") or policy.get("notification_profile") or "").strip().lower() == "shadow_only":
952+
return True
953+
return False
954+
955+
956+
def _strategy_plugin_auto_consumption_allowed(signal: StrategyPluginSignal, *, action: str) -> bool:
957+
if _is_strategy_position_control_notice(signal, action=action):
958+
return True
959+
controls = _strategy_plugin_execution_controls(signal)
960+
if controls:
961+
return False
962+
return bool(
963+
action in STRATEGY_PLUGIN_AUTOMATED_POSITION_ACTIONS
964+
and _strategy_plugin_policy_allows_position_control(signal)
965+
and _strategy_plugin_consumption_status(signal) == "automation_approved"
966+
)
967+
968+
969+
def _strategy_plugin_consumption_notification_line(
970+
signal: StrategyPluginSignal,
971+
*,
972+
translator: Callable[..., str] | None = None,
973+
) -> str | None:
974+
route = _normalize_strategy_plugin_field(getattr(signal, "canonical_route", None))
975+
action = _normalize_strategy_plugin_field(getattr(signal, "suggested_action", None))
976+
if _strategy_plugin_auto_consumption_allowed(signal, action=action):
977+
if action == "defend" or route == "risk_off":
978+
key = "strategy_plugin_consumption_auto_defend"
979+
elif action == "delever" or route == "risk_reduced":
980+
key = "strategy_plugin_consumption_auto_delever"
981+
else:
982+
key = "strategy_plugin_consumption_auto"
983+
return _translate(translator, key, fallback="Plugin consumption: included in this cycle's position calculation")
984+
if _strategy_plugin_is_notification_only(signal):
985+
return _translate(
986+
translator,
987+
"strategy_plugin_consumption_review_only",
988+
fallback="Plugin consumption: review-only notice, not used for automatic position calculation",
989+
)
990+
if action in STRATEGY_PLUGIN_AUTOMATED_POSITION_ACTIONS:
991+
return _translate(
992+
translator,
993+
"strategy_plugin_consumption_loaded_not_applied",
994+
fallback=(
995+
"Plugin consumption: loaded but did not rewrite positions; this strategy does not enable "
996+
"automatic consumption for this state"
997+
),
998+
)
897999
return None
8981000

8991001

@@ -912,20 +1014,28 @@ def build_strategy_plugin_notification_lines(
9121014
)
9131015
if localized_line:
9141016
lines.append(localized_line)
1017+
if translator is not None:
1018+
consumption_line = _strategy_plugin_consumption_notification_line(signal, translator=translator)
1019+
if consumption_line:
1020+
lines.append(consumption_line)
9151021
continue
9161022
route = getattr(signal, "canonical_route", None) or "unknown_route"
9171023
action = getattr(signal, "suggested_action", None) or "unknown_action"
9181024
lines.append(
9191025
_translate(
9201026
translator,
9211027
"strategy_plugin_line",
922-
fallback="Plugin: {plugin} | status: {route} | notice: {action}",
1028+
fallback="Plugin: {plugin} | enabled: {enabled} | status: {route} | notice: {action}",
9231029
plugin=translate_strategy_plugin_value("name", getattr(signal, "plugin", None), translator=translator),
1030+
enabled=_strategy_plugin_enabled_label(signal, translator=translator),
9241031
mode=translate_strategy_plugin_value("mode", getattr(signal, "effective_mode", None), translator=translator),
9251032
route=translate_strategy_plugin_value("route", route, translator=translator),
9261033
action=translate_strategy_plugin_value("action", action, translator=translator),
9271034
)
9281035
)
1036+
consumption_line = _strategy_plugin_consumption_notification_line(signal, translator=translator)
1037+
if consumption_line:
1038+
lines.append(consumption_line)
9291039
return tuple(lines)
9301040

9311041

@@ -985,6 +1095,11 @@ def build_strategy_plugin_error_notification_lines(
9851095
fallback="Plugin signal failed to load: {reason}; this run falls back to built-in strategy rules",
9861096
reason=reason,
9871097
),
1098+
_translate(
1099+
translator,
1100+
"strategy_plugin_consumption_unavailable",
1101+
fallback="Plugin consumption: no plugin signal consumed",
1102+
),
9881103
)
9891104

9901105

tests/test_notification_localization.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_strategy_plugin_i18n_has_matching_locale_keys(self):
8080
self.assertIn("strategy_plugin_name_taco_rebound_shadow", STRATEGY_PLUGIN_I18N["zh"])
8181
self.assertEqual(STRATEGY_PLUGIN_I18N["zh"]["strategy_plugin_name_taco_rebound_shadow"], "TACO 反弹观察通知")
8282

83-
def test_merge_strategy_plugin_i18n_fills_and_overrides_shared_keys(self):
83+
def test_merge_strategy_plugin_i18n_fills_missing_keys_without_overriding_callers(self):
8484
merged = merge_strategy_plugin_i18n(
8585
{
8686
"zh": {
@@ -92,6 +92,20 @@ def test_merge_strategy_plugin_i18n_fills_and_overrides_shared_keys(self):
9292
)
9393

9494
self.assertEqual(merged["zh"]["no_trades"], "无需调仓")
95+
self.assertEqual(merged["zh"]["strategy_plugin_name_taco_rebound_shadow"], "TACO 旧观察通知")
96+
self.assertEqual(merged["en"]["strategy_plugin_name_taco_rebound_shadow"], "TACO Rebound Watch Notice")
97+
98+
def test_merge_strategy_plugin_i18n_can_prefer_shared_keys(self):
99+
merged = merge_strategy_plugin_i18n(
100+
{
101+
"zh": {
102+
"strategy_plugin_name_taco_rebound_shadow": "TACO 旧观察通知",
103+
},
104+
"en": {},
105+
},
106+
shared_wins=True,
107+
)
108+
95109
self.assertEqual(merged["zh"]["strategy_plugin_name_taco_rebound_shadow"], "TACO 反弹观察通知")
96110
self.assertEqual(merged["en"]["strategy_plugin_route_watch"], "watch")
97111

tests/test_strategy_plugins.py

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -590,11 +590,12 @@ def test_attach_strategy_plugin_metadata_ignores_shadow_artifact_without_runtime
590590
def test_strategy_plugin_notification_lines_use_translator_when_available(self):
591591
signal = validate_strategy_plugin_signal_payload(_signal_payload())
592592
translations = {
593-
"strategy_plugin_line": "plugin={plugin}|mode={mode}|route={route}|action={action}",
593+
"strategy_plugin_line": "plugin={plugin}|enabled={enabled}|route={route}|action={action}",
594594
"strategy_plugin_error_line": "plugin-error={reason}|fallback=built-in",
595595
"strategy_plugin_error_reason_ValueError": "config validation failed",
596+
"strategy_plugin_consumption_unavailable": "consumption=none",
597+
"strategy_plugin_enabled_true": "yes",
596598
"strategy_plugin_name_crisis_response_shadow": "Crisis",
597-
"strategy_plugin_mode_shadow": "shadow",
598599
"strategy_plugin_route_no_action": "no action",
599600
"strategy_plugin_action_watch_only": "watch only",
600601
}
@@ -606,15 +607,134 @@ def test_strategy_plugin_notification_lines_use_translator_when_available(self):
606607
else translations.get(key, key),
607608
)
608609

609-
self.assertEqual(lines, ("plugin=Crisis|mode=shadow|route=no action|action=watch only",))
610+
self.assertEqual(lines, ("plugin=Crisis|enabled=yes|route=no action|action=watch only",))
610611
self.assertEqual(
611612
build_strategy_plugin_error_notification_lines(
612613
"ValueError: bad config\ntrace line",
613614
translator=lambda key, **kwargs: translations.get(key, key).format(**kwargs)
614615
if kwargs
615616
else translations.get(key, key),
616617
),
617-
("plugin-error=config validation failed|fallback=built-in",),
618+
("plugin-error=config validation failed|fallback=built-in", "consumption=none"),
619+
)
620+
621+
def test_strategy_plugin_notification_lines_include_auto_consumption_line(self):
622+
signal = validate_strategy_plugin_signal_payload(
623+
{
624+
**_signal_payload(plugin=PLUGIN_MARKET_REGIME_CONTROL),
625+
"canonical_route": "risk_off",
626+
"suggested_action": "defend",
627+
"would_trade_if_enabled": True,
628+
"execution_controls": {
629+
**_signal_payload()["execution_controls"],
630+
"strategy_runtime_metadata_allowed": True,
631+
"position_control_allowed": True,
632+
"consumption_evidence_status": "automation_approved",
633+
},
634+
}
635+
)
636+
translations = {
637+
"strategy_plugin_line": "plugin={plugin}|enabled={enabled}|route={route}|action={action}",
638+
"strategy_plugin_enabled_true": "yes",
639+
"strategy_plugin_name_market_regime_control": "Market Regime",
640+
"strategy_plugin_route_risk_off": "risk off",
641+
"strategy_plugin_action_defend": "defend",
642+
"strategy_plugin_consumption_auto_defend": "consumption=auto-defend",
643+
}
644+
645+
lines = build_strategy_plugin_notification_lines(
646+
[signal],
647+
translator=lambda key, **kwargs: translations.get(key, key).format(**kwargs)
648+
if kwargs
649+
else translations.get(key, key),
650+
)
651+
652+
self.assertEqual(
653+
lines,
654+
(
655+
"plugin=Market Regime|enabled=yes|route=risk off|action=defend",
656+
"consumption=auto-defend",
657+
),
658+
)
659+
660+
def test_strategy_plugin_notification_lines_include_review_only_consumption_line(self):
661+
signal = validate_strategy_plugin_signal_payload(
662+
{
663+
**_signal_payload(plugin=PLUGIN_MARKET_REGIME_CONTROL),
664+
"canonical_route": "opportunity_watch",
665+
"suggested_action": "notify_manual_review",
666+
"would_trade_if_enabled": False,
667+
"execution_controls": {
668+
**_signal_payload()["execution_controls"],
669+
"strategy_runtime_metadata_allowed": False,
670+
"position_control_allowed": False,
671+
"consumption_evidence_status": "notification_only",
672+
"capital_impact": "notification_only",
673+
},
674+
}
675+
)
676+
translations = {
677+
"strategy_plugin_line": "plugin={plugin}|enabled={enabled}|route={route}|action={action}",
678+
"strategy_plugin_enabled_true": "yes",
679+
"strategy_plugin_name_market_regime_control": "Market Regime",
680+
"strategy_plugin_route_opportunity_watch": "opportunity watch",
681+
"strategy_plugin_action_notify_manual_review": "manual review",
682+
"strategy_plugin_consumption_review_only": "consumption=review-only",
683+
}
684+
685+
lines = build_strategy_plugin_notification_lines(
686+
[signal],
687+
translator=lambda key, **kwargs: translations.get(key, key).format(**kwargs)
688+
if kwargs
689+
else translations.get(key, key),
690+
)
691+
692+
self.assertEqual(
693+
lines,
694+
(
695+
"plugin=Market Regime|enabled=yes|route=opportunity watch|action=manual review",
696+
"consumption=review-only",
697+
),
698+
)
699+
700+
def test_strategy_plugin_notification_lines_include_loaded_not_applied_consumption_line(self):
701+
signal = validate_strategy_plugin_signal_payload(
702+
{
703+
**_signal_payload(plugin=PLUGIN_MARKET_REGIME_CONTROL),
704+
"canonical_route": "risk_reduced",
705+
"suggested_action": "delever",
706+
"would_trade_if_enabled": True,
707+
"execution_controls": {
708+
**_signal_payload()["execution_controls"],
709+
"strategy_runtime_metadata_allowed": False,
710+
"position_control_allowed": False,
711+
"consumption_evidence_status": "automation_approved",
712+
},
713+
}
714+
)
715+
translations = {
716+
"strategy_plugin_line": "plugin={plugin}|enabled={enabled}|route={route}|action={action}",
717+
"strategy_plugin_enabled_true": "yes",
718+
"strategy_plugin_name_market_regime_control": "Market Regime",
719+
"strategy_plugin_route_risk_reduced": "risk reduced",
720+
"strategy_plugin_action_delever": "de-lever",
721+
"strategy_plugin_consumption_review_only": "consumption=review-only",
722+
"strategy_plugin_consumption_loaded_not_applied": "consumption=loaded-not-applied",
723+
}
724+
725+
lines = build_strategy_plugin_notification_lines(
726+
[signal],
727+
translator=lambda key, **kwargs: translations.get(key, key).format(**kwargs)
728+
if kwargs
729+
else translations.get(key, key),
730+
)
731+
732+
self.assertEqual(
733+
lines,
734+
(
735+
"plugin=Market Regime|enabled=yes|route=risk reduced|action=de-lever",
736+
"consumption=loaded-not-applied",
737+
),
618738
)
619739

620740
def test_strategy_plugin_notification_lines_can_use_artifact_localized_message(self):

0 commit comments

Comments
 (0)