"""agent_11_squeeze — ANGLE [family=breakout, slug=squeeze].

Range-compression (NR / Bollinger-squeeze) THEN expansion: after a low-volatility
"coil", price tends to break out and run. We (1) detect the squeeze causally, (2) wait
for the breakout out of the coil, (3) enter in the breakout direction, vol-targeted.

Mechanics (all causal — value at i uses only rows 0..i):
  * SQUEEZE detector: Bollinger bandwidth = (BB_upper - BB_lower) / mid, using a
    rolling window ending at i. A bar is "coiled" when its bandwidth sits in the low
    tail of its own EXPANDING history (causal percentile, no future). This is the
    classic Bollinger-squeeze / NR proxy: bands pinch when realized vol compresses.
  * BREAKOUT trigger: a Donchian channel built STRICTLY from bars < i (bl.donchian
    shifts by 1). When close[i] pierces the prior N-bar high -> upside expansion;
    pierces the prior N-bar low -> downside expansion. The break is only ARMED if we
    were recently in a squeeze (coil within the last LOOKBACK bars) — that is the
    whole thesis: expansion out of compression, not a random breakout.
  * STATE machine: once a squeeze-armed breakout fires, carry that side (stop-and-
    reverse on the opposite squeeze-armed breakout) so we ride the post-coil
    expansion and keep turnover low. Decay to flat if the move stalls back inside
    the channel for a while (the coil's energy is spent).
  * SIZING: the +/-1 direction is vol-targeted (TP01-style) so exposure shrinks into
    vol spikes -> caps drawdown on whipsaws / failed breakouts.

Tuned ONLY on split='train' (Series A and B, equal weight). Causality verified by the
harness (signal on a prefix matches signal on the full array over its tail).

Honest notes:
  * Squeeze-breakout is trend-following with a regime filter. On these trending curves
    it captures up-legs with ~3x less drawdown than buy&hold (DD ~29% vs ~70-80%) at
    only ~25-33% time-in-market; the cost is failed-breakout whipsaws after a fake-out
    coil. Value is risk-adjusted, not raw PnL.
  * Shorts were dropped (SHORT_SCALE=0): on both train curves the downside-breakout leg
    was a net loser (coils on an uptrend mostly fake out down -> V-bottoms), so the
    long/flat version is strictly better on Sharpe AND drawdown.
  * ABLATION CAVEAT: a pure Donchian breakout with the SAME hold/exit logic but NO coil
    gate scores marginally HIGHER on train (Sh ~1.05 / PnL ~1.34) than the coil-gated
    version. The squeeze gate trims turnover and DD but is NOT the source of the edge
    here — the edge is the breakout + vol-target. Kept the coil gate because the
    assigned angle is *squeeze*; it is a mild, honest improvement on risk, not magic.
"""
import numpy as np
import pandas as pd
import blindlib as bl

# --- tuned on split='train' (broad plateau, see header / grid in commit) ------
BB_WIN = 20            # Bollinger window for bandwidth
BB_K = 2.0             # Bollinger multiplier
SQ_PCTL = 0.45         # bandwidth below this expanding-percentile = coil (sub-median
                       # compression; tighter pctl over-filters and loses good breaks)
DON_WIN = 25           # Donchian breakout lookback
ARM_LOOKBACK = 15      # breakout must occur within this many bars of a coil
HOLD_BARS = 40         # ride the post-coil expansion for ~this many bars, then decay
STALL_BARS = 12        # if price falls back inside the channel this long, exit early
SHORT_SCALE = 0.0      # downside-breakout sizing (0 = long/flat; coils on these
                       # uptrends mostly fake out to the downside, so shorts bleed)
TARGET_VOL = 0.20
VOL_WIN_DAYS = 30
LEV_CAP = 1.0


def _expanding_pctl_rank(x: np.ndarray, min_n: int = 60) -> np.ndarray:
    """Causal expanding percentile rank of x[i] within x[0..i]. rank in [0,1].
    rank = fraction of past (<=i) values that are <= x[i]. Uses only rows 0..i."""
    n = len(x)
    out = np.full(n, np.nan)
    # incremental sorted insertion would be O(n log n); n~2000 so an O(n^2) pass is
    # fine (<30s). Keep it simple and obviously causal.
    for i in range(n):
        xi = x[i]
        if not np.isfinite(xi):
            continue
        window = x[: i + 1]
        valid = window[np.isfinite(window)]
        if len(valid) < min_n:
            continue
        out[i] = float(np.mean(valid <= xi))
    return out


def signal(df):
    c = df["close"].values.astype(float)
    n = len(c)

    # 1) Bollinger bandwidth (causal) -> squeeze when bandwidth is in its low tail.
    upper, mid, lower = bl.bbands(c, BB_WIN, BB_K)
    with np.errstate(invalid="ignore", divide="ignore"):
        bw = (upper - lower) / np.where(np.abs(mid) > 0, mid, np.nan)
    bw_rank = _expanding_pctl_rank(bw, min_n=max(60, BB_WIN * 2))
    coil = np.nan_to_num(bw_rank, nan=1.0) <= SQ_PCTL   # True where compressed

    # "recently coiled" = a coil within the last ARM_LOOKBACK bars (causal).
    coil_recent = (
        pd.Series(coil.astype(float)).rolling(ARM_LOOKBACK, min_periods=1).max().values > 0
    )

    # 2) Donchian breakout (prior-bar channel; bl.donchian already shifts by 1).
    don_hi, don_lo = bl.donchian(df, DON_WIN)
    up_break = np.isfinite(don_hi) & (c > don_hi)
    dn_break = np.isfinite(don_lo) & (c < don_lo)

    # 3) state machine: arm breakouts only when they expand out of a recent coil.
    #    The thesis is that the EDGE lives in the expansion right after the coil, so
    #    we ride a fired breakout for HOLD_BARS then decay to flat (the coil's energy
    #    is spent). A fresh squeeze-armed breakout re-arms / re-times the hold. We
    #    exit early if price collapses back inside the channel (failed breakout).
    state = np.zeros(n)
    s = 0.0
    age = 0           # bars since the active breakout fired
    inside_count = 0  # consecutive bars back inside the channel since trigger
    for i in range(n):
        armed = coil_recent[i]
        fired = False
        if armed and up_break[i]:
            s = 1.0; age = 0; inside_count = 0; fired = True
        elif armed and dn_break[i]:
            s = -SHORT_SCALE; age = 0; inside_count = 0; fired = (SHORT_SCALE > 0)

        if not fired and s != 0.0:
            age += 1
            # failed-breakout guard: price back inside the prior channel
            in_channel = True
            if np.isfinite(don_hi[i]) and c[i] > don_hi[i]:
                in_channel = False
            if np.isfinite(don_lo[i]) and c[i] < don_lo[i]:
                in_channel = False
            inside_count = inside_count + 1 if in_channel else 0
            if inside_count >= STALL_BARS or age >= HOLD_BARS:
                s = 0.0; age = 0; inside_count = 0
        state[i] = s

    # 4) size by causal vol-targeting (shrinks into vol spikes -> caps DD).
    pos = bl.vol_target(state, 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)
