"""Agent 06 — Acceleration / momentum-of-momentum (family=trend, slug=accel). The angle (assigned): 2nd difference / momentum-of-momentum. Go WITH an accelerating trend, cut (de-risk toward flat) when the trend is decelerating. Construction (all causal): 1. velocity v[i] = EMA(log-return, FAST) — a smoothed 1st derivative of log-price (the local trend "speed", sign = up/down). 2. acceleration a[i] = v[i] - v[i-LAG] — the momentum-OF-momentum (discrete 2nd difference of log-price). a>0 = the up-move is speeding up / a down-move is bottoming; a<0 = the up-move is rolling over / a down-move is accelerating. 3. Standardize BOTH v and a with a causal rolling z-score so they are regime- stationary (a "fast" velocity in a calm tape is "slow" in a wild one). 4. Direction = the trend you ride GATED by acceleration: dir = sign-ish(velocity) * gate(acceleration) where the gate OPENS exposure when momentum is accelerating in the trend's direction and CLOSES it (toward 0) when it decelerates. Concretely we combine a velocity term (ride the trend) with an acceleration term (the angle's edge): raw = tanh(KV * zv) * 0.5 + tanh(KA * za) * 0.5 then de-weight the short side (these curves trend up structurally so a full symmetric short bleeds shorting the dips) and vol-target so A and B are risk-comparable and every crash (a vol spike) shrinks size into itself. Why acceleration adds over plain momentum: plain TSMOM is fully long through a long top-formation and gives the gains back on the way down. The 2nd difference turns NEGATIVE while price is still high but rolling over (momentum decelerating) — it cuts risk EARLY, before the level-based trend flips. Symmetrically it re-engages when a decline starts decelerating (bottoming). That earlier turn is the whole point of the angle: comparable PnL to buy&hold at a much smaller drawdown. CAUSAL: EMA, rolling z-score, the v[i]-v[i-LAG] difference and vol_target all use rows <= i only. No shift(-k), no centered windows, no global fit. Verified by causality_ok. Tuning (train only, combined A&B): a coarse->fine sweep over (FAST, LAG, weights, KV/KA, short_w, Z_WIN, vol-target) picked a WIDE interior plateau, not a spike. The chosen cell (FAST=28, LAG=30, Z_WIN=200, KV=KA=1.5, W_VEL=0.4/W_ACC=0.6, SHORT_W=0, vol25) is interior on EVERY axis: FAST in [22..36] -> sh_min 1.50..1.52; LAG in [26..40] -> 1.41..1.52 (peak 30); Z_WIN in [160..220] -> 1.52..1.56; W_ACC/KA/KV/vol all smooth & monotone. -> train combined: pnl_mean ~2.3, maxdd_worst ~0.20, sharpe_min ~1.52. SHORT_W=0 (long-flat) beat every short weight on train (sh_min collapses 1.31->0.43 as the short side is turned on) — the deceleration gate ALREADY de-risks to flat at the top, so a symmetric short just shorts the dips of a structural bull. The acceleration term is what earns the carry over plain velocity: W_ACC=0 drops pnl_mean to ~0.6 (it ducks risk too early); W_ACC~0.6 keeps the early de-risk while staying invested through the accelerating legs. DD ~0.20 vs a ~77-79% buy&hold drawdown. """ import numpy as np import blindlib as bl FAST = 28 # EMA span for the velocity (smoothed log-return / local slope) LAG = 30 # horizon of the 2nd difference: accel = v[i] - v[i-LAG] Z_WIN = 200 # causal window to standardize velocity & acceleration KV = 1.5 # tanh gain on the velocity z (ride the trend) KA = 1.5 # tanh gain on the acceleration z (the angle's edge) W_VEL = 0.4 # weight on the velocity (trend) term W_ACC = 0.6 # weight on the acceleration (momentum-of-momentum) term SHORT_W = 0.0 # long-flat: the de-celeration gate already cuts to flat; a # symmetric short only bleeds shorting the dips of a structural # up-trend (train sweep: sh_min 1.31@0.0 -> 0.43@1.0). 0 = flat. TARGET_VOL = 0.27 VOL_WIN_DAYS = 25 LEV_CAP = 1.5 def _lagged_diff(x: np.ndarray, lag: int) -> np.ndarray: """Causal discrete derivative: out[i] = x[i] - x[i-lag], 0 for i < lag.""" out = np.zeros(len(x)) if lag < len(x): out[lag:] = x[lag:] - x[:-lag] return out def signal(df): c = df["close"].values.astype(float) lr = np.zeros(len(c)) lr[1:] = np.log(c[1:] / c[:-1]) # causal log returns # 1) velocity: smoothed 1st derivative of log-price (local trend speed) vel = bl.ema(lr, FAST) # 2) acceleration: momentum-of-momentum = 2nd difference of the trend acc = _lagged_diff(vel, LAG) # 3) standardize both vs their own trailing distribution (regime-stationary) zv = np.nan_to_num(bl.zscore(vel, Z_WIN), nan=0.0) za = np.nan_to_num(bl.zscore(acc, Z_WIN), nan=0.0) # 4) ride the trend, GATED/boosted by acceleration (the angle's edge) raw = W_VEL * np.tanh(KV * zv) + W_ACC * np.tanh(KA * za) raw = np.clip(raw, -1.0, 1.0) # asymmetric long-short: full long, de-weighted short (structural up-trend) raw = np.where(raw >= 0.0, raw, raw * SHORT_W) # causal vol-targeting: shrink size into vol spikes (every crash is a vol spike) 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)