Skip to content

Commit 4305a3c

Browse files
authored
Merge pull request #50 from QuantStrategyLab/plugin-definition-registry
Add strategy plugin definition registry
2 parents 8e87e70 + 3c10858 commit 4305a3c

4 files changed

Lines changed: 242 additions & 6 deletions

File tree

docs/strategy_plugin_runtime_contract.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,23 @@ Do not put `mode` in the platform mount config. `expected_mode` may be used only
5757
as a fail-closed guard and should be `shadow` when present. Artifacts declaring
5858
`paper`, `advisory`, or `live` are rejected.
5959

60+
## Plugin Definitions
61+
62+
The shared kit owns plugin compatibility through a registry-style
63+
`StrategyPluginDefinition`. Platform repos should not hard-code which strategies
64+
a plugin supports; they should call the shared parser/loader and let it reject
65+
unsupported mounts or artifacts.
66+
67+
The default registry currently defines:
68+
69+
| Plugin | Supported strategies | Supported mode | Escalated alert channel |
70+
| --- | --- | --- | --- |
71+
| `crisis_response_shadow` | `tqqq_growth_income`, `soxl_soxx_trend_income` | `shadow` | `google_voice` |
72+
73+
To expand a plugin later, update the shared definition or pass an explicit
74+
definition registry into the parser/loader. This keeps future plugin eligibility
75+
changes out of platform runtime code.
76+
6077
## Runtime Loader
6178

6279
Use `quant_platform_kit.common.strategy_plugins`:

src/quant_platform_kit/common/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,16 @@
3939
resolve_runtime_target_from_env,
4040
)
4141
from .strategy_plugins import (
42+
CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES,
43+
DEFAULT_STRATEGY_PLUGIN_DEFINITIONS,
44+
PLUGIN_CRISIS_RESPONSE_SHADOW,
4245
PLUGIN_MODE_SHADOW,
46+
STRATEGY_PLUGIN_ALERT_CHANNEL_GOOGLE_VOICE,
4347
STRATEGY_PLUGIN_ALERT_ACTIONS,
4448
STRATEGY_PLUGIN_NON_ALERT_ROUTES,
4549
SUPPORTED_STRATEGY_PLUGIN_MODES,
4650
StrategyPluginAlertMessage,
51+
StrategyPluginDefinition,
4752
StrategyPluginMountConfig,
4853
StrategyPluginSignal,
4954
build_strategy_plugin_alert_key,
@@ -52,18 +57,23 @@
5257
build_strategy_plugin_report_payload,
5358
load_configured_strategy_plugin_signals,
5459
load_strategy_plugin_signal,
60+
normalize_strategy_plugin_definitions,
5561
normalize_strategy_plugin_mode,
5662
parse_strategy_plugin_mounts,
5763
should_alert_strategy_plugin_signal,
5864
translate_strategy_plugin_value,
65+
validate_strategy_plugin_compatibility,
5966
validate_strategy_plugin_signal_payload,
6067
)
6168

6269
__all__ = [
6370
"COMMON_ZH_NOTIFICATION_REPLACEMENTS",
71+
"CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES",
6472
"DEFAULT_EXECUTION_BLOCKING_SKIP_REASONS",
73+
"DEFAULT_STRATEGY_PLUGIN_DEFINITIONS",
6574
"DEFAULT_TERMINAL_FUNDING_BLOCK_SKIP_REASONS",
6675
"DEFAULT_TERMINAL_STRATEGY_RUN_STAGES",
76+
"PLUGIN_CRISIS_RESPONSE_SHADOW",
6777
"PLUGIN_MODE_SHADOW",
6878
"STAGE_COMPLETED",
6979
"STAGE_DRY_RUN_COMPLETED",
@@ -74,6 +84,7 @@
7484
"STAGE_PARTIAL_SUBMITTED",
7585
"STAGE_RECONCILED",
7686
"STAGE_SUBMITTED",
87+
"STRATEGY_PLUGIN_ALERT_CHANNEL_GOOGLE_VOICE",
7788
"STRATEGY_PLUGIN_ALERT_ACTIONS",
7889
"STRATEGY_PLUGIN_NON_ALERT_ROUTES",
7990
"SUPPORTED_STRATEGY_PLUGIN_MODES",
@@ -93,6 +104,7 @@
93104
"RuntimeAssembly",
94105
"build_runtime_assembly",
95106
"StrategyPluginAlertMessage",
107+
"StrategyPluginDefinition",
96108
"StrategyPluginMountConfig",
97109
"StrategyPluginSignal",
98110
"build_strategy_plugin_alert_key",
@@ -102,11 +114,13 @@
102114
"build_runtime_target",
103115
"load_configured_strategy_plugin_signals",
104116
"load_strategy_plugin_signal",
117+
"normalize_strategy_plugin_definitions",
105118
"normalize_strategy_plugin_mode",
106119
"parse_strategy_plugin_mounts",
107120
"resolve_runtime_target_from_env",
108121
"should_alert_strategy_plugin_signal",
109122
"translate_strategy_plugin_value",
110123
"translator_uses_zh",
124+
"validate_strategy_plugin_compatibility",
111125
"validate_strategy_plugin_signal_payload",
112126
]

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,66 @@
1010
from pathlib import Path
1111
from typing import Any, Callable
1212

13+
PLUGIN_CRISIS_RESPONSE_SHADOW = "crisis_response_shadow"
1314
PLUGIN_MODE_SHADOW = "shadow"
15+
STRATEGY_PLUGIN_ALERT_CHANNEL_GOOGLE_VOICE = "google_voice"
1416
SUPPORTED_STRATEGY_PLUGIN_MODES = frozenset({PLUGIN_MODE_SHADOW})
1517
DEFAULT_PLUGIN_ARTIFACT_CACHE_DIR = Path(tempfile.gettempdir()) / "quant_strategy_plugin_artifacts"
1618
STRATEGY_PLUGIN_NON_ALERT_ROUTES = frozenset({"no_action"})
1719
STRATEGY_PLUGIN_ALERT_ACTIONS = frozenset({"defend", "blocked"})
20+
CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES = frozenset(
21+
{
22+
"tqqq_growth_income",
23+
"soxl_soxx_trend_income",
24+
}
25+
)
26+
27+
28+
@dataclass(frozen=True)
29+
class StrategyPluginDefinition:
30+
plugin: str
31+
supported_strategies: frozenset[str] | None = None
32+
supported_modes: frozenset[str] = field(default_factory=lambda: SUPPORTED_STRATEGY_PLUGIN_MODES)
33+
alert_channels: tuple[str, ...] = ()
34+
35+
def normalized(self) -> "StrategyPluginDefinition":
36+
plugin = _required_string(self.plugin, field_name="plugin")
37+
supported_strategies = (
38+
frozenset(_required_string(strategy, field_name="supported_strategy") for strategy in self.supported_strategies)
39+
if self.supported_strategies is not None
40+
else None
41+
)
42+
supported_modes = frozenset(
43+
normalize_strategy_plugin_mode(mode, field_name="supported_mode")
44+
for mode in self.supported_modes
45+
)
46+
if not supported_modes:
47+
raise ValueError(f"strategy plugin definition for {plugin} must include at least one supported mode")
48+
alert_channels = tuple(
49+
_required_string(channel, field_name="alert_channel")
50+
for channel in self.alert_channels
51+
)
52+
return StrategyPluginDefinition(
53+
plugin=plugin,
54+
supported_strategies=supported_strategies,
55+
supported_modes=supported_modes,
56+
alert_channels=alert_channels,
57+
)
58+
59+
def supports_strategy(self, strategy: str) -> bool:
60+
if self.supported_strategies is None:
61+
return True
62+
return strategy in self.supported_strategies
63+
64+
65+
DEFAULT_STRATEGY_PLUGIN_DEFINITIONS: Mapping[str, StrategyPluginDefinition] = {
66+
PLUGIN_CRISIS_RESPONSE_SHADOW: StrategyPluginDefinition(
67+
plugin=PLUGIN_CRISIS_RESPONSE_SHADOW,
68+
supported_strategies=CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES,
69+
supported_modes=SUPPORTED_STRATEGY_PLUGIN_MODES,
70+
alert_channels=(STRATEGY_PLUGIN_ALERT_CHANNEL_GOOGLE_VOICE,),
71+
)
72+
}
1873

1974

2075
@dataclass(frozen=True)
@@ -77,11 +132,66 @@ def normalize_strategy_plugin_mode(value: Any, *, field_name: str = "mode") -> s
77132
return mode
78133

79134

135+
def normalize_strategy_plugin_definitions(
136+
plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None,
137+
) -> Mapping[str, StrategyPluginDefinition]:
138+
raw_definitions = (
139+
DEFAULT_STRATEGY_PLUGIN_DEFINITIONS.values()
140+
if plugin_definitions is None
141+
else plugin_definitions.values()
142+
if isinstance(plugin_definitions, Mapping)
143+
else plugin_definitions
144+
)
145+
definitions: dict[str, StrategyPluginDefinition] = {}
146+
for definition in raw_definitions:
147+
if not isinstance(definition, StrategyPluginDefinition):
148+
raise TypeError("strategy plugin definitions must be StrategyPluginDefinition objects")
149+
normalized = definition.normalized()
150+
if normalized.plugin in definitions:
151+
raise ValueError(f"duplicate strategy plugin definition: plugin={normalized.plugin}")
152+
definitions[normalized.plugin] = normalized
153+
return definitions
154+
155+
156+
def validate_strategy_plugin_compatibility(
157+
*,
158+
strategy: str,
159+
plugin: str,
160+
mode: str | None = None,
161+
plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None,
162+
source: str = "plugin",
163+
) -> None:
164+
strategy_name = _required_string(strategy, field_name="strategy")
165+
plugin_name = _required_string(plugin, field_name="plugin")
166+
definitions = normalize_strategy_plugin_definitions(plugin_definitions)
167+
definition = definitions.get(plugin_name)
168+
if definition is None:
169+
return
170+
if not definition.supports_strategy(strategy_name):
171+
allowed = ", ".join(sorted(definition.supported_strategies or ())) or "any"
172+
raise ValueError(
173+
f"strategy plugin {plugin_name} does not support strategy {strategy_name} "
174+
f"in {source}; supported strategies: {allowed}"
175+
)
176+
if mode is None:
177+
return
178+
mode_name = normalize_strategy_plugin_mode(mode, field_name="mode")
179+
if mode_name not in definition.supported_modes:
180+
allowed_modes = ", ".join(sorted(definition.supported_modes))
181+
raise ValueError(
182+
f"strategy plugin {plugin_name} does not support mode {mode_name} "
183+
f"in {source}; supported modes: {allowed_modes}"
184+
)
185+
186+
80187
def parse_strategy_plugin_mounts(
81188
raw_config: str | Sequence[Mapping[str, Any]] | Mapping[str, Any] | None,
189+
*,
190+
plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None,
82191
) -> tuple[StrategyPluginMountConfig, ...]:
83192
if raw_config is None or raw_config == "":
84193
return ()
194+
definitions = normalize_strategy_plugin_definitions(plugin_definitions)
85195
payload: Any
86196
if isinstance(raw_config, str):
87197
payload = json.loads(raw_config)
@@ -111,17 +221,25 @@ def parse_strategy_plugin_mounts(
111221
raise ValueError(f"duplicate strategy plugin mount: strategy={strategy} plugin={plugin}")
112222
seen.add(key)
113223
expected_mode = item.get("expected_mode")
224+
normalized_expected_mode = (
225+
normalize_strategy_plugin_mode(expected_mode, field_name="expected_mode")
226+
if expected_mode is not None
227+
else None
228+
)
229+
validate_strategy_plugin_compatibility(
230+
strategy=strategy,
231+
plugin=plugin,
232+
mode=normalized_expected_mode,
233+
plugin_definitions=definitions,
234+
source="mount",
235+
)
114236
mounts.append(
115237
StrategyPluginMountConfig(
116238
strategy=strategy,
117239
plugin=plugin,
118240
signal_path=signal_path,
119241
enabled=_as_bool(item.get("enabled"), default=True),
120-
expected_mode=(
121-
normalize_strategy_plugin_mode(expected_mode, field_name="expected_mode")
122-
if expected_mode is not None
123-
else None
124-
),
242+
expected_mode=normalized_expected_mode,
125243
)
126244
)
127245
return tuple(mounts)
@@ -132,21 +250,31 @@ def load_configured_strategy_plugin_signals(
132250
*,
133251
strategy_profile: str | None = None,
134252
client_factory: Any = None,
253+
plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None,
135254
) -> tuple[StrategyPluginSignal, ...]:
136255
selected_strategy = _optional_string(strategy_profile)
256+
definitions = normalize_strategy_plugin_definitions(plugin_definitions)
137257
signals: list[StrategyPluginSignal] = []
138258
for mount in mounts:
139259
if not mount.enabled:
140260
continue
141261
if selected_strategy is not None and mount.strategy != selected_strategy:
142262
continue
263+
validate_strategy_plugin_compatibility(
264+
strategy=mount.strategy,
265+
plugin=mount.plugin,
266+
mode=mount.expected_mode,
267+
plugin_definitions=definitions,
268+
source="mount",
269+
)
143270
signals.append(
144271
load_strategy_plugin_signal(
145272
mount.signal_path,
146273
expected_strategy=mount.strategy,
147274
expected_plugin=mount.plugin,
148275
expected_mode=mount.expected_mode,
149276
client_factory=client_factory,
277+
plugin_definitions=definitions,
150278
)
151279
)
152280
return tuple(signals)
@@ -159,6 +287,7 @@ def load_strategy_plugin_signal(
159287
expected_plugin: str | None = None,
160288
expected_mode: str | None = None,
161289
client_factory: Any = None,
290+
plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None,
162291
) -> StrategyPluginSignal:
163292
local_path, metadata = _materialize_artifact_path(reference, client_factory=client_factory)
164293
if not local_path.exists():
@@ -173,6 +302,7 @@ def load_strategy_plugin_signal(
173302
expected_mode=expected_mode,
174303
source_uri=metadata.get("source_uri"),
175304
local_path=str(local_path),
305+
plugin_definitions=plugin_definitions,
176306
)
177307

178308

@@ -184,6 +314,7 @@ def validate_strategy_plugin_signal_payload(
184314
expected_mode: str | None = None,
185315
source_uri: str | None = None,
186316
local_path: str | None = None,
317+
plugin_definitions: Mapping[str, StrategyPluginDefinition] | Sequence[StrategyPluginDefinition] | None = None,
187318
) -> StrategyPluginSignal:
188319
strategy = _required_string(payload.get("strategy"), field_name="strategy")
189320
plugin = _required_string(payload.get("plugin"), field_name="plugin")
@@ -210,6 +341,13 @@ def validate_strategy_plugin_signal_payload(
210341
"strategy plugin artifact mode mismatch: "
211342
f"expected {normalized_expected_mode}, got {effective_mode}"
212343
)
344+
validate_strategy_plugin_compatibility(
345+
strategy=strategy,
346+
plugin=plugin,
347+
mode=effective_mode,
348+
plugin_definitions=plugin_definitions,
349+
source="artifact",
350+
)
213351

214352
execution_controls = payload.get("execution_controls") or {}
215353
if not isinstance(execution_controls, Mapping):

0 commit comments

Comments
 (0)