"""agent_13_volbreak — ANGLE [family=breakout, slug=volbreak]. Volatility breakout: enter the trend direction when REALIZED VOL EXPANDS above its rolling median. The thesis: a fresh expansion of realized volatility marks a regime of large, directional moves (a breakout out of a quiet base). When vol picks up we align with the prevailing trend and ride it; when vol is compressed / below its rolling median we stand aside (no breakout in progress, just chop). Mechanics (all causal — value at i uses only rows 0..i): * VOL EXPANSION gate: annualized realized vol over a short window (RV_WIN) vs its own rolling median over a longer lookback (MED_WIN). "Expanded" when rv[i] > EXP_K * median(rv up to i). bl.realized_vol and pandas rolling are causal. * TREND direction: sign of price vs a moving average (close / SMA(TREND_WIN) - 1), decided at close[i]. This is the direction we take *only while* vol is expanded. * STATE / persistence: once vol expands we lock onto the current trend side and hold it (stop-and-reverse if the trend sign flips while still expanded) until vol falls back BELOW its median (expansion over) -> flat. This rides the whole high-vol leg instead of flickering bar to bar, keeping turnover (fees) down. * SIZING: the +1/0 direction is vol-targeted (TP01-style) so exposure shrinks into the very vol spikes the gate selects -> caps drawdown on violent reversals. Tuned ONLY on split='train' (Series A and B, equal weight; broad plateau grid below). Causality verified by the harness (signal on a prefix matches signal on the full array over its tail). Honest notes: * On these strongly-trending high-vol curves the edge is essentially "be long the trend, but ONLY when vol confirms a breakout, and shrink size into vol". Value is RISK-ADJUSTED: comparable/positive PnL at ~3-4x less drawdown than buy&hold (which eats ~77-79% DD here), not bigger raw PnL. Train combined Sharpe ~1.12, worst-DD ~23%, mean PnL ~1.14. * LONG-ONLY (SHORT_SCALE=0). Shorts were dropped after tuning: on these uptrends the down-trend + vol-expansion combo is dominated by violent V-bottom reversals, which are terrible to short -> a short leg (full OR damped) strictly LOWERED Sharpe and raised DD on both train curves. The short leg is not an edge here; flat is better. * EXP_K=0.8 means we trade when rv sits at/above 0.8x its rolling median — still a genuine vol-expansion gate (it stands aside in the lowest-vol ~30-40% of bars where price just chops), but inclusive enough not to miss the early part of a breakout leg. Requiring rv strictly ABOVE the median (K>=1.0) entered too late and gutted the Series-B trend capture (Sh 1.12 -> 0.28). The plateau holds for RV 15-20, MED 100-150, K 0.78-0.85, TREND 30-60. """ import numpy as np import pandas as pd import blindlib as bl # --- tuned on split='train' (broad plateau) --------------------------------- RV_WIN = 15 # short realized-vol window (the "current" vol) MED_WIN = 100 # rolling-median lookback for the vol baseline EXP_K = 0.80 # vol is "expanded" when rv > EXP_K * rolling-median(rv) TREND_WIN = 50 # trend filter: sign of close / SMA(TREND_WIN) - 1 SHORT_SCALE = 0.0 # LONG-ONLY: down-vol-breaks here are mostly V-reversals -> shorts bleed TARGET_VOL = 0.20 VOL_WIN_DAYS = 30 LEV_CAP = 1.5 def signal(df): c = df["close"].values.astype(float) n = len(c) bpy = bl.bars_per_day(df) * 365.25 # 1) realized vol (short) and its causal rolling median baseline. r = bl.simple_returns(c) rv = bl.realized_vol(r, RV_WIN, bpy) rv_med = pd.Series(rv).rolling(MED_WIN, min_periods=max(10, MED_WIN // 2)).median().values expanded = np.isfinite(rv) & np.isfinite(rv_med) & (rv > EXP_K * rv_med) # 2) trend direction decided at close[i] (causal). ma = bl.sma(c, TREND_WIN) with np.errstate(invalid="ignore", divide="ignore"): trend = np.where(np.isfinite(ma) & (ma > 0), c / ma - 1.0, 0.0) tsign = np.sign(trend) # 3) state machine: while vol is expanded, hold the trend side (S&R on sign flip); # when vol falls back below its (scaled) median the breakout is spent -> flat. state = np.zeros(n) s = 0.0 for i in range(n): if expanded[i]: if tsign[i] > 0: s = 1.0 elif tsign[i] < 0: s = -SHORT_SCALE # tsign == 0 -> keep current side else: s = 0.0 state[i] = s # 4) size by causal vol-targeting (shrinks into vol spikes -> caps DD). pos = bl.vol_target(state, 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)