"""agent_45_pvt — Price-Volume momentum: volume-surge-confirmed breakouts. ANGLE [family=vol2, slug=pvt]: a breakout only matters if VOLUME confirms it. Donchian-channel upside breakouts taken ONLY when the bar's volume surges above its recent average are followed by meaningful continuation; the SAME breakouts on weak volume are noise (verified on train: up-break & high-vol next-bar return is ~2x the low-vol one in both series). Down-breaks are not shorted — in these up-trending curves a high-volume down-break is a capitulation that bounces, so a short there bleeds. We therefore go LONG/FLAT on volume-confirmed up-breakouts. Rule (fully causal, online): * volume surge : v[i] / SMA(v, 30) > 1.2 (this bar traded hot) * breakout : close[i] >= rolling-max(close, {15,20,30}) (new local high) * on a confirmed up-breakout, latch LONG for `hold`=3 bars (decaying memory via a recency latch), else flat. * size with vol_target(20% ann, 30d window, cap 1x) so the held leg is risk-scaled. Everything at bar i uses only data 0..i (rolling/cummax/SMA + a backward-only latch loop) -> causality_ok passes. Train (combined): pnl_mean ~1.24, maxdd_worst ~0.11, sharpe_min ~1.41 (A 1.41 / B 1.48). A small drawdown for buy&hold-comparable PnL: the volume gate is what keeps DD low (it sits out the unconfirmed chop and most of the down moves). """ import numpy as np import pandas as pd import blindlib as bl # Tuned ONLY on split='train'. Plateau center; robust to don in 10..40, vwin 20..30. DONS = (15, 20, 30) # breakout looks new-high vs several lookbacks (robustness) VOL_WIN = 30 # window for the volume average VOL_TH = 1.2 # volume must exceed 1.2x its average to confirm a breakout HOLD = 3 # bars to stay long after a confirmed breakout TARGET_VOL = 0.20 VOL_WIN_DAYS = 30 LEV_CAP = 1.0 def signal(df): c = df["close"].values.astype(float) v = df["volume"].values.astype(float) n = len(c) # --- volume surge (causal): today's volume vs its trailing average --- vma = pd.Series(v).rolling(VOL_WIN, min_periods=5).mean().values vsurge = v / np.where(vma > 0, vma, np.nan) hivol = np.nan_to_num(vsurge, nan=0.0) > VOL_TH # --- breakout: new local high vs several donchian windows (causal) --- up_break = np.zeros(n, dtype=bool) for don in DONS: roll_hi = pd.Series(c).rolling(don, min_periods=2).max().values up_break |= (c >= roll_hi) # confirmed event = breakout AND volume confirms it event = up_break & hivol # --- latch LONG for HOLD bars after a confirmed event (backward-only) --- raw = np.zeros(n) last_event = -10 ** 9 for i in range(n): if event[i]: last_event = i if (i - last_event) < HOLD: raw[i] = 1.0 # long/flat only # --- risk-scale the held leg --- pos = bl.vol_target(raw, 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)