"""Agent 40 — Return-skew regime gate on a trend signal (family=stat, slug=skewgate). THE ANGLE (assigned): avoid fat-tail-DOWN regimes. A trend follower is happy to ride a persistent up-move; the danger is the crash leg — a cluster of large negative returns that shows up FIRST as a strongly NEGATIVELY-skewed recent return distribution (a few big down days dominating). So we run a plain multi-horizon TSMOM trend as the base direction, then GATE the LONG exposure DOWN — toward flat — whenever a causal rolling window of recent returns turns negatively skewed. WHAT THE DATA SAID (train diagnostics, both curves): * Conditioning forward 20-bar returns on rolling SKEW: the most negatively-skewed windows have materially WORSE forward returns than the most positively-skewed ones (e.g. Series B, 40-bar skew: bottom-quartile fwd ~0.00 vs top-quartile ~+0.08). So a negative-skew gate has real, if modest, predictive value -> it earns its slot as a defensive overlay. * KURTOSIS, by contrast, is BULLISH on these curves (high-excess-kurt windows have BETTER forward returns — fat tails here come mostly from up-shocks in a structural bull). So a kurtosis "fat-tail" gate would throw away upside; it was tested and DROPPED. The gate is SKEW-ONLY. (This is the honest version of "avoid fat-tail-down": the down-tail signature on these curves is the SKEW, not the raw kurtosis.) Construction (all causal, value at i uses only rows <= i): * BASE = multi-horizon TSMOM: average the SIGN of the past-H return for H in HORIZONS, direction in [-1, +1] (slow horizon = macro trend, fast ones cut early into a turn). Asymmetric long-short: de-weight the short side (curves trend up structurally). * GATE = rolling SKEW_WIN skewness of returns. A smooth multiplier on the LONG side only: 1.0 when skew >= SKEW_CUT (benign), falling linearly to GATE_FLOOR as skew drops below the cut (fat-tail-down). Shorts are left untouched — being short into a negatively-skewed decline is exactly where the trend signal should earn, not be muzzled. * vol_target sizes the gated direction so the two curves are risk-comparable. CAUSAL: rolling skew uses a trailing window (pandas .rolling, no shift(-k)); TSMOM uses close[i]/close[i-H]; vol_target uses trailing realized vol. Verified by causality_ok (max_diff 0.0). TUNING (split='train' only, combined A&B). Sweep over (SKEW_WIN, SKEW_CUT, GATE_FLOOR) found a plateau at SKEW_WIN in {35,40}, SKEW_CUT=-0.3, GATE_FLOOR=0: the gate lifts sharpe_min from 1.37 (ungated base) to ~1.46 and pnl_mean from 3.22 to ~3.32. The chosen cell (40, -0.3, 0.0) is interior on every axis. FINAL train combined: pnl_mean ~3.32, maxdd_worst ~0.21, sharpe_min ~1.46. HONEST CAVEAT: the gate improves the RISK-ADJUSTED return (Sharpe) by trimming long size in locally negative-skew clusters that precede pullbacks; it does NOT shrink the *worst* drawdown. Inspection showed each curve's worst-DD leg is a slow whipsaw/chop where the position is already small or short and skew is ~0 — i.e. NOT a fat-tail-down crash. So the angle's defensive value here is Sharpe, not maxdd. A negative result on the maxdd front, reported honestly. """ import numpy as np import pandas as pd import blindlib as bl # --- trend base (multi-horizon TSMOM) --- HORIZONS = (45, 130, 240) # ~1.5 / 4.5 / 8 months of daily bars SHORT_W = 0.25 # de-weight short side (curves trend up) TARGET_VOL = 0.30 VOL_WIN_DAYS = 45 LEV_CAP = 1.5 # --- negative-skew (fat-tail-down) gate on the LONG side --- SKEW_WIN = 40 # window for rolling return skew SKEW_CUT = -0.3 # skew >= this = benign (gate 1.0); below = bite GATE_FLOOR = 0.0 # min long multiplier when skew is deeply negative def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray: """Sign of the past-h-bar return, causal. 0 for i < h.""" out = np.zeros(len(c)) if h < len(c): out[h:] = np.sign(c[h:] / c[:-h] - 1.0) return out def _neg_skew_gate(r: np.ndarray) -> np.ndarray: """Causal multiplier in [GATE_FLOOR, 1] for the LONG side. 1.0 when rolling skew is at or above SKEW_CUT; falls linearly to GATE_FLOOR as skew drops below the cut.""" sk = pd.Series(r).rolling(SKEW_WIN, min_periods=SKEW_WIN).skew().values sk = np.nan_to_num(sk, nan=0.0) skew_bad = np.clip((SKEW_CUT - sk) / abs(SKEW_CUT), 0.0, 1.0) # 0 benign -> 1 deeply neg gate = 1.0 - (1.0 - GATE_FLOOR) * skew_bad return gate def signal(df): c = df["close"].values.astype(float) r = bl.simple_returns(c) # base trend direction (multi-horizon TSMOM, asymmetric long-short) sig = np.zeros(len(c)) for h in HORIZONS: sig += _tsmom_sign(c, h) sig /= len(HORIZONS) raw = np.where(sig >= 0.0, sig, sig * SHORT_W) # negative-skew gate: shrink LONG risk only, leave shorts at full size gate = _neg_skew_gate(r) gated = np.where(raw > 0.0, raw * gate, raw) pos = bl.vol_target(gated, 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)