"""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 (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 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)