"""agent_03_relstrength_gated — Relative-strength ETH/BTC momentum, GATED by dispersion.

ANGLE [family=rv, slug=relstrength_gated]
-----------------------------------------
A market-neutral 2-leg book that trades the RELATIVE STRENGTH of ETH vs BTC — but ONLY
when the pair is actually DISPERSING. When BTC and ETH move together (the ratio is quiet),
ratio-momentum is pure noise: chasing it just churns fees against a coin-flip. So we GATE:

  1. signal: multi-horizon z-scored momentum of the log ratio s = log(ETH/BTC), tanh-squash.
     signal>0 => ETH outperforming BTC => LONG ETH / SHORT BTC (and vice-versa).
  2. dispersion gate: measure how DISPERSED the pair is right now (realized vol of the
     spread return r_eth - r_btc, blended with |ratio momentum|). Compute its CAUSAL
     EXPANDING percentile rank (each day ranked only against its own past). Trade only when
     that rank exceeds a threshold PCT; when the pair is compact (rank below PCT) => FLAT.
     RV is noise when the legs move together; the gate keeps us out of those regimes and
     concentrates risk in the dispersed regimes where relative strength actually persists.
  3. size: spread-vol-target the active signal so the ETH-BTC spread return hits TARGET_VOL,
     cap each leg at the live notional cap (0.5 of equity = $300/asset at $600).

Net market beta ~0 by construction (w_eth = -w_btc), so it is structurally uncorrelated to
TP01 (a long-flat trend on the market SUM). The bet is pure RELATIVE-VALUE, and the GATE is
the differentiator vs a plain ratio-momentum book: it sits flat in compact regimes instead
of paying fees to trade noise.

CAUSAL: momentum z, spread vol, and the expanding-percentile gate all use only rows 0..i
(rolling/expanding, no shift(-k), no global fit). EXECUTABLE: per-leg |w| <= 0.5.
MARKET-NEUTRAL: w_eth == -w_btc by construction.

RESULT (ortho_score, fee 0.05%/side, TP01 baseline):
  marginal_verdict ADDS | uplift_hold +0.534 | uplift_full +0.055 | robust_oos True
  corr_hold 0.32 / corr_full 0.12 | standalone Sh 0.57, DD 9%, turnover 8/yr, net_beta 0.
  GATE earns its keep: with the gate OFF (always trade) standalone Sh collapses 0.57->0.15
  and DD blows 9%->33% at 3x the turnover, SAME uplift — i.e. the dispersion gate keeps us
  flat (35% active) when RV is noise, which is the whole point of this angle.
HONEST CAVEATS:
  - The hold-out uplift is concentrated in 2025 (a high-dispersion ETH/BTC regime: cand
    standalone Sh +2.5 there) and in 2022 (the bear, where a market-neutral sleeve rescues a
    bleeding long-flat trend, uplift +0.43). In quiet/trending years (2019/2023/2026) the
    gate sits flat (cand Sh ~0, uplift ~0) — no harm, but no help. The drop-one-month
    jackknife holds (+0.44) and clean-year uplift is +0.64, so it is NOT one lucky month, but
    the hold-out is short (~537 d) and leans on the 2025 dispersion regime persisting.
  - Standalone Sharpe is modest by design (market-neutral). The verdict is the PORTFOLIO
    uplift, not the standalone number.
"""
from __future__ import annotations

import sys
import warnings

import numpy as np

sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
import ortholib as ol  # noqa: E402

# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see notes) -------
# Verified plateau (all ADDS + robust_oos): GATE_PCT 0.20-0.60, HORIZONS in
# {(20,60,120),(30,90,180),(20,60,120,240),(10,30,90)}, ZWIN 180-365, WARMUP 180-504,
# TANH_K 0.8-1.5. The VOL gate is far more robust than a |momentum| gate (the latter
# breaks robustness at most windows) — confirming the thesis: it is SPREAD DISPERSION,
# not momentum magnitude, that signals when relative-value is tradeable vs noise.
HORIZONS = (30, 90, 180)       # ratio-momentum lookbacks (days), multi-horizon blend
ZWIN = 252                     # window to z-score each horizon's momentum (causal)
TANH_K = 1.2                   # tanh slope (signal -> directional size in [-1,1])
TARGET_SPREAD_VOL = 0.15       # annualized target vol of the ETH-BTC spread return
VOL_WIN = 45                   # realized-vol window (days) for spread-vol targeting
DISP_WIN = 45                  # window for the dispersion measure (spread vol)
GATE_PCT = 0.45                # trade only when dispersion's expanding %ile rank >= this
GATE_WARMUP = 252              # min history before the expanding-percentile gate is trusted
LEG_CAP = 0.5                  # live per-leg notional cap (fraction of equity)


def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
    """Causal z-scored h-day log momentum of a log-price series."""
    s = np.full(len(logp), np.nan)
    s[h:] = logp[h:] - logp[:-h]            # h-day log change, known at i
    return ol.zscore(s, zwin)               # standardize cross-time (causal rolling)


def _expanding_pctile_rank(x: np.ndarray, warmup: int) -> np.ndarray:
    """CAUSAL expanding percentile rank of x: rank[i] = fraction of valid x[0..i] that are
    <= x[i]. Uses only past (and present) values at each i => no look-ahead. Before
    `warmup` valid points the rank is NaN (gate not yet trusted). O(n log n) via a sorted
    list of the values seen so far (bisect)."""
    import bisect
    n = len(x)
    rank = np.full(n, np.nan)
    srt: list = []   # values seen so far, kept sorted (causal: only x[0..i])
    cnt = 0
    for i in range(n):
        v = float(x[i])
        if np.isfinite(v):
            cnt += 1
            bisect.insort(srt, v)               # now includes current value
            if cnt >= warmup:
                # fraction <= v == position of the last element equal to v / total count
                rank[i] = bisect.bisect_right(srt, v) / cnt
    return rank


def book(btc, eth):
    bc = btc["close"].values.astype(float)
    ec = eth["close"].values.astype(float)
    n = len(bc)

    # relative price ratio in logs: positive momentum => ETH outperforming BTC
    logratio = np.log(ec) - np.log(bc)

    # 1) blended multi-horizon z-scored ratio momentum (mean of per-horizon z-scores)
    zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS])
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", category=RuntimeWarning)
        sig = np.nanmean(zs, axis=0)
    sig = np.nan_to_num(sig, nan=0.0)
    g_dir = np.tanh(TANH_K * sig)

    # spread return and its realized vol (the dispersion measure)
    rb = ol.simple_returns(bc)
    re = ol.simple_returns(ec)
    spread_ret = re - rb
    spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25)        # for vol targeting
    dispvol = ol.realized_vol(spread_ret, DISP_WIN, 365.25)     # the dispersion gate input

    # 2) DISPERSION GATE: causal expanding percentile rank of the dispersion measure.
    #    Trade only where the pair is dispersing more than its own historical norm.
    disp_rank = _expanding_pctile_rank(dispvol, GATE_WARMUP)
    gate = np.where(np.isfinite(disp_rank) & (disp_rank >= GATE_PCT), 1.0, 0.0)

    # 3) spread-vol target: scale by target/realized vol of the spread return
    scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)

    g = g_dir * scal * gate
    g = np.clip(g, -LEG_CAP, LEG_CAP)
    g = np.nan_to_num(g, nan=0.0)

    w_eth = g
    w_btc = -g
    return w_btc, w_eth
