|
| 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 | +] |
0 commit comments