"""Agent 05 — Momentum z-score (family=trend, slug=momz). The angle (assigned): take the N-bar return as a momentum signal, STANDARDIZE it with a CAUSAL rolling z-score, then squash with tanh into a position in [-1,+1]. Tune N. Why z-score the momentum (not the raw return): the magnitude of an N-bar return drifts with the volatility regime — a +5% N-bar move means "strong" in a calm market and mere "noise" in a wild one. Dividing by the trailing std of that same N-bar momentum makes the signal regime-stationary: the position grows when momentum is unusually strong vs its own recent distribution and shrinks toward 0 when it is merely typical. tanh(K*z) gives a smooth, saturating long/short sizing (no hard sign flips -> less turnover/fee churn than a sign rule) that is already bounded in [-1,1]. Single N is regime-fragile here (a lone lookback's sharpe_min ricochets 0.4..1.1 across N on the two train curves). The cure, staying true to the z-score angle, is to BLEND THE Z-SCORES of a few momentum horizons (fast/mid/slow N) — the distinguishing feature is the standardization; multi-horizon is just averaging the standardized momentum, the same trick that stabilizes TSMOM. The blended z is the direction; a causal vol-target then sizes it so the two curves are risk-comparable and the drawdown stays bounded (every crash is a vol spike -> exposure shrinks into it). Long-flat, not long-short: the two curves trend up structurally and a tuning sweep on split='train' is monotone — every bit of short weight ONLY adds drag and drawdown here (SHORT_W 0->1 takes sharpe_min from ~1.4 down to ~0.85 and DD 0.17->0.33). So SHORT_W=0: go long when blended momentum-z is positive, flat otherwise. (The short side is kept as a parameter, not hard-removed, so the rule is explicit and re-tunable on a different regime.) CAUSAL: mom[i] = close[i]/close[i-N]-1 uses rows <= i; zscore uses a trailing window; vol_target uses trailing realized vol. No shift(-k), no centered windows, no global fit. Verified by causality_ok (max_diff 0.0). Tuning (train only, combined A&B; coarse->fine sweep). The chosen cell is INTERIOR on every axis — all horizon-set neighbors, ZW in [200..280], VW in [30..40], K in [2.5..4] stay in sharpe_min ~1.2..1.45 at DD ~0.16..0.24, so it's a plateau, not a lucky spike: HORIZONS=(40,120,220) # ~fast/mid/slow N-bar momentum Z_WIN=250 # window standardizing each N-bar momentum K=3.0 # tanh gain (near-saturating; >=2.5 is flat) SHORT_W=0.0 # long-flat (short only added drag here) TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5 -> train combined: pnl_mean ~2.77, maxdd_worst ~0.17, sharpe_min ~1.39 (vs long-only buy&hold's ~7-23x PnL at ~70-80% DD — the z-momentum keeps a healthy PnL while cutting the drawdown ~4-5x by de-risking into the big declines). """ import numpy as np import blindlib as bl HORIZONS = (40, 120, 220) # N-bar momentum lookbacks (fast/mid/slow) — the "N" of the angle Z_WIN = 250 # causal window standardizing each N-bar momentum K = 3.0 # tanh gain on the blended z-score (near-saturating) SHORT_W = 0.0 # de-weight the short side; 0 -> long-flat (best on train) TARGET_VOL = 0.25 VOL_WIN_DAYS = 35 LEV_CAP = 1.5 def _mom(c: np.ndarray, n: int) -> np.ndarray: """Causal N-bar return. mom[i] = c[i]/c[i-n] - 1, undefined (0) for i < n.""" out = np.zeros(len(c)) if n < len(c): out[n:] = c[n:] / c[:-n] - 1.0 return out def signal(df): c = df["close"].values.astype(float) # blend the z-scores of several momentum horizons -> regime-stationary direction zsum = np.zeros(len(c)) for n in HORIZONS: z = bl.zscore(_mom(c, n), Z_WIN) # standardize vs own trailing distribution zsum += np.nan_to_num(z, nan=0.0) z = zsum / len(HORIZONS) raw = np.tanh(K * z) # smooth, saturating direction in [-1, 1] raw = np.where(raw >= 0.0, raw, raw * SHORT_W) # de-weight short side (0 = long-flat) 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)