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:
@@ -0,0 +1,186 @@
|
||||
"""Agent 33 — GradientBoostingClassifier up/down direction model (family=ml, slug=gbm).
|
||||
|
||||
THE ANGLE (assigned): a GradientBoostingClassifier (sklearn) that classifies "will the
|
||||
forward move be up or down?" from a causal technical feature vector, refit on an EXPANDING
|
||||
walk-forward window on PAST rows only (periodic refit), and maps the class probability
|
||||
p(up) into a probability-weighted position in [-1, +1]. This is the gradient-boosted-tree
|
||||
cousin of agent_30 (logistic) / agent_32 (MLP): shallow additive trees can pick up
|
||||
threshold/interaction effects (e.g. "high momentum AND low vol") 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 GBM
|
||||
targets exactly that Bernoulli up/down label and emits a calibrated-ish probability — a
|
||||
natural conviction: p~0.5 -> flat, p far from 0.5 -> take the side. Shallow stumps
|
||||
(max_depth small), few estimators, a low learning_rate and subsampling keep the additive
|
||||
model from carving 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 GBM is refit on the EXPANDING window of those realized (X, y) pairs at most every
|
||||
REFIT_EVERY bars; the fitted model is frozen in between. position[i] = frozen model's
|
||||
p(up) at row i, mapped to a direction, then vol-targeted. Deterministic (fixed
|
||||
random_state, no shuffle) 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): shallow trees (max_depth 2) + few estimators
|
||||
+ low learning_rate + subsample<1 so the weak edge isn't overfit; FWD_H in the forecastable
|
||||
band (next-bar sign is a coin-flip; multi-bar sign is the persistent quantity); 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. Refit cadence is COARSE
|
||||
(~40 bars) because a GBM is ~100x slower to fit than a logit and the edge is slow-moving.
|
||||
|
||||
HONEST READ: forward-sign forecastability here is weak and a GBM 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.ensemble import GradientBoostingClassifier
|
||||
_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) ----
|
||||
N_EST = 120 # number of boosting stages (modest; heavy shrinkage on a thin edge)
|
||||
MAX_DEPTH = 2 # shallow trees (stumps/pairs) -> capture interactions, resist overfit
|
||||
LEARN_RATE = 0.03 # low learning rate (heavy shrinkage on a weak signal)
|
||||
SUBSAMPLE = 0.7 # stochastic GB: subsample rows per stage -> regularize + decorrelate
|
||||
MIN_LEAF = 30 # large min leaf -> no carving the noise into tiny leaves
|
||||
WARMUP = 260 # realized (X, y) pairs required before the first fit
|
||||
REFIT_EVERY = 40 # expanding-window refit cadence (COARSE: GBM is slow + edge is slow)
|
||||
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 = 15 # 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 ~12-20 (best at 15).
|
||||
DEADBAND = 0.04 # ignore |2p-1| below this (no-conviction -> flat, saves fee churn)
|
||||
GAIN = 3.0 # conviction gain on the centered probability 2*(p-0.5)
|
||||
SHORT_SCALE = 0.0 # LONG-FLAT book. Both curves drift UP, so the classifier's real
|
||||
# value is STEPPING ASIDE from declines, not shorting them — the
|
||||
# train scan is unambiguous that a short side (even partial) only
|
||||
# ADDS drawdown (it fights the up-drift) without improving PnL or
|
||||
# Sharpe. p(up)<0.5 -> FLAT, not short. The de-risking is the alpha.
|
||||
TARGET_VOL = 0.18 # vol-target the directional book (pure PnL/DD knob; Sharpe ~flat in it)
|
||||
VOL_WIN_DAYS = 45 # vol-estimation window (45 > 30 cut the worst DD on the train scan)
|
||||
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):
|
||||
"""GradientBoostingClassifier fit on raw features (trees are scale-invariant).
|
||||
Returns the fitted model, or None if labels are single-class (no fit possible yet)."""
|
||||
if len(np.unique(ytr)) < 2:
|
||||
return None
|
||||
if _HAVE_SK:
|
||||
m = GradientBoostingClassifier(
|
||||
n_estimators=N_EST, max_depth=MAX_DEPTH, learning_rate=LEARN_RATE,
|
||||
subsample=SUBSAMPLE, min_samples_leaf=MIN_LEAF, random_state=0)
|
||||
m.fit(Xtr, ytr)
|
||||
return m
|
||||
return None
|
||||
|
||||
|
||||
def _predict_proba(m, xi):
|
||||
classes = list(m.classes_)
|
||||
if 1.0 not in classes:
|
||||
return 0.5
|
||||
j = classes.index(1.0)
|
||||
return float(m.predict_proba(xi.reshape(1, -1))[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)
|
||||
model = 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 model 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:
|
||||
model = fit
|
||||
if model is not None:
|
||||
prob[i] = _predict_proba(model, 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)
|
||||
Reference in New Issue
Block a user