Skip to content

Commit 8d0340c

Browse files
Pigbibicodex
andcommitted
feat: add explicit benchmark drawdown guard
Co-Authored-By: Codex <noreply@openai.com>
1 parent b5211b6 commit 8d0340c

7 files changed

Lines changed: 467 additions & 24 deletions

src/quant_strategy_plugins/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Open strategy plugin implementations for QuantStrategyLab runtimes."""
22

3+
from .benchmark_drawdown_guard import (
4+
PROFILE as BENCHMARK_DRAWDOWN_GUARD_PROFILE,
5+
SCHEMA_VERSION as BENCHMARK_DRAWDOWN_GUARD_SCHEMA_VERSION,
6+
build_benchmark_drawdown_guard_signal,
7+
)
38
from .crisis_response_shadow_plugin import (
49
SCHEMA_VERSION as CRISIS_RESPONSE_SHADOW_SCHEMA_VERSION,
510
SHADOW_PROFILE as CRISIS_RESPONSE_SHADOW_PROFILE,
@@ -33,6 +38,8 @@
3338
)
3439

3540
__all__ = [
41+
"BENCHMARK_DRAWDOWN_GUARD_PROFILE",
42+
"BENCHMARK_DRAWDOWN_GUARD_SCHEMA_VERSION",
3643
"CRISIS_RESPONSE_SHADOW_PROFILE",
3744
"CRISIS_RESPONSE_SHADOW_SCHEMA_VERSION",
3845
"MACRO_RISK_GOVERNOR_PROFILE",
@@ -43,6 +50,7 @@
4350
"PANIC_REVERSAL_SHADOW_SCHEMA_VERSION",
4451
"TACO_REBOUND_PROFILE",
4552
"TACO_REBOUND_SHADOW_SCHEMA_VERSION",
53+
"build_benchmark_drawdown_guard_signal",
4654
"build_crisis_response_shadow_signal",
4755
"build_macro_risk_governor_signal",
4856
"build_market_regime_control_signal",
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""Price-only, research-only benchmark drawdown guard.
2+
3+
The guard deliberately emits a signal instead of an allocation. A strategy
4+
must explicitly opt in through the existing unified market-regime control and
5+
bind the exact guard configuration to its own research candidate before the
6+
signal can affect a portfolio.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any
12+
13+
import pandas as pd
14+
15+
from .plugin_signal_utils import json_scalar, normalize_close, resolve_signal_date
16+
17+
SCHEMA_VERSION = "benchmark_drawdown_guard.v1"
18+
PROFILE = "benchmark_drawdown_guard"
19+
20+
ROUTE_NO_ACTION = "no_action"
21+
ROUTE_RISK_REDUCED = "risk_reduced"
22+
ROUTE_RISK_OFF = "risk_off"
23+
ROUTE_BLOCKED = "blocked"
24+
25+
ACTION_NO_ACTION = "no_action"
26+
ACTION_DELEVER = "delever"
27+
ACTION_DEFEND = "defend"
28+
ACTION_BLOCKED = "blocked"
29+
30+
31+
def _ratio(value: object, *, name: str, lower: float = 0.0, upper: float = 1.0) -> float:
32+
if isinstance(value, bool) or not isinstance(value, (int, float)):
33+
raise ValueError(f"{name} must be a finite ratio")
34+
result = float(value)
35+
if not pd.notna(result) or not lower <= result <= upper:
36+
raise ValueError(f"{name} must be a finite ratio")
37+
return result
38+
39+
40+
def _positive_int(value: object, *, name: str) -> int:
41+
if isinstance(value, bool) or not isinstance(value, int) or value < 2:
42+
raise ValueError(f"{name} must be an integer greater than one")
43+
return value
44+
45+
46+
def _nonnegative_int(value: object, *, name: str) -> int:
47+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
48+
raise ValueError(f"{name} must be a non-negative integer")
49+
return value
50+
51+
52+
def _threshold(value: object, *, name: str) -> float:
53+
if isinstance(value, bool) or not isinstance(value, (int, float)):
54+
raise ValueError(f"{name} must be a drawdown threshold")
55+
result = float(value)
56+
if not pd.notna(result) or not -1.0 < result < 0.0:
57+
raise ValueError(f"{name} must be a drawdown threshold")
58+
return result
59+
60+
61+
def _blocked(*, as_of: str, benchmark_symbol: str, reason_code: str) -> dict[str, Any]:
62+
return {
63+
"schema_version": SCHEMA_VERSION,
64+
"profile": PROFILE,
65+
"as_of": as_of,
66+
"benchmark_symbol": benchmark_symbol,
67+
"canonical_route": ROUTE_BLOCKED,
68+
"suggested_action": ACTION_BLOCKED,
69+
"would_trade_if_enabled": False,
70+
"kill_switch_active": True,
71+
"leverage_scalar": 0.0,
72+
"risk_asset_scalar": 0.0,
73+
"reason_codes": (reason_code,),
74+
"data_quality": {"status": "PARKED", "reason_codes": (reason_code,)},
75+
"execution_controls": {
76+
"broker_order_allowed": False,
77+
"live_allocation_mutation_allowed": False,
78+
"strategy_opt_in_required": True,
79+
},
80+
}
81+
82+
83+
def build_benchmark_drawdown_guard_signal(
84+
price_history,
85+
*,
86+
benchmark_symbol: str,
87+
as_of: str | None,
88+
drawdown_lookback_sessions: int,
89+
soft_drawdown_threshold: float,
90+
hard_drawdown_threshold: float,
91+
soft_risk_asset_scalar: float,
92+
hard_risk_asset_scalar: float,
93+
max_price_age_days: int,
94+
) -> dict[str, Any]:
95+
"""Build one causal, configured benchmark guard signal.
96+
97+
No threshold, scalar, benchmark, or freshness policy has a hidden default.
98+
This prevents a caller from accidentally treating a research helper as an
99+
unstated, live-capable stop-loss policy.
100+
"""
101+
symbol = str(benchmark_symbol or "").strip().upper()
102+
if not symbol:
103+
raise ValueError("benchmark_symbol is required")
104+
lookback = _positive_int(drawdown_lookback_sessions, name="drawdown_lookback_sessions")
105+
max_age = _nonnegative_int(max_price_age_days, name="max_price_age_days")
106+
soft_threshold = _threshold(soft_drawdown_threshold, name="soft_drawdown_threshold")
107+
hard_threshold = _threshold(hard_drawdown_threshold, name="hard_drawdown_threshold")
108+
if hard_threshold >= soft_threshold:
109+
raise ValueError("hard_drawdown_threshold must be below soft_drawdown_threshold")
110+
soft_scalar = _ratio(soft_risk_asset_scalar, name="soft_risk_asset_scalar")
111+
hard_scalar = _ratio(hard_risk_asset_scalar, name="hard_risk_asset_scalar")
112+
if hard_scalar > soft_scalar:
113+
raise ValueError("hard_risk_asset_scalar must not exceed soft_risk_asset_scalar")
114+
115+
# A missing or malformed price payload must park the guard rather than
116+
# leave the enclosing strategy with an implicit "no action" result.
117+
# This is deliberately narrower than ``Exception``: programming bugs
118+
# should still be visible to CI instead of being disguised as data gaps.
119+
fallback_as_of = str(as_of or "unavailable").strip() or "unavailable"
120+
try:
121+
close = normalize_close(price_history)
122+
requested_date, signal_date = resolve_signal_date(close, as_of)
123+
except (KeyError, RuntimeError, TypeError, ValueError):
124+
return json_scalar(
125+
_blocked(
126+
as_of=fallback_as_of,
127+
benchmark_symbol=symbol,
128+
reason_code="benchmark_history_unavailable",
129+
)
130+
)
131+
signal_as_of = signal_date.date().isoformat()
132+
if symbol not in close.columns:
133+
return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_missing"))
134+
price_age_days = int((requested_date - signal_date).days)
135+
if price_age_days > max_age:
136+
return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_stale"))
137+
benchmark = pd.to_numeric(close[symbol], errors="coerce").loc[:signal_date].dropna()
138+
if len(benchmark) < lookback:
139+
return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_history_incomplete"))
140+
window = benchmark.tail(lookback)
141+
current = float(window.iloc[-1])
142+
peak = float(window.max())
143+
if current <= 0.0 or peak <= 0.0:
144+
return json_scalar(_blocked(as_of=signal_as_of, benchmark_symbol=symbol, reason_code="benchmark_price_invalid"))
145+
drawdown = current / peak - 1.0
146+
147+
route = ROUTE_NO_ACTION
148+
action = ACTION_NO_ACTION
149+
risk_asset_scalar = 1.0
150+
reason_codes: tuple[str, ...] = ()
151+
if drawdown <= hard_threshold:
152+
route = ROUTE_RISK_OFF
153+
action = ACTION_DEFEND
154+
risk_asset_scalar = hard_scalar
155+
reason_codes = ("benchmark_drawdown_hard",)
156+
elif drawdown <= soft_threshold:
157+
route = ROUTE_RISK_REDUCED
158+
action = ACTION_DELEVER
159+
risk_asset_scalar = soft_scalar
160+
reason_codes = ("benchmark_drawdown_soft",)
161+
return json_scalar(
162+
{
163+
"schema_version": SCHEMA_VERSION,
164+
"profile": PROFILE,
165+
"as_of": signal_as_of,
166+
"benchmark_symbol": symbol,
167+
"canonical_route": route,
168+
"suggested_action": action,
169+
"would_trade_if_enabled": route != ROUTE_NO_ACTION,
170+
"kill_switch_active": False,
171+
"leverage_scalar": risk_asset_scalar,
172+
"risk_asset_scalar": risk_asset_scalar,
173+
"reason_codes": reason_codes,
174+
"data_quality": {
175+
"status": "READY",
176+
"price_age_days": price_age_days,
177+
"lookback_sessions": lookback,
178+
},
179+
"metrics": {
180+
"rolling_drawdown": drawdown,
181+
"soft_drawdown_threshold": soft_threshold,
182+
"hard_drawdown_threshold": hard_threshold,
183+
},
184+
"execution_controls": {
185+
"broker_order_allowed": False,
186+
"live_allocation_mutation_allowed": False,
187+
"strategy_opt_in_required": True,
188+
},
189+
}
190+
)
191+
192+
193+
__all__ = [
194+
"ACTION_BLOCKED",
195+
"ACTION_DEFEND",
196+
"ACTION_DELEVER",
197+
"ACTION_NO_ACTION",
198+
"PROFILE",
199+
"ROUTE_BLOCKED",
200+
"ROUTE_NO_ACTION",
201+
"ROUTE_RISK_OFF",
202+
"ROUTE_RISK_REDUCED",
203+
"SCHEMA_VERSION",
204+
"build_benchmark_drawdown_guard_signal",
205+
]

src/quant_strategy_plugins/market_regime_control_plugin.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
COMPONENT_MACRO = "macro"
1919
COMPONENT_TACO = "taco"
2020
COMPONENT_PANIC_REVERSAL = "panic_reversal"
21+
COMPONENT_BENCHMARK_GUARD = "benchmark_guard"
2122

2223
ROUTE_NO_ACTION = "no_action"
2324
ROUTE_WATCH = "watch"
@@ -90,6 +91,8 @@ def _component_key(payload: Mapping[str, Any]) -> str | None:
9091
plugin = _optional_text(payload.get("plugin") or payload.get("profile")).lower()
9192
if "crisis_response" in plugin:
9293
return COMPONENT_CRISIS
94+
if "benchmark_drawdown_guard" in plugin:
95+
return COMPONENT_BENCHMARK_GUARD
9396
if "macro_risk_governor" in plugin:
9497
return COMPONENT_MACRO
9598
if "taco" in plugin:
@@ -108,7 +111,13 @@ def _normalize_component_signals(
108111
if not isinstance(payload, Mapping):
109112
continue
110113
component = str(key or "").strip().lower()
111-
if component in {COMPONENT_CRISIS, COMPONENT_MACRO, COMPONENT_TACO, COMPONENT_PANIC_REVERSAL}:
114+
if component in {
115+
COMPONENT_CRISIS,
116+
COMPONENT_MACRO,
117+
COMPONENT_TACO,
118+
COMPONENT_PANIC_REVERSAL,
119+
COMPONENT_BENCHMARK_GUARD,
120+
}:
112121
normalized[component] = payload
113122
continue
114123
inferred = _component_key(payload)
@@ -353,11 +362,13 @@ def build_market_regime_control_signal(
353362
macro = components.get(COMPONENT_MACRO)
354363
taco = components.get(COMPONENT_TACO)
355364
panic_reversal = components.get(COMPONENT_PANIC_REVERSAL)
365+
benchmark_guard = components.get(COMPONENT_BENCHMARK_GUARD)
356366

357367
crisis_route = _normalized_route(crisis)
358368
macro_route = _normalized_route(macro)
359369
taco_route = _normalized_route(taco)
360370
panic_reversal_route = _normalized_route(panic_reversal)
371+
benchmark_guard_route = _normalized_route(benchmark_guard)
361372
crisis_active = bool(crisis_route in CRISIS_ACTIVE_ROUTES and not _blocked(crisis))
362373
crisis_watch = bool(
363374
crisis_route in CRISIS_WATCH_ROUTES
@@ -399,6 +410,7 @@ def build_market_regime_control_signal(
399410
and not _blocked(panic_reversal)
400411
)
401412
blocked = any(_blocked(payload) for payload in components.values())
413+
benchmark_guard_blocked = _blocked(benchmark_guard)
402414

403415
final_route = ROUTE_NO_ACTION
404416
suggested_action = ACTION_NO_ACTION
@@ -416,7 +428,15 @@ def build_market_regime_control_signal(
416428
vetoed_opportunities: list[dict[str, Any]] = []
417429
reason_codes: list[str] = []
418430

419-
if crisis_active:
431+
if benchmark_guard_blocked:
432+
final_route = ROUTE_BLOCKED
433+
suggested_action = ACTION_BLOCKED
434+
route_source = COMPONENT_BENCHMARK_GUARD
435+
reason_codes.extend(
436+
f"benchmark_guard:{code}"
437+
for code in _reason_codes(benchmark_guard) or ("blocked",)
438+
)
439+
elif crisis_active:
420440
final_route = ROUTE_RISK_OFF
421441
suggested_action = ACTION_DEFEND
422442
route_source = COMPONENT_CRISIS
@@ -439,6 +459,26 @@ def build_market_regime_control_signal(
439459
summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto)
440460
if summary:
441461
vetoed_opportunities.append(summary)
462+
elif benchmark_guard_route == ROUTE_RISK_OFF:
463+
final_route = ROUTE_RISK_OFF
464+
suggested_action = ACTION_DEFEND
465+
route_source = COMPONENT_BENCHMARK_GUARD
466+
would_trade_if_enabled = True
467+
leverage_scalar = _clamp_ratio(
468+
benchmark_guard.get("leverage_scalar") if isinstance(benchmark_guard, Mapping) else None,
469+
default=0.0,
470+
)
471+
risk_asset_scalar = _clamp_ratio(
472+
benchmark_guard.get("risk_asset_scalar") if isinstance(benchmark_guard, Mapping) else None,
473+
default=0.0,
474+
)
475+
risk_budget_scalar = risk_asset_scalar
476+
crisis_defense_required = risk_asset_scalar == 0.0
477+
blocked_actions = ("increase_leverage", "increase_risk", "taco_rebound_veto", "panic_reversal_veto")
478+
reason_codes.extend(
479+
f"benchmark_guard:{code}"
480+
for code in _reason_codes(benchmark_guard) or ("benchmark_drawdown_hard",)
481+
)
442482
elif macro_active and macro_route == "crisis":
443483
final_route = ROUTE_RISK_OFF
444484
suggested_action = ACTION_DEFEND
@@ -461,6 +501,25 @@ def build_market_regime_control_signal(
461501
summary = _opportunity_summary(COMPONENT_PANIC_REVERSAL, panic_reversal, veto)
462502
if summary:
463503
vetoed_opportunities.append(summary)
504+
elif benchmark_guard_route == ROUTE_RISK_REDUCED:
505+
final_route = ROUTE_RISK_REDUCED
506+
suggested_action = ACTION_DELEVER
507+
route_source = COMPONENT_BENCHMARK_GUARD
508+
would_trade_if_enabled = True
509+
leverage_scalar = _clamp_ratio(
510+
benchmark_guard.get("leverage_scalar") if isinstance(benchmark_guard, Mapping) else None,
511+
default=1.0,
512+
)
513+
risk_asset_scalar = _clamp_ratio(
514+
benchmark_guard.get("risk_asset_scalar") if isinstance(benchmark_guard, Mapping) else None,
515+
default=1.0,
516+
)
517+
risk_budget_scalar = risk_asset_scalar
518+
blocked_actions = ("increase_leverage", "taco_rebound_veto", "panic_reversal_veto")
519+
reason_codes.extend(
520+
f"benchmark_guard:{code}"
521+
for code in _reason_codes(benchmark_guard) or ("benchmark_drawdown_soft",)
522+
)
464523
elif macro_active:
465524
final_route = ROUTE_RISK_REDUCED
466525
suggested_action = ACTION_DELEVER
@@ -579,6 +638,7 @@ def build_market_regime_control_signal(
579638
COMPONENT_MACRO: _compact_signal(macro),
580639
COMPONENT_TACO: _compact_signal(taco),
581640
COMPONENT_PANIC_REVERSAL: _compact_signal(panic_reversal),
641+
COMPONENT_BENCHMARK_GUARD: _compact_signal(benchmark_guard),
582642
},
583643
"execution_controls": {
584644
"capital_impact": "strategy_opt_in",

0 commit comments

Comments
 (0)