"""Agent 28 — Williams %R momentum/reversion HYBRID, trend-gated (family=osc, slug=willr). The angle (assigned): Williams %R momentum/reversion hybrid with a trend gate. Williams %R is the inverse of the Stochastic %K: %R = -100 * (HH - close) / (HH - LL) over a trailing window, ranging -100 (close at the window LOW = oversold) .. 0 (close at the window HIGH = overbought). It measures where the close sits in its own rolling high/low channel, so it is self-normalizing and sweeps the FULL -100..0 range even inside a bull (measured on train: %R<-80 ~14% of bars, %R>-20 ~26% of bars). That dual occupancy is what makes a HYBRID (reversion on one leg + momentum on the other) genuinely playable here. Reading the train curves first (both A and B, split='train'): they trend UP very hard (A 100->792, B 100->2400). A pure symmetric reversion ("short every %R>-20") would just short the bull and bleed; a pure momentum rule rides crashes. The HYBRID + trend gate resolves this by using %R DIFFERENTLY on each side of a long trend filter: REVERSION LEG (in an UPTREND, close above a long SMA): %R dipping into oversold (< OS, e.g. -80) is a BUY-THE-DIP setup. To ANTICIPATE the bounce instead of knife-catching a still-falling close, we require %R to TURN BACK UP (cross up through a short signal line = SMA of %R, the standard stochastic-style trigger). We then HOLD the long (hysteresis) until %R recovers past EXIT, then flat. This is the reversion half of the hybrid. MOMENTUM LEG (in an UPTREND): once %R pushes into and STAYS overbought (> OB, e.g. -20), in a hard bull that is NOT a fade signal — overbought persists and the trend runs. So instead of shorting it (textbook reversion) we take a SMALLER continuation LONG (MOM_W). This is the momentum half of the hybrid: %R>-20 in an uptrend = "trend is strong, stay with it", the opposite trade to what reversion alone would do. This is the key difference from the pure-reversion stochastic/RSI agents. DOWNTREND (close below the long SMA): the symmetry returns and %R is read as reversion again — %R overbought (> OB) with a cross DOWN through its signal line is a reversion SHORT (rips fade). %R oversold we stand flat (don't knife-catch long under a downtrend). The short side is down-weighted (SHORT_W) because the drift is up; on train it is marginal (see HONEST NOTE). So the gate does three jobs: (1) picks the reversion side (dip-long in up, rip-short in down), (2) flips the overbought reading from "fade" to "ride" inside the bull (the hybrid), (3) suppresses the side that fights the drift. Sizing is smooth (deeper extreme -> bigger appetite, floored at BASE while holding) then VOL-TARGETED so the two curves are risk-comparable and exposure shrinks into vol spikes (crashes are vol spikes) — that is what bounds the drawdown. The leverage cap rarely binds, so the edge is NOT leverage. HONEST NOTE (negative findings kept): (1) The downtrend reversion-short is nearly free but adds little on train; kept small to honor the bidirectional angle. (2) The momentum continuation leg (MOM_W) is what distinguishes this from a pure-reversion oscillator — in a market that trends this hard it earns by riding the overbought regime instead of fading it, but it ALSO partly degenerates toward trend participation (the honest ceiling for any direction-on-a-bull rule). The genuine oscillator content is the cross-timed dip entry + overbought exit cycle plus the DD control from the trend gate + vol-target. (3) A pure always-on %R weighting (no flat state) degenerated into buy-and-hold (DD blew out); the hysteresis flat state is what keeps DD modest. Result: an honest, modest combined train Sharpe at a small DD — a fraction of buy&hold PnL but several-x less drawdown (it anticipates the dip / rides the strong trend rather than holding through every crash). CAUSAL: %R uses trailing rolling max(high)/min(low) (<= i); its signal line is a trailing SMA of %R; the cross compares (%R - sig) at i vs i-1 (past only); the hold-state is a forward cumulative pass over PAST bars only; the SMA trend filter and vol_target use trailing data. No shift(-k), no centered windows, no global fit. Verified by causality_ok. Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell sits on a broad plateau (OB in [-35..-25], MOM_W in [0.3..0.5], SIG_WIN=5, R_WIN in [20..28], EXIT in [-50..-40], OS=-80, BASE/TVOL/VWD all hold sharpe_min ~1.1..1.29 at DD ~3.3..5.6% — a plateau, not a spike; SHORT_W is nearly free / marginal): R_WIN=20, SIG_WIN=5, OS=-80, OB=-35, EXIT=-45, TREND_WIN=150 MOM_W=0.4, SHORT_W=0.4, BASE=0.6, TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5 -> train combined: pnl_mean ~0.46, maxdd_worst ~0.045, sharpe_min ~1.22 """ import numpy as np import pandas as pd import blindlib as bl R_WIN = 20 # %R lookback (rolling high/low window). 20 > textbook 14 for these trends. SIG_WIN = 5 # signal line = SMA(%R, SIG_WIN): the line %R crosses (stochastic-style trigger). OS = -80.0 # oversold: %R below this in an uptrend + cross-up = dip-long entry. OB = -35.0 # overbought: momentum-ride (uptrend) / reversion-short (downtrend) threshold. EXIT = -45.0 # dip-long HELD until %R recovers past EXIT (hysteresis entry/exit pair). TREND_WIN = 150 # long SMA: above = uptrend (dips=long, OB=ride), below = downtrend (OB=short). MOM_W = 0.4 # weight on the uptrend overbought MOMENTUM-continuation long (the hybrid half). SHORT_W = 0.4 # weight on the downtrend reversion-short; marginal (see HONEST NOTE). BASE = 0.6 # base long size while holding a dip (scaled up if %R still oversold). TARGET_VOL = 0.25 VOL_WIN_DAYS = 35 LEV_CAP = 1.5 def _willr(df, r_win, sig_win): """Causal Williams %R + its signal line. %R[i] = -100*(HH-close)/(HH-LL) over the trailing r_win bars (<= i); sig[i] = SMA(%R, sig_win) (trailing). No look-ahead.""" h = df["high"].values.astype(float) l = df["low"].values.astype(float) c = df["close"].values.astype(float) hh = pd.Series(h).rolling(r_win, min_periods=1).max().values ll = pd.Series(l).rolling(r_win, min_periods=1).min().values rng = hh - ll wr = np.where(rng > 1e-12, -100.0 * (hh - c) / rng, -50.0) sig = bl.sma(wr, sig_win) return wr, sig def signal(df): c = df["close"].values.astype(float) n = len(c) wr, sig = _willr(df, R_WIN, SIG_WIN) trend_up = c > bl.sma(c, TREND_WIN) # causal trailing SMA trend gate # --- %R / signal-line crosses (past-only: compares i vs i-1) --- ds = wr - sig ds_prev = np.concatenate(([0.0], ds[:-1])) cross_up = (ds > 0) & (ds_prev <= 0) # %R turns up through its signal line cross_dn = (ds < 0) & (ds_prev >= 0) # %R turns down through its signal line # --- smooth appetites (further past the extreme -> bigger) --- # oversold depth: %R from OS down to -100 -> long appetite 0..1 long_app = np.clip((OS - wr) / (100.0 + OS), 0.0, 1.0) # overbought depth: %R from OB up to 0 -> 0..1 (used by both momentum-long & rev-short) ob_app = np.clip((wr - OB) / (0.0 - OB), 0.0, 1.0) # --- trend-gated Williams %R momentum/reversion hybrid with hysteresis --- # Forward pass is PURE PAST-ONLY: state at bar i depends only on bars <= i. held = np.zeros(n) in_long = False for i in range(n): if in_long: # exit the held dip-long when trend breaks down OR %R has recovered past EXIT if (not trend_up[i]) or (wr[i] >= EXIT): in_long = False else: # enter a dip-long in an uptrend when %R is oversold AND turns up through its line if trend_up[i] and (wr[i] < OS) and cross_up[i]: in_long = True if in_long: held[i] = max(BASE, long_app[i]) # ride the recovery, bigger if still oversold elif trend_up[i]: # MOMENTUM half of the hybrid: overbought in an uptrend = ride the strong trend held[i] = MOM_W * ob_app[i] else: # downtrend reversion-short: overbought AND %R turning down through its line if (wr[i] > OB) and cross_dn[i]: held[i] = -SHORT_W * ob_app[i] else: held[i] = 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)