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>
This commit is contained in:
Adriano Dal Pastro
2026-06-21 07:05:04 +00:00
parent f5d30d88b9
commit 1afb1014c9
63 changed files with 6660 additions and 0 deletions
@@ -0,0 +1,115 @@
"""Agent 17 — Short-term reversal, trend-gated (family=meanrev, slug=st_reversal).
THE ANGLE (assigned): fade the last 1-3 bar move, but ONLY when the longer trend
AGREES with the fade direction. So we never fight the trend: we only take the leg of
the reversal that points the same way the slow regime already points.
* UPTREND (price > slow SMA): the trend-agreeing fade is to fade a DROP -> go LONG
the bounce. (Fading a rise here would mean shorting INTO an uptrend = fighting the
trend -> NOT allowed, stay flat on that leg.)
* DOWNTREND (price < slow SMA): the trend-agreeing fade is to fade a RISE -> go SHORT
the dead-cat. (Fading a drop here would mean longing INTO a downtrend = fighting the
trend -> NOT allowed, stay flat on that leg.)
Why this is the structure in the data (train study, both curves):
Forward 1-bar return after a 1-bar move, conditioned on the 150-SMA regime --
A UP & drop>5% -> +0.0050 (bounce) UP & rise>5% -> +0.0007 (rise gives back)
B UP & drop>5% -> +0.0115 (bounce) UP & rise>5% -> -0.0004 (rise gives back)
A DN & rise>2% -> -0.0039 (fades) DN & drop0-2% -> ~0
B DN & rise>2% -> -0.0038 (fades)
-> corr(-r, fwd) is POSITIVE in both regimes (UP ~0.03-0.08, DN ~0.15): a 1-bar move
partially reverses next bar. The trend gate keeps only the half of that reversion
that the slow trend supports, so the (gated) short leg lives only where the curve
is genuinely rolling over -- it does not bleed shorting a structural bull.
The reversal impulse is the (vol-scaled) negative of the recent move -r_k -- a CONTINUOUS,
saturating fade of the last K-bar return -- rather than sparse extreme-only entries, so
more of the small bounces are captured. We blend K=1..3 (mostly K=1, the cleanest
reversal) and normalize each move by trailing vol so the threshold is in sigma, not raw %.
CAUSAL: sma(c,SLOW), the K-bar past returns, the trailing-vol scaler, the trend gate and
vol_target at bar i all use only rows <= i. No shift(-k), no centered windows, no global
fit. Verified by causality_ok.
Tuning (train only, combined A&B, coarse->fine; interior plateau, not a spike). Series A
is the binding constraint (a weaker, deeper-pullback reversal than B); the chosen cell
maximizes A's sharpe at a controlled DD without overfitting B. Perturbations around the
center all stay in sharpe_min ~0.48..0.58 at DD ~0.14..0.16:
SLOW 125..135 (smin 0.51..0.55), Z_SAT 0.85..1.05 (smin 0.52..0.56),
SHORT_W 0..0.5 (smin 0.53..0.54 -- the gated short adds a touch), K-weights from pure
1-bar (smin 0.58, DD 0.16) to (0.5,0.3,0.2) (smin 0.53, DD 0.14). vol_target scales
PnL<->DD ~linearly (sharpe flat) so TARGET_VOL is just the risk dial; LEV_CAP is not
binding (vol-target keeps |pos|<1 on these curves).
Chosen (interior, robust): SLOW=130, K_WEIGHTS=(0.7,0.2,0.1), Z_SAT=0.95, SHORT_W=0.25,
TARGET_VOL=0.25, VOL_WIN_DAYS=30, LEV_CAP=2.0
-> train combined: pnl_mean ~0.52, maxdd_worst ~0.15, sharpe_min ~0.55
(A ~0.55 sharpe / B ~1.3 sharpe). A modest, positive PnL at a ~15% drawdown -- the
trend-gated short-term reversal harvests the in-trend bounces while sidestepping the
big declines, vs long-only buy&hold's ~6-23x PnL at ~70-80% DD.
"""
import numpy as np
import blindlib as bl
SLOW = 130 # slow SMA -> trend regime for the agreement gate
K_WEIGHTS = (0.7, 0.2, 0.1) # blend of the 1-,2-,3-bar fades (mostly the 1-bar, the cleanest)
Z_SAT = 0.95 # move size (in trailing sigma) that saturates the fade impulse to +-1
SHORT_W = 0.25 # weight on the (trend-gated) short leg; gated -> it helps a little
TARGET_VOL = 0.25
VOL_WIN_DAYS = 30
LEV_CAP = 2.0
EPS = 1e-9
def signal(df):
c = df["close"].values.astype(float)
n = len(c)
r = bl.simple_returns(c) # r[i] = c[i]/c[i-1]-1 (causal, uses <= i)
# trailing daily-vol scaler so the "size of the last move" is measured in sigma,
# not raw % (otherwise A and B, with different vols, would need different thresholds).
vol = bl.rolling_std(r, 30)
vol = np.where(np.isfinite(vol) & (vol > EPS), vol, np.nan)
# causal fill: use the last finite vol seen so far; fallback to a constant for warmup.
vol = _ffill(vol)
vol = np.where(np.isfinite(vol), vol, np.nanmedian(vol[np.isfinite(vol)]) if np.isfinite(vol).any() else 0.03)
# FADE impulse = -(recent K-bar move) / vol, blended over K=1..3 and saturated to +-1.
# Positive impulse = price just DROPPED (fade -> want long); negative = just ROSE.
impulse = np.zeros(n)
for k, w in zip((1, 2, 3), K_WEIGHTS):
mk = np.zeros(n)
mk[k:] = c[k:] / c[:-k] - 1.0 # past k-bar return ending at i (causal)
# normalize the k-bar move by sqrt(k)*vol so each horizon is on the same sigma scale
zk = -mk / (np.sqrt(k) * vol + EPS) # FADE = negative of the move
impulse += w * np.clip(zk / Z_SAT, -1.0, 1.0)
impulse = np.clip(impulse, -1.0, 1.0)
slow = bl.sma(c, SLOW) # trend regime line (causal)
uptrend = c > slow
# TREND-AGREEMENT GATE: keep ONLY the fade leg that AGREES with the slow trend.
# uptrend + impulse>0 (price dropped) -> LONG the bounce (fade agrees: up)
# downtrend+ impulse<0 (price rose) -> SHORT the dead-cat (fade agrees: down)
# The disagreeing legs (fade a rise in an uptrend = short into a bull; fade a drop in a
# downtrend = long into a bear) are momentum/continuation, not reversion -> stay FLAT.
raw = np.zeros(n)
long_ok = (impulse > 0) & uptrend
short_ok = (impulse < 0) & (~uptrend)
raw[long_ok] = impulse[long_ok]
raw[short_ok] = impulse[short_ok] * SHORT_W
pos = bl.vol_target(raw, 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)
def _ffill(a):
"""Causal forward-fill of NaNs (each value uses only past finite values)."""
out = a.copy()
last = np.nan
for i in range(len(out)):
if np.isfinite(out[i]):
last = out[i]
else:
out[i] = last
return out