From 8d0340cdbef42e2b552dcdba3216c9221c8156d4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:03:10 +0800 Subject: [PATCH] feat: add explicit benchmark drawdown guard Co-Authored-By: Codex --- src/quant_strategy_plugins/__init__.py | 8 + .../benchmark_drawdown_guard.py | 205 ++++++++++++++++++ .../market_regime_control_plugin.py | 64 +++++- .../strategy_plugin_runner.py | 32 +++ tests/test_benchmark_drawdown_guard.py | 116 ++++++++++ tests/test_market_regime_control_plugin.py | 22 ++ uv.lock | 44 ++-- 7 files changed, 467 insertions(+), 24 deletions(-) create mode 100644 src/quant_strategy_plugins/benchmark_drawdown_guard.py create mode 100644 tests/test_benchmark_drawdown_guard.py diff --git a/src/quant_strategy_plugins/__init__.py b/src/quant_strategy_plugins/__init__.py index ee4762e..f28e84e 100644 --- a/src/quant_strategy_plugins/__init__.py +++ b/src/quant_strategy_plugins/__init__.py @@ -1,5 +1,10 @@ """Open strategy plugin implementations for QuantStrategyLab runtimes.""" +from .benchmark_drawdown_guard import ( + PROFILE as BENCHMARK_DRAWDOWN_GUARD_PROFILE, + SCHEMA_VERSION as BENCHMARK_DRAWDOWN_GUARD_SCHEMA_VERSION, + build_benchmark_drawdown_guard_signal, +) from .crisis_response_shadow_plugin import ( SCHEMA_VERSION as CRISIS_RESPONSE_SHADOW_SCHEMA_VERSION, SHADOW_PROFILE as CRISIS_RESPONSE_SHADOW_PROFILE, @@ -33,6 +38,8 @@ ) __all__ = [ + "BENCHMARK_DRAWDOWN_GUARD_PROFILE", + "BENCHMARK_DRAWDOWN_GUARD_SCHEMA_VERSION", "CRISIS_RESPONSE_SHADOW_PROFILE", "CRISIS_RESPONSE_SHADOW_SCHEMA_VERSION", "MACRO_RISK_GOVERNOR_PROFILE", @@ -43,6 +50,7 @@ "PANIC_REVERSAL_SHADOW_SCHEMA_VERSION", "TACO_REBOUND_PROFILE", "TACO_REBOUND_SHADOW_SCHEMA_VERSION", + "build_benchmark_drawdown_guard_signal", "build_crisis_response_shadow_signal", "build_macro_risk_governor_signal", "build_market_regime_control_signal", diff --git a/src/quant_strategy_plugins/benchmark_drawdown_guard.py b/src/quant_strategy_plugins/benchmark_drawdown_guard.py new file mode 100644 index 0000000..7d84b57 --- /dev/null +++ b/src/quant_strategy_plugins/benchmark_drawdown_guard.py @@ -0,0 +1,205 @@ +"""Price-only, research-only benchmark drawdown guard. + +The guard deliberately emits a signal instead of an allocation. A strategy +must explicitly opt in through the existing unified market-regime control and +bind the exact guard configuration to its own research candidate before the +signal can affect a portfolio. +""" + +from __future__ import annotations + +from typing import Any + +import pandas as pd + +from .plugin_signal_utils import json_scalar, normalize_close, resolve_signal_date + +SCHEMA_VERSION = "benchmark_drawdown_guard.v1" +PROFILE = "benchmark_drawdown_guard" + +ROUTE_NO_ACTION = "no_action" +ROUTE_RISK_REDUCED = "risk_reduced" +ROUTE_RISK_OFF = "risk_off" +ROUTE_BLOCKED = "blocked" + +ACTION_NO_ACTION = "no_action" +ACTION_DELEVER = "delever" +ACTION_DEFEND = "defend" +ACTION_BLOCKED = "blocked" + + +def _ratio(value: object, *, name: str, lower: float = 0.0, upper: float = 1.0) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a finite ratio") + result = float(value) + if not pd.notna(result) or not lower <= result <= upper: + raise ValueError(f"{name} must be a finite ratio") + return result + + +def _positive_int(value: object, *, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 2: + raise ValueError(f"{name} must be an integer greater than one") + return value + + +def _nonnegative_int(value: object, *, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{name} must be a non-negative integer") + return value + + +def _threshold(value: object, *, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a drawdown threshold") + result = float(value) + if not pd.notna(result) or not -1.0 < result < 0.0: + raise ValueError(f"{name} must be a drawdown threshold") + return result + + +def _blocked(*, as_of: str, benchmark_symbol: str, reason_code: str) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "profile": PROFILE, + "as_of": as_of, + "benchmark_symbol": benchmark_symbol, + "canonical_route": ROUTE_BLOCKED, + "suggested_action": ACTION_BLOCKED, + "would_trade_if_enabled": False, + "kill_switch_active": True, + "leverage_scalar": 0.0, + "risk_asset_scalar": 0.0, + "reason_codes": (reason_code,), + "data_quality": {"status": "PARKED", "reason_codes": (reason_code,)}, + "execution_controls": { + "broker_order_allowed": False, + "live_allocation_mutation_allowed": False, + "strategy_opt_in_required": True, + }, + } + + +def build_benchmark_drawdown_guard_signal( + price_history, + *, + benchmark_symbol: str, + as_of: str | None, + drawdown_lookback_sessions: int, + soft_drawdown_threshold: float, + hard_drawdown_threshold: float, + soft_risk_asset_scalar: float, + hard_risk_asset_scalar: float, + max_price_age_days: int, +) -> dict[str, Any]: + """Build one causal, configured benchmark guard signal. + + No threshold, scalar, benchmark, or freshness policy has a hidden default. + This prevents a caller from accidentally treating a research helper as an + unstated, live-capable stop-loss policy. + """ + symbol = str(benchmark_symbol or "").strip().upper() + if not symbol: + raise ValueError("benchmark_symbol is required") + lookback = _positive_int(drawdown_lookback_sessions, name="drawdown_lookback_sessions") + max_age = _nonnegative_int(max_price_age_days, name="max_price_age_days") + soft_threshold = _threshold(soft_drawdown_threshold, name="soft_drawdown_threshold") + hard_threshold = _threshold(hard_drawdown_threshold, name="hard_drawdown_threshold") + if hard_threshold >= soft_threshold: + raise ValueError("hard_drawdown_threshold must be below soft_drawdown_threshold") + soft_scalar = _ratio(soft_risk_asset_scalar, name="soft_risk_asset_scalar") + hard_scalar = _ratio(hard_risk_asset_scalar, name="hard_risk_asset_scalar") + if hard_scalar > soft_scalar: + raise ValueError("hard_risk_asset_scalar must not exceed soft_risk_asset_scalar") + + # A missing or malformed price payload must park the guard rather than + # leave the enclosing strategy with an implicit "no action" result. + # This is deliberately narrower than ``Exception``: programming bugs + # should still be visible to CI instead of being disguised as data gaps. + fallback_as_of = str(as_of or "unavailable").strip() or "unavailable" + try: + close = normalize_close(price_history) + requested_date, signal_date = resolve_signal_date(close, as_of) + except (KeyError, RuntimeError, TypeError, ValueError): + return json_scalar( + _blocked( + as_of=fallback_as_of, + benchmark_symbol=symbol, + reason_code="benchmark_history_unavailable", + ) + ) + signal_as_of = signal_date.date().isoformat() + if symbol not in close.columns: + return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_missing")) + price_age_days = int((requested_date - signal_date).days) + if price_age_days > max_age: + return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_stale")) + benchmark = pd.to_numeric(close[symbol], errors="coerce").loc[:signal_date].dropna() + if len(benchmark) < lookback: + return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_history_incomplete")) + window = benchmark.tail(lookback) + current = float(window.iloc[-1]) + peak = float(window.max()) + if current <= 0.0 or peak <= 0.0: + return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_price_invalid")) + drawdown = current / peak - 1.0 + + route = ROUTE_NO_ACTION + action = ACTION_NO_ACTION + risk_asset_scalar = 1.0 + reason_codes: tuple[str, ...] = () + if drawdown <= hard_threshold: + route = ROUTE_RISK_OFF + action = ACTION_DEFEND + risk_asset_scalar = hard_scalar + reason_codes = ("benchmark_drawdown_hard",) + elif drawdown <= soft_threshold: + route = ROUTE_RISK_REDUCED + action = ACTION_DELEVER + risk_asset_scalar = soft_scalar + reason_codes = ("benchmark_drawdown_soft",) + return json_scalar( + { + "schema_version": SCHEMA_VERSION, + "profile": PROFILE, + "as_of": signal_as_of, + "benchmark_symbol": symbol, + "canonical_route": route, + "suggested_action": action, + "would_trade_if_enabled": route != ROUTE_NO_ACTION, + "kill_switch_active": False, + "leverage_scalar": risk_asset_scalar, + "risk_asset_scalar": risk_asset_scalar, + "reason_codes": reason_codes, + "data_quality": { + "status": "READY", + "price_age_days": price_age_days, + "lookback_sessions": lookback, + }, + "metrics": { + "rolling_drawdown": drawdown, + "soft_drawdown_threshold": soft_threshold, + "hard_drawdown_threshold": hard_threshold, + }, + "execution_controls": { + "broker_order_allowed": False, + "live_allocation_mutation_allowed": False, + "strategy_opt_in_required": True, + }, + } + ) + + +__all__ = [ + "ACTION_BLOCKED", + "ACTION_DEFEND", + "ACTION_DELEVER", + "ACTION_NO_ACTION", + "PROFILE", + "ROUTE_BLOCKED", + "ROUTE_NO_ACTION", + "ROUTE_RISK_OFF", + "ROUTE_RISK_REDUCED", + "SCHEMA_VERSION", + "build_benchmark_drawdown_guard_signal", +] diff --git a/src/quant_strategy_plugins/market_regime_control_plugin.py b/src/quant_strategy_plugins/market_regime_control_plugin.py index 1e23204..d77fc94 100644 --- a/src/quant_strategy_plugins/market_regime_control_plugin.py +++ b/src/quant_strategy_plugins/market_regime_control_plugin.py @@ -18,6 +18,7 @@ COMPONENT_MACRO = "macro" COMPONENT_TACO = "taco" COMPONENT_PANIC_REVERSAL = "panic_reversal" +COMPONENT_BENCHMARK_GUARD = "benchmark_guard" ROUTE_NO_ACTION = "no_action" ROUTE_WATCH = "watch" @@ -90,6 +91,8 @@ 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 "benchmark_drawdown_guard" in plugin: + return COMPONENT_BENCHMARK_GUARD if "macro_risk_governor" in plugin: return COMPONENT_MACRO if "taco" in plugin: @@ -108,7 +111,13 @@ def _normalize_component_signals( if not isinstance(payload, Mapping): continue component = str(key or "").strip().lower() - if component in {COMPONENT_CRISIS, COMPONENT_MACRO, COMPONENT_TACO, COMPONENT_PANIC_REVERSAL}: + if component in { + COMPONENT_CRISIS, + COMPONENT_MACRO, + COMPONENT_TACO, + COMPONENT_PANIC_REVERSAL, + COMPONENT_BENCHMARK_GUARD, + }: normalized[component] = payload continue inferred = _component_key(payload) @@ -353,11 +362,13 @@ def build_market_regime_control_signal( macro = components.get(COMPONENT_MACRO) taco = components.get(COMPONENT_TACO) panic_reversal = components.get(COMPONENT_PANIC_REVERSAL) + benchmark_guard = components.get(COMPONENT_BENCHMARK_GUARD) crisis_route = _normalized_route(crisis) macro_route = _normalized_route(macro) taco_route = _normalized_route(taco) panic_reversal_route = _normalized_route(panic_reversal) + benchmark_guard_route = _normalized_route(benchmark_guard) crisis_active = bool(crisis_route in CRISIS_ACTIVE_ROUTES and not _blocked(crisis)) crisis_watch = bool( crisis_route in CRISIS_WATCH_ROUTES @@ -399,6 +410,7 @@ def build_market_regime_control_signal( and not _blocked(panic_reversal) ) blocked = any(_blocked(payload) for payload in components.values()) + benchmark_guard_blocked = _blocked(benchmark_guard) final_route = ROUTE_NO_ACTION suggested_action = ACTION_NO_ACTION @@ -416,7 +428,15 @@ def build_market_regime_control_signal( vetoed_opportunities: list[dict[str, Any]] = [] reason_codes: list[str] = [] - if crisis_active: + if benchmark_guard_blocked: + final_route = ROUTE_BLOCKED + suggested_action = ACTION_BLOCKED + route_source = COMPONENT_BENCHMARK_GUARD + reason_codes.extend( + f"benchmark_guard:{code}" + for code in _reason_codes(benchmark_guard) or ("blocked",) + ) + elif crisis_active: final_route = ROUTE_RISK_OFF suggested_action = ACTION_DEFEND route_source = COMPONENT_CRISIS @@ -439,6 +459,26 @@ def build_market_regime_control_signal( summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto) if summary: vetoed_opportunities.append(summary) + elif benchmark_guard_route == ROUTE_RISK_OFF: + final_route = ROUTE_RISK_OFF + suggested_action = ACTION_DEFEND + route_source = COMPONENT_BENCHMARK_GUARD + would_trade_if_enabled = True + leverage_scalar = _clamp_ratio( + benchmark_guard.get("leverage_scalar") if isinstance(benchmark_guard, Mapping) else None, + default=0.0, + ) + risk_asset_scalar = _clamp_ratio( + benchmark_guard.get("risk_asset_scalar") if isinstance(benchmark_guard, Mapping) else None, + default=0.0, + ) + risk_budget_scalar = risk_asset_scalar + crisis_defense_required = risk_asset_scalar == 0.0 + blocked_actions = ("increase_leverage", "increase_risk", "taco_rebound_veto", "panic_reversal_veto") + reason_codes.extend( + f"benchmark_guard:{code}" + for code in _reason_codes(benchmark_guard) or ("benchmark_drawdown_hard",) + ) elif macro_active and macro_route == "crisis": final_route = ROUTE_RISK_OFF suggested_action = ACTION_DEFEND @@ -461,6 +501,25 @@ def build_market_regime_control_signal( summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto) if summary: vetoed_opportunities.append(summary) + elif benchmark_guard_route == ROUTE_RISK_REDUCED: + final_route = ROUTE_RISK_REDUCED + suggested_action = ACTION_DELEVER + route_source = COMPONENT_BENCHMARK_GUARD + would_trade_if_enabled = True + leverage_scalar = _clamp_ratio( + benchmark_guard.get("leverage_scalar") if isinstance(benchmark_guard, Mapping) else None, + default=1.0, + ) + risk_asset_scalar = _clamp_ratio( + benchmark_guard.get("risk_asset_scalar") if isinstance(benchmark_guard, Mapping) else None, + default=1.0, + ) + risk_budget_scalar = risk_asset_scalar + blocked_actions = ("increase_leverage", "taco_rebound_veto", "panic_reversal_veto") + reason_codes.extend( + f"benchmark_guard:{code}" + for code in _reason_codes(benchmark_guard) or ("benchmark_drawdown_soft",) + ) elif macro_active: final_route = ROUTE_RISK_REDUCED suggested_action = ACTION_DELEVER @@ -579,6 +638,7 @@ def build_market_regime_control_signal( COMPONENT_MACRO: _compact_signal(macro), COMPONENT_TACO: _compact_signal(taco), COMPONENT_PANIC_REVERSAL: _compact_signal(panic_reversal), + COMPONENT_BENCHMARK_GUARD: _compact_signal(benchmark_guard), }, "execution_controls": { "capital_impact": "strategy_opt_in", diff --git a/src/quant_strategy_plugins/strategy_plugin_runner.py b/src/quant_strategy_plugins/strategy_plugin_runner.py index f00c1b1..b9c2e1b 100644 --- a/src/quant_strategy_plugins/strategy_plugin_runner.py +++ b/src/quant_strategy_plugins/strategy_plugin_runner.py @@ -9,6 +9,7 @@ import pandas as pd from .artifacts import write_json +from .benchmark_drawdown_guard import build_benchmark_drawdown_guard_signal from .crisis_response_shadow_plugin import ( SHADOW_MODE, build_crisis_response_shadow_signal, @@ -1467,6 +1468,33 @@ def _build_macro_risk_governor_payload(price_history: pd.DataFrame, plugin_confi ) +def _build_benchmark_drawdown_guard_payload( + price_history: pd.DataFrame, plugin_config: Mapping[str, Any] +) -> dict[str, Any]: + required = ( + "benchmark_guard_benchmark_symbol", + "benchmark_guard_drawdown_lookback_sessions", + "benchmark_guard_soft_drawdown_threshold", + "benchmark_guard_hard_drawdown_threshold", + "benchmark_guard_soft_risk_asset_scalar", + "benchmark_guard_hard_risk_asset_scalar", + "benchmark_guard_max_price_age_days", + ) + if any(key not in plugin_config for key in required): + raise ValueError("benchmark drawdown guard requires an explicit frozen policy") + return build_benchmark_drawdown_guard_signal( + price_history, + benchmark_symbol=plugin_config["benchmark_guard_benchmark_symbol"], + as_of=str(plugin_config.get("as_of", "") or "").strip() or None, + drawdown_lookback_sessions=plugin_config["benchmark_guard_drawdown_lookback_sessions"], + soft_drawdown_threshold=plugin_config["benchmark_guard_soft_drawdown_threshold"], + hard_drawdown_threshold=plugin_config["benchmark_guard_hard_drawdown_threshold"], + soft_risk_asset_scalar=plugin_config["benchmark_guard_soft_risk_asset_scalar"], + hard_risk_asset_scalar=plugin_config["benchmark_guard_hard_risk_asset_scalar"], + max_price_age_days=plugin_config["benchmark_guard_max_price_age_days"], + ) + + 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): @@ -1479,6 +1507,10 @@ def _build_market_regime_control_payload(price_history: pd.DataFrame, plugin_con panic_config = dict(plugin_config) panic_config.setdefault("suppress_when_price_crisis_guard_active", False) components["panic_reversal"] = _build_panic_reversal_payload(price_history, panic_config) + if _as_bool(plugin_config.get("benchmark_drawdown_guard_enabled"), default=False): + components["benchmark_guard"] = _build_benchmark_drawdown_guard_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(), diff --git a/tests/test_benchmark_drawdown_guard.py b/tests/test_benchmark_drawdown_guard.py new file mode 100644 index 0000000..83af715 --- /dev/null +++ b/tests/test_benchmark_drawdown_guard.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quant_strategy_plugins.benchmark_drawdown_guard import ( + ROUTE_BLOCKED, + ROUTE_NO_ACTION, + ROUTE_RISK_OFF, + ROUTE_RISK_REDUCED, + build_benchmark_drawdown_guard_signal, +) +from quant_strategy_plugins.strategy_plugin_runner import _build_market_regime_control_payload + + +def _prices(closes: list[float]) -> pd.DataFrame: + dates = pd.bdate_range("2026-01-02", periods=len(closes)) + return pd.DataFrame( + { + "symbol": "QQQ", + "as_of": dates, + "close": closes, + } + ) + + +def _guard(prices: pd.DataFrame, **overrides: object) -> dict[str, object]: + config: dict[str, object] = { + "benchmark_symbol": "QQQ", + "as_of": "2026-02-12", + "drawdown_lookback_sessions": 20, + "soft_drawdown_threshold": -0.05, + "hard_drawdown_threshold": -0.10, + "soft_risk_asset_scalar": 0.50, + "hard_risk_asset_scalar": 0.0, + "max_price_age_days": 3, + } + config.update(overrides) + return build_benchmark_drawdown_guard_signal(prices, **config) # type: ignore[arg-type] + + +def test_guard_preserves_risk_when_benchmark_drawdown_is_below_soft_threshold() -> None: + payload = _guard(_prices([100.0 + index for index in range(30)])) + + assert payload["canonical_route"] == ROUTE_NO_ACTION + assert payload["risk_asset_scalar"] == 1.0 + assert payload["execution_controls"]["broker_order_allowed"] is False # type: ignore[index] + + +def test_guard_reduces_all_risk_assets_on_soft_benchmark_drawdown() -> None: + payload = _guard(_prices([100.0 + index for index in range(20)] + [116.0] * 5 + [113.0] * 5)) + + assert payload["canonical_route"] == ROUTE_RISK_REDUCED + assert payload["risk_asset_scalar"] == 0.50 + assert payload["reason_codes"] == ["benchmark_drawdown_soft"] + + +def test_guard_moves_to_defense_on_hard_benchmark_drawdown() -> None: + payload = _guard(_prices([100.0 + index for index in range(20)] + [100.0] * 10)) + + assert payload["canonical_route"] == ROUTE_RISK_OFF + assert payload["risk_asset_scalar"] == 0.0 + assert payload["reason_codes"] == ["benchmark_drawdown_hard"] + + +@pytest.mark.parametrize( + ("prices", "overrides", "reason"), + [ + (pd.DataFrame(), {}, "benchmark_history_unavailable"), + (_prices([100.0] * 10), {"as_of": "2026-01-15"}, "benchmark_history_incomplete"), + (_prices([100.0] * 30), {"benchmark_symbol": "SOXX"}, "benchmark_missing"), + (_prices([100.0] * 30), {"as_of": "2026-03-01", "max_price_age_days": 1}, "benchmark_stale"), + ], +) +def test_guard_fails_closed_on_unusable_benchmark_input( + prices: pd.DataFrame, overrides: dict[str, object], reason: str +) -> None: + payload = _guard(prices, **overrides) + + assert payload["canonical_route"] == ROUTE_BLOCKED + assert payload["kill_switch_active"] is True + assert payload["reason_codes"] == [reason] + + +def test_guard_rejects_implicit_or_incoherent_policy() -> None: + with pytest.raises(ValueError, match="hard_drawdown_threshold"): + _guard(_prices([100.0] * 30), hard_drawdown_threshold=-0.04) + with pytest.raises(ValueError, match="hard_risk_asset_scalar"): + _guard(_prices([100.0] * 30), hard_risk_asset_scalar=0.75) + + +def test_unified_market_regime_requires_an_explicit_policy_to_mount_the_guard() -> None: + config = { + "crisis_enabled": False, + "macro_enabled": False, + "taco_enabled": False, + "panic_reversal_enabled": False, + "benchmark_drawdown_guard_enabled": True, + "benchmark_guard_benchmark_symbol": "QQQ", + "benchmark_guard_drawdown_lookback_sessions": 20, + "benchmark_guard_soft_drawdown_threshold": -0.05, + "benchmark_guard_hard_drawdown_threshold": -0.10, + "benchmark_guard_soft_risk_asset_scalar": 0.50, + "benchmark_guard_hard_risk_asset_scalar": 0.0, + "benchmark_guard_max_price_age_days": 3, + "as_of": "2026-02-12", + } + + payload = _build_market_regime_control_payload( + _prices([100.0 + index for index in range(20)] + [116.0] * 5 + [113.0] * 5), config + ) + + assert payload["canonical_route"] == ROUTE_RISK_REDUCED + assert payload["position_control"]["risk_asset_scalar"] == 0.5 + with pytest.raises(ValueError, match="explicit frozen policy"): + _build_market_regime_control_payload(_prices([100.0] * 30), {"benchmark_drawdown_guard_enabled": True}) diff --git a/tests/test_market_regime_control_plugin.py b/tests/test_market_regime_control_plugin.py index f67c90a..fabc1fc 100644 --- a/tests/test_market_regime_control_plugin.py +++ b/tests/test_market_regime_control_plugin.py @@ -89,6 +89,28 @@ def test_market_regime_control_macro_delever_blocks_taco_veto() -> None: assert "macro:vix_crisis_level" in payload["position_control"]["reason_codes"] +def test_market_regime_control_applies_the_generic_benchmark_guard_to_all_risk_assets() -> None: + payload = build_market_regime_control_signal( + { + "benchmark_guard": { + "profile": "benchmark_drawdown_guard", + "as_of": "2026-08-25", + "canonical_route": "risk_reduced", + "suggested_action": "delever", + "leverage_scalar": 0.5, + "risk_asset_scalar": 0.5, + "reason_codes": ["benchmark_drawdown_soft"], + }, + } + ) + + assert payload["canonical_route"] == "risk_reduced" + assert payload["position_control"]["route_source"] == "benchmark_guard" + assert payload["position_control"]["leverage_scalar"] == 0.5 + assert payload["position_control"]["risk_asset_scalar"] == 0.5 + assert payload["component_signals"]["benchmark_guard"]["available"] is True + + def test_market_regime_control_taco_is_notification_with_local_veto_only() -> None: payload = build_market_regime_control_signal( { diff --git a/uv.lock b/uv.lock index 240a35b..b0ab604 100644 --- a/uv.lock +++ b/uv.lock @@ -638,7 +638,7 @@ requires-dist = [ { name = "build", marker = "extra == 'test'", specifier = ">=1.2" }, { name = "pandas", specifier = ">=2.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, - { name = "ruff", marker = "extra == 'test'", specifier = "==0.15.22" }, + { name = "ruff", marker = "extra == 'test'", specifier = "==0.16.4" }, { name = "yfinance", specifier = ">=0.2.40" }, ] provides-extras = ["test"] @@ -673,27 +673,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +version = "0.16.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] [[package]]