From dd2ae0a5e866d78fe3cfb801a6ee04238794480d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 03:24:04 +0800 Subject: [PATCH] Support strategy plugin notification targets --- README.md | 3 + README.zh-CN.md | 2 + docs/strategy_plugin_runtime_contract.md | 23 +- .../strategy_plugin_runtime_contract.zh-CN.md | 23 +- src/quant_platform_kit/common/__init__.py | 12 + .../common/strategy_plugins.py | 226 ++++++++++++++++-- tests/test_strategy_plugins.py | 87 +++++++ 7 files changed, 355 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2fced42..dc53094 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ Plugin artifacts may carry display-only `strategy_plugin_messages.v1` and use those strings, while strategy and platform logic should continue to depend on machine fields such as `canonical_route`, `suggested_action`, `reason_codes`, and `position_control`. +General notification artifacts are loaded through `notification_targets`, not +through synthetic strategy mounts; they can trigger alerts but never attach +position controls to a strategy runtime. Plugin alert delivery is provider-neutral at the platform boundary. Platform repositories pass runtime settings into `publish_strategy_plugin_alerts`; this repository handles configured `email`, `sms`, `push`, and `telegram` channels without coupling plugin logic to a broker platform. diff --git a/README.zh-CN.md b/README.zh-CN.md index 55bfa5e..f3300f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,6 +52,8 @@ QuantPlatformKit 插件 artifact 可以携带展示层 `strategy_plugin_messages.v1` 和 `strategy_plugin_log.v1` 中英文通知 / 日志文案。平台 renderer 可以使用这些文案,但策略和平台逻辑应继续依赖 `canonical_route`、`suggested_action`、`reason_codes`、`position_control` 等机器字段。 +通用通知 artifact 通过 `notification_targets` 加载,不再通过 synthetic strategy +挂载;它们可以触发告警,但永远不会把仓位控制附加到策略 runtime。 插件告警发送在平台边界保持 provider-neutral。平台仓库只把 runtime settings 传入 `publish_strategy_plugin_alerts`;这个仓库负责按配置发送 `email`、`sms`、`push` 和 `telegram`,不让插件逻辑耦合某个券商平台。 diff --git a/docs/strategy_plugin_runtime_contract.md b/docs/strategy_plugin_runtime_contract.md index 199064c..d1951f3 100644 --- a/docs/strategy_plugin_runtime_contract.md +++ b/docs/strategy_plugin_runtime_contract.md @@ -88,8 +88,8 @@ changes out of platform runtime code. SOXL/SOXX is intentionally not listed as a `market_regime_control` runtime mount. Broad macro and crisis signals for SOXL should be delivered through a -general notification artifact and reviewed manually unless a future backtest -promotes an explicit strategy-level opt-in. +general `notification_targets.market_regime_notification` artifact and reviewed +manually unless a future backtest promotes an explicit strategy-level opt-in. ## Runtime Loader @@ -114,6 +114,25 @@ notification_lines = build_strategy_plugin_notification_lines(signals, locale="z alert_messages = build_strategy_plugin_alert_messages(signals) ``` +General notification artifacts use `notification_targets`, not synthetic +strategy names. They can be loaded and sent through the same notification and +alert builders, but they are not attached to strategy runtime metadata and +cannot authorize position changes: + +```python +from quant_platform_kit.common.strategy_plugins import ( + load_configured_strategy_plugin_notification_target_signals, + parse_strategy_plugin_notification_targets, +) + +targets = parse_strategy_plugin_notification_targets(raw_json_config) +notification_signals = load_configured_strategy_plugin_notification_target_signals(targets) +notification_lines = build_strategy_plugin_notification_lines( + notification_signals, + locale="zh-CN", +) +``` + The loader validates: - the artifact is a JSON object diff --git a/docs/strategy_plugin_runtime_contract.zh-CN.md b/docs/strategy_plugin_runtime_contract.zh-CN.md index 10df490..260b3a5 100644 --- a/docs/strategy_plugin_runtime_contract.zh-CN.md +++ b/docs/strategy_plugin_runtime_contract.zh-CN.md @@ -79,8 +79,9 @@ artifact 内,并固定为通知/观察用途的 `shadow`。 registry 给 parser / loader。这样平台运行时代码不用承载未来插件资格变更。 SOXL/SOXX 故意不列入 `market_regime_control` 的运行时挂载清单。SOXL -相关宏观和危机信号应通过通用通知 artifact 分发,并由人工复核;除非未来 -回测证明策略级 opt-in 有优势,否则不默认自动消费仓位控制。 +相关宏观和危机信号应通过通用 +`notification_targets.market_regime_notification` artifact 分发,并由人工复核; +除非未来回测证明策略级 opt-in 有优势,否则不默认自动消费仓位控制。 ## Runtime Loader @@ -105,6 +106,24 @@ notification_lines = build_strategy_plugin_notification_lines(signals, locale="z alert_messages = build_strategy_plugin_alert_messages(signals) ``` +通用通知 artifact 使用 `notification_targets`,不是 synthetic strategy。它们 +可以复用同一套通知和告警 builder,但不会附加到策略 runtime metadata,也不能 +授权仓位变化: + +```python +from quant_platform_kit.common.strategy_plugins import ( + load_configured_strategy_plugin_notification_target_signals, + parse_strategy_plugin_notification_targets, +) + +targets = parse_strategy_plugin_notification_targets(raw_json_config) +notification_signals = load_configured_strategy_plugin_notification_target_signals(targets) +notification_lines = build_strategy_plugin_notification_lines( + notification_signals, + locale="zh-CN", +) +``` + loader 会校验: - artifact 是 JSON object diff --git a/src/quant_platform_kit/common/__init__.py b/src/quant_platform_kit/common/__init__.py index 720f5f5..f17bad6 100644 --- a/src/quant_platform_kit/common/__init__.py +++ b/src/quant_platform_kit/common/__init__.py @@ -41,6 +41,7 @@ from .strategy_plugins import ( CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES, DEFAULT_STRATEGY_PLUGIN_DEFINITIONS, + GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, PLUGIN_CRISIS_RESPONSE_SHADOW, PLUGIN_MARKET_REGIME_CONTROL, PLUGIN_MACRO_RISK_GOVERNOR, @@ -52,6 +53,7 @@ STRATEGY_PLUGIN_ALERT_CHANNEL_TELEGRAM, STRATEGY_PLUGIN_ALERT_ACTIONS, STRATEGY_PLUGIN_NON_ALERT_ROUTES, + STRATEGY_PLUGIN_NOTIFICATION_TARGETS, STRATEGY_PLUGIN_SCHEMA_VERSIONS, SUPPORTED_STRATEGY_PLUGIN_MODES, MACRO_RISK_GOVERNOR_SUPPORTED_STRATEGIES, @@ -60,6 +62,7 @@ StrategyPluginAlertMessage, StrategyPluginDefinition, StrategyPluginMountConfig, + StrategyPluginNotificationTargetConfig, StrategyPluginSignal, build_strategy_plugin_alert_guidance, build_strategy_plugin_alert_key, @@ -68,14 +71,17 @@ build_strategy_plugin_notification_lines, build_strategy_plugin_report_payload, extract_strategy_plugin_localized_message, + load_configured_strategy_plugin_notification_target_signals, load_configured_strategy_plugin_signals, load_strategy_plugin_signal, normalize_strategy_plugin_definitions, normalize_strategy_plugin_mode, + parse_strategy_plugin_notification_targets, parse_strategy_plugin_mounts, should_alert_strategy_plugin_signal, translate_strategy_plugin_value, validate_strategy_plugin_compatibility, + validate_strategy_plugin_notification_target, validate_strategy_plugin_schema_version, validate_strategy_plugin_signal_payload, ) @@ -85,6 +91,7 @@ "CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES", "DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS", "DEFAULT_STRATEGY_PLUGIN_DEFINITIONS", + "GENERAL_MARKET_REGIME_NOTIFICATION_TARGET", "DEFAULT_TERMINAL_FUNDING_BLOCK_SKIP_REASONS", "DEFAULT_TERMINAL_STRATEGY_RUN_STAGES", "PLUGIN_CRISIS_RESPONSE_SHADOW", @@ -107,6 +114,7 @@ "STRATEGY_PLUGIN_ALERT_CHANNEL_TELEGRAM", "STRATEGY_PLUGIN_ALERT_ACTIONS", "STRATEGY_PLUGIN_NON_ALERT_ROUTES", + "STRATEGY_PLUGIN_NOTIFICATION_TARGETS", "STRATEGY_PLUGIN_SCHEMA_VERSIONS", "SUPPORTED_STRATEGY_PLUGIN_MODES", "MACRO_RISK_GOVERNOR_SUPPORTED_STRATEGIES", @@ -130,6 +138,7 @@ "StrategyPluginAlertMessage", "StrategyPluginDefinition", "StrategyPluginMountConfig", + "StrategyPluginNotificationTargetConfig", "StrategyPluginSignal", "build_strategy_plugin_alert_guidance", "build_strategy_plugin_alert_key", @@ -139,16 +148,19 @@ "build_strategy_plugin_report_payload", "extract_strategy_plugin_localized_message", "build_runtime_target", + "load_configured_strategy_plugin_notification_target_signals", "load_configured_strategy_plugin_signals", "load_strategy_plugin_signal", "normalize_strategy_plugin_definitions", "normalize_strategy_plugin_mode", + "parse_strategy_plugin_notification_targets", "parse_strategy_plugin_mounts", "resolve_runtime_target_from_env", "should_alert_strategy_plugin_signal", "translate_strategy_plugin_value", "translator_uses_zh", "validate_strategy_plugin_compatibility", + "validate_strategy_plugin_notification_target", "validate_strategy_plugin_schema_version", "validate_strategy_plugin_signal_payload", ] diff --git a/src/quant_platform_kit/common/strategy_plugins.py b/src/quant_platform_kit/common/strategy_plugins.py index 4e41eff..f734420 100644 --- a/src/quant_platform_kit/common/strategy_plugins.py +++ b/src/quant_platform_kit/common/strategy_plugins.py @@ -15,6 +15,7 @@ PLUGIN_MARKET_REGIME_CONTROL = "market_regime_control" PLUGIN_MACRO_RISK_GOVERNOR = "macro_risk_governor" PLUGIN_TACO_REBOUND_SHADOW = "taco_rebound_shadow" +GENERAL_MARKET_REGIME_NOTIFICATION_TARGET = "market_regime_notification" PLUGIN_MODE_SHADOW = "shadow" STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL = "email" STRATEGY_PLUGIN_ALERT_CHANNEL_SMS = "sms" @@ -40,6 +41,9 @@ "mega_cap_leader_rotation_top50_balanced", } ) +STRATEGY_PLUGIN_NOTIFICATION_TARGETS: Mapping[str, frozenset[str]] = { + PLUGIN_MARKET_REGIME_CONTROL: frozenset({GENERAL_MARKET_REGIME_NOTIFICATION_TARGET}), +} STRATEGY_PLUGIN_SCHEMA_VERSIONS: Mapping[str, frozenset[str]] = { PLUGIN_CRISIS_RESPONSE_SHADOW: frozenset({"crisis_response_shadow.v1"}), PLUGIN_MARKET_REGIME_CONTROL: frozenset({"market_regime_control.v1"}), @@ -252,6 +256,16 @@ class StrategyPluginMountConfig: expected_schema_version: str | None = None +@dataclass(frozen=True) +class StrategyPluginNotificationTargetConfig: + notification_target: str + plugin: str + signal_path: str + enabled: bool = True + expected_mode: str | None = None + expected_schema_version: str | None = None + + @dataclass(frozen=True) class StrategyPluginSignal: strategy: str @@ -271,10 +285,14 @@ class StrategyPluginSignal: deprecated_plugin: bool = False successor_plugin: str | None = None supported_schema_versions: tuple[str, ...] = () + target_type: str = "strategy" + notification_target: str = "" def report_summary(self) -> dict[str, Any]: return { "strategy": self.strategy, + "target_type": self.target_type, + "notification_target": self.notification_target, "plugin": self.plugin, "mode": self.mode, "configured_mode": self.configured_mode, @@ -361,6 +379,23 @@ def validate_strategy_plugin_compatibility( ) +def validate_strategy_plugin_notification_target( + *, + notification_target: str, + plugin: str, + source: str = "plugin", +) -> None: + target_name = _required_string(notification_target, field_name="notification_target") + plugin_name = _required_string(plugin, field_name="plugin") + allowed_targets = STRATEGY_PLUGIN_NOTIFICATION_TARGETS.get(plugin_name, frozenset()) + if target_name not in allowed_targets: + allowed = ", ".join(sorted(allowed_targets)) or "(none)" + raise ValueError( + f"strategy plugin {plugin_name} does not support notification target {target_name} " + f"in {source}; supported notification targets: {allowed}" + ) + + def validate_strategy_plugin_schema_version( *, plugin: str, @@ -459,6 +494,76 @@ def parse_strategy_plugin_mounts( return tuple(mounts) +def parse_strategy_plugin_notification_targets( + raw_config: str | Sequence[Mapping[str, Any]] | Mapping[str, Any] | None, + *, + plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None, +) -> tuple[StrategyPluginNotificationTargetConfig, ...]: + if raw_config is None or raw_config == "": + return () + definitions = normalize_strategy_plugin_definitions(plugin_definitions) + payload: Any + if isinstance(raw_config, str): + payload = json.loads(raw_config) + else: + payload = raw_config + + if isinstance(payload, Mapping): + payload = payload.get("notification_targets", ()) + if not isinstance(payload, Sequence) or isinstance(payload, (str, bytes)): + raise ValueError("strategy plugin notification target config must be a JSON list or object with notification_targets") + + targets: list[StrategyPluginNotificationTargetConfig] = [] + seen: set[tuple[str, str]] = set() + for item in payload: + if not isinstance(item, Mapping): + raise ValueError("each strategy plugin notification target must be an object") + if "mode" in item: + raise ValueError("notification target config must not set mode; read mode from the plugin artifact") + notification_target = _required_string(item.get("notification_target"), field_name="notification_target") + plugin = _required_string(item.get("plugin"), field_name="plugin") + signal_path = _required_string( + item.get("signal_path") or item.get("latest_signal_path") or item.get("path"), + field_name="signal_path", + ) + key = (notification_target, plugin) + if key in seen: + raise ValueError( + f"duplicate strategy plugin notification target: notification_target={notification_target} plugin={plugin}" + ) + seen.add(key) + expected_mode = item.get("expected_mode") + normalized_expected_mode = ( + normalize_strategy_plugin_mode(expected_mode, field_name="expected_mode") + if expected_mode is not None + else None + ) + expected_schema_version = _optional_string(item.get("expected_schema_version")) + validate_strategy_plugin_schema_version( + plugin=plugin, + schema_version=expected_schema_version, + expected_schema_version=expected_schema_version, + plugin_definitions=definitions, + source="notification_target", + ) + validate_strategy_plugin_notification_target( + notification_target=notification_target, + plugin=plugin, + source="notification_target", + ) + targets.append( + StrategyPluginNotificationTargetConfig( + notification_target=notification_target, + plugin=plugin, + signal_path=signal_path, + enabled=_as_bool(item.get("enabled"), default=True), + expected_mode=normalized_expected_mode, + expected_schema_version=expected_schema_version, + ) + ) + return tuple(targets) + + def load_configured_strategy_plugin_signals( mounts: Sequence[StrategyPluginMountConfig], *, @@ -495,10 +600,45 @@ def load_configured_strategy_plugin_signals( return tuple(signals) +def load_configured_strategy_plugin_notification_target_signals( + targets: Sequence[StrategyPluginNotificationTargetConfig], + *, + notification_target: str | None = None, + client_factory: Any = None, + plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None, +) -> tuple[StrategyPluginSignal, ...]: + selected_target = _optional_string(notification_target) + definitions = normalize_strategy_plugin_definitions(plugin_definitions) + signals: list[StrategyPluginSignal] = [] + for target in targets: + if not target.enabled: + continue + if selected_target is not None and target.notification_target != selected_target: + continue + validate_strategy_plugin_notification_target( + notification_target=target.notification_target, + plugin=target.plugin, + source="notification_target", + ) + signals.append( + load_strategy_plugin_signal( + target.signal_path, + expected_notification_target=target.notification_target, + expected_plugin=target.plugin, + expected_mode=target.expected_mode, + expected_schema_version=target.expected_schema_version, + client_factory=client_factory, + plugin_definitions=definitions, + ) + ) + return tuple(signals) + + def load_strategy_plugin_signal( reference: str, *, expected_strategy: str | None = None, + expected_notification_target: str | None = None, expected_plugin: str | None = None, expected_mode: str | None = None, expected_schema_version: str | None = None, @@ -514,6 +654,7 @@ def load_strategy_plugin_signal( return validate_strategy_plugin_signal_payload( payload, expected_strategy=expected_strategy, + expected_notification_target=expected_notification_target, expected_plugin=expected_plugin, expected_mode=expected_mode, expected_schema_version=expected_schema_version, @@ -527,6 +668,7 @@ def validate_strategy_plugin_signal_payload( payload: Mapping[str, Any], *, expected_strategy: str | None = None, + expected_notification_target: str | None = None, expected_plugin: str | None = None, expected_mode: str | None = None, expected_schema_version: str | None = None, @@ -534,7 +676,17 @@ def validate_strategy_plugin_signal_payload( local_path: str | None = None, plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None, ) -> StrategyPluginSignal: - strategy = _required_string(payload.get("strategy"), field_name="strategy") + target_type = _optional_string(payload.get("target_type")) or ( + "notification_target" if _optional_string(payload.get("notification_target")) else "strategy" + ) + if target_type not in {"strategy", "notification_target"}: + raise ValueError(f"strategy plugin signal target_type must be strategy or notification_target; got {target_type!r}") + if target_type == "notification_target": + notification_target = _required_string(payload.get("notification_target"), field_name="notification_target") + strategy = _optional_string(payload.get("strategy")) or "" + else: + strategy = _required_string(payload.get("strategy"), field_name="strategy") + notification_target = _optional_string(payload.get("notification_target")) or "" plugin = _required_string(payload.get("plugin"), field_name="plugin") mode = normalize_strategy_plugin_mode(payload.get("mode"), field_name="mode") configured_mode = normalize_strategy_plugin_mode( @@ -547,9 +699,25 @@ def validate_strategy_plugin_signal_payload( ) expected_strategy = _optional_string(expected_strategy) + expected_notification_target = _optional_string(expected_notification_target) expected_plugin = _optional_string(expected_plugin) + if expected_strategy is not None and target_type != "strategy": + raise ValueError( + "strategy plugin artifact target mismatch: " + f"expected strategy {expected_strategy}, got notification target {notification_target}" + ) if expected_strategy is not None and strategy != expected_strategy: raise ValueError(f"strategy plugin artifact strategy mismatch: expected {expected_strategy}, got {strategy}") + if expected_notification_target is not None and target_type != "notification_target": + raise ValueError( + "strategy plugin artifact target mismatch: " + f"expected notification target {expected_notification_target}, got strategy {strategy}" + ) + if expected_notification_target is not None and notification_target != expected_notification_target: + raise ValueError( + "strategy plugin artifact notification_target mismatch: " + f"expected {expected_notification_target}, got {notification_target}" + ) if expected_plugin is not None and plugin != expected_plugin: raise ValueError(f"strategy plugin artifact plugin mismatch: expected {expected_plugin}, got {plugin}") if expected_mode is not None: @@ -559,13 +727,20 @@ def validate_strategy_plugin_signal_payload( "strategy plugin artifact mode mismatch: " f"expected {normalized_expected_mode}, got {effective_mode}" ) - validate_strategy_plugin_compatibility( - strategy=strategy, - plugin=plugin, - mode=effective_mode, - plugin_definitions=plugin_definitions, - source="artifact", - ) + if target_type == "strategy": + validate_strategy_plugin_compatibility( + strategy=strategy, + plugin=plugin, + mode=effective_mode, + plugin_definitions=plugin_definitions, + source="artifact", + ) + else: + validate_strategy_plugin_notification_target( + notification_target=notification_target, + plugin=plugin, + source="artifact", + ) schema_version = _optional_string(payload.get("schema_version")) or "" definitions = normalize_strategy_plugin_definitions(plugin_definitions) definition = definitions.get(plugin) @@ -599,6 +774,8 @@ def validate_strategy_plugin_signal_payload( deprecated_plugin=bool(definition.deprecated) if definition is not None else False, successor_plugin=definition.successor_plugin if definition is not None else None, supported_schema_versions=tuple(sorted(definition.supported_schema_versions)) if definition is not None else (), + target_type=target_type, + notification_target=notification_target, ) @@ -853,10 +1030,17 @@ def build_strategy_plugin_alert_key( context_label: str | None = None, namespace: str = "strategy_plugin_alert", ) -> str: + target_label = ( + _optional_key_part(getattr(signal, "strategy", None)) + or _optional_key_part(getattr(signal, "notification_target", None)) + or _optional_key_part(strategy_label) + or "unknown" + ) payload = { "namespace": _optional_key_part(namespace) or "strategy_plugin_alert", "context": _optional_key_part(context_label) or "default", - "strategy": _optional_key_part(getattr(signal, "strategy", None)) or _optional_key_part(strategy_label) or "unknown", + "target_type": _optional_key_part(getattr(signal, "target_type", None)) or "strategy", + "target": target_label, "plugin": _optional_key_part(getattr(signal, "plugin", None)) or "unknown", "mode": _optional_key_part(getattr(signal, "effective_mode", None)) or "unknown", "as_of": _optional_key_part(getattr(signal, "as_of", None)) or "unknown", @@ -871,7 +1055,7 @@ def build_strategy_plugin_alert_key( ( _sanitize_key_part(payload["namespace"]), _sanitize_key_part(payload["context"]), - _sanitize_key_part(payload["strategy"]), + _sanitize_key_part(payload["target"]), _sanitize_key_part(payload["plugin"]), _sanitize_key_part(payload["as_of"]), _sanitize_key_part(payload["route"]), @@ -904,7 +1088,11 @@ def build_strategy_plugin_alert_messages( ) translated_route = translate_strategy_plugin_value("route", route, translator=translator) translated_action = translate_strategy_plugin_value("action", action, translator=translator) - strategy = str(strategy_label or getattr(signal, "strategy", None) or "").strip() or "unknown" + target_type = str(getattr(signal, "target_type", None) or "strategy").strip() + strategy = str(strategy_label or getattr(signal, "strategy", None) or "").strip() + notification_target = str(getattr(signal, "notification_target", None) or "").strip() + target_label = strategy or notification_target or "unknown" + target_name = "Notification target" if target_type == "notification_target" else "Strategy" guidance = build_strategy_plugin_alert_guidance(signal, translator=translator) scope_note = build_strategy_plugin_alert_scope_note(signal, translator=translator) ai_audit_note = build_strategy_plugin_ai_audit_note(signal, translator=translator) @@ -912,7 +1100,7 @@ def build_strategy_plugin_alert_messages( translator, "strategy_plugin_alert_subject", fallback="Strategy plugin alert: {plugin} | {route}", - strategy=strategy, + strategy=target_label, plugin=plugin, route=translated_route, ) @@ -935,9 +1123,10 @@ def build_strategy_plugin_alert_messages( [ _translate( translator, - "strategy_plugin_alert_strategy", - fallback="Strategy: {strategy}", - strategy=strategy, + "strategy_plugin_alert_target", + fallback="{target_name}: {target}", + target_name=target_name, + target=target_label, ), _translate( translator, @@ -992,8 +1181,11 @@ def build_strategy_plugin_alert_messages( ) ) metadata = { + "target_type": target_type, + "target": target_label, "strategy": getattr(signal, "strategy", None), - "strategy_label": strategy, + "strategy_label": strategy or None, + "notification_target": notification_target or None, "plugin": getattr(signal, "plugin", None), "mode": getattr(signal, "effective_mode", None), "as_of": getattr(signal, "as_of", None), @@ -1013,7 +1205,7 @@ def build_strategy_plugin_alert_messages( body="\n".join(body_lines), alert_key=build_strategy_plugin_alert_key( signal, - strategy_label=strategy, + strategy_label=target_label, context_label=context, namespace=alert_namespace, ), diff --git a/tests/test_strategy_plugins.py b/tests/test_strategy_plugins.py index 03fedec..4c3651f 100644 --- a/tests/test_strategy_plugins.py +++ b/tests/test_strategy_plugins.py @@ -8,6 +8,7 @@ from quant_platform_kit.common.strategy_plugins import ( CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES, DEFAULT_STRATEGY_PLUGIN_DEFINITIONS, + GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, MACRO_RISK_GOVERNOR_SUPPORTED_STRATEGIES, PLUGIN_CRISIS_RESPONSE_SHADOW, PLUGIN_MARKET_REGIME_CONTROL, @@ -18,6 +19,7 @@ STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH, STRATEGY_PLUGIN_ALERT_CHANNEL_SMS, STRATEGY_PLUGIN_ALERT_CHANNEL_TELEGRAM, + STRATEGY_PLUGIN_NOTIFICATION_TARGETS, STRATEGY_PLUGIN_SCHEMA_VERSIONS, MARKET_REGIME_CONTROL_SUPPORTED_STRATEGIES, TACO_REBOUND_SHADOW_SUPPORTED_STRATEGIES, @@ -27,11 +29,14 @@ build_strategy_plugin_notification_lines, build_strategy_plugin_report_payload, extract_strategy_plugin_localized_message, + load_configured_strategy_plugin_notification_target_signals, load_configured_strategy_plugin_signals, load_strategy_plugin_signal, + parse_strategy_plugin_notification_targets, parse_strategy_plugin_mounts, should_alert_strategy_plugin_signal, validate_strategy_plugin_compatibility, + validate_strategy_plugin_notification_target, validate_strategy_plugin_schema_version, validate_strategy_plugin_signal_payload, ) @@ -194,6 +199,10 @@ def test_default_plugin_definition_supports_market_regime_control_for_approved_s definition = DEFAULT_STRATEGY_PLUGIN_DEFINITIONS[PLUGIN_MARKET_REGIME_CONTROL] self.assertEqual(definition.supported_strategies, MARKET_REGIME_CONTROL_SUPPORTED_STRATEGIES) + self.assertEqual( + STRATEGY_PLUGIN_NOTIFICATION_TARGETS[PLUGIN_MARKET_REGIME_CONTROL], + frozenset({GENERAL_MARKET_REGIME_NOTIFICATION_TARGET}), + ) self.assertEqual(definition.supported_schema_versions, STRATEGY_PLUGIN_SCHEMA_VERSIONS[PLUGIN_MARKET_REGIME_CONTROL]) self.assertEqual(definition.default_schema_version, "market_regime_control.v1") self.assertFalse(definition.deprecated) @@ -227,6 +236,40 @@ def test_default_plugin_definition_supports_market_regime_control_for_approved_s plugin=PLUGIN_MARKET_REGIME_CONTROL, mode=PLUGIN_MODE_SHADOW, ) + validate_strategy_plugin_notification_target( + notification_target=GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, + plugin=PLUGIN_MARKET_REGIME_CONTROL, + ) + + def test_parse_strategy_plugin_notification_targets_accepts_general_market_regime(self): + targets = parse_strategy_plugin_notification_targets( + { + "notification_targets": [ + { + "notification_target": GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, + "plugin": PLUGIN_MARKET_REGIME_CONTROL, + "signal_path": "gs://bucket/market_regime_notification/latest_signal.json", + "expected_schema_version": "market_regime_control.v1", + } + ] + } + ) + + self.assertEqual(targets[0].notification_target, GENERAL_MARKET_REGIME_NOTIFICATION_TARGET) + self.assertEqual(targets[0].plugin, PLUGIN_MARKET_REGIME_CONTROL) + self.assertEqual(targets[0].expected_schema_version, "market_regime_control.v1") + + def test_parse_strategy_plugin_notification_targets_rejects_strategy_only_target(self): + with self.assertRaisesRegex(ValueError, "does not support notification target"): + parse_strategy_plugin_notification_targets( + [ + { + "notification_target": "soxl_soxx_trend_income", + "plugin": PLUGIN_MARKET_REGIME_CONTROL, + "signal_path": "gs://bucket/market_regime/latest_signal.json", + } + ] + ) def test_parse_strategy_plugin_mounts_rejects_unsupported_crisis_response_strategy(self): raw = [ @@ -569,6 +612,50 @@ def test_strategy_plugin_notification_lines_can_use_artifact_localized_message(s ("需要通知:市场状态风险降低。",), ) + def test_notification_target_signal_loads_without_strategy(self): + payload = { + **_signal_payload(plugin=PLUGIN_MARKET_REGIME_CONTROL), + "target_type": "notification_target", + "notification_target": GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, + } + payload.pop("strategy", None) + + signal = validate_strategy_plugin_signal_payload( + payload, + expected_notification_target=GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, + expected_plugin=PLUGIN_MARKET_REGIME_CONTROL, + ) + + self.assertEqual(signal.strategy, "") + self.assertEqual(signal.target_type, "notification_target") + self.assertEqual(signal.notification_target, GENERAL_MARKET_REGIME_NOTIFICATION_TARGET) + self.assertEqual(signal.report_summary()["notification_target"], GENERAL_MARKET_REGIME_NOTIFICATION_TARGET) + + def test_load_configured_strategy_plugin_notification_target_signals(self): + with tempfile.TemporaryDirectory() as tmp_dir: + signal_path = Path(tmp_dir) / "latest_signal.json" + payload = { + **_signal_payload(plugin=PLUGIN_MARKET_REGIME_CONTROL), + "target_type": "notification_target", + "notification_target": GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, + } + payload.pop("strategy", None) + signal_path.write_text(json.dumps(payload), encoding="utf-8") + targets = parse_strategy_plugin_notification_targets( + [ + { + "notification_target": GENERAL_MARKET_REGIME_NOTIFICATION_TARGET, + "plugin": PLUGIN_MARKET_REGIME_CONTROL, + "signal_path": str(signal_path), + } + ] + ) + + signals = load_configured_strategy_plugin_notification_target_signals(targets) + + self.assertEqual(len(signals), 1) + self.assertEqual(signals[0].notification_target, GENERAL_MARKET_REGIME_NOTIFICATION_TARGET) + def test_strategy_plugin_no_action_signal_does_not_escalate_alert(self): signal = validate_strategy_plugin_signal_payload(_signal_payload())