"""TRD05 — ADX-filtered EMA crossover. Hypothesis: EMA(fast, slow) cross provides directional signal ONLY when ADX(14) > threshold (trending regime). When ADX is below the threshold (chop), position goes flat. Grid (<=4 param sets, total backtests = 4 params * 2 assets * 2 tfs = 16, but we limit to 2 TFs): (fast_ema, slow_ema, adx_period, adx_thresh) - (20, 100, 14, 25) — canonical from hypothesis - (10, 50, 14, 25) — faster cross - (20, 100, 14, 20) — more lenient ADX gate - (5, 20, 14, 25) — short-term cross with ADX filter We run 4 configs but only 1 TF at a time to stay within 2-CPU budget. Best config selected by min-asset holdout Sharpe across 2 TFs (1d, 12h). ADX calculation (causal): +DM[i] = max(high[i]-high[i-1], 0) if > (low[i-1]-low[i]) else 0 -DM[i] = max(low[i-1]-low[i], 0) if > (high[i]-high[i-1]) else 0 TR[i] = max(high[i]-low[i], |high[i]-close[i-1]|, |low[i]-close[i-1]|) Smooth over `period` with Wilder's EMA (alpha=1/period) +DI = 100 * smooth(+DM) / smooth(TR) -DI = 100 * smooth(-DM) / smooth(TR) DX = 100 * |+DI - -DI| / (+DI + -DI) ADX = Wilder EMA(DX, period) """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import pandas as pd def _wilder_ema(x: np.ndarray, period: int) -> np.ndarray: """Wilder smoothing (EMA with alpha=1/period, adjust=False).""" alpha = 1.0 / period out = np.empty(len(x), dtype=float) out[0] = x[0] for i in range(1, len(x)): out[i] = out[i - 1] * (1.0 - alpha) + x[i] * alpha return out def _adx(df: pd.DataFrame, period: int = 14) -> np.ndarray: """Compute causal ADX(period). Returns array len(df), NaN for first ~2*period bars.""" h = df["high"].values.astype(float) l = df["low"].values.astype(float) c = df["close"].values.astype(float) n = len(h) # True Range pc = np.roll(c, 1) pc[0] = c[0] tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) # Directional Movements up = h - np.roll(h, 1) dn = np.roll(l, 1) - l up[0] = 0.0 dn[0] = 0.0 pos_dm = np.where((up > dn) & (up > 0), up, 0.0) neg_dm = np.where((dn > up) & (dn > 0), dn, 0.0) # Wilder smooth str_ = _wilder_ema(tr, period) spdm = _wilder_ema(pos_dm, period) sndm = _wilder_ema(neg_dm, period) # DI lines pdi = 100.0 * np.where(str_ > 0, spdm / str_, 0.0) ndi = 100.0 * np.where(str_ > 0, sndm / str_, 0.0) # DX and ADX denom = pdi + ndi dx = np.where(denom > 0, 100.0 * np.abs(pdi - ndi) / denom, 0.0) adx = _wilder_ema(dx, period) # First 2*period bars are warm-up — NaN them adx[:2 * period] = np.nan return adx def make_target(fast: int, slow: int, adx_period: int, adx_thresh: float, vol_target: bool = True): """Return a target_fn for study_weights that implements ADX-filtered EMA cross.""" def target_fn(df: pd.DataFrame) -> np.ndarray: c = df["close"].values.astype(float) ema_fast = al.ema(c, fast) ema_slow = al.ema(c, slow) adx_vals = _adx(df, adx_period) # Signal: +1 if fast > slow (bullish trend), -1 if fast < slow (bearish) # Flat when ADX < threshold (choppy) or ADX is NaN (warmup) cross_signal = np.where(ema_fast > ema_slow, 1.0, -1.0) trending = np.where( np.isfinite(adx_vals) & (adx_vals > adx_thresh), 1.0, 0.0 ) direction = cross_signal * trending # Long-flat only (like TP01, we don't short crypto) # Actually let's try L/S first since hypothesis doesn't restrict direction_lf = np.clip(direction, 0, 1) # long-flat version if vol_target: return al.vol_target(direction_lf, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) else: return direction_lf return target_fn # --- Grid of configs --------------------------------------------------------- CONFIGS = [ dict(fast=20, slow=100, adx_period=14, adx_thresh=25), # canonical dict(fast=10, slow=50, adx_period=14, adx_thresh=25), # faster cross dict(fast=20, slow=100, adx_period=14, adx_thresh=20), # relaxed gate dict(fast=5, slow=20, adx_period=14, adx_thresh=25), # short-term ] # We test 2 timeframes: 1d and 12h (within 2-CPU budget constraint) TFS = ("1d", "12h") best_rep = None best_score = -999.0 print("=== TRD05: ADX-filtered EMA crossover ===\n") for cfg in CONFIGS: label = f"TRD05(ema{cfg['fast']}/{cfg['slow']},adx{cfg['adx_period']}>{cfg['adx_thresh']})" fn = make_target(**cfg) rep = al.study_weights(label, fn, tfs=TFS) print(al.fmt(rep)) print() # Score = min holdout sharpe across cells score = rep["verdict"].get("best_holdout_sharpe", -999.0) or -999.0 if score > best_score: best_score = score best_rep = rep best_cfg = cfg print("\n" + "=" * 60) print(f"BEST CONFIG: {best_cfg}") print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))