"""Agent 26 — Stochastic oscillator reversion + cross, trend-gated (family=osc, slug=stoch).

The angle (assigned): a rolling Stochastic oscillator (%K / %D). %K = where the close sits
in its rolling [min(low), max(high)] window (0..100); %D = a short SMA of %K (the signal
line). Trade the REVERSION (%K leaving an oversold extreme) timed by the %K-vs-%D CROSS,
GATED by a longer trend filter. Tune the windows.

Reading the train curves first (both A and B, split='train'): they trend UP very hard
(A 100->792, B 100->2400 over the window). UNLIKE RSI — which in these up-curves never
dips below ~40 so textbook 30/70 is dead — the Stochastic %K is normalized against its
OWN rolling high/low, so it sweeps the FULL 0..100 range even inside the bull: %K<20
~12-14% of bars, %K>80 ~24-27% of bars (measured). That is exactly the structure a
stochastic reversion rule needs, so the angle is genuinely playable here, but it still
has to be REGIME-AWARE because the curves drift up:

  * In an UPTREND (close above a long SMA) %K oversold (<LO) is a BUY-THE-DIP setup, and we
    require %K to CROSS BACK UP through its signal line %D — the standard stochastic long
    trigger — before going LONG. That waits for the dip to actually TURN (anticipating the
    bounce) instead of knife-catching while %K is still falling. We HOLD the long
    (hysteresis) until %K recovers into EXIT, then go flat. We do NOT short %K>80 in an
    uptrend — overbought in a bull keeps running (that is momentum, not reversion).
  * In a DOWNTREND (close below the long SMA) the symmetry returns: %K overbought (>80) with
    a %K cross DOWN through %D is a reversion SHORT (rips fade). %K<LO we stand flat (don't
    knife-catch long under a downtrend). The short side is down-weighted (SHORT_W) because
    the drift is up; on train it is marginal (see HONEST NOTE).

WHY THE CROSS MATTERS (the "anticipation" the angle asks for): entering the instant %K
prints <LO is usually early — %K is still falling. Waiting for the %K/%D up-cross times the
turn, which on train is the difference between a coin-flip dip rule and a positive one: with
the cross the dip-long sits at ~9-12% DD with a clean positive Sharpe; without it the same
thresholds bleed. The cross also cuts whipsaw turnover (~5-6 round-trips/yr, fee-cheap).

The trend gate does two jobs: it picks WHICH side of the oscillator is reversion (buy dips
in up-trend / sell rips in down-trend) and it suppresses the side that fights the drift.
Sizing is smooth (deeper oversold -> bigger appetite, floored at BASE while holding) then
VOL-TARGETED so the two curves are risk-comparable and exposure shrinks into vol spikes
(crashes are vol spikes) — that is what bounds the drawdown. Note the leverage cap never
binds here (post-vol-target appetite stays <=1), so the edge does NOT rely on leverage.

HONEST NOTE (negative findings kept): (1) the downtrend short side is essentially free but
adds nothing on train — SHORT_W=0.5 gives sharpe_min 0.51 vs 0.53 at SHORT_W=0; it is kept
small to honor the bidirectional angle, not because it earns. (2) A continuous always-on
oscillator weighting (no flat state) was tried and pushed time-in-market to ~99% and DD to
0.20-0.37 — it degenerated into buy-and-hold; the hysteresis flat state is what keeps the
DD at ~12%. (3) In a market that trends this hard, even a cross-gated dip-buy is PARTLY
trend participation (the dips it buys recover and it rides them). The genuine reversion
content is the oversold-entry / cross-timed turn / overbought-exit cycle plus the DD control
from the trend gate + vol-target. Result: an honest, MODEST combined train Sharpe ~0.5 at
~12% DD — a fraction of buy&hold's huge PnL but ~6x less drawdown (it anticipates the dip
rather than just holding the asset through every crash).

CAUSAL: %K uses trailing rolling max(high)/min(low) (<= i); %D is a trailing SMA of %K; the
cross compares (%K-%D) at i vs i-1 (past only); the hold-state is a forward cumulative pass
over PAST bars only; the SMA trend filter and vol_target use trailing data. No shift(-k), no
centered windows, no global fit. Verified by causality_ok (max_diff 0.0).

Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell sits
on a broad plateau (K in [14..20], LO in [40..50], EXIT in [55..65], D in [3..5], TREND_WIN
in [150..200] all hold sharpe_min ~0.37..0.53 at DD ~0.09..0.12 — a plateau, not a spike):
  K_WIN=20, D_WIN=5, LO=50, EXIT=55, TREND_WIN=150
  SHORT_W=0.5, BASE=0.7, TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5
  -> train combined: pnl_mean ~0.17, maxdd_worst ~0.12, sharpe_min ~0.51
"""
import numpy as np
import pandas as pd
import blindlib as bl

K_WIN = 20          # %K lookback (rolling high/low window). 20 > textbook 14 for these trends.
D_WIN = 5           # %D = SMA(%K, D_WIN): the signal line the %K crosses.
LO = 50.0           # oversold threshold below which a %K/%D up-cross is a dip-long entry.
EXIT = 55.0         # dip-long HELD until %K recovers past EXIT (hysteresis entry/exit pair).
TREND_WIN = 150     # long SMA: above = uptrend (buy dips), below = downtrend (sell rips).
SHORT_W = 0.5       # weight on the downtrend reversion-short; marginal (see HONEST NOTE).
BASE = 0.7          # base long size while holding a dip (scaled up if %K still oversold).
TARGET_VOL = 0.25
VOL_WIN_DAYS = 35
LEV_CAP = 1.5


def _stoch(df, k_win, d_win):
    """Causal Stochastic oscillator. %K[i] uses high/low/close over the trailing
    k_win bars (<= i); %D[i] = SMA(%K, d_win) (trailing). No look-ahead."""
    h = df["high"].values.astype(float)
    l = df["low"].values.astype(float)
    c = df["close"].values.astype(float)
    hh = pd.Series(h).rolling(k_win, min_periods=1).max().values
    ll = pd.Series(l).rolling(k_win, min_periods=1).min().values
    rng = hh - ll
    k = np.where(rng > 1e-12, (c - ll) / rng * 100.0, 50.0)
    d = bl.sma(k, d_win)
    return k, d


def signal(df):
    c = df["close"].values.astype(float)
    n = len(c)
    k, d = _stoch(df, K_WIN, D_WIN)
    trend_up = c > bl.sma(c, TREND_WIN)               # causal trailing SMA trend gate

    # --- %K/%D crosses (past-only: compares i vs i-1) ---
    kd = k - d
    kd_prev = np.concatenate(([0.0], kd[:-1]))
    cross_up = (kd > 0) & (kd_prev <= 0)              # %K turns up through its signal line
    cross_dn = (kd < 0) & (kd_prev >= 0)              # %K turns down through its signal line

    # --- smooth reversion appetite from %K (further past threshold -> bigger) ---
    long_app = np.clip((LO - k) / LO, 0.0, 1.0)               # oversold depth -> long appetite
    short_app = np.clip((k - 80.0) / 20.0, 0.0, 1.0)         # overbought depth -> short appetite

    # --- trend-gated stochastic reversion with cross-triggered entry + hysteresis ---
    # Forward pass is PURE PAST-ONLY: in_long at bar i depends only on bars <= i.
    held = np.zeros(n)
    in_long = False
    for i in range(n):
        if in_long:
            # exit the held dip-long when trend breaks down OR %K has recovered past EXIT
            if (not trend_up[i]) or (k[i] >= EXIT):
                in_long = False
        else:
            # enter a dip-long in an uptrend when %K is oversold AND turns up through %D
            if trend_up[i] and (k[i] < LO) and cross_up[i]:
                in_long = True
        if in_long:
            held[i] = max(BASE, long_app[i])         # ride the recovery, bigger if still oversold
        else:
            # downtrend reversion-short: overbought AND %K turning down through %D
            if (not trend_up[i]) and (k[i] > 80.0) and cross_dn[i]:
                held[i] = -SHORT_W * short_app[i]
            else:
                held[i] = 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)
