"""Agent 39 — Efficiency-ratio / fractal GATE on a momentum signal (family=stat, slug=effratio). THE ANGLE (assigned): take a plain momentum bet, but TRADE ONLY WHEN THE MOVE IS "EFFICIENT". Efficiency = how straight the path is. We measure it with two interchangeable causal fractal gauges and use them as an ON/OFF gate, NOT as an adaptive average (that is the sibling KAMA angle). Here momentum decides DIRECTION and the efficiency ratio decides WHETHER WE ARE ALLOWED TO TAKE THE TRADE. EFFICIENCY GAUGES (both causal, both in [0,1], higher = straighter / more trending): * Kaufman Efficiency Ratio (ER): net displacement / total path length over n bars. ER[i] = |c[i]-c[i-n]| / sum_{k} |c[k]-c[k-1]| ER -> 1 a clean directional move, ER -> 0 a random-walk chop. * Fractal-dimension proxy (1 - normalized roughness): in chop the path's total length is many times its displacement (high fractal dimension ~2 = plane-filling); in a trend length ~ displacement (dimension ~1 = a line). We map this to an efficiency score E_fd in [0,1] = ER itself is the cleanest such proxy, so the primary gauge IS ER; we blend a SLOWER ER to require efficiency on two horizons. DIRECTION (momentum): sign of a fast/slow EMA spread of price (a standard momentum signal). This is the "plain momentum" the angle gates — not KAMA. GATE: trade only when the (blended) efficiency ratio is above a CAUSAL expanding quantile of its own history (the move is efficient ENOUGH for THIS curve right now). In chop the gate is shut -> flat -> we skip the whipsaw that kills naked momentum. LONG-SHORT: curves trend up structurally so a symmetric short bleeds (shorts the dips). Keep the long full size, de-weight the short (SHORT_W) so the short only protects the big EFFICIENT declines (a crash is a very efficient down-move -> the gate is OPEN and momentum is down -> we are short exactly when it pays). SIZING: causal vol_target so A and B are risk-comparable and every vol spike (= every crash) auto-shrinks exposure -> the ~77-79% buy&hold drawdown collapses. CAUSAL: EMA spread, ER (both horizons), the expanding-quantile gate, and vol_target all use rows <= i only. No shift(-k), no centered window, no global fit. Verified by causality_ok (max_diff ~0). """ import numpy as np import pandas as pd import blindlib as bl # --- momentum (direction) --- [tuned on train, wide plateau] EMA_FAST = 10 EMA_SLOW = 50 # --- efficiency gate (the angle) --- ER_WIN = 25 # fast efficiency-ratio lookback (~1 month daily) ER_WIN2 = 60 # slow efficiency-ratio lookback (require efficiency on 2 horizons) ER_BLEND = 0.5 # weight of the slow ER in the blended gauge ER_Q = 0.33 # expanding-quantile gate: trade only when eff above its own history WARMUP = 60 # min bars before the expanding gate is trusted # --- exposure --- SHORT_W = 0.25 # de-weight the short side (curves trend up); 0 -> long-flat TARGET_VOL = 0.30 VOL_WIN_DAYS = 25 LEV_CAP = 1.5 def _efficiency_ratio(c: np.ndarray, n: int) -> np.ndarray: """Kaufman efficiency ratio over n bars, causal. ER[i] uses close[i-n..i].""" change = np.zeros(len(c)) change[n:] = np.abs(c[n:] - c[:-n]) d = np.abs(np.diff(c, prepend=c[0])) volatility = pd.Series(d).rolling(n, min_periods=n).sum().values er = np.where(volatility > 0, change / volatility, 0.0) er[:n] = 0.0 return np.nan_to_num(er, nan=0.0) def _expanding_quantile(x: np.ndarray, q: float, warmup: int) -> np.ndarray: """Causal expanding quantile: thr[i] = q-quantile of x[0..i]. Impassable before warmup.""" return pd.Series(x).expanding(min_periods=warmup).quantile(q).values def signal(df): c = df["close"].values.astype(float) n = len(c) # DIRECTION: plain momentum = sign of fast-slow EMA spread ef = bl.ema(c, EMA_FAST) es = bl.ema(c, EMA_SLOW) direction = np.sign(ef - es) # EFFICIENCY GAUGE: blend a fast and a slow Kaufman efficiency ratio er_fast = _efficiency_ratio(c, ER_WIN) er_slow = _efficiency_ratio(c, ER_WIN2) eff = (1.0 - ER_BLEND) * er_fast + ER_BLEND * er_slow # GATE: only trade when efficiency is high relative to this curve's own past thr = _expanding_quantile(eff, ER_Q, WARMUP) active = np.where(np.isfinite(thr) & (eff >= thr), 1.0, 0.0) raw = direction * active raw = np.where(raw >= 0.0, raw, raw * SHORT_W) # de-weight the short side 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)