"""agent_11_squeeze — ANGLE [family=breakout, slug=squeeze]. Range-compression (NR / Bollinger-squeeze) THEN expansion: after a low-volatility "coil", price tends to break out and run. We (1) detect the squeeze causally, (2) wait for the breakout out of the coil, (3) enter in the breakout direction, vol-targeted. Mechanics (all causal — value at i uses only rows 0..i): * SQUEEZE detector: Bollinger bandwidth = (BB_upper - BB_lower) / mid, using a rolling window ending at i. A bar is "coiled" when its bandwidth sits in the low tail of its own EXPANDING history (causal percentile, no future). This is the classic Bollinger-squeeze / NR proxy: bands pinch when realized vol compresses. * BREAKOUT trigger: a Donchian channel built STRICTLY from bars < i (bl.donchian shifts by 1). When close[i] pierces the prior N-bar high -> upside expansion; pierces the prior N-bar low -> downside expansion. The break is only ARMED if we were recently in a squeeze (coil within the last LOOKBACK bars) — that is the whole thesis: expansion out of compression, not a random breakout. * STATE machine: once a squeeze-armed breakout fires, carry that side (stop-and- reverse on the opposite squeeze-armed breakout) so we ride the post-coil expansion and keep turnover low. Decay to flat if the move stalls back inside the channel for a while (the coil's energy is spent). * SIZING: the +/-1 direction is vol-targeted (TP01-style) so exposure shrinks into vol spikes -> caps drawdown on whipsaws / failed breakouts. Tuned ONLY on split='train' (Series A and B, equal weight). Causality verified by the harness (signal on a prefix matches signal on the full array over its tail). Honest notes: * Squeeze-breakout is trend-following with a regime filter. On these trending curves it captures up-legs with ~3x less drawdown than buy&hold (DD ~29% vs ~70-80%) at only ~25-33% time-in-market; the cost is failed-breakout whipsaws after a fake-out coil. Value is risk-adjusted, not raw PnL. * Shorts were dropped (SHORT_SCALE=0): on both train curves the downside-breakout leg was a net loser (coils on an uptrend mostly fake out down -> V-bottoms), so the long/flat version is strictly better on Sharpe AND drawdown. * ABLATION CAVEAT: a pure Donchian breakout with the SAME hold/exit logic but NO coil gate scores marginally HIGHER on train (Sh ~1.05 / PnL ~1.34) than the coil-gated version. The squeeze gate trims turnover and DD but is NOT the source of the edge here — the edge is the breakout + vol-target. Kept the coil gate because the assigned angle is *squeeze*; it is a mild, honest improvement on risk, not magic. """ import numpy as np import pandas as pd import blindlib as bl # --- tuned on split='train' (broad plateau, see header / grid in commit) ------ BB_WIN = 20 # Bollinger window for bandwidth BB_K = 2.0 # Bollinger multiplier SQ_PCTL = 0.45 # bandwidth below this expanding-percentile = coil (sub-median # compression; tighter pctl over-filters and loses good breaks) DON_WIN = 25 # Donchian breakout lookback ARM_LOOKBACK = 15 # breakout must occur within this many bars of a coil HOLD_BARS = 40 # ride the post-coil expansion for ~this many bars, then decay STALL_BARS = 12 # if price falls back inside the channel this long, exit early SHORT_SCALE = 0.0 # downside-breakout sizing (0 = long/flat; coils on these # uptrends mostly fake out to the downside, so shorts bleed) TARGET_VOL = 0.20 VOL_WIN_DAYS = 30 LEV_CAP = 1.0 def _expanding_pctl_rank(x: np.ndarray, min_n: int = 60) -> np.ndarray: """Causal expanding percentile rank of x[i] within x[0..i]. rank in [0,1]. rank = fraction of past (<=i) values that are <= x[i]. Uses only rows 0..i.""" n = len(x) out = np.full(n, np.nan) # incremental sorted insertion would be O(n log n); n~2000 so an O(n^2) pass is # fine (<30s). Keep it simple and obviously causal. for i in range(n): xi = x[i] if not np.isfinite(xi): continue window = x[: i + 1] valid = window[np.isfinite(window)] if len(valid) < min_n: continue out[i] = float(np.mean(valid <= xi)) return out def signal(df): c = df["close"].values.astype(float) n = len(c) # 1) Bollinger bandwidth (causal) -> squeeze when bandwidth is in its low tail. upper, mid, lower = bl.bbands(c, BB_WIN, BB_K) with np.errstate(invalid="ignore", divide="ignore"): bw = (upper - lower) / np.where(np.abs(mid) > 0, mid, np.nan) bw_rank = _expanding_pctl_rank(bw, min_n=max(60, BB_WIN * 2)) coil = np.nan_to_num(bw_rank, nan=1.0) <= SQ_PCTL # True where compressed # "recently coiled" = a coil within the last ARM_LOOKBACK bars (causal). coil_recent = ( pd.Series(coil.astype(float)).rolling(ARM_LOOKBACK, min_periods=1).max().values > 0 ) # 2) Donchian breakout (prior-bar channel; bl.donchian already shifts by 1). don_hi, don_lo = bl.donchian(df, DON_WIN) up_break = np.isfinite(don_hi) & (c > don_hi) dn_break = np.isfinite(don_lo) & (c < don_lo) # 3) state machine: arm breakouts only when they expand out of a recent coil. # The thesis is that the EDGE lives in the expansion right after the coil, so # we ride a fired breakout for HOLD_BARS then decay to flat (the coil's energy # is spent). A fresh squeeze-armed breakout re-arms / re-times the hold. We # exit early if price collapses back inside the channel (failed breakout). state = np.zeros(n) s = 0.0 age = 0 # bars since the active breakout fired inside_count = 0 # consecutive bars back inside the channel since trigger for i in range(n): armed = coil_recent[i] fired = False if armed and up_break[i]: s = 1.0; age = 0; inside_count = 0; fired = True elif armed and dn_break[i]: s = -SHORT_SCALE; age = 0; inside_count = 0; fired = (SHORT_SCALE > 0) if not fired and s != 0.0: age += 1 # failed-breakout guard: price back inside the prior channel in_channel = True if np.isfinite(don_hi[i]) and c[i] > don_hi[i]: in_channel = False if np.isfinite(don_lo[i]) and c[i] < don_lo[i]: in_channel = False inside_count = inside_count + 1 if in_channel else 0 if inside_count >= STALL_BARS or age >= HOLD_BARS: s = 0.0; age = 0; inside_count = 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)