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,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
|
||||
Reference in New Issue
Block a user