"""agent_23_vol_of_vol — ANGLE [family=vol, slug=vol_of_vol].

Vol-of-vol gate: trade the trend ONLY when volatility itself is STABLE; flatten when
vol is spiking erratically.

The idea (distinct from a plain vol-LEVEL gate): what kills a trend-follower is not
high volatility per se — a calm, persistently-high-vol grind still trends — but the
INSTABILITY of the vol regime. When realized volatility itself starts jumping around
(vol-of-vol spikes), the market is in a disorderly, regime-shifting state where trends
V-reverse and whipsaw, and where the violent declines are born. So:

  * Compute short-window realized vol  rv[i]  (the "current" vol).
  * Compute VOL-OF-VOL  vov[i] = trailing std of the LOG-CHANGES of rv (a scale-free
    measure of how erratically vol is moving — robust to the absolute vol level, which
    differs across the two curves).
  * Rank vov against its EXPANDING percentile (causal, self-calibrating threshold — no
    magic vol-of-vol level, adapts as the series evolves, never peeks at the full sample).
  * STABLE-VOL regime (vov-rank <= PCTL): TREND-FOLLOW the prevailing multi-horizon
    TSMOM sign blend (~1/2/4 months).
  * ERRATIC-VOL regime (vov-rank > PCTL): stand aside (FLAT) — refuse directional
    exposure where vol is spiking erratically.

Final size is a trailing vol-target so exposure also shrinks into raw vol inside the
stable regime.

CAUSAL: rv uses a trailing window; the log-change std uses a trailing window; the
percentile rank is EXPANDING (only past bars); each TSMOM sign uses close[i]/close[i-H];
vol_target uses a trailing realized-vol window. No look-ahead, no centered windows, no
global fit. Verified by causality_ok (max_diff 0.0).

Tuned ONLY on split='train' (Series A & B, equal weight). A coarse->fine sweep found a
WIDE plateau and one load-bearing insight: only the TOP of the vol-of-vol distribution
hurts. Tight gates (PCTL ~0.55-0.65) are too restrictive — they sit out the early, still-
orderly part of the trend legs and DROP the Sharpe to ~0.83. Flattening only the most
ERRATIC ~20% (PCTL ~0.80) is what lifts both Sharpe and PnL. Around the chosen cell the
plateau is flat: VOV_WIN in [30..50] -> sharpe_min 1.12..1.16, PCTL in [0.76..0.84] ->
1.12..1.17, all at DD ~0.19-0.23. The chosen cell is interior on every axis:
  RV_WIN=30, VOV_WIN=40, PCTL=0.80, HORIZONS=(25,60,120), TARGET_VOL=0.22, VOL_WIN=45
  -> train combined: pnl_mean ~1.87, maxdd_worst ~0.20, sharpe_min ~1.16.

Honest notes:
  * The erratic-vol leg is LONG-FLAT (not contrarian) — refusing exposure where vol is
    unstable, not betting against the move. The value is RISK-ADJUSTED: comparable PnL
    at ~4x less drawdown than buy&hold (~0.77-0.79 DD on these curves), by sitting out
    the disorderly regimes where the violent declines are born.
  * TARGET_VOL is a pure DD/PnL dial (Sharpe flat ~1.16 across 0.18..0.26); LEV_CAP does
    not bind (the vol-target weight sits below 1.0). 0.22 is a balanced cell.
  * This gate measures the STABILITY of vol (vol-of-vol), distinct from a vol-LEVEL gate:
    a calm persistently-HIGH-vol grind still trends and is kept; it is the erratic,
    regime-shifting vol that is flattened. The Sharpe ceiling (~1.16) is set by the
    absence of a short leg — it avoids the chop but cannot profit from the declines.
"""
import numpy as np
import pandas as pd
import blindlib as bl

RV_WIN = 30               # short realized-vol window ("current" vol)
VOV_WIN = 40              # trailing window for vol-of-vol (std of log-changes of rv)
PCTL = 0.80               # expanding vov-percentile gate: trend-follow when rank <= this
HORIZONS = (25, 60, 120)  # multi-horizon TSMOM sign blend (~1/2/4 months of daily bars)
TARGET_VOL = 0.22
VOL_WIN_DAYS = 45
LEV_CAP = 1.5
MIN_HIST = 60             # warmup before the expanding percentile is trusted


def _expanding_pctl_rank(x: np.ndarray, min_hist: int) -> np.ndarray:
    """rank[i] = fraction of finite x[0..i] that are <= x[i]  (causal, expanding).
    NaN until `min_hist` finite values have accumulated."""
    n = len(x)
    rank = np.full(n, np.nan)
    seen: list[float] = []
    for i in range(n):
        v = x[i]
        if np.isfinite(v):
            seen.append(v)
            if len(seen) >= min_hist:
                rank[i] = float(np.mean(np.asarray(seen) <= v))
    return rank


def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray:
    """Sign of the past-h-bar return, causal. 0 for i < h."""
    out = np.zeros(len(c))
    if h < len(c):
        out[h:] = np.sign(c[h:] / c[:-h] - 1.0)
    return out


def _vol_of_vol(rv: np.ndarray, win: int) -> np.ndarray:
    """vol-of-vol: trailing std of the log-changes of realized vol (scale-free)."""
    rv_s = pd.Series(rv)
    logrv = np.log(rv_s.where(rv_s > 0))
    dlog = logrv.diff()
    return dlog.rolling(win, min_periods=max(5, win // 2)).std().values


def signal(df):
    c = df["close"].values.astype(float)
    bpy = bl.bars_per_day(df) * 365.25

    # 1) short-window realized vol, then its vol-of-vol and EXPANDING percentile (causal).
    rv = bl.realized_vol(bl.simple_returns(c), RV_WIN, bpy)
    vov = _vol_of_vol(rv, VOV_WIN)
    rank = _expanding_pctl_rank(vov, MIN_HIST)
    stable = np.isfinite(rank) & (rank <= PCTL)   # the STABLE-VOL regime we trade

    # 2) multi-horizon TSMOM sign blend -> graded direction in [-1, +1] (causal).
    sig = np.zeros(len(c))
    for h in HORIZONS:
        sig += _tsmom_sign(c, h)
    sig /= len(HORIZONS)

    # 3) vol-of-vol gate: trend-follow ONLY when vol is stable, else flat.
    raw = np.where(stable, sig, 0.0)

    # 4) causal vol-targeting (shrinks size into vol -> caps DD).
    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)
