"""agent_21_atr_ride — ANGLE: ATR-channel trend ride with an ATR trailing stop that scales the position DOWN on adverse moves (family=vol, slug=atr_ride). Idea (assigned angle): * Build an ATR channel around an EMA mid-line: mid = EMA_N(close); band half-width = K_ENTRY * ATR_M. A close above mid + K_ENTRY*ATR starts an uptrend ride. * Maintain an ATR TRAILING STOP (Chandelier / SuperTrend flavour): a stop line that RATCHETS in the trade's favour and never loosens. While long, the stop is (highest-close-since-entry - K_STOP*ATR) and only moves up. A close below it ends the ride (flatten). * The distinguishing twist of THIS angle (vs a binary breakout) is the SCALE-DOWN on adverse moves. Instead of a hard on/off stop we size by the ATR "stop room": room[i] = clip( (close[i] - stop[i]) / (K_STOP*ATR[i]) , 0, 1 ) = how much cushion (in ATR units, normalised by the stop distance) sits between the close and the trailing stop. Exposure is proportional to that cushion, so the book runs full deep in a healthy trend, BLEEDS OFF smoothly as price falls back toward the stop, and goes flat once the stop breaks. We ride winners and de-risk into reversals BEFORE the stop is hit, instead of binary all-in / all-out. Long/flat only. Both curves trend up; the short side of an ATR ride is whipsaw on the V-shaped bottoms (same lesson as the donchian/keltner siblings), so a stop-out goes to FLAT, never short. The ride exposure (already in [0,1]) is then vol-targeted so the long shrinks further into vol spikes (every crash is a vol spike) -> caps the DD. CAUSAL: mid (EMA) and ATR are built with .shift(1) -> strictly from bars <= i-1, and the close[i] that pierces the channel / sits above the stop is a real, tradeable event at close[i]. The trailing-stop state machine is a forward scan using only data <= i (peak is the running max of past closes; the stop only ratchets up). vol_target uses realized vol up to i. No future rows, no centered windows, no global fit -> causality_ok = true (verified: max_diff 0.0). The evaluator then holds the position during bar i+1. TUNING (split='train' only, Series A & B equal weight; chosen cell is a plateau center): * N_EMA x N_ATR: the (20,20) cell is the best risk-adjusted corner of the EMA/ATR grid (sharpe_min ~1.39 vs ~1.06-1.27 at slower 30-60 windows) and its 27-cell neighbourhood (N_EMA 18-25, N_ATR 15-25, K_STOP 2.0-3.0) holds sharpe_min in [1.16, 1.41] (median 1.30, 93% of cells > 1.2) -> a genuine plateau, not an isolated peak. * K_ENTRY = 1.0 is the clear ridge: the K_ENTRY row 0.5->1.5 peaks sharply at 1.0 (sharpe_min jumps to ~1.3-1.4) because requiring a full ATR of breakout above the mid filters out the chop-region false starts. * K_STOP = 2.5 ATR: the whole K_STOP 2.0-3.5 strip at K_ENTRY=1.0 is flat-high (sharpe_min 1.29-1.39, DD 0.22-0.28); 2.5 is the interior balance. * TARGET_VOL is a pure PnL/DD dial with FLAT Sharpe (~1.39 across 0.20-0.30): 0.20 -> pnl 1.75/DD 0.16 ... 0.30 -> pnl 3.23/DD 0.23 ... 0.40 -> pnl 4.81/DD 0.29. 0.30 is the balanced cell. VOL_WIN=30 is interior and best on Sharpe (1.39 vs 1.28 at 60). LEV_CAP=1.0 (never lever past fully invested) preserves the de-risking benefit. Train (combined A&B): pnl_mean ~3.23, maxdd_worst ~0.23, sharpe_min ~1.39. Honest note: this is trend-following, not alpha — its value is turning a high-PnL / ~77-79%-DD uptrend into comparable PnL at ~23% drawdown (DD cut ~3.4x). The scale-down twist buys a slightly lower DD and steadier equity than a binary ATR breakout would, at the cost of leaving some upside on the table in the very strongest legs (the position is rarely pinned at 1.0). The short side was not pursued: on these up-trending curves it is value-destroying whipsaw, the same finding as the sibling breakout angles. """ import numpy as np import pandas as pd import blindlib as bl N_EMA = 20 # ATR-channel mid-line EMA span N_ATR = 20 # ATR window (channel half-width AND trailing-stop unit) K_ENTRY = 1.0 # entry: close > mid + K_ENTRY*ATR -> start the ride (ridge value) K_STOP = 2.5 # trailing stop distance in ATR (Chandelier) -> also the scale ruler TARGET_VOL = 0.30 # PnL/DD dial; Sharpe flat across 0.20-0.30 VOL_WIN_DAYS = 30 LEV_CAP = 1.0 def _atr_ride_exposure(df): """Long/flat exposure in [0,1]: 0 when out of the ride; while in the ride, the value is the ATR 'stop room' (cushion above the trailing stop, in [0,1]) so the position scales DOWN smoothly on adverse moves and goes flat when the stop breaks.""" c = df["close"].values.astype(float) n = len(c) mid = pd.Series(bl.ema(c, N_EMA)).shift(1).values # EMA built strictly <= i-1 atr = pd.Series(bl.atr(df, N_ATR)).shift(1).values # ATR built strictly <= i-1 expo = np.zeros(n) in_ride = False peak = -np.inf # highest close since entry (drives the ratcheting stop) for i in range(n): m, a = mid[i], atr[i] if not (np.isfinite(m) and np.isfinite(a) and a > 0): continue if not in_ride: # entry: close pierces the upper ATR channel (full ATR above the mid) if c[i] > m + K_ENTRY * a: in_ride = True peak = c[i] if in_ride: peak = max(peak, c[i]) stop = peak - K_STOP * a # Chandelier trailing stop (ratchets via peak) if c[i] <= stop: in_ride = False # stop broken -> ride over, flat expo[i] = 0.0 peak = -np.inf else: # SCALE DOWN on adverse moves: cushion above the stop, normalised to [0,1]. room = (c[i] - stop) / (K_STOP * a) expo[i] = float(np.clip(room, 0.0, 1.0)) return expo def signal(df): expo = _atr_ride_exposure(df) # long/flat in [0,1], already scaled by stop room pos = bl.vol_target(expo, 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), 0.0, LEV_CAP)