"""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)
