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,193 @@
"""Agent 32 — MLPClassifier up/down direction model (family=ml, slug=mlp_clf).
THE ANGLE (assigned): a SMALL MLPClassifier (sklearn, one hidden layer) that classifies
"will the forward move be up or down?" from a causal technical feature vector, refit on an
EXPANDING walk-forward window every ~25 bars, and maps the class probability p(up) into a
position in [-1, +1]. This is the NONLINEAR cousin of agent_30 (logistic): a tiny neural net
can in principle pick up feature interactions a linear logit cannot, while staying a
classifier (sign is the only persistent quantity here, magnitude is noise).
WHY A CLASSIFIER (not a return-regressor): the per-bar *magnitude* of these curves is
dominated by noise; only the SIGN of a multi-bar forward move has any persistence. The MLP
targets exactly that Bernoulli up/down label and emits a bounded probability — a natural
conviction: p~0.5 -> flat, p far from 0.5 -> take the side. Strong L2 (alpha) + a tiny net
keep it from chasing the thin edge into noise.
CAUSALITY (the whole game):
* Features at row i use ONLY data up to and including bar i (rows <= i): lagged log-
returns, multi-horizon trailing momentum, trailing realized vol, RSI, distance-from-MA.
* The LABEL for row j is the sign of the cumulative return over bar j -> j+FWD_H, which
needs close[j+FWD_H]. Sitting at decision-row i we may train ONLY on rows whose label is
already realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own label is NEVER used.
* The MLP is refit on the EXPANDING window of those realized (X, y) pairs at most every
REFIT_EVERY (~25) bars; weights frozen in between. position[i] = frozen model's p(up) at
row i, mapped to a direction, then vol-targeted. Deterministic (fixed random_state,
lbfgs, capped iters) so signal(prefix) == signal(full)[:cut].
-> Verified by causality_ok (signal on a prefix must match signal on the full array).
TUNING (split='train' only, combined A & B): tiny net (one layer) + strong alpha so the weak
edge isn't overfit; FWD_H in the forecastable band (next-bar sign is a coin-flip); WARMUP big
enough that the first fit sees a real sample; conviction = tanh(GAIN * (2p-1)) with a deadband
and an asymmetric short scale (both curves drift UP, so the classifier's real value is
STEPPING ASIDE from declines, not fighting the drift with full shorts); then vol-targeted
(cap 1.0) so the DRAWDOWN, not the raw forecast, is what we control.
HONEST READ: forward-sign forecastability here is weak and an MLP does not manufacture it.
The realistic, defensible win is a vol-controlled, low-drawdown book that de-risks/flips into
declines — comparable PnL to long-only at a FRACTION of the ~77% buy&hold drawdown. The
de-risking is the alpha, not a strong classifier. A thin/negative result is the honest result.
"""
import warnings
import numpy as np
import blindlib as bl
warnings.filterwarnings("ignore")
try:
from sklearn.neural_network import MLPClassifier
_HAVE_SK = True
except Exception: # pragma: no cover - sklearn expected present
_HAVE_SK = False
# ---- tuned on split='train' only (interior of broad plateaus; see scans below) ----
# Train scans (combined A&B, ranked on the orchestrator's worst-case sharpe_min):
# FWD x HIDDEN x alpha -> winner FWD=10, HIDDEN=(6,), alpha=2.0 (shmin 0.68, ddw 0.21).
# refit cadence: RE=25 beats RE=20; FWD=10/12 plateau, FWD=8 fragile (B turns negative).
# short-scale ablation: shmin is MONOTONE-DECREASING in the short size — the classifier's
# real edge is STEPPING ASIDE (long/flat), not shorting the up-drift. SS=0.0 wins (shmin
# 0.81) but is a degenerate prob->position map; SS=0.10 keeps a genuine, small short so the
# mapping truly spans [-1,1] at little cost (shmin 0.76, ddw 0.20, pnl_mean 0.56).
HIDDEN = (6,) # ONE tiny hidden layer (edge is thin -> keep it small + fast)
MLP_ALPHA = 2.0 # L2 penalty (STRONG: the lag->sign edge is tiny -> resist overfit)
MAX_ITER = 200 # capped optimizer iterations (lbfgs on a tiny net converges fast)
WARMUP = 220 # realized (X, y) pairs required before the first fit
REFIT_EVERY = 25 # expanding-window refit cadence (assigned ~25; beats 20 on train)
LAGS = (1, 2, 3, 5) # lagged log-return features
MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features
VOL_WIN = 20 # trailing realized-vol feature window
RSI_WIN = 14 # RSI feature window
MA_WIN = 50 # distance-from-MA feature window
FWD_H = 10 # label HORIZON: sign of cumulative return over next FWD_H bars.
# next-bar sign is a coin-flip; the multi-bar sign is the persistent,
# classifiable quantity. Plateau FWD ~10-12 (FWD=8 fragile on B).
DEADBAND = 0.06 # ignore |2p-1| below this (no-conviction -> flat, saves fee churn)
GAIN = 2.0 # conviction gain on the centered probability 2*(p-0.5)
SHORT_SCALE = 0.10 # asymmetric book: full long, only a SMALL short. Curves drift UP, so
# the classifier's value is STEPPING ASIDE from declines; shorting the
# drift strictly worsens shmin/DD (ablation). 0.10 keeps a genuine
# (small) short so the mapping stays a real prob->[-1,1] position.
TARGET_VOL = 0.20 # vol-target the directional book
VOL_WIN_DAYS = 30
LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut
def _build_features(c):
"""Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i."""
n = len(c)
lr = np.zeros(n)
lr[1:] = np.log(c[1:] / c[:-1]) # lr[i] = return of bar ending at i (causal)
csum = np.cumsum(lr)
cs2 = np.cumsum(lr * lr)
cols = []
# lagged returns: value at i is the return k bars ago (all <= i)
for k in LAGS:
f = np.zeros(n)
if k < n:
f[k:] = lr[: n - k]
cols.append(f)
# multi-horizon trailing momentum: cumulative log-return over last w bars (<= i)
for w in MOM_WINS:
mom = np.zeros(n)
mom[w:] = csum[w:] - csum[:-w]
cols.append(mom)
# trailing realized vol (std of last VOL_WIN returns, <= i)
vol = np.zeros(n)
for i in range(VOL_WIN, n):
m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN
v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m
vol[i] = np.sqrt(max(v, 0.0))
cols.append(vol)
# RSI (causal, from blindlib), centered to ~[-0.5, 0.5]
rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 - 0.5
cols.append(rsi)
# distance from a trailing MA (causal): log(close / sma)
ma = np.nan_to_num(bl.sma(c, MA_WIN), nan=c[0])
ma[ma <= 0] = 1e-9
dist = np.log(np.maximum(c, 1e-9) / ma)
dist[:MA_WIN] = 0.0
cols.append(dist)
X = np.column_stack(cols)
return X, lr, csum
def _fit(Xtr, ytr):
"""MLPClassifier fit on standardized features. Returns (mu, sd, model) or None if the
training labels are single-class (no fit possible yet)."""
if len(np.unique(ytr)) < 2:
return None
mu = Xtr.mean(axis=0)
sd = Xtr.std(axis=0)
sd[sd < 1e-12] = 1.0
Xs = (Xtr - mu) / sd
if _HAVE_SK:
m = MLPClassifier(hidden_layer_sizes=HIDDEN, activation="tanh",
alpha=MLP_ALPHA, solver="lbfgs", max_iter=MAX_ITER,
random_state=0)
m.fit(Xs, ytr)
return (mu, sd, m)
return None
def _predict_proba(coef, xi):
mu, sd, m = coef
xs = ((xi - mu) / sd).reshape(1, -1)
# class order from sklearn; index of the "up" (label 1.0) class
classes = list(m.classes_)
if 1.0 not in classes:
return 0.5
j = classes.index(1.0)
return float(m.predict_proba(xs)[0, j])
def signal(df):
c = df["close"].values.astype(float)
n = len(c)
X, lr, csum = _build_features(c)
# label[j] = 1 if cumulative return over bar j -> j+FWD_H is up, else 0.
# realized (known) only as of close[j+FWD_H].
fwd = np.zeros(n)
fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H]
label = (fwd > 0).astype(float)
first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN, MA_WIN) # first fully-featured row
prob = np.full(n, 0.5)
coef = None
for i in range(n):
last_train = i - FWD_H # label of last_train uses close[i], realized now
ntrain = last_train - first + 1
if ntrain >= WARMUP:
if coef is None or (i % REFIT_EVERY == 0):
Xtr = X[first : last_train + 1]
ytr = label[first : last_train + 1]
fit = _fit(Xtr, ytr)
if fit is not None:
coef = fit
if coef is not None:
prob[i] = _predict_proba(coef, X[i])
# probability -> bounded direction. centered conviction 2*(p-0.5) in [-1,1];
# deadband kills no-conviction bars; tanh sharpens; the short side is scaled down
# (the up-drift makes full shorts a losing fight — we mainly want to step aside).
conv = 2.0 * prob - 1.0
conv = np.where(np.abs(conv) < DEADBAND, 0.0, conv)
direction = np.tanh(GAIN * conv)
direction = np.where(direction < 0.0, direction * SHORT_SCALE, direction)
direction = np.nan_to_num(direction, nan=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)