"""Agent 15 — Bollinger-band reversion, low-vol gated (family=meanrev, slug=bbands).

The angle (assigned): fade touches of the Bollinger bands (bl.bbands), only in a
low-vol regime. Tune win, k.

What the train curves actually say (A & B, split='train', diagnosed before coding):
both trend UP hard (+6.7x / +23x, ann vol ~0.7-0.9). The TEXTBOOK symmetric band-fade
is a LOSER here and the data is blunt about why:

  * UPPER-band touch -> CONTINUATION, not reversion. fwd-5bar after a close>=upper is
    +3.4%/+2.7% (A/B) even when we restrict to the low-vol regime. In a bull, riding the
    upper band is momentum; shorting it just bleeds against the drift. So the SHORT side
    of the classic fade is dead and we do NOT take it.
  * LOWER-band touch is reversion ONLY when it is a DIP IN AN UPTREND. close<=lower while
    price is above a long SMA -> fwd-5bar +3.5%/+7.2% (A/B): the band stretch snaps back
    up. The same lower touch in a DOWNTREND / high-vol continues DOWN (A high-vol lo-touch
    fwd-5 = -3.9%): a real knife. So the reversion we keep is the buy-the-dip-in-uptrend
    leg, and we gate it OFF in downtrends and in high vol.

Hence the rule is an HONEST, one-sided Bollinger reversion: LONG the lower-band touch,
but only while (a) close is above a long trend SMA and (b) realized vol is in its lower
regime (the assigned low-vol gate). %b drives a smooth appetite (deeper below the band ->
bigger), the long is HELD with hysteresis until price mean-reverts back through the mid
band, then flat. Sizing is vol-targeted so the two curves are risk-comparable and exposure
shrinks into vol spikes (which are exactly the regime where the dip-buy fails).

HONEST NOTE: in a market trending this hard a trend+lowvol-gated dip-buy partially
degenerates toward trend participation — the genuine reversion content is the buy-below-band
/ exit-at-mid cycle plus the DD control from the gates + vol-target. The symmetric short-the-
upper-band leg that "Bollinger reversion" classically implies carries NEGATIVE edge on these
curves, so taking it would only add drawdown; the result is therefore a modest-but-real
reversion edge, NOT a high-PnL alpha. A negative result for the *symmetric* fade is itself a
finding (documented above).

CAUSAL: bbands/sma/realized_vol are trailing (value at i uses bars <= i); the hold-state is
a forward cumulative pass over PAST bars only; vol_target uses trailing realized vol. No
shift(-k), no centered windows, no global fit. Verified by causality_ok (max_diff ~0).

Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell is
interior on every axis and sits on a stable plateau (neighbouring K in [1.8..2.2],
TREND_WIN in [100..150], VOL_PCT in [0.65..0.85], ENTRY_PB in [0..0.1] all give
sharpe_min ~0.43-0.48 at DD ~0.08, sharpe_mean ~0.74-0.80):
  BB_WIN=20, BB_K=2.0, TREND_WIN=120, VOL_WIN=20, VOL_PCT=0.65,
  ENTRY_PB=0.10 (touch lower band), EXIT_PB=0.50 (exit at the MID band),
  TARGET_VOL=0.25, VOL_WIN_DAYS=30, LEV_CAP=1.5, BASE=1.0
  -> train combined: pnl_mean ~0.29, maxdd_worst ~0.08, sharpe_min ~0.48 (A binds; B ~1.1).
Exiting at the mid band (not higher) is the binding choice: Series A's dips are shallow and
fizzle, so holding the reversion past mid turns Series A negative (Sharpe 0.48 -> -0.0).
"""
import numpy as np
import blindlib as bl
import pandas as pd

BB_WIN = 20          # Bollinger lookback ("win" of the angle)
BB_K = 2.0           # band width in std ("k" of the angle)
TREND_WIN = 120      # long SMA: dip-buy only ABOVE it (reversion lives in the uptrend)
VOL_WIN = 20         # realized-vol lookback for the low-vol gate
VOL_PCT = 0.65       # low-vol gate: only act when rolling vol is below its expanding p65
ENTRY_PB = 0.10      # enter when %b <= this (close at/below the lower band)
EXIT_PB = 0.50       # exit when %b >= this (price has mean-reverted to the MID band)
TARGET_VOL = 0.25
VOL_WIN_DAYS = 30
LEV_CAP = 1.5
BASE = 1.0           # full size while holding a dip-long (the events are sparse; ride the snap-back)


def _expanding_quantile_below(x, q):
    """Causal: at bar i, is x[i] at/below the q-quantile of x[0..i]? (expanding, no leak)."""
    s = np.asarray(x, float)
    thr = pd.Series(s).expanding(min_periods=30).quantile(q).values
    out = s <= thr
    out[~np.isfinite(thr)] = False
    return out


def signal(df):
    c = df["close"].values.astype(float)
    n = len(c)
    up, mid, lo = bl.bbands(c, BB_WIN, BB_K)      # causal trailing bands
    band_w = up - lo
    # %b: 0 = at the lower band, 0.5 = at the mid band, 1 = at the upper band.
    pb = np.where(np.isfinite(band_w) & (band_w > 0), (c - lo) / band_w, np.nan)
    trend_up = c > bl.sma(c, TREND_WIN)           # causal trend gate

    r = bl.simple_returns(c)
    rv = bl.realized_vol(r, VOL_WIN, 365.0)       # causal trailing realized vol
    low_vol = _expanding_quantile_below(rv, VOL_PCT)   # causal expanding low-vol regime gate

    # One-sided Bollinger reversion: buy the lower-band touch (dip) in uptrend + low-vol,
    # HOLD with hysteresis until %b mean-reverts back up to the MID band, then flat. The
    # symmetric upper-band SHORT is a proven loser on these curves (continuation), so flat.
    # Forward pass is PURE PAST-ONLY: in_long at i depends only on bars <= i.
    held = np.zeros(n)
    in_long = False
    for i in range(n):
        if in_long:
            # exit when the dip has mean-reverted to the mid band, or the trend breaks
            if (not trend_up[i]) or (np.isfinite(pb[i]) and pb[i] >= EXIT_PB):
                in_long = False
        else:
            # enter a dip-long: %b at/below the lower band, in uptrend, in low-vol regime
            if trend_up[i] and low_vol[i] and np.isfinite(pb[i]) and pb[i] <= ENTRY_PB:
                in_long = True
        held[i] = BASE if in_long else 0.0

    pos = bl.vol_target(held, 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)
