Skip to content

Commit 5b14894

Browse files
committed
Add plugin consumption policy registry
1 parent 78745c4 commit 5b14894

7 files changed

Lines changed: 270 additions & 23 deletions

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ send notifications; plugin research and signal generation live here.
3535
optional external financial-stress fields. The artifact can expose
3636
`leverage_scalar` and `risk_asset_scalar` to strategy runtimes that explicitly
3737
opt in through mounted metadata. OSINT-style fields such as a Pentagon pizza
38-
index are kept as watch-only evidence and do not contribute to the actionable
39-
trading score.
38+
index, plus sentiment fields such as Fear & Greed, put/call, and safe-haven
39+
demand, are kept as watch-only evidence and do not contribute to the
40+
actionable trading score.
4041
- `market_regime_control`: unified deterministic facade for crisis, macro, and
4142
TACO signals. Only strategies with positive backtest evidence should mount
4243
position controls for automated consumption; SOXL/SOXX currently receives

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac
2525

2626
- `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。
2727
可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。
28-
- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数这类 OSINT 字段只作为 watch-only 证据,不进入可执行分数。
28+
- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand 这类 OSINT 或情绪字段只作为 watch-only 证据,不进入可执行分数。
2929
- `market_regime_control`:统一确定性 facade,汇总 crisis、macro 和 TACO 信号,输出版本化的 `notification``position_control`。只有经过回测证明自动消费有效的策略才挂载仓位控制;SOXL/SOXX 这类未通过统一宏观插件复核的高波动行业杠杆策略只接收通用通知,人工决定是否干预。股票/ETF 轮动策略通过本地风险缩放策略消费;TACO 在统一插件里保持通知-only,并会被危机和宏观降风险路线 veto。设计说明见 [Market Regime Control 统一插件方案](docs/market-regime-control-plan.zh-CN.md)
3030
- `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。
3131
该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
- `crisis_response_shadow`
2727
负责硬危机防守。`true_crisis` 和泡沫脆弱性触发后,统一插件输出 `risk_off` / `defend`,仓位目标交给策略侧 opt-in 执行。
2828
- `macro_risk_governor`
29-
负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced``risk_off`
29+
负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced``risk_off`Fear & Greed、put/call、safe-haven demand 和五角大楼比萨指数只作为 watch-only 证据,先用于通知和回测观察。
3030
- `taco_rebound_shadow`
3131
负责 TQQQ 事件反弹通知。它输出人工复核通知和本地 veto 线索,不直接提高仓位。
3232

@@ -71,13 +71,23 @@
7171

7272
旧插件仍可运行历史回测和兼容输出,但新策略集成应优先挂载 `market_regime_control`
7373

74+
策略插件 runner 使用显式消费权限 registry,而不是只维护松散 allowlist:
75+
76+
- `notification_allowed`:允许生成和分发通知 artifact。
77+
- `position_control_allowed`:允许策略 runtime 自动消费仓位控制字段。
78+
- `evidence_status`:记录该策略/插件组合是 `automation_approved``notification_only` 还是 `deprecated_compatibility`
79+
- `since_version`:记录该消费权限从哪个 runner schema 开始生效。
80+
81+
SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中;它通过 `market_regime_notification` 接收通用通知,避免配置误用把通知信号升级成自动调仓。
82+
7483
## 版本管理
7584

7685
当前对外契约是:
7786

7887
- 统一插件 schema:`market_regime_control.v1`
7988
- 仲裁器 schema:`market_regime_arbiter.v1`
8089
- 运行器总 schema:`strategy_plugins.v1`
90+
- 策略消费权限 schema:随 `strategy_plugins.v1` 通过 `consumption_policy` 输出
8191

8292
升级原则:
8393

src/quant_strategy_plugins/macro_risk_governor_plugin.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ def build_macro_risk_governor_signal(
258258
hy_oas_delta_threshold: float = 1.0,
259259
financial_stress_watch_level: float = 1.0,
260260
pizza_index_watch_level: float = 2.0,
261+
fear_greed_extreme_fear_level: float = 25.0,
262+
put_call_watch_level: float = 1.20,
263+
safe_haven_demand_watch_level: float = 1.0,
261264
watch_score_threshold: float = 3.0,
262265
delever_score_threshold: float = 5.0,
263266
crisis_score_threshold: float = 7.0,
@@ -448,6 +451,53 @@ def build_macro_risk_governor_signal(
448451
threshold=float(pizza_index_watch_level),
449452
actionable=False,
450453
)
454+
fear_greed_index = _external_float(
455+
external_row,
456+
"fear_greed_index",
457+
"fear_and_greed_index",
458+
"cnn_fear_greed_index",
459+
"cnn_fear_and_greed_index",
460+
)
461+
_add_check(
462+
checks,
463+
"fear_greed_extreme_fear_watch",
464+
fear_greed_index is not None and fear_greed_index <= float(fear_greed_extreme_fear_level),
465+
weight=1.0,
466+
value=fear_greed_index,
467+
threshold=float(fear_greed_extreme_fear_level),
468+
actionable=False,
469+
)
470+
put_call_ratio = _external_float(
471+
external_row,
472+
"put_call_ratio",
473+
"equity_put_call_ratio",
474+
"cboe_put_call_ratio",
475+
"put_call",
476+
)
477+
_add_check(
478+
checks,
479+
"put_call_stress_watch",
480+
put_call_ratio is not None and put_call_ratio >= float(put_call_watch_level),
481+
weight=1.0,
482+
value=put_call_ratio,
483+
threshold=float(put_call_watch_level),
484+
actionable=False,
485+
)
486+
safe_haven_demand = _external_float(
487+
external_row,
488+
"safe_haven_demand",
489+
"safe_haven_demand_index",
490+
"safe_haven_demand_zscore",
491+
)
492+
_add_check(
493+
checks,
494+
"safe_haven_demand_watch",
495+
safe_haven_demand is not None and safe_haven_demand >= float(safe_haven_demand_watch_level),
496+
weight=1.0,
497+
value=safe_haven_demand,
498+
threshold=float(safe_haven_demand_watch_level),
499+
actionable=False,
500+
)
451501
realized_vol_confirmed_for_action = None
452502
realized_vol_check = checks.get("benchmark_realized_volatility_high")
453503
if realized_vol_check is not None:
@@ -476,6 +526,9 @@ def build_macro_risk_governor_signal(
476526
"hy_oas_delta_63d": hy_oas_delta,
477527
"financial_stress": financial_stress,
478528
"pentagon_pizza_index": pizza_index,
529+
"fear_greed_index": fear_greed_index,
530+
"put_call_ratio": put_call_ratio,
531+
"safe_haven_demand": safe_haven_demand,
479532
"benchmark_realized_volatility_requires_confirmation": bool(realized_vol_requires_confirmation),
480533
"benchmark_realized_volatility_confirmed_for_action": realized_vol_confirmed_for_action,
481534
}

src/quant_strategy_plugins/strategy_plugin_runner.py

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,9 @@
3939
PLUGIN_MACRO_RISK_GOVERNOR = MACRO_RISK_GOVERNOR_PROFILE
4040
PLUGIN_TACO_REBOUND_SHADOW = TACO_REBOUND_PROFILE
4141
SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,)
42-
PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = {
43-
PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income",),
44-
PLUGIN_MARKET_REGIME_CONTROL: (
45-
GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY,
46-
"tqqq_growth_income",
47-
"global_etf_rotation",
48-
"russell_1000_multi_factor_defensive",
49-
"tech_communication_pullback_enhancement",
50-
"mega_cap_leader_rotation_top50_balanced",
51-
),
52-
PLUGIN_MACRO_RISK_GOVERNOR: ("tqqq_growth_income",),
53-
PLUGIN_TACO_REBOUND_SHADOW: ("tqqq_growth_income",),
54-
}
42+
EVIDENCE_AUTOMATION_APPROVED = "automation_approved"
43+
EVIDENCE_NOTIFICATION_ONLY = "notification_only"
44+
EVIDENCE_DEPRECATED_COMPATIBILITY = "deprecated_compatibility"
5545
PLUGIN_SCHEMA_VERSIONS: dict[str, tuple[str, ...]] = {
5646
PLUGIN_CRISIS_RESPONSE_SHADOW: ("crisis_response_shadow.v1",),
5747
PLUGIN_MARKET_REGIME_CONTROL: ("market_regime_control.v1",),
@@ -79,6 +69,115 @@ class PluginRunResult:
7969
message: str = ""
8070

8171

72+
@dataclass(frozen=True)
73+
class PluginConsumptionPolicy:
74+
plugin: str
75+
strategy: str
76+
notification_allowed: bool
77+
position_control_allowed: bool
78+
evidence_status: str
79+
since_version: str
80+
description: str
81+
intended_strategy_role: str | None = None
82+
83+
84+
PLUGIN_CONSUMPTION_POLICIES: tuple[PluginConsumptionPolicy, ...] = (
85+
PluginConsumptionPolicy(
86+
plugin=PLUGIN_MARKET_REGIME_CONTROL,
87+
strategy=GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY,
88+
notification_allowed=True,
89+
position_control_allowed=False,
90+
evidence_status=EVIDENCE_NOTIFICATION_ONLY,
91+
since_version="strategy_plugins.v1",
92+
description="General market-regime notice. Not mounted into an automated strategy runtime.",
93+
intended_strategy_role="general_market_regime_notification",
94+
),
95+
PluginConsumptionPolicy(
96+
plugin=PLUGIN_MARKET_REGIME_CONTROL,
97+
strategy="tqqq_growth_income",
98+
notification_allowed=True,
99+
position_control_allowed=True,
100+
evidence_status=EVIDENCE_AUTOMATION_APPROVED,
101+
since_version="strategy_plugins.v1",
102+
description="Backtested automatic macro/crisis risk controls for the TQQQ growth-income strategy.",
103+
),
104+
PluginConsumptionPolicy(
105+
plugin=PLUGIN_MARKET_REGIME_CONTROL,
106+
strategy="global_etf_rotation",
107+
notification_allowed=True,
108+
position_control_allowed=True,
109+
evidence_status=EVIDENCE_AUTOMATION_APPROVED,
110+
since_version="strategy_plugins.v1",
111+
description="Local risk-scaling consumer for broad ETF rotation.",
112+
),
113+
PluginConsumptionPolicy(
114+
plugin=PLUGIN_MARKET_REGIME_CONTROL,
115+
strategy="russell_1000_multi_factor_defensive",
116+
notification_allowed=True,
117+
position_control_allowed=True,
118+
evidence_status=EVIDENCE_AUTOMATION_APPROVED,
119+
since_version="strategy_plugins.v1",
120+
description="Local risk-scaling consumer for the Russell 1000 defensive sleeve.",
121+
),
122+
PluginConsumptionPolicy(
123+
plugin=PLUGIN_MARKET_REGIME_CONTROL,
124+
strategy="tech_communication_pullback_enhancement",
125+
notification_allowed=True,
126+
position_control_allowed=True,
127+
evidence_status=EVIDENCE_AUTOMATION_APPROVED,
128+
since_version="strategy_plugins.v1",
129+
description="Local risk-scaling consumer for the tech/communication pullback profile.",
130+
),
131+
PluginConsumptionPolicy(
132+
plugin=PLUGIN_MARKET_REGIME_CONTROL,
133+
strategy="mega_cap_leader_rotation_top50_balanced",
134+
notification_allowed=True,
135+
position_control_allowed=True,
136+
evidence_status=EVIDENCE_AUTOMATION_APPROVED,
137+
since_version="strategy_plugins.v1",
138+
description="Local risk-scaling consumer for the mega-cap leader rotation profile.",
139+
),
140+
PluginConsumptionPolicy(
141+
plugin=PLUGIN_CRISIS_RESPONSE_SHADOW,
142+
strategy="tqqq_growth_income",
143+
notification_allowed=True,
144+
position_control_allowed=False,
145+
evidence_status=EVIDENCE_DEPRECATED_COMPATIBILITY,
146+
since_version="strategy_plugins.v1",
147+
description="Deprecated direct crisis shadow mount kept for historical replay; new consumers use market_regime_control.",
148+
),
149+
PluginConsumptionPolicy(
150+
plugin=PLUGIN_MACRO_RISK_GOVERNOR,
151+
strategy="tqqq_growth_income",
152+
notification_allowed=True,
153+
position_control_allowed=False,
154+
evidence_status=EVIDENCE_DEPRECATED_COMPATIBILITY,
155+
since_version="strategy_plugins.v1",
156+
description="Deprecated direct macro governor mount kept for historical replay; new consumers use market_regime_control.",
157+
),
158+
PluginConsumptionPolicy(
159+
plugin=PLUGIN_TACO_REBOUND_SHADOW,
160+
strategy="tqqq_growth_income",
161+
notification_allowed=True,
162+
position_control_allowed=False,
163+
evidence_status=EVIDENCE_NOTIFICATION_ONLY,
164+
since_version="strategy_plugins.v1",
165+
description="Manual-review event rebound notifier for TQQQ only.",
166+
),
167+
)
168+
PLUGIN_CONSUMPTION_POLICY_REGISTRY: dict[tuple[str, str], PluginConsumptionPolicy] = {
169+
(policy.plugin, policy.strategy): policy for policy in PLUGIN_CONSUMPTION_POLICIES
170+
}
171+
PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = {
172+
plugin: tuple(
173+
policy.strategy
174+
for policy in PLUGIN_CONSUMPTION_POLICIES
175+
if policy.plugin == plugin and policy.notification_allowed
176+
)
177+
for plugin in sorted({policy.plugin for policy in PLUGIN_CONSUMPTION_POLICIES})
178+
}
179+
180+
82181
PluginRunner = Callable[[Mapping[str, Any], str], PluginRunResult]
83182
PluginPayloadBuilder = Callable[[pd.DataFrame, Mapping[str, Any]], dict[str, Any]]
84183
PluginOutputWriter = Callable[[Mapping[str, Any], str | Path], Mapping[str, Path]]
@@ -159,14 +258,19 @@ def _validate_plugin_strategy(plugin_name: str, strategy: str) -> None:
159258
raise ValueError(
160259
f"{plugin_name} is research-only and cannot be mounted to {strategy!r}: {research_only_reason}"
161260
)
162-
compatible = PLUGIN_COMPATIBLE_STRATEGIES.get(plugin_name, ())
163-
if compatible and strategy not in compatible:
164-
choices = ", ".join(compatible)
261+
policy = PLUGIN_CONSUMPTION_POLICY_REGISTRY.get((plugin_name, strategy))
262+
if policy is None or not policy.notification_allowed:
263+
compatible = PLUGIN_COMPATIBLE_STRATEGIES.get(plugin_name, ())
264+
choices = ", ".join(compatible) if compatible else "(none)"
165265
raise ValueError(
166266
f"{plugin_name} is strategy-limited and can only be mounted to: {choices}; got strategy={strategy!r}"
167267
)
168268

169269

270+
def _plugin_consumption_policy(plugin_name: str, strategy: str) -> PluginConsumptionPolicy | None:
271+
return PLUGIN_CONSUMPTION_POLICY_REGISTRY.get((plugin_name, strategy))
272+
273+
170274
def _flatten_strategy_plugin_entry(entry: Mapping[str, Any]) -> dict[str, Any]:
171275
plugin_config = {
172276
key: value
@@ -333,6 +437,9 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[
333437
"hy_oas_delta_threshold",
334438
"financial_stress_watch_level",
335439
"pizza_index_watch_level",
440+
"fear_greed_extreme_fear_level",
441+
"put_call_watch_level",
442+
"safe_haven_demand_watch_level",
336443
"watch_score_threshold",
337444
"delever_score_threshold",
338445
"crisis_score_threshold",
@@ -389,21 +496,34 @@ def _mode_execution_controls(mode: str) -> dict[str, Any]:
389496
raise ValueError(f"unsupported plugin mode: {mode!r}") from exc
390497

391498

392-
def _apply_plugin_contract(payload: Mapping[str, Any], *, strategy: str, plugin: str, mode: str) -> dict[str, Any]:
499+
def _apply_plugin_contract(
500+
payload: Mapping[str, Any],
501+
*,
502+
strategy: str,
503+
plugin: str,
504+
mode: str,
505+
consumption_policy: PluginConsumptionPolicy | None = None,
506+
) -> dict[str, Any]:
393507
contracted_payload = dict(payload)
394508
contracted_payload["strategy"] = strategy
395509
contracted_payload["plugin"] = plugin
396510
contracted_payload["mode"] = mode
397511
contracted_payload["configured_mode"] = mode
398512
contracted_payload["effective_mode"] = mode
513+
if consumption_policy is not None:
514+
contracted_payload["consumption_policy"] = asdict(consumption_policy)
399515

400516
execution_controls = dict(contracted_payload.get("execution_controls") or {})
401517
execution_controls.update(_mode_execution_controls(mode))
402518
execution_controls["configured_mode"] = mode
403519
execution_controls["effective_mode"] = mode
404520
execution_controls["repository_broker_write_allowed"] = False
405521
execution_controls["repository_allocation_mutation_allowed"] = False
406-
if strategy == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY:
522+
if consumption_policy is not None:
523+
execution_controls["notification_allowed"] = bool(consumption_policy.notification_allowed)
524+
execution_controls["position_control_allowed"] = bool(consumption_policy.position_control_allowed)
525+
execution_controls["consumption_evidence_status"] = consumption_policy.evidence_status
526+
if consumption_policy is not None and consumption_policy.intended_strategy_role == "general_market_regime_notification":
407527
execution_controls["capital_impact"] = "notification_only"
408528
execution_controls["strategy_runtime_metadata_allowed"] = False
409529
execution_controls["position_control_shadow_only"] = True
@@ -482,12 +602,20 @@ def _run_table_strategy_plugin(
482602
message="plugin disabled",
483603
)
484604
_validate_plugin_mode(plugin, mode)
605+
_validate_plugin_strategy(plugin, strategy)
606+
consumption_policy = _plugin_consumption_policy(plugin, strategy)
485607

486608
prices_path = str(plugin_config.get("prices", "")).strip()
487609
if not prices_path:
488610
raise ValueError(f"{plugin} for strategy={strategy} requires a prices path")
489611
payload = spec.build_payload(read_table(prices_path), plugin_config)
490-
payload = _apply_plugin_contract(payload, strategy=strategy, plugin=plugin, mode=mode)
612+
payload = _apply_plugin_contract(
613+
payload,
614+
strategy=strategy,
615+
plugin=plugin,
616+
mode=mode,
617+
consumption_policy=consumption_policy,
618+
)
491619
paths = spec.write_outputs(payload, output_dir)
492620
return PluginRunResult(
493621
strategy=strategy,
@@ -640,9 +768,12 @@ def main(argv: list[str] | None = None) -> int:
640768
"PLUGIN_MACRO_RISK_GOVERNOR",
641769
"PLUGIN_TACO_REBOUND_SHADOW",
642770
"PLUGIN_COMPATIBLE_STRATEGIES",
771+
"PLUGIN_CONSUMPTION_POLICIES",
772+
"PLUGIN_CONSUMPTION_POLICY_REGISTRY",
643773
"PLUGIN_DEPRECATED_SUCCESSORS",
644774
"PLUGIN_RESEARCH_ONLY_REASONS",
645775
"PLUGIN_SCHEMA_VERSIONS",
776+
"PluginConsumptionPolicy",
646777
"PluginRunResult",
647778
"load_plugin_config",
648779
"main",

0 commit comments

Comments
 (0)