"""agent_49_adx_dir — Trend-strength (ADX-like) GATED directional position. ANGLE [family=mix, slug=adx_dir]: Build a causal ADX (Average Directional Index) from directional movement and ATR. ADX measures TREND STRENGTH (not direction). We take a directional position ONLY when trend strength is HIGH (ADX above an adaptive, past-only threshold); otherwise flat. Direction is the directional-movement sign (+DI vs -DI). Size is vol-targeted so a calm strong trend and a violent one carry comparable risk. Long-only: on these strongly up-trending overlaid curves, shorting "strong" down-moves (which are mostly sharp counter-trend dips that snap back) was net- negative and added drawdown in the train sweep — the honest result is that the ADX gate adds value as a LONG participation filter, lifting risk-adjusted return (train combined Sharpe ~1.1 at ~10% DD vs buy&hold ~1.0 at ~77% DD), not by catching the declines short. Everything is causal: +DM/-DM, ATR (Wilder EWM), DI, DX, ADX (EWM of DX) all use only data up to bar i. The ADX gate threshold is an EXPANDING quantile (past-only), so the strength bar adapts to each curve without peeking forward. Tuned ONLY on split='train'. Params chosen on a broad plateau (win 10-20, gate q 0.30-0.45 all positive at <15% DD), centered at win=14, q=0.38. """ import numpy as np import pandas as pd import blindlib as bl ADX_WIN = 14 # directional-movement / ADX smoothing window GATE_Q = 0.38 # expanding-quantile threshold on ADX (trend-strength gate) GATE_MINP = 120 # warmup bars before the gate can fire TARGET_VOL = 0.20 VOL_WIN = 30 LEV_CAP = 1.0 def _wilder(x, win): """Wilder smoothing == EWM with alpha=1/win, adjust=False. Fully causal.""" return pd.Series(x).ewm(alpha=1.0 / win, adjust=False).mean().values def _adx(df, win): """Causal ADX + DI+ / DI-. value[i] uses only data <= i.""" h = df["high"].values.astype(float) l = df["low"].values.astype(float) c = df["close"].values.astype(float) pc = np.roll(c, 1); pc[0] = c[0] ph = np.roll(h, 1); ph[0] = h[0] pl = np.roll(l, 1); pl[0] = l[0] up = h - ph # this bar's up extension dn = pl - l # this bar's down extension plus_dm = np.where((up > dn) & (up > 0), up, 0.0) minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0) tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) atr = _wilder(tr, win) atr_safe = np.where(atr > 0, atr, np.nan) di_plus = np.nan_to_num(100.0 * _wilder(plus_dm, win) / atr_safe, nan=0.0) di_minus = np.nan_to_num(100.0 * _wilder(minus_dm, win) / atr_safe, nan=0.0) di_sum = di_plus + di_minus dx = 100.0 * np.abs(di_plus - di_minus) / np.where(di_sum > 0, di_sum, np.nan) dx = np.nan_to_num(dx, nan=0.0) adx = _wilder(dx, win) return adx, di_plus, di_minus def _expanding_quantile(x, q, min_periods): """Past-only expanding quantile. value[i] uses x[0..i] -> causal.""" out = pd.Series(x).expanding(min_periods=min_periods).quantile(q).values return np.where(np.isfinite(out), out, np.inf) # flat (inf thr) until warmed def signal(df): c = df["close"].values.astype(float) adx, di_p, di_m = _adx(df, ADX_WIN) # Trend-STRENGTH gate: only act when ADX is in its upper regime (past-only thr). adx_thr = _expanding_quantile(adx, GATE_Q, GATE_MINP) strong = adx > adx_thr # Direction from directional movement: +DI dominant -> up, -DI dominant -> down. di_dir = np.sign(di_p - di_m) # Long-only on these up-trending curves (shorting strong dips was net-negative). raw_dir = np.where(di_dir > 0, 1.0, 0.0) direction = np.where(strong, raw_dir, 0.0).astype(float) # Vol-target so calm strong trends and wild ones carry comparable risk. pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN, leverage_cap=LEV_CAP) return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0)