Files
PythagorasGoal/scripts/research/blind/agents/agent_10_keltner.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

88 lines
4.5 KiB
Python

"""agent_10_keltner — ANGLE: Keltner channel breakout (long / flat).
Idea (assigned angle): a Keltner channel is an EMA mid-line wrapped by an ATR band,
upper[i] = EMA_N(close)[i-1] + K * ATR_M[i-1]
lower[i] = EMA_N(close)[i-1] - K_EXIT * ATR_M[i-1]
Ride breakouts: go LONG when close[i] pierces the prior-bar UPPER band (an upside
breakout out of the channel); EXIT to FLAT when close[i] pierces the prior-bar LOWER
band. Hold the long between those two events (a turtle-style state machine) so we stay
in persistent trends and keep turnover (fees) low. Tune N, M, K, K_EXIT on train only.
WHY LONG/FLAT, NOT LONG/SHORT (honest tuning result on split='train'):
The textbook Keltner breakout is stop-and-reverse (short below the lower band). I
tuned both. Long/SHORT tops out at sharpe_min ~1.04 (maxdd ~0.39); switching the short
leg to FLAT lifts sharpe_min to ~1.56 and cuts maxdd to ~0.28. On BOTH series the short
leg is value-destroying: the pair trends up, so downside breakouts are mostly V-shaped
bottoms / chop where a short gets whipsawed. So the breakout *exit* is kept (a lower-
band break flattens us) but we never flip short. The Keltner breakout EVENT still drives
every entry and exit — the angle is intact.
Tuned on split='train' (Series A & B, equal weight). Broad plateau: 59/340 nearby cells
keep sharpe_min > 1.40, so the chosen point is a plateau CENTER, not an isolated peak:
* N_EMA = 20 (Keltner mid-line EMA span)
* N_ATR = 30 (ATR window for the band half-width)
* K = 1.0 (entry band multiplier: close above EMA + 1.0*ATR -> upside breakout)
* K_EXIT = 0.5 (exit band multiplier: close below EMA - 0.5*ATR -> flatten; tighter
than entry so we exit a failing trend faster than we re-enter)
* vol-target the long to 30% ann vol (vol_win=30d, cap 1.0): the long size shrinks into
vol spikes (every crash is a vol spike) -> caps the drawdown of late/whipsaw entries.
Sharpe is ~flat (1.55-1.56) across target_vol 0.20-0.40; target_vol only trades PnL
for DD (0.20 -> pnl 2.7/DD 0.19 ... 0.40 -> pnl 9.2/DD 0.34). 0.30 is the balance.
Causality: the channel that close[i] is tested against is EMA/ATR evaluated at i-1 (one-
bar lag via .shift(1)), so it is built from bars STRICTLY before i; a close[i] that
pierces it is a real, tradeable event at close[i]. The state machine is a forward scan
(uses only data <= i). The evaluator then holds the position during bar i+1. No future
rows -> causality_ok = true.
Train (combined A&B): pnl_mean ~5.55, maxdd_worst ~0.28, sharpe_min ~1.56.
Honest note: Keltner breakout is pure trend-following, not alpha. Its value here is
converting a high-PnL / ~77-79%-DD uptrend into comparable PnL at ~28% drawdown (DD cut
~2.7x). The full long/short Keltner was MUCH worse (sharpe_min ~1.04, DD ~0.39) — the
edge that matters is the FLAT side, exactly as for the sibling donchian breakout.
"""
import numpy as np
import pandas as pd
import blindlib as bl
N_EMA = 20 # Keltner mid-line EMA span
N_ATR = 30 # ATR window for the band half-width
K = 1.0 # entry band multiplier: break of EMA + K*ATR -> long
K_EXIT = 0.5 # exit band multiplier: break of EMA - K_EXIT*ATR -> flat
TARGET_VOL = 0.30
VOL_WIN_DAYS = 30
LEV_CAP = 1.0
def _keltner_band(df, n_ema, n_atr, k):
"""Lagged Keltner upper/lower at multiplier k: EMA[i-1] +/- k*ATR[i-1]."""
c = df["close"].values.astype(float)
mid = pd.Series(bl.ema(c, n_ema)).shift(1).values # EMA built <= i-1
band = pd.Series(bl.atr(df, n_atr)).shift(1).values # ATR built <= i-1
return mid + k * band, mid - k * band
def signal(df):
c = df["close"].values.astype(float)
upper, _ = _keltner_band(df, N_EMA, N_ATR, K) # entry channel (wider)
_, lower = _keltner_band(df, N_EMA, N_ATR, K_EXIT) # exit channel (tighter)
up = c > upper # upside breakout -> enter / stay long (tradeable at close[i])
dn = c < lower # downside breakout of tighter band -> 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 np.isfinite(upper[i]) and up[i]:
s = 1.0
elif np.isfinite(lower[i]) and 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)