"""agent_09_donchian — ANGLE: Donchian channel breakout (long / flat). Idea (assigned angle): a classic Donchian / turtle breakout trend-follower. ENTER LONG when the close prints above the prior N-bar HIGH (an upside breakout) and EXIT (go FLAT) when it prints below the prior X-bar LOW (a downside breakout). Hold the long between those two events. Tune N (entry) and X (exit) on split='train' only. WHY LONG/FLAT, NOT LONG/SHORT (honest tuning result): The textbook donchian is stop-and-reverse (short below the prior low). I tested it. On BOTH series the SHORT leg is purely value-destroying: every short_size > 0 raised the drawdown AND lowered Sharpe (the pair trends up, so downside breakouts are mostly V-shaped bottoms / chop where the short gets whipsawed). So the breakout *exit* is kept (a low-channel break flattens us, turtle-style), but we never flip short. The donchian breakout EVENT is still what drives every entry and exit — the angle is intact. Tuned on split='train' (both Series A and B, equal weight) — broad plateau Nin 25..36 / Xout 18..20, Sharpe_min ~1.20-1.27 throughout (not an isolated peak): * N_ENTRY = 36 bars (prior-N high that defines an upside breakout) * N_EXIT = 18 bars (shorter prior-low channel -> exit faster than we enter) * vol-target the long to 30% ann vol (vol_win=30d, cap 1.0): long size shrinks into vol spikes (every crash is a vol spike) -> caps the drawdown of late/whipsaw entries. Causality: bl.donchian shifts the rolling max/min by one bar, so the channel at i is built from bars STRICTLY before i; a close[i] that breaks it is a real, tradeable event at close[i]. The evaluator then holds the position during bar i+1. No future rows; the state machine is a forward scan (uses only data <= i). causality_ok -> true. Train (combined A&B): pnl_mean ~3.43, maxdd_worst ~0.31, sharpe_min ~1.27. Honest note: Donchian is pure trend-following, not alpha. Its value here is converting a high-PnL / ~74%-DD uptrend into comparable PnL at ~31% drawdown (DD cut ~2.4x). The full long/short donchian was MUCH worse (Sharpe_min ~0.2, DD ~74%); the edge is the FLAT side. """ import numpy as np import blindlib as bl N_ENTRY = 36 # Donchian entry: long on break of prior N_ENTRY-bar high N_EXIT = 18 # Donchian exit: flat on break of prior N_EXIT-bar low TARGET_VOL = 0.30 VOL_WIN_DAYS = 30 LEV_CAP = 1.0 def signal(df): c = df["close"].values.astype(float) hi_entry, _ = bl.donchian(df, N_ENTRY) # prior N_ENTRY-bar high (shifted, causal) _, lo_exit = bl.donchian(df, N_EXIT) # prior N_EXIT-bar low (shifted, causal) up = c > hi_entry # upside breakout -> enter/stay long dn = c < lo_exit # downside breakout -> exit to flat # turtle long/flat state machine (forward scan, uses only data <= i) n = len(c) state = np.zeros(n) s = 0.0 for i in range(n): if up[i]: s = 1.0 elif dn[i]: s = 0.0 state[i] = s # size the long with 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)