"""Agent 15 — Bollinger-band reversion, low-vol gated (family=meanrev, slug=bbands). The angle (assigned): fade touches of the Bollinger bands (bl.bbands), only in a low-vol regime. Tune win, k. What the train curves actually say (A & B, split='train', diagnosed before coding): both trend UP hard (+6.7x / +23x, ann vol ~0.7-0.9). The TEXTBOOK symmetric band-fade is a LOSER here and the data is blunt about why: * UPPER-band touch -> CONTINUATION, not reversion. fwd-5bar after a close>=upper is +3.4%/+2.7% (A/B) even when we restrict to the low-vol regime. In a bull, riding the upper band is momentum; shorting it just bleeds against the drift. So the SHORT side of the classic fade is dead and we do NOT take it. * LOWER-band touch is reversion ONLY when it is a DIP IN AN UPTREND. close<=lower while price is above a long SMA -> fwd-5bar +3.5%/+7.2% (A/B): the band stretch snaps back up. The same lower touch in a DOWNTREND / high-vol continues DOWN (A high-vol lo-touch fwd-5 = -3.9%): a real knife. So the reversion we keep is the buy-the-dip-in-uptrend leg, and we gate it OFF in downtrends and in high vol. Hence the rule is an HONEST, one-sided Bollinger reversion: LONG the lower-band touch, but only while (a) close is above a long trend SMA and (b) realized vol is in its lower regime (the assigned low-vol gate). %b drives a smooth appetite (deeper below the band -> bigger), the long is HELD with hysteresis until price mean-reverts back through the mid band, then flat. Sizing is vol-targeted so the two curves are risk-comparable and exposure shrinks into vol spikes (which are exactly the regime where the dip-buy fails). HONEST NOTE: in a market trending this hard a trend+lowvol-gated dip-buy partially degenerates toward trend participation — the genuine reversion content is the buy-below-band / exit-at-mid cycle plus the DD control from the gates + vol-target. The symmetric short-the- upper-band leg that "Bollinger reversion" classically implies carries NEGATIVE edge on these curves, so taking it would only add drawdown; the result is therefore a modest-but-real reversion edge, NOT a high-PnL alpha. A negative result for the *symmetric* fade is itself a finding (documented above). CAUSAL: bbands/sma/realized_vol are trailing (value at i uses bars <= i); the hold-state is a forward cumulative pass over PAST bars only; vol_target uses trailing realized vol. No shift(-k), no centered windows, no global fit. Verified by causality_ok (max_diff ~0). Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell is interior on every axis and sits on a stable plateau (neighbouring K in [1.8..2.2], TREND_WIN in [100..150], VOL_PCT in [0.65..0.85], ENTRY_PB in [0..0.1] all give sharpe_min ~0.43-0.48 at DD ~0.08, sharpe_mean ~0.74-0.80): BB_WIN=20, BB_K=2.0, TREND_WIN=120, VOL_WIN=20, VOL_PCT=0.65, ENTRY_PB=0.10 (touch lower band), EXIT_PB=0.50 (exit at the MID band), TARGET_VOL=0.25, VOL_WIN_DAYS=30, LEV_CAP=1.5, BASE=1.0 -> train combined: pnl_mean ~0.29, maxdd_worst ~0.08, sharpe_min ~0.48 (A binds; B ~1.1). Exiting at the mid band (not higher) is the binding choice: Series A's dips are shallow and fizzle, so holding the reversion past mid turns Series A negative (Sharpe 0.48 -> -0.0). """ import numpy as np import blindlib as bl import pandas as pd BB_WIN = 20 # Bollinger lookback ("win" of the angle) BB_K = 2.0 # band width in std ("k" of the angle) TREND_WIN = 120 # long SMA: dip-buy only ABOVE it (reversion lives in the uptrend) VOL_WIN = 20 # realized-vol lookback for the low-vol gate VOL_PCT = 0.65 # low-vol gate: only act when rolling vol is below its expanding p65 ENTRY_PB = 0.10 # enter when %b <= this (close at/below the lower band) EXIT_PB = 0.50 # exit when %b >= this (price has mean-reverted to the MID band) TARGET_VOL = 0.25 VOL_WIN_DAYS = 30 LEV_CAP = 1.5 BASE = 1.0 # full size while holding a dip-long (the events are sparse; ride the snap-back) def _expanding_quantile_below(x, q): """Causal: at bar i, is x[i] at/below the q-quantile of x[0..i]? (expanding, no leak).""" s = np.asarray(x, float) thr = pd.Series(s).expanding(min_periods=30).quantile(q).values out = s <= thr out[~np.isfinite(thr)] = False return out def signal(df): c = df["close"].values.astype(float) n = len(c) up, mid, lo = bl.bbands(c, BB_WIN, BB_K) # causal trailing bands band_w = up - lo # %b: 0 = at the lower band, 0.5 = at the mid band, 1 = at the upper band. pb = np.where(np.isfinite(band_w) & (band_w > 0), (c - lo) / band_w, np.nan) trend_up = c > bl.sma(c, TREND_WIN) # causal trend gate r = bl.simple_returns(c) rv = bl.realized_vol(r, VOL_WIN, 365.0) # causal trailing realized vol low_vol = _expanding_quantile_below(rv, VOL_PCT) # causal expanding low-vol regime gate # One-sided Bollinger reversion: buy the lower-band touch (dip) in uptrend + low-vol, # HOLD with hysteresis until %b mean-reverts back up to the MID band, then flat. The # symmetric upper-band SHORT is a proven loser on these curves (continuation), so flat. # Forward pass is PURE PAST-ONLY: in_long at i depends only on bars <= i. held = np.zeros(n) in_long = False for i in range(n): if in_long: # exit when the dip has mean-reverted to the mid band, or the trend breaks if (not trend_up[i]) or (np.isfinite(pb[i]) and pb[i] >= EXIT_PB): in_long = False else: # enter a dip-long: %b at/below the lower band, in uptrend, in low-vol regime if trend_up[i] and low_vol[i] and np.isfinite(pb[i]) and pb[i] <= ENTRY_PB: in_long = True held[i] = BASE if in_long else 0.0 pos = bl.vol_target(held, 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)