"""agent_20_regime_switch — ANGLE [family=vol, slug=regime_switch]. Regime switch on the realized-vol PERCENTILE (expanding / online): * Compute short-window realized vol rv[i] at each bar. * Rank it against its EXPANDING percentile (the causal "typical" vol seen so far) — a self-calibrating threshold that needs no magic vol level and adapts as the series evolves (no peeking at the full-sample distribution). * LOW-VOL regime (rv-rank <= PCTL): TREND-FOLLOW. Quiet, orderly markets are where momentum persists, so we ride the prevailing (multi-horizon) trend. * HIGH-VOL regime (rv-rank > PCTL): stand aside (FLAT). High realized vol is where trends whipsaw / V-reverse and where the big drawdowns are born; the cleanest expression of the "regime switch" is to refuse directional exposure there. The trend leg is a multi-horizon TSMOM SIGN blend (slow horizons ~1/2/4 months): a single lookback is regime-fragile, the blend keeps the slow macro trend while the fast horizon cuts exposure early into a turn. Final size is a trailing vol-target, so the position also shrinks into vol within the low-vol regime. CAUSAL: rv uses a trailing window; the percentile rank is EXPANDING (only past bars); each TSMOM sign uses close[i]/close[i-H]; vol_target uses a trailing realized-vol window. No look-ahead, no centered windows, no global fit. Verified by causality_ok (max_diff 0.0). Tuned ONLY on split='train' (Series A & B, equal weight). A coarse->fine sweep found a WIDE plateau: HZ=(25,60,120), PCTL in [0.60..0.70], VW in [35..55], RV in [15..25] all give sharpe_min ~1.25-1.30 at DD ~0.17-0.19. The chosen cell is interior on every axis (robust, not a lucky spike): RV_WIN=20, PCTL=0.65, HORIZONS=(25,60,120), TARGET_VOL=0.22, VOL_WIN=45, LEV_CAP=1.5 -> train combined: pnl_mean ~2.0, maxdd_worst ~0.18, sharpe_min ~1.30. Honest notes: * The high-vol leg is LONG-FLAT (not revert). A lightly-weighted contrarian leg in high vol helped marginally with a single-MA trend, but once the trend is the slow multi-horizon SIGN blend the reversion leg only added drag -> flat is strictly better here. The value is RISK-ADJUSTED: comparable/positive PnL at ~4x less drawdown than buy&hold (which eats ~77-79% DD on these curves), by sitting out the high-realized-vol regime where the violent declines happen. * Loosening the gate (PCTL ~0.65, not 0.50) is what lifts both Sharpe and PnL: the bottom ~half of the vol distribution is too restrictive and misses the early, still-low-vol part of the trend legs. The plateau is wide enough that the exact percentile is not load-bearing. """ import numpy as np import blindlib as bl RV_WIN = 20 # short realized-vol window ("current" vol) PCTL = 0.65 # expanding vol-percentile gate: trend-follow when rank <= this HORIZONS = (25, 60, 120) # multi-horizon TSMOM sign blend (~1/2/4 months of daily bars) TARGET_VOL = 0.22 VOL_WIN_DAYS = 45 LEV_CAP = 1.5 MIN_HIST = 60 # warmup before the expanding percentile is trusted def _expanding_pctl_rank(x: np.ndarray, min_hist: int) -> np.ndarray: """rank[i] = fraction of finite x[0..i] that are <= x[i] (causal, expanding). NaN until `min_hist` finite values have accumulated.""" n = len(x) rank = np.full(n, np.nan) seen: list[float] = [] for i in range(n): v = x[i] if np.isfinite(v): seen.append(v) if len(seen) >= min_hist: rank[i] = float(np.mean(np.asarray(seen) <= v)) return rank def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray: """Sign of the past-h-bar return, causal. 0 for i < h.""" out = np.zeros(len(c)) if h < len(c): out[h:] = np.sign(c[h:] / c[:-h] - 1.0) return out def signal(df): c = df["close"].values.astype(float) bpy = bl.bars_per_day(df) * 365.25 # 1) short-window realized vol and its EXPANDING percentile rank (causal). rv = bl.realized_vol(bl.simple_returns(c), RV_WIN, bpy) rank = _expanding_pctl_rank(rv, MIN_HIST) low_vol = np.isfinite(rank) & (rank <= PCTL) # the LOW-VOL regime we trade # 2) multi-horizon TSMOM sign blend -> graded direction in [-1, +1] (causal). sig = np.zeros(len(c)) for h in HORIZONS: sig += _tsmom_sign(c, h) sig /= len(HORIZONS) # 3) regime switch: trend-follow ONLY in the low-vol regime, else flat. raw = np.where(low_vol, sig, 0.0) # 4) causal vol-targeting (shrinks size into vol -> caps DD). pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0)