Files
PythagorasGoal/scripts/research/blind/agents/agent_21_atr_ride.py
T
Adriano Dal Pastro 1afb1014c9 research(blind): 52 agenti ciechi su curve anonime BTC/ETH — orchestratore valuta PnL/maxDD, niente di nuovo regge
Flotta di 52 subagenti "esperti di segnali" su storico BTC/ETH ANONIMIZZATO (Series A/B
rebased a 100, calendario sintetico, split 70/30) — non sanno cosa siano. Ognuno scrive un
signal(df)->position causale (script o ML), tunato solo sul train. Orchestratore valuta su
PnL e maxDD nel test held-out.

Harness cieco leak-free (riusabile):
- make_blind.py: export anonimo + overlay; blindlib.py: evaluator con shift della posizione +
  GUARDIA DI CAUSALITA' online (squalifica ogni look-ahead, ML incluso); blind_eval.py CLI;
  score_all.py giudice OOS; verify_top.py (corr-al-trend, fee-stress, jackknife).
- 52/52 passano la guardia (zero leak su tutta la flotta).

Esito OOS (benchmark buy&hold: -7% PnL, 68% DD):
- top = macd (+21%, DD 11%, Sh 0.84), accel, vol_of_vol, regime_switch, rf, obv — tutti
  trend/vol-regime. Sharpe OOS ~0.84 decade dal train ~1.4. Mean-rev e ML in fondo.
- 3 scettici indipendenti: REFUTED. regime-luck (top-5 bar = 67-102% del PnL); trend-redundancy
  (HAC alpha t=+0.9..+1.5, nessuno >1.96 — TSMOM travestito); overfit (accel/vov knife-edge).

Verdetto: ri-conferma CIECA e indipendente del soffitto direzionale ~1.3. macd = classe-TP01,
forward-monitor non deploy. Diario 2026-06-21-blind-signal-fleet.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:05:04 +00:00

109 lines
6.0 KiB
Python

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