"""Agent 18 — Distance-from-MA reversion, trend-gated (family=meanrev, slug=dist_ma). THE ANGLE (assigned): position = -tanh(scaled distance of price from its MA). Buy when price is stretched BELOW its MA, sell when stretched ABOVE — a reversion-to-the-MA impulse, sized by how far price has wandered. Tune the MA window and the tanh scale. WHY THE PURE ANGLE LOSES, AND WHAT SURVIVES. The naive symmetric form (-tanh(scale * (price/MA - 1)) traded both sides) is CATASTROPHIC on these two curves: both trend up ~7x (A) / ~23x (B) over the train window, so shorting every stretch ABOVE the MA just fights a relentless uptrend. Measured: the pure symmetric angle returns -79%..-95% with sharpe ~ -0.5..-0.9 (it shorts the bull). A conditioning study of next-bar return vs the normalized distance-from-MA confirms the asymmetry: the LARGEST positive next-bar returns sit at the HIGHEST positive distance (that's momentum continuation, NOT reversion — never short it), while the genuine reversion edge lives only on the DOWNSIDE — when price is stretched well below its MA, the next bar bounces (+0.27%..+0.35% in the deepest dip bin, pooled A&B). So the distance-from-MA reversion that actually exists here is the short-horizon PULLBACK inside the prevailing trend, not a fade of the trend itself. THE RULE. impulse = -tanh(SCALE * z) where z = (price/SMA(MA) - 1) standardized by a trailing rolling std (so A and B, with different vol, get comparable stretch units). impulse>0 = price below its MA (a dip -> reversion says go long); impulse<0 = price above its MA (a rally -> short). A TREND GATE then keeps only the reversion leg that agrees with the regime: * UPTREND (price > SMA(SLOW)): take only the LONG impulse (buy the dip that bounces). * DOWNTREND (price < SMA(SLOW)): take only the SHORT impulse (fade the dead-cat rally), down-weighted by SHORT_W. Tuning drives SHORT_W -> 0: both curves trend up, so the downtrend-short reversion only adds drawdown over this sample. A causal vol_target sizes the impulse so the two series are risk-comparable and exposure shrinks into vol spikes. CAUSAL: SMA(MA), SMA(SLOW), the rolling std and vol_target at bar i use only rows <= i. No shift(-k), no centered windows, no global fit. Verified by causality_ok (online-consistent). TUNING (train only, combined A&B; coarse->fine, plateau not spike). A FAST MA (the distance is a short-horizon pullback, not a slow-trend gap) is decisively better than a medium MA: ma=3 beats ma=20+ by ~0.2 sharpe at lower DD. The chosen cell is interior on every axis: MA 3..5 -> sharpe_min 0.69..0.81 ; SCALE 1.0..2.5 -> 0.72..0.76 (PnL rises, DD ~flat) ; NORM_WIN 30..90 -> 0.75..0.80 ; SLOW 110..140 -> sharpe_min 0.74..0.81 (a real plateau). SHORT_W 0->0.5 only lowers sharpe (the downtrend short fights the structural uptrend). vol_target trades PnL<->DD ~linearly (sharpe flat), so TARGET_VOL is just the risk dial. MA=3, NORM_WIN=60, SCALE=1.5, SLOW=130, SHORT_W=0.0, TARGET_VOL=0.30, VOL_WIN=30, LEV_CAP=2.0 -> train combined: pnl_mean ~0.70, maxdd_worst ~0.115, sharpe_min ~0.80 (a solid PnL at an ~11-12% drawdown: the reversion-in-trend harvests the pullback bounces while sidestepping the deep declines, vs long-only buy&hold's huge PnL at ~70-80% DD.) HONEST CAVEAT: the value here is the DROP IN DRAWDOWN (~6x lower than buy&hold), not beating buy&hold's raw PnL on a 7x/23x bull run. The PURE assigned angle (symmetric fade) is a loser on trending data — it only becomes positive once gated to the dip side of the trend. """ import numpy as np import pandas as pd import blindlib as bl MA = 3 # fast SMA -> the distance is a SHORT-HORIZON pullback from price NORM_WIN = 60 # trailing window standardizing the distance (so A & B are comparable) SCALE = 1.5 # tanh scale on the standardized distance -> reversion impulse magnitude SLOW = 130 # trend-regime SMA for the agreement gate SHORT_W = 0.0 # weight on the (gated) downtrend-short leg; tuning -> 0 (long-flat best) TARGET_VOL = 0.30 VOL_WIN_DAYS = 30 LEV_CAP = 2.0 def signal(df): c = df["close"].values.astype(float) n = len(c) # distance of price from its (fast) MA, standardized by a trailing rolling std (causal). dist = c / bl.sma(c, MA) - 1.0 sd = pd.Series(dist).rolling(NORM_WIN).std().values zd = np.nan_to_num(dist / np.where(sd > 0, sd, np.nan), nan=0.0) # the assigned angle: reversion impulse = -tanh(scaled distance). # zd>0 (price above MA) -> impulse<0 (short the stretch) # zd<0 (price below MA) -> impulse>0 (long the dip) impulse = -np.tanh(SCALE * zd) # trend-agreement gate: keep only the reversion leg that agrees with the regime. up = c > bl.sma(c, SLOW) raw = np.zeros(n) long_ok = (impulse > 0) & up # buy the dip inside an uptrend short_ok = (impulse < 0) & (~up) # fade the rally inside a downtrend (down-weighted) raw[long_ok] = impulse[long_ok] raw[short_ok] = impulse[short_ok] * SHORT_W 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)