From 965a01faedc1779c3b272e70551db0963a61fbc4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 28 May 2026 20:04:49 +0800 Subject: [PATCH 01/11] Add unified market regime control plugin --- README.md | 7 + README.zh-CN.md | 1 + docs/examples/strategy_plugins.example.toml | 48 ++ pyproject.toml | 1 + src/quant_strategy_plugins/__init__.py | 20 + .../macro_risk_governor_plugin.py | 692 ++++++++++++++++++ .../market_regime_control_plugin.py | 415 +++++++++++ .../strategy_plugin_runner.py | 115 +++ tests/test_macro_risk_governor_plugin.py | 122 +++ tests/test_market_regime_control_plugin.py | 120 +++ tests/test_strategy_plugin_runner.py | 104 +++ 11 files changed, 1645 insertions(+) create mode 100644 src/quant_strategy_plugins/macro_risk_governor_plugin.py create mode 100644 src/quant_strategy_plugins/market_regime_control_plugin.py create mode 100644 tests/test_macro_risk_governor_plugin.py create mode 100644 tests/test_market_regime_control_plugin.py diff --git a/README.md b/README.md index 6792d67..566d9ca 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,13 @@ send notifications; plugin research and signal generation live here. gaps only, never overrides the deterministic route, places orders, or changes allocations. Local Codex is tried first when enabled; OpenAI-compatible and Anthropic fallback endpoints can be configured. +- `macro_risk_governor`: deterministic macro de-leveraging governor for TQQQ. + It scores price trend, realized volatility, VIX, credit-pair stress, and + optional external financial-stress fields. The artifact can expose + `leverage_scalar` and `risk_asset_scalar` to strategy runtimes that explicitly + opt in through mounted metadata. OSINT-style fields such as a Pentagon pizza + index are kept as watch-only evidence and do not contribute to the actionable + trading score. - `taco_rebound_shadow`: TQQQ-only event-rebound context notifier. It writes manual-review artifacts and never recommends position size or changes allocations. Softening/de-escalation events stay watch-only until post-event diff --git a/README.zh-CN.md b/README.zh-CN.md index c78050d..c24918b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,6 +25,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac - `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。 可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。 +- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数这类 OSINT 字段只作为 watch-only 证据,不进入可执行分数。 - `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。 该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。 - TACO panic-rebound 研究、组合回测和 overlay 对比也归属本仓库;snapshot pipeline 仓库只保留兼容入口。 diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index e6e3498..037882d 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -33,6 +33,25 @@ ai_audit_model = "gpt-5.4-mini" [strategy_plugins.outputs] output_dir = "data/output/tqqq_growth_income/plugins/crisis_response_shadow" +[[strategy_plugins]] +strategy = "tqqq_growth_income" +plugin = "macro_risk_governor" +enabled = true +# Deterministic macro de-leveraging governor. VIX/credit/price stress can emit +# leverage_scalar and risk_asset_scalar for strategies that explicitly consume +# mounted plugin metadata. OSINT fields remain watch-only evidence. + +[strategy_plugins.inputs] +prices = "data/output/macro_risk_governor/input/price_history.csv" +external_context = "data/output/macro_risk_governor/input/external_context.csv" +benchmark_symbol = "QQQ" +attack_symbol = "TQQQ" +vix_symbols = ["VIX", "^VIX", "VIXCLS"] +credit_pairs = ["HYG:IEF", "LQD:IEF"] + +[strategy_plugins.outputs] +output_dir = "data/output/tqqq_growth_income/plugins/macro_risk_governor" + [[strategy_plugins]] strategy = "tqqq_growth_income" plugin = "taco_rebound_shadow" @@ -54,3 +73,32 @@ ai_audit_model = "gpt-5.4-mini" [strategy_plugins.outputs] output_dir = "data/output/tqqq_growth_income/plugins/taco_rebound_shadow" + +[[strategy_plugins]] +strategy = "tqqq_growth_income" +plugin = "market_regime_control" +enabled = true +# Unified facade over crisis_response_shadow, macro_risk_governor, and +# taco_rebound_shadow. It emits one deterministic arbiter result with two +# exits: notification and strategy-opt-in position_control. Broker writes and +# live allocation mutation remain disabled in shadow mode. + +[strategy_plugins.inputs] +prices = "data/output/market_regime_control/input/price_history.csv" +external_context = "data/output/market_regime_control/input/external_context.csv" +event_set = "geopolitical-deescalation" +benchmark_symbol = "QQQ" +attack_symbol = "TQQQ" +vix_symbols = ["VIX", "^VIX", "VIXCLS"] +credit_pairs = ["HYG:IEF", "LQD:IEF"] +financial_symbols = ["XLF", "KRE"] +rate_symbols = ["IEF", "TLT"] +strategy_policy = "levered_growth_income_v1" +taco_opportunity_size_scalar = 0.0 +# Component toggles support staged shadow rollout. +crisis_enabled = true +macro_enabled = true +taco_enabled = true + +[strategy_plugins.outputs] +output_dir = "data/output/tqqq_growth_income/plugins/market_regime_control" diff --git a/pyproject.toml b/pyproject.toml index 4598236..00fe9ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ test = ["pytest>=8", "ruff>=0.8"] [project.scripts] qsp-build-crisis-response-shadow-signal = "quant_strategy_plugins.crisis_response_shadow_plugin:main" +qsp-build-macro-risk-governor-signal = "quant_strategy_plugins.macro_risk_governor_plugin:main" qsp-build-taco-rebound-shadow-signal = "quant_strategy_plugins.taco_rebound_shadow_plugin:main" qsp-run-strategy-plugins = "quant_strategy_plugins.strategy_plugin_runner:main" diff --git a/src/quant_strategy_plugins/__init__.py b/src/quant_strategy_plugins/__init__.py index 7f8a5f0..b7c69a9 100644 --- a/src/quant_strategy_plugins/__init__.py +++ b/src/quant_strategy_plugins/__init__.py @@ -6,6 +6,18 @@ build_crisis_response_shadow_signal, write_crisis_response_shadow_outputs, ) +from .macro_risk_governor_plugin import ( + MACRO_RISK_GOVERNOR_PROFILE, + SCHEMA_VERSION as MACRO_RISK_GOVERNOR_SCHEMA_VERSION, + build_macro_risk_governor_signal, + write_macro_risk_governor_outputs, +) +from .market_regime_control_plugin import ( + MARKET_REGIME_CONTROL_PROFILE, + SCHEMA_VERSION as MARKET_REGIME_CONTROL_SCHEMA_VERSION, + build_market_regime_control_signal, + write_market_regime_control_outputs, +) from .strategy_plugin_runner import run_configured_plugins from .taco_rebound_shadow_plugin import ( SCHEMA_VERSION as TACO_REBOUND_SHADOW_SCHEMA_VERSION, @@ -17,11 +29,19 @@ __all__ = [ "CRISIS_RESPONSE_SHADOW_PROFILE", "CRISIS_RESPONSE_SHADOW_SCHEMA_VERSION", + "MACRO_RISK_GOVERNOR_PROFILE", + "MACRO_RISK_GOVERNOR_SCHEMA_VERSION", + "MARKET_REGIME_CONTROL_PROFILE", + "MARKET_REGIME_CONTROL_SCHEMA_VERSION", "TACO_REBOUND_PROFILE", "TACO_REBOUND_SHADOW_SCHEMA_VERSION", "build_crisis_response_shadow_signal", + "build_macro_risk_governor_signal", + "build_market_regime_control_signal", "build_taco_rebound_shadow_signal", "run_configured_plugins", "write_crisis_response_shadow_outputs", + "write_macro_risk_governor_outputs", + "write_market_regime_control_outputs", "write_taco_rebound_shadow_outputs", ] diff --git a/src/quant_strategy_plugins/macro_risk_governor_plugin.py b/src/quant_strategy_plugins/macro_risk_governor_plugin.py new file mode 100644 index 0000000..a45ca4c --- /dev/null +++ b/src/quant_strategy_plugins/macro_risk_governor_plugin.py @@ -0,0 +1,692 @@ +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + +import pandas as pd + +from .artifacts import write_json +from .plugin_signal_utils import flatten_for_csv, json_scalar, normalize_close, resolve_signal_date +from .russell_1000_multi_factor_defensive_snapshot import read_table +from .yfinance_prices import download_price_history + +SCHEMA_VERSION = "macro_risk_governor.v1" +SHADOW_MODE = "shadow" +MACRO_RISK_GOVERNOR_PROFILE = "macro_risk_governor" +DEFAULT_BENCHMARK_SYMBOL = "QQQ" +DEFAULT_ATTACK_SYMBOL = "TQQQ" +DEFAULT_VIX_SYMBOLS = ("VIX", "^VIX", "VIXCLS") +DEFAULT_CREDIT_PAIRS = (("HYG", "IEF"), ("LQD", "IEF")) +DEFAULT_OUTPUT_DIR = "data/output/macro_risk_governor" +DEFAULT_MAX_PRICE_AGE_DAYS = 4 +DEFAULT_MAX_EXTERNAL_CONTEXT_AGE_DAYS = 10 + +ROUTE_NO_ACTION = "no_action" +ROUTE_WATCH = "watch" +ROUTE_DELEVER = "delever" +ROUTE_CRISIS = "crisis" + +ACTION_NO_ACTION = "no_action" +ACTION_WATCH_ONLY = "watch_only" +ACTION_DELEVER = "delever" +ACTION_DEFEND = "defend" +ACTION_BLOCKED = "blocked" + + +def _as_float(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + if not pd.notna(result): + return None + return result + + +def _ratio_at(series: pd.Series, date: pd.Timestamp, lookback: int) -> float | None: + values = pd.to_numeric(series, errors="coerce").sort_index() + if date not in values.index: + values = values.reindex(values.index.union(pd.DatetimeIndex([date]))).sort_index().ffill() + current = _as_float(values.loc[date]) if date in values.index else None + shifted = values.shift(int(lookback)) + previous = _as_float(shifted.loc[date]) if date in shifted.index else None + if current is None or previous is None or previous <= 0.0: + return None + return current / previous - 1.0 + + +def _rolling_drawdown_at(series: pd.Series, date: pd.Timestamp, lookback: int) -> float | None: + values = pd.to_numeric(series, errors="coerce").sort_index() + high = values.rolling(int(lookback), min_periods=max(20, min(int(lookback), 63))).max() + if date not in values.index: + values = values.reindex(values.index.union(pd.DatetimeIndex([date]))).sort_index().ffill() + high = high.reindex(high.index.union(pd.DatetimeIndex([date]))).sort_index().ffill() + current = _as_float(values.loc[date]) if date in values.index else None + peak = _as_float(high.loc[date]) if date in high.index else None + if current is None or peak is None or peak <= 0.0: + return None + return current / peak - 1.0 + + +def _realized_volatility_at(series: pd.Series, date: pd.Timestamp, window: int) -> float | None: + returns = pd.to_numeric(series, errors="coerce").pct_change(fill_method=None) + volatility = returns.rolling(int(window), min_periods=int(window)).std() + if date not in volatility.index: + volatility = volatility.reindex(volatility.index.union(pd.DatetimeIndex([date]))).sort_index().ffill() + value = _as_float(volatility.loc[date]) if date in volatility.index else None + return None if value is None else float(value * (252 ** 0.5)) + + +def _latest_external_row(external_context: pd.DataFrame | None, signal_date: pd.Timestamp) -> tuple[pd.Series, pd.Timestamp | None]: + if external_context is None or external_context.empty or "as_of" not in external_context.columns: + return pd.Series(dtype=object), None + frame = pd.DataFrame(external_context).copy() + frame["as_of"] = pd.to_datetime(frame["as_of"], errors="coerce").dt.tz_localize(None).dt.normalize() + frame = frame.dropna(subset=["as_of"]).sort_values("as_of") + frame = frame.loc[frame["as_of"] <= signal_date] + if frame.empty: + return pd.Series(dtype=object), None + row = frame.iloc[-1] + return row, pd.Timestamp(row["as_of"]).normalize() + + +def _external_float(row: pd.Series, *names: str) -> float | None: + if row.empty: + return None + normalized = {str(key).strip().lower(): key for key in row.index} + for name in names: + key = normalized.get(str(name).strip().lower()) + if key is not None: + value = _as_float(row.get(key)) + if value is not None: + return value + return None + + +def _external_delta( + external_context: pd.DataFrame | None, + signal_date: pd.Timestamp, + names: Sequence[str], + lookback: int, +) -> float | None: + if external_context is None or external_context.empty or "as_of" not in external_context.columns: + return None + frame = pd.DataFrame(external_context).copy() + frame["as_of"] = pd.to_datetime(frame["as_of"], errors="coerce").dt.tz_localize(None).dt.normalize() + frame = frame.dropna(subset=["as_of"]).sort_values("as_of").set_index("as_of") + if frame.empty: + return None + normalized = {str(column).strip().lower(): column for column in frame.columns} + column = None + for name in names: + column = normalized.get(str(name).strip().lower()) + if column is not None: + break + if column is None: + return None + values = pd.to_numeric(frame[column], errors="coerce") + values = values.reindex(values.index.union(pd.DatetimeIndex([signal_date]))).sort_index().ffill() + if signal_date not in values.index: + return None + current = _as_float(values.loc[signal_date]) + previous = _as_float(values.shift(int(lookback)).loc[signal_date]) + if current is None or previous is None: + return None + return current - previous + + +def _first_available_symbol(close: pd.DataFrame, symbols: Sequence[str]) -> str | None: + for symbol in symbols: + normalized = str(symbol).strip().upper() + if normalized in close.columns: + return normalized + return None + + +def _pair_relative_return( + close: pd.DataFrame, + numerator: str, + denominator: str, + signal_date: pd.Timestamp, + lookback: int, +) -> float | None: + numerator = str(numerator).strip().upper() + denominator = str(denominator).strip().upper() + if numerator not in close.columns or denominator not in close.columns: + return None + ratio = pd.to_numeric(close[numerator], errors="coerce") / pd.to_numeric(close[denominator], errors="coerce") + return _ratio_at(ratio, signal_date, lookback) + + +def _add_check( + checks: dict[str, dict[str, Any]], + name: str, + active: bool, + *, + weight: float, + value: float | None, + threshold: float | None, + actionable: bool = True, +) -> None: + checks[name] = { + "active": bool(active), + "weight": float(weight), + "value": value, + "threshold": threshold, + "actionable": bool(actionable), + } + + +def _score_checks(checks: Mapping[str, Mapping[str, Any]], *, actionable_only: bool) -> float: + score = 0.0 + for check in checks.values(): + if actionable_only and not bool(check.get("actionable", True)): + continue + if bool(check.get("active", False)): + score += float(check.get("weight", 0.0) or 0.0) + return float(score) + + +def _build_data_quality( + *, + kill_reasons: Sequence[str], + benchmark_price_available: bool, + price_age_days: int, + max_price_age_days: int, + vix_available: bool, + credit_context_available: bool, + external_as_of: pd.Timestamp | None, + external_age_days: int | None, + max_external_context_age_days: int, +) -> dict[str, Any]: + external_context_fresh = external_as_of is None or ( + external_age_days is not None and external_age_days <= int(max_external_context_age_days) + ) + checks = { + "benchmark_price_available": bool(benchmark_price_available), + "price_data_fresh": int(price_age_days) <= int(max_price_age_days), + "vix_available": bool(vix_available), + "credit_context_available": bool(credit_context_available), + "external_context_fresh_if_present": bool(external_context_fresh), + "kill_switch_clear": not bool(kill_reasons), + } + warnings = list(kill_reasons) + if not vix_available: + warnings.append("vix price or external context unavailable") + if not credit_context_available: + warnings.append("credit pair context unavailable") + if not external_context_fresh: + warnings.append("external context stale") + score = sum(1.0 for value in checks.values() if value) / float(len(checks)) + if kill_reasons: + score = min(score, 0.5) + return { + "schema_version": "deterministic_data_quality.v1", + "quality_score": round(float(score), 4), + "checks": checks, + "warnings": warnings, + } + + +def build_macro_risk_governor_signal( + price_history, + *, + external_context: pd.DataFrame | None = None, + as_of: str | None = None, + benchmark_symbol: str = DEFAULT_BENCHMARK_SYMBOL, + attack_symbol: str = DEFAULT_ATTACK_SYMBOL, + vix_symbols: Sequence[str] = DEFAULT_VIX_SYMBOLS, + credit_pairs: Sequence[tuple[str, str]] = DEFAULT_CREDIT_PAIRS, + max_price_age_days: int = DEFAULT_MAX_PRICE_AGE_DAYS, + max_external_context_age_days: int = DEFAULT_MAX_EXTERNAL_CONTEXT_AGE_DAYS, + ma_days: int = 200, + benchmark_drawdown_watch: float = -0.10, + benchmark_drawdown_crisis: float = -0.18, + realized_vol_window: int = 10, + realized_vol_threshold: float = 0.35, + vix_watch_level: float = 28.0, + vix_crisis_level: float = 35.0, + vix_spike_lookback_days: int = 5, + vix_spike_threshold: float = 0.25, + credit_relative_lookback_days: int = 21, + credit_relative_threshold: float = -0.04, + hy_oas_watch_level: float = 5.0, + hy_oas_delta_lookback_days: int = 63, + hy_oas_delta_threshold: float = 1.0, + financial_stress_watch_level: float = 1.0, + pizza_index_watch_level: float = 2.0, + watch_score_threshold: float = 3.0, + delever_score_threshold: float = 5.0, + crisis_score_threshold: float = 7.0, + delever_leverage_scalar: float = 0.0, + delever_risk_asset_scalar: float = 1.0, + crisis_leverage_scalar: float = 0.0, + crisis_risk_asset_scalar: float = 0.0, +) -> dict[str, Any]: + close = normalize_close(price_history) + benchmark_symbol = str(benchmark_symbol or DEFAULT_BENCHMARK_SYMBOL).strip().upper() + attack_symbol = str(attack_symbol or DEFAULT_ATTACK_SYMBOL).strip().upper() + requested_date, signal_date = resolve_signal_date(close, as_of) + signal_iso = signal_date.date().isoformat() + latest_price_date = pd.Timestamp(close.index.max()).normalize() + price_age_days = int((requested_date - signal_date).days) + + external_row, external_as_of = _latest_external_row(external_context, signal_date) + external_age_days = int((signal_date - external_as_of).days) if external_as_of is not None else None + + kill_reasons: list[str] = [] + if benchmark_symbol not in close.columns: + kill_reasons.append(f"missing benchmark price data: {benchmark_symbol}") + if price_age_days > int(max_price_age_days): + kill_reasons.append( + f"price data stale: signal_as_of={signal_iso}, requested_as_of={requested_date.date().isoformat()}" + ) + if external_age_days is not None and external_age_days > int(max_external_context_age_days): + kill_reasons.append(f"external context stale: external_context_as_of={external_as_of.date().isoformat()}") + + checks: dict[str, dict[str, Any]] = {} + evidence: dict[str, Any] = { + "benchmark_symbol": benchmark_symbol, + "attack_symbol": attack_symbol, + "vix_symbol": None, + "credit_pairs": [f"{numerator}:{denominator}" for numerator, denominator in credit_pairs], + "metrics": {}, + } + + if benchmark_symbol in close.columns: + benchmark = pd.to_numeric(close[benchmark_symbol], errors="coerce") + benchmark_price = _as_float(benchmark.loc[signal_date]) + benchmark_ma = _as_float(benchmark.rolling(int(ma_days), min_periods=min(int(ma_days), 120)).mean().loc[signal_date]) + drawdown_63d = _rolling_drawdown_at(benchmark, signal_date, 63) + drawdown_252d = _rolling_drawdown_at(benchmark, signal_date, 252) + realized_vol = _realized_volatility_at(benchmark, signal_date, realized_vol_window) + below_ma = benchmark_price is not None and benchmark_ma is not None and benchmark_price < benchmark_ma + _add_check(checks, "benchmark_below_ma", below_ma, weight=2.0, value=benchmark_price, threshold=benchmark_ma) + _add_check( + checks, + "benchmark_drawdown_watch", + drawdown_63d is not None and drawdown_63d <= float(benchmark_drawdown_watch), + weight=1.0, + value=drawdown_63d, + threshold=float(benchmark_drawdown_watch), + ) + _add_check( + checks, + "benchmark_drawdown_crisis", + drawdown_252d is not None and drawdown_252d <= float(benchmark_drawdown_crisis), + weight=2.0, + value=drawdown_252d, + threshold=float(benchmark_drawdown_crisis), + ) + _add_check( + checks, + "benchmark_realized_volatility_high", + realized_vol is not None and realized_vol >= float(realized_vol_threshold), + weight=1.0, + value=realized_vol, + threshold=float(realized_vol_threshold), + ) + evidence["metrics"].update( + { + "benchmark_price": benchmark_price, + "benchmark_ma": benchmark_ma, + "benchmark_drawdown_63d": drawdown_63d, + "benchmark_drawdown_252d": drawdown_252d, + "benchmark_realized_volatility": realized_vol, + } + ) + + vix_symbol = _first_available_symbol(close, vix_symbols) + vix_level = None + vix_spike = None + if vix_symbol is not None: + vix = pd.to_numeric(close[vix_symbol], errors="coerce") + vix_level = _as_float(vix.loc[signal_date]) + vix_spike = _ratio_at(vix, signal_date, int(vix_spike_lookback_days)) + evidence["vix_symbol"] = vix_symbol + else: + vix_level = _external_float(external_row, "vix", "vixcls", "vix_level") + vix_spike = _external_float(external_row, "vix_5d_change", "vix_spike_5d") + if vix_level is not None: + evidence["vix_symbol"] = "external_context" + _add_check( + checks, + "vix_watch_level", + vix_level is not None and vix_level >= float(vix_watch_level), + weight=1.0, + value=vix_level, + threshold=float(vix_watch_level), + ) + _add_check( + checks, + "vix_crisis_level", + vix_level is not None and vix_level >= float(vix_crisis_level), + weight=1.0, + value=vix_level, + threshold=float(vix_crisis_level), + ) + _add_check( + checks, + "vix_spike", + vix_spike is not None and vix_spike >= float(vix_spike_threshold), + weight=1.0, + value=vix_spike, + threshold=float(vix_spike_threshold), + ) + evidence["metrics"].update({"vix_level": vix_level, "vix_spike": vix_spike}) + + credit_returns: dict[str, float | None] = {} + credit_context_available = False + credit_pair_stress = False + for numerator, denominator in credit_pairs: + value = _pair_relative_return(close, numerator, denominator, signal_date, int(credit_relative_lookback_days)) + key = f"{str(numerator).strip().upper()}:{str(denominator).strip().upper()}" + credit_returns[key] = value + if value is not None: + credit_context_available = True + credit_pair_stress = credit_pair_stress or value <= float(credit_relative_threshold) + _add_check( + checks, + "credit_pair_stress", + credit_pair_stress, + weight=2.0, + value=min((value for value in credit_returns.values() if value is not None), default=None), + threshold=float(credit_relative_threshold), + ) + evidence["metrics"]["credit_pair_relative_returns"] = credit_returns + + hy_oas = _external_float(external_row, "hy_oas", "high_yield_oas", "bamlh0a0hym2") + hy_oas_delta = _external_float(external_row, "hy_oas_delta_63d", "high_yield_oas_delta_63d") + if hy_oas_delta is None: + hy_oas_delta = _external_delta( + external_context, + signal_date, + ("hy_oas", "high_yield_oas", "bamlh0a0hym2"), + int(hy_oas_delta_lookback_days), + ) + _add_check( + checks, + "hy_oas_watch_level", + hy_oas is not None and hy_oas >= float(hy_oas_watch_level), + weight=2.0, + value=hy_oas, + threshold=float(hy_oas_watch_level), + ) + _add_check( + checks, + "hy_oas_widening", + hy_oas_delta is not None and hy_oas_delta >= float(hy_oas_delta_threshold), + weight=1.0, + value=hy_oas_delta, + threshold=float(hy_oas_delta_threshold), + ) + financial_stress = _external_float(external_row, "stlfsi", "stlfsi4", "nfci", "anfci", "financial_stress") + _add_check( + checks, + "financial_stress_index_high", + financial_stress is not None and financial_stress >= float(financial_stress_watch_level), + weight=2.0, + value=financial_stress, + threshold=float(financial_stress_watch_level), + ) + pizza_index = _external_float( + external_row, + "pentagon_pizza_index", + "pizza_index", + "pizza_activity_index", + "osint_pizza_index", + ) + _add_check( + checks, + "pentagon_pizza_watch", + pizza_index is not None and pizza_index >= float(pizza_index_watch_level), + weight=1.0, + value=pizza_index, + threshold=float(pizza_index_watch_level), + actionable=False, + ) + evidence["metrics"].update( + { + "hy_oas": hy_oas, + "hy_oas_delta_63d": hy_oas_delta, + "financial_stress": financial_stress, + "pentagon_pizza_index": pizza_index, + } + ) + + actionable_score = _score_checks(checks, actionable_only=True) + total_score = _score_checks(checks, actionable_only=False) + reason_codes = tuple(name for name, check in checks.items() if bool(check.get("active", False))) + + canonical_route = ROUTE_NO_ACTION + suggested_action = ACTION_NO_ACTION + leverage_scalar = 1.0 + risk_asset_scalar = 1.0 + would_trade_if_enabled = False + if actionable_score >= float(crisis_score_threshold): + canonical_route = ROUTE_CRISIS + suggested_action = ACTION_DEFEND + leverage_scalar = float(crisis_leverage_scalar) + risk_asset_scalar = float(crisis_risk_asset_scalar) + would_trade_if_enabled = True + elif actionable_score >= float(delever_score_threshold): + canonical_route = ROUTE_DELEVER + suggested_action = ACTION_DELEVER + leverage_scalar = float(delever_leverage_scalar) + risk_asset_scalar = float(delever_risk_asset_scalar) + would_trade_if_enabled = True + elif total_score >= float(watch_score_threshold): + canonical_route = ROUTE_WATCH + suggested_action = ACTION_WATCH_ONLY + + kill_switch_active = bool(kill_reasons) + if kill_switch_active: + canonical_route = ROUTE_NO_ACTION + suggested_action = ACTION_BLOCKED + leverage_scalar = 1.0 + risk_asset_scalar = 1.0 + would_trade_if_enabled = False + + data_freshness = { + "requested_as_of": requested_date.date().isoformat(), + "signal_as_of": signal_iso, + "prices_as_of": latest_price_date.date().isoformat(), + "price_age_days": price_age_days, + "max_price_age_days": int(max_price_age_days), + "external_context_as_of": external_as_of.date().isoformat() if external_as_of is not None else None, + "external_context_age_days": external_age_days, + "max_external_context_age_days": int(max_external_context_age_days), + } + data_quality = _build_data_quality( + kill_reasons=kill_reasons, + benchmark_price_available=benchmark_symbol in close.columns, + price_age_days=price_age_days, + max_price_age_days=int(max_price_age_days), + vix_available=vix_level is not None, + credit_context_available=credit_context_available, + external_as_of=external_as_of, + external_age_days=external_age_days, + max_external_context_age_days=int(max_external_context_age_days), + ) + payload = { + "as_of": signal_iso, + "mode": SHADOW_MODE, + "schema_version": SCHEMA_VERSION, + "profile": MACRO_RISK_GOVERNOR_PROFILE, + "canonical_route": canonical_route, + "suggested_action": suggested_action, + "leverage_scalar": max(0.0, min(1.0, float(leverage_scalar))), + "risk_asset_scalar": max(0.0, min(1.0, float(risk_asset_scalar))), + "would_trade_if_enabled": would_trade_if_enabled, + "actionable_score": round(float(actionable_score), 4), + "total_score": round(float(total_score), 4), + "reason_codes": reason_codes, + "checks": checks, + "kill_switch_active": kill_switch_active, + "kill_switch_reason": "; ".join(kill_reasons), + "data_freshness": data_freshness, + "data_quality": data_quality, + "evidence": evidence, + "audit_summary": { + "route_source": "deterministic_macro_risk_governor", + "final_route": canonical_route, + "actionable_score": round(float(actionable_score), 4), + "total_score": round(float(total_score), 4), + "reason_codes": reason_codes, + "note": "OSINT-only fields are watch evidence and do not contribute to actionable_score.", + }, + "execution_controls": { + "capital_impact": "strategy_opt_in", + "broker_order_allowed": False, + "live_allocation_mutation_allowed": False, + "log_namespace": MACRO_RISK_GOVERNOR_PROFILE, + "notification_profile": "shadow_only", + "intended_strategy_role": "macro_deleveraging_governor", + "defensive_destination": "unlevered_or_cash_like", + "strategy_runtime_metadata_allowed": True, + "ai_audit_shadow_only": False, + }, + "generated_at": datetime.now(timezone.utc).isoformat(), + } + return json_scalar(payload) + + +def write_macro_risk_governor_outputs(payload: Mapping[str, Any], output_dir: str | Path) -> dict[str, Path]: + output_root = Path(output_dir) + signal_date = str(payload["as_of"]) + signal_dir = output_root / "signals" + audit_dir = output_root / "audit" + latest_path = output_root / "latest_signal.json" + dated_json_path = signal_dir / f"{signal_date}.json" + dated_csv_path = signal_dir / f"{signal_date}.csv" + evidence_csv_path = audit_dir / f"{signal_date}_evidence.csv" + + write_json(latest_path, payload) + write_json(dated_json_path, payload) + signal_dir.mkdir(parents=True, exist_ok=True) + audit_dir.mkdir(parents=True, exist_ok=True) + pd.DataFrame([flatten_for_csv(payload)]).to_csv(dated_csv_path, index=False) + evidence_payload = { + "as_of": payload.get("as_of"), + "canonical_route": payload.get("canonical_route"), + "suggested_action": payload.get("suggested_action"), + "leverage_scalar": payload.get("leverage_scalar"), + "risk_asset_scalar": payload.get("risk_asset_scalar"), + "actionable_score": payload.get("actionable_score"), + "total_score": payload.get("total_score"), + **flatten_for_csv(payload.get("data_freshness", {})), + **flatten_for_csv(payload.get("data_quality", {})), + **flatten_for_csv(payload.get("evidence", {})), + **flatten_for_csv(payload.get("audit_summary", {})), + } + pd.DataFrame([evidence_payload]).to_csv(evidence_csv_path, index=False) + return { + "latest_signal": latest_path, + "signal_json": dated_json_path, + "signal_csv": dated_csv_path, + "evidence_csv": evidence_csv_path, + } + + +def _parse_str_tuple(value: str | Sequence[str]) -> tuple[str, ...]: + values = value.split(",") if isinstance(value, str) else list(value) + return tuple(dict.fromkeys(str(item).strip().upper() for item in values if str(item).strip())) + + +def _parse_credit_pairs(value: str | Sequence[str]) -> tuple[tuple[str, str], ...]: + pairs: list[tuple[str, str]] = [] + for item in _parse_str_tuple(value): + parts = [part.strip().upper() for part in item.replace("/", ":").split(":")] + if len(parts) != 2 or not all(parts): + raise ValueError(f"credit pair must use NUMERATOR:DENOMINATOR syntax: {item!r}") + pair = (parts[0], parts[1]) + if pair not in pairs: + pairs.append(pair) + return tuple(pairs) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Build deterministic macro risk governor shadow signal.") + input_group = parser.add_mutually_exclusive_group(required=True) + input_group.add_argument("--prices", help="Existing long price-history CSV with symbol/as_of/close columns") + input_group.add_argument("--download", action="store_true", help="Download adjusted price history through yfinance") + parser.add_argument("--external-context", default=None) + parser.add_argument("--as-of", default=None) + parser.add_argument("--price-start", default="2010-01-01") + parser.add_argument("--price-end", default=None) + parser.add_argument("--download-proxy", default=None) + parser.add_argument("--benchmark-symbol", default=DEFAULT_BENCHMARK_SYMBOL) + parser.add_argument("--attack-symbol", default=DEFAULT_ATTACK_SYMBOL) + parser.add_argument("--vix-symbols", default=",".join(DEFAULT_VIX_SYMBOLS)) + parser.add_argument( + "--credit-pairs", + default=",".join(f"{numerator}:{denominator}" for numerator, denominator in DEFAULT_CREDIT_PAIRS), + ) + parser.add_argument("--max-price-age-days", type=int, default=DEFAULT_MAX_PRICE_AGE_DAYS) + parser.add_argument("--max-external-context-age-days", type=int, default=DEFAULT_MAX_EXTERNAL_CONTEXT_AGE_DAYS) + parser.add_argument("--watch-score-threshold", type=float, default=3.0) + parser.add_argument("--delever-score-threshold", type=float, default=5.0) + parser.add_argument("--crisis-score-threshold", type=float, default=7.0) + parser.add_argument("--delever-leverage-scalar", type=float, default=0.0) + parser.add_argument("--delever-risk-asset-scalar", type=float, default=1.0) + parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + vix_symbols = _parse_str_tuple(args.vix_symbols) + credit_pairs = _parse_credit_pairs(args.credit_pairs) + if args.download: + symbols = [args.benchmark_symbol, args.attack_symbol, *vix_symbols] + for numerator, denominator in credit_pairs: + symbols.extend([numerator, denominator]) + symbols = list(dict.fromkeys(str(symbol).strip().upper() for symbol in symbols if str(symbol).strip())) + price_history = download_price_history( + symbols, + start=args.price_start, + end=args.price_end, + proxy=args.download_proxy, + ) + else: + price_history = read_table(args.prices) + external_context = read_table(args.external_context) if args.external_context else None + payload = build_macro_risk_governor_signal( + price_history, + external_context=external_context, + as_of=args.as_of, + benchmark_symbol=args.benchmark_symbol, + attack_symbol=args.attack_symbol, + vix_symbols=vix_symbols, + credit_pairs=credit_pairs, + max_price_age_days=args.max_price_age_days, + max_external_context_age_days=args.max_external_context_age_days, + watch_score_threshold=args.watch_score_threshold, + delever_score_threshold=args.delever_score_threshold, + crisis_score_threshold=args.crisis_score_threshold, + delever_leverage_scalar=args.delever_leverage_scalar, + delever_risk_asset_scalar=args.delever_risk_asset_scalar, + ) + paths = write_macro_risk_governor_outputs(payload, args.output_dir) + print(f"wrote macro risk governor signal -> {paths['latest_signal']}") + print( + f"route={payload['canonical_route']} action={payload['suggested_action']} " + f"score={payload['actionable_score']}/{payload['total_score']}" + ) + return 0 + + +__all__ = [ + "MACRO_RISK_GOVERNOR_PROFILE", + "ROUTE_CRISIS", + "ROUTE_DELEVER", + "ROUTE_NO_ACTION", + "ROUTE_WATCH", + "SCHEMA_VERSION", + "SHADOW_MODE", + "build_macro_risk_governor_signal", + "main", + "write_macro_risk_governor_outputs", +] diff --git a/src/quant_strategy_plugins/market_regime_control_plugin.py b/src/quant_strategy_plugins/market_regime_control_plugin.py new file mode 100644 index 0000000..cfa8650 --- /dev/null +++ b/src/quant_strategy_plugins/market_regime_control_plugin.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pandas as pd + +from .artifacts import write_json +from .plugin_signal_utils import flatten_for_csv, json_scalar + +SCHEMA_VERSION = "market_regime_control.v1" +SHADOW_MODE = "shadow" +MARKET_REGIME_CONTROL_PROFILE = "market_regime_control" + +COMPONENT_CRISIS = "crisis" +COMPONENT_MACRO = "macro" +COMPONENT_TACO = "taco" + +ROUTE_NO_ACTION = "no_action" +ROUTE_WATCH = "watch" +ROUTE_OPPORTUNITY_WATCH = "opportunity_watch" +ROUTE_RISK_REDUCED = "risk_reduced" +ROUTE_RISK_OFF = "risk_off" +ROUTE_BLOCKED = "blocked" + +ACTION_NO_ACTION = "no_action" +ACTION_WATCH_ONLY = "watch_only" +ACTION_NOTIFY_MANUAL_REVIEW = "notify_manual_review" +ACTION_DELEVER = "delever" +ACTION_DEFEND = "defend" +ACTION_BLOCKED = "blocked" + +CRISIS_ACTIVE_ROUTES = frozenset({"true_crisis"}) +CRISIS_WATCH_ROUTES = frozenset({"valuation_fragility", "systemic_stress_watch", "rate_bear", "policy_shock_watch"}) +MACRO_ACTIVE_ROUTES = frozenset({"delever", "crisis"}) +MACRO_WATCH_ROUTES = frozenset({"watch"}) +TACO_ACTIVE_ROUTES = frozenset({"taco_rebound", "taco_fake_crisis"}) + + +def _as_bool(value: Any, *, default: bool = False) -> bool: + if value is None: + return bool(default) + if isinstance(value, bool): + return value + if isinstance(value, (int, float)) and not isinstance(value, bool): + return bool(value) + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "on"}: + return True + if text in {"0", "false", "no", "n", "off", ""}: + return False + return bool(default) + + +def _as_float(value: Any, *, default: float) -> float: + try: + result = float(value) + except (TypeError, ValueError): + result = float(default) + if not pd.notna(result): + result = float(default) + return float(result) + + +def _clamp_ratio(value: Any, *, default: float) -> float: + return max(0.0, min(1.0, _as_float(value, default=default))) + + +def _optional_text(value: Any) -> str: + return str(value or "").strip() + + +def _normalized_route(payload: Mapping[str, Any] | None) -> str: + if not isinstance(payload, Mapping): + return "" + return _optional_text(payload.get("canonical_route") or payload.get("route")).lower() + + +def _normalized_action(payload: Mapping[str, Any] | None) -> str: + if not isinstance(payload, Mapping): + return "" + return _optional_text(payload.get("suggested_action") or payload.get("action")).lower() + + +def _component_key(payload: Mapping[str, Any]) -> str | None: + plugin = _optional_text(payload.get("plugin") or payload.get("profile")).lower() + if "crisis_response" in plugin: + return COMPONENT_CRISIS + if "macro_risk_governor" in plugin: + return COMPONENT_MACRO + if "taco" in plugin: + return COMPONENT_TACO + return None + + +def _normalize_component_signals( + component_signals: Mapping[str, Mapping[str, Any]] | Sequence[Mapping[str, Any]], +) -> dict[str, Mapping[str, Any]]: + if isinstance(component_signals, Mapping): + normalized: dict[str, Mapping[str, Any]] = {} + for key, payload in component_signals.items(): + if not isinstance(payload, Mapping): + continue + component = str(key or "").strip().lower() + if component in {COMPONENT_CRISIS, COMPONENT_MACRO, COMPONENT_TACO}: + normalized[component] = payload + continue + inferred = _component_key(payload) + if inferred: + normalized[inferred] = payload + return normalized + + normalized = {} + for payload in component_signals: + if not isinstance(payload, Mapping): + continue + component = _component_key(payload) + if component: + normalized[component] = payload + return normalized + + +def _reason_codes(payload: Mapping[str, Any] | None) -> tuple[str, ...]: + if not isinstance(payload, Mapping): + return () + raw = payload.get("reason_codes") + if isinstance(raw, str): + return tuple(item.strip() for item in raw.split(",") if item.strip()) + if isinstance(raw, Sequence) and not isinstance(raw, (bytes, bytearray)): + return tuple(str(item).strip() for item in raw if str(item).strip()) + route = _normalized_route(payload) + if route and route != ROUTE_NO_ACTION: + return (route,) + return () + + +def _blocked(payload: Mapping[str, Any] | None) -> bool: + if not isinstance(payload, Mapping): + return False + return _as_bool(payload.get("kill_switch_active"), default=False) or _normalized_action(payload) == ACTION_BLOCKED + + +def _compact_signal(payload: Mapping[str, Any] | None) -> dict[str, Any]: + if not isinstance(payload, Mapping): + return {"available": False} + compact = { + "available": True, + "profile": _optional_text(payload.get("plugin") or payload.get("profile")), + "schema_version": _optional_text(payload.get("schema_version")), + "as_of": _optional_text(payload.get("as_of")), + "canonical_route": _normalized_route(payload), + "suggested_action": _normalized_action(payload), + "would_trade_if_enabled": _as_bool(payload.get("would_trade_if_enabled"), default=False), + "kill_switch_active": _blocked(payload), + "reason_codes": _reason_codes(payload), + } + for key in ( + "actionable_score", + "total_score", + "leverage_scalar", + "risk_asset_scalar", + "risk_multiplier_suggestion", + "manual_review_required", + "rebound_context_active", + "event_context_active", + "price_crisis_guard_active", + "watch_label", + "notification_reason", + "suppression_reason", + ): + if key in payload: + compact[key] = payload.get(key) + for key in ("data_freshness", "data_quality", "event_quality", "audit_summary"): + value = payload.get(key) + if isinstance(value, Mapping): + compact[key] = dict(value) + return json_scalar(compact) + + +def _signal_as_of(components: Mapping[str, Mapping[str, Any]], explicit_as_of: str | None) -> str: + explicit = _optional_text(explicit_as_of) + if explicit: + return explicit + dates = sorted(_optional_text(payload.get("as_of")) for payload in components.values() if _optional_text(payload.get("as_of"))) + return dates[-1] if dates else datetime.now(timezone.utc).date().isoformat() + + +def build_market_regime_control_signal( + component_signals: Mapping[str, Mapping[str, Any]] | Sequence[Mapping[str, Any]], + *, + strategy_policy: str = "levered_growth_income_v1", + taco_opportunity_size_scalar: float = 0.0, + as_of: str | None = None, +) -> dict[str, Any]: + components = _normalize_component_signals(component_signals) + crisis = components.get(COMPONENT_CRISIS) + macro = components.get(COMPONENT_MACRO) + taco = components.get(COMPONENT_TACO) + + crisis_route = _normalized_route(crisis) + macro_route = _normalized_route(macro) + taco_route = _normalized_route(taco) + crisis_active = bool(crisis_route in CRISIS_ACTIVE_ROUTES and not _blocked(crisis)) + crisis_watch = bool( + crisis_route in CRISIS_WATCH_ROUTES + or ( + isinstance(crisis, Mapping) + and _optional_text(crisis.get("watch_label")) + and _normalized_action(crisis) == ACTION_WATCH_ONLY + ) + ) + macro_active = bool(macro_route in MACRO_ACTIVE_ROUTES and not _blocked(macro)) + macro_watch = bool(macro_route in MACRO_WATCH_ROUTES and not _blocked(macro)) + taco_active = bool( + taco_route in TACO_ACTIVE_ROUTES + and not _blocked(taco) + and ( + _as_bool(taco.get("manual_review_required") if isinstance(taco, Mapping) else None, default=False) + or _as_bool(taco.get("rebound_context_active") if isinstance(taco, Mapping) else None, default=False) + or taco_route == "taco_fake_crisis" + ) + ) + taco_watch = bool(isinstance(taco, Mapping) and _normalized_action(taco) == ACTION_WATCH_ONLY and not _blocked(taco)) + blocked = any(_blocked(payload) for payload in components.values()) + + final_route = ROUTE_NO_ACTION + suggested_action = ACTION_NO_ACTION + route_source = "none" + would_trade_if_enabled = False + risk_budget_scalar = 1.0 + leverage_scalar = 1.0 + risk_asset_scalar = 1.0 + taco_allowed = False + local_delever_veto_allowed = False + crisis_defense_required = False + blocked_actions: tuple[str, ...] = () + vetoes: list[str] = [] + reason_codes: list[str] = [] + + if crisis_active: + final_route = ROUTE_RISK_OFF + suggested_action = ACTION_DEFEND + route_source = COMPONENT_CRISIS + would_trade_if_enabled = True + risk_budget_scalar = 0.0 + leverage_scalar = 0.0 + risk_asset_scalar = 0.0 + crisis_defense_required = True + blocked_actions = ("increase_leverage", "increase_risk", "taco_rebound_veto") + reason_codes.extend(f"crisis:{code}" for code in _reason_codes(crisis) or ("true_crisis",)) + if taco_active: + vetoes.append("crisis_blocks_taco") + elif macro_active and macro_route == "crisis": + final_route = ROUTE_RISK_OFF + suggested_action = ACTION_DEFEND + route_source = COMPONENT_MACRO + would_trade_if_enabled = True + leverage_scalar = _clamp_ratio(macro.get("leverage_scalar") if isinstance(macro, Mapping) else None, default=0.0) + risk_asset_scalar = _clamp_ratio(macro.get("risk_asset_scalar") if isinstance(macro, Mapping) else None, default=0.0) + risk_budget_scalar = risk_asset_scalar + blocked_actions = ("increase_leverage", "increase_risk", "taco_rebound_veto") + reason_codes.extend(f"macro:{code}" for code in _reason_codes(macro) or ("crisis",)) + if taco_active: + vetoes.append("macro_crisis_blocks_taco") + elif macro_active: + final_route = ROUTE_RISK_REDUCED + suggested_action = ACTION_DELEVER + route_source = COMPONENT_MACRO + would_trade_if_enabled = True + leverage_scalar = _clamp_ratio(macro.get("leverage_scalar") if isinstance(macro, Mapping) else None, default=0.0) + risk_asset_scalar = _clamp_ratio(macro.get("risk_asset_scalar") if isinstance(macro, Mapping) else None, default=1.0) + risk_budget_scalar = risk_asset_scalar + blocked_actions = ("increase_leverage", "taco_rebound_veto") + reason_codes.extend(f"macro:{code}" for code in _reason_codes(macro) or ("delever",)) + if taco_active: + vetoes.append("macro_delever_blocks_taco") + elif blocked: + final_route = ROUTE_BLOCKED + suggested_action = ACTION_BLOCKED + route_source = "data_quality" + reason_codes.extend(f"{key}:blocked" for key, payload in components.items() if _blocked(payload)) + elif taco_active: + final_route = ROUTE_OPPORTUNITY_WATCH + suggested_action = ACTION_NOTIFY_MANUAL_REVIEW + route_source = COMPONENT_TACO + taco_allowed = True + local_delever_veto_allowed = True + reason_codes.extend(f"taco:{code}" for code in _reason_codes(taco) or ("taco_rebound",)) + elif macro_watch or crisis_watch or taco_watch: + final_route = ROUTE_WATCH + suggested_action = ACTION_WATCH_ONLY + route_source = "watch" + reason_codes.extend(f"macro:{code}" for code in _reason_codes(macro)) + reason_codes.extend(f"crisis:{code}" for code in _reason_codes(crisis)) + reason_codes.extend(f"taco:{code}" for code in _reason_codes(taco)) + + notification = { + "allowed": True, + "profile": "shadow_only" if suggested_action != ACTION_NOTIFY_MANUAL_REVIEW else "manual_review_only", + "should_notify": final_route not in {ROUTE_NO_ACTION}, + "route": final_route, + "suggested_action": suggested_action, + "route_source": route_source, + "reason_codes": tuple(dict.fromkeys(reason_codes)), + "vetoes": tuple(vetoes), + } + position_control = { + "allowed": True, + "mode": SHADOW_MODE, + "final_route": final_route, + "suggested_action": suggested_action, + "route_source": route_source, + "risk_budget_scalar": _clamp_ratio(risk_budget_scalar, default=1.0), + "leverage_scalar": _clamp_ratio(leverage_scalar, default=1.0), + "risk_asset_scalar": _clamp_ratio(risk_asset_scalar, default=1.0), + "taco_allowed": taco_allowed, + "taco_size_scalar": _clamp_ratio(taco_opportunity_size_scalar, default=0.0) if taco_allowed else 0.0, + "local_delever_veto_allowed": local_delever_veto_allowed, + "crisis_defense_required": crisis_defense_required, + "blocked_actions": blocked_actions, + "defensive_destination_role": "cash_like" if final_route == ROUTE_RISK_OFF else "unlevered_or_cash_like", + "reason_codes": tuple(dict.fromkeys(reason_codes)), + "vetoes": tuple(vetoes), + } + signal_as_of = _signal_as_of(components, as_of) + payload = { + "as_of": signal_as_of, + "mode": SHADOW_MODE, + "schema_version": SCHEMA_VERSION, + "profile": MARKET_REGIME_CONTROL_PROFILE, + "canonical_route": final_route, + "suggested_action": suggested_action, + "would_trade_if_enabled": would_trade_if_enabled, + "strategy_policy": str(strategy_policy or "levered_growth_income_v1").strip(), + "arbiter": { + "schema_version": "market_regime_arbiter.v1", + "final_route": final_route, + "suggested_action": suggested_action, + "route_source": route_source, + "vetoes": tuple(vetoes), + "reason_codes": tuple(dict.fromkeys(reason_codes)), + }, + "notification": notification, + "position_control": position_control, + "component_signals": { + COMPONENT_CRISIS: _compact_signal(crisis), + COMPONENT_MACRO: _compact_signal(macro), + COMPONENT_TACO: _compact_signal(taco), + }, + "execution_controls": { + "capital_impact": "strategy_opt_in", + "broker_order_allowed": False, + "live_allocation_mutation_allowed": False, + "repository_broker_write_allowed": False, + "repository_allocation_mutation_allowed": False, + "log_namespace": MARKET_REGIME_CONTROL_PROFILE, + "notification_profile": notification["profile"], + "intended_strategy_role": "unified_market_regime_control", + "strategy_runtime_metadata_allowed": True, + "position_control_shadow_only": True, + "ai_audit_shadow_only": False, + }, + "audit_summary": { + "route_source": route_source, + "final_route": final_route, + "suggested_action": suggested_action, + "reason_codes": tuple(dict.fromkeys(reason_codes)), + "vetoes": tuple(vetoes), + "note": "Deterministic arbiter only; AI and OSINT-only evidence cannot directly increase position authority.", + }, + "generated_at": datetime.now(timezone.utc).isoformat(), + } + return json_scalar(payload) + + +def write_market_regime_control_outputs(payload: Mapping[str, Any], output_dir: str | Path) -> dict[str, Path]: + output_root = Path(output_dir) + signal_date = str(payload["as_of"]) + signal_dir = output_root / "signals" + audit_dir = output_root / "audit" + latest_path = output_root / "latest_signal.json" + dated_json_path = signal_dir / f"{signal_date}.json" + dated_csv_path = signal_dir / f"{signal_date}.csv" + evidence_csv_path = audit_dir / f"{signal_date}_evidence.csv" + + write_json(latest_path, payload) + write_json(dated_json_path, payload) + signal_dir.mkdir(parents=True, exist_ok=True) + audit_dir.mkdir(parents=True, exist_ok=True) + pd.DataFrame([flatten_for_csv(payload)]).to_csv(dated_csv_path, index=False) + evidence_payload = { + "as_of": payload.get("as_of"), + "canonical_route": payload.get("canonical_route"), + "suggested_action": payload.get("suggested_action"), + **flatten_for_csv(payload.get("arbiter", {})), + **flatten_for_csv(payload.get("position_control", {})), + **flatten_for_csv(payload.get("component_signals", {})), + } + pd.DataFrame([evidence_payload]).to_csv(evidence_csv_path, index=False) + return { + "latest_signal": latest_path, + "signal_json": dated_json_path, + "signal_csv": dated_csv_path, + "evidence_csv": evidence_csv_path, + } + + +__all__ = [ + "MARKET_REGIME_CONTROL_PROFILE", + "SCHEMA_VERSION", + "build_market_regime_control_signal", + "write_market_regime_control_outputs", +] diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index de1ba24..f330dfb 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -14,6 +14,16 @@ build_crisis_response_shadow_signal, write_crisis_response_shadow_outputs, ) +from .macro_risk_governor_plugin import ( + MACRO_RISK_GOVERNOR_PROFILE, + build_macro_risk_governor_signal, + write_macro_risk_governor_outputs, +) +from .market_regime_control_plugin import ( + MARKET_REGIME_CONTROL_PROFILE, + build_market_regime_control_signal, + write_market_regime_control_outputs, +) from .russell_1000_multi_factor_defensive_snapshot import read_table from .taco_panic_rebound_research import DEFAULT_EVENT_SET, resolve_trade_war_event_set from .taco_rebound_shadow_plugin import ( @@ -24,10 +34,14 @@ DEFAULT_RUNNER_OUTPUT_DIR = "data/output/strategy_plugins" PLUGIN_CRISIS_RESPONSE_SHADOW = "crisis_response_shadow" +PLUGIN_MARKET_REGIME_CONTROL = MARKET_REGIME_CONTROL_PROFILE +PLUGIN_MACRO_RISK_GOVERNOR = MACRO_RISK_GOVERNOR_PROFILE PLUGIN_TACO_REBOUND_SHADOW = TACO_REBOUND_PROFILE SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,) PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = { PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income", "soxl_soxx_trend_income"), + PLUGIN_MARKET_REGIME_CONTROL: ("tqqq_growth_income",), + PLUGIN_MACRO_RISK_GOVERNOR: ("tqqq_growth_income",), PLUGIN_TACO_REBOUND_SHADOW: ("tqqq_growth_income",), } PLUGIN_RESEARCH_ONLY_REASONS: dict[str, str] = {} @@ -281,6 +295,58 @@ def _build_taco_rebound_kwargs(plugin_config: Mapping[str, Any]) -> dict[str, An return kwargs +def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[str, Any]: + kwargs: dict[str, Any] = {} + string_keys = { + "as_of", + "benchmark_symbol", + "attack_symbol", + } + numeric_keys = { + "benchmark_drawdown_watch", + "benchmark_drawdown_crisis", + "realized_vol_threshold", + "vix_watch_level", + "vix_crisis_level", + "vix_spike_threshold", + "credit_relative_threshold", + "hy_oas_watch_level", + "hy_oas_delta_threshold", + "financial_stress_watch_level", + "pizza_index_watch_level", + "watch_score_threshold", + "delever_score_threshold", + "crisis_score_threshold", + "delever_leverage_scalar", + "delever_risk_asset_scalar", + "crisis_leverage_scalar", + "crisis_risk_asset_scalar", + } + integer_keys = { + "max_price_age_days", + "max_external_context_age_days", + "ma_days", + "realized_vol_window", + "vix_spike_lookback_days", + "credit_relative_lookback_days", + "hy_oas_delta_lookback_days", + } + for key in string_keys: + if key in plugin_config and plugin_config[key] is not None: + kwargs[key] = str(plugin_config[key]).strip() + for key in numeric_keys: + if key in plugin_config and plugin_config[key] is not None: + kwargs[key] = float(plugin_config[key]) + for key in integer_keys: + if key in plugin_config and plugin_config[key] is not None: + kwargs[key] = int(plugin_config[key]) + if "vix_symbols" in plugin_config: + kwargs["vix_symbols"] = _as_str_tuple(plugin_config["vix_symbols"]) + if "credit_pairs" in plugin_config: + kwargs["credit_pairs"] = _as_credit_pairs(plugin_config["credit_pairs"]) + return kwargs + + PLUGIN_MODE_EXECUTION_CONTROLS: dict[str, dict[str, Any]] = { SHADOW_MODE: { "capital_impact": "none", @@ -339,6 +405,31 @@ def _build_taco_rebound_payload(price_history: pd.DataFrame, plugin_config: Mapp ) +def _build_macro_risk_governor_payload(price_history: pd.DataFrame, plugin_config: Mapping[str, Any]) -> dict[str, Any]: + external_context = _optional_table(plugin_config.get("external_context")) + return build_macro_risk_governor_signal( + price_history, + external_context=external_context, + **_build_macro_risk_governor_kwargs(plugin_config), + ) + + +def _build_market_regime_control_payload(price_history: pd.DataFrame, plugin_config: Mapping[str, Any]) -> dict[str, Any]: + components: dict[str, Mapping[str, Any]] = {} + if _as_bool(plugin_config.get("crisis_enabled"), default=True): + components["crisis"] = _build_crisis_response_payload(price_history, plugin_config) + if _as_bool(plugin_config.get("macro_enabled"), default=True): + components["macro"] = _build_macro_risk_governor_payload(price_history, plugin_config) + if _as_bool(plugin_config.get("taco_enabled"), default=True): + components["taco"] = _build_taco_rebound_payload(price_history, plugin_config) + return build_market_regime_control_signal( + components, + strategy_policy=str(plugin_config.get("strategy_policy", "levered_growth_income_v1")).strip(), + taco_opportunity_size_scalar=float(plugin_config.get("taco_opportunity_size_scalar", 0.0) or 0.0), + as_of=str(plugin_config.get("as_of", "") or "").strip() or None, + ) + + def _run_table_strategy_plugin( plugin_config: Mapping[str, Any], default_mode: str, @@ -391,6 +482,16 @@ def _run_table_strategy_plugin( build_payload=_build_taco_rebound_payload, write_outputs=write_taco_rebound_shadow_outputs, ) +MACRO_RISK_GOVERNOR_SPEC = PluginExecutionSpec( + default_plugin=PLUGIN_MACRO_RISK_GOVERNOR, + build_payload=_build_macro_risk_governor_payload, + write_outputs=write_macro_risk_governor_outputs, +) +MARKET_REGIME_CONTROL_SPEC = PluginExecutionSpec( + default_plugin=PLUGIN_MARKET_REGIME_CONTROL, + build_payload=_build_market_regime_control_payload, + write_outputs=write_market_regime_control_outputs, +) def run_crisis_response_shadow_plugin(plugin_config: Mapping[str, Any], default_mode: str) -> PluginRunResult: @@ -401,8 +502,18 @@ def run_taco_rebound_shadow_plugin(plugin_config: Mapping[str, Any], default_mod return _run_table_strategy_plugin(plugin_config, default_mode, TACO_REBOUND_SHADOW_SPEC) +def run_macro_risk_governor_plugin(plugin_config: Mapping[str, Any], default_mode: str) -> PluginRunResult: + return _run_table_strategy_plugin(plugin_config, default_mode, MACRO_RISK_GOVERNOR_SPEC) + + +def run_market_regime_control_plugin(plugin_config: Mapping[str, Any], default_mode: str) -> PluginRunResult: + return _run_table_strategy_plugin(plugin_config, default_mode, MARKET_REGIME_CONTROL_SPEC) + + PLUGIN_RUNNERS: dict[str, PluginRunner] = { PLUGIN_CRISIS_RESPONSE_SHADOW: run_crisis_response_shadow_plugin, + PLUGIN_MARKET_REGIME_CONTROL: run_market_regime_control_plugin, + PLUGIN_MACRO_RISK_GOVERNOR: run_macro_risk_governor_plugin, PLUGIN_TACO_REBOUND_SHADOW: run_taco_rebound_shadow_plugin, } @@ -494,6 +605,8 @@ def main(argv: list[str] | None = None) -> int: __all__ = [ "PLUGIN_CRISIS_RESPONSE_SHADOW", + "PLUGIN_MARKET_REGIME_CONTROL", + "PLUGIN_MACRO_RISK_GOVERNOR", "PLUGIN_TACO_REBOUND_SHADOW", "PLUGIN_COMPATIBLE_STRATEGIES", "PLUGIN_RESEARCH_ONLY_REASONS", @@ -502,5 +615,7 @@ def main(argv: list[str] | None = None) -> int: "main", "run_configured_plugins", "run_crisis_response_shadow_plugin", + "run_market_regime_control_plugin", + "run_macro_risk_governor_plugin", "run_taco_rebound_shadow_plugin", ] diff --git a/tests/test_macro_risk_governor_plugin.py b/tests/test_macro_risk_governor_plugin.py new file mode 100644 index 0000000..22560ef --- /dev/null +++ b/tests/test_macro_risk_governor_plugin.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import json + +import pandas as pd + +from quant_strategy_plugins.macro_risk_governor_plugin import ( + ROUTE_CRISIS, + ROUTE_DELEVER, + ROUTE_NO_ACTION, + ROUTE_WATCH, + build_macro_risk_governor_signal, + write_macro_risk_governor_outputs, +) + + +def _macro_prices(*, stress: bool = False) -> pd.DataFrame: + dates = pd.bdate_range("2025-01-02", periods=260) + rows: list[dict[str, object]] = [] + qqq = pd.Series([100.0 + idx * 0.10 for idx in range(len(dates))], index=dates) + vix = pd.Series(15.0, index=dates) + hyg = pd.Series(100.0, index=dates) + ief = pd.Series(100.0, index=dates) + if stress: + qqq.iloc[230:] = pd.Series( + [125.0 - idx * (45.0 / 29.0) for idx in range(30)], + index=dates[230:], + ) + vix.iloc[-6:] = [24.0, 27.0, 31.0, 34.0, 38.0, 41.0] + hyg.iloc[-22:] = pd.Series( + [100.0 - idx * (9.0 / 21.0) for idx in range(22)], + index=dates[-22:], + ) + ief.iloc[-22:] = pd.Series( + [100.0 + idx * (3.0 / 21.0) for idx in range(22)], + index=dates[-22:], + ) + prices = { + "QQQ": qqq, + "TQQQ": qqq * 3.0, + "VIX": vix, + "HYG": hyg, + "IEF": ief, + } + for symbol, series in prices.items(): + for as_of, close in series.items(): + rows.append({"symbol": symbol, "as_of": as_of, "close": close, "volume": 1_000_000}) + return pd.DataFrame(rows) + + +def test_macro_risk_governor_stays_no_action_in_quiet_market() -> None: + payload = build_macro_risk_governor_signal(_macro_prices(), as_of="2025-12-31") + + assert payload["canonical_route"] == ROUTE_NO_ACTION + assert payload["suggested_action"] == "no_action" + assert payload["would_trade_if_enabled"] is False + assert payload["leverage_scalar"] == 1.0 + assert payload["risk_asset_scalar"] == 1.0 + + +def test_macro_risk_governor_routes_stress_to_delever_when_below_crisis_threshold() -> None: + payload = build_macro_risk_governor_signal( + _macro_prices(stress=True), + as_of="2025-12-31", + crisis_score_threshold=99.0, + ) + + assert payload["canonical_route"] == ROUTE_DELEVER + assert payload["suggested_action"] == "delever" + assert payload["would_trade_if_enabled"] is True + assert payload["leverage_scalar"] == 0.0 + assert payload["risk_asset_scalar"] == 1.0 + assert "vix_crisis_level" in payload["reason_codes"] + assert payload["checks"]["pentagon_pizza_watch"]["actionable"] is False + + +def test_macro_risk_governor_routes_severe_stress_to_crisis() -> None: + payload = build_macro_risk_governor_signal(_macro_prices(stress=True), as_of="2025-12-31") + + assert payload["canonical_route"] == ROUTE_CRISIS + assert payload["suggested_action"] == "defend" + assert payload["would_trade_if_enabled"] is True + assert payload["leverage_scalar"] == 0.0 + assert payload["risk_asset_scalar"] == 0.0 + + +def test_macro_risk_governor_keeps_pizza_index_watch_only() -> None: + external_context = pd.DataFrame( + [ + { + "as_of": "2025-12-31", + "pentagon_pizza_index": 3.0, + } + ] + ) + + payload = build_macro_risk_governor_signal( + _macro_prices(), + external_context=external_context, + as_of="2025-12-31", + watch_score_threshold=1.0, + ) + + assert payload["canonical_route"] == ROUTE_WATCH + assert payload["suggested_action"] == "watch_only" + assert payload["would_trade_if_enabled"] is False + assert payload["actionable_score"] == 0.0 + assert payload["checks"]["pentagon_pizza_watch"]["active"] is True + assert payload["checks"]["pentagon_pizza_watch"]["actionable"] is False + + +def test_macro_risk_governor_writes_json_csv_and_evidence(tmp_path) -> None: + payload = build_macro_risk_governor_signal(_macro_prices(stress=True), as_of="2025-12-31") + + paths = write_macro_risk_governor_outputs(payload, tmp_path) + + assert paths["latest_signal"].exists() + assert paths["signal_json"].exists() + assert paths["signal_csv"].exists() + assert paths["evidence_csv"].exists() + latest = json.loads(paths["latest_signal"].read_text(encoding="utf-8")) + assert latest["schema_version"] == "macro_risk_governor.v1" diff --git a/tests/test_market_regime_control_plugin.py b/tests/test_market_regime_control_plugin.py new file mode 100644 index 0000000..332a8c0 --- /dev/null +++ b/tests/test_market_regime_control_plugin.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from quant_strategy_plugins.market_regime_control_plugin import build_market_regime_control_signal + + +def test_market_regime_control_crisis_blocks_taco_opportunity() -> None: + payload = build_market_regime_control_signal( + { + "crisis": { + "profile": "crisis_response_shadow", + "as_of": "2026-05-28", + "canonical_route": "true_crisis", + "suggested_action": "defend", + "would_trade_if_enabled": True, + }, + "taco": { + "profile": "taco_rebound_shadow", + "as_of": "2026-05-28", + "canonical_route": "taco_rebound", + "suggested_action": "notify_manual_review", + "manual_review_required": True, + "rebound_context_active": True, + }, + } + ) + + assert payload["profile"] == "market_regime_control" + assert payload["canonical_route"] == "risk_off" + assert payload["suggested_action"] == "defend" + assert payload["would_trade_if_enabled"] is True + assert payload["position_control"]["leverage_scalar"] == 0.0 + assert payload["position_control"]["risk_asset_scalar"] == 0.0 + assert payload["position_control"]["taco_allowed"] is False + assert payload["position_control"]["crisis_defense_required"] is True + assert "crisis_blocks_taco" in payload["arbiter"]["vetoes"] + + +def test_market_regime_control_macro_delever_blocks_taco_veto() -> None: + payload = build_market_regime_control_signal( + { + "macro": { + "profile": "macro_risk_governor", + "as_of": "2026-05-28", + "canonical_route": "delever", + "suggested_action": "delever", + "leverage_scalar": 0.0, + "risk_asset_scalar": 1.0, + "reason_codes": ["vix_crisis_level"], + }, + "taco": { + "profile": "taco_rebound_shadow", + "as_of": "2026-05-28", + "canonical_route": "taco_rebound", + "suggested_action": "notify_manual_review", + "manual_review_required": True, + "rebound_context_active": True, + }, + } + ) + + assert payload["canonical_route"] == "risk_reduced" + assert payload["suggested_action"] == "delever" + assert payload["position_control"]["leverage_scalar"] == 0.0 + assert payload["position_control"]["risk_asset_scalar"] == 1.0 + assert payload["position_control"]["taco_allowed"] is False + assert "macro_delever_blocks_taco" in payload["arbiter"]["vetoes"] + assert "macro:vix_crisis_level" in payload["position_control"]["reason_codes"] + + +def test_market_regime_control_taco_is_notification_with_local_veto_only() -> None: + payload = build_market_regime_control_signal( + { + "taco": { + "profile": "taco_rebound_shadow", + "as_of": "2026-05-28", + "canonical_route": "taco_rebound", + "suggested_action": "notify_manual_review", + "manual_review_required": True, + "rebound_context_active": True, + }, + }, + taco_opportunity_size_scalar=0.25, + ) + + assert payload["canonical_route"] == "opportunity_watch" + assert payload["suggested_action"] == "notify_manual_review" + assert payload["would_trade_if_enabled"] is False + assert payload["notification"]["should_notify"] is True + assert payload["position_control"]["taco_allowed"] is True + assert payload["position_control"]["local_delever_veto_allowed"] is True + assert payload["position_control"]["taco_size_scalar"] == 0.25 + assert payload["execution_controls"]["broker_order_allowed"] is False + assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False + + +def test_market_regime_control_blocked_component_blocks_taco_opportunity() -> None: + payload = build_market_regime_control_signal( + { + "macro": { + "profile": "macro_risk_governor", + "as_of": "2026-05-28", + "canonical_route": "no_action", + "suggested_action": "blocked", + "kill_switch_active": True, + }, + "taco": { + "profile": "taco_rebound_shadow", + "as_of": "2026-05-28", + "canonical_route": "taco_rebound", + "suggested_action": "notify_manual_review", + "manual_review_required": True, + "rebound_context_active": True, + }, + } + ) + + assert payload["canonical_route"] == "blocked" + assert payload["suggested_action"] == "blocked" + assert payload["position_control"]["taco_allowed"] is False + assert "macro:blocked" in payload["position_control"]["reason_codes"] diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index cd33c64..fa7c98f 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -9,6 +9,8 @@ from quant_strategy_plugins.crisis_response_research import ROUTE_TRUE_CRISIS from quant_strategy_plugins.strategy_plugin_runner import ( PLUGIN_CRISIS_RESPONSE_SHADOW, + PLUGIN_MARKET_REGIME_CONTROL, + PLUGIN_MACRO_RISK_GOVERNOR, PLUGIN_TACO_REBOUND_SHADOW, load_plugin_config, main, @@ -129,6 +131,24 @@ def _taco_rebound_prices() -> pd.DataFrame: return pd.DataFrame(rows) +def _macro_stress_prices() -> pd.DataFrame: + dates = pd.bdate_range("2025-01-02", periods=260) + rows: list[dict[str, object]] = [] + qqq = pd.Series([100.0 + idx * 0.10 for idx in range(len(dates))], index=dates) + qqq.iloc[-30:] = pd.Series([125.0 - idx * (45.0 / 29.0) for idx in range(30)], index=dates[-30:]) + vix = pd.Series(15.0, index=dates) + vix.iloc[-6:] = [24.0, 27.0, 31.0, 34.0, 38.0, 41.0] + hyg = pd.Series(100.0, index=dates) + hyg.iloc[-22:] = pd.Series([100.0 - idx * (9.0 / 21.0) for idx in range(22)], index=dates[-22:]) + ief = pd.Series(100.0, index=dates) + ief.iloc[-22:] = pd.Series([100.0 + idx * (3.0 / 21.0) for idx in range(22)], index=dates[-22:]) + prices = {"QQQ": qqq, "TQQQ": qqq * 3.0, "VIX": vix, "HYG": hyg, "IEF": ief} + for symbol, series in prices.items(): + for as_of, close in series.items(): + rows.append({"symbol": symbol, "as_of": as_of, "close": close, "volume": 1_000_000}) + return pd.DataFrame(rows) + + def test_strategy_plugin_runner_executes_strategy_scoped_shadow_plugin(tmp_path) -> None: config = _shadow_plugin_config(tmp_path) summary = run_configured_plugins(config) @@ -159,6 +179,90 @@ def test_strategy_plugin_runner_executes_strategy_scoped_shadow_plugin(tmp_path) assert "platform behavior contract" in payload["execution_controls"]["mode_note"] +def test_strategy_plugin_runner_runs_macro_risk_governor_for_tqqq(tmp_path) -> None: + prices_path = tmp_path / "macro_prices.csv" + output_dir = tmp_path / STRATEGY_NAME / "plugins" / PLUGIN_MACRO_RISK_GOVERNOR + _macro_stress_prices().to_csv(prices_path, index=False) + config = { + "output_dir": str(tmp_path / "runner"), + "default_mode": "shadow", + "strategy_plugins": [ + { + "strategy": STRATEGY_NAME, + "plugin": PLUGIN_MACRO_RISK_GOVERNOR, + "enabled": True, + "inputs": { + "prices": str(prices_path), + "as_of": "2025-12-31", + "vix_symbols": ["VIX"], + "credit_pairs": ["HYG:IEF"], + "crisis_score_threshold": 99.0, + }, + "outputs": {"output_dir": str(output_dir)}, + } + ], + } + + summary = run_configured_plugins(config) + + result = summary["strategy_plugins"][0] + assert result["strategy"] == STRATEGY_NAME + assert result["plugin"] == PLUGIN_MACRO_RISK_GOVERNOR + assert result["status"] == "ok" + assert "route=delever action=delever" in result["message"] + payload = json.loads((output_dir / "latest_signal.json").read_text(encoding="utf-8")) + assert payload["strategy"] == STRATEGY_NAME + assert payload["plugin"] == PLUGIN_MACRO_RISK_GOVERNOR + assert payload["canonical_route"] == "delever" + assert payload["execution_controls"]["broker_order_allowed"] is False + assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False + + +def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_path) -> None: + prices_path = tmp_path / "market_regime_prices.csv" + output_dir = tmp_path / STRATEGY_NAME / "plugins" / PLUGIN_MARKET_REGIME_CONTROL + _macro_stress_prices().to_csv(prices_path, index=False) + config = { + "output_dir": str(tmp_path / "runner"), + "default_mode": "shadow", + "strategy_plugins": [ + { + "strategy": STRATEGY_NAME, + "plugin": PLUGIN_MARKET_REGIME_CONTROL, + "enabled": True, + "inputs": { + "prices": str(prices_path), + "as_of": "2025-12-31", + "vix_symbols": ["VIX"], + "credit_pairs": ["HYG:IEF"], + "crisis_enabled": False, + "taco_enabled": False, + "crisis_score_threshold": 99.0, + }, + "outputs": {"output_dir": str(output_dir)}, + } + ], + } + + summary = run_configured_plugins(config) + + result = summary["strategy_plugins"][0] + assert result["strategy"] == STRATEGY_NAME + assert result["plugin"] == PLUGIN_MARKET_REGIME_CONTROL + assert result["status"] == "ok" + assert "route=risk_reduced action=delever" in result["message"] + payload = json.loads((output_dir / "latest_signal.json").read_text(encoding="utf-8")) + assert payload["strategy"] == STRATEGY_NAME + assert payload["plugin"] == PLUGIN_MARKET_REGIME_CONTROL + assert payload["canonical_route"] == "risk_reduced" + assert payload["position_control"]["leverage_scalar"] == 0.0 + assert payload["position_control"]["risk_asset_scalar"] == 1.0 + assert payload["position_control"]["taco_allowed"] is False + assert payload["execution_controls"]["strategy_runtime_metadata_allowed"] is True + assert payload["execution_controls"]["broker_order_allowed"] is False + assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False + + def test_strategy_plugin_runner_rehearses_triggered_shadow_artifact_without_execution_permissions(tmp_path) -> None: prices = _financial_crisis_prices() prices_path = tmp_path / "crisis_prices.csv" From ed07ba91c064232f43955102f19108c2dd85fda5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 28 May 2026 21:16:09 +0800 Subject: [PATCH 02/11] Tune macro volatility confirmation defaults --- .../macro_risk_governor_plugin.py | 40 +++++++++++++-- .../strategy_plugin_runner.py | 6 +++ tests/test_macro_risk_governor_plugin.py | 49 +++++++++++++++++-- tests/test_strategy_plugin_runner.py | 2 +- 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/quant_strategy_plugins/macro_risk_governor_plugin.py b/src/quant_strategy_plugins/macro_risk_governor_plugin.py index a45ca4c..da487ee 100644 --- a/src/quant_strategy_plugins/macro_risk_governor_plugin.py +++ b/src/quant_strategy_plugins/macro_risk_governor_plugin.py @@ -245,7 +245,8 @@ def build_macro_risk_governor_signal( benchmark_drawdown_watch: float = -0.10, benchmark_drawdown_crisis: float = -0.18, realized_vol_window: int = 10, - realized_vol_threshold: float = 0.35, + realized_vol_threshold: float = 0.30, + realized_vol_requires_confirmation: bool = True, vix_watch_level: float = 28.0, vix_crisis_level: float = 35.0, vix_spike_lookback_days: int = 5, @@ -261,7 +262,7 @@ def build_macro_risk_governor_signal( delever_score_threshold: float = 5.0, crisis_score_threshold: float = 7.0, delever_leverage_scalar: float = 0.0, - delever_risk_asset_scalar: float = 1.0, + delever_risk_asset_scalar: float = 0.0, crisis_leverage_scalar: float = 0.0, crisis_risk_asset_scalar: float = 0.0, ) -> dict[str, Any]: @@ -447,12 +448,36 @@ def build_macro_risk_governor_signal( threshold=float(pizza_index_watch_level), actionable=False, ) + realized_vol_confirmed_for_action = None + realized_vol_check = checks.get("benchmark_realized_volatility_high") + if realized_vol_check is not None: + volatility_active = bool(realized_vol_check.get("active", False)) + if volatility_active: + confirmation_checks = ( + "vix_watch_level", + "vix_crisis_level", + "vix_spike", + "credit_pair_stress", + "hy_oas_watch_level", + "hy_oas_widening", + "financial_stress_index_high", + ) + realized_vol_confirmed_for_action = any( + bool(checks.get(name, {}).get("active", False)) for name in confirmation_checks + ) + if bool(realized_vol_requires_confirmation) and not realized_vol_confirmed_for_action: + realized_vol_check["actionable"] = False + realized_vol_check["suppression_reason"] = "missing_volatility_stress_confirmation" + realized_vol_check["confirmation_required"] = bool(realized_vol_requires_confirmation) + realized_vol_check["confirmed_for_action"] = realized_vol_confirmed_for_action evidence["metrics"].update( { "hy_oas": hy_oas, "hy_oas_delta_63d": hy_oas_delta, "financial_stress": financial_stress, "pentagon_pizza_index": pizza_index, + "benchmark_realized_volatility_requires_confirmation": bool(realized_vol_requires_confirmation), + "benchmark_realized_volatility_confirmed_for_action": realized_vol_confirmed_for_action, } ) @@ -626,11 +651,18 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--max-price-age-days", type=int, default=DEFAULT_MAX_PRICE_AGE_DAYS) parser.add_argument("--max-external-context-age-days", type=int, default=DEFAULT_MAX_EXTERNAL_CONTEXT_AGE_DAYS) + parser.add_argument("--realized-vol-threshold", type=float, default=0.30) + parser.add_argument( + "--realized-vol-requires-confirmation", + action=argparse.BooleanOptionalAction, + default=True, + help="Require VIX, credit, or financial-stress confirmation before realized volatility contributes to actionable score.", + ) parser.add_argument("--watch-score-threshold", type=float, default=3.0) parser.add_argument("--delever-score-threshold", type=float, default=5.0) parser.add_argument("--crisis-score-threshold", type=float, default=7.0) parser.add_argument("--delever-leverage-scalar", type=float, default=0.0) - parser.add_argument("--delever-risk-asset-scalar", type=float, default=1.0) + parser.add_argument("--delever-risk-asset-scalar", type=float, default=0.0) parser.add_argument("--output-dir", default=DEFAULT_OUTPUT_DIR) return parser @@ -663,6 +695,8 @@ def main(argv: list[str] | None = None) -> int: credit_pairs=credit_pairs, max_price_age_days=args.max_price_age_days, max_external_context_age_days=args.max_external_context_age_days, + realized_vol_threshold=args.realized_vol_threshold, + realized_vol_requires_confirmation=args.realized_vol_requires_confirmation, watch_score_threshold=args.watch_score_threshold, delever_score_threshold=args.delever_score_threshold, crisis_score_threshold=args.crisis_score_threshold, diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index f330dfb..01cf8b9 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -331,6 +331,9 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[ "credit_relative_lookback_days", "hy_oas_delta_lookback_days", } + bool_keys = { + "realized_vol_requires_confirmation", + } for key in string_keys: if key in plugin_config and plugin_config[key] is not None: kwargs[key] = str(plugin_config[key]).strip() @@ -340,6 +343,9 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[ for key in integer_keys: if key in plugin_config and plugin_config[key] is not None: kwargs[key] = int(plugin_config[key]) + for key in bool_keys: + if key in plugin_config and plugin_config[key] is not None: + kwargs[key] = _as_bool(plugin_config[key]) if "vix_symbols" in plugin_config: kwargs["vix_symbols"] = _as_str_tuple(plugin_config["vix_symbols"]) if "credit_pairs" in plugin_config: diff --git a/tests/test_macro_risk_governor_plugin.py b/tests/test_macro_risk_governor_plugin.py index 22560ef..dae85d0 100644 --- a/tests/test_macro_risk_governor_plugin.py +++ b/tests/test_macro_risk_governor_plugin.py @@ -14,11 +14,11 @@ ) -def _macro_prices(*, stress: bool = False) -> pd.DataFrame: +def _macro_prices(*, stress: bool = False, volatility_spike: bool = False, vix_level: float = 15.0) -> pd.DataFrame: dates = pd.bdate_range("2025-01-02", periods=260) rows: list[dict[str, object]] = [] qqq = pd.Series([100.0 + idx * 0.10 for idx in range(len(dates))], index=dates) - vix = pd.Series(15.0, index=dates) + vix = pd.Series(float(vix_level), index=dates) hyg = pd.Series(100.0, index=dates) ief = pd.Series(100.0, index=dates) if stress: @@ -35,6 +35,11 @@ def _macro_prices(*, stress: bool = False) -> pd.DataFrame: [100.0 + idx * (3.0 / 21.0) for idx in range(22)], index=dates[-22:], ) + if volatility_spike: + qqq.iloc[-10:] = pd.Series( + [130.0, 122.0, 131.0, 121.0, 132.0, 122.0, 133.0, 123.0, 134.0, 124.0], + index=dates[-10:], + ) prices = { "QQQ": qqq, "TQQQ": qqq * 3.0, @@ -69,7 +74,7 @@ def test_macro_risk_governor_routes_stress_to_delever_when_below_crisis_threshol assert payload["suggested_action"] == "delever" assert payload["would_trade_if_enabled"] is True assert payload["leverage_scalar"] == 0.0 - assert payload["risk_asset_scalar"] == 1.0 + assert payload["risk_asset_scalar"] == 0.0 assert "vix_crisis_level" in payload["reason_codes"] assert payload["checks"]["pentagon_pizza_watch"]["actionable"] is False @@ -109,6 +114,44 @@ def test_macro_risk_governor_keeps_pizza_index_watch_only() -> None: assert payload["checks"]["pentagon_pizza_watch"]["actionable"] is False +def test_macro_risk_governor_requires_confirmation_for_realized_volatility_action() -> None: + payload = build_macro_risk_governor_signal( + _macro_prices(volatility_spike=True), + as_of="2025-12-31", + watch_score_threshold=1.0, + delever_score_threshold=1.0, + crisis_score_threshold=99.0, + ) + + volatility_check = payload["checks"]["benchmark_realized_volatility_high"] + assert volatility_check["active"] is True + assert volatility_check["actionable"] is False + assert volatility_check["confirmed_for_action"] is False + assert volatility_check["suppression_reason"] == "missing_volatility_stress_confirmation" + assert payload["actionable_score"] == 0.0 + assert payload["total_score"] == 1.0 + assert payload["canonical_route"] == ROUTE_WATCH + assert payload["suggested_action"] == "watch_only" + + +def test_macro_risk_governor_allows_realized_volatility_action_when_vix_confirms() -> None: + payload = build_macro_risk_governor_signal( + _macro_prices(volatility_spike=True, vix_level=30.0), + as_of="2025-12-31", + watch_score_threshold=1.0, + delever_score_threshold=1.0, + crisis_score_threshold=99.0, + ) + + volatility_check = payload["checks"]["benchmark_realized_volatility_high"] + assert volatility_check["active"] is True + assert volatility_check["actionable"] is True + assert volatility_check["confirmed_for_action"] is True + assert payload["actionable_score"] >= 1.0 + assert payload["canonical_route"] == ROUTE_DELEVER + assert payload["suggested_action"] == "delever" + + def test_macro_risk_governor_writes_json_csv_and_evidence(tmp_path) -> None: payload = build_macro_risk_governor_signal(_macro_prices(stress=True), as_of="2025-12-31") diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index fa7c98f..99184d1 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -256,7 +256,7 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_ assert payload["plugin"] == PLUGIN_MARKET_REGIME_CONTROL assert payload["canonical_route"] == "risk_reduced" assert payload["position_control"]["leverage_scalar"] == 0.0 - assert payload["position_control"]["risk_asset_scalar"] == 1.0 + assert payload["position_control"]["risk_asset_scalar"] == 0.0 assert payload["position_control"]["taco_allowed"] is False assert payload["execution_controls"]["strategy_runtime_metadata_allowed"] is True assert payload["execution_controls"]["broker_order_allowed"] is False From 34c9734a02935caa080c3ae8464a76b3cb0ae123 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 28 May 2026 21:40:59 +0800 Subject: [PATCH 03/11] Add versioned market regime plugin contract --- docs/examples/strategy_plugins.example.toml | 127 +++++++++--------- .../strategy_plugin_runner.py | 15 ++- tests/test_strategy_plugin_runner.py | 53 +++++++- 3 files changed, 133 insertions(+), 62 deletions(-) diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index 037882d..fbebe0f 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -3,11 +3,72 @@ default_mode = "shadow" [[strategy_plugins]] strategy = "tqqq_growth_income" -plugin = "crisis_response_shadow" +plugin = "market_regime_control" +enabled = true +# Default runtime contract. This facade arbitrates crisis_response_shadow, +# macro_risk_governor, and taco_rebound_shadow into one deterministic signal. +# Strategy code consumes only notification and position_control from this +# artifact; broker writes and live allocation mutation remain disabled. + +[strategy_plugins.inputs] +prices = "data/output/market_regime_control/input/tqqq_price_history.csv" +external_context = "data/output/market_regime_control/input/external_context.csv" +event_set = "geopolitical-deescalation" +benchmark_symbol = "QQQ" +attack_symbol = "TQQQ" +vix_symbols = ["VIX", "^VIX", "VIXCLS"] +credit_pairs = ["HYG:IEF", "LQD:IEF"] +financial_symbols = ["XLF", "KRE"] +rate_symbols = ["IEF", "TLT"] +strategy_policy = "levered_growth_income_v1" +realized_vol_threshold = 0.30 +realized_vol_requires_confirmation = true +delever_risk_asset_scalar = 0.0 +taco_opportunity_size_scalar = 0.0 +crisis_enabled = true +macro_enabled = true +taco_enabled = true + +[strategy_plugins.outputs] +output_dir = "data/output/tqqq_growth_income/plugins/market_regime_control" + +[[strategy_plugins]] +strategy = "soxl_soxx_trend_income" +plugin = "market_regime_control" enabled = true -# The runner enforces plugin/strategy compatibility. This plugin is scoped to -# TQQQ/SOXL leveraged equity black-swan defense strategies. -# mode is optional when it matches default_mode; only shadow notification mode is supported. +# SOXL uses the same macro/crisis contract. TACO stays disabled because the +# current rebound-event playbook is calibrated for TQQQ/QQQ. + +[strategy_plugins.inputs] +prices = "data/output/market_regime_control/input/soxl_price_history.csv" +external_context = "data/output/market_regime_control/input/external_context.csv" +event_set = "full" +benchmark_symbol = "SOXX" +attack_symbol = "SOXL" +vix_symbols = ["VIX", "^VIX", "VIXCLS"] +credit_pairs = ["HYG:IEF", "LQD:IEF"] +financial_symbols = ["XLF", "KRE"] +rate_symbols = ["IEF", "TLT"] +strategy_policy = "levered_growth_income_v1" +realized_vol_threshold = 0.30 +realized_vol_requires_confirmation = true +delever_risk_asset_scalar = 0.0 +taco_enabled = false +crisis_enabled = true +macro_enabled = true + +[strategy_plugins.outputs] +output_dir = "data/output/soxl_soxx_trend_income/plugins/market_regime_control" + +# Deprecated compatibility mounts. They remain runnable for historical +# backtests and downstream consumers that have not migrated to +# market_regime_control yet. New strategy integrations should not read them +# directly. + +[[strategy_plugins]] +strategy = "tqqq_growth_income" +plugin = "crisis_response_shadow" +enabled = false [strategy_plugins.inputs] prices = "data/output/crisis_response_shadow/input/price_history.csv" @@ -16,19 +77,6 @@ event_set = "full" financial_symbols = ["XLF", "KRE"] credit_pairs = ["HYG:IEF", "LQD:IEF"] rate_symbols = ["IEF", "TLT"] -# Optional shadow-only AI audit. API keys are read from env vars, not TOML: -# QSP_STRATEGY_PLUGIN_AI_AUDIT_API_KEY / QSP_STRATEGY_PLUGIN_AI_AUDIT_FALLBACK_API_KEY -# Legacy QSP_CRISIS_AI_AUDIT_* names are still accepted. -ai_audit_enabled = true -ai_audit_codex_enabled = true -ai_audit_model = "gpt-5.4-mini" -# ai_audit_base_url = "https://api.openai.com/v1" -# ai_audit_fallback_base_url = "https://fallback.example.com/v1" -# ai_audit_fallback_model = "fallback-model" -# Anthropic fallback follows the CryptoCodexAuditBridge provider-fallback style. -# ai_audit_anthropic_model = "claude-sonnet-4-6" -# ai_audit_anthropic_base_url = "https://api.anthropic.com/v1" -# ai_audit_anthropic_version = "2023-06-01" [strategy_plugins.outputs] output_dir = "data/output/tqqq_growth_income/plugins/crisis_response_shadow" @@ -36,10 +84,7 @@ output_dir = "data/output/tqqq_growth_income/plugins/crisis_response_shadow" [[strategy_plugins]] strategy = "tqqq_growth_income" plugin = "macro_risk_governor" -enabled = true -# Deterministic macro de-leveraging governor. VIX/credit/price stress can emit -# leverage_scalar and risk_asset_scalar for strategies that explicitly consume -# mounted plugin metadata. OSINT fields remain watch-only evidence. +enabled = false [strategy_plugins.inputs] prices = "data/output/macro_risk_governor/input/price_history.csv" @@ -55,50 +100,12 @@ output_dir = "data/output/tqqq_growth_income/plugins/macro_risk_governor" [[strategy_plugins]] strategy = "tqqq_growth_income" plugin = "taco_rebound_shadow" -enabled = true -# Notification-only TACO context. The artifact may trigger manual-review alerts, -# but it never recommends position size or mutates allocations. -# Manual-review alerts require post-event price rebound confirmation by default. +enabled = false [strategy_plugins.inputs] prices = "data/output/taco_rebound_shadow/input/price_history.csv" event_set = "geopolitical-deescalation" start_date = "2026-01-01" -# Optional shadow-only AI audit for event/source quality. It cannot alter the -# manual-review/watch-only route or generate allocation/order instructions. -ai_audit_enabled = true -ai_audit_codex_enabled = true -ai_audit_model = "gpt-5.4-mini" -# ai_audit_anthropic_model = "claude-sonnet-4-6" [strategy_plugins.outputs] output_dir = "data/output/tqqq_growth_income/plugins/taco_rebound_shadow" - -[[strategy_plugins]] -strategy = "tqqq_growth_income" -plugin = "market_regime_control" -enabled = true -# Unified facade over crisis_response_shadow, macro_risk_governor, and -# taco_rebound_shadow. It emits one deterministic arbiter result with two -# exits: notification and strategy-opt-in position_control. Broker writes and -# live allocation mutation remain disabled in shadow mode. - -[strategy_plugins.inputs] -prices = "data/output/market_regime_control/input/price_history.csv" -external_context = "data/output/market_regime_control/input/external_context.csv" -event_set = "geopolitical-deescalation" -benchmark_symbol = "QQQ" -attack_symbol = "TQQQ" -vix_symbols = ["VIX", "^VIX", "VIXCLS"] -credit_pairs = ["HYG:IEF", "LQD:IEF"] -financial_symbols = ["XLF", "KRE"] -rate_symbols = ["IEF", "TLT"] -strategy_policy = "levered_growth_income_v1" -taco_opportunity_size_scalar = 0.0 -# Component toggles support staged shadow rollout. -crisis_enabled = true -macro_enabled = true -taco_enabled = true - -[strategy_plugins.outputs] -output_dir = "data/output/tqqq_growth_income/plugins/market_regime_control" diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index 01cf8b9..584e130 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -40,10 +40,21 @@ SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,) PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = { PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income", "soxl_soxx_trend_income"), - PLUGIN_MARKET_REGIME_CONTROL: ("tqqq_growth_income",), + PLUGIN_MARKET_REGIME_CONTROL: ("tqqq_growth_income", "soxl_soxx_trend_income"), PLUGIN_MACRO_RISK_GOVERNOR: ("tqqq_growth_income",), PLUGIN_TACO_REBOUND_SHADOW: ("tqqq_growth_income",), } +PLUGIN_SCHEMA_VERSIONS: dict[str, tuple[str, ...]] = { + PLUGIN_CRISIS_RESPONSE_SHADOW: ("crisis_response_shadow.v1",), + PLUGIN_MARKET_REGIME_CONTROL: ("market_regime_control.v1",), + PLUGIN_MACRO_RISK_GOVERNOR: ("macro_risk_governor.v1",), + PLUGIN_TACO_REBOUND_SHADOW: ("taco_rebound_shadow.v2",), +} +PLUGIN_DEPRECATED_SUCCESSORS: dict[str, str] = { + PLUGIN_CRISIS_RESPONSE_SHADOW: PLUGIN_MARKET_REGIME_CONTROL, + PLUGIN_MACRO_RISK_GOVERNOR: PLUGIN_MARKET_REGIME_CONTROL, + PLUGIN_TACO_REBOUND_SHADOW: PLUGIN_MARKET_REGIME_CONTROL, +} PLUGIN_RESEARCH_ONLY_REASONS: dict[str, str] = {} @@ -615,7 +626,9 @@ def main(argv: list[str] | None = None) -> int: "PLUGIN_MACRO_RISK_GOVERNOR", "PLUGIN_TACO_REBOUND_SHADOW", "PLUGIN_COMPATIBLE_STRATEGIES", + "PLUGIN_DEPRECATED_SUCCESSORS", "PLUGIN_RESEARCH_ONLY_REASONS", + "PLUGIN_SCHEMA_VERSIONS", "PluginRunResult", "load_plugin_config", "main", diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index 99184d1..b0c232e 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -8,9 +8,12 @@ from quant_strategy_plugins.crisis_response_research import ROUTE_TRUE_CRISIS from quant_strategy_plugins.strategy_plugin_runner import ( + PLUGIN_COMPATIBLE_STRATEGIES, PLUGIN_CRISIS_RESPONSE_SHADOW, + PLUGIN_DEPRECATED_SUCCESSORS, PLUGIN_MARKET_REGIME_CONTROL, PLUGIN_MACRO_RISK_GOVERNOR, + PLUGIN_SCHEMA_VERSIONS, PLUGIN_TACO_REBOUND_SHADOW, load_plugin_config, main, @@ -263,6 +266,53 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_ assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False +def test_strategy_plugin_runner_runs_unified_market_regime_control_for_soxl(tmp_path) -> None: + prices_path = tmp_path / "market_regime_prices.csv" + output_dir = tmp_path / SOXL_STRATEGY_NAME / "plugins" / PLUGIN_MARKET_REGIME_CONTROL + _soxl_quiet_prices().to_csv(prices_path, index=False) + config = { + "output_dir": str(tmp_path / "runner"), + "default_mode": "shadow", + "strategy_plugins": [ + { + "strategy": SOXL_STRATEGY_NAME, + "plugin": PLUGIN_MARKET_REGIME_CONTROL, + "enabled": True, + "inputs": { + "prices": str(prices_path), + "as_of": "2025-11-19", + "benchmark_symbol": "SOXX", + "attack_symbol": "SOXL", + "crisis_enabled": False, + "macro_enabled": False, + "taco_enabled": False, + }, + "outputs": {"output_dir": str(output_dir)}, + } + ], + } + + summary = run_configured_plugins(config) + + result = summary["strategy_plugins"][0] + assert result["strategy"] == SOXL_STRATEGY_NAME + assert result["plugin"] == PLUGIN_MARKET_REGIME_CONTROL + assert result["status"] == "ok" + payload = json.loads((output_dir / "latest_signal.json").read_text(encoding="utf-8")) + assert payload["strategy"] == SOXL_STRATEGY_NAME + assert payload["plugin"] == PLUGIN_MARKET_REGIME_CONTROL + assert payload["schema_version"] in PLUGIN_SCHEMA_VERSIONS[PLUGIN_MARKET_REGIME_CONTROL] + assert payload["canonical_route"] == "no_action" + + +def test_strategy_plugin_runner_contract_registry_prefers_unified_plugin() -> None: + assert SOXL_STRATEGY_NAME in PLUGIN_COMPATIBLE_STRATEGIES[PLUGIN_MARKET_REGIME_CONTROL] + assert PLUGIN_SCHEMA_VERSIONS[PLUGIN_MARKET_REGIME_CONTROL] == ("market_regime_control.v1",) + assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_CRISIS_RESPONSE_SHADOW] == PLUGIN_MARKET_REGIME_CONTROL + assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_MACRO_RISK_GOVERNOR] == PLUGIN_MARKET_REGIME_CONTROL + assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_TACO_REBOUND_SHADOW] == PLUGIN_MARKET_REGIME_CONTROL + + def test_strategy_plugin_runner_rehearses_triggered_shadow_artifact_without_execution_permissions(tmp_path) -> None: prices = _financial_crisis_prices() prices_path = tmp_path / "crisis_prices.csv" @@ -642,6 +692,7 @@ def test_strategy_plugin_runner_example_config_uses_default_mode_without_duplica assert config["default_mode"] == "shadow" assert "mode" not in config["strategy_plugins"][0] + assert config["strategy_plugins"][0]["plugin"] == PLUGIN_MARKET_REGIME_CONTROL assert config["strategy_plugins"][0]["outputs"]["output_dir"].endswith( - "tqqq_growth_income/plugins/crisis_response_shadow" + "tqqq_growth_income/plugins/market_regime_control" ) From 886e20545efebdd6f0aef73d04845ae58025d257 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 28 May 2026 22:39:30 +0800 Subject: [PATCH 04/11] Broaden market regime control compatibility --- README.md | 4 ++++ src/quant_strategy_plugins/strategy_plugin_runner.py | 9 ++++++++- tests/test_strategy_plugin_runner.py | 9 ++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 566d9ca..4b8d7c9 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ send notifications; plugin research and signal generation live here. opt in through mounted metadata. OSINT-style fields such as a Pentagon pizza index are kept as watch-only evidence and do not contribute to the actionable trading score. +- `market_regime_control`: unified deterministic facade for crisis, macro, and + TACO signals. Levered strategies can consume position controls directly; + stock/ETF rotation strategies should consume the same artifact through their + local risk-scaling policy and keep TACO as notification-only. - `taco_rebound_shadow`: TQQQ-only event-rebound context notifier. It writes manual-review artifacts and never recommends position size or changes allocations. Softening/de-escalation events stay watch-only until post-event diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index 584e130..cd0aab9 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -40,7 +40,14 @@ SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,) PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = { PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income", "soxl_soxx_trend_income"), - PLUGIN_MARKET_REGIME_CONTROL: ("tqqq_growth_income", "soxl_soxx_trend_income"), + PLUGIN_MARKET_REGIME_CONTROL: ( + "tqqq_growth_income", + "soxl_soxx_trend_income", + "global_etf_rotation", + "russell_1000_multi_factor_defensive", + "tech_communication_pullback_enhancement", + "mega_cap_leader_rotation_top50_balanced", + ), PLUGIN_MACRO_RISK_GOVERNOR: ("tqqq_growth_income",), PLUGIN_TACO_REBOUND_SHADOW: ("tqqq_growth_income",), } diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index b0c232e..ec4d956 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -306,7 +306,14 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_soxl(tmp_ def test_strategy_plugin_runner_contract_registry_prefers_unified_plugin() -> None: - assert SOXL_STRATEGY_NAME in PLUGIN_COMPATIBLE_STRATEGIES[PLUGIN_MARKET_REGIME_CONTROL] + assert set(PLUGIN_COMPATIBLE_STRATEGIES[PLUGIN_MARKET_REGIME_CONTROL]) == { + STRATEGY_NAME, + SOXL_STRATEGY_NAME, + "global_etf_rotation", + "russell_1000_multi_factor_defensive", + "tech_communication_pullback_enhancement", + "mega_cap_leader_rotation_top50_balanced", + } assert PLUGIN_SCHEMA_VERSIONS[PLUGIN_MARKET_REGIME_CONTROL] == ("market_regime_control.v1",) assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_CRISIS_RESPONSE_SHADOW] == PLUGIN_MARKET_REGIME_CONTROL assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_MACRO_RISK_GOVERNOR] == PLUGIN_MARKET_REGIME_CONTROL From e68b8688d1ad5402443c8550a7accfe274189697 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 28 May 2026 23:06:02 +0800 Subject: [PATCH 05/11] Document market regime control plan --- README.zh-CN.md | 1 + docs/market-regime-control-plan.zh-CN.md | 118 +++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 docs/market-regime-control-plan.zh-CN.md diff --git a/README.zh-CN.md b/README.zh-CN.md index c24918b..4a181cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,6 +26,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac - `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。 可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。 - `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数这类 OSINT 字段只作为 watch-only 证据,不进入可执行分数。 +- `market_regime_control`:统一确定性 facade,汇总 crisis、macro 和 TACO 信号,输出版本化的 `notification` 和 `position_control`。杠杆策略可直接消费仓位控制,股票/ETF 轮动策略通过本地风险缩放策略消费;TACO 在统一插件里保持通知-only,并会被危机和宏观降风险路线 veto。设计说明见 [Market Regime Control 统一插件方案](docs/market-regime-control-plan.zh-CN.md)。 - `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。 该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。 - TACO panic-rebound 研究、组合回测和 overlay 对比也归属本仓库;snapshot pipeline 仓库只保留兼容入口。 diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md new file mode 100644 index 0000000..b33cbea --- /dev/null +++ b/docs/market-regime-control-plan.zh-CN.md @@ -0,0 +1,118 @@ +# Market Regime Control 统一插件方案 + +本文档记录 `market_regime_control` 的当前设计边界、信号优先级、策略消费方式和回测结论。 + +## 目标 + +`market_regime_control` 是统一的确定性市场状态 facade。它把原来的危机防守、宏观降杠杆和 TACO 事件反弹通知汇总成一个版本化 artifact,供策略仓库统一消费。 + +核心目标: + +- 在宏观环境恶化、系统性危机或泡沫破裂风险上升时,主动降低仓位和杠杆。 +- 保留 TACO 对假危机、事件缓和和反弹机会的通知能力,但不让它绕过危机防守。 +- 不依赖 AI 做交易决策。AI 只允许做 shadow-only 证据复核和通知辅助。 +- 输出稳定的 `market_regime_control.v1` schema,策略仓库按 schema 消费 `notification` 和 `position_control`。 + +非目标: + +- 插件仓库不调用券商接口,不直接改账户分配。 +- OSINT 字段,例如五角大楼比萨指数,只能作为 watch-only evidence,不能直接进入可执行分数。 +- TACO 默认不增加仓位,不作为危机期间的抄底开关。 + +## 组件职责 + +`market_regime_control` 内部保留三个确定性组件: + +- `crisis_response_shadow` + 负责硬危机防守。`true_crisis` 和泡沫脆弱性触发后,统一插件输出 `risk_off` / `defend`,仓位目标交给策略侧 opt-in 执行。 +- `macro_risk_governor` + 负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced` 或 `risk_off`。 +- `taco_rebound_shadow` + 负责 TQQQ 事件反弹通知。它输出人工复核通知和本地 veto 线索,不直接提高仓位。 + +统一插件输出四组主要字段: + +- `notification` + 通知是否应该发出、路由来源、原因码和 veto 说明。 +- `position_control` + 策略侧可消费的 `risk_budget_scalar`、`leverage_scalar`、`risk_asset_scalar`、`taco_allowed` 和 `blocked_actions`。 +- `component_signals` + 子组件的压缩证据,便于通知和审计。 +- `execution_controls` + 明确插件仓库只写 artifact,不允许券商下单或账户配置变更。 + +## 仲裁优先级 + +当前优先级按风险优先设计: + +1. `crisis_response_shadow` 的 `true_crisis` 或泡沫脆弱性优先级最高,输出 `risk_off`,并 veto TACO。 +2. `macro_risk_governor` 的 `crisis` 其次,输出 `risk_off`,并 veto TACO。 +3. `macro_risk_governor` 的 `delever` 输出 `risk_reduced`,降低杠杆或风险资产预算,并 veto TACO。 +4. 数据质量 kill switch 或组件 blocked 状态会阻断机会侧动作。 +5. 只有没有危机和宏观降风险时,TACO 才能输出 `opportunity_watch` 和人工复核通知。 +6. watch-only 信号只通知,不给仓位权限。 + +这个顺序保证危机插件、宏观插件和 TACO 不冲突:防守优先,机会次之,通知和执行权限分离。 + +## 策略消费方式 + +策略仓库只挂载 `market_regime_control/latest_signal.json`,不再直接消费旧的三个插件。 + +建议消费规则: + +- TQQQ 杠杆增长收益策略 + 默认消费 `position_control`。`risk_off` 降到现金类或非风险资产;`risk_reduced` 按策略配置降低杠杆或风险预算;TACO 只触发人工复核和本地 veto。 +- SOXL/SOXX 趋势收益策略 + 默认挂载统一插件。`risk_off` 生效;`risk_reduced` 可按配置选择通知-only 或部分降风险,避免过度干扰高波动半导体策略。 +- Global ETF、Russell 1000、Tech/Communication、Mega Cap 类轮动策略 + 默认支持统一插件。`risk_reduced` 建议做 50% 风险预算缩放,`risk_off` 建议归零风险资产预算。 +- DCA 或收入型低频策略 + 默认 notification-only,允许用户显式开启仓位影响。 + +旧插件仍可运行历史回测和兼容输出,但新策略集成应优先挂载 `market_regime_control`。 + +## 版本管理 + +当前对外契约是: + +- 统一插件 schema:`market_regime_control.v1` +- 仲裁器 schema:`market_regime_arbiter.v1` +- 运行器总 schema:`strategy_plugins.v1` + +升级原则: + +- 向后兼容字段可以在 v1 内新增。 +- 删除字段、改变字段语义或改变默认执行权限,需要升级到 v2。 +- 策略仓库应按 `schema_version` 校验可消费版本,并在配置层保留 opt-in/opt-out。 +- 旧插件标记 deprecated successor 为 `market_regime_control`,但保留历史入口方便复现旧回测。 + +## 回测结论 + +真实产品短中周期和长周期合成代理都支持当前设计。 + +TQQQ 2010-2026 真实产品窗口: + +- `cash_vol30_5_7_vol_confirmed` 方案相对基线 CAGR 提升约 `+1.03pp`。 +- 最大回撤改善约 `+0.29pp`。 +- COVID 窗口 CAGR 改善约 `+11.47pp`,最大回撤改善约 `+4.15pp`。 + +1999-2026 QQQ 合成 3x 长周期代理: + +- 纯危机上下文版本:CAGR `14.78% -> 18.93%`,最大回撤 `-94.54% -> -87.63%`。 +- 增强泡沫脆弱性版本:CAGR `14.78% -> 19.87%`,最大回撤 `-94.54% -> -86.60%`。 +- 金融危机窗口:CAGR `-45.25% -> -27.65%`,最大回撤 `-59.87% -> -40.47%`。 +- 互联网泡沫破裂窗口:增强泡沫脆弱性后 CAGR `-67.78% -> -51.69%`,最大回撤改善约 `+7.94pp`。 + +设计含义: + +- 金融危机主要由 `true_crisis` 路线负责,适合硬降风险。 +- 互联网泡沫不能只靠传统危机确认,需要保留泡沫脆弱性 route。 +- TACO 对这些长周期危机不是防守组件,应继续作为机会通知和人工复核组件。 + +## 当前推荐默认值 + +- 杠杆策略:默认挂载统一插件,允许 `risk_off` 生效。 +- 高波动行业杠杆策略:默认挂载统一插件,但 `risk_reduced` 是否影响仓位由策略配置控制。 +- 轮动策略:默认开启 50% risk scaling 和 `risk_off` 归零。 +- TACO:默认通知-only;只有没有危机和宏观降风险时才允许提示机会。 +- AI audit:默认不参与交易权限,只能写审计结论和通知证据。 From 78745c43e083521b00326715ef57a420ddeff45a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 00:05:45 +0800 Subject: [PATCH 06/11] Route SOXL market regime signals to notification only --- README.md | 8 +-- README.zh-CN.md | 2 +- docs/examples/strategy_plugins.example.toml | 9 ++-- docs/market-regime-control-plan.zh-CN.md | 4 +- .../strategy_plugin_runner.py | 11 +++- tests/test_strategy_plugin_runner.py | 51 ++++++++++++------- 6 files changed, 54 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 4b8d7c9..18641e9 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,11 @@ send notifications; plugin research and signal generation live here. index are kept as watch-only evidence and do not contribute to the actionable trading score. - `market_regime_control`: unified deterministic facade for crisis, macro, and - TACO signals. Levered strategies can consume position controls directly; - stock/ETF rotation strategies should consume the same artifact through their - local risk-scaling policy and keep TACO as notification-only. + TACO signals. Only strategies with positive backtest evidence should mount + position controls for automated consumption; SOXL/SOXX currently receives + broad macro/crisis signals as general notifications only. Stock/ETF rotation + strategies should consume the same artifact through their local risk-scaling + policy and keep TACO as notification-only. - `taco_rebound_shadow`: TQQQ-only event-rebound context notifier. It writes manual-review artifacts and never recommends position size or changes allocations. Softening/de-escalation events stay watch-only until post-event diff --git a/README.zh-CN.md b/README.zh-CN.md index 4a181cc..0abc30b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac - `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。 可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。 - `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数这类 OSINT 字段只作为 watch-only 证据,不进入可执行分数。 -- `market_regime_control`:统一确定性 facade,汇总 crisis、macro 和 TACO 信号,输出版本化的 `notification` 和 `position_control`。杠杆策略可直接消费仓位控制,股票/ETF 轮动策略通过本地风险缩放策略消费;TACO 在统一插件里保持通知-only,并会被危机和宏观降风险路线 veto。设计说明见 [Market Regime Control 统一插件方案](docs/market-regime-control-plan.zh-CN.md)。 +- `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)。 - `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。 该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。 - TACO panic-rebound 研究、组合回测和 overlay 对比也归属本仓库;snapshot pipeline 仓库只保留兼容入口。 diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index fbebe0f..c7a3890 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -33,11 +33,12 @@ taco_enabled = true output_dir = "data/output/tqqq_growth_income/plugins/market_regime_control" [[strategy_plugins]] -strategy = "soxl_soxx_trend_income" +strategy = "market_regime_notification" plugin = "market_regime_control" enabled = true -# SOXL uses the same macro/crisis contract. TACO stays disabled because the -# current rebound-event playbook is calibrated for TQQQ/QQQ. +# General market-regime notification. This artifact is not mounted into the +# SOXL/SOXX strategy runtime; sector-levered SOXL keeps its own validated SOXX +# volatility gate and humans decide whether broad macro/crisis notices matter. [strategy_plugins.inputs] prices = "data/output/market_regime_control/input/soxl_price_history.csv" @@ -58,7 +59,7 @@ crisis_enabled = true macro_enabled = true [strategy_plugins.outputs] -output_dir = "data/output/soxl_soxx_trend_income/plugins/market_regime_control" +output_dir = "data/output/market_regime_notification/plugins/market_regime_control" # Deprecated compatibility mounts. They remain runnable for historical # backtests and downstream consumers that have not migrated to diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md index b33cbea..a2a45f6 100644 --- a/docs/market-regime-control-plan.zh-CN.md +++ b/docs/market-regime-control-plan.zh-CN.md @@ -63,7 +63,7 @@ - TQQQ 杠杆增长收益策略 默认消费 `position_control`。`risk_off` 降到现金类或非风险资产;`risk_reduced` 按策略配置降低杠杆或风险预算;TACO 只触发人工复核和本地 veto。 - SOXL/SOXX 趋势收益策略 - 默认挂载统一插件。`risk_off` 生效;`risk_reduced` 可按配置选择通知-only 或部分降风险,避免过度干扰高波动半导体策略。 + 不默认挂载统一插件,也不消费 `position_control`。SOXL 继续只使用已经通过复核的 SOXX 自身趋势和波动率降杠杆门;宏观、危机和 OSINT 信号只进入通用通知,由人工决定是否干预。 - Global ETF、Russell 1000、Tech/Communication、Mega Cap 类轮动策略 默认支持统一插件。`risk_reduced` 建议做 50% 风险预算缩放,`risk_off` 建议归零风险资产预算。 - DCA 或收入型低频策略 @@ -112,7 +112,7 @@ TQQQ 2010-2026 真实产品窗口: ## 当前推荐默认值 - 杠杆策略:默认挂载统一插件,允许 `risk_off` 生效。 -- 高波动行业杠杆策略:默认挂载统一插件,但 `risk_reduced` 是否影响仓位由策略配置控制。 +- 高波动行业杠杆策略:除非回测证明自动消费能提升收益/回撤组合,否则不默认挂载统一插件;SOXL 当前只接收通用通知。 - 轮动策略:默认开启 50% risk scaling 和 `risk_off` 归零。 - TACO:默认通知-only;只有没有危机和宏观降风险时才允许提示机会。 - AI audit:默认不参与交易权限,只能写审计结论和通知证据。 diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index cd0aab9..54402dd 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -33,16 +33,17 @@ ) DEFAULT_RUNNER_OUTPUT_DIR = "data/output/strategy_plugins" +GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY = "market_regime_notification" PLUGIN_CRISIS_RESPONSE_SHADOW = "crisis_response_shadow" PLUGIN_MARKET_REGIME_CONTROL = MARKET_REGIME_CONTROL_PROFILE PLUGIN_MACRO_RISK_GOVERNOR = MACRO_RISK_GOVERNOR_PROFILE PLUGIN_TACO_REBOUND_SHADOW = TACO_REBOUND_PROFILE SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,) PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = { - PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income", "soxl_soxx_trend_income"), + PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income",), PLUGIN_MARKET_REGIME_CONTROL: ( + GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, "tqqq_growth_income", - "soxl_soxx_trend_income", "global_etf_rotation", "russell_1000_multi_factor_defensive", "tech_communication_pullback_enhancement", @@ -402,6 +403,11 @@ def _apply_plugin_contract(payload: Mapping[str, Any], *, strategy: str, plugin: execution_controls["effective_mode"] = mode execution_controls["repository_broker_write_allowed"] = False execution_controls["repository_allocation_mutation_allowed"] = False + if strategy == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY: + execution_controls["capital_impact"] = "notification_only" + execution_controls["strategy_runtime_metadata_allowed"] = False + execution_controls["position_control_shadow_only"] = True + execution_controls["intended_strategy_role"] = "general_market_regime_notification" execution_controls["mode_note"] = ( "Mode is the platform behavior contract; this repository writes artifacts and does not call brokers" ) @@ -628,6 +634,7 @@ def main(argv: list[str] | None = None) -> int: __all__ = [ + "GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY", "PLUGIN_CRISIS_RESPONSE_SHADOW", "PLUGIN_MARKET_REGIME_CONTROL", "PLUGIN_MACRO_RISK_GOVERNOR", diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index ec4d956..4097307 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -8,6 +8,7 @@ from quant_strategy_plugins.crisis_response_research import ROUTE_TRUE_CRISIS from quant_strategy_plugins.strategy_plugin_runner import ( + GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, PLUGIN_COMPATIBLE_STRATEGIES, PLUGIN_CRISIS_RESPONSE_SHADOW, PLUGIN_DEPRECATED_SUCCESSORS, @@ -266,16 +267,16 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_ assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False -def test_strategy_plugin_runner_runs_unified_market_regime_control_for_soxl(tmp_path) -> None: +def test_strategy_plugin_runner_runs_general_market_regime_notification(tmp_path) -> None: prices_path = tmp_path / "market_regime_prices.csv" - output_dir = tmp_path / SOXL_STRATEGY_NAME / "plugins" / PLUGIN_MARKET_REGIME_CONTROL + output_dir = tmp_path / GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY / "plugins" / PLUGIN_MARKET_REGIME_CONTROL _soxl_quiet_prices().to_csv(prices_path, index=False) config = { "output_dir": str(tmp_path / "runner"), "default_mode": "shadow", "strategy_plugins": [ { - "strategy": SOXL_STRATEGY_NAME, + "strategy": GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, "plugin": PLUGIN_MARKET_REGIME_CONTROL, "enabled": True, "inputs": { @@ -295,20 +296,42 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_soxl(tmp_ summary = run_configured_plugins(config) result = summary["strategy_plugins"][0] - assert result["strategy"] == SOXL_STRATEGY_NAME + assert result["strategy"] == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY assert result["plugin"] == PLUGIN_MARKET_REGIME_CONTROL assert result["status"] == "ok" payload = json.loads((output_dir / "latest_signal.json").read_text(encoding="utf-8")) - assert payload["strategy"] == SOXL_STRATEGY_NAME + assert payload["strategy"] == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY assert payload["plugin"] == PLUGIN_MARKET_REGIME_CONTROL assert payload["schema_version"] in PLUGIN_SCHEMA_VERSIONS[PLUGIN_MARKET_REGIME_CONTROL] assert payload["canonical_route"] == "no_action" + assert payload["execution_controls"]["capital_impact"] == "notification_only" + assert payload["execution_controls"]["strategy_runtime_metadata_allowed"] is False + + +def test_strategy_plugin_runner_rejects_soxl_market_regime_control_mount(tmp_path) -> None: + prices_path = tmp_path / "market_regime_prices.csv" + _soxl_quiet_prices().to_csv(prices_path, index=False) + config = { + "output_dir": str(tmp_path / "runner"), + "default_mode": "shadow", + "strategy_plugins": [ + { + "strategy": SOXL_STRATEGY_NAME, + "plugin": PLUGIN_MARKET_REGIME_CONTROL, + "enabled": True, + "inputs": {"prices": str(prices_path), "benchmark_symbol": "SOXX", "attack_symbol": "SOXL"}, + } + ], + } + + with pytest.raises(ValueError, match="strategy-limited"): + run_configured_plugins(config) def test_strategy_plugin_runner_contract_registry_prefers_unified_plugin() -> None: assert set(PLUGIN_COMPATIBLE_STRATEGIES[PLUGIN_MARKET_REGIME_CONTROL]) == { + GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, STRATEGY_NAME, - SOXL_STRATEGY_NAME, "global_etf_rotation", "russell_1000_multi_factor_defensive", "tech_communication_pullback_enhancement", @@ -433,9 +456,8 @@ def test_strategy_plugin_runner_uses_default_mode_when_entry_mode_is_omitted(tmp assert payload["execution_controls"]["notification_profile"] == "shadow_only" -def test_strategy_plugin_runner_mounts_crisis_shadow_to_soxl_strategy(tmp_path) -> None: +def test_strategy_plugin_runner_rejects_crisis_shadow_soxl_strategy_mount(tmp_path) -> None: prices_path = tmp_path / "soxl_prices.csv" - output_dir = tmp_path / SOXL_STRATEGY_NAME / "plugins" / PLUGIN_CRISIS_RESPONSE_SHADOW _soxl_quiet_prices().to_csv(prices_path, index=False) config = { "output_dir": str(tmp_path / "runner"), @@ -455,21 +477,12 @@ def test_strategy_plugin_runner_mounts_crisis_shadow_to_soxl_strategy(tmp_path) "credit_pairs": [], "rate_symbols": [], }, - "outputs": {"output_dir": str(output_dir)}, } ], } - summary = run_configured_plugins(config) - - result = summary["strategy_plugins"][0] - assert result["strategy"] == SOXL_STRATEGY_NAME - assert result["plugin"] == PLUGIN_CRISIS_RESPONSE_SHADOW - assert result["effective_mode"] == "shadow" - payload = json.loads((output_dir / "latest_signal.json").read_text(encoding="utf-8")) - assert payload["strategy"] == SOXL_STRATEGY_NAME - assert payload["evidence"]["metrics"]["benchmark_symbol"] == "SOXX" - assert payload["execution_controls"]["broker_order_allowed"] is False + with pytest.raises(ValueError, match="strategy-limited"): + run_configured_plugins(config) def test_strategy_plugin_runner_filters_by_strategy(tmp_path) -> None: From 5b1489407488be05d826f7f8a4db5239c21c9306 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 00:28:05 +0800 Subject: [PATCH 07/11] Add plugin consumption policy registry --- README.md | 5 +- README.zh-CN.md | 2 +- docs/market-regime-control-plan.zh-CN.md | 12 +- .../macro_risk_governor_plugin.py | 53 ++++++ .../strategy_plugin_runner.py | 169 ++++++++++++++++-- tests/test_macro_risk_governor_plugin.py | 33 ++++ tests/test_strategy_plugin_runner.py | 19 ++ 7 files changed, 270 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 18641e9..b7927ee 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ send notifications; plugin research and signal generation live here. optional external financial-stress fields. The artifact can expose `leverage_scalar` and `risk_asset_scalar` to strategy runtimes that explicitly opt in through mounted metadata. OSINT-style fields such as a Pentagon pizza - index are kept as watch-only evidence and do not contribute to the actionable - trading score. + index, plus sentiment fields such as Fear & Greed, put/call, and safe-haven + demand, are kept as watch-only evidence and do not contribute to the + actionable trading score. - `market_regime_control`: unified deterministic facade for crisis, macro, and TACO signals. Only strategies with positive backtest evidence should mount position controls for automated consumption; SOXL/SOXX currently receives diff --git a/README.zh-CN.md b/README.zh-CN.md index 0abc30b..c119b39 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,7 +25,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac - `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。 可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。 -- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数这类 OSINT 字段只作为 watch-only 证据,不进入可执行分数。 +- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand 这类 OSINT 或情绪字段只作为 watch-only 证据,不进入可执行分数。 - `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)。 - `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。 该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。 diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md index a2a45f6..5cf24de 100644 --- a/docs/market-regime-control-plan.zh-CN.md +++ b/docs/market-regime-control-plan.zh-CN.md @@ -26,7 +26,7 @@ - `crisis_response_shadow` 负责硬危机防守。`true_crisis` 和泡沫脆弱性触发后,统一插件输出 `risk_off` / `defend`,仓位目标交给策略侧 opt-in 执行。 - `macro_risk_governor` - 负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced` 或 `risk_off`。 + 负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced` 或 `risk_off`。Fear & Greed、put/call、safe-haven demand 和五角大楼比萨指数只作为 watch-only 证据,先用于通知和回测观察。 - `taco_rebound_shadow` 负责 TQQQ 事件反弹通知。它输出人工复核通知和本地 veto 线索,不直接提高仓位。 @@ -71,6 +71,15 @@ 旧插件仍可运行历史回测和兼容输出,但新策略集成应优先挂载 `market_regime_control`。 +策略插件 runner 使用显式消费权限 registry,而不是只维护松散 allowlist: + +- `notification_allowed`:允许生成和分发通知 artifact。 +- `position_control_allowed`:允许策略 runtime 自动消费仓位控制字段。 +- `evidence_status`:记录该策略/插件组合是 `automation_approved`、`notification_only` 还是 `deprecated_compatibility`。 +- `since_version`:记录该消费权限从哪个 runner schema 开始生效。 + +SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中;它通过 `market_regime_notification` 接收通用通知,避免配置误用把通知信号升级成自动调仓。 + ## 版本管理 当前对外契约是: @@ -78,6 +87,7 @@ - 统一插件 schema:`market_regime_control.v1` - 仲裁器 schema:`market_regime_arbiter.v1` - 运行器总 schema:`strategy_plugins.v1` +- 策略消费权限 schema:随 `strategy_plugins.v1` 通过 `consumption_policy` 输出 升级原则: diff --git a/src/quant_strategy_plugins/macro_risk_governor_plugin.py b/src/quant_strategy_plugins/macro_risk_governor_plugin.py index da487ee..6c2938f 100644 --- a/src/quant_strategy_plugins/macro_risk_governor_plugin.py +++ b/src/quant_strategy_plugins/macro_risk_governor_plugin.py @@ -258,6 +258,9 @@ def build_macro_risk_governor_signal( hy_oas_delta_threshold: float = 1.0, financial_stress_watch_level: float = 1.0, pizza_index_watch_level: float = 2.0, + fear_greed_extreme_fear_level: float = 25.0, + put_call_watch_level: float = 1.20, + safe_haven_demand_watch_level: float = 1.0, watch_score_threshold: float = 3.0, delever_score_threshold: float = 5.0, crisis_score_threshold: float = 7.0, @@ -448,6 +451,53 @@ def build_macro_risk_governor_signal( threshold=float(pizza_index_watch_level), actionable=False, ) + fear_greed_index = _external_float( + external_row, + "fear_greed_index", + "fear_and_greed_index", + "cnn_fear_greed_index", + "cnn_fear_and_greed_index", + ) + _add_check( + checks, + "fear_greed_extreme_fear_watch", + fear_greed_index is not None and fear_greed_index <= float(fear_greed_extreme_fear_level), + weight=1.0, + value=fear_greed_index, + threshold=float(fear_greed_extreme_fear_level), + actionable=False, + ) + put_call_ratio = _external_float( + external_row, + "put_call_ratio", + "equity_put_call_ratio", + "cboe_put_call_ratio", + "put_call", + ) + _add_check( + checks, + "put_call_stress_watch", + put_call_ratio is not None and put_call_ratio >= float(put_call_watch_level), + weight=1.0, + value=put_call_ratio, + threshold=float(put_call_watch_level), + actionable=False, + ) + safe_haven_demand = _external_float( + external_row, + "safe_haven_demand", + "safe_haven_demand_index", + "safe_haven_demand_zscore", + ) + _add_check( + checks, + "safe_haven_demand_watch", + safe_haven_demand is not None and safe_haven_demand >= float(safe_haven_demand_watch_level), + weight=1.0, + value=safe_haven_demand, + threshold=float(safe_haven_demand_watch_level), + actionable=False, + ) realized_vol_confirmed_for_action = None realized_vol_check = checks.get("benchmark_realized_volatility_high") if realized_vol_check is not None: @@ -476,6 +526,9 @@ def build_macro_risk_governor_signal( "hy_oas_delta_63d": hy_oas_delta, "financial_stress": financial_stress, "pentagon_pizza_index": pizza_index, + "fear_greed_index": fear_greed_index, + "put_call_ratio": put_call_ratio, + "safe_haven_demand": safe_haven_demand, "benchmark_realized_volatility_requires_confirmation": bool(realized_vol_requires_confirmation), "benchmark_realized_volatility_confirmed_for_action": realized_vol_confirmed_for_action, } diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index 54402dd..a5d69a6 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -39,19 +39,9 @@ PLUGIN_MACRO_RISK_GOVERNOR = MACRO_RISK_GOVERNOR_PROFILE PLUGIN_TACO_REBOUND_SHADOW = TACO_REBOUND_PROFILE SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,) -PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = { - PLUGIN_CRISIS_RESPONSE_SHADOW: ("tqqq_growth_income",), - PLUGIN_MARKET_REGIME_CONTROL: ( - GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, - "tqqq_growth_income", - "global_etf_rotation", - "russell_1000_multi_factor_defensive", - "tech_communication_pullback_enhancement", - "mega_cap_leader_rotation_top50_balanced", - ), - PLUGIN_MACRO_RISK_GOVERNOR: ("tqqq_growth_income",), - PLUGIN_TACO_REBOUND_SHADOW: ("tqqq_growth_income",), -} +EVIDENCE_AUTOMATION_APPROVED = "automation_approved" +EVIDENCE_NOTIFICATION_ONLY = "notification_only" +EVIDENCE_DEPRECATED_COMPATIBILITY = "deprecated_compatibility" PLUGIN_SCHEMA_VERSIONS: dict[str, tuple[str, ...]] = { PLUGIN_CRISIS_RESPONSE_SHADOW: ("crisis_response_shadow.v1",), PLUGIN_MARKET_REGIME_CONTROL: ("market_regime_control.v1",), @@ -79,6 +69,115 @@ class PluginRunResult: message: str = "" +@dataclass(frozen=True) +class PluginConsumptionPolicy: + plugin: str + strategy: str + notification_allowed: bool + position_control_allowed: bool + evidence_status: str + since_version: str + description: str + intended_strategy_role: str | None = None + + +PLUGIN_CONSUMPTION_POLICIES: tuple[PluginConsumptionPolicy, ...] = ( + PluginConsumptionPolicy( + plugin=PLUGIN_MARKET_REGIME_CONTROL, + strategy=GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, + notification_allowed=True, + position_control_allowed=False, + evidence_status=EVIDENCE_NOTIFICATION_ONLY, + since_version="strategy_plugins.v1", + description="General market-regime notice. Not mounted into an automated strategy runtime.", + intended_strategy_role="general_market_regime_notification", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_MARKET_REGIME_CONTROL, + strategy="tqqq_growth_income", + notification_allowed=True, + position_control_allowed=True, + evidence_status=EVIDENCE_AUTOMATION_APPROVED, + since_version="strategy_plugins.v1", + description="Backtested automatic macro/crisis risk controls for the TQQQ growth-income strategy.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_MARKET_REGIME_CONTROL, + strategy="global_etf_rotation", + notification_allowed=True, + position_control_allowed=True, + evidence_status=EVIDENCE_AUTOMATION_APPROVED, + since_version="strategy_plugins.v1", + description="Local risk-scaling consumer for broad ETF rotation.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_MARKET_REGIME_CONTROL, + strategy="russell_1000_multi_factor_defensive", + notification_allowed=True, + position_control_allowed=True, + evidence_status=EVIDENCE_AUTOMATION_APPROVED, + since_version="strategy_plugins.v1", + description="Local risk-scaling consumer for the Russell 1000 defensive sleeve.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_MARKET_REGIME_CONTROL, + strategy="tech_communication_pullback_enhancement", + notification_allowed=True, + position_control_allowed=True, + evidence_status=EVIDENCE_AUTOMATION_APPROVED, + since_version="strategy_plugins.v1", + description="Local risk-scaling consumer for the tech/communication pullback profile.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_MARKET_REGIME_CONTROL, + strategy="mega_cap_leader_rotation_top50_balanced", + notification_allowed=True, + position_control_allowed=True, + evidence_status=EVIDENCE_AUTOMATION_APPROVED, + since_version="strategy_plugins.v1", + description="Local risk-scaling consumer for the mega-cap leader rotation profile.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_CRISIS_RESPONSE_SHADOW, + strategy="tqqq_growth_income", + notification_allowed=True, + position_control_allowed=False, + evidence_status=EVIDENCE_DEPRECATED_COMPATIBILITY, + since_version="strategy_plugins.v1", + description="Deprecated direct crisis shadow mount kept for historical replay; new consumers use market_regime_control.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_MACRO_RISK_GOVERNOR, + strategy="tqqq_growth_income", + notification_allowed=True, + position_control_allowed=False, + evidence_status=EVIDENCE_DEPRECATED_COMPATIBILITY, + since_version="strategy_plugins.v1", + description="Deprecated direct macro governor mount kept for historical replay; new consumers use market_regime_control.", + ), + PluginConsumptionPolicy( + plugin=PLUGIN_TACO_REBOUND_SHADOW, + strategy="tqqq_growth_income", + notification_allowed=True, + position_control_allowed=False, + evidence_status=EVIDENCE_NOTIFICATION_ONLY, + since_version="strategy_plugins.v1", + description="Manual-review event rebound notifier for TQQQ only.", + ), +) +PLUGIN_CONSUMPTION_POLICY_REGISTRY: dict[tuple[str, str], PluginConsumptionPolicy] = { + (policy.plugin, policy.strategy): policy for policy in PLUGIN_CONSUMPTION_POLICIES +} +PLUGIN_COMPATIBLE_STRATEGIES: dict[str, tuple[str, ...]] = { + plugin: tuple( + policy.strategy + for policy in PLUGIN_CONSUMPTION_POLICIES + if policy.plugin == plugin and policy.notification_allowed + ) + for plugin in sorted({policy.plugin for policy in PLUGIN_CONSUMPTION_POLICIES}) +} + + PluginRunner = Callable[[Mapping[str, Any], str], PluginRunResult] PluginPayloadBuilder = Callable[[pd.DataFrame, Mapping[str, Any]], dict[str, Any]] PluginOutputWriter = Callable[[Mapping[str, Any], str | Path], Mapping[str, Path]] @@ -159,14 +258,19 @@ def _validate_plugin_strategy(plugin_name: str, strategy: str) -> None: raise ValueError( f"{plugin_name} is research-only and cannot be mounted to {strategy!r}: {research_only_reason}" ) - compatible = PLUGIN_COMPATIBLE_STRATEGIES.get(plugin_name, ()) - if compatible and strategy not in compatible: - choices = ", ".join(compatible) + policy = PLUGIN_CONSUMPTION_POLICY_REGISTRY.get((plugin_name, strategy)) + if policy is None or not policy.notification_allowed: + compatible = PLUGIN_COMPATIBLE_STRATEGIES.get(plugin_name, ()) + choices = ", ".join(compatible) if compatible else "(none)" raise ValueError( f"{plugin_name} is strategy-limited and can only be mounted to: {choices}; got strategy={strategy!r}" ) +def _plugin_consumption_policy(plugin_name: str, strategy: str) -> PluginConsumptionPolicy | None: + return PLUGIN_CONSUMPTION_POLICY_REGISTRY.get((plugin_name, strategy)) + + def _flatten_strategy_plugin_entry(entry: Mapping[str, Any]) -> dict[str, Any]: plugin_config = { key: value @@ -333,6 +437,9 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[ "hy_oas_delta_threshold", "financial_stress_watch_level", "pizza_index_watch_level", + "fear_greed_extreme_fear_level", + "put_call_watch_level", + "safe_haven_demand_watch_level", "watch_score_threshold", "delever_score_threshold", "crisis_score_threshold", @@ -389,13 +496,22 @@ def _mode_execution_controls(mode: str) -> dict[str, Any]: raise ValueError(f"unsupported plugin mode: {mode!r}") from exc -def _apply_plugin_contract(payload: Mapping[str, Any], *, strategy: str, plugin: str, mode: str) -> dict[str, Any]: +def _apply_plugin_contract( + payload: Mapping[str, Any], + *, + strategy: str, + plugin: str, + mode: str, + consumption_policy: PluginConsumptionPolicy | None = None, +) -> dict[str, Any]: contracted_payload = dict(payload) contracted_payload["strategy"] = strategy contracted_payload["plugin"] = plugin contracted_payload["mode"] = mode contracted_payload["configured_mode"] = mode contracted_payload["effective_mode"] = mode + if consumption_policy is not None: + contracted_payload["consumption_policy"] = asdict(consumption_policy) execution_controls = dict(contracted_payload.get("execution_controls") or {}) execution_controls.update(_mode_execution_controls(mode)) @@ -403,7 +519,11 @@ def _apply_plugin_contract(payload: Mapping[str, Any], *, strategy: str, plugin: execution_controls["effective_mode"] = mode execution_controls["repository_broker_write_allowed"] = False execution_controls["repository_allocation_mutation_allowed"] = False - if strategy == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY: + if consumption_policy is not None: + execution_controls["notification_allowed"] = bool(consumption_policy.notification_allowed) + execution_controls["position_control_allowed"] = bool(consumption_policy.position_control_allowed) + execution_controls["consumption_evidence_status"] = consumption_policy.evidence_status + if consumption_policy is not None and consumption_policy.intended_strategy_role == "general_market_regime_notification": execution_controls["capital_impact"] = "notification_only" execution_controls["strategy_runtime_metadata_allowed"] = False execution_controls["position_control_shadow_only"] = True @@ -482,12 +602,20 @@ def _run_table_strategy_plugin( message="plugin disabled", ) _validate_plugin_mode(plugin, mode) + _validate_plugin_strategy(plugin, strategy) + consumption_policy = _plugin_consumption_policy(plugin, strategy) prices_path = str(plugin_config.get("prices", "")).strip() if not prices_path: raise ValueError(f"{plugin} for strategy={strategy} requires a prices path") payload = spec.build_payload(read_table(prices_path), plugin_config) - payload = _apply_plugin_contract(payload, strategy=strategy, plugin=plugin, mode=mode) + payload = _apply_plugin_contract( + payload, + strategy=strategy, + plugin=plugin, + mode=mode, + consumption_policy=consumption_policy, + ) paths = spec.write_outputs(payload, output_dir) return PluginRunResult( strategy=strategy, @@ -640,9 +768,12 @@ def main(argv: list[str] | None = None) -> int: "PLUGIN_MACRO_RISK_GOVERNOR", "PLUGIN_TACO_REBOUND_SHADOW", "PLUGIN_COMPATIBLE_STRATEGIES", + "PLUGIN_CONSUMPTION_POLICIES", + "PLUGIN_CONSUMPTION_POLICY_REGISTRY", "PLUGIN_DEPRECATED_SUCCESSORS", "PLUGIN_RESEARCH_ONLY_REASONS", "PLUGIN_SCHEMA_VERSIONS", + "PluginConsumptionPolicy", "PluginRunResult", "load_plugin_config", "main", diff --git a/tests/test_macro_risk_governor_plugin.py b/tests/test_macro_risk_governor_plugin.py index dae85d0..a8229cd 100644 --- a/tests/test_macro_risk_governor_plugin.py +++ b/tests/test_macro_risk_governor_plugin.py @@ -114,6 +114,39 @@ def test_macro_risk_governor_keeps_pizza_index_watch_only() -> None: assert payload["checks"]["pentagon_pizza_watch"]["actionable"] is False +def test_macro_risk_governor_keeps_fear_greed_fields_watch_only() -> None: + external_context = pd.DataFrame( + [ + { + "as_of": "2025-12-31", + "fear_greed_index": 18.0, + "put_call_ratio": 1.35, + "safe_haven_demand": 1.4, + } + ] + ) + + payload = build_macro_risk_governor_signal( + _macro_prices(), + external_context=external_context, + as_of="2025-12-31", + watch_score_threshold=1.0, + ) + + assert payload["canonical_route"] == ROUTE_WATCH + assert payload["suggested_action"] == "watch_only" + assert payload["would_trade_if_enabled"] is False + assert payload["actionable_score"] == 0.0 + assert payload["total_score"] == 3.0 + assert payload["checks"]["fear_greed_extreme_fear_watch"]["active"] is True + assert payload["checks"]["fear_greed_extreme_fear_watch"]["actionable"] is False + assert payload["checks"]["put_call_stress_watch"]["active"] is True + assert payload["checks"]["put_call_stress_watch"]["actionable"] is False + assert payload["checks"]["safe_haven_demand_watch"]["active"] is True + assert payload["checks"]["safe_haven_demand_watch"]["actionable"] is False + assert payload["evidence"]["metrics"]["fear_greed_index"] == 18.0 + + def test_macro_risk_governor_requires_confirmation_for_realized_volatility_action() -> None: payload = build_macro_risk_governor_signal( _macro_prices(volatility_spike=True), diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index 4097307..60182e6 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -8,8 +8,11 @@ from quant_strategy_plugins.crisis_response_research import ROUTE_TRUE_CRISIS from quant_strategy_plugins.strategy_plugin_runner import ( + EVIDENCE_AUTOMATION_APPROVED, + EVIDENCE_NOTIFICATION_ONLY, GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY, PLUGIN_COMPATIBLE_STRATEGIES, + PLUGIN_CONSUMPTION_POLICY_REGISTRY, PLUGIN_CRISIS_RESPONSE_SHADOW, PLUGIN_DEPRECATED_SUCCESSORS, PLUGIN_MARKET_REGIME_CONTROL, @@ -263,6 +266,9 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_ assert payload["position_control"]["risk_asset_scalar"] == 0.0 assert payload["position_control"]["taco_allowed"] is False assert payload["execution_controls"]["strategy_runtime_metadata_allowed"] is True + assert payload["execution_controls"]["position_control_allowed"] is True + assert payload["execution_controls"]["consumption_evidence_status"] == EVIDENCE_AUTOMATION_APPROVED + assert payload["consumption_policy"]["position_control_allowed"] is True assert payload["execution_controls"]["broker_order_allowed"] is False assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False @@ -306,6 +312,9 @@ def test_strategy_plugin_runner_runs_general_market_regime_notification(tmp_path assert payload["canonical_route"] == "no_action" assert payload["execution_controls"]["capital_impact"] == "notification_only" assert payload["execution_controls"]["strategy_runtime_metadata_allowed"] is False + assert payload["execution_controls"]["position_control_allowed"] is False + assert payload["execution_controls"]["consumption_evidence_status"] == EVIDENCE_NOTIFICATION_ONLY + assert payload["consumption_policy"]["strategy"] == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY def test_strategy_plugin_runner_rejects_soxl_market_regime_control_mount(tmp_path) -> None: @@ -341,6 +350,16 @@ def test_strategy_plugin_runner_contract_registry_prefers_unified_plugin() -> No assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_CRISIS_RESPONSE_SHADOW] == PLUGIN_MARKET_REGIME_CONTROL assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_MACRO_RISK_GOVERNOR] == PLUGIN_MARKET_REGIME_CONTROL assert PLUGIN_DEPRECATED_SUCCESSORS[PLUGIN_TACO_REBOUND_SHADOW] == PLUGIN_MARKET_REGIME_CONTROL + assert ( + PLUGIN_MARKET_REGIME_CONTROL, + SOXL_STRATEGY_NAME, + ) not in PLUGIN_CONSUMPTION_POLICY_REGISTRY + assert PLUGIN_CONSUMPTION_POLICY_REGISTRY[ + (PLUGIN_MARKET_REGIME_CONTROL, GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY) + ].position_control_allowed is False + assert PLUGIN_CONSUMPTION_POLICY_REGISTRY[ + (PLUGIN_MARKET_REGIME_CONTROL, STRATEGY_NAME) + ].position_control_allowed is True def test_strategy_plugin_runner_rehearses_triggered_shadow_artifact_without_execution_permissions(tmp_path) -> None: From d62a34f6e69852bcfa26be08bd4734ceefcf1284 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 00:34:37 +0800 Subject: [PATCH 08/11] Add watch-only macro indicator coverage --- README.md | 7 +- README.zh-CN.md | 2 +- docs/examples/strategy_plugins.example.toml | 3 + docs/market-regime-control-plan.zh-CN.md | 8 +- .../macro_risk_governor_plugin.py | 266 +++++++++++++++++- .../strategy_plugin_runner.py | 17 ++ tests/test_macro_risk_governor_plugin.py | 59 ++++ 7 files changed, 356 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b7927ee..d8e7fa8 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,10 @@ send notifications; plugin research and signal generation live here. optional external financial-stress fields. The artifact can expose `leverage_scalar` and `risk_asset_scalar` to strategy runtimes that explicitly opt in through mounted metadata. OSINT-style fields such as a Pentagon pizza - index, plus sentiment fields such as Fear & Greed, put/call, and safe-haven - demand, are kept as watch-only evidence and do not contribute to the - actionable trading score. + index, plus sentiment, options-volatility, rates, breadth, funding, and + liquidity fields such as Fear & Greed, put/call, VVIX, SKEW, MOVE, yield + curves, dollar stress, and safe-haven demand, are kept as watch-only evidence + and do not contribute to the actionable trading score. - `market_regime_control`: unified deterministic facade for crisis, macro, and TACO signals. Only strategies with positive backtest evidence should mount position controls for automated consumption; SOXL/SOXX currently receives diff --git a/README.zh-CN.md b/README.zh-CN.md index c119b39..9e2249b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,7 +25,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac - `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。 可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。 -- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand 这类 OSINT 或情绪字段只作为 watch-only 证据,不进入可执行分数。 +- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数、Fear & Greed、put/call、VVIX、SKEW、MOVE、收益率曲线、美元压力、safe-haven demand 等 OSINT、情绪或跨资产字段只作为 watch-only 证据,不进入可执行分数。 - `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)。 - `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。 该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。 diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index c7a3890..d873719 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -17,6 +17,7 @@ event_set = "geopolitical-deescalation" benchmark_symbol = "QQQ" attack_symbol = "TQQQ" vix_symbols = ["VIX", "^VIX", "VIXCLS"] +vix3m_symbols = ["VIX3M", "^VIX3M", "VXV", "^VXV"] credit_pairs = ["HYG:IEF", "LQD:IEF"] financial_symbols = ["XLF", "KRE"] rate_symbols = ["IEF", "TLT"] @@ -47,6 +48,7 @@ event_set = "full" benchmark_symbol = "SOXX" attack_symbol = "SOXL" vix_symbols = ["VIX", "^VIX", "VIXCLS"] +vix3m_symbols = ["VIX3M", "^VIX3M", "VXV", "^VXV"] credit_pairs = ["HYG:IEF", "LQD:IEF"] financial_symbols = ["XLF", "KRE"] rate_symbols = ["IEF", "TLT"] @@ -93,6 +95,7 @@ external_context = "data/output/macro_risk_governor/input/external_context.csv" benchmark_symbol = "QQQ" attack_symbol = "TQQQ" vix_symbols = ["VIX", "^VIX", "VIXCLS"] +vix3m_symbols = ["VIX3M", "^VIX3M", "VXV", "^VXV"] credit_pairs = ["HYG:IEF", "LQD:IEF"] [strategy_plugins.outputs] diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md index 5cf24de..ac56634 100644 --- a/docs/market-regime-control-plan.zh-CN.md +++ b/docs/market-regime-control-plan.zh-CN.md @@ -26,7 +26,7 @@ - `crisis_response_shadow` 负责硬危机防守。`true_crisis` 和泡沫脆弱性触发后,统一插件输出 `risk_off` / `defend`,仓位目标交给策略侧 opt-in 执行。 - `macro_risk_governor` - 负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced` 或 `risk_off`。Fear & Greed、put/call、safe-haven demand 和五角大楼比萨指数只作为 watch-only 证据,先用于通知和回测观察。 + 负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced` 或 `risk_off`。Fear & Greed、put/call、safe-haven demand、VVIX、SKEW、MOVE、收益率曲线、美元压力、市场宽度、AAII/NAAIM 和五角大楼比萨指数只作为 watch-only 证据,先用于通知和回测观察。 - `taco_rebound_shadow` 负责 TQQQ 事件反弹通知。它输出人工复核通知和本地 veto 线索,不直接提高仓位。 @@ -80,6 +80,12 @@ SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中;它通过 `market_regime_notification` 接收通用通知,避免配置误用把通知信号升级成自动调仓。 +当前观察指标分层: + +- 已可执行打分:价格趋势、63/252 日回撤、实现波动、VIX 水平/尖峰、信用 ETF 相对压力、高收益 OAS、金融压力指数。 +- 已接入 watch-only:五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand、VIX/VIX3M 期限结构、VVIX、SKEW、MOVE、IG OAS、资金压力利差、10Y-2Y/10Y-3M 曲线、DXY 21 日压力、50/200 日市场宽度、新高新低、涨跌线回撤、AAII bearish-bullish spread、NAAIM exposure。 +- 未进入自动仓位:所有 watch-only 指标。它们只用于通知、证据归档和后续历史回测。 + ## 版本管理 当前对外契约是: diff --git a/src/quant_strategy_plugins/macro_risk_governor_plugin.py b/src/quant_strategy_plugins/macro_risk_governor_plugin.py index 6c2938f..e0775b4 100644 --- a/src/quant_strategy_plugins/macro_risk_governor_plugin.py +++ b/src/quant_strategy_plugins/macro_risk_governor_plugin.py @@ -18,6 +18,7 @@ DEFAULT_BENCHMARK_SYMBOL = "QQQ" DEFAULT_ATTACK_SYMBOL = "TQQQ" DEFAULT_VIX_SYMBOLS = ("VIX", "^VIX", "VIXCLS") +DEFAULT_VIX3M_SYMBOLS = ("VIX3M", "^VIX3M", "VXV", "^VXV") DEFAULT_CREDIT_PAIRS = (("HYG", "IEF"), ("LQD", "IEF")) DEFAULT_OUTPUT_DIR = "data/output/macro_risk_governor" DEFAULT_MAX_PRICE_AGE_DAYS = 4 @@ -179,6 +180,35 @@ def _add_check( } +def _add_external_watch_check( + checks: dict[str, dict[str, Any]], + external_row: pd.Series, + name: str, + aliases: Sequence[str], + *, + threshold: float, + direction: str, + weight: float = 1.0, +) -> float | None: + value = _external_float(external_row, *aliases) + if direction == "lte": + active = value is not None and value <= float(threshold) + elif direction == "gte": + active = value is not None and value >= float(threshold) + else: + raise ValueError(f"unsupported watch direction: {direction!r}") + _add_check( + checks, + name, + active, + weight=float(weight), + value=value, + threshold=float(threshold), + actionable=False, + ) + return value + + def _score_checks(checks: Mapping[str, Mapping[str, Any]], *, actionable_only: bool) -> float: score = 0.0 for check in checks.values(): @@ -238,6 +268,7 @@ def build_macro_risk_governor_signal( benchmark_symbol: str = DEFAULT_BENCHMARK_SYMBOL, attack_symbol: str = DEFAULT_ATTACK_SYMBOL, vix_symbols: Sequence[str] = DEFAULT_VIX_SYMBOLS, + vix3m_symbols: Sequence[str] = DEFAULT_VIX3M_SYMBOLS, credit_pairs: Sequence[tuple[str, str]] = DEFAULT_CREDIT_PAIRS, max_price_age_days: int = DEFAULT_MAX_PRICE_AGE_DAYS, max_external_context_age_days: int = DEFAULT_MAX_EXTERNAL_CONTEXT_AGE_DAYS, @@ -261,6 +292,21 @@ def build_macro_risk_governor_signal( fear_greed_extreme_fear_level: float = 25.0, put_call_watch_level: float = 1.20, safe_haven_demand_watch_level: float = 1.0, + vix_term_structure_watch_level: float = 1.0, + vvix_watch_level: float = 110.0, + skew_watch_level: float = 150.0, + move_watch_level: float = 130.0, + ig_oas_watch_level: float = 2.0, + ig_oas_delta_threshold: float = 0.5, + funding_stress_watch_level: float = 0.5, + yield_curve_inversion_watch_level: float = -0.50, + dollar_stress_return_threshold: float = 0.03, + pct_above_200d_watch_level: float = 0.35, + pct_above_50d_watch_level: float = 0.30, + new_high_new_low_spread_watch_level: float = -0.10, + advance_decline_drawdown_watch_level: float = -0.10, + aaii_bear_bull_spread_watch_level: float = 0.25, + naaim_exposure_watch_level: float = 40.0, watch_score_threshold: float = 3.0, delever_score_threshold: float = 5.0, crisis_score_threshold: float = 7.0, @@ -345,6 +391,8 @@ def build_macro_risk_governor_signal( vix_symbol = _first_available_symbol(close, vix_symbols) vix_level = None vix_spike = None + vix3m_level = None + vix_term_structure = None if vix_symbol is not None: vix = pd.to_numeric(close[vix_symbol], errors="coerce") vix_level = _as_float(vix.loc[signal_date]) @@ -355,6 +403,21 @@ def build_macro_risk_governor_signal( vix_spike = _external_float(external_row, "vix_5d_change", "vix_spike_5d") if vix_level is not None: evidence["vix_symbol"] = "external_context" + vix3m_symbol = _first_available_symbol(close, vix3m_symbols) + if vix3m_symbol is not None: + vix3m = pd.to_numeric(close[vix3m_symbol], errors="coerce") + vix3m_level = _as_float(vix3m.loc[signal_date]) + else: + vix3m_level = _external_float(external_row, "vix3m", "vix_3m", "vix3m_level", "vxv", "vxv_level") + vix_term_structure = _external_float( + external_row, + "vix_term_structure", + "vix_vix3m_ratio", + "vix3m_ratio", + "vix_to_vix3m", + ) + if vix_term_structure is None and vix_level is not None and vix3m_level is not None and vix3m_level > 0: + vix_term_structure = vix_level / vix3m_level _add_check( checks, "vix_watch_level", @@ -379,7 +442,23 @@ def build_macro_risk_governor_signal( value=vix_spike, threshold=float(vix_spike_threshold), ) - evidence["metrics"].update({"vix_level": vix_level, "vix_spike": vix_spike}) + _add_check( + checks, + "vix_term_structure_inverted_watch", + vix_term_structure is not None and vix_term_structure >= float(vix_term_structure_watch_level), + weight=1.0, + value=vix_term_structure, + threshold=float(vix_term_structure_watch_level), + actionable=False, + ) + evidence["metrics"].update( + { + "vix_level": vix_level, + "vix_spike": vix_spike, + "vix3m_level": vix3m_level, + "vix_term_structure": vix_term_structure, + } + ) credit_returns: dict[str, float | None] = {} credit_context_available = False @@ -498,6 +577,175 @@ def build_macro_risk_governor_signal( threshold=float(safe_haven_demand_watch_level), actionable=False, ) + vvix = _add_external_watch_check( + checks, + external_row, + "vvix_high_watch", + ("vvix", "vvix_index", "cboe_vvix"), + threshold=float(vvix_watch_level), + direction="gte", + ) + skew = _add_external_watch_check( + checks, + external_row, + "skew_high_watch", + ("skew", "skew_index", "cboe_skew"), + threshold=float(skew_watch_level), + direction="gte", + ) + move = _add_external_watch_check( + checks, + external_row, + "move_high_watch", + ("move", "move_index", "ice_bofaml_move", "bond_volatility_index"), + threshold=float(move_watch_level), + direction="gte", + ) + ig_oas = _add_external_watch_check( + checks, + external_row, + "ig_oas_watch_level", + ("ig_oas", "investment_grade_oas", "bamLC0A0CM", "bam_lc0a0cm"), + threshold=float(ig_oas_watch_level), + direction="gte", + weight=1.0, + ) + ig_oas_delta = _external_float(external_row, "ig_oas_delta_63d", "investment_grade_oas_delta_63d") + if ig_oas_delta is None: + ig_oas_delta = _external_delta( + external_context, + signal_date, + ("ig_oas", "investment_grade_oas", "bamLC0A0CM", "bam_lc0a0cm"), + int(hy_oas_delta_lookback_days), + ) + _add_check( + checks, + "ig_oas_widening_watch", + ig_oas_delta is not None and ig_oas_delta >= float(ig_oas_delta_threshold), + weight=1.0, + value=ig_oas_delta, + threshold=float(ig_oas_delta_threshold), + actionable=False, + ) + funding_stress = _add_external_watch_check( + checks, + external_row, + "funding_stress_watch", + ( + "ted_spread", + "libor_ois_spread", + "sofr_ois_spread", + "fra_ois_spread", + "funding_stress", + "funding_stress_index", + ), + threshold=float(funding_stress_watch_level), + direction="gte", + ) + yield_curve_10y2y = _external_float( + external_row, + "yield_curve_10y2y", + "10y2y", + "t10y2y", + "us10y2y", + "treasury_10y2y", + ) + yield_curve_10y3m = _external_float( + external_row, + "yield_curve_10y3m", + "10y3m", + "t10y3m", + "us10y3m", + "treasury_10y3m", + ) + yield_curve_min = min( + (value for value in (yield_curve_10y2y, yield_curve_10y3m) if value is not None), + default=None, + ) + _add_check( + checks, + "yield_curve_inversion_watch", + yield_curve_min is not None and yield_curve_min <= float(yield_curve_inversion_watch_level), + weight=1.0, + value=yield_curve_min, + threshold=float(yield_curve_inversion_watch_level), + actionable=False, + ) + dollar_return_21d = _add_external_watch_check( + checks, + external_row, + "dollar_stress_watch", + ("dxy_return_21d", "dollar_index_return_21d", "usd_return_21d", "dxy_21d_return"), + threshold=float(dollar_stress_return_threshold), + direction="gte", + ) + pct_above_200d = _add_external_watch_check( + checks, + external_row, + "market_breadth_pct_above_200d_watch", + ( + "pct_above_200d", + "percent_above_200d", + "spx_pct_above_200d", + "nasdaq_100_pct_above_200d", + ), + threshold=float(pct_above_200d_watch_level), + direction="lte", + ) + pct_above_50d = _add_external_watch_check( + checks, + external_row, + "market_breadth_pct_above_50d_watch", + ( + "pct_above_50d", + "percent_above_50d", + "spx_pct_above_50d", + "nasdaq_100_pct_above_50d", + ), + threshold=float(pct_above_50d_watch_level), + direction="lte", + ) + new_high_new_low_spread = _add_external_watch_check( + checks, + external_row, + "new_high_new_low_spread_watch", + ( + "new_high_new_low_spread", + "nh_nl_spread", + "new_highs_new_lows_spread", + "new_high_new_low_ratio", + ), + threshold=float(new_high_new_low_spread_watch_level), + direction="lte", + ) + advance_decline_drawdown = _add_external_watch_check( + checks, + external_row, + "advance_decline_drawdown_watch", + ( + "advance_decline_drawdown", + "ad_line_drawdown", + "advance_decline_line_drawdown", + ), + threshold=float(advance_decline_drawdown_watch_level), + direction="lte", + ) + aaii_bear_bull_spread = _add_external_watch_check( + checks, + external_row, + "aaii_bear_bull_spread_watch", + ("aaii_bear_bull_spread", "aaii_bears_minus_bulls", "aaii_bearish_bullish_spread"), + threshold=float(aaii_bear_bull_spread_watch_level), + direction="gte", + ) + naaim_exposure = _add_external_watch_check( + checks, + external_row, + "naaim_exposure_low_watch", + ("naaim_exposure", "naaim_exposure_index", "naaim_manager_exposure"), + threshold=float(naaim_exposure_watch_level), + direction="lte", + ) realized_vol_confirmed_for_action = None realized_vol_check = checks.get("benchmark_realized_volatility_high") if realized_vol_check is not None: @@ -529,6 +777,22 @@ def build_macro_risk_governor_signal( "fear_greed_index": fear_greed_index, "put_call_ratio": put_call_ratio, "safe_haven_demand": safe_haven_demand, + "vvix": vvix, + "skew": skew, + "move": move, + "ig_oas": ig_oas, + "ig_oas_delta_63d": ig_oas_delta, + "funding_stress": funding_stress, + "yield_curve_10y2y": yield_curve_10y2y, + "yield_curve_10y3m": yield_curve_10y3m, + "yield_curve_min": yield_curve_min, + "dollar_return_21d": dollar_return_21d, + "pct_above_200d": pct_above_200d, + "pct_above_50d": pct_above_50d, + "new_high_new_low_spread": new_high_new_low_spread, + "advance_decline_drawdown": advance_decline_drawdown, + "aaii_bear_bull_spread": aaii_bear_bull_spread, + "naaim_exposure": naaim_exposure, "benchmark_realized_volatility_requires_confirmation": bool(realized_vol_requires_confirmation), "benchmark_realized_volatility_confirmed_for_action": realized_vol_confirmed_for_action, } diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index a5d69a6..243092c 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -440,6 +440,21 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[ "fear_greed_extreme_fear_level", "put_call_watch_level", "safe_haven_demand_watch_level", + "vix_term_structure_watch_level", + "vvix_watch_level", + "skew_watch_level", + "move_watch_level", + "ig_oas_watch_level", + "ig_oas_delta_threshold", + "funding_stress_watch_level", + "yield_curve_inversion_watch_level", + "dollar_stress_return_threshold", + "pct_above_200d_watch_level", + "pct_above_50d_watch_level", + "new_high_new_low_spread_watch_level", + "advance_decline_drawdown_watch_level", + "aaii_bear_bull_spread_watch_level", + "naaim_exposure_watch_level", "watch_score_threshold", "delever_score_threshold", "crisis_score_threshold", @@ -474,6 +489,8 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[ kwargs[key] = _as_bool(plugin_config[key]) if "vix_symbols" in plugin_config: kwargs["vix_symbols"] = _as_str_tuple(plugin_config["vix_symbols"]) + if "vix3m_symbols" in plugin_config: + kwargs["vix3m_symbols"] = _as_str_tuple(plugin_config["vix3m_symbols"]) if "credit_pairs" in plugin_config: kwargs["credit_pairs"] = _as_credit_pairs(plugin_config["credit_pairs"]) return kwargs diff --git a/tests/test_macro_risk_governor_plugin.py b/tests/test_macro_risk_governor_plugin.py index a8229cd..7144c4c 100644 --- a/tests/test_macro_risk_governor_plugin.py +++ b/tests/test_macro_risk_governor_plugin.py @@ -147,6 +147,65 @@ def test_macro_risk_governor_keeps_fear_greed_fields_watch_only() -> None: assert payload["evidence"]["metrics"]["fear_greed_index"] == 18.0 +def test_macro_risk_governor_keeps_common_external_risk_indicators_watch_only() -> None: + external_context = pd.DataFrame( + [ + { + "as_of": "2025-12-31", + "vix_vix3m_ratio": 1.08, + "vvix": 125.0, + "skew": 160.0, + "move": 145.0, + "ig_oas": 2.6, + "ig_oas_delta_63d": 0.7, + "ted_spread": 0.8, + "yield_curve_10y2y": -0.8, + "dxy_return_21d": 0.04, + "pct_above_200d": 0.30, + "pct_above_50d": 0.25, + "new_high_new_low_spread": -0.20, + "advance_decline_drawdown": -0.15, + "aaii_bear_bull_spread": 0.35, + "naaim_exposure": 30.0, + } + ] + ) + + payload = build_macro_risk_governor_signal( + _macro_prices(), + external_context=external_context, + as_of="2025-12-31", + watch_score_threshold=1.0, + ) + + watch_checks = ( + "vix_term_structure_inverted_watch", + "vvix_high_watch", + "skew_high_watch", + "move_high_watch", + "ig_oas_watch_level", + "ig_oas_widening_watch", + "funding_stress_watch", + "yield_curve_inversion_watch", + "dollar_stress_watch", + "market_breadth_pct_above_200d_watch", + "market_breadth_pct_above_50d_watch", + "new_high_new_low_spread_watch", + "advance_decline_drawdown_watch", + "aaii_bear_bull_spread_watch", + "naaim_exposure_low_watch", + ) + assert payload["canonical_route"] == ROUTE_WATCH + assert payload["suggested_action"] == "watch_only" + assert payload["would_trade_if_enabled"] is False + assert payload["actionable_score"] == 0.0 + for check_name in watch_checks: + assert payload["checks"][check_name]["active"] is True + assert payload["checks"][check_name]["actionable"] is False + assert payload["evidence"]["metrics"]["vix_term_structure"] == 1.08 + assert payload["evidence"]["metrics"]["yield_curve_min"] == -0.8 + + def test_macro_risk_governor_requires_confirmation_for_realized_volatility_action() -> None: payload = build_macro_risk_governor_signal( _macro_prices(volatility_spike=True), From fe10cdae68ca77d41fd96c86c3fd57c93ccb0a60 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 01:20:35 +0800 Subject: [PATCH 09/11] Add macro external context builder --- README.md | 17 + README.zh-CN.md | 15 + docs/examples/strategy_plugins.example.toml | 2 + docs/market-regime-control-plan.zh-CN.md | 14 + pyproject.toml | 3 +- .../macro_external_context.py | 465 ++++++++++++++++++ tests/test_macro_external_context.py | 132 +++++ 7 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 src/quant_strategy_plugins/macro_external_context.py create mode 100644 tests/test_macro_external_context.py diff --git a/README.md b/README.md index d8e7fa8..91f0459 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,23 @@ qsp-build-crisis-response-shadow-signal \ --output-dir data/output/tqqq_growth_income/plugins/crisis_response_shadow ``` +Build the public hard-data `external_context` CSV used by macro and unified +market-regime plugins: + +```bash +qsp-build-macro-external-context \ + --start 1999-01-01 \ + --output data/output/market_regime_control/input/external_context.csv +``` + +The builder downloads public FRED/CBOE fields when available: VIX, VIX3M, +VVIX, SKEW, Cboe put/call ratios, HY/IG OAS, financial-stress indices, yield +curves, trade-weighted dollar stress, and TED/funding stress. Fields without a +stable no-login historical feed, such as CNN Fear & Greed, AAII, NAAIM, +Pentagon pizza, MOVE, and breadth, can be supplied with `--manual-context`. +OAS coverage follows what the public FRED graph endpoint returns; archived +local OAS history can be injected with the same manual context path. + AI audit reads API settings from environment variables: - `QSP_STRATEGY_PLUGIN_AI_AUDIT_CODEX_ENABLED`, default `true` diff --git a/README.zh-CN.md b/README.zh-CN.md index 9e2249b..1c6f1a4 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,6 +49,21 @@ qsp-build-crisis-response-shadow-signal \ --output-dir data/output/tqqq_growth_income/plugins/crisis_response_shadow ``` +生成宏观和统一市场状态插件使用的公开硬数据 `external_context` CSV: + +```bash +qsp-build-macro-external-context \ + --start 1999-01-01 \ + --output data/output/market_regime_control/input/external_context.csv +``` + +构建器会尽量下载公开 FRED/CBOE 字段:VIX、VIX3M、VVIX、SKEW、Cboe +put/call、HY/IG OAS、金融压力指数、收益率曲线、贸易加权美元压力和 +TED/funding stress。CNN Fear & Greed、AAII、NAAIM、五角大楼比萨指数、 +MOVE、市场宽度等没有稳定免登录历史源的字段,可以通过 `--manual-context` +提供。OAS 覆盖范围以 FRED 公开 graph endpoint 实际返回为准;如果需要更早的 +本地归档 OAS 历史,也通过同一个 manual context 注入。 + AI audit 使用环境变量读取 API 配置: - `QSP_STRATEGY_PLUGIN_AI_AUDIT_CODEX_ENABLED`,默认 `true` diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index d873719..6c9afeb 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -12,6 +12,8 @@ enabled = true [strategy_plugins.inputs] prices = "data/output/market_regime_control/input/tqqq_price_history.csv" +# Build with: +# qsp-build-macro-external-context --start 1999-01-01 --output data/output/market_regime_control/input/external_context.csv external_context = "data/output/market_regime_control/input/external_context.csv" event_set = "geopolitical-deescalation" benchmark_symbol = "QQQ" diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md index ac56634..2e4bfa3 100644 --- a/docs/market-regime-control-plan.zh-CN.md +++ b/docs/market-regime-control-plan.zh-CN.md @@ -86,6 +86,13 @@ SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中 - 已接入 watch-only:五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand、VIX/VIX3M 期限结构、VVIX、SKEW、MOVE、IG OAS、资金压力利差、10Y-2Y/10Y-3M 曲线、DXY 21 日压力、50/200 日市场宽度、新高新低、涨跌线回撤、AAII bearish-bullish spread、NAAIM exposure。 - 未进入自动仓位:所有 watch-only 指标。它们只用于通知、证据归档和后续历史回测。 +公开历史数据构建: + +- `qsp-build-macro-external-context` 生成统一 `external_context.csv`,供 `macro_risk_governor` 和 `market_regime_control` 使用。 +- 自动下载 FRED/CBOE 可稳定复现的硬数据:VIX、VIX3M、VVIX、SKEW、Cboe put/call、HY/IG OAS、STLFSI/NFCI/ANFCI、10Y-2Y、10Y-3M、贸易加权美元指数和 TED/funding stress。 +- CNN Fear & Greed、AAII、NAAIM、五角大楼比萨指数、MOVE、市场宽度等没有稳定免登录历史 CSV 的字段,不在构建器里伪造;需要通过 `--manual-context` 注入,仍默认 watch-only。 +- ICE BofA OAS 取决于 FRED 公开 graph endpoint 的可返回历史长度;如果 FRED 只返回近年滚动窗口,长周期信用压力仍以 HYG/IEF、LQD/IEF 等 ETF 相对压力为主,历史 OAS 可用 `--manual-context` 注入。 + ## 版本管理 当前对外契约是: @@ -112,6 +119,13 @@ TQQQ 2010-2026 真实产品窗口: - 最大回撤改善约 `+0.29pp`。 - COVID 窗口 CAGR 改善约 `+11.47pp`,最大回撤改善约 `+4.15pp`。 +公开外部上下文补充回测(2010-02-12 到 2026-04-16): + +- 核心宏观信号不加外部上下文:CAGR `24.74% -> 25.77%`,最大回撤 `-35.07% -> -34.77%`,最终权益约 `+14.23%`。 +- 外部字段仅 watch-only:CAGR、回撤和最终权益与核心宏观信号完全一致;watch 天数从 `343` 增至 `558`,证明新增跨资产/情绪字段只增加通知,不影响自动仓位。 +- 外部硬数据直接参与可执行分数:CAGR `24.99%`,最大回撤 `-34.72%`,最终权益仅较基线 `+3.33%`;比核心宏观信号保守,收益/回撤组合较差。 +- 结论:新增外部指标默认应保持 watch-only;只有价格/VIX/信用 ETF 等核心信号继续参与自动仓位。STLFSI/HY OAS 等硬数据可保留为研究开关,不宜默认提升自动降仓权限。 + 1999-2026 QQQ 合成 3x 长周期代理: - 纯危机上下文版本:CAGR `14.78% -> 18.93%`,最大回撤 `-94.54% -> -87.63%`。 diff --git a/pyproject.toml b/pyproject.toml index 00fe9ab..eeea07a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quant-strategy-plugins" -version = "0.1.1" +version = "0.1.2" description = "Open sidecar strategy plugins that emit QuantPlatformKit-compatible signal artifacts." readme = "README.md" requires-python = ">=3.11" @@ -22,6 +22,7 @@ test = ["pytest>=8", "ruff>=0.8"] [project.scripts] qsp-build-crisis-response-shadow-signal = "quant_strategy_plugins.crisis_response_shadow_plugin:main" +qsp-build-macro-external-context = "quant_strategy_plugins.macro_external_context:main" qsp-build-macro-risk-governor-signal = "quant_strategy_plugins.macro_risk_governor_plugin:main" qsp-build-taco-rebound-shadow-signal = "quant_strategy_plugins.taco_rebound_shadow_plugin:main" qsp-run-strategy-plugins = "quant_strategy_plugins.strategy_plugin_runner:main" diff --git a/src/quant_strategy_plugins/macro_external_context.py b/src/quant_strategy_plugins/macro_external_context.py new file mode 100644 index 0000000..2e24e40 --- /dev/null +++ b/src/quant_strategy_plugins/macro_external_context.py @@ -0,0 +1,465 @@ +from __future__ import annotations + +import argparse +import json +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlencode + +import pandas as pd + +FRED_GRAPH_URL = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}" +CBOE_INDEX_HISTORY_URL = "https://cdn.cboe.com/api/global/us_indices/daily_prices/{symbol}_History.csv" +CBOE_PUT_CALL_URL = "https://cdn.cboe.com/resources/options/volume_and_call_put_ratios/{kind}.csv" + +DEFAULT_START_DATE = "1999-01-01" +DEFAULT_OUTPUT_PATH = "data/output/macro_external_context/external_context.csv" + +DEFAULT_FRED_SERIES: Mapping[str, str] = { + "VIXCLS": "vix", + "VXVCLS": "vix3m", + "BAMLH0A0HYM2": "hy_oas", + "BAMLC0A0CM": "ig_oas", + "STLFSI4": "stlfsi4", + "NFCI": "nfci", + "ANFCI": "anfci", + "T10Y2Y": "yield_curve_10y2y", + "T10Y3M": "yield_curve_10y3m", + "DTWEXBGS": "dollar_index", + "TEDRATE": "ted_spread", +} +DEFAULT_CBOE_INDEX_SERIES: Mapping[str, str] = { + "VIX": "vix", + "VIX3M": "vix3m", + "VVIX": "vvix", + "SKEW": "skew", +} +DEFAULT_CBOE_PUT_CALL_SERIES: Mapping[str, str] = { + "totalpc": "put_call_ratio", + "equitypc": "equity_put_call_ratio", + "indexpc": "index_put_call_ratio", +} +DEFAULT_EMPTY_EXTERNAL_FIELDS = ( + "fear_greed_index", + "pentagon_pizza_index", + "safe_haven_demand", + "move", + "pct_above_200d", + "pct_above_50d", + "new_high_new_low_spread", + "advance_decline_drawdown", + "aaii_bear_bull_spread", + "naaim_exposure", +) + + +@dataclass(frozen=True) +class SourceCoverage: + source: str + column: str + status: str + start: str | None = None + end: str | None = None + rows: int = 0 + message: str = "" + + +def _normalize_timestamp(value: object | None) -> pd.Timestamp | None: + if value is None: + return None + timestamp = pd.to_datetime(value, errors="coerce") + if pd.isna(timestamp): + return None + timestamp = pd.Timestamp(timestamp) + if timestamp.tzinfo is not None: + timestamp = timestamp.tz_convert(None) + return timestamp.normalize() + + +def _date_mask(series: pd.Series, *, start: str | None, end: str | None) -> pd.Series: + mask = pd.Series(True, index=series.index) + start_ts = _normalize_timestamp(start) + end_ts = _normalize_timestamp(end) + if start_ts is not None: + mask &= series >= start_ts + if end_ts is not None: + mask &= series <= end_ts + return mask + + +def _clean_numeric_series(frame: pd.DataFrame, *, date_column: str, value_column: str) -> pd.Series: + if date_column not in frame.columns: + raise ValueError(f"missing date column: {date_column}") + if value_column not in frame.columns: + raise ValueError(f"missing value column: {value_column}") + dates = pd.to_datetime(frame[date_column], errors="coerce").dt.tz_localize(None).dt.normalize() + values = pd.to_numeric(frame[value_column].replace(".", pd.NA), errors="coerce") + cleaned = ( + pd.DataFrame({"as_of": dates, "value": values}) + .dropna(subset=["as_of", "value"]) + .drop_duplicates(subset=["as_of"], keep="last") + .sort_values("as_of") + .set_index("as_of")["value"] + ) + cleaned.index.name = "as_of" + return cleaned.astype(float) + + +def _coverage(source: str, column: str, series: pd.Series, *, status: str = "ok", message: str = "") -> SourceCoverage: + values = pd.to_numeric(series, errors="coerce").dropna() + if values.empty: + empty_status = "empty" if status == "ok" else status + return SourceCoverage(source=source, column=column, status=empty_status, message=message) + return SourceCoverage( + source=source, + column=column, + status=status, + start=values.index.min().date().isoformat(), + end=values.index.max().date().isoformat(), + rows=int(values.shape[0]), + message=message, + ) + + +def fetch_fred_series(series_id: str, *, start: str | None = None, end: str | None = None) -> pd.Series: + series_id = str(series_id or "").strip().upper() + if not series_id: + raise ValueError("series_id is required") + params: dict[str, str] = {} + if start: + params["cosd"] = str(start) + if end: + params["coed"] = str(end) + query = f"&{urlencode(params)}" if params else "" + frame = pd.read_csv(f"{FRED_GRAPH_URL.format(series_id=series_id)}{query}") + values = _clean_numeric_series(frame, date_column="observation_date", value_column=series_id) + if values.empty: + return values + mask = _date_mask(values.index.to_series(index=values.index), start=start, end=end) + return values.loc[mask] + + +def _cboe_value_column(frame: pd.DataFrame, symbol: str) -> str: + symbol = str(symbol or "").strip().upper() + columns = {str(column).strip().upper(): column for column in frame.columns} + for candidate in ("CLOSE", symbol, "LAST"): + column = columns.get(candidate) + if column is not None: + return str(column) + raise ValueError(f"missing CBOE value column for {symbol}") + + +def fetch_cboe_index_history(symbol: str, *, start: str | None = None, end: str | None = None) -> pd.Series: + symbol = str(symbol or "").strip().upper() + if not symbol: + raise ValueError("symbol is required") + frame = pd.read_csv(CBOE_INDEX_HISTORY_URL.format(symbol=symbol)) + values = _clean_numeric_series(frame, date_column="DATE", value_column=_cboe_value_column(frame, symbol)) + if values.empty: + return values + mask = _date_mask(values.index.to_series(index=values.index), start=start, end=end) + return values.loc[mask] + + +def fetch_cboe_put_call_ratio(kind: str, *, start: str | None = None, end: str | None = None) -> pd.Series: + kind = str(kind or "").strip().lower() + if not kind: + raise ValueError("kind is required") + frame = pd.read_csv(CBOE_PUT_CALL_URL.format(kind=kind), skiprows=2) + values = _clean_numeric_series(frame, date_column="DATE", value_column="P/C Ratio") + if values.empty: + return values + mask = _date_mask(values.index.to_series(index=values.index), start=start, end=end) + return values.loc[mask] + + +def read_manual_context(path: str | Path) -> pd.DataFrame: + manual_path = Path(path) + if not manual_path.exists(): + raise FileNotFoundError(f"manual context file not found: {manual_path}") + if manual_path.suffix.lower() == ".csv": + frame = pd.read_csv(manual_path) + elif manual_path.suffix.lower() in {".json", ".jsonl"}: + frame = pd.read_json(manual_path, orient="records", lines=manual_path.suffix.lower() == ".jsonl") + else: + raise ValueError("manual context must be .csv, .json, or .jsonl") + if "as_of" not in frame.columns: + raise ValueError("manual context missing required column: as_of") + frame = frame.copy() + frame["as_of"] = pd.to_datetime(frame["as_of"], errors="coerce").dt.tz_localize(None).dt.normalize() + frame = frame.dropna(subset=["as_of"]).drop_duplicates(subset=["as_of"], keep="last").sort_values("as_of") + return frame + + +def _merge_series(frame: pd.DataFrame, column: str, series: pd.Series, *, prefer_new: bool = True) -> pd.DataFrame: + column = str(column or "").strip() + if not column or series.empty: + return frame + values = pd.to_numeric(series, errors="coerce").dropna() + if values.empty: + return frame + values.name = column + merged = frame.join(values, how="outer", rsuffix="__new") + new_column = f"{column}__new" + if new_column in merged.columns: + existing = pd.to_numeric(merged[column], errors="coerce") + incoming = pd.to_numeric(merged[new_column], errors="coerce") + merged[column] = incoming.combine_first(existing) if prefer_new else existing.combine_first(incoming) + merged = merged.drop(columns=[new_column]) + return merged.sort_index() + + +def _merge_manual_context(frame: pd.DataFrame, manual_context: pd.DataFrame | None) -> pd.DataFrame: + if manual_context is None or manual_context.empty: + return frame + manual = manual_context.copy() + manual["as_of"] = pd.to_datetime(manual["as_of"], errors="coerce").dt.tz_localize(None).dt.normalize() + manual = manual.dropna(subset=["as_of"]).drop_duplicates("as_of", keep="last").set_index("as_of").sort_index() + for column in manual.columns: + frame = _merge_series(frame, str(column), manual[column], prefer_new=True) + return frame + + +def _add_derived_columns(frame: pd.DataFrame, *, return_lookback_days: int, delta_lookback_days: int) -> pd.DataFrame: + result = frame.copy().sort_index() + if {"vix", "vix3m"} <= set(result.columns): + denominator = pd.to_numeric(result["vix3m"], errors="coerce") + result["vix_vix3m_ratio"] = pd.to_numeric(result["vix"], errors="coerce") / denominator.where(denominator > 0.0) + if "hy_oas" in result.columns: + result["hy_oas_delta_63d"] = pd.to_numeric(result["hy_oas"], errors="coerce").diff(int(delta_lookback_days)) + if "ig_oas" in result.columns: + result["ig_oas_delta_63d"] = pd.to_numeric(result["ig_oas"], errors="coerce").diff(int(delta_lookback_days)) + if "stlfsi4" in result.columns and "financial_stress" not in result.columns: + result["financial_stress"] = pd.to_numeric(result["stlfsi4"], errors="coerce") + if "ted_spread" in result.columns and "funding_stress" not in result.columns: + result["funding_stress"] = pd.to_numeric(result["ted_spread"], errors="coerce") + if "dollar_index" in result.columns: + dollar = pd.to_numeric(result["dollar_index"], errors="coerce") + result["dollar_index_return_21d"] = dollar / dollar.shift(int(return_lookback_days)) - 1.0 + result["dxy_return_21d"] = result["dollar_index_return_21d"] + return result + + +def _bounded_ffill(frame: pd.DataFrame, *, max_staleness_days: int) -> pd.DataFrame: + if frame.empty: + return frame + max_staleness_days = int(max_staleness_days) + if max_staleness_days < 0: + return frame + result = frame.sort_index().copy() + index_dates = pd.Series(result.index, index=result.index) + for column in result.columns: + values = pd.to_numeric(result[column], errors="coerce") + valid_dates = index_dates.where(values.notna()).ffill() + filled = values.ffill() + ages = (index_dates - valid_dates).dt.days + result[column] = filled.where(ages <= max_staleness_days) + return result + + +def _finalize_frame( + frame: pd.DataFrame, + *, + start: str | None, + end: str | None, + empty_fields: Sequence[str], + include_empty_fields: bool, +) -> pd.DataFrame: + if frame.empty: + result = pd.DataFrame(columns=["as_of"]) + else: + result = frame.sort_index().copy() + dates = result.index.to_series(index=result.index) + mask = _date_mask(dates, start=start, end=end) + result = result.loc[mask] + result.index.name = "as_of" + result = result.reset_index() + result["as_of"] = pd.to_datetime(result["as_of"], errors="coerce").dt.strftime("%Y-%m-%d") + if include_empty_fields: + for column in empty_fields: + if column not in result.columns: + result[column] = pd.NA + non_date_columns = [column for column in result.columns if column != "as_of"] + result = result.dropna(how="all", subset=non_date_columns) if non_date_columns else result + ordered_columns = ["as_of"] + sorted(column for column in result.columns if column != "as_of") + return result.loc[:, ordered_columns].reset_index(drop=True) + + +def build_macro_external_context( + *, + start: str | None = DEFAULT_START_DATE, + end: str | None = None, + fred_series: Mapping[str, str] = DEFAULT_FRED_SERIES, + cboe_index_series: Mapping[str, str] = DEFAULT_CBOE_INDEX_SERIES, + cboe_put_call_series: Mapping[str, str] = DEFAULT_CBOE_PUT_CALL_SERIES, + manual_context: pd.DataFrame | None = None, + include_empty_fields: bool = False, + return_lookback_days: int = 21, + delta_lookback_days: int = 63, + max_field_staleness_days: int = 10, + fred_reader: Callable[..., pd.Series] = fetch_fred_series, + cboe_index_reader: Callable[..., pd.Series] = fetch_cboe_index_history, + cboe_put_call_reader: Callable[..., pd.Series] = fetch_cboe_put_call_ratio, +) -> tuple[pd.DataFrame, list[SourceCoverage]]: + frame = pd.DataFrame() + frame.index = pd.DatetimeIndex([], name="as_of") + coverage: list[SourceCoverage] = [] + + for series_id, column in fred_series.items(): + source = f"fred:{series_id}" + try: + values = fred_reader(series_id, start=start, end=end) + frame = _merge_series(frame, column, values, prefer_new=False) + coverage.append(_coverage(source, column, values)) + except Exception as exc: # pragma: no cover - exact network failures vary by environment + coverage.append(SourceCoverage(source=source, column=column, status="error", message=str(exc))) + + for symbol, column in cboe_index_series.items(): + source = f"cboe_index:{symbol}" + try: + values = cboe_index_reader(symbol, start=start, end=end) + frame = _merge_series(frame, column, values, prefer_new=True) + coverage.append(_coverage(source, column, values)) + except Exception as exc: # pragma: no cover - exact network failures vary by environment + coverage.append(SourceCoverage(source=source, column=column, status="error", message=str(exc))) + + for kind, column in cboe_put_call_series.items(): + source = f"cboe_put_call:{kind}" + try: + values = cboe_put_call_reader(kind, start=start, end=end) + frame = _merge_series(frame, column, values, prefer_new=True) + coverage.append(_coverage(source, column, values)) + except Exception as exc: # pragma: no cover - exact network failures vary by environment + coverage.append(SourceCoverage(source=source, column=column, status="error", message=str(exc))) + + frame = _merge_manual_context(frame, manual_context) + frame = _add_derived_columns( + frame, + return_lookback_days=int(return_lookback_days), + delta_lookback_days=int(delta_lookback_days), + ) + frame = _bounded_ffill(frame, max_staleness_days=int(max_field_staleness_days)) + result = _finalize_frame( + frame, + start=start, + end=end, + empty_fields=DEFAULT_EMPTY_EXTERNAL_FIELDS, + include_empty_fields=include_empty_fields, + ) + return result, coverage + + +def write_macro_external_context_outputs( + frame: pd.DataFrame, + coverage: Sequence[SourceCoverage], + output_path: str | Path, + *, + include_manifest: bool = True, +) -> dict[str, Path]: + target = Path(output_path) + target.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(target, index=False) + paths = {"external_context": target} + if include_manifest: + manifest_path = target.with_suffix(target.suffix + ".manifest.json") + manifest = { + "schema_version": "macro_external_context.v1", + "generated_at": datetime.now(timezone.utc).isoformat(), + "output_path": str(target), + "rows": int(frame.shape[0]), + "columns": list(frame.columns), + "coverage": [coverage_item.__dict__ for coverage_item in coverage], + "notes": [ + "FRED and CBOE public data are populated when available.", + ( + "CNN Fear & Greed, AAII, NAAIM, Pentagon pizza, MOVE, and breadth fields can be " + "supplied through manual_context." + ), + ( + "ICE BofA OAS coverage follows the public FRED graph endpoint; if FRED limits " + "history, archived local OAS data should be supplied through manual_context." + ), + ], + } + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + paths["manifest"] = manifest_path + return paths + + +def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build macro risk governor external context from public hard-data sources." + ) + parser.add_argument("--start", default=DEFAULT_START_DATE, help="Earliest as_of date to include.") + parser.add_argument( + "--end", + default=None, + help="Latest as_of date to include. Defaults to latest available source rows.", + ) + parser.add_argument("--output", default=DEFAULT_OUTPUT_PATH, help="Output CSV path.") + parser.add_argument( + "--manual-context", + default=None, + help=( + "Optional CSV/JSON/JSONL with as_of plus extra fields. Values override downloaded columns " + "on matching dates." + ), + ) + parser.add_argument( + "--include-empty-fields", + action="store_true", + help="Include known manually sourced columns even when no values are downloaded.", + ) + parser.add_argument( + "--no-manifest", + action="store_true", + help="Do not write the companion manifest JSON.", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + manual_context = read_manual_context(args.manual_context) if args.manual_context else None + frame, coverage = build_macro_external_context( + start=args.start, + end=args.end, + manual_context=manual_context, + include_empty_fields=bool(args.include_empty_fields), + ) + paths = write_macro_external_context_outputs( + frame, + coverage, + args.output, + include_manifest=not bool(args.no_manifest), + ) + ok_sources = sum(1 for item in coverage if item.status == "ok") + error_sources = sum(1 for item in coverage if item.status == "error") + print( + f"wrote {paths['external_context']} rows={len(frame)} columns={len(frame.columns)} " + f"sources_ok={ok_sources} sources_error={error_sources}", + flush=True, + ) + if "manifest" in paths: + print(f"wrote {paths['manifest']}", flush=True) + return 0 + + +__all__ = [ + "DEFAULT_CBOE_INDEX_SERIES", + "DEFAULT_CBOE_PUT_CALL_SERIES", + "DEFAULT_FRED_SERIES", + "SourceCoverage", + "build_macro_external_context", + "fetch_cboe_index_history", + "fetch_cboe_put_call_ratio", + "fetch_fred_series", + "read_manual_context", + "write_macro_external_context_outputs", +] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_macro_external_context.py b/tests/test_macro_external_context.py new file mode 100644 index 0000000..e0a7e60 --- /dev/null +++ b/tests/test_macro_external_context.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json + +import pandas as pd + +from quant_strategy_plugins.macro_external_context import ( + SourceCoverage, + build_macro_external_context, + read_manual_context, + write_macro_external_context_outputs, +) + + +def _series(values: dict[str, float]) -> pd.Series: + series = pd.Series(values, dtype=float) + series.index = pd.to_datetime(series.index).tz_localize(None).normalize() + series.index.name = "as_of" + return series + + +def test_build_macro_external_context_derives_public_hard_data_fields() -> None: + fred_values = { + "BAMLH0A0HYM2": _series({"2025-01-01": 3.0, "2025-01-02": 3.2, "2025-01-03": 3.8}), + "BAMLC0A0CM": _series({"2025-01-01": 1.0, "2025-01-02": 1.1, "2025-01-03": 1.6}), + "STLFSI4": _series({"2025-01-01": -0.2, "2025-01-02": 0.1, "2025-01-03": 1.2}), + "TEDRATE": _series({"2025-01-01": 0.2, "2025-01-02": 0.3, "2025-01-03": 0.8}), + "DTWEXBGS": _series({"2025-01-01": 100.0, "2025-01-02": 101.0, "2025-01-03": 104.0}), + "T10Y2Y": _series({"2025-01-03": -0.6}), + } + cboe_values = { + "VIX": _series({"2025-01-01": 20.0, "2025-01-02": 30.0, "2025-01-03": 40.0}), + "VIX3M": _series({"2025-01-01": 25.0, "2025-01-02": 25.0, "2025-01-03": 32.0}), + "VVIX": _series({"2025-01-03": 125.0}), + "SKEW": _series({"2025-01-03": 160.0}), + } + put_call_values = { + "totalpc": _series({"2025-01-03": 1.3}), + "equitypc": _series({"2025-01-03": 1.1}), + } + + frame, coverage = build_macro_external_context( + start="2025-01-01", + end="2025-01-03", + fred_series={key: value for key, value in { + "BAMLH0A0HYM2": "hy_oas", + "BAMLC0A0CM": "ig_oas", + "STLFSI4": "stlfsi4", + "TEDRATE": "ted_spread", + "DTWEXBGS": "dollar_index", + "T10Y2Y": "yield_curve_10y2y", + }.items()}, + cboe_index_series={"VIX": "vix", "VIX3M": "vix3m", "VVIX": "vvix", "SKEW": "skew"}, + cboe_put_call_series={"totalpc": "put_call_ratio", "equitypc": "equity_put_call_ratio"}, + return_lookback_days=2, + delta_lookback_days=2, + fred_reader=lambda series_id, **_: fred_values[series_id], + cboe_index_reader=lambda symbol, **_: cboe_values[symbol], + cboe_put_call_reader=lambda kind, **_: put_call_values[kind], + ) + + latest = frame.loc[frame["as_of"].eq("2025-01-03")].iloc[0] + assert latest["vix"] == 40.0 + assert latest["vix3m"] == 32.0 + assert latest["vix_vix3m_ratio"] == 1.25 + assert round(float(latest["hy_oas_delta_63d"]), 4) == 0.8 + assert round(float(latest["ig_oas_delta_63d"]), 4) == 0.6 + assert latest["financial_stress"] == 1.2 + assert latest["funding_stress"] == 0.8 + assert latest["put_call_ratio"] == 1.3 + assert latest["equity_put_call_ratio"] == 1.1 + assert round(float(latest["dxy_return_21d"]), 4) == 0.04 + assert latest["yield_curve_10y2y"] == -0.6 + assert any(item.source == "fred:BAMLH0A0HYM2" and item.status == "ok" for item in coverage) + + +def test_manual_context_overrides_downloaded_values_and_adds_private_fields(tmp_path) -> None: + manual_path = tmp_path / "manual.csv" + manual_path.write_text( + "as_of,vix,fear_greed_index,pentagon_pizza_index,naaim_exposure\n" + "2025-01-03,55,18,3,30\n", + encoding="utf-8", + ) + + frame, _coverage = build_macro_external_context( + start="2025-01-01", + end="2025-01-03", + fred_series={}, + cboe_index_series={"VIX": "vix"}, + cboe_put_call_series={}, + manual_context=read_manual_context(manual_path), + cboe_index_reader=lambda symbol, **_: _series({"2025-01-03": 22.0}), + ) + + latest = frame.iloc[-1] + assert latest["vix"] == 55.0 + assert latest["fear_greed_index"] == 18.0 + assert latest["pentagon_pizza_index"] == 3.0 + assert latest["naaim_exposure"] == 30.0 + + +def test_build_macro_external_context_bounds_forward_fill_to_staleness_window() -> None: + frame, _coverage = build_macro_external_context( + start="2025-01-01", + end="2025-01-15", + fred_series={"STLFSI4": "stlfsi4"}, + cboe_index_series={"VIX": "vix"}, + cboe_put_call_series={}, + max_field_staleness_days=3, + fred_reader=lambda series_id, **_: _series({"2025-01-03": 1.2}), + cboe_index_reader=lambda symbol, **_: _series({"2025-01-06": 20.0, "2025-01-10": 21.0, "2025-01-15": 22.0}), + ) + + by_date = frame.set_index("as_of") + assert by_date.loc["2025-01-06", "financial_stress"] == 1.2 + assert pd.isna(by_date.loc["2025-01-10", "financial_stress"]) + assert pd.isna(by_date.loc["2025-01-15", "financial_stress"]) + + +def test_write_macro_external_context_outputs_writes_manifest(tmp_path) -> None: + output_path = tmp_path / "external_context.csv" + frame = pd.DataFrame([{"as_of": "2025-01-03", "vix": 30.0}]) + coverage = [SourceCoverage(source="cboe_index:VIX", column="vix", status="ok", rows=1)] + + paths = write_macro_external_context_outputs(frame, coverage, output_path) + + assert paths["external_context"] == output_path + assert output_path.exists() + manifest = json.loads(paths["manifest"].read_text(encoding="utf-8")) + assert manifest["schema_version"] == "macro_external_context.v1" + assert manifest["rows"] == 1 + assert manifest["coverage"][0]["source"] == "cboe_index:VIX" From 5b2d774de6d763935667613fbe8d9e216d68a434 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 01:40:38 +0800 Subject: [PATCH 10/11] Fix external stress signals to watch-only by default --- README.md | 16 ++--- README.zh-CN.md | 2 +- docs/examples/strategy_plugins.example.toml | 6 ++ docs/market-regime-control-plan.zh-CN.md | 7 ++- pyproject.toml | 2 +- .../macro_risk_governor_plugin.py | 37 ++++++++--- .../strategy_plugin_runner.py | 1 + tests/test_macro_risk_governor_plugin.py | 62 +++++++++++++++++++ tests/test_strategy_plugin_runner.py | 57 +++++++++++++++++ 9 files changed, 171 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 91f0459..99ddf54 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,16 @@ send notifications; plugin research and signal generation live here. allocations. Local Codex is tried first when enabled; OpenAI-compatible and Anthropic fallback endpoints can be configured. - `macro_risk_governor`: deterministic macro de-leveraging governor for TQQQ. - It scores price trend, realized volatility, VIX, credit-pair stress, and - optional external financial-stress fields. The artifact can expose + It scores price trend, realized volatility, VIX, and credit-pair stress. The + artifact can expose `leverage_scalar` and `risk_asset_scalar` to strategy runtimes that explicitly - opt in through mounted metadata. OSINT-style fields such as a Pentagon pizza - index, plus sentiment, options-volatility, rates, breadth, funding, and - liquidity fields such as Fear & Greed, put/call, VVIX, SKEW, MOVE, yield - curves, dollar stress, and safe-haven demand, are kept as watch-only evidence - and do not contribute to the actionable trading score. + opt in through mounted metadata. External hard-data fields such as HY OAS and + financial-stress indices, plus OSINT-style, sentiment, options-volatility, + rates, breadth, funding, and liquidity fields such as a Pentagon pizza index, + Fear & Greed, put/call, VVIX, SKEW, MOVE, yield curves, dollar stress, and + safe-haven demand, are kept as watch-only evidence by default. They do not + contribute to the actionable trading score unless explicitly enabled for + research. - `market_regime_control`: unified deterministic facade for crisis, macro, and TACO signals. Only strategies with positive backtest evidence should mount position controls for automated consumption; SOXL/SOXX currently receives diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c6f1a4..76d938e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -25,7 +25,7 @@ Brokers、Schwab、LongBridge、Firstrade 等平台仓库只负责加载 artifac - `crisis_response_shadow`:面向杠杆美股策略的黑天鹅防守观察插件。它只写入 shadow-mode artifact,不调用券商接口。 可选启用 AI shadow audit:AI 只审计证据一致性和数据缺口,不改写确定性路线、不下单、不改仓位;默认优先尝试本机 Codex,失败后可走 OpenAI-compatible 或 Anthropic fallback endpoint。 -- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX、信用相对压力和可选外部金融压力字段打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。五角大楼比萨指数、Fear & Greed、put/call、VVIX、SKEW、MOVE、收益率曲线、美元压力、safe-haven demand 等 OSINT、情绪或跨资产字段只作为 watch-only 证据,不进入可执行分数。 +- `macro_risk_governor`:面向 TQQQ 的确定性宏观降杠杆插件。它按价格趋势、实现波动、VIX 和信用 ETF 相对压力打分,输出 `leverage_scalar` / `risk_asset_scalar` 给显式 opt-in 的策略运行时消费。HY OAS、金融压力指数、五角大楼比萨指数、Fear & Greed、put/call、VVIX、SKEW、MOVE、收益率曲线、美元压力、safe-haven demand 等外部硬数据、OSINT、情绪或跨资产字段默认只作为 watch-only 证据,不进入可执行分数;只有显式研究开关开启后才允许外部压力字段参与自动分数。 - `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)。 - `taco_rebound_shadow`:仅适用于 TQQQ 的事件反弹上下文通知插件。它只写入人工复核 artifact,不给仓位大小建议,也不改动配置或账户分配。缓和/降温事件会先保持 watch-only,只有事件后价格反弹确认通过后才触发人工复核通知,以减少过早抄底提醒。 该插件也可选启用同样的 shadow-only AI audit,但 AI 只复核事件来源和反弹证据质量。 diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index 6c9afeb..94a53ea 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -26,6 +26,10 @@ rate_symbols = ["IEF", "TLT"] strategy_policy = "levered_growth_income_v1" realized_vol_threshold = 0.30 realized_vol_requires_confirmation = true +# Fixed production default after external-context backtest: external hard-data +# stress fields are notification/watch evidence unless this research switch is +# explicitly enabled. +external_stress_actionable = false delever_risk_asset_scalar = 0.0 taco_opportunity_size_scalar = 0.0 crisis_enabled = true @@ -57,6 +61,7 @@ rate_symbols = ["IEF", "TLT"] strategy_policy = "levered_growth_income_v1" realized_vol_threshold = 0.30 realized_vol_requires_confirmation = true +external_stress_actionable = false delever_risk_asset_scalar = 0.0 taco_enabled = false crisis_enabled = true @@ -99,6 +104,7 @@ attack_symbol = "TQQQ" vix_symbols = ["VIX", "^VIX", "VIXCLS"] vix3m_symbols = ["VIX3M", "^VIX3M", "VXV", "^VXV"] credit_pairs = ["HYG:IEF", "LQD:IEF"] +external_stress_actionable = false [strategy_plugins.outputs] output_dir = "data/output/tqqq_growth_income/plugins/macro_risk_governor" diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md index 2e4bfa3..040b0b1 100644 --- a/docs/market-regime-control-plan.zh-CN.md +++ b/docs/market-regime-control-plan.zh-CN.md @@ -26,7 +26,7 @@ - `crisis_response_shadow` 负责硬危机防守。`true_crisis` 和泡沫脆弱性触发后,统一插件输出 `risk_off` / `defend`,仓位目标交给策略侧 opt-in 执行。 - `macro_risk_governor` - 负责宏观降杠杆。它看价格趋势、实现波动、VIX、信用相对压力和可选金融压力字段,输出 `risk_reduced` 或 `risk_off`。Fear & Greed、put/call、safe-haven demand、VVIX、SKEW、MOVE、收益率曲线、美元压力、市场宽度、AAII/NAAIM 和五角大楼比萨指数只作为 watch-only 证据,先用于通知和回测观察。 + 负责宏观降杠杆。它看价格趋势、实现波动、VIX 和信用 ETF 相对压力,输出 `risk_reduced` 或 `risk_off`。HY OAS、金融压力指数、Fear & Greed、put/call、safe-haven demand、VVIX、SKEW、MOVE、收益率曲线、美元压力、市场宽度、AAII/NAAIM 和五角大楼比萨指数默认只作为 watch-only 证据,先用于通知和回测观察;只有显式研究开关 `external_stress_actionable` 开启后,外部压力字段才允许进入可执行分数。 - `taco_rebound_shadow` 负责 TQQQ 事件反弹通知。它输出人工复核通知和本地 veto 线索,不直接提高仓位。 @@ -82,9 +82,10 @@ SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中 当前观察指标分层: -- 已可执行打分:价格趋势、63/252 日回撤、实现波动、VIX 水平/尖峰、信用 ETF 相对压力、高收益 OAS、金融压力指数。 -- 已接入 watch-only:五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand、VIX/VIX3M 期限结构、VVIX、SKEW、MOVE、IG OAS、资金压力利差、10Y-2Y/10Y-3M 曲线、DXY 21 日压力、50/200 日市场宽度、新高新低、涨跌线回撤、AAII bearish-bullish spread、NAAIM exposure。 +- 已可执行打分:价格趋势、63/252 日回撤、实现波动、VIX 水平/尖峰、信用 ETF 相对压力。 +- 已接入 watch-only:HY OAS、金融压力指数、五角大楼比萨指数、Fear & Greed、put/call、safe-haven demand、VIX/VIX3M 期限结构、VVIX、SKEW、MOVE、IG OAS、资金压力利差、10Y-2Y/10Y-3M 曲线、DXY 21 日压力、50/200 日市场宽度、新高新低、涨跌线回撤、AAII bearish-bullish spread、NAAIM exposure。 - 未进入自动仓位:所有 watch-only 指标。它们只用于通知、证据归档和后续历史回测。 +- 研究开关:`external_stress_actionable = true` 可让 HY OAS、HY OAS 63 日扩张和金融压力指数进入可执行分数;默认固定为 `false`。 公开历史数据构建: diff --git a/pyproject.toml b/pyproject.toml index eeea07a..e56d7e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quant-strategy-plugins" -version = "0.1.2" +version = "0.1.3" description = "Open sidecar strategy plugins that emit QuantPlatformKit-compatible signal artifacts." readme = "README.md" requires-python = ">=3.11" diff --git a/src/quant_strategy_plugins/macro_risk_governor_plugin.py b/src/quant_strategy_plugins/macro_risk_governor_plugin.py index e0775b4..c7738d9 100644 --- a/src/quant_strategy_plugins/macro_risk_governor_plugin.py +++ b/src/quant_strategy_plugins/macro_risk_governor_plugin.py @@ -314,6 +314,7 @@ def build_macro_risk_governor_signal( delever_risk_asset_scalar: float = 0.0, crisis_leverage_scalar: float = 0.0, crisis_risk_asset_scalar: float = 0.0, + external_stress_actionable: bool = False, ) -> dict[str, Any]: close = normalize_close(price_history) benchmark_symbol = str(benchmark_symbol or DEFAULT_BENCHMARK_SYMBOL).strip().upper() @@ -496,6 +497,7 @@ def build_macro_risk_governor_signal( weight=2.0, value=hy_oas, threshold=float(hy_oas_watch_level), + actionable=bool(external_stress_actionable), ) _add_check( checks, @@ -504,6 +506,7 @@ def build_macro_risk_governor_signal( weight=1.0, value=hy_oas_delta, threshold=float(hy_oas_delta_threshold), + actionable=bool(external_stress_actionable), ) financial_stress = _external_float(external_row, "stlfsi", "stlfsi4", "nfci", "anfci", "financial_stress") _add_check( @@ -513,6 +516,7 @@ def build_macro_risk_governor_signal( weight=2.0, value=financial_stress, threshold=float(financial_stress_watch_level), + actionable=bool(external_stress_actionable), ) pizza_index = _external_float( external_row, @@ -751,15 +755,20 @@ def build_macro_risk_governor_signal( if realized_vol_check is not None: volatility_active = bool(realized_vol_check.get("active", False)) if volatility_active: - confirmation_checks = ( + confirmation_checks = [ "vix_watch_level", "vix_crisis_level", "vix_spike", "credit_pair_stress", - "hy_oas_watch_level", - "hy_oas_widening", - "financial_stress_index_high", - ) + ] + if bool(external_stress_actionable): + confirmation_checks.extend( + [ + "hy_oas_watch_level", + "hy_oas_widening", + "financial_stress_index_high", + ] + ) realized_vol_confirmed_for_action = any( bool(checks.get(name, {}).get("active", False)) for name in confirmation_checks ) @@ -793,6 +802,7 @@ def build_macro_risk_governor_signal( "advance_decline_drawdown": advance_decline_drawdown, "aaii_bear_bull_spread": aaii_bear_bull_spread, "naaim_exposure": naaim_exposure, + "external_stress_actionable": bool(external_stress_actionable), "benchmark_realized_volatility_requires_confirmation": bool(realized_vol_requires_confirmation), "benchmark_realized_volatility_confirmed_for_action": realized_vol_confirmed_for_action, } @@ -877,7 +887,10 @@ def build_macro_risk_governor_signal( "actionable_score": round(float(actionable_score), 4), "total_score": round(float(total_score), 4), "reason_codes": reason_codes, - "note": "OSINT-only fields are watch evidence and do not contribute to actionable_score.", + "note": ( + "External stress and OSINT-style fields are watch evidence by default and do not contribute " + "to actionable_score unless explicitly enabled." + ), }, "execution_controls": { "capital_impact": "strategy_opt_in", @@ -973,7 +986,16 @@ def build_parser() -> argparse.ArgumentParser: "--realized-vol-requires-confirmation", action=argparse.BooleanOptionalAction, default=True, - help="Require VIX, credit, or financial-stress confirmation before realized volatility contributes to actionable score.", + help=( + "Require VIX or credit confirmation before realized volatility contributes to actionable score. " + "External stress confirms only when --external-stress-actionable is enabled." + ), + ) + parser.add_argument( + "--external-stress-actionable", + action=argparse.BooleanOptionalAction, + default=False, + help="Allow external HY OAS and financial-stress fields to contribute to actionable score.", ) parser.add_argument("--watch-score-threshold", type=float, default=3.0) parser.add_argument("--delever-score-threshold", type=float, default=5.0) @@ -1014,6 +1036,7 @@ def main(argv: list[str] | None = None) -> int: max_external_context_age_days=args.max_external_context_age_days, realized_vol_threshold=args.realized_vol_threshold, realized_vol_requires_confirmation=args.realized_vol_requires_confirmation, + external_stress_actionable=args.external_stress_actionable, watch_score_threshold=args.watch_score_threshold, delever_score_threshold=args.delever_score_threshold, crisis_score_threshold=args.crisis_score_threshold, diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index 243092c..b70b214 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -474,6 +474,7 @@ def _build_macro_risk_governor_kwargs(plugin_config: Mapping[str, Any]) -> dict[ } bool_keys = { "realized_vol_requires_confirmation", + "external_stress_actionable", } for key in string_keys: if key in plugin_config and plugin_config[key] is not None: diff --git a/tests/test_macro_risk_governor_plugin.py b/tests/test_macro_risk_governor_plugin.py index 7144c4c..f112552 100644 --- a/tests/test_macro_risk_governor_plugin.py +++ b/tests/test_macro_risk_governor_plugin.py @@ -206,6 +206,68 @@ def test_macro_risk_governor_keeps_common_external_risk_indicators_watch_only() assert payload["evidence"]["metrics"]["yield_curve_min"] == -0.8 +def test_macro_risk_governor_keeps_external_stress_watch_only_by_default() -> None: + external_context = pd.DataFrame( + [ + { + "as_of": "2025-12-31", + "hy_oas": 8.0, + "hy_oas_delta_63d": 2.0, + "financial_stress": 2.0, + } + ] + ) + + payload = build_macro_risk_governor_signal( + _macro_prices(), + external_context=external_context, + as_of="2025-12-31", + watch_score_threshold=1.0, + delever_score_threshold=1.0, + crisis_score_threshold=99.0, + ) + + assert payload["canonical_route"] == ROUTE_WATCH + assert payload["suggested_action"] == "watch_only" + assert payload["would_trade_if_enabled"] is False + assert payload["actionable_score"] == 0.0 + assert payload["total_score"] == 5.0 + for check_name in ("hy_oas_watch_level", "hy_oas_widening", "financial_stress_index_high"): + assert payload["checks"][check_name]["active"] is True + assert payload["checks"][check_name]["actionable"] is False + + +def test_macro_risk_governor_can_opt_in_external_stress_actionability() -> None: + external_context = pd.DataFrame( + [ + { + "as_of": "2025-12-31", + "hy_oas": 8.0, + "hy_oas_delta_63d": 2.0, + "financial_stress": 2.0, + } + ] + ) + + payload = build_macro_risk_governor_signal( + _macro_prices(), + external_context=external_context, + as_of="2025-12-31", + external_stress_actionable=True, + watch_score_threshold=1.0, + delever_score_threshold=1.0, + crisis_score_threshold=99.0, + ) + + assert payload["canonical_route"] == ROUTE_DELEVER + assert payload["suggested_action"] == "delever" + assert payload["would_trade_if_enabled"] is True + assert payload["actionable_score"] == 5.0 + for check_name in ("hy_oas_watch_level", "hy_oas_widening", "financial_stress_index_high"): + assert payload["checks"][check_name]["active"] is True + assert payload["checks"][check_name]["actionable"] is True + + def test_macro_risk_governor_requires_confirmation_for_realized_volatility_action() -> None: payload = build_macro_risk_governor_signal( _macro_prices(volatility_spike=True), diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index 60182e6..7fdeee6 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -225,6 +225,63 @@ def test_strategy_plugin_runner_runs_macro_risk_governor_for_tqqq(tmp_path) -> N assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False +def test_strategy_plugin_runner_keeps_external_stress_watch_only_unless_opted_in(tmp_path) -> None: + prices_path = tmp_path / "macro_prices.csv" + external_path = tmp_path / "external_context.csv" + output_dir = tmp_path / STRATEGY_NAME / "plugins" / PLUGIN_MACRO_RISK_GOVERNOR + _quiet_prices().to_csv(prices_path, index=False) + pd.DataFrame( + [ + { + "as_of": "2025-11-19", + "hy_oas": 8.0, + "hy_oas_delta_63d": 2.0, + "financial_stress": 2.0, + } + ] + ).to_csv(external_path, index=False) + base_inputs = { + "prices": str(prices_path), + "external_context": str(external_path), + "as_of": "2025-11-19", + "vix_symbols": ["VIX"], + "credit_pairs": [], + "watch_score_threshold": 1.0, + "delever_score_threshold": 1.0, + "crisis_score_threshold": 99.0, + } + config = { + "output_dir": str(tmp_path / "runner"), + "default_mode": "shadow", + "strategy_plugins": [ + { + "strategy": STRATEGY_NAME, + "plugin": PLUGIN_MACRO_RISK_GOVERNOR, + "enabled": True, + "inputs": base_inputs, + "outputs": {"output_dir": str(output_dir / "watch_only")}, + }, + { + "strategy": STRATEGY_NAME, + "plugin": PLUGIN_MACRO_RISK_GOVERNOR, + "enabled": True, + "inputs": {**base_inputs, "external_stress_actionable": True}, + "outputs": {"output_dir": str(output_dir / "actionable")}, + }, + ], + } + + summary = run_configured_plugins(config) + + assert [result["status"] for result in summary["strategy_plugins"]] == ["ok", "ok"] + watch_payload = json.loads((output_dir / "watch_only" / "latest_signal.json").read_text(encoding="utf-8")) + actionable_payload = json.loads((output_dir / "actionable" / "latest_signal.json").read_text(encoding="utf-8")) + assert watch_payload["canonical_route"] == "watch" + assert watch_payload["actionable_score"] == 0.0 + assert actionable_payload["canonical_route"] == "delever" + assert actionable_payload["actionable_score"] == 5.0 + + def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_path) -> None: prices_path = tmp_path / "market_regime_prices.csv" output_dir = tmp_path / STRATEGY_NAME / "plugins" / PLUGIN_MARKET_REGIME_CONTROL From 75401d1f373716b7144f8d48460839f44cca1f01 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 29 May 2026 01:51:35 +0800 Subject: [PATCH 11/11] Add localized plugin notification messages --- README.md | 18 + README.zh-CN.md | 14 + docs/examples/strategy_plugins.example.toml | 2 + docs/market-regime-control-plan.zh-CN.md | 5 + pyproject.toml | 2 +- .../strategy_plugin_runner.py | 353 ++++++++++++++++++ tests/test_strategy_plugin_runner.py | 18 + 7 files changed, 411 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 99ddf54..3bf8443 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,24 @@ Generated artifacts include `latest_signal.json`, dated JSON, dated CSV, and an evidence CSV. `latest_signal.json` is the file platform runtimes mount via `*_STRATEGY_PLUGIN_MOUNTS_JSON`. +## Notification and Log i18n + +Runner-managed artifacts add display-only i18n fields for notifications and +logs: + +- `localized_messages.schema_version = strategy_plugin_messages.v1` +- `localized_messages.notification.en-US` / `localized_messages.notification.zh-CN` +- `localized_messages.log.en-US` / `localized_messages.log.zh-CN` +- `log_record.schema_version = strategy_plugin_log.v1` +- `market_regime_control.notification.localized_message_schema_version` + +Strategy and broker runtimes should keep trading logic on machine fields such +as `schema_version`, `canonical_route`, `suggested_action`, `reason_codes`, and +`position_control`. Localized strings are for human notification surfaces and +logs only. `market_regime_control.notification` mirrors the localized +notification text and reason labels so existing notification code can render a +message without translating route/action codes itself. + ## Local Checks ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index 76d938e..b4e3a79 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -90,6 +90,20 @@ qsp-build-taco-rebound-shadow-signal \ 输出包括 `latest_signal.json`、按日期归档的 JSON、按日期归档的 CSV,以及 evidence CSV。平台运行时通过 `*_STRATEGY_PLUGIN_MOUNTS_JSON` 挂载的就是 `latest_signal.json`。 +## 通知和日志 i18n + +通过 strategy plugin runner 生成的 artifact 会附带展示层 i18n 字段: + +- `localized_messages.schema_version = strategy_plugin_messages.v1` +- `localized_messages.notification.en-US` / `localized_messages.notification.zh-CN` +- `localized_messages.log.en-US` / `localized_messages.log.zh-CN` +- `log_record.schema_version = strategy_plugin_log.v1` +- `market_regime_control.notification.localized_message_schema_version` + +策略和券商运行时的交易逻辑仍应只读取 `schema_version`、`canonical_route`、 +`suggested_action`、`reason_codes` 和 `position_control` 等机器字段。中英文文案只用于通知界面和日志展示,不参与策略判断。`market_regime_control.notification` +会同步包含本地化通知文案和原因标签,方便现有通知代码直接渲染,不需要在策略仓库里重复翻译 route/action code。 + ## 本地检查 ```bash diff --git a/docs/examples/strategy_plugins.example.toml b/docs/examples/strategy_plugins.example.toml index 94a53ea..31e805b 100644 --- a/docs/examples/strategy_plugins.example.toml +++ b/docs/examples/strategy_plugins.example.toml @@ -9,6 +9,8 @@ enabled = true # macro_risk_governor, and taco_rebound_shadow into one deterministic signal. # Strategy code consumes only notification and position_control from this # artifact; broker writes and live allocation mutation remain disabled. +# Runner artifacts include en-US/zh-CN localized_messages and log_record for +# display/logging. Strategy logic should still read machine route/action codes. [strategy_plugins.inputs] prices = "data/output/market_regime_control/input/tqqq_price_history.csv" diff --git a/docs/market-regime-control-plan.zh-CN.md b/docs/market-regime-control-plan.zh-CN.md index 040b0b1..e401c10 100644 --- a/docs/market-regime-control-plan.zh-CN.md +++ b/docs/market-regime-control-plan.zh-CN.md @@ -40,6 +40,8 @@ 子组件的压缩证据,便于通知和审计。 - `execution_controls` 明确插件仓库只写 artifact,不允许券商下单或账户配置变更。 +- `localized_messages` / `log_record` + 提供 `en-US` 和 `zh-CN` 通知、日志文案。交易逻辑继续只读 route/action/reason code 和仓位控制字段,本地化文案只用于展示和审计日志。 ## 仲裁优先级 @@ -101,6 +103,8 @@ SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中 - 统一插件 schema:`market_regime_control.v1` - 仲裁器 schema:`market_regime_arbiter.v1` - 运行器总 schema:`strategy_plugins.v1` +- 通知文案 schema:`strategy_plugin_messages.v1` +- 日志记录 schema:`strategy_plugin_log.v1` - 策略消费权限 schema:随 `strategy_plugins.v1` 通过 `consumption_policy` 输出 升级原则: @@ -108,6 +112,7 @@ SOXL/SOXX 不出现在 `market_regime_control` 的策略级消费 registry 中 - 向后兼容字段可以在 v1 内新增。 - 删除字段、改变字段语义或改变默认执行权限,需要升级到 v2。 - 策略仓库应按 `schema_version` 校验可消费版本,并在配置层保留 opt-in/opt-out。 +- 通知和日志 i18n 是展示层契约;策略不能把本地化字符串作为交易判断依据,必须继续消费机器可读 code。 - 旧插件标记 deprecated successor 为 `market_regime_control`,但保留历史入口方便复现旧回测。 ## 回测结论 diff --git a/pyproject.toml b/pyproject.toml index e56d7e9..633c1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "quant-strategy-plugins" -version = "0.1.3" +version = "0.1.4" description = "Open sidecar strategy plugins that emit QuantPlatformKit-compatible signal artifacts." readme = "README.md" requires-python = ">=3.11" diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index b70b214..2913680 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -39,6 +39,10 @@ PLUGIN_MACRO_RISK_GOVERNOR = MACRO_RISK_GOVERNOR_PROFILE PLUGIN_TACO_REBOUND_SHADOW = TACO_REBOUND_PROFILE SUPPORTED_PLUGIN_MODES = (SHADOW_MODE,) +STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION = "strategy_plugin_messages.v1" +STRATEGY_PLUGIN_LOG_SCHEMA_VERSION = "strategy_plugin_log.v1" +DEFAULT_MESSAGE_LOCALE = "en-US" +SUPPORTED_MESSAGE_LOCALES = ("en-US", "zh-CN") EVIDENCE_AUTOMATION_APPROVED = "automation_approved" EVIDENCE_NOTIFICATION_ONLY = "notification_only" EVIDENCE_DEPRECATED_COMPATIBILITY = "deprecated_compatibility" @@ -177,6 +181,100 @@ class PluginConsumptionPolicy: for plugin in sorted({policy.plugin for policy in PLUGIN_CONSUMPTION_POLICIES}) } +LOCALIZED_ROUTE_LABELS: dict[str, dict[str, str]] = { + "blocked": {"en-US": "Blocked", "zh-CN": "已阻断"}, + "crisis": {"en-US": "Crisis", "zh-CN": "危机"}, + "delever": {"en-US": "De-lever", "zh-CN": "降杠杆"}, + "no_action": {"en-US": "No action", "zh-CN": "无动作"}, + "opportunity_watch": {"en-US": "Opportunity watch", "zh-CN": "机会观察"}, + "risk_off": {"en-US": "Risk off", "zh-CN": "风险关闭"}, + "risk_reduced": {"en-US": "Risk reduced", "zh-CN": "风险降低"}, + "taco_rebound": {"en-US": "TACO rebound", "zh-CN": "TACO 反弹"}, + "true_crisis": {"en-US": "True crisis", "zh-CN": "真实危机"}, + "watch": {"en-US": "Watch", "zh-CN": "观察"}, +} +LOCALIZED_ACTION_LABELS: dict[str, dict[str, str]] = { + "blocked": {"en-US": "Blocked", "zh-CN": "已阻断"}, + "defend": {"en-US": "Defend", "zh-CN": "防守"}, + "delever": {"en-US": "De-lever", "zh-CN": "降杠杆"}, + "no_action": {"en-US": "No action", "zh-CN": "无动作"}, + "notify_manual_review": {"en-US": "Notify manual review", "zh-CN": "通知人工复核"}, + "watch_only": {"en-US": "Watch only", "zh-CN": "仅观察"}, +} +LOCALIZED_SOURCE_LABELS: dict[str, dict[str, str]] = { + "crisis": {"en-US": "Crisis", "zh-CN": "危机"}, + "data_quality": {"en-US": "Data quality", "zh-CN": "数据质量"}, + "macro": {"en-US": "Macro", "zh-CN": "宏观"}, + "taco": {"en-US": "TACO", "zh-CN": "TACO"}, +} +LOCALIZED_REASON_LABELS: dict[str, dict[str, str]] = { + "aaii_bear_bull_spread_watch": { + "en-US": "AAII bearish-bullish spread watch", + "zh-CN": "AAII 熊牛差观察", + }, + "advance_decline_drawdown_watch": { + "en-US": "Advance-decline drawdown watch", + "zh-CN": "涨跌线回撤观察", + }, + "benchmark_below_ma": {"en-US": "Benchmark below moving average", "zh-CN": "基准低于均线"}, + "benchmark_drawdown_crisis": {"en-US": "Benchmark crisis drawdown", "zh-CN": "基准危机回撤"}, + "benchmark_drawdown_watch": {"en-US": "Benchmark drawdown watch", "zh-CN": "基准回撤观察"}, + "benchmark_realized_volatility_high": { + "en-US": "High realized volatility", + "zh-CN": "实现波动偏高", + }, + "blocked": {"en-US": "Blocked", "zh-CN": "已阻断"}, + "credit_pair_stress": {"en-US": "Credit-pair stress", "zh-CN": "信用 ETF 相对压力"}, + "crisis": {"en-US": "Crisis", "zh-CN": "危机"}, + "delever": {"en-US": "De-lever", "zh-CN": "降杠杆"}, + "dollar_stress_watch": {"en-US": "Dollar stress watch", "zh-CN": "美元压力观察"}, + "fear_greed_extreme_fear_watch": { + "en-US": "Fear & Greed extreme fear watch", + "zh-CN": "恐惧贪婪极度恐惧观察", + }, + "financial_stress_index_high": { + "en-US": "Financial stress index high", + "zh-CN": "金融压力指数偏高", + }, + "funding_stress_watch": {"en-US": "Funding stress watch", "zh-CN": "资金压力观察"}, + "hy_oas_watch_level": {"en-US": "High-yield OAS watch", "zh-CN": "高收益 OAS 观察"}, + "hy_oas_widening": {"en-US": "High-yield OAS widening", "zh-CN": "高收益 OAS 扩张"}, + "ig_oas_watch_level": {"en-US": "Investment-grade OAS watch", "zh-CN": "投资级 OAS 观察"}, + "ig_oas_widening_watch": {"en-US": "Investment-grade OAS widening watch", "zh-CN": "投资级 OAS 扩张观察"}, + "market_breadth_pct_above_50d_watch": { + "en-US": "50-day market breadth watch", + "zh-CN": "50 日市场宽度观察", + }, + "market_breadth_pct_above_200d_watch": { + "en-US": "200-day market breadth watch", + "zh-CN": "200 日市场宽度观察", + }, + "move_high_watch": {"en-US": "MOVE high watch", "zh-CN": "MOVE 偏高观察"}, + "naaim_exposure_low_watch": {"en-US": "NAAIM low exposure watch", "zh-CN": "NAAIM 低仓位观察"}, + "new_high_new_low_spread_watch": { + "en-US": "New-high/new-low spread watch", + "zh-CN": "新高新低差观察", + }, + "pentagon_pizza_watch": {"en-US": "Pentagon pizza index watch", "zh-CN": "五角大楼比萨指数观察"}, + "put_call_stress_watch": {"en-US": "Put/call stress watch", "zh-CN": "Put/call 压力观察"}, + "safe_haven_demand_watch": {"en-US": "Safe-haven demand watch", "zh-CN": "避险需求观察"}, + "skew_high_watch": {"en-US": "SKEW high watch", "zh-CN": "SKEW 偏高观察"}, + "taco_rebound": {"en-US": "TACO rebound context", "zh-CN": "TACO 反弹上下文"}, + "true_crisis": {"en-US": "True crisis", "zh-CN": "真实危机"}, + "vix_crisis_level": {"en-US": "VIX crisis level", "zh-CN": "VIX 危机水平"}, + "vix_spike": {"en-US": "VIX spike", "zh-CN": "VIX 尖峰"}, + "vix_term_structure_inverted_watch": { + "en-US": "VIX term-structure inversion watch", + "zh-CN": "VIX 期限结构倒挂观察", + }, + "vix_watch_level": {"en-US": "VIX watch level", "zh-CN": "VIX 观察水平"}, + "vvix_high_watch": {"en-US": "VVIX high watch", "zh-CN": "VVIX 偏高观察"}, + "yield_curve_inversion_watch": { + "en-US": "Yield-curve inversion watch", + "zh-CN": "收益率曲线倒挂观察", + }, +} + PluginRunner = Callable[[Mapping[str, Any], str], PluginRunResult] PluginPayloadBuilder = Callable[[pd.DataFrame, Mapping[str, Any]], dict[str, Any]] @@ -514,6 +612,233 @@ def _mode_execution_controls(mode: str) -> dict[str, Any]: raise ValueError(f"unsupported plugin mode: {mode!r}") from exc +def _payload_code(value: Any) -> str: + return str(value or "").strip().lower() + + +def _localized_label(labels: Mapping[str, Mapping[str, str]], code: Any, locale: str) -> str: + normalized = _payload_code(code) + if not normalized: + return "" + localized = labels.get(normalized, {}) + if locale in localized: + return localized[locale] + if DEFAULT_MESSAGE_LOCALE in localized: + return localized[DEFAULT_MESSAGE_LOCALE] + return normalized if locale == "zh-CN" else normalized.replace("_", " ") + + +def _message_join(values: Sequence[str], locale: str) -> str: + cleaned = [str(value).strip() for value in values if str(value).strip()] + if not cleaned: + return "无" if locale == "zh-CN" else "none" + return "、".join(cleaned) if locale == "zh-CN" else ", ".join(cleaned) + + +def _message_reason_codes(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str): + return tuple(item.strip() for item in value.split(",") if item.strip()) + if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)): + return tuple(str(item).strip() for item in value if str(item).strip()) + return () + + +def _nested_mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]: + value = payload.get(key) + return value if isinstance(value, Mapping) else {} + + +def _payload_route(payload: Mapping[str, Any]) -> str: + arbiter = _nested_mapping(payload, "arbiter") + return _payload_code(payload.get("canonical_route") or payload.get("route") or arbiter.get("final_route")) + + +def _payload_action(payload: Mapping[str, Any]) -> str: + arbiter = _nested_mapping(payload, "arbiter") + return _payload_code(payload.get("suggested_action") or payload.get("action") or arbiter.get("suggested_action")) + + +def _payload_reason_codes(payload: Mapping[str, Any]) -> tuple[str, ...]: + reason_codes: list[str] = [] + for container in ( + payload, + _nested_mapping(payload, "arbiter"), + _nested_mapping(payload, "notification"), + _nested_mapping(payload, "position_control"), + ): + reason_codes.extend(_message_reason_codes(container.get("reason_codes"))) + if not reason_codes: + route = _payload_route(payload) + if route and route != "no_action": + reason_codes.append(route) + return tuple(dict.fromkeys(reason_codes)) + + +def _localized_reason_label(reason_code: str, locale: str) -> str: + source, separator, raw_code = str(reason_code).partition(":") + if separator: + source_label = _localized_label(LOCALIZED_SOURCE_LABELS, source, locale) + reason_label = _localized_label(LOCALIZED_REASON_LABELS, raw_code, locale) + separator_text = ":" if locale == "zh-CN" else ": " + return f"{source_label}{separator_text}{reason_label}" + return _localized_label(LOCALIZED_REASON_LABELS, source, locale) + + +def _localized_reason_labels(reason_codes: Sequence[str], locale: str) -> tuple[str, ...]: + return tuple(_localized_reason_label(reason_code, locale) for reason_code in reason_codes) + + +def _payload_should_notify(payload: Mapping[str, Any], route: str) -> bool: + notification = _nested_mapping(payload, "notification") + if "should_notify" in notification: + return _as_bool(notification.get("should_notify"), default=False) + if "manual_review_required" in payload: + return _as_bool(payload.get("manual_review_required"), default=False) or route != "no_action" + return route != "no_action" + + +def _format_notification_message( + *, + locale: str, + strategy: str, + plugin: str, + as_of: str, + route_label: str, + action_label: str, + reason_labels: Sequence[str], + should_notify: bool, +) -> str: + reason_text = _message_join(reason_labels, locale) + if locale == "zh-CN": + prefix = "需要通知" if should_notify else "无需通知" + return ( + f"{prefix}:策略 {strategy} 的 {plugin} 在 {as_of or '未知日期'} 输出" + f"市场状态 {route_label},建议动作 {action_label},原因:{reason_text}。" + ) + prefix = "Notification required" if should_notify else "No notification required" + return ( + f"{prefix}: {plugin} for strategy {strategy} produced market regime {route_label} " + f"on {as_of or 'unknown date'} with suggested action {action_label}. Reasons: {reason_text}." + ) + + +def _format_log_message( + *, + locale: str, + strategy: str, + plugin: str, + as_of: str, + route: str, + action: str, + route_label: str, + action_label: str, + reason_codes: Sequence[str], + reason_labels: Sequence[str], +) -> str: + code_text = _message_join(reason_codes, "en-US") + label_text = _message_join(reason_labels, locale) + if locale == "zh-CN": + return ( + f"策略={strategy} 插件={plugin} 日期={as_of or '未知'} 路线={route}({route_label}) " + f"动作={action}({action_label}) 原因码={code_text} 原因={label_text}" + ) + return ( + f"strategy={strategy} plugin={plugin} as_of={as_of or 'unknown'} route={route}({route_label}) " + f"action={action}({action_label}) reason_codes={code_text} reasons={label_text}" + ) + + +def _build_localized_messages( + payload: Mapping[str, Any], + *, + strategy: str, + plugin: str, +) -> dict[str, Any]: + route = _payload_route(payload) or "unknown" + action = _payload_action(payload) or "unknown" + reason_codes = _payload_reason_codes(payload) + as_of = str(payload.get("as_of") or "").strip() + should_notify = _payload_should_notify(payload, route) + + route_labels = { + locale: _localized_label(LOCALIZED_ROUTE_LABELS, route, locale) for locale in SUPPORTED_MESSAGE_LOCALES + } + action_labels = { + locale: _localized_label(LOCALIZED_ACTION_LABELS, action, locale) for locale in SUPPORTED_MESSAGE_LOCALES + } + reason_labels = { + locale: list(_localized_reason_labels(reason_codes, locale)) for locale in SUPPORTED_MESSAGE_LOCALES + } + notification_messages = { + locale: _format_notification_message( + locale=locale, + strategy=strategy, + plugin=plugin, + as_of=as_of, + route_label=route_labels[locale], + action_label=action_labels[locale], + reason_labels=reason_labels[locale], + should_notify=should_notify, + ) + for locale in SUPPORTED_MESSAGE_LOCALES + } + log_messages = { + locale: _format_log_message( + locale=locale, + strategy=strategy, + plugin=plugin, + as_of=as_of, + route=route, + action=action, + route_label=route_labels[locale], + action_label=action_labels[locale], + reason_codes=reason_codes, + reason_labels=reason_labels[locale], + ) + for locale in SUPPORTED_MESSAGE_LOCALES + } + return { + "schema_version": STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION, + "default_locale": DEFAULT_MESSAGE_LOCALE, + "supported_locales": list(SUPPORTED_MESSAGE_LOCALES), + "labels": { + "canonical_route": route_labels, + "suggested_action": action_labels, + "reason_codes": reason_labels, + }, + "notification": notification_messages, + "log": log_messages, + } + + +def _build_log_record( + payload: Mapping[str, Any], + *, + strategy: str, + plugin: str, + mode: str, + localized_messages: Mapping[str, Any], +) -> dict[str, Any]: + reason_codes = _payload_reason_codes(payload) + execution_controls = _nested_mapping(payload, "execution_controls") + return { + "schema_version": STRATEGY_PLUGIN_LOG_SCHEMA_VERSION, + "event": "strategy_plugin_signal", + "namespace": str(execution_controls.get("log_namespace") or plugin), + "strategy": strategy, + "plugin": plugin, + "mode": mode, + "as_of": str(payload.get("as_of") or "").strip(), + "canonical_route": _payload_route(payload), + "suggested_action": _payload_action(payload), + "reason_codes": list(reason_codes), + "default_locale": DEFAULT_MESSAGE_LOCALE, + "localized_messages": dict(localized_messages.get("log", {})), + } + + def _apply_plugin_contract( payload: Mapping[str, Any], *, @@ -549,7 +874,33 @@ def _apply_plugin_contract( execution_controls["mode_note"] = ( "Mode is the platform behavior contract; this repository writes artifacts and does not call brokers" ) + execution_controls["message_i18n_schema_version"] = STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION + execution_controls["log_schema_version"] = STRATEGY_PLUGIN_LOG_SCHEMA_VERSION + execution_controls["default_locale"] = DEFAULT_MESSAGE_LOCALE + execution_controls["supported_locales"] = list(SUPPORTED_MESSAGE_LOCALES) contracted_payload["execution_controls"] = execution_controls + localized_messages = _build_localized_messages( + contracted_payload, + strategy=strategy, + plugin=plugin, + ) + contracted_payload["localized_messages"] = localized_messages + contracted_payload["log_record"] = _build_log_record( + contracted_payload, + strategy=strategy, + plugin=plugin, + mode=mode, + localized_messages=localized_messages, + ) + notification = contracted_payload.get("notification") + if isinstance(notification, Mapping): + localized_notification = dict(notification) + localized_notification["localized_message_schema_version"] = STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION + localized_notification["default_locale"] = DEFAULT_MESSAGE_LOCALE + localized_notification["supported_locales"] = list(SUPPORTED_MESSAGE_LOCALES) + localized_notification["localized_messages"] = dict(localized_messages["notification"]) + localized_notification["localized_reason_labels"] = dict(localized_messages["labels"]["reason_codes"]) + contracted_payload["notification"] = localized_notification return contracted_payload @@ -791,6 +1142,8 @@ def main(argv: list[str] | None = None) -> int: "PLUGIN_DEPRECATED_SUCCESSORS", "PLUGIN_RESEARCH_ONLY_REASONS", "PLUGIN_SCHEMA_VERSIONS", + "STRATEGY_PLUGIN_LOG_SCHEMA_VERSION", + "STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION", "PluginConsumptionPolicy", "PluginRunResult", "load_plugin_config", diff --git a/tests/test_strategy_plugin_runner.py b/tests/test_strategy_plugin_runner.py index 7fdeee6..79ee338 100644 --- a/tests/test_strategy_plugin_runner.py +++ b/tests/test_strategy_plugin_runner.py @@ -19,6 +19,8 @@ PLUGIN_MACRO_RISK_GOVERNOR, PLUGIN_SCHEMA_VERSIONS, PLUGIN_TACO_REBOUND_SHADOW, + STRATEGY_PLUGIN_LOG_SCHEMA_VERSION, + STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION, load_plugin_config, main, run_configured_plugins, @@ -184,6 +186,11 @@ def test_strategy_plugin_runner_executes_strategy_scoped_shadow_plugin(tmp_path) assert payload["execution_controls"]["repository_broker_write_allowed"] is False assert payload["execution_controls"]["repository_allocation_mutation_allowed"] is False assert "platform behavior contract" in payload["execution_controls"]["mode_note"] + assert payload["localized_messages"]["schema_version"] == STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION + assert "No notification required" in payload["localized_messages"]["notification"]["en-US"] + assert "无需通知" in payload["localized_messages"]["notification"]["zh-CN"] + assert payload["log_record"]["schema_version"] == STRATEGY_PLUGIN_LOG_SCHEMA_VERSION + assert "策略=" in payload["log_record"]["localized_messages"]["zh-CN"] def test_strategy_plugin_runner_runs_macro_risk_governor_for_tqqq(tmp_path) -> None: @@ -223,6 +230,9 @@ def test_strategy_plugin_runner_runs_macro_risk_governor_for_tqqq(tmp_path) -> N assert payload["canonical_route"] == "delever" assert payload["execution_controls"]["broker_order_allowed"] is False assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False + assert payload["localized_messages"]["labels"]["canonical_route"]["zh-CN"] == "降杠杆" + assert payload["localized_messages"]["labels"]["suggested_action"]["en-US"] == "De-lever" + assert "VIX 危机水平" in payload["localized_messages"]["notification"]["zh-CN"] def test_strategy_plugin_runner_keeps_external_stress_watch_only_unless_opted_in(tmp_path) -> None: @@ -328,6 +338,12 @@ def test_strategy_plugin_runner_runs_unified_market_regime_control_for_tqqq(tmp_ assert payload["consumption_policy"]["position_control_allowed"] is True assert payload["execution_controls"]["broker_order_allowed"] is False assert payload["execution_controls"]["live_allocation_mutation_allowed"] is False + assert payload["localized_messages"]["labels"]["canonical_route"]["en-US"] == "Risk reduced" + assert payload["localized_messages"]["labels"]["suggested_action"]["zh-CN"] == "降杠杆" + assert payload["notification"]["localized_message_schema_version"] == STRATEGY_PLUGIN_MESSAGE_SCHEMA_VERSION + assert "风险降低" in payload["notification"]["localized_messages"]["zh-CN"] + assert "宏观:VIX 危机水平" in payload["notification"]["localized_reason_labels"]["zh-CN"] + assert payload["log_record"]["canonical_route"] == "risk_reduced" def test_strategy_plugin_runner_runs_general_market_regime_notification(tmp_path) -> None: @@ -372,6 +388,8 @@ def test_strategy_plugin_runner_runs_general_market_regime_notification(tmp_path assert payload["execution_controls"]["position_control_allowed"] is False assert payload["execution_controls"]["consumption_evidence_status"] == EVIDENCE_NOTIFICATION_ONLY assert payload["consumption_policy"]["strategy"] == GENERAL_MARKET_REGIME_NOTIFICATION_STRATEGY + assert payload["notification"]["localized_messages"]["en-US"].startswith("No notification required") + assert payload["log_record"]["localized_messages"]["zh-CN"] def test_strategy_plugin_runner_rejects_soxl_market_regime_control_mount(tmp_path) -> None: