"""agent_13_range_compression_intra — STRUCT family, slug=range_compression_intra (TF 1h).

ANGLE (assigned): intraday RANGE COMPRESSION (narrow-range / low intraday vol regime) precedes
an EXPANSION; position in the breakout DIRECTION next session. Gated, low turnover.

THE INTRADAY INFORMATION. A 1d bar only sees close-to-close. The intraday feed lets us measure
how COMPRESSED the recent path is -- the Parkinson high-low range relative to its own causal
history. "Compression" (a coil) is a *volatility* statement, not a directional one; the classic
NR / squeeze idea is that a coiled market RELEASES, and the release tends to RUN in the breakout
direction. So the design is two-stage:
  1. COMPRESSION GATE (vol regime, intraday-native): arm only when the trailing Parkinson range
     sits in the LOW tail of its causal expanding distribution (a coil). This is the part a pure
     1d Donchian cannot express -- it needs the intra-bar high-low path, standardized causally.
  2. BREAKOUT DIRECTION (when armed): the first decisive pierce of the recent channel sets the
     sign (+1 break up / -1 break down). We then CARRY that direction (stop-and-stay) until the
     opposite decisive break -- NOT flat-at-day-end -- so turnover is the number of genuine
     regime changes (~40-80/yr), not a daily round-trip (~500/yr fee-death).

WHY SYMMETRIC (long-short). Lesson of this fleet (agent_04 vs agent_09): a LONG-FLAT overlay on a
c2c-trend carrier just re-rides the bull beta -> corr ~0.9-0.96 to TP01 -> REDUNDANT. The slot is
earned by the SHORT leg: going short on a decisive DOWN-break out of a coil fires in the
down/choppy windows where TP01 sits flat, which is what DECORRELATES the daily stream from a
long-flat TSMOM book. So this is a SYMMETRIC breakout, gated by compression.

WHY THE COMPRESSION GATE (vs agent_09's plain prior-day breakout). agent_09 breaks out of
yesterday's range unconditionally. Here we add the coil filter: only the breakouts that follow a
genuine VOL CONTRACTION count. The hypothesis is that post-compression breakouts have a cleaner
follow-through (less whipsaw) than breakouts from an already-expanded range. The gate also CUTS
turnover (we are armed a fraction of the time) and is the intraday-native edge.

THE FEE WALL. The literal "trade every NR7 breakout, flat at close" churns. We make it
low-turnover by (a) carrying the direction 24/7 between decisive breaks (stop-and-stay), (b) a
wide decisive-break buffer (k * channel range) that filters noise pierces, (c) vol-targeting the
carried direction so the position drifts rather than jumps. Target turnover < ~80/yr.

CAUSAL: the compression z-score uses an EXPANDING mean/std shifted by 1 (excludes bar i). The
breakout channel uses the prior `chan_win` bars STRICTLY before i (donchian shift(1)). close[i]
is compared to levels known at i; the evaluator holds position[i] during bar i+1. No full-sample
fit, no shift(-k).

HONEST VERDICT (scored 2026-06-21, hardened judge). abs=PASS, marginal=ADDS, earns_slot=TRUE.
Config arm=-0.25 / chan=3d / park=2d / buf=0.20 sits on a PLATEAU (buf 0.10-0.20 and expmin
60-120 all keep slot=True/PASS). Standalone: BTC full 1.04 / hold 0.93, ETH full 0.51 / hold
1.79 (ETH full is the weak leg -- 2022/2024 down-years bite the symmetric book -- but its hold-out
is the strongest of all). In-sample standalone Sharpe 0.70 (>=0.5 edge). corr to TP01 full 0.42 /
hold 0.20 -- DECORRELATED via the short leg, as in agent_09. Multi-cut persistent (positive uplift
EVERY year 2020-2026: 0.09->0.22 pre-hold-out, 0.75 in 2025), jackknife drop-best-month +0.61,
clean-year (2025) uplift +0.26. NOT a hedge (uplift in TP01-up +0.15 dominates TP01-down +0.02).
Blend 0.75*TP01+0.25*a13: full 1.30->1.35 (uplift +0.055), HOLD-OUT 0.31->1.05 (uplift +0.75),
DD->11.8%. Turnover 10.7-13.4/yr (way under the 120 cap), fee-survivable to 0.20% RT (full Sh
0.49). HONEST CAVEATS: (1) full-sample blend uplift is small (+0.055) -- the value is hold-out
risk-diversification, not standing return; (2) the 2026 multi-cut figure (3.57) is a SHORT window
(few months) and overstated -- the trustworthy persistence is 2020-2025; (3) the compression GATE
is real intraday-native information but most of the marginal lift comes from the SYMMETRIC short
leg firing in the 2022/2025-26 down-chop, a short-favorable regime -- the in-sample edge + 6-year
multi-cut persistence are what keep it from being a single-window artifact. A genuine, low-turnover
slot-earner whose alpha is hold-out decorrelation, not absolute return.
"""
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

# --- compression gate (intraday-native vol regime) ---
_PARK_WIN_D = 2          # trailing window (days) for the Parkinson range estimate
_COMP_EXP_MIN_D = 90     # min days before the expanding standardization is trusted
_COMP_Z_ARM = -0.25      # arm the breakout only when range-z <= this (a coil; <0 = below avg)

# --- breakout channel (the release direction) ---
_CHAN_WIN_D = 3          # Donchian channel = high/low over the prior _CHAN_WIN_D days
_BUFFER_K = 0.20         # decisive-break buffer = k * channel range (filters noise pierces; plateau 0.10-0.20)
_ALLOW_SHORT = True      # SYMMETRIC -> the short leg decorrelates from TP01

# --- sizing ---
_VOL_TARGET = 0.20
_VOL_WIN_D = 30
_LEV_CAP = 2.0


def _expanding_z(x: np.ndarray, min_obs: int) -> np.ndarray:
    """Strictly causal expanding-standardized z-score (mean/std over rows 0..i-1).
    expanding().shift(1) -> bar i standardized by stats EXCLUDING i. NaN until min_obs."""
    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 _parkinson_vol(df: pd.DataFrame, win: int) -> np.ndarray:
    """Trailing Parkinson high-low range vol (annualization-free; we only use its z-score)."""
    hi = df["high"].values.astype(float)
    lo = df["low"].values.astype(float)
    park = (np.log(np.where((hi > 0) & (lo > 0), hi / lo, 1.0))) ** 2 / (4.0 * np.log(2.0))
    return np.sqrt(pd.Series(park).rolling(win, min_periods=win).mean().values)


def _compression_armed(df: pd.DataFrame, bpd: int) -> np.ndarray:
    """Boolean per-bar: is the market COILED (range in the low causal tail)?"""
    pvol = _parkinson_vol(df, _PARK_WIN_D * bpd)
    z = _expanding_z(pvol, _COMP_EXP_MIN_D * bpd)
    z = np.nan_to_num(z, nan=99.0)            # un-armed before the gate is trusted
    return z <= _COMP_Z_ARM


def _gated_breakout_direction(df: pd.DataFrame, bpd: int) -> np.ndarray:
    """Carried (stop-and-stay) symmetric breakout, but a NEW direction is only TAKEN when the
    market was COILED at the moment of the decisive pierce. Between takes we carry the last dir."""
    c = df["close"].values.astype(float)
    armed = _compression_armed(df, bpd)
    win = _CHAN_WIN_D * bpd
    hi_chan, lo_chan = al.donchian(df, win)   # prior-window high/low, shifted -> causal
    rng = hi_chan - lo_chan
    up_lvl = hi_chan + _BUFFER_K * rng
    dn_lvl = lo_chan - _BUFFER_K * rng
    low_state = -1.0 if _ALLOW_SHORT else 0.0
    n = len(c)
    dirn = np.zeros(n)
    cur = 0.0
    for i in range(n):
        if armed[i]:
            if np.isfinite(up_lvl[i]) and c[i] > up_lvl[i]:
                cur = 1.0
            elif np.isfinite(dn_lvl[i]) and c[i] < dn_lvl[i]:
                cur = low_state
        dirn[i] = cur                          # carry 24/7 between coiled-breakout takes
    return dirn


def target(df: pd.DataFrame) -> np.ndarray:
    bpd = al.bars_per_day(df)
    direction = _gated_breakout_direction(df, bpd)
    pos = al.vol_target(direction, df, _VOL_TARGET, _VOL_WIN_D, _LEV_CAP)
    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"])
