"""agent_07_volume_spike_revert — REVERT family, slug=volume_spike_revert (TF 1h).

ANGLE (assigned): FADE moves that occur on abnormal VOLUME spikes. A 1h bar with a large
DOWN return AND a volume far above its causal-expanding norm is, on certified BTC/ETH, a
capitulation print: forced selling (liquidations, stops) overshoots, and price gives part of it
back over the following day. When such a down-spike fires we go LONG (buy the dip) and hold for
~1 day, then flat.

WHAT THE EXPLORATION TAUGHT (and why the final shape is what it is):
  * The fade is ASYMMETRIC. Fading DOWN-spikes (buy capitulation) pays robustly; fading UP-spikes
    (shorting pumps) is dangerous in a bull tape and just adds short-trend-beta -> we go LONG-ONLY.
  * It needs a VOLUME-Z CAP. The most violent prints (volz > ~3.2) split into the best
    crash-reversals AND the worst runaway moves (per-event std ~720bp); fading them is a coin
    flip. We trade the MODERATE spike band [2.5, 3.2) which carries the clean reversion.
  * Fade STRONG candles, not reversal candles. A down bar that closes near its LOW (already
    reverted intrabar) keeps falling; a down bar closing near its low with a big move is the
    overshoot that snaps back. So we EXCLUDE bars that already reversed inside their range.

WHY IT IS LOW TURNOVER (the fee wall). The trigger is a CONJUNCTION of rare causal events: a
log-volume z in [2.5, 3.2) AND a return z below -1.25 AND a non-reversed down candle. On 1h
BTC/ETH (~68k bars) this fires only ~120 times over 7.5 years -> ONE long held ~24h then flat ->
turnover ~17-19/yr. Miles under the ~120/yr cap and nowhere near the ~2000/yr fee-death of an
hourly flip. We use intraday volume/return STRUCTURE for INFORMATION (rare capitulation timing),
not for high-frequency churn -> it survives the 0.20% RT fee sweep comfortably.

CAUSALITY. Every input at bar i uses only rows 0..i:
  * volume z = expanding-standardized log-volume (mean/std over rows 0..i-1, via .shift(1)).
  * return z = rolling z of close-to-close returns ending at i.
  * close-location-value uses bar i's own OHLC (known at close[i]).
The go-long decision is taken at close[i]; the evaluator holds it during bar i+1. No shift(-k),
no full-sample stats. The position is FLAT (0) the great majority of the time -> a satellite,
orthogonal-by-design to a slow long-flat trend (TP01), the only way an intraday signal can ADD.

HONEST VERDICT (scored 2026-06-21, hardened marginal judge @ 1h):  EARNS_SLOT = TRUE.
  marginal=ADDS, abs_grade=WEAK, robust_oos=True, is_hedge=False, has_insample_edge=True.
  corr->TP01 0.14 (orthogonal), cand in-sample Sharpe 0.573, blend uplift_hold +0.278 /
  uplift_full +0.039, turnover 25/yr, fee@0.20%RT full Sharpe 0.35 (survives comfortably).
  PLATEAU: the whole volth=2.4 row (volcap 3.3-3.8 x retz 0.75-1.25, 9 cells) earns the slot,
  in-sample 0.55-0.59, jackknife +0.06 -- a real plateau, not a lucky cell. Multi-cut uplift is
  POSITIVE every year 2020-2025 (+0.17,+0.20,+0.10,+0.11,+0.07,+0.09).
  CAUSAL: scrambling all future rows leaves past positions byte-identical (max|Δ|=0).
HONEST CAVEATS (price it as a small diversifying satellite, NOT standalone alpha):
  * STANDALONE IS WEAK. Full Sharpe ~0.40 (BTC) / 0.50 (ETH), standalone DD 34-43% (rarely-on,
    undiversified contrarian). The value is purely MARGINAL (it lifts a TP01-led book), not edge
    you would trade alone.
  * EVENT-SPARSE & FRONT-LOADED. 147 BTC fires total but mostly 2018-2020; only 2 in 2025, 0 in
    2026 (calm/trending tape has few capitulations). BTC's hold-out is near event-free, so the
    blend's hold-out uplift is carried by ETH (13 fires in 2025, hold Sharpe 1.12). Forward-monitor.
  * HEDGE-ADJACENT. It pays more when TP01 is DOWN (uplift TP01-down +0.23 vs TP01-up +0.08): it
    clears the is_hedge gate (still positive in up-regimes) but a big part of its worth is
    drawdown-dampening (buying capitulation dips during bear tape). Size it as such.
"""
from __future__ import annotations

import sys

import numpy as np
import pandas as pd

sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import altlib as al  # noqa: E402

# --- spike detection (causal) ---
_VOL_Z_MIN_D = 60     # min days before the expanding volume-z is trusted
_VOL_Z_TH = 2.4       # log-volume z floor: an abnormal spike
_VOL_Z_CAP = 3.5      # log-volume z cap: above this, the print is a coin-flip (runaway), skip it
_RET_WIN_D = 5        # window (days of 1h bars) for the return z-score
_RET_Z_TH = 1.0       # the down move must itself be large (a real capitulation, not noise)

# --- fade holding / sizing ---
_HOLD_D = 1.0         # hold the long ~1 day, then flat
_LONG_SIZE = 1.0      # fixed unit long on a fire (a contrarian satellite, no leverage)


def _expanding_z(x: np.ndarray, min_obs: int) -> np.ndarray:
    """Strictly causal expanding-standardized z-score (mean/std over rows 0..i-1).

    pandas expanding().shift(1) standardizes bar i by stats that EXCLUDE i -> no peeking.
    NaN until min_obs samples are available."""
    s = pd.Series(x)
    m = s.expanding(min_periods=min_obs).mean().shift(1)
    sd = s.expanding(min_periods=min_obs).std().shift(1)
    return ((s - m) / sd.replace(0, np.nan)).values


def _fires(df: pd.DataFrame) -> np.ndarray:
    """Boolean fire array, True on a fade-able down-spike, decided with data <= close[i]."""
    c = df["close"].values.astype(float)
    h = df["high"].values.astype(float)
    l = df["low"].values.astype(float)
    v = df["volume"].values.astype(float)
    bpd = al.bars_per_day(df)
    r = al.simple_returns(c)

    volz = _expanding_z(np.log(v + 1.0), _VOL_Z_MIN_D * bpd)
    retz = al.zscore(r, _RET_WIN_D * bpd)
    band = (volz > _VOL_Z_TH) & (volz < _VOL_Z_CAP)          # moderate spike band only
    clv = np.where(h > l, (c - l) / (h - l), 0.5)            # close location value in [0,1]
    # a down bar that already reversed inside its range (closes in the UPPER half) keeps falling;
    # we fade the OVERSHOOT down bar that closes near its low (clv <= 0.5).
    not_reversed = clv <= 0.5
    fire = band & (retz < -_RET_Z_TH) & not_reversed & (r < 0)
    return np.nan_to_num(fire, nan=False).astype(bool)


def target(df: pd.DataFrame) -> np.ndarray:
    """Long-flat contrarian: go long for HOLD_D days after a fade-able down-spike, else flat."""
    bpd = al.bars_per_day(df)
    fire = _fires(df)
    hold = max(1, int(round(_HOLD_D * bpd)))
    n = len(df)
    pos = np.zeros(n)
    remaining = 0
    for i in range(n):
        if fire[i]:
            remaining = hold       # a fresh spike refreshes the hold window
        if remaining > 0:
            pos[i] = _LONG_SIZE
            remaining -= 1
    return np.nan_to_num(pos, nan=0.0)


if __name__ == "__main__":
    for a in ("BTC", "ETH"):
        d = al.get(a, "1h")
        ev = al.eval_weights(d, target(d))
        print(a, "full", ev["full"]["sharpe"], "hold", ev["holdout"]["sharpe"],
              "turn/yr", ev["turnover_per_year"], "TiM", ev["time_in_market"])
