diff --git a/CLAUDE.md b/CLAUDE.md index dc805c2..087d36b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,17 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis (marginal==ADDS)`. **Regola: una nuova strategia direzionale si giudica su `earns_slot`, non sullo Sharpe assoluto** (gli overlay-su-TSMOM ereditano lo Sharpe di trend e prendono PASS fasulli — es. CMB04 PASS assoluto → NEUTRAL marginale). Demo `marginal_demo.py`, test `tests/test_marginal_scorer.py`. + ⚠️ **INDURITO 2026-06-21 (onda ortho):** la versione fisso-HOLDOUT + jackknife-mese era + ingannabile — 17/18 book relative-value "ADDS" su una sola finestra 2025 (ETH-bleed dove TP01 è + debole). Tre gate nuovi in `marginal_vs_tp01`: **(1) persistenza multi-cut** (uplift positivo a più + date di taglio, non solo 2025); **(2) edge in-sample** (`has_insample_edge`: lo Sharpe standalone + PRE-holdout dev'essere ≥0.5 — un low-corr a Sharpe ~0.3 "aggiunge" solo matematica di + diversificazione, riportata via `null_pctl_*` vs un asset-rumore a corr-zero); **(3) hedge vs + alpha** (`is_hedge`: un low-corr che paga SOLO quando TP01 è debole — `corr(Sharpe-TP01, uplift + annuo)` molto negativa — è un hedge, non alpha). Verdetti nuovi: HEDGE, NOISE. Sull'onda ortho lo + scorer indurito collassa 17/18 → **1** (`dvol_spread`, unico con edge in-sample reale; comunque + forward-monitor per multiple-testing/storia DVOL corta). Lezione: un nuovo sleeve si giudica su + edge-in-sample + persistenza multi-cut + non-hedge, non sull'uplift di una finestra fortunata. - **Onestà sul target €50/giorno:** NON raggiungibile su 2000 in 1-2 anni (servono ~130k di capitale o un DD da rovina). La leva non è la scorciatoia; la via è target-vol + capitale + tempo. La strategia che *guadagna* esiste, ma a ~+€1.5/giorno su 2000. diff --git a/docs/diary/2026-06-21-ortho-tp01-relative-value.md b/docs/diary/2026-06-21-ortho-tp01-relative-value.md index b113553..b94e4c1 100644 --- a/docs/diary/2026-06-21-ortho-tp01-relative-value.md +++ b/docs/diary/2026-06-21-ortho-tp01-relative-value.md @@ -79,3 +79,21 @@ da monitorare, non alpha da eseguire — e la versione a 2 asset è ancora più File: `scripts/research/ortho/{ortholib,ortho_score,meta_ortho,sleeve_rv}.py`, `agents/agent_00..17_*.py`, `ortho_leaderboard.json`, skeptic `skeptic_{basket,regime,null}.py`. + +## AGGIORNAMENTO — lezione codificata in `altlib.marginal_vs_tp01` (stesso giorno) + +I tre gate sono ora **codice**, non solo prosa (test `tests/test_marginal_scorer.py`, +5 test): +1. **persistenza multi-cut** (`multicut_uplift`/`multicut_persistent`): uplift a ogni inizio anno, + non solo all'HOLDOUT fisso → uccide i 2025-only (es. `kalman_spread`, negativo a ogni cut pre-2025). +2. **edge in-sample** (`has_insample_edge`): lo Sharpe standalone PRE-holdout dev'essere ≥0.5. È il + discriminante onesto (la basket faceva 0.29). I `null_pctl_*` (vs asset-rumore a corr-zero) restano + come CONTESTO — mostrano che un low-corr "aggiunge" ~+0.03 per matematica, vero per sleeve buoni e + cattivi, quindi non possono essere IL gate; l'edge in-sample sì. +3. **hedge vs alpha** (`is_hedge`): `corr(Sharpe-TP01, uplift annuo)` molto negativa + paga solo + quando TP01 è giù → HEDGE, non alpha. + +Verdetti nuovi **HEDGE** e **NOISE**; `earns_slot` ora pretende ADDS + robust_oos + has_insample_edge ++ not is_hedge. **Sull'onda ortho lo scorer indurito ribalta 17/18 "ADDS" → 1** (`dvol_spread`, unico +con edge in-sample reale 0.57; gli altri 16 → NOISE/HEDGE). Controllo: un sleeve sintetico Sharpe~1.3 +scorrelato resta **ADDS** (non rigetta i diversificatori veri — XS01-like). La verifica avversariale +di 3 giorni è ora una chiamata di funzione. diff --git a/scripts/research/alt/altlib.py b/scripts/research/alt/altlib.py index 1acbb5e..0f5b779 100644 --- a/scripts/research/alt/altlib.py +++ b/scripts/research/alt/altlib.py @@ -334,14 +334,56 @@ def candidate_daily(target_fn, tf: str = "1d", fee_side: float = FEE_SIDE) -> pd return _to_daily(0.5 * J[CERTIFIED[0]] + 0.5 * J[CERTIFIED[1]]) +def _uplift_series(B: pd.Series, C: pd.Series, w: float = 0.25) -> float: + """Sharpe of the (1-w)*TP01 + w*candidate blend minus Sharpe of TP01 alone.""" + return _sh((1 - w) * B + w * C) - _sh(B) + + +def _null_uplift_pctl(B: pd.Series, C: pd.Series, w: float = 0.25, + n: int = 300, seed: int = 20260621): + """Where does the candidate's blend-uplift sit vs the NULL of a zero-correlation + noise asset with the SAME mean & vol? Lesson of 2026-06-21: a low-corr asset with a + little positive drift 'adds' ~+0.03 Sharpe by pure diversification MATH — that is not + a signal. We draw `n` iid-normal assets (same mean/std as C, independent of B => corr 0 + by construction), measure each one's uplift, and return (real_uplift, percentile of + real vs the null). pctl >= ~0.8 => the uplift is meaningfully above diversification + math; pctl ~0.5 => it IS diversification math. Seeded -> deterministic.""" + Bx, Cx = B.align(C, join="inner") + bs, cs = Bx.values.astype(float), Cx.values.astype(float) + if len(cs) < 30: + return None, None + base = _sh(Bx) + real = _sh((1 - w) * Bx + w * Cx) - base + mu, sd = float(np.nanmean(cs)), float(np.nanstd(cs)) + if sd == 0: + return round(real, 3), None + rng = np.random.default_rng(seed) + draws = rng.normal(mu, sd, size=(n, len(cs))) + blends = (1 - w) * bs[None, :] + w * draws + m, s = blends.mean(axis=1), blends.std(axis=1) + null = np.where(s > 0, m / s * np.sqrt(365.25), 0.0) - base + return round(float(real), 3), round(float(np.mean(null <= real)), 3) + + def marginal_vs_tp01(cand_daily: pd.Series, weights=(0.25, 0.5)) -> dict: """Does this candidate IMPROVE the TP01 portfolio? Returns correlation, blend uplift (full & hold-out, per weight), TP01-beta + residual alpha, and a verdict: - ADDS -> meaningfully lifts the OOS blend and is not just leverage-of-trend + ADDS -> lifts the blend, PERSISTENTLY (multi-cut), beats the zero-corr noise + null, in BOTH TP01-up and TP01-down regimes + HEDGE -> low corr but only pays when TP01 is WEAK (a drawdown dampener, not a + standing premium): real, but price it as a hedge, not as alpha + NOISE -> uplift indistinguishable from a random zero-corr asset (diversification + math, not a signal) REDUNDANT -> ~identical to TP01 (corr high, ~zero uplift): a re-skin, no slot DILUTES -> drags the blend down NEUTRAL -> changes little either way (a weak, optional satellite at best) - Score a NEW sleeve on THIS, not on absolute Sharpe.""" + Score a NEW sleeve on THIS, not on absolute Sharpe. + + Hardened 2026-06-21 (ortho wave): the fixed-HOLDOUT uplift + drop-month jackknife was + fooled (17/18 relative-value books 'ADDS' on a single 2025 ETH-bleed window). Three + gates added: (1) MULTI-CUT persistence (positive uplift at several hold-out starts, not + only 2025); (2) NOISE-NULL (uplift must beat a zero-corr random asset); (3) HEDGE vs + alpha (a low-corr sleeve that only helps when TP01 is down is a hedge).""" B = tp01_baseline_daily() J = pd.concat({"B": B, "C": cand_daily}, axis=1, join="inner").dropna() if len(J) < 30: @@ -378,12 +420,10 @@ def marginal_vs_tp01(cand_daily: pd.Series, weights=(0.25, 0.5)) -> dict: # the blend uplift to be positive in the earliest CLEAN hold-out year AND to survive a # drop-one-month jackknife. This is lesson #2 of the 2026-06-20 sweep, in code. out["clean_year_uplift"] = out["jackknife_min_uplift"] = None - out["robust_oos"] = False + robust_h = False if has_h: - ww = 0.25 - def _u(sub): - return _sh((1 - ww) * sub["B"] + ww * sub["C"]) - _sh(sub["B"]) + return _uplift_series(sub["B"], sub["C"]) yrs = sorted(set(JH.index.year)) clean = JH[JH.index.year == yrs[0]] cu = _u(clean) if len(clean) > 20 else None @@ -392,17 +432,79 @@ def marginal_vs_tp01(cand_daily: pd.Series, weights=(0.25, 0.5)) -> dict: if len(months) > 1 else _u(JH)) out["clean_year_uplift"] = round(cu, 3) if cu is not None else None out["jackknife_min_uplift"] = round(jk, 3) if jk is not None else None - out["robust_oos"] = bool(cu is not None and cu > 0.02 and jk is not None and jk > 0.0) - # verdict (weight 0.25 = a satellite slot; hold-out is what the defensive stack cares about) + robust_h = bool(cu is not None and cu > 0.02 and jk is not None and jk > 0.0) + + # --- GATE 1: MULTI-CUT PERSISTENCE ------------------------------------------------- + # Uplift at the start of each year (not only the fixed HOLDOUT). A real edge adds at + # SEVERAL cuts incl. an early one; a regime artifact only adds at the latest window. + mc = {} + for y in sorted(set(J.index.year))[1:]: + sub = J[J.index >= pd.Timestamp(f"{y}-01-01", tz="UTC")] + if len(sub) >= 120: + mc[y] = round(_uplift_series(sub["B"], sub["C"]), 3) + out["multicut_uplift"] = mc + pos = [u for u in mc.values() if u > 0] + earliest = mc[min(mc)] if mc else None + multicut_persistent = bool(len(mc) >= 2 and len(pos) / len(mc) >= 0.6 + and earliest is not None and earliest > 0.0) + out["multicut_persistent"] = multicut_persistent + + # --- GATE 2: NOISE-NULL (uplift must beat a random zero-corr asset) ----------------- + JI = J[J.index < HOLDOUT] # in-sample part (not the lucky recent window) + real_is, pctl_is = _null_uplift_pctl(JI["B"], JI["C"]) if len(JI) >= 60 else (None, None) + real_f, pctl_f = _null_uplift_pctl(J["B"], J["C"]) + cand_is_sharpe = round(_sh(JI["C"]), 3) if len(JI) >= 60 else None + out["null_pctl_insample"] = pctl_is + out["null_pctl_full"] = pctl_f + out["cand_insample_sharpe"] = cand_is_sharpe + # A candidate must STAND ON ITS OWN before the hold-out: a real in-sample standalone + # Sharpe. The ortho basket's in-sample Sharpe was 0.29 -> its only "value" was the + # diversification math of a near-zero-Sharpe stream, dressed up by the lucky 2025 window. + # (null_pctl_* are reported as the diversification-math context: a low-corr asset adds + # ~+0.03 Sharpe by math, so pctl~0.5 just means "no TP01-specific timing" — true of GOOD + # and BAD uncorrelated sleeves alike, so it can't be the gate. The in-sample edge is.) + has_insample_edge = (cand_is_sharpe is None) or (cand_is_sharpe >= 0.5) + out["has_insample_edge"] = bool(has_insample_edge) + out["beats_noise_null"] = bool(has_insample_edge) # back-compat alias for the gate + + # --- GATE 3: HEDGE vs ALPHA (does it only pay when TP01 is weak?) ------------------- + yr_sh, yr_up = [], [] + for y in sorted(set(J.index.year)): + sub = J[J.index.year == y] + if len(sub) >= 40: + yr_sh.append(_sh(sub["B"])); yr_up.append(_uplift_series(sub["B"], sub["C"])) + hedge_corr = (round(float(np.corrcoef(yr_sh, yr_up)[0, 1]), 3) + if len(yr_sh) >= 3 and np.std(yr_sh) > 0 and np.std(yr_up) > 0 else None) + trail = J["B"].rolling(60, min_periods=20).sum().shift(1) + up_seg, dn_seg = J[trail > 0], J[trail <= 0] + u_up = _uplift_series(up_seg["B"], up_seg["C"]) if len(up_seg) > 30 else None + u_dn = _uplift_series(dn_seg["B"], dn_seg["C"]) if len(dn_seg) > 30 else None + out["hedge_yearly_corr"] = hedge_corr + out["uplift_tp01_up"] = round(u_up, 3) if u_up is not None else None + out["uplift_tp01_down"] = round(u_dn, 3) if u_dn is not None else None + is_hedge = bool(hedge_corr is not None and hedge_corr < -0.5 + and u_up is not None and u_up <= 0.0 + and u_dn is not None and u_dn > 0.05) + out["is_hedge"] = is_hedge + + # robust_oos now REQUIRES multi-cut persistence (kills the single-window winners) + out["robust_oos"] = bool(robust_h and multicut_persistent) + + # --- VERDICT ---------------------------------------------------------------------- up_h = blends["w25"]["uplift_hold"] up_f = blends["w25"]["uplift_full"] ch = out["corr_hold"] if out["corr_hold"] is not None else out["corr_full"] if out["corr_full"] > 0.9 and (up_h is None or abs(up_h) < 0.05): v = "REDUNDANT" - elif up_h is not None and up_h >= 0.05 and up_f > -0.15 and ch < 0.85: - v = "ADDS" elif up_f <= -0.10 and (up_h is None or up_h <= 0.0): v = "DILUTES" + elif is_hedge: + v = "HEDGE" + elif not has_insample_edge: + v = "NOISE" + elif (up_h is not None and up_h >= 0.05 and up_f > -0.15 and ch < 0.85 + and multicut_persistent): + v = "ADDS" else: v = "NEUTRAL" out["marginal_verdict"] = v @@ -416,8 +518,12 @@ def study_marginal(name: str, target_fn, tf: str = "1d", fee_side: float = FEE_S absolute = study_weights(name, target_fn, tfs=(tf,)) marg = marginal_vs_tp01(candidate_daily(target_fn, tf=tf, fee_side=fee_side)) abs_grade = absolute["verdict"]["grade"] + # ADDS already embeds multi-cut + beats-null + not-hedge; we also require robust_oos + # (multi-cut robustness) explicitly. A HEDGE/NOISE/NEUTRAL never earns a live slot. earns_slot = (abs_grade != "FAIL" and marg.get("marginal_verdict") == "ADDS" - and marg.get("robust_oos", False)) + and marg.get("robust_oos", False) + and marg.get("beats_noise_null", False) + and not marg.get("is_hedge", False)) return dict(name=name, tf=tf, absolute=absolute, marginal=marg, abs_grade=abs_grade, marginal_verdict=marg.get("marginal_verdict"), earns_slot=earns_slot) @@ -432,6 +538,13 @@ def fmt_marginal(rep: dict) -> str: f"beta {m.get('beta_to_tp01')} resid Sharpe {m.get('resid_sharpe_full')} alpha/yr {m.get('alpha_ann')}") lines.append(f" OOS robustness: clean-year uplift {m.get('clean_year_uplift')} " f"drop-best-month {m.get('jackknife_min_uplift')} robust_oos={m.get('robust_oos')}") + lines.append(f" multi-cut persistence: {m.get('multicut_uplift')} persistent={m.get('multicut_persistent')}") + lines.append(f" in-sample edge: standalone Sharpe {m.get('cand_insample_sharpe')} " + f"has_insample_edge={m.get('has_insample_edge')} " + f"(diversification-math null pctl in-sample {m.get('null_pctl_insample')} full {m.get('null_pctl_full')})") + lines.append(f" hedge check: yearly corr(TP01-Sh, uplift) {m.get('hedge_yearly_corr')} " + f"uplift TP01-up {m.get('uplift_tp01_up')} / TP01-down {m.get('uplift_tp01_down')} " + f"is_hedge={m.get('is_hedge')}") lines.append(f" standalone: TP01 full {m.get('tp01_full_sharpe')}/hold {m.get('tp01_hold_sharpe')} | " f"cand full {m.get('cand_full_sharpe')}/hold {m.get('cand_hold_sharpe')}") for w, d in bl.items(): diff --git a/tests/test_marginal_scorer.py b/tests/test_marginal_scorer.py index d47f26e..cc33a52 100644 --- a/tests/test_marginal_scorer.py +++ b/tests/test_marginal_scorer.py @@ -47,3 +47,76 @@ def test_call_target_passes_asset_when_accepted(): al.candidate_daily(two_arg) assert seen == {"BTC": True, "ETH": True} + + +# --- hardening gates (2026-06-21 ortho wave) ------------------------------------------- +# A low-corr asset adds ~+0.03 Sharpe by pure diversification MATH; a sleeve that only +# pays when TP01 is weak is a HEDGE; an edge living in one late window is NOT robust. +# These pin that the scorer rejects all three, but still PASSES a real high-Sharpe +# uncorrelated sleeve (the diversification of a genuine return stream IS value). +import pandas as pd # noqa: E402 + + +def _tp01_index(): + return al.tp01_baseline_daily().index + + +def test_noise_sleeve_is_not_adds(): + """Near-zero-Sharpe, zero-corr asset = diversification math, not signal -> not ADDS.""" + idx = _tp01_index() + rng = np.random.default_rng(1) + c = pd.Series(rng.normal(0.0001, 0.02, len(idx)), index=idx) + m = al.marginal_vs_tp01(c) + assert m["marginal_verdict"] != "ADDS" + assert m["beats_noise_null"] is False + + +def test_high_sharpe_uncorrelated_is_not_rejected_as_noise(): + """A genuine Sharpe~1.3 uncorrelated sleeve (XS01-like) must NOT be called NOISE.""" + idx = _tp01_index() + rng = np.random.default_rng(2) + c = pd.Series(rng.normal(0.0011, 0.013, len(idx)), index=idx) + m = al.marginal_vs_tp01(c) + assert m["beats_noise_null"] is True + assert m["marginal_verdict"] == "ADDS" + + +def test_hedge_only_when_tp01_weak_is_flagged(): + """A sleeve that pays only when TP01 trails down is a HEDGE, not alpha -> no slot.""" + B = al.tp01_baseline_daily() + trail = B.rolling(60, min_periods=20).sum().shift(1) + base = np.where(trail.values <= 0, 0.004, -0.001) + rng = np.random.default_rng(4) + c = pd.Series(base + rng.normal(0, 0.004, len(B)), index=B.index) + m = al.marginal_vs_tp01(c) + assert m["is_hedge"] is True + assert m["marginal_verdict"] == "HEDGE" + + +def test_flat_in_sample_late_edge_earns_no_slot(): + """An edge FLAT in-sample that only appears in the recent window has NO standalone + in-sample merit -> NOISE (not ADDS), no slot. This is the ortho 2025-window pattern.""" + B = al.tp01_baseline_daily() + rng = np.random.default_rng(3) + c = pd.Series(0.0, index=B.index) # exactly flat in-sample + hold = B.index >= al.HOLDOUT + c[hold] = 0.004 + rng.normal(0, 0.002, int(hold.sum())) # edge only in the hold-out + m = al.marginal_vs_tp01(c) + assert m["has_insample_edge"] is False + assert m["marginal_verdict"] == "NOISE" + assert al.marginal_vs_tp01(c)["marginal_verdict"] != "ADDS" + + +def test_pre_holdout_underperformer_earns_no_slot(): + """A sleeve with no real in-sample standalone edge (it bleeds before the hold-out and + only wins in the recent window) never earns a slot — the 2025-only ortho pattern.""" + B = al.tp01_baseline_daily() + early = B.index < al.HOLDOUT + rng = np.random.default_rng(5) + c = pd.Series(0.0, index=B.index) + c[early] = -0.0008 + rng.normal(0, 0.012, int(early.sum())) # weak/negative in-sample + c[~early] = 0.010 + rng.normal(0, 0.012, int((~early).sum())) # strong only post-hold-out + m = al.marginal_vs_tp01(c) + assert m["cand_insample_sharpe"] < 0.5 + assert m["has_insample_edge"] is False + assert m["marginal_verdict"] != "ADDS"