"""agent_25_channel_pos — ANGLE [struct/channel_pos]: position WITHIN the Donchian channel. Idea (assigned angle): instead of a binary breakout AT the channel edge, measure WHERE the close sits inside the rolling Donchian channel [lo, hi] as a continuous fraction chpos = (close - lo) / (hi - lo) in [0, 1] (0.5 = mid-channel). Then take a directional position only when location AND trend AGREE: * LONG when chpos is in the UPPER third (>= UP_TH) AND the channel/price slope is UP, * SHORT when chpos is in the LOWER third (<= LO_TH) AND the slope is DOWN, * FLAT in the middle band or when slope disagrees with location. The "slope" filter is what makes the angle anticipatory rather than a reversal: riding the upper third while the channel is still pushing up is a continuation read; the lower-third + down-slope short tries to catch the persistent declines (the big drawdowns the benchmark eats). WHY a slope gate (honest tuning result): Channel-position WITHOUT a slope gate is a mean-reversion read (buy low-in-channel) and on these trending curves it bleeds — it fights the trend and the upper third without a trend filter chops on every pullback. Requiring location AND slope to agree turns it into a trend-confirmation read that holds longs through the up-leg and only shorts confirmed down-legs. The slope is the prior-W channel-midpoint change (causal). Sizing: the agreed direction (+1/-1/0) is vol-targeted (TP01-style, causal realized vol) so size shrinks into vol spikes (= crashes) -> caps drawdown. Causality: bl.donchian shifts the rolling hi/lo by one bar, so the channel at i is built from bars STRICTLY before i. chpos[i], the slope (a backward difference of a causal EMA of close), and the vol scaling all use only data <= i. The forward scan keeps no future state. The evaluator then HOLDS the position during bar i+1. causality_ok -> true. WHY the short leg is sized 0.30 (honest tuning result): A full-size (-1.0) short bled on these up-trending curves (combined Sharpe_min 1.06, DD 0.30). Shrinking the short leg monotonically improved risk-adjusted return; long/flat alone was best on raw PnL/Sharpe but had a slightly fatter DD (0.256). The chosen short=0.30 keeps a genuine lower-third+down-slope SHORT (the angle is intact) and TRIMS the drawdown (0.256 -> 0.229) at ~no PnL cost. So the angle's short leg earns its place, just at a modest size. Plateau (tuned on train only): broad and well-behaved around DON 35-45 / UP-LO 0.62-0.66 / SLOPE_WIN 15-20 / short 0.15-0.35 (Sharpe_min ~1.3-1.4 throughout, not an isolated peak). FINAL train (combined A&B): pnl_mean ~4.06, maxdd_worst ~0.229, sharpe_min ~1.34, sharpe_mean ~1.40. Per-series: A pnl 4.88 / DD 0.226 / Sh 1.45 ; B pnl 3.22 / DD 0.193 / Sh 1.33. Turnover ~14/yr. causality.ok = true (max_diff 0). Honest note: this is a trend-confirmation read dressed as a channel-position rule (the slope gate makes it ride the trend, not fade it); its value is comparable PnL to buy&hold at ~1/3 of the drawdown, NOT independent alpha. """ import numpy as np import blindlib as bl DON_WIN = 40 # Donchian window for the channel UP_TH = 0.62 # upper-band threshold on chpos (>=) -> "upper third" (location) LO_TH = 0.38 # lower-band threshold on chpos (<=) -> "lower third" (location) SLOPE_WIN = 20 # bars over which we measure the price slope (trend gate) SLOPE_EPS = 0.0 # min |slope| to count as up/down (0 = any non-zero sign) SHORT_SIZE = 0.30 # short-leg size (lower third + down-slope). <1 by tuning: the curves # trend up, so a full-size short bleeds; a modest short still TRIMS DD. TARGET_VOL = 0.30 VOL_WIN_DAYS = 30 LEV_CAP = 1.0 def signal(df): c = df["close"].values.astype(float) n = len(c) hi, lo = bl.donchian(df, DON_WIN) # prior-DON_WIN hi/lo (shifted, causal) width = hi - lo # continuous position within the channel in [0,1]; mid (0.5) where channel undefined. with np.errstate(invalid="ignore", divide="ignore"): chpos = (c - lo) / width chpos = np.where(np.isfinite(chpos) & (width > 0), chpos, 0.5) chpos = np.clip(chpos, 0.0, 1.0) # causal slope: change of a smoothed close over SLOPE_WIN bars, normalized by price. sm = bl.ema(c, SLOPE_WIN) slope = np.zeros(n) slope[SLOPE_WIN:] = (sm[SLOPE_WIN:] - sm[:-SLOPE_WIN]) / np.maximum(sm[:-SLOPE_WIN], 1e-9) up_loc = chpos >= UP_TH dn_loc = chpos <= LO_TH up_slope = slope > SLOPE_EPS dn_slope = slope < -SLOPE_EPS direction = np.zeros(n) direction[up_loc & up_slope] = 1.0 # upper third + rising -> long direction[dn_loc & dn_slope] = -SHORT_SIZE # lower third + falling -> (small) short # warmup: no channel yet -> flat direction[:DON_WIN] = 0.0 pos = bl.vol_target(direction, 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)