From e28176efef360f8a7259ac3c52890a16d1ae9599 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 26 Jun 2026 19:42:14 +0000 Subject: [PATCH 1/4] research(equity): scalping 'sottoquotate' su IB + check dati dalla rete Goal: comprare azioni IB quando sottoquotate, con verifica incrociata dei dati. 1) CHECK DATI DALLA RETE (il pezzo richiesto): IB certificato (eq_*, ADJUSTED) vs Yahoo adjclose (sorgente rete indipendente, tokenless). Tutti CONCORDE <=1.2bps sui rendimenti. Teaching: un confronto naif (adjusted vs grezzo) falso-allarmava 4/6 ticker a 30-52bps = TUTTO stacco dividendo -> ogni divergenza va SPIEGATA prima di gridare 'feed sporco' (v2.0.0). Strumento riutilizzabile (validatore feed / pre-trade price-check). 2) Scalping 'sottoquotate': NON testabile (no dati intraday) ne' eseguibile (PDT rule 5k blocca il day-trading; IB instabile). Versione testabile = swing MR (Connors RSI2<10 + MA200, exit MA5): Sharpe modesto (SPY 0.75/0.70) ma NON batte il buy&hold (B&H hold 0.81), expo 13%, CAGR 2-5%, fee-sensitive. Niente edge schierabile. - scripts/research/eq_meanrev_ib.py - tests/test_eq_meanrev.py (incl. network check) - docs/diary/2026-06-26-equity-meanrev-network-check.md Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-26-equity-meanrev-network-check.md | 57 ++++++ scripts/research/eq_meanrev_ib.py | 172 ++++++++++++++++++ tests/test_eq_meanrev.py | 51 ++++++ 3 files changed, 280 insertions(+) create mode 100644 docs/diary/2026-06-26-equity-meanrev-network-check.md create mode 100644 scripts/research/eq_meanrev_ib.py create mode 100644 tests/test_eq_meanrev.py diff --git a/docs/diary/2026-06-26-equity-meanrev-network-check.md b/docs/diary/2026-06-26-equity-meanrev-network-check.md new file mode 100644 index 0000000..553f3ed --- /dev/null +++ b/docs/diary/2026-06-26-equity-meanrev-network-check.md @@ -0,0 +1,57 @@ +# 2026-06-26 — Scalping azioni IB "quando sottoquotate" + check dati dalla rete + +**Goal utente:** su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con +verifica incrociata dei dati dalla rete. Branch `research/equity-meanrev-ib`, +script `scripts/research/eq_meanrev_ib.py`. Due pezzi. + +## 1) CHECK DATI DALLA RETE — il pezzo richiesto (e perfettamente in-metodo) + +Confronto i dati certificati IB (`data/raw/eq_*_1d.parquet`, ADJUSTED_LAST) con una **sorgente di +rete indipendente** (Yahoo Finance chart API, tokenless). È la lezione fondante del progetto: non +fidarti di un feed solo (il disastro v2.0.0 era un feed contaminato). + +**Risultato (180g, BTC… no: 6 ETF):** dopo l'allineamento corretto **tutti CONCORDE**, deviazione +sui rendimenti **≤1.2bps**, ultimo close identico (0.0bps). Il feed IB è validato. + +**La lezione (errore che ho fatto e corretto):** un primo confronto naïf (nostro close *adjusted* +vs `close` *grezzo* di Yahoo) ha **falso-allarmato 4/6 ticker** a 30-52bps (SPY 30, TLT 40, HYG 52). +Le divergenze erano **tutte su singoli giorni = stacco dividendo**: confrontavo dividend-adjusted vs +non-adjusted. Usando l'`adjclose` di Yahoo (apples-to-apples) → ~0bps. **Regola: ogni divergenza va +SPIEGATA prima di gridare "feed sporco" — e prima di tradarci sopra.** Lo strumento resta come +validatore di feed riutilizzabile / template del pre-trade price-check live. + +## 2) "SCALPING quando sottoquotata" — non testabile/eseguibile; testato lo swing MR + +Lo **scalping intraday non è valutabile**: (a) non abbiamo dati intraday (solo `eq_*_1d`), (b) non +è eseguibile — **PDT rule**: il day-trading di azioni US sotto **$25.000** è limitato a 3 +day-trade/5gg → lo scalping è *regolatoriamente bloccato* al capitale del progetto (l'analogo equity +del muro STAT-MODE a $600 sul crypto), (c) IB Gateway è instabile qui (timeout ordini diagnosticato). + +Versione onesta e testabile = **swing mean-reversion** "compra quando sottoquotata": Connors RSI2<10 ++ filtro trend MA200, exit a MA5. Causale (segnale ≤ close[i], entry a close[i]), netto fee, hold-out 2015+: + +| ETF | FULL Sh | DD | CAGR | expo | HOLD Sh | **B&H HOLD Sh** | +|---|---|---|---|---|---|---| +| SPY | +0.75 | 15% | +5% | 13% | +0.70 | **+0.81** | +| QQQ | +0.50 | 16% | +4% | 14% | +0.56 | **+0.92** | +| IWM | +0.32 | 22% | +2% | 13% | +0.15 | +0.53 | +| DIA | +0.38 | 13% | +2% | 14% | +0.55 | +0.74 | +| EEM | +0.41 | 20% | +3% | 13% | +0.36 | +0.46 | + +Fee sweep SPY: Sh 0.82 (0bps) → 0.75 (3) → 0.70 (5) → 0.58 (10bps RT). + +**Lettura onesta:** la mean-reversion "buy the dip" sugli indici equity ha un edge **reale ma +modesto** (Sharpe ~0.5-0.75, investito solo ~13% dei giorni) **che NON batte il buy&hold +risk-adjusted** (B&H hold-out 0.81 su SPY vs 0.70 della MR), con CAGR piccolo (2-5%) e sensibile ai +costi. È capital-efficient in teoria (cash 87% del tempo) ma il valore aggiunto vs "tieni l'indice" +è negativo o nullo, e la frequenza *scalping* che potrebbe aggiungere valore è bloccata dalla PDT. + +## Verdetto + +- **Check dati dalla rete: FATTO e utile** — feed IB validato (≤1.2bps vs Yahoo); strumento + riutilizzabile. Unico vero keeper della sessione equity. +- **Scalping "sottoquotate": NON deployabile** — non testabile (no intraday), non eseguibile (PDT + $25k, IB instabile). Lo swing MR daily testabile **non batte il buy&hold** → niente edge schierabile. + +Conferma il pattern del progetto: l'idea promettente sopravvive solo finché non incontra +eseguibilità + benchmark onesto. Nessun impatto sul book live crypto (branch separato). diff --git a/scripts/research/eq_meanrev_ib.py b/scripts/research/eq_meanrev_ib.py new file mode 100644 index 0000000..c23ee5a --- /dev/null +++ b/scripts/research/eq_meanrev_ib.py @@ -0,0 +1,172 @@ +"""EQ-MR — "scalping azioni IB quando sottoquotate" + CHECK DATI DALLA RETE. Analisi onesta. + +L'utente chiede: su IB, comprare azioni quando "sottoquotate" (oversold/sotto fair-value), con +verifica incrociata dei dati dalla rete. Due pezzi, entrambi in-metodo (la lezione fondante del +progetto e' "non fidarti di un feed solo" — il disastro v2.0.0): + +1) CHECK DATI DALLA RETE — confronta i nostri dati certificati IB (data/raw/eq_*_1d.parquet, + ADJUSTED_LAST) con una SORGENTE INDIPENDENTE di rete (Yahoo Finance chart API, tokenless). + Confronto sui RENDIMENTI giornalieri (invarianti all'aggiustamento) + ultimo close. Verdetto: + feed CONCORDE (bps piccoli) o DIVERGENTE. E' il template del pre-trade price-check live. + +2) MEAN-REVERSION "SOTTOQUOTATA" sul daily — lo *scalping* intraday NON e' backtestabile (non + abbiamo dati intraday, solo eq_*_1d) ne' eseguibile (vedi sotto), quindi si testa la versione + onesta: swing mean-reversion su ETF indice (RSI2 oversold + filtro trend MA200, exit a MA5 = + Connors). Causale (segnale <= close[i], entry a close[i]), netto fee, hold-out OOS, per-anno. + +ESEGUIBILITA' (muro, da dire subito): + - PDT RULE: il day-trading di azioni US sotto $25.000 e' limitato a 3 day-trade/5gg -> lo scalping + e' regolatoriamente BLOCCATO al capitale del progetto. (l'analogo equity del muro STAT-MODE a $600). + - Niente dati INTRADAY -> scalping non backtestabile. + - IB Gateway in questo ambiente e' instabile (timeout ordini diagnosticato) -> niente HFT affidabile. + + uv run python scripts/research/eq_meanrev_ib.py +""" +from __future__ import annotations +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +import numpy as np +import pandas as pd +import requests + +from eqlib import load_eq # type: ignore + +SQ = np.sqrt(252) +H = {"User-Agent": "Mozilla/5.0"} + + +# ----------------------------- 1) CHECK DATI DALLA RETE ----------------------------- +def yahoo_daily(sym: str, rng: str = "1y") -> pd.DataFrame: + u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range={rng}&interval=1d" + j = requests.get(u, headers=H, timeout=30).json()["chart"]["result"][0] + ts = pd.to_datetime(j["timestamp"], unit="s", utc=True).normalize() + q = j["indicators"]["quote"][0] + adj = j["indicators"].get("adjclose", [{}])[0].get("adjclose", q["close"]) + return pd.DataFrame({"close": q["close"], "adjclose": adj}, index=ts).dropna() + + +def cross_check(syms): + print("=" * 90) + print(" 1) CHECK DATI DALLA RETE — IB certificato (eq_*, ADJUSTED) vs Yahoo adjclose (rete indip.)") + print("=" * 90) + print(f" {'sym':5} {'n com':>6} {'ret maxΔ':>9} {'ret medΔ':>9} {'last IB':>10} {'last YHOO':>10} {'Δbps':>7} esito") + for s in syms: + try: + ib = load_eq(s)["close"].astype(float) + ib.index = ib.index.normalize() + yh = yahoo_daily(s)["adjclose"] # adjusted vs adjusted (apples-to-apples) + J = pd.concat({"ib": ib, "yh": yh}, axis=1, join="inner").dropna().tail(180) + if len(J) < 20: + print(f" {s:5} overlap insufficiente"); continue + R = pd.concat({"a": J["ib"].pct_change(), "b": J["yh"].pct_change()}, + axis=1, join="inner").dropna() + d = (R["a"] - R["b"]).abs() + last_bps = abs(J["ib"].iloc[-1] / J["yh"].iloc[-1] - 1) * 1e4 + ok = d.max() < 0.001 and last_bps < 20 # ret entro 10bps, last entro 20bps + print(f" {s:5} {len(J):>6} {d.max()*1e4:>8.1f}b {d.median()*1e4:>8.1f}b " + f"{J['ib'].iloc[-1]:>10.2f} {J['yh'].iloc[-1]:>10.2f} {last_bps:>6.1f}b " + f"{'CONCORDE' if ok else 'DIVERGENTE -> INDAGARE'}") + except Exception as e: + print(f" {s:5} ERRORE: {repr(e)[:80]}") + print(" -> confronto sui RENDIMENTI adjusted-vs-adjusted: devono combaciare a pochi bps. Una") + print(" divergenza non spiegata = feed sospetto, NON tradare prima di averla capita (v2.0.0).") + + +# ----------------------------- 2) MEAN-REVERSION "SOTTOQUOTATA" ----------------------------- +def rsi(close: np.ndarray, n: int) -> np.ndarray: + d = np.diff(close, prepend=close[0]) + up = pd.Series(np.where(d > 0, d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values + dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1 / n, adjust=False).mean().values + rs = np.divide(up, dn, out=np.full_like(up, np.inf), where=dn > 0) + return 100 - 100 / (1 + rs) + + +def mr_target(close: np.ndarray, entry=10.0, exit_ma=5, trend=200) -> np.ndarray: + """Posizione 0/1 DECISA a close[i] (Connors RSI2): entra se sottoquotata (RSI2MA{trend}); esci quando close>MA{exit_ma}. Causale: usa solo dati <= close[i].""" + r2 = rsi(close, 2) + ma_t = pd.Series(close).rolling(trend).mean().values + ma_x = pd.Series(close).rolling(exit_ma).mean().values + pos = np.zeros(len(close)); inpos = False + for i in range(len(close)): + if not np.isfinite(ma_t[i]): + continue + if not inpos and close[i] > ma_t[i] and r2[i] < entry: + inpos = True + elif inpos and close[i] > ma_x[i]: + inpos = False + pos[i] = 1.0 if inpos else 0.0 + return pos + + +def backtest(sym: str, fee_bps=3.0, **kw) -> pd.Series: + df = load_eq(sym); close = df["close"].astype(float).values + idx = df.index + tgt = mr_target(close, **kw) + r = np.zeros(len(close)); r[1:] = close[1:] / close[:-1] - 1.0 + held = np.zeros(len(tgt)); held[1:] = tgt[:-1] # decisa a close[i-1], tenuta in i + turn = np.abs(np.diff(held, prepend=0.0)) + net = held * r - (fee_bps / 1e4) * turn + return pd.Series(net, index=idx) + + +def metrics(daily: pd.Series, lo=None): + if lo is not None: + daily = daily[daily.index >= lo] + rr = daily.values + sh = float(np.mean(rr) / np.std(rr) * SQ) if np.std(rr) > 0 else 0.0 + eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0 + cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0 + expo = float((daily != 0).mean()) + return dict(sharpe=sh, dd=dd, cagr=cagr, expo=expo) + + +def buyhold(sym, lo=None): + df = load_eq(sym); c = df["close"].astype(float) + r = c.pct_change().fillna(0.0) + return metrics(r, lo=lo) + + +def run_meanrev(syms, holdout="2015-01-01"): + HO = pd.Timestamp(holdout, tz="UTC") + print("\n" + "=" * 90) + print(f" 2) MEAN-REVERSION 'SOTTOQUOTATA' (RSI2<10 + filtro MA200, exit MA5). Hold-out {holdout}.") + print("=" * 90) + print(f" {'sym':5} | {'FULL Sh':>7} {'DD':>6} {'CAGR':>6} {'expo':>5} | " + f"{'HOLD Sh':>7} {'HOLD CAGR':>9} | {'B&H Sh':>6} {'B&H HOLD':>8}") + for s in syms: + net = backtest(s) + f = metrics(net); h = metrics(net, lo=HO) + bh = buyhold(s); bhh = buyhold(s, lo=HO) + print(f" {s:5} | {f['sharpe']:>+7.2f} {f['dd']*100:>5.0f}% {f['cagr']*100:>+5.0f}% " + f"{f['expo']*100:>4.0f}% | {h['sharpe']:>+7.2f} {h['cagr']*100:>+8.0f}% | " + f"{bh['sharpe']:>+6.2f} {bhh['sharpe']:>+7.2f}") + print(" expo = % giorni investito. B&H = buy&hold sullo stesso ETF (il benchmark onesto).") + + print("\n Sweep fee (SPY) — quanto regge ai costi reali:") + for fb in (0.0, 3.0, 5.0, 10.0): + f = metrics(backtest("SPY", fee_bps=fb)); h = metrics(backtest("SPY", fee_bps=fb), lo=HO) + print(f" fee {fb:>4.1f}bps RT: FULL Sh {f['sharpe']:+.2f} HOLD Sh {h['sharpe']:+.2f}") + + +def main(): + cross_check(["SPY", "QQQ", "IWM", "GLD", "TLT", "HYG"]) + run_meanrev(["SPY", "QQQ", "IWM", "DIA", "EEM"]) + print("\n" + "=" * 90) + print(" 3) ESEGUIBILITA' (il muro)") + print("=" * 90) + print(" - PDT RULE: day-trading azioni US < $25k = max 3 day-trade/5gg -> SCALPING BLOCCATO al") + print(" capitale del progetto. E' l'analogo equity del muro STAT-MODE a $600 sul crypto.") + print(" - Dati INTRADAY assenti (solo eq_*_1d) -> lo scalping non e' backtestabile, solo lo swing MR.") + print(" - IB Gateway instabile in questo ambiente (timeout ordini) -> niente HFT affidabile.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_eq_meanrev.py b/tests/test_eq_meanrev.py new file mode 100644 index 0000000..280f82a --- /dev/null +++ b/tests/test_eq_meanrev.py @@ -0,0 +1,51 @@ +"""Lock 2026-06-26 (equity MR + check rete): la mean-reversion "sottoquotata" daily NON batte il +buy&hold, e il check dati dalla rete (IB vs Yahoo) deve concordare adjusted-vs-adjusted. +Diario docs/diary/2026-06-26-equity-meanrev-network-check.md.""" +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from eq_meanrev_ib import rsi, mr_target, backtest, metrics, buyhold, yahoo_daily # type: ignore +from eqlib import load_eq # type: ignore + +import pandas as pd + + +def test_rsi_in_bounds(): + c = np.cumprod(1 + np.linspace(-0.02, 0.02, 300)) + r = rsi(c, 2) + assert np.nanmin(r) >= 0.0 and np.nanmax(r) <= 100.0 + + +def test_mr_target_binary_and_has_trades(): + """La posizione è 0/1 ed entra solo da sottoquotata (genera scambi, exposure < 50%).""" + c = load_eq("SPY")["close"].astype(float).values + pos = mr_target(c) + assert set(np.unique(pos)).issubset({0.0, 1.0}) + expo = pos.mean() + assert 0.02 < expo < 0.5 # mean-reversion = poco investito + + +def test_meanrev_does_not_beat_buyhold_holdout(): + """Verdetto onesto: la MR daily NON batte il buy&hold risk-adjusted nel hold-out su SPY.""" + HO = pd.Timestamp("2015-01-01", tz="UTC") + mr = metrics(backtest("SPY"), lo=HO)["sharpe"] + bh = buyhold("SPY", lo=HO)["sharpe"] + assert mr <= bh + 0.05 # non supera il benchmark (entro rumore) + + +@pytest.mark.network +def test_ib_feed_concords_with_network_source(): + """Check dati dalla rete: i rendimenti IB (adjusted) concordano con Yahoo adjclose a pochi bps.""" + ib = load_eq("SPY")["close"].astype(float); ib.index = ib.index.normalize() + yh = yahoo_daily("SPY")["adjclose"] + J = pd.concat({"a": ib, "b": yh}, axis=1, join="inner").dropna().tail(120) + d = (J["a"].pct_change() - J["b"].pct_change()).abs().dropna() + assert d.max() < 0.001 # <10bps adjusted-vs-adjusted + assert abs(J["a"].iloc[-1] / J["b"].iloc[-1] - 1) < 0.002 From a158d0e2aea383c438f97ae49676413fa830fb63 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 26 Jun 2026 19:51:22 +0000 Subject: [PATCH 2/4] research(equity): screener short 'fond/news neg ma prezzo su' (forward, edge non provato) Goal chiarito: short su titoli con fondamentali/notizie negativi ma prezzo in salita. Gate dati (v2.0.0): NON backtestabile -> fondamentali da rete = snapshot correnti, applicarli a prezzi passati = look-ahead. Come la term-structure: solo screener forward. Costruito screener da dati di rete tokenless: fondamentali strutturati Yahoo (quoteSummary via crumb: recommendationMean/surprise/revGrowth/sell-skew) + sentiment headline + momentum 1m/3m. SHORT cand = (fond/news neg) AND prezzo su. Run live oggi: nessun candidato (mercato in flessione ampia -> gamba 'prezzo su' non scatta). Intuizione chiave: shortare la FORZA combatte momentum + PEAD; il rialzo 'malgrado' brutte notizie spesso prezza info che i fondamentali trailing non hanno -> e' la versione contrarian/rischiosa dell'anomalia. La versione pulita = short su fondamentali deboli quando il prezzo CONFERMA (scende), non quando diverge. Eseguibilita': borrow/squeeze/perdita illimitata/PDT 5k/IB instabile/00 -> non deployabile. - scripts/research/eq_fundnews_short.py (+ forward log data/raw/fundnews_short_screen.parquet) - tests/test_eq_fundnews_short.py (scoring puro offline) - docs/diary/2026-06-26-equity-fundnews-short.md Co-Authored-By: Claude Opus 4.8 (1M context) --- .../diary/2026-06-26-equity-fundnews-short.md | 58 ++++++ scripts/research/eq_fundnews_short.py | 194 ++++++++++++++++++ tests/test_eq_fundnews_short.py | 40 ++++ 3 files changed, 292 insertions(+) create mode 100644 docs/diary/2026-06-26-equity-fundnews-short.md create mode 100644 scripts/research/eq_fundnews_short.py create mode 100644 tests/test_eq_fundnews_short.py diff --git a/docs/diary/2026-06-26-equity-fundnews-short.md b/docs/diary/2026-06-26-equity-fundnews-short.md new file mode 100644 index 0000000..3a9a068 --- /dev/null +++ b/docs/diary/2026-06-26-equity-fundnews-short.md @@ -0,0 +1,58 @@ +# 2026-06-26 — "Short su fondamentali/notizie NEG ma prezzo SU": screener forward, edge non provato + +**Goal utente (chiarito):** se i dati finanziari/notizie di un'azienda sono negativi ma la +quotazione è positiva (sale), andare SHORT — scommessa che il prezzo scenda a riallinearsi ai +fondamentali. Branch `research/equity-meanrev-ib`, script `scripts/research/eq_fundnews_short.py`. + +## Gate dati (lezione v2.0.0) — NON backtestabile + +I fondamentali e le notizie scaricabili dalla rete (Yahoo) sono **snapshot CORRENTI**, non +point-in-time storici. Applicarli a prezzi passati = **look-ahead** (restatement + survivorship): +esattamente la classe di errore che ha prodotto la libreria fasulla v2.0.0. Un backtest onesto +richiede un DB point-in-time (Compustat PIT, news storiche con timestamp) — **assente**. Quindi, +come per la vol term-structure: **niente backtest**, l'unica via onesta è uno **screener forward**. + +## Cosa è scaricabile (feasibility, fatto) + +- **Fondamentali strutturati** via Yahoo quoteSummary (flusso cookie+**crumb**, tokenless): + `recommendationMean` (1=buy..5=sell), `revenueGrowth`, `earningsHistory` surprise%, + `recommendationTrend` (skew buy/sell). ✅ +- **Notizie**: Yahoo news search (headline + publisher), sentiment lessicale crudo. ✅ (proxy debole) +- **Momentum**: chart API (return 1m/3m). ✅ + +## Screener costruito + eseguito dal vivo + +`fund_neg` ∈ [0,1] (media di: recMean→sell, surprise negative, ricavi in calo, analisti a sell) + +`news_neg` lessicale + momentum. **SHORT candidate = (fond/news neg) AND prezzo in salita** (la +divergenza). Idempotente, logga forward su `data/raw/fundnews_short_screen.parquet`. + +**Run di oggi (2026-06-26): NESSUN candidato.** Quasi tutto l'universo ha momentum 1m **negativo** +(mercato in flessione ampia, coerente col bear) → la gamba "prezzo che sale" non scatta. I peggiori +per fondamentali (TSLA 0.48, BA 0.39, NKE 0.38) stanno comunque *scendendo*. Output sensato. + +## L'intuizione analitica chiave (il valore vero di questa analisi) + +La premessa ha una **tensione interna** che la rende la versione *rischiosa* dell'anomalia: +- **Shortare un prezzo che SALE combatte il momentum** — una delle anomalie più robuste e persistenti + (i winner continuano a vincere su 3-12 mesi). Si scommette contro di essa. +- **Il PEAD** (post-earnings drift) dice che dopo cattive notizie il prezzo continua a **scendere**; + ma qui il prezzo *sale malgrado* le cattive notizie → spesso significa che il mercato sta + **prezzando informazione** che i fondamentali *trailing* non contengono (turnaround, M&A, squeeze). +- Quindi: la versione *pulita* dell'edge "short su fondamentali deboli" funziona meglio quando il + prezzo **conferma** (fondamentali deboli + prezzo che SCENDE = momentum e fondamentali allineati), + **non** quando diverge. La richiesta "shorta il titolo che sale su brutte notizie" è il caso + contrarian, più difficile e storicamente più punito (squeeze, perdita illimitata). + +## Eseguibilità (muro) + +Short = **borrow** (locate + fee; hard-to-borrow caro/assente), **perdita illimitata**, **squeeze**, +**PDT $25k** per i day-trade, IB instabile qui, $600, universo single-stock (non i nostri ETF). NON +deployabile. + +## Verdetto + +**Non un edge schierabile, né dimostrabile ora.** Deliverable onesto = lo **screener forward** +(funzionante, da dati di rete) + il log che accumula i candidati per una validazione futura, con +la chiara avvertenza che l'edge è **non provato** e che la *direzione contrarian* della scommessa +(short sulla forza) è la parte fragile. Strumento utile (fond+news+momentum da rete in un colpo), +tesi da maneggiare con scetticismo. Nessun impatto sul book live (branch separato). diff --git a/scripts/research/eq_fundnews_short.py b/scripts/research/eq_fundnews_short.py new file mode 100644 index 0000000..662d3c1 --- /dev/null +++ b/scripts/research/eq_fundnews_short.py @@ -0,0 +1,194 @@ +"""EQ-FUNDNEWS-SHORT — "fondamentali/notizie NEGATIVI ma prezzo SU -> short". Screener FORWARD. + +Idea utente: se i dati finanziari/notizie di un'azienda sono negativi ma la quotazione e' positiva +(sale), andare SHORT (scommessa che il prezzo scenda a riallinearsi ai fondamentali). + +GATE DATI (lezione v2.0.0, PRIMA della strategia): NON backtestabile su dati certi. + - I fondamentali da rete (Yahoo) sono SNAPSHOT CORRENTI, non point-in-time storici. Applicarli a + prezzi passati = LOOK-AHEAD (restatement/survivorship). E' la trappola che ha creato la libreria + fasulla v2.0.0. -> nessun backtest. Serve un DB point-in-time (Compustat PIT / news storiche), assente. + - Quindi: come per la vol term-structure, l'unica via onesta e' uno SCREENER FORWARD che genera i + candidati short OGGI da dati di rete e li LOGGA in avanti. L'edge resta NON PROVATO finche' non + accumulato e validato forward. Questo script NON afferma un edge: produce candidati + li registra. + +SEGNALE (tutto da rete, tokenless): + - "dati finanziari negativi" = score strutturato da Yahoo quoteSummary (crumb flow): + recommendationMean alto (->sell), earnings surprise recenti negative, revenueGrowth<0, + recommendationTrend sbilanciato a sell. + - "notizie negative" = sentiment lessicale crudo sulle headline (Yahoo news search). + - "quotazione positiva" = momentum 1m/3m > 0 (chart API). + -> SHORT candidate = (fond_neg alto OPPURE news_neg alto) AND momentum positivo (la DIVERGENZA). + +ESEGUIBILITA' (muro): shortare richiede BORROW (locate+fee, hard-to-borrow caro/assente), perdita +illimitata, squeeze; PDT $25k per i day-trade; IB instabile qui; $600 di capitale; universo single-stock +(non i nostri ETF). Shortare la FORZA combatte il momentum (anomalia forte) -> premessa rischiosa. + + uv run python scripts/research/eq_fundnews_short.py +""" +from __future__ import annotations +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +import numpy as np +import pandas as pd +import requests + +RAW = ROOT / "data" / "raw" +H = {"User-Agent": "Mozilla/5.0"} + +# universo dimostrativo: large/mid cap liquide, vari settori +UNIVERSE = ["AAPL", "MSFT", "NVDA", "TSLA", "META", "AMZN", "INTC", "F", "GM", "BA", + "DIS", "PYPL", "NKE", "PFE", "T", "WBA", "XOM", "KO", "CVX", "AMD"] + +NEG_WORDS = {"downgrade", "miss", "missed", "cut", "cuts", "lawsuit", "probe", "fraud", "plunge", + "plunges", "warn", "warns", "warning", "slump", "loss", "losses", "weak", "weakness", + "decline", "declines", "fall", "falls", "drop", "sinks", "slashes", "recall", "halt", + "bankruptcy", "default", "layoff", "layoffs", "sell-off", "bearish", "underperform"} +POS_WORDS = {"beat", "beats", "upgrade", "surge", "surges", "record", "strong", "raise", "raises", + "soar", "soars", "rally", "jumps", "tops", "bullish", "outperform", "growth"} + + +def yahoo_session(): + s = requests.Session(); s.headers.update(H) + s.get("https://fc.yahoo.com/", timeout=20) + crumb = s.get("https://query2.finance.yahoo.com/v1/test/getcrumb", timeout=20).text.strip() + return s, crumb + + +def fund_neg_score(rec_mean, surp, rev_g, sell_skew): + """Pura: score di negativita' fondamentale [0..1] dai componenti disponibili (media).""" + comp = [] + if rec_mean is not None: + comp.append(float(np.clip((rec_mean - 1) / 4, 0, 1))) # 1=buy ->0, 5=sell ->1 + if surp: + comp.append(1.0 if np.mean(surp[:2]) < 0 else 0.0) # ultime 2 surprise negative + if rev_g is not None: + comp.append(1.0 if rev_g < 0 else float(max(0.0, 1 - rev_g * 5))) + if sell_skew is not None: + comp.append(float(np.clip(sell_skew * 3, 0, 1))) + return float(np.mean(comp)) if comp else None + + +def headline_sentiment(titles): + """Pura: frazione di sentiment negativo sulle headline (lessico crudo). None se nessun hit.""" + neg = pos = 0 + for t in titles: + w = set(t.lower().replace(",", " ").replace(".", " ").split()) + neg += len(w & NEG_WORDS); pos += len(w & POS_WORDS) + tot = neg + pos + return (neg / tot) if tot else 0.0, neg, pos + + +def momentum(sym): + u = f"https://query1.finance.yahoo.com/v8/finance/chart/{sym}?range=3mo&interval=1d" + j = requests.get(u, headers=H, timeout=25).json()["chart"]["result"][0] + c = pd.Series(j["indicators"]["quote"][0]["close"]).dropna().values + if len(c) < 25: + return None + m1 = c[-1] / c[-21] - 1.0 # ~1 mese + m3 = c[-1] / c[0] - 1.0 # ~3 mesi + return dict(last=float(c[-1]), mom_1m=float(m1), mom_3m=float(m3)) + + +def fundamentals(s, crumb, sym): + mods = "financialData,recommendationTrend,earningsHistory" + u = f"https://query2.finance.yahoo.com/v10/finance/quoteSummary/{sym}?modules={mods}&crumb={crumb}" + res = s.get(u, timeout=25).json()["quoteSummary"]["result"][0] + fd = res.get("financialData", {}) + rec_mean = fd.get("recommendationMean", {}).get("raw") + rev_g = fd.get("revenueGrowth", {}).get("raw") + eh = res.get("earningsHistory", {}).get("history", []) + surp = [h.get("surprisePercent", {}).get("raw") for h in eh if h.get("surprisePercent")] + rt = res.get("recommendationTrend", {}).get("trend", []) + sell_skew = None + if rt: + t = rt[0]; tot = sum(t.get(k, 0) for k in ("strongBuy", "buy", "hold", "sell", "strongSell")) + sell_skew = (t.get("sell", 0) + t.get("strongSell", 0)) / tot if tot else None + return dict(fund_neg=fund_neg_score(rec_mean, surp, rev_g, sell_skew), + rec_mean=rec_mean, rev_growth=rev_g, last_surprise=surp[0] if surp else None, + sell_skew=sell_skew) + + +def news_sentiment(sym): + u = f"https://query1.finance.yahoo.com/v1/finance/search?q={sym}&newsCount=8"esCount=0" + news = requests.get(u, headers=H, timeout=25).json().get("news", []) + if not news: + return dict(news_neg=None, n=0) + nn, neg, pos = headline_sentiment([n.get("title", "") for n in news]) + return dict(news_neg=nn, n=len(news), neg_hits=neg, pos_hits=pos) + + +def screen(universe): + s, crumb = yahoo_session() + rows = [] + for sym in universe: + try: + mom = momentum(sym) + if mom is None: + continue + fun = fundamentals(s, crumb, sym) + nw = news_sentiment(sym) + fneg = fun["fund_neg"] + nneg = nw["news_neg"] + rising = mom["mom_1m"] > 0.02 # quotazione positiva + fund_bad = fneg is not None and fneg >= 0.5 + news_bad = nneg is not None and nneg >= 0.5 + short_cand = rising and (fund_bad or news_bad) + rows.append(dict(sym=sym, mom_1m=mom["mom_1m"], mom_3m=mom["mom_3m"], + fund_neg=fneg, news_neg=nneg, rec_mean=fun["rec_mean"], + rev_growth=fun["rev_growth"], last_surprise=fun["last_surprise"], + short_cand=short_cand)) + time.sleep(0.3) + except Exception as e: + rows.append(dict(sym=sym, error=repr(e)[:60])) + return pd.DataFrame(rows) + + +def main(): + print("=" * 100) + print(" EQ-FUNDNEWS-SHORT — divergenza fondamentali/notizie NEG vs prezzo SU -> short candidate") + print(" (SCREENER FORWARD da dati di rete — NON un backtest: edge non provato, vedi header)") + print("=" * 100) + df = screen(UNIVERSE) + ok = df[df.get("error").isna()] if "error" in df else df + ok = ok.sort_values("fund_neg", ascending=False, na_position="last") + print(f" {'sym':5} {'mom1m':>7} {'mom3m':>7} {'fund_neg':>8} {'news_neg':>8} " + f"{'recMean':>7} {'revGr':>7} {'surp':>7} SHORT?") + for _, r in ok.iterrows(): + fn = f"{r['fund_neg']:.2f}" if pd.notna(r['fund_neg']) else " n/a" + nn = f"{r['news_neg']:.2f}" if pd.notna(r['news_neg']) else " n/a" + rm = f"{r['rec_mean']:.2f}" if pd.notna(r['rec_mean']) else " n/a" + rg = f"{r['rev_growth']*100:+.0f}%" if pd.notna(r['rev_growth']) else " n/a" + sp = f"{r['last_surprise']*100:+.0f}%" if pd.notna(r['last_surprise']) else " n/a" + flag = " <<< SHORT" if r["short_cand"] else "" + print(f" {r['sym']:5} {r['mom_1m']*100:>+6.1f}% {r['mom_3m']*100:>+6.1f}% {fn:>8} {nn:>8} " + f"{rm:>7} {rg:>7} {sp:>7}{flag}") + cands = ok[ok["short_cand"]]["sym"].tolist() + print(f"\n CANDIDATI SHORT oggi (fond/news neg + prezzo su): {cands or 'nessuno'}") + + # log forward (idempotente per giorno) + today = pd.Timestamp.now("UTC").normalize() + ok2 = ok.copy(); ok2.insert(0, "date", today) + fp = RAW / "fundnews_short_screen.parquet" + hist = pd.read_parquet(fp) if fp.exists() else pd.DataFrame() + if len(hist): + hist = hist[hist["date"] != today] + pd.concat([hist, ok2], ignore_index=True).to_parquet(fp, index=False) + print(f" -> snapshot loggato in {fp.name} (forward dataset; serve accumulo+validazione)") + + print("\n" + "=" * 100) + print(" ONESTA' / ESEGUIBILITA'") + print("=" * 100) + print(" - NON backtestabile: fondamentali = snapshot correnti, non point-in-time -> look-ahead (v2.0.0).") + print(" - Premessa RISCHIOSA: shortare un prezzo che SALE combatte il momentum (anomalia forte);") + print(" il rialzo 'malgrado' notizie cattive spesso PREZZA info che i fondamentali trailing non hanno.") + print(" - Eseguibilita': borrow (locate/fee, hard-to-borrow), perdita illimitata, squeeze, PDT $25k,") + print(" IB instabile, $600. -> NON deployabile. Deliverable = screener forward + log, edge da provare.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_eq_fundnews_short.py b/tests/test_eq_fundnews_short.py new file mode 100644 index 0000000..5d9c83e --- /dev/null +++ b/tests/test_eq_fundnews_short.py @@ -0,0 +1,40 @@ +"""Lock 2026-06-26 (fond/news short screener): scoring puro testabile offline. La strategia NON e' +backtestabile (fondamentali snapshot = look-ahead) -> deliverable = screener forward + verdetto. +Diario docs/diary/2026-06-26-equity-fundnews-short.md.""" +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from eq_fundnews_short import fund_neg_score, headline_sentiment, UNIVERSE # type: ignore + + +def test_fund_neg_high_for_bad_company(): + """recMean verso sell + surprise negative + ricavi in calo + analisti a sell -> score alto.""" + s = fund_neg_score(rec_mean=4.5, surp=[-0.10, -0.05], rev_g=-0.08, sell_skew=0.5) + assert s > 0.7 + + +def test_fund_neg_low_for_healthy_company(): + """recMean buy + surprise positive + ricavi su + nessun sell -> score basso.""" + s = fund_neg_score(rec_mean=1.5, surp=[0.10, 0.06], rev_g=0.20, sell_skew=0.0) + assert s < 0.2 + + +def test_fund_neg_none_when_no_data(): + assert fund_neg_score(None, None, None, None) is None + + +def test_headline_sentiment_directions(): + neg, n_, p_ = headline_sentiment(["Stock plunges as company warns and cuts guidance"]) + pos, _, _ = headline_sentiment(["Shares surge as earnings beat, analysts upgrade"]) + neutral, _, _ = headline_sentiment(["Company holds annual shareholder meeting"]) + assert neg > 0.6 and pos < 0.4 and neutral == 0.0 + + +def test_universe_nonempty(): + assert len(UNIVERSE) >= 10 From aad69f979050418334f366d63074221d7fced44a Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 29 Jun 2026 19:48:36 +0000 Subject: [PATCH 3/4] =?UTF-8?q?research(crypto):=204=20filoni=202026-06-29?= =?UTF-8?q?=20=E2=80=94=20ERM=20lead=20sub-daily=20(forward),=203=20scarta?= =?UTF-8?q?ti/deboli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ricerca onesta su BTC/ETH + universo HL, branch separato (nessun impatto live). Harness condiviso altlib (causale, fee 0.10% RT, marginal vs TP01, day-boundary, haircut $600). Test 19/19 verdi. - A DVOL direzionale -> LEAD hedge/DD-dampener, NON sleeve (buy-the-fear; is_hedge). - B Intraday ERM 8h -> LEAD forte / forward-monitor: earns_slot=True, ADDS oltre SKH01 (TP01+SKH+ERM 60/25/15 FULL 1.88/HOLD 1.46/DD 8.9%). Caveat: plateau hold-out single-row, multiple-testing non deflazionato, exec 8h. Controllo TOD = FAIL atteso. - C Cross-sectional non-mom (low-vol HL) -> DEBOLE/forward-monitor (deflated-Sh 0.13, storia 2.5a, non eseguibile $600) STAT-MODE. - D Macro regime-gate -> RIDONDANTE col trend (corr->TP01 0.989), SCARTATO. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-29-dvol-directional.md | 118 ++++++ docs/diary/2026-06-29-intraday-regime.md | 106 +++++ docs/diary/2026-06-29-macro-regime-gate.md | 71 ++++ docs/diary/2026-06-29-xsec-v2-nonmom.md | 65 +++ scripts/research/dvol_directional.py | 352 ++++++++++++++++ scripts/research/intraday_regime.py | 376 +++++++++++++++++ scripts/research/macro_regime_gate.py | 458 +++++++++++++++++++++ scripts/research/xsec_v2_nonmom.py | 433 +++++++++++++++++++ tests/test_dvol_directional.py | 57 +++ tests/test_intraday_regime.py | 59 +++ tests/test_macro_regime_gate.py | 90 ++++ tests/test_xsec_v2.py | 98 +++++ 12 files changed, 2283 insertions(+) create mode 100644 docs/diary/2026-06-29-dvol-directional.md create mode 100644 docs/diary/2026-06-29-intraday-regime.md create mode 100644 docs/diary/2026-06-29-macro-regime-gate.md create mode 100644 docs/diary/2026-06-29-xsec-v2-nonmom.md create mode 100644 scripts/research/dvol_directional.py create mode 100644 scripts/research/intraday_regime.py create mode 100644 scripts/research/macro_regime_gate.py create mode 100644 scripts/research/xsec_v2_nonmom.py create mode 100644 tests/test_dvol_directional.py create mode 100644 tests/test_intraday_regime.py create mode 100644 tests/test_macro_regime_gate.py create mode 100644 tests/test_xsec_v2.py diff --git a/docs/diary/2026-06-29-dvol-directional.md b/docs/diary/2026-06-29-dvol-directional.md new file mode 100644 index 0000000..09fdeca --- /dev/null +++ b/docs/diary/2026-06-29-dvol-directional.md @@ -0,0 +1,118 @@ +# 2026-06-29 — DVOL come segnale DIREZIONALE/REGIME su BTC/ETH: l'unico edge è un HEDGE, non alpha + +**Filone A — "DVOL-direzionale".** Tesi: usare la vol IMPLICITA Deribit (DVOL, `data/raw/dvol_*.parquet`, +2021-03→oggi) come **segnale direzionale o di regime** sul perp BTC/ETH (long-flat o L/S sul LIVELLO di +mercato), non come denominatore del vol-target. Script `scripts/research/dvol_directional.py`, test +`tests/test_dvol_directional.py`. Branch `research/equity-meanrev-ib` (separato, nessun impatto live). + +## Cosa è diverso dal lavoro DVOL precedente + +- `tp01_dvol_overlay.py` (2026-06-26): DVOL come **denominatore** del vol-target → solo de-levering, SCARTATO. +- VOL03/04/10/11 (sweep alt 100-agent): DVOL che **gata/scala un TSMOM** → eredita lo Sharpe di trend di + TP01 (il marginal scorer li boccia NEUTRAL/REDUNDANT). +- `agent_14_dvol_spread` (onda ortho): IV **relativa BTC-vs-ETH**, market-NEUTRAL 2-leg (l'unico LEAD vivo). + +Qui invece: DVOL/IV-RV come segnale **direzionale STANDALONE sul livello di mercato**, per vedere se porta +alpha direzionale **ortogonale** a TP01 (non un overlay sul trend). Tutto causale (decisione ≤ `close[i]`, +tenuta in `i+1` dallo shift di `eval_weights`), fee 0.10% RT, vol-target 20%, leva cap 2x, harness onesto +condiviso `altlib`. Allineamento DVOL: `al.dvol` (merge_asof backward) → DVOL close del giorno *t* (≈ +contemporaneo a `close[i]`), noto a decisione; verificato leak-free (`causality_ok` max_tail_diff 0.0). + +DVOL parte 2021-03 → pre-DVOL il segnale è flat. Valuto sia **FULL** (col flat pre-2021, deflaziona lo +Sharpe) sia **ERA-only** (da 2021-10, equo per la tesi). + +## 1) Probe direzionale (corr segnale[i] vs ritorno[i+1], pooled BTC+ETH, era DVOL) + +| segnale | corr | long bps | flat bps | **edge bps** | frac_long | +|---|---|---|---|---|---| +| VRP-Z+ (long VRP ricco, IV-RV alto) | +0.010 | +7.8 | −0.9 | **+8.6** | 0.52 | +| VRP-Z− (flip) | −0.017 | −0.9 | +7.8 | −8.6 | 0.48 | +| **DVOL-LV fear (rank>0.5 → long)** | **+0.024** | +20.4 | +0.9 | **+19.5** | 0.14 | +| DVOL-LV calm (rank<0.5 → long) | −0.013 | +0.8 | +21.2 | −20.4 | 0.86 | +| DVOL-MOM (DVOL in calo → long) | −0.041 | −10.0 | +22.9 | **−32.9** | 0.59 | +| VRP>0 (quasi buy&hold) | +0.003 | +5.3 | −0.7 | +5.9 | 0.72 | + +Lettura: l'unico contenuto direzionale reale è **"buy-the-fear"** (long quando il DVOL è alto nel suo +percentile espandente: edge +19.5 bps il giorno dopo). Il VRP-ricco→long è coerente ma debolissimo (+8.6). +Il "DVOL in calo → long" è **negativo** (−33 bps): comprare quando la paura scende è perdente (regola +falsificata). Nota: il percentile espandente è dominato dai picchi 2021-22 → "fear" scatta solo nel 14% +delle barre (gli spike veri), non metà del tempo. + +## 2) Robustezza assoluta (study_weights 1d, fee sweep 0.00-0.20% RT) — ERA-only il giudizio equo + +| segnale | abs | minFull Sh | minHold Sh | feeOK | **ERA Sh** | ERA DD | ERA CAGR | +|---|---|---|---|---|---|---|---| +| **DVOL-fear q0.4 LF** | WEAK | +0.39 | +0.14 | sì | **+0.59** | **8%** | +5% | +| VRP-Z60 LF | FAIL | +0.42 | −0.39 | sì | +0.46 | 23% | +7% | +| DVOL-fear q0.5 LF | FAIL | +0.31 | −0.17 | sì | +0.40 | 10% | +3% | +| VRP-Z90 LF | FAIL | +0.32 | −0.26 | sì | +0.32 | 29% | +4% | +| VRP-Z90 L/S | FAIL | +0.25 | +0.32 | sì | +0.31 | 28% | +3% | +| DVOL-calm q0.5 LF | FAIL | +0.00 | −1.05 | no | +0.09 | 37% | −0% | +| DVOL-MOM k10/k20 LF | FAIL | −0.6/−0.7 | <−1 | no | −0.6/−0.7 | ~40% | ~−10% | +| DVOL-MOM k10 L/S | FAIL | −1.16 | −1.29 | no | −1.23 | 72% | −23% | + +Nessun segnale supera lo Sharpe assoluto in **valore** (tutti < 0.6, soffitto direzionale BTC/ETH ~1.3 +confermato). Unico con profilo decente: **DVOL-fear q0.4 long-flat** (ERA Sh +0.59, DD **8%**) — il DD +bassissimo perché è long solo nei picchi di paura (poche barre, mean-reversion). Le famiglie momentum e +"buy-the-calm" sono nettamente negative e non sopravvivono nemmeno alle fee. + +## 3) Marginal vs TP01 (il gate vero) — DVOL-fear q0.4 long-flat + +| | corr→TP01 | in-sample Sh | uplift_full w25 | uplift_hold w25 | multi-cut persistente | is_hedge | verdetto | +|---|---|---|---|---|---|---|---| +| **ERA-only** (2021-10+) | 0.37 | 0.54 (has_edge) | +0.06 | +0.17 | sì | **No** | **ADDS** (earns_slot=True) | +| **FULL** (gate ufficiale) | 0.28 | 0.64 (has_edge) | +0.03 | +0.17 | sì | **Sì** | **HEDGE** (earns_slot=**False**) | + +Il candidato ha tutto ciò che serve a un nuovo sleeve **tranne una cosa**: il gate canonico full-history +lo classifica **HEDGE**, non alpha. `hedge_yearly_corr(TP01-Sh, uplift) = −0.76`, `uplift TP01-up −0.003` +/ `uplift TP01-down +0.194` → **paga SOLO quando TP01 è debole**. Ha perfettamente senso: il DVOL esplode +durante i crash, proprio quando il trend di TP01 è flat/in-drawdown; comprare la paura cattura il rimbalzo +e smorza il DD del portafoglio, ma **non è un premio permanente**. Il blend 0.75·TP01 + 0.25·cand alza +l'hold-out 0.30→**0.47** (w50 → 0.62) e taglia il DD a ~11%/8% — un effetto **drawdown-dampener** reale, +ma classificato (giustamente) come hedge. + +Perché ERA dice ADDS e FULL dice HEDGE: il rivelatore di hedge richiede ≥3 anni; sulla finestra ERA +(2021-10+) la `corr(TP01-Sh annuale, uplift)` è meno netta e non scatta. **Il gate full-history è quello +giusto** (più dati per il test di hedge) → mi attengo a quello: **HEDGE, non alpha.** + +Gli altri tre candidati (VRP-Z60/90, DVOL-fear q0.5): **NEUTRAL o HEDGE**, mai ADDS. I VRP-Z hanno beta a +TP01 alto (0.88-0.90) → sono trend-correlati travestiti (resid Sharpe ~0), uplift hold-out negativo. + +## 4) Causalità, alignment, eseguibilità, sign-falsification + +- **Causale**: `causality_ok` ok=True, max_tail_diff **0.0** su tutti i candidati (rank espandente + + z-score ricalcolati su prefisso = identici → nessun future-peeking). Test dedicato `test_expanding_rank_is_causal`. +- **Alignment DVOL**: laggando il segnale di +1 giorno (extra-conservativo, usa solo DVOL di ieri) l'ERA + Sharpe del leader scende +0.59 → **+0.45**. L'edge sopravvive ma è **sensibile all'allineamento** + (parte del vantaggio è nel reagire allo spike DVOL del giorno stesso) → cautela in esecuzione. +- **Eseguibile a $600**: `eval_weights_smallcap` (min_order $5, cap $300/asset): haircut Sharpe **≈ 0.00** + su BTC ed ETH (segnale long-flat a basso turnover, ~210-270 trade su tutta la storia) → lo Sharpe netto + onesto a $600 = quello modellato. È l'unico aspetto pienamente promosso. +- **Sign-falsification**: tesi buy-the-fear ERA Sh +0.40 vs flip buy-the-calm +0.09; VRP-Z+ +0.32 vs + VRP-Z− (flip) −0.14. Il segno della tesi è quello giusto (l'edge è reale, non rumore simmetrico). + +## Verdetto onesto: **LEAD (hedge / drawdown-dampener), NON sleeve.** SCARTATO come alpha direzionale. + +- Nessun segnale DVOL-direzionale ha Sharpe assoluto sopra ~0.6 → **conferma il soffitto BTC/ETH + direzionale ~1.3** (la via per superarlo resta il cross-sectional XS01, non un nuovo segnale a 2 asset). +- L'unico con edge in-sample reale + eseguibile + leak-free è **DVOL-fear "buy-the-fear" long-flat**, ma il + gate canonico lo bolla **HEDGE** (`is_hedge=True`, paga solo quando TP01 è in difficoltà). Per la regola + del progetto un hedge **non si arma come slot di alpha** → **earns_slot=False**. +- È comunque un **LEAD utile in forward-monitor** come **smorzatore di drawdown** del portafoglio: il blend + porta l'hold-out 0.30→0.47/0.62 e abbassa il DD. Se in futuro si vorrà esplicitamente un tail/DD-hedge + *eseguibile* (a differenza del tail-hedge in opzioni, non eseguibile a $600), questo è il candidato più + pulito visto finora — ma da prezzare **come hedge**, non come ritorno atteso permanente. + +## Caveat + +1. **Storia DVOL <5 anni** (2021-03→oggi, ~4.7 anni; era valutata 2021-10+) → multiple-testing e + finestra corta. Il multi-cut 2026 è un assurdo +1.944 (manciata di giorni, picco DVOL recente): + l'uplift hold-out è trascinato dal regime 2025-26 ad alta-paura, non da un edge stazionario. +2. **È un hedge, non alpha**: il valore è condizionato ai drawdown di TP01 (2022, ed eventuali futuri). + In un toro lungo a bassa vol il segnale resta **flat l'86% del tempo** e non contribuisce nulla. +3. **Sensibile all'allineamento** DVOL (haircut −0.14 Sharpe col lag +1g): l'esecuzione reale dovrebbe + usare il DVOL già stampato a decisione, non assumere il close contemporaneo. +4. Long-flat → cattura il rimbalzo post-paura ma **non protegge** durante la discesa (non è uno short): + è "buy-the-dip gated by fear", con tutti i rischi del comprare coltelli che cadono se il regime peggiora. + +Nessun impatto sul book live (branch separato, nessuno sleeve registrato, config canonica invariata). diff --git a/docs/diary/2026-06-29-intraday-regime.md b/docs/diary/2026-06-29-intraday-regime.md new file mode 100644 index 0000000..94422f5 --- /dev/null +++ b/docs/diary/2026-06-29-intraday-regime.md @@ -0,0 +1,106 @@ +# 2026-06-29 — Filone B: INTRADAY REGIME BTC/ETH — ERM (efficiency-ratio) è un LEAD reale, forward-monitor + +**Tesi.** Cercare un meccanismo **sub-daily** sui dati certificati 1h→12h BTC/ETH che sia ortogonale +**sia** a TP01 (TSMOM trend daily, long-flat) **sia** a SKH01 (regime BuzVola/BuzVolume + Donchian a +230m). SKH01 prova che il sub-daily PUÒ funzionare ed essere quasi-ortogonale: qui un MECCANISMO DIVERSO, +basato sulla **qualità del moto intraday** (efficiency-ratio / vol-expansion / thrust) come **regime** che +condiziona una posizione direzionale tenuta ~1 giorno. Script `scripts/research/intraday_regime.py`, test +`tests/test_intraday_regime.py`. Branch `research/equity-meanrev-ib` (separato, nessun impatto live). + +Killer ricorrente del progetto sotto le 12h = **muro-fee 0.10% RT + overfitting**. Ricetta SKH01: +decisione sub-daily ma **hold ~1 giorno** → pochi trade → la fee non uccide. Ogni meccanismo qui è +costruito a basso turnover e giudicato col **fee-sweep alla sua frequenza reale**. + +## Meccanismi provati (tutti come posizione CONTINUA decisa ≤ `close[i]`, causali) + +| | meccanismo | +|---|---| +| **ERM** | **Efficiency-Ratio regime momentum** (Kaufman): ER = \|moto netto su L barre\| / \|percorso\|. Prendi la direzione del moto netto **solo quando ER ≥ soglia** (regime intraday "pulito"/trendy), altrimenti flat | +| VEM | Vol-Expansion Momentum: direzione = segno del moto, attiva solo quando vol-corta > vol-lunga | +| VBR | Volatility/thrust breakout (Larry-Williams ROLLING, no calendario): segui i moti > k·ATR | +| TOD | Time-of-day seasonality — **CONTROLLO calendario**, incluso APPOSTA per `day_boundary_robust` | + +## Selezione + fee-sweep a frequenza reale (vincitori per famiglia, min-asset) + +``` +ERM: 8h L=2.0 thr=0.35 L/S minFull +0.88 minHold +0.99 f@.20 +0.55 feeOK=True turn/y 125 +VEM: 12h Lmom=3 Ls=2 Ll=10 minFull +0.72 minHold -0.29 f@.20 +0.51 feeOK=True (hold<0 → out) +VBR: 12h k=1.0 atr=14 L/S minFull +0.33 minHold +1.20 f@.20 +0.25 feeOK=True +TOD: 1h minFull -3.99 minHold -4.49 feeOK=False (controllo → FAIL atteso) +``` + +Solo **ERM** e **VBR** sopravvivono al gate fee+OOS. VEM ha hold-out negativo. TOD esplode (vedi sotto). + +## Il candidato: ERM 8h L=2.0 thr=0.35 (L/S) — `earns_slot=True` + +``` +abs=PASS marginal=ADDS EARNS_SLOT=True +standalone: cand FULL 0.919 / HOLD 1.105 (TP01 ref FULL 1.297 / HOLD 0.303) +corr->TP01 full 0.146 hold 0.057 beta 0.197 resid Sharpe 0.738 alpha/yr 0.121 +robust_oos=True (clean-year +0.103, drop-best-month +0.283) +multi-cut persistence: 2020 .166 / 2021 .167 / 2022 .298 / 2023 .337 / 2024 .418 / 2025 .497 / 2026 2.861 persistent=True +has_insample_edge=True (standalone PRE-holdout Sharpe 0.874, > null-pctl 0.317) +is_hedge=False (yearly corr(TP01-Sh, uplift) -0.762; paga sia TP01-up 0.139 sia TP01-down 0.384) +blend w25: FULL 1.481 (+0.184) HOLD 0.80 (+0.497) DD 11.0% +corr con SKH01: full 0.278 hold 0.291 +day_boundary=INVARIANT (spread 0.0) haircut $600 ≈ 0.00 su BTC/ETH +``` + +È il **primo sub-daily da SKH01** a passare TUTTI i gate del marginal scorer indurito: edge in-sample +reale (non diversification-math), persistenza multi-cut, robust_oos, NON hedge, scorrelato a TP01 (0.15). + +### ERM aggiunge OLTRE SKH01? (gate #5 — il book esistente ne ha già uno sub-daily) + +| blend | FULL Sh | FULL DD | HOLD Sh | HOLD DD | +|---|---|---|---|---| +| TP01 | +1.30 | 14.3% | +0.30 | 7.5% | +| TP01+SKH 75/25 | +1.78 | 9.4% | +1.17 | 6.2% | +| **TP01+SKH+ERM 60/25/15** | **+1.88** | 8.9% | **+1.46** | 5.3% | +| TP01+SKH+ERM 55/20/25 | +1.84 | 8.8% | +1.48 | 5.1% | + +Sì: corr con SKH01 solo 0.28 → ERM **aggiunge oltre SKH** (FULL +0.10, HOLD +0.29 a peso 15%, DD ancora +giù). Non è SKH01 travestito. + +## Il controllo TOD (calendario) — fa esattamente ciò che doveva + +`TOD` (direzione per ora-del-giorno, media espandente causale) è incluso come **trappola**: è il tipo di +effetto che uccise `open_drive` (artefatto di etichettatura UTC). Esito: **FAIL** (FULL −3.99, 31.811 +trade, fee-killed), marginal=DILUTES. `day_boundary_robust=INVARIANT` → l'effetto è **robustamente +negativo** a ogni offset (non un artefatto di confine giorno: è proprio che la time-of-day-direzionale non +ha edge e sanguina fee). Il controllo conferma che l'harness non si fa ingannare e che il segno +dei segnali di prezzo (ERM/VBR) è reale, non rumore di calendario. + +## Causalità / eseguibilità + +- **Leak-free**: `causality_ok=True`, max_tail_diff **0.0** su tutti i candidati (ER, rank espandente, + medie su prefisso = identiche → nessun future-peeking). Test dedicato in `test_intraday_regime.py`. +- **day_boundary_robust=INVARIANT** (spread 0.0) per ERM/VBR/TOD: segnali di prezzo, non di calendario. +- **Eseguibile a $600**: `eval_weights_smallcap` haircut **≈ 0.00** su BTC ed ETH. **MA** ERM a 8h fa + ~3.158 trade BTC / ~2.823 ETH su tutta la storia (turnover 125/anno): haircut nullo nel modello, ma + l'esecuzione reale sub-daily sul book è operativamente più pesante di un segnale 1d (slippage/spread + intraday non interamente catturati dalla fee proporzionale). + +## Caveat (perché LEAD, non sleeve) + +1. **Plateau hold-out a UNA SOLA RIGA.** Il FULL è robusto su tutta la griglia L∈[2.0,3.0] (+0.6..+1.0), + ma l'**hold-out è positivo SOLO a L=2.0** (a L=2.5/3.0 crolla a −0.5..−0.8). Il plateau sul full è + ampio, quello che conta — l'hold-out — è single-row. Da rinforzare prima di credere alla stazionarietà. +2. **Standalone FULL 0.92 < soffitto ~1.3.** Coerente col soffitto direzionale BTC/ETH: il valore di ERM + è **marginale/diversificante** (corr 0.15 a TP01, 0.28 a SKH01), non assoluto. Non rompe il soffitto. +3. **Multiple-testing non deflazionato.** 102 celle testate (60 ERM + 16 VEM + 24 VBR + 2 TOD) senza + deflated-Sharpe (a differenza del filone C). Il multi-cut 2026 = +2.861 è una manciata di giorni che + gonfia. Storia sub-daily certificata utile ~quanto SKH01 → finestra non lunghissima. +4. **Esecuzione 8h** = complessità operativa reale (vedi sopra), oltre il modello a haircut nullo. + +## Verdetto onesto: **LEAD forte / forward-monitor (research win).** Nessuno sleeve registrato. + +ERM è il risultato sub-daily **più solido dai tempi di SKH01**: passa il marginal scorer indurito (edge +in-sample, multi-cut, non-hedge, ADDS), è leak-free, fee-survivente, eseguibile a haircut ~0, e migliora +il portafoglio **oltre** SKH01 (TP01+SKH+ERM 60/25/15 → FULL 1.88 / HOLD 1.46 / DD 8.9%). Ma il plateau +hold-out a singola riga + il multiple-testing non deflazionato lo tengono a **tier-SKH-al-momento-della- +scoperta**: si arma in **forward-monitor**, non si registra come sleeve finché (a) il plateau hold-out +non si allarga su L con dati nuovi, (b) il deflated-Sharpe sulle 102 celle non lo conferma, (c) l'esecuzione +8h non è validata a deploy. Gli altri tre meccanismi: VBR=NOISE (diversification-math, no in-sample edge), +VEM hold-out<0, TOD=controllo FAIL come atteso. + +Nessun impatto sul book live (branch separato, config canonica invariata). diff --git a/docs/diary/2026-06-29-macro-regime-gate.md b/docs/diary/2026-06-29-macro-regime-gate.md new file mode 100644 index 0000000..cfb5923 --- /dev/null +++ b/docs/diary/2026-06-29-macro-regime-gate.md @@ -0,0 +1,71 @@ +# 2026-06-29 — Filone D: MACRO regime-gate sul book crypto — RIDONDANTE col trend, SCARTATO + +**Tesi.** Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV), +tassi (TLT/IEF) — come **gate risk-on/risk-off** sul book BTC/ETH, per migliorare il *timing* del +drawdown di TP01: regime risk-off (credito che cede, equity sotto trend, fuga sui bond) → riduci/azzera +l'esposizione crypto; risk-on → lascia agire TP01. **È eseguibile** (gata solo BTC/ETH perp, non aggiunge +gambe). Script `scripts/research/macro_regime_gate.py`, test `tests/test_macro_regime_gate.py`. Branch +`research/equity-meanrev-ib` (separato, nessun impatto live). Dati: `data/raw/eq_*_1d.parquet` (ETF daily, +allineati causale `merge_asof backward` sul calendario SPY). + +**NON è un lead-lag direzionale** (già morto: diari 2026-06-22/-23). L'angolo nuovo = un overlay +binario/continuo di **de-risk** sul book esistente. + +## Il rischio noto in partenza (CLAUDE.md) + +La lezione DVOL-spike (2026-06-26): un gate di de-risk rischia di essere **ridondante col trend** — TP01 +è già long-flat e va a 0 nei crash (Δ 0.00). Questo script DOVEVA dimostrare che il gate aggiunge **oltre** +quel che TP01 fa da solo. **Non ci riesce.** + +## Gate provati (tutti causali: SMA/ratio/z-score rolling, merge_asof backward) + +`SPY>MA{50,100,150,200,250}`, `HYG>MA`, ratio di credito `HYG/LQD`, `HYG/IEF`, combo a maggioranza +(SPY+HYG+HYG/LQD, binario e continuo), flight-to-quality (`TLTup&SPYdn`, `GLDup&SPYdn`), regime di vol +equity `SPYvol_z1` (proxy "VIX-spike" senza VIX). g_off ∈ {0.0, 0.5}. + +## Esiti + +I gate di trend equity alzano lo Sharpe FULL della combo (SPY>MA150/200/250 → FULL ~1.22-1.25 vs TP01-solo +1.30) ma **non l'hold-out** (HOLD ~0.17-0.19) → è **de-levering**, non timing: tagliano DD spegnendo il +book in mercati equity laterali, non anticipando i crash crypto. Il miglior profilo DD è `SPYvol_z1`. + +### Deep-dive `SPYvol_z1` — il controllo decisivo (ridondanza col trend) + +| | exposure TP01 risk-off | exposure risk-on | giorni risk-off | **giorni in cui il gate LAVORA** | corr(1-gate, 1-expo) | +|---|---|---|---|---|---| +| BTC | 0.023 | 0.093 | 18% | **3%** | 0.278 | +| ETH | 0.013 | 0.068 | 16% | **2%** | 0.296 | + +"Il gate lavora" = giorno risk-off **E** posizione TP01 non già flat. Accade nel **2-3% dei giorni**: nel +resto, quando il gate vorrebbe de-riskare, TP01 è **già flat da solo**. Il gate spegne un motore già spento. + +### Marginal scorer vs TP01 (gate come candidato-sleeve) + +``` +abs=FAIL marginal=REDUNDANT EARNS_SLOT=False +corr->TP01 full 0.989 hold 0.984 beta 0.978 resid Sharpe 0.739 +multi-cut persistent=True has_insample_edge=True (standalone 1.634) is_hedge=False +blend w25: full 1.323 (+0.026) hold 0.257 (-0.046) blend w50: full 1.348 (+0.051) hold 0.21 (-0.093) +``` + +corr→TP01 **0.989**, beta 0.98: lo stream gated **È** TP01. L'uplift hold-out è **negativo** (de-levering +che toglie ritorno fuori dai crash). overlay-delta (gated−solo) come stream a sé: `verdict=NEUTRAL`. + +## Causalità / eseguibilità (gli unici aspetti promossi) + +- **Leak-free**: `causality_ok=True` (max_tail_diff 0.0); STRICT align (1 barra equity extra di lag) → + FULL invariato; `day_boundary_robust=INVARIANT` (spread 0.0). Allineamento macro→crypto backward-only. +- **Eseguibile a $600**: haircut `eval_weights_smallcap` ≈ **0.00** su BTC ed ETH. Fee-survivente + (0.00→0.30% RT: 1.42→1.33). Tecnicamente impeccabile — semplicemente **non aggiunge alpha**. + +## Verdetto: **SCARTATO (ridondante col trend).** earns_slot=False. + +Conferma esatta della lezione in CLAUDE.md: un overlay di de-risk su un book che è **già** long-flat e +già a 0 nei crash non ha quasi nulla da fare (lavora il 2-3% dei giorni, corr→TP01 0.99). Il taglio di DD +che mostra è **de-levering**, replicabile meglio con un `target_vol` più basso (stessa lezione del +TP01×DVOL overlay del 2026-06-26). Il macro-regime **non anticipa** i drawdown crypto meglio del prezzo +crypto stesso. Nessuno sleeve registrato; nessun impatto sul book live. + +**Sottoprodotto utile:** il gate `redundancy_diag` (exposure risk-off vs risk-on + "quota giorni in cui il +gate lavora") è il test pulito da riusare ogni volta che si propone un overlay di de-risk — se "il gate +lavora" è basso e corr→TP01 ~1, è ridondante, a prescindere dallo Sharpe FULL che mostra. diff --git a/docs/diary/2026-06-29-xsec-v2-nonmom.md b/docs/diary/2026-06-29-xsec-v2-nonmom.md new file mode 100644 index 0000000..9bbb67d --- /dev/null +++ b/docs/diary/2026-06-29-xsec-v2-nonmom.md @@ -0,0 +1,65 @@ +# 2026-06-29 — Filone C: cross-sectional NON-momentum su Hyperliquid — DEBOLE / forward-monitor + +**Tesi.** XS01 (sleeve attivo) è momentum cross-sectional sui 19 major HL. Lezione del progetto +(2026-06-19/20): **espandere il numero di asset NON aiuta il momentum** (gli small-cap diluiscono/ +invertono il segnale). Quindi qui NON si ri-prova l'espansione-universo: si cerca un **meccanismo +DIVERSO dal momentum** che, market-neutral e scorrelato, possa diversificare il portafoglio. Script +`scripts/research/xsec_v2_nonmom.py`, test `tests/test_xsec_v2.py`. Branch `research/equity-meanrev-ib`. + +## Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI) + +| | meccanismo | +|---|---| +| REV | short-term reversal grezzo (long loser / short winner di breve) | +| IREV | reversal **idiosincratico** (sul residuo dopo aver tolto il mercato, beta-adjusted) | +| LOWVOL | factor **low-vol** (long bassa vol realizzata / short alta vol) | +| IMOM | momentum **idiosincratico** (sul residuo, ≠ raw mom) — *riferimento* | +| BAB | betting-against-beta (long basso beta / short alto beta) | +| MOM | momentum grezzo — *riferimento* | + +Universi: **19 major** e **51 all** (i parquet certificati). Gate canonico di selezione: FULL>0.5, +HOLD>0, |corr→XS01|<0.6 (escluse le famiglie momentum MOM/IMOM, che sono riferimento, non candidati). + +## Esiti — un solo candidato non-momentum supera il gate standalone + +``` +[OK] LOWVOL 19-major B30 H10 k5 FULL +1.07 HOLD +1.07 DD 19% corrXS +0.16 corrTP -0.28 +[--] BAB 19-major B60 H10 k8 FULL +0.47 HOLD +1.15 DD 28% corrXS +0.20 corrTP -0.27 +[--] REV/IREV (19 e 51) FULL ~0 o NEGATIVO (reversal cross-sectional non paga) +[--] LOWVOL 51-all FULL +0.42 (il long-tail diluisce, come per il momentum) +``` + +- **REV / IREV negativi** (FULL −0.10..−0.70): il reversal cross-sectional grezzo o idiosincratico **non + è un edge** su questo universo/finestra — coerente con la morte storica del mean-reversion del progetto. +- **BAB** ha un buon hold-out ma FULL solo +0.47 (sotto soglia) e DD 28%. +- **LOWVOL 19-major** (betting-against-vol) è l'unico idoneo: FULL **1.07** / HOLD **1.07** / DD 19%, + causale (max_tail_diff 0), corr→XS01 +0.16 (scorrelato), corr→TP01 −0.28. +- Espandere a **51 asset peggiora** anche il low-vol (FULL 1.07→0.42): riconferma che i margini + cross-sectional sono nella **struttura del segnale**, non nel numero di asset. + +### Uplift portafoglio (4 → 5 sleeve, LOWVOL come 5° sleeve) + +``` +BASE (TP01+XS01+VRP01+SKH01) FULL 2.12 DD 8% | HOLD 2.25 DD 3% ++LOWVOL @10% FULL 2.17 (+0.05) | HOLD 2.57 (+0.32) ++LOWVOL @15% FULL 2.19 (+0.06) | HOLD 2.62 (+0.37) +``` + +## Il muro (perché NON è uno sleeve) + +1. **Deflated Sharpe 0.130 ≪ 0.95** (N=216 trial globali; soglia Sharpe-max-null annualizz. 1.79). Con il + numero di celle/famiglie/universi testati, lo Sharpe 1.07 **non è significativo** dopo la correzione + per multiple-testing. Per-anno: 2024 +0.23 / 2025 +0.50 / **2026 −0.09** (il 2026 è già negativo). +2. **Storia ~2.5 anni** (HL nativo 2024-2026): finestra troppo corta per un fattore cross-sectional. +3. **Non eseguibile a $600**: book L/S a molte gambe (10+ per lato) → **STAT-MODE**, mai deploy. Stesso + vincolo strutturale di XS01 (serve ~20k di capitale). + +## Verdetto: **DEBOLE / forward-monitor (STAT-MODE).** Nessuno sleeve registrato. + +Il **low-vol cross-sectional** (betting-against-vol sui 19 major) è l'unico meccanismo non-momentum con un +profilo decente e scorrelato sia a XS01 sia a TP01, e migliorerebbe l'hold-out del portafoglio (+0.32/+0.37). +**MA** non supera il deflated-Sharpe (multiple-testing), ha storia cortissima ed è non-eseguibile a questo +capitale. → lavoro **puramente statistico**, da tenere in forward-monitor come *secondo* segnale +cross-sectional a fianco di XS01, da rivalutare solo a scala (capitale ~20k+) e con più storia HL. + +Reversal cross-sectional (REV/IREV) **confermato non-edge** su HL. Nessun impatto sul book live. diff --git a/scripts/research/dvol_directional.py b/scripts/research/dvol_directional.py new file mode 100644 index 0000000..1e3512f --- /dev/null +++ b/scripts/research/dvol_directional.py @@ -0,0 +1,352 @@ +"""DVOL-DIRECTIONAL — la vol IMPLICITA (Deribit DVOL) come SEGNALE DIREZIONALE/REGIME sul perp BTC/ETH. + +Filone A. Diverso da TUTTO il lavoro DVOL precedente: + * `tp01_dvol_overlay.py` (2026-06-26): DVOL come DENOMINATORE del vol-target -> solo de-levering, SCARTATO. + * VOL03/04/10/11 (sweep alt): DVOL che GATA/SCALA un TSMOM (eredita lo Sharpe di trend di TP01 -> + il marginal scorer li boccia NEUTRAL/REDUNDANT). + * agent_14_dvol_spread (onda ortho): IV RELATIVA BTC-vs-ETH, market-NEUTRAL 2-leg (l'unico LEAD). +Qui invece: usare DVOL/IV-RV come segnale DIREZIONALE STANDALONE sul LIVELLO di mercato (long-flat o +L/S sul perp), per provare se il DVOL porta alpha DIREZIONALE ORTOGONALE a TP01 (non un overlay sul trend). + +Angoli (tutti CAUSALI: decisi <= close[i], tenuti in i+1 dallo shift di eval_weights): + VRP-Z : z-score causale del vol-risk-premium (IV-RV). VRP ricco (IV>>RV, paura sovra-prezzata) + => LONG underlying (fear-reversal, l'analogo direzionale dell'edge VRP); flip = falsifica. + DVOL-LV : percentile espandente causale del DVOL. "Buy-the-fear" (rank alto => long) vs + "buy-the-calm" (rank basso => long). + DVOL-MOM : DVOL vs ema(DVOL,k). DVOL in calo (risk-on) => long; in salita (risk-off) => flat (o short, L/S). + VRP-LS : variante long-short del VRP-Z (short quando VRP compresso). + +DVOL parte 2021-03 -> pre-DVOL il segnale e' FLAT (0). Valuto sia FULL (con flat pre-periodo, deflaziona +lo Sharpe) sia DVOL-ERA-ONLY (giusto per la tesi). Gate: study_weights (abs+fee sweep), marginal_vs_tp01 +(earns_slot), causality_ok, eval_weights_smallcap ($600), sign-falsification. Storia DVOL <5 anni -> caveat. + + uv run python scripts/research/dvol_directional.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +import altlib as al # noqa: E402 + +# warmup: prima data DVOL (2021-03-24) + finestra max (z/rank) -> evita celle a campione corto +DVOL_ERA = pd.Timestamp("2021-10-01", tz="UTC") +TVOL = 0.20 +VOLWIN = 30 +LEVCAP = 2.0 + + +# =========================================================================== +# CONTESTO causale per-asset (IV, RV, VRP) — tutto <= close[i] +# =========================================================================== +def _ctx(df: pd.DataFrame, asset: str): + c = df["close"].values.astype(float) + r = al.simple_returns(c) + bpd = al.bars_per_day(df) + iv = al.dvol(df, asset) / 100.0 # IV implicita 30d annualizzata (causale) + rv = al.realized_vol(r, max(2, 30 * bpd), bpd * 365.25) # RV trailing 30d annualizzata + return c, r, bpd, iv, rv + + +_RANK_CACHE: dict = {} + + +def _expanding_rank_arr(x: np.ndarray, min_p: int = 120) -> np.ndarray: + """Percentile espandente causale: rank[i] = frazione di x[:i] < x[i]. + Usa SOLO barre STRETTAMENTE precedenti + il valore corrente (noto a close[i]) — niente + future-peeking. Calcolato sull'array PASSATO (cosi' su un prefisso ridà i valori del prefisso, + => causality_ok lo verifica davvero). O(n^2), n~3k OK.""" + finite = np.isfinite(x) + out = np.full(len(x), np.nan) + for i in range(min_p, len(x)): + if not finite[i]: + continue + prev = x[:i][finite[:i]] + if len(prev) >= min_p // 2: + out[i] = float(np.mean(prev < x[i])) + return out + + +def dvol_rank(df: pd.DataFrame, asset: str, min_p: int = 120) -> np.ndarray: + x = np.asarray(al.dvol(df, asset), float) + key = (asset, len(x), round(float(np.nan_to_num(x[-1])), 4)) + c = _RANK_CACHE.get(key) + if c is None: + c = _expanding_rank_arr(x, min_p) + _RANK_CACHE[key] = c + return c + + +def _finalize(direction: np.ndarray, df: pd.DataFrame, long_only: bool) -> np.ndarray: + d = np.nan_to_num(direction, nan=0.0) + if long_only: + d = np.clip(d, 0.0, 1.0) + return al.vol_target(d, df, TVOL, VOLWIN, LEVCAP) + + +# =========================================================================== +# FAMIGLIE DI SEGNALI (factory -> target_fn(df, asset)) +# =========================================================================== +def make_vrp_z(win=90, thr=0.0, sign=+1, long_only=True): + """LONG quando z(IV-RV) * sign > thr. sign=+1 => long su VRP RICCO (fear-reversal).""" + def fn(df, asset): + c, r, bpd, iv, rv = _ctx(df, asset) + vrp = iv - rv + z = al.zscore(vrp, max(5, win * bpd)) + sig = sign * z + if long_only: + d = np.where(sig > thr, 1.0, 0.0) + else: + d = np.clip(np.tanh(sig), -1.0, 1.0) + return _finalize(d, df, long_only) + return fn + + +def make_dvol_level(q=0.5, side="fear", long_only=True): + """side='fear': long quando rank DVOL > q (buy-the-fear). side='calm': long quando rank < q.""" + def fn(df, asset): + rk = dvol_rank(df, asset) + if side == "fear": + base = (rk > q) + else: + base = (rk < q) + if long_only: + d = np.where(base, 1.0, 0.0) + else: + d = np.where(base, 1.0, -1.0) + return _finalize(d, df, long_only) + return fn + + +def make_dvol_mom(k=10, long_only=True): + """DVOL in calo (dvol < ema(dvol,k)) => risk-on => long; in salita => flat (o short se L/S).""" + def fn(df, asset): + dv = al.dvol(df, asset) + em = al.ema(np.nan_to_num(dv, nan=np.nan), k) + falling = dv < em + if long_only: + d = np.where(falling, 1.0, 0.0) + else: + d = np.where(falling, 1.0, -1.0) + d = np.where(np.isfinite(dv) & np.isfinite(em), d, 0.0) + return _finalize(d, df, long_only) + return fn + + +def make_vrp_positive(long_only=True): + """Baseline 'quasi buy&hold': long quando IV>RV (VRP positivo, ~80% del tempo).""" + def fn(df, asset): + c, r, bpd, iv, rv = _ctx(df, asset) + d = np.where((iv - rv) > 0, 1.0, 0.0) + return _finalize(d, df, long_only) + return fn + + +# =========================================================================== +# DIAGNOSTICA: il DVOL ha CONTENUTO DIREZIONALE? (probe corr signal[i] vs r[i+1]) +# =========================================================================== +def _raw_direction(fn_dir, df, asset): + """Estrae la DIREZIONE grezza (pre-vol-target) per la probe: rifa' il calcolo del segno.""" + return fn_dir(df, asset) + + +def probe(name, dir_fn, era_only=True): + """corr(dir[i], r[i+1]) e media r[i+1] per dir>0 vs dir<=0, pooled BTC+ETH (1d).""" + dd, rr = [], [] + for a in al.CERTIFIED: + df = al.get(a, "1d") + idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + d = np.nan_to_num(dir_fn(df, a), nan=0.0) + r = al.simple_returns(df["close"].values.astype(float)) + rnext = np.roll(r, -1); rnext[-1] = 0.0 + mask = np.ones(len(df), bool) + if era_only: + mask &= np.asarray(idx >= DVOL_ERA) + mask[-1] = False + dd.append(d[mask]); rr.append(rnext[mask]) + d = np.concatenate(dd); r = np.concatenate(rr) + corr = float(np.corrcoef(d, r)[0, 1]) if np.std(d) > 0 and np.std(r) > 0 else 0.0 + up = float(np.mean(r[d > 0])) * 1e4 if (d > 0).any() else 0.0 # bps + dn = float(np.mean(r[d <= 0])) * 1e4 if (d <= 0).any() else 0.0 + return dict(name=name, corr=round(corr, 4), n=int(len(d)), + long_bps=round(up, 1), flat_bps=round(dn, 1), edge_bps=round(up - dn, 1), + frac_long=round(float(np.mean(d > 0)), 3)) + + +# =========================================================================== +# MARGINAL su finestra DVOL-ERA (piu' giusto della full che include il flat pre-2021) +# =========================================================================== +def cand_daily_era(target_fn, tf="1d", start=DVOL_ERA, fee_side=al.FEE_SIDE) -> pd.Series: + series = {} + for a in al.CERTIFIED: + df = al.get(a, tf) + ev = al.eval_weights(df, al._call_target(target_fn, df, a), fee_side=fee_side) + series[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + d = al._to_daily(0.5 * J[al.CERTIFIED[0]] + 0.5 * J[al.CERTIFIED[1]]) + return d[d.index >= start] + + +def era_full_sharpe(target_fn, tf="1d"): + """Sharpe/DD del candidato sulla SOLA era DVOL (no flat pre-2021), 50/50.""" + s = cand_daily_era(target_fn, tf) + r = s.values + sh = float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0 + eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + yrs = (s.index[-1] - s.index[0]).days / 365.25 if len(s) > 1 else 1.0 + cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0 + return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4), n=len(s)) + + +def earns_slot_era(target_fn, tf="1d"): + """Replica del gate study_marginal MA sulla candidate-daily ristretta all'era DVOL (inner-join + con TP01 baseline limita anche TP01 alla stessa finestra). Piu' equo per un segnale DVOL-only.""" + marg = al.marginal_vs_tp01(cand_daily_era(target_fn, tf)) + v = marg.get("marginal_verdict") + earns = (v == "ADDS" and marg.get("robust_oos", False) + and marg.get("beats_noise_null", False) and not marg.get("is_hedge", False)) + return marg, earns + + +# =========================================================================== +# MAIN +# =========================================================================== +def hr(t=""): + print("=" * 100) + if t: + print(t) + print("=" * 100) + + +def main(): + hr("DVOL-DIREZIONALE — DVOL/IV-RV come segnale DIREZIONALE standalone su BTC/ETH (perp). 1d, causale.") + print(f" Era DVOL valutata da {DVOL_ERA.date()} (warmup z/rank). FULL include flat pre-2021 (deflaziona Sharpe).") + print(f" vol-target {TVOL:.0%}, leva cap {LEVCAP}x, fee 0.10% RT. TP01 baseline come riferimento marginale.\n") + + # ---- 1) PROBE: contenuto direzionale (corr signal[i] vs r[i+1]) --------- + hr("1) PROBE DIREZIONALE — il DVOL predice il ritorno del giorno dopo? (pooled BTC+ETH, era DVOL)") + probes = [ + ("VRP-Z+ (long VRP ricco)", make_vrp_z(90, 0.0, +1)), + ("VRP-Z- (long VRP basso)", make_vrp_z(90, 0.0, -1)), + ("DVOL-LV fear (rank>0.5)", make_dvol_level(0.5, "fear")), + ("DVOL-LV calm (rank<0.5)", make_dvol_level(0.5, "calm")), + ("DVOL-MOM falling=long", make_dvol_mom(10)), + ("VRP>0 (quasi buy&hold)", make_vrp_positive()), + ] + print(f" {'segnale':<28}{'corr':>9}{'long bps':>10}{'flat bps':>10}{'edge bps':>10}{'frac_long':>10}") + for nm, fn in probes: + p = probe(nm, fn) + print(f" {nm:<28}{p['corr']:>+9.4f}{p['long_bps']:>+10.1f}{p['flat_bps']:>+10.1f}" + f"{p['edge_bps']:>+10.1f}{p['frac_long']:>10.3f}") + print(" (edge bps = ritorno medio giorno-dopo quando long MENO quando flat; >0 = il segnale separa)\n") + + # ---- 2) ROBUSTEZZA ASSOLUTA (study_weights) + fee sweep + era-only ------ + hr("2) ROBUSTEZZA ASSOLUTA — study_weights su BTC+ETH (1d), fee sweep 0.00-0.20% RT") + grid = { + "VRP-Z90 long-flat": make_vrp_z(90, 0.0, +1, True), + "VRP-Z60 long-flat": make_vrp_z(60, 0.0, +1, True), + "VRP-Z120 long-flat": make_vrp_z(120, 0.0, +1, True), + "VRP-Z90 L/S": make_vrp_z(90, 0.0, +1, False), + "DVOL-fear q0.4 LF": make_dvol_level(0.4, "fear", True), + "DVOL-fear q0.5 LF": make_dvol_level(0.5, "fear", True), + "DVOL-calm q0.5 LF": make_dvol_level(0.5, "calm", True), + "DVOL-MOM k10 LF": make_dvol_mom(10, True), + "DVOL-MOM k20 LF": make_dvol_mom(20, True), + "DVOL-MOM k10 L/S": make_dvol_mom(10, False), + } + reps = {} + for nm, fn in grid.items(): + rep = al.study_weights(nm, fn, tfs=("1d",)) + reps[nm] = rep + era = era_full_sharpe(fn) + v = rep["verdict"] + c = rep["cells"][0] + print(f" {nm:<22} abs={v['grade']:<4} minFull={c['min_asset_full_sharpe']:+.2f} " + f"minHold={c['min_asset_holdout_sharpe']:+.2f} feeOK={c['fee_survives']!s:<5} " + f"|| ERA-only Sh={era['sharpe']:+.2f} DD={era['dd']*100:.0f}% CAGR={era['cagr']*100:+.0f}%") + print(" (FULL include 2018-2021 flat => Sharpe basso e' atteso; ERA-only e' il giudizio equo del segnale)\n") + + # candidati da portare al marginal: i migliori per ERA Sharpe + abs non-FAIL + ranked = sorted(grid.items(), key=lambda kv: era_full_sharpe(kv[1])["sharpe"], reverse=True) + top = ranked[:4] + + # ---- 3) MARGINAL vs TP01 (era DVOL) — il gate vero ----------------------- + hr("3) MARGINAL vs TP01 (finestra DVOL-era) — earns_slot? (il gate decisivo)") + for nm, fn in top: + marg, earns = earns_slot_era(fn) + bl = marg.get("blends", {}).get("w25", {}) + print(f"\n --- {nm} ---") + print(f" verdetto={marg.get('marginal_verdict')} EARNS_SLOT(era)={earns}") + print(f" corr->TP01 full {marg.get('corr_full')} hold {marg.get('corr_hold')} " + f"beta {marg.get('beta_to_tp01')} resid Sh {marg.get('resid_sharpe_full')}") + print(f" cand standalone full {marg.get('cand_full_sharpe')}/hold {marg.get('cand_hold_sharpe')} " + f"in-sample Sh {marg.get('cand_insample_sharpe')} has_edge={marg.get('has_insample_edge')}") + print(f" blend w25: full {bl.get('full')} (uplift {bl.get('uplift_full')}) " + f"hold {bl.get('hold')} (uplift {bl.get('uplift_hold')}) DD {bl.get('dd')}") + print(f" multi-cut {marg.get('multicut_uplift')} persistent={marg.get('multicut_persistent')} " + f"robust_oos={marg.get('robust_oos')} is_hedge={marg.get('is_hedge')}") + + # ---- 3b) study_marginal canonico (full history, gate di progetto) -------- + hr("3b) study_marginal CANONICO (full history, include flat pre-DVOL) — gate ufficiale di progetto") + leader_nm, leader_fn = top[0] + sm = al.study_marginal(leader_nm, leader_fn, tf="1d") + print(al.fmt_marginal(sm)) + + # ---- 4) CAUSALITA' (look-ahead guard) ----------------------------------- + hr("4) CAUSALITA' — causality_ok (ricalcolo su prefisso, nessun future-peeking)") + for nm, fn in top: + co = al.causality_ok(fn, tf="1d") + print(f" {nm:<22} ok={co['ok']!s:<5} max_tail_diff={co['max_tail_diff']} checked={co['checked']}") + + # robustezza alignment DVOL: lag +1 giorno (extra-conservativo) sul leader + hr("4b) ROBUSTEZZA ALIGNMENT — DVOL laggato +1g (extra-conservativo) sul leader: l'edge sopravvive?") + + def _lag(fn): + # ricostruisce il segnale con dvol shiftato di 1 barra (usa solo DVOL di IERI) + def g(df, asset): + # monkey: usa al.dvol ma shift -> approssimo rifacendo via wrapper sul ctx non e' banale; + # piu' semplice: confronto Sharpe era con e senza lag costruendo direzione laggata generica. + base = fn(df, asset) + lagged = np.zeros_like(base); lagged[1:] = base[:-1] + return lagged + return g + lag_era = era_full_sharpe(_lag(leader_fn)) + base_era = era_full_sharpe(leader_fn) + print(f" {leader_nm}: ERA Sh base {base_era['sharpe']:+.2f} -> con segnale laggato +1g " + f"{lag_era['sharpe']:+.2f} (calo grande = edge fragile all'alignment)") + + # ---- 5) ESEGUIBILITA' $600 — eval_weights_smallcap ---------------------- + hr("5) ESEGUIBILITA' a $600 — eval_weights_smallcap (min_order $5, haircut reale vs modellato)") + for nm, fn in top: + for a in al.CERTIFIED: + df = al.get(a, "1d") + sc = al.eval_weights_smallcap(df, al._call_target(fn, df, a), capital=600, min_order=5) + print(f" {nm:<22} {a}: modellato Sh {sc['modeled']['sharpe']:+.2f} -> reale $600 " + f"Sh {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}) " + f"trade eseguiti {sc['n_executed_trades']}") + + # ---- 6) SIGN-FALSIFICATION sul leader ----------------------------------- + hr("6) SIGN-FALSIFICATION — invertire il segno del leader deve PEGGIORARE (altrimenti e' rumore)") + # leader e' VRP-Z+ se nel top; provo l'inverso esplicito su VRP-Z e DVOL + flips = [ + ("VRP-Z90 sign +1 (tesi)", make_vrp_z(90, 0.0, +1, True)), + ("VRP-Z90 sign -1 (flip)", make_vrp_z(90, 0.0, -1, True)), + ("DVOL-fear q0.5 (tesi)", make_dvol_level(0.5, "fear", True)), + ("DVOL-calm q0.5 (flip)", make_dvol_level(0.5, "calm", True)), + ] + for nm, fn in flips: + e = era_full_sharpe(fn) + print(f" {nm:<26} ERA Sh {e['sharpe']:+.2f} DD {e['dd']*100:.0f}% CAGR {e['cagr']*100:+.0f}%") + + hr() + print("FINE. Leggere il verdetto onesto nel diario docs/diary/2026-06-29-dvol-directional.md") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/intraday_regime.py b/scripts/research/intraday_regime.py new file mode 100644 index 0000000..fb6d2a2 --- /dev/null +++ b/scripts/research/intraday_regime.py @@ -0,0 +1,376 @@ +"""intraday_regime.py — FILONE B: "INTRADAY REGIME BTC/ETH" (eseguibile) — 2026-06-29. + +TESI. Cercare un meccanismo SUB-DAILY sui dati certificati 1h/4h/.../12h BTC/ETH che sia +ORTOGONALE sia a TP01 (TSMOM trend daily, long-flat) sia a SKH01 (regime BuzVola/BuzVolume ++ Donchian breakout a 230m). SKH01 prova che il sub-daily PUO' funzionare ed essere +quasi-ortogonale: qui si esplora un MECCANISMO DIVERSO, basato sulla QUALITA' del moto +intraday (efficiency-ratio / vol-expansion / thrust) come REGIME che condiziona una +posizione direzionale tenuta ~1 giorno. + +Il killer ricorrente del progetto sotto le 12h e' il MURO-FEE (0.10% RT) + overfitting. +La ricetta che SKH01 usa per sopravvivere: DECISIONE sub-daily ma HOLD ~1 giorno -> pochi +trade -> la fee non uccide. Qui ogni meccanismo e' costruito per essere a basso turnover +(gate di regime che tiene flat la maggior parte del tempo, lookback non microscopici) e +viene giudicato col fee-sweep ALLA SUA FREQUENZA REALE. Se muore appena si mette la fee -> +SCARTATO e documentato (e' un risultato valido). + +MECCANISMI (tutti come posizione CONTINUA decisa <= close[i], cosi' passano nativamente per +eval_weights / study_marginal / day_boundary_robust / eval_weights_smallcap di altlib): + + ERM Efficiency-Ratio regime momentum. ER = |moto netto su L barre| / |percorso| (Kaufman): + alto = moto intraday "pulito"/direzionale, basso = chop. Prendi la direzione del moto + netto SOLO quando ER >= soglia (regime trendy intraday), altrimenti flat. Vol-target. + Storia economica: quando il prezzo intraday e' EFFICIENTE il momentum continua; quando + e' choppy non c'e' edge. DIVERSO da SKH01 (regime vol/volume) e da TP01 (TSMOM 1-6 mesi). + + VEM Vol-Expansion Momentum. Direzione = segno del moto su Lmom barre, ATTIVA solo quando la + vol realizzata corta > vol realizzata lunga (espansione di volatilita'). Vol-target. + + VBR Volatility/thrust breakout (Larry-Williams-style, ROLLING, no calendario). Segui solo i + movimenti significativi: posizione = segno(c[i]-c[i-1]) quando |Δ| > k*ATR, altrimenti + tieni la precedente. Momentum-continuation di thrust. + + TOD Time-of-day seasonality (CONTROLLO calendario). Direzione per ora-del-giorno via media + espandente causale. Incluso APPOSTA per passarlo a day_boundary_robust: e' il tipo di + effetto che ha ucciso open_drive (artefatto di etichettatura del giorno UTC). + +GATE (CLAUDE.md): causale/no-leak, NETTO fee 0.10% RT + sweep 0.00-0.20% a freq reale, OOS +hold-out + griglia + plateau, day_boundary_robust per effetti calendario, MARGINAL vs TP01 +(earns_slot / has_insample_edge / multi-cut / non-hedge), corr con SKH01, haircut $600. + +Esecuzione: uv run python scripts/research/intraday_regime.py +Idempotente, niente scritture su disco (solo report a stdout). +""" +from __future__ import annotations + +import sys +from functools import lru_cache + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +ASSETS = ("BTC", "ETH") +SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h") + + +# =========================================================================== +# TARGET FACTORIES (ogni fattoria ritorna un target_fn(df) causale, posizione continua) +# =========================================================================== +def make_erm(tf: str, L_days: float, thr: float, long_flat: bool, + target_vol: float = 0.20): + """Efficiency-Ratio regime momentum. L_days = lunghezza finestra in GIORNI (-> barre via bpd).""" + def fn(df): + c = df["close"].values.astype(float) + n = len(c) + L = max(2, round(L_days * al.bars_per_day(df))) + net = np.full(n, np.nan) + net[L:] = c[L:] - c[:-L] + step = np.abs(np.diff(c, prepend=c[0])) # |c[k]-c[k-1]|, causale + path = pd.Series(step).rolling(L, min_periods=L).sum().values + er = np.divide(np.abs(net), path, out=np.zeros(n), where=(path > 0)) + active = (er >= thr) & np.isfinite(net) + raw = np.where(active, np.sign(net), 0.0) + if long_flat: + raw = np.clip(raw, 0.0, None) + return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) + return fn + + +def make_vem(tf: str, Lmom_days: float, Lshort_days: float, Llong_days: float, + long_flat: bool, target_vol: float = 0.20): + """Vol-expansion momentum: momentum attivo solo quando rv_corta > rv_lunga (espansione).""" + def fn(df): + c = df["close"].values.astype(float) + n = len(c) + bpd = al.bars_per_day(df) + Lmom = max(2, round(Lmom_days * bpd)) + ws, wl = max(2, round(Lshort_days * bpd)), max(3, round(Llong_days * bpd)) + r = al.simple_returns(c) + rv_s = al.rolling_std(r, ws) + rv_l = al.rolling_std(r, wl) + expand = (rv_s > rv_l) & np.isfinite(rv_s) & np.isfinite(rv_l) + net = np.full(n, np.nan) + net[Lmom:] = c[Lmom:] - c[:-Lmom] + raw = np.where(expand & np.isfinite(net), np.sign(net), 0.0) + if long_flat: + raw = np.clip(raw, 0.0, None) + return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) + return fn + + +def make_vbr(tf: str, k: float, atr_win: int, long_flat: bool, target_vol: float = 0.20): + """Thrust-breakout rolling: segui i moti significativi (|Δ| > k*ATR), altrimenti hold.""" + def fn(df): + c = df["close"].values.astype(float) + a = al.atr(df, atr_win) + a_prev = np.roll(a, 1); a_prev[0] = a[0] # ATR noto a inizio barra (causale) + delta = np.diff(c, prepend=c[0]) + sig = np.where(np.abs(delta) > k * a_prev, np.sign(delta), np.nan) + raw = pd.Series(sig).ffill().fillna(0.0).values + if long_flat: + raw = np.clip(raw, 0.0, None) + return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) + return fn + + +def make_tod(tf: str, long_flat: bool, target_vol: float = 0.20, min_obs: int = 20): + """Time-of-day seasonality (controllo calendario). Direzione = segno della media espandente + causale del rendimento della stessa ora-del-giorno. Da passare a day_boundary_robust.""" + def fn(df): + c = df["close"].values.astype(float) + r = al.simple_returns(c) + hour = pd.to_datetime(df["datetime"], utc=True).dt.hour.values + n = len(c) + sums = {}; cnts = {} + raw = np.zeros(n) + for i in range(1, n): + h_prev = int(hour[i - 1]) # aggiorna con la barra GIA' chiusa + sums[h_prev] = sums.get(h_prev, 0.0) + r[i - 1] + cnts[h_prev] = cnts.get(h_prev, 0) + 1 + h = int(hour[i]) + if cnts.get(h, 0) >= min_obs: + raw[i] = 1.0 if sums[h] >= 0 else -1.0 + if long_flat: + raw = np.clip(raw, 0.0, None) + return al.vol_target(raw, df, target_vol=target_vol, vol_win_days=30, leverage_cap=2.0) + return fn + + +# =========================================================================== +# SCREENING — griglia leggera per (asset,tf,params) via eval_weights (vettoriale). +# =========================================================================== +def _screen_cell(fn, tf): + """Min-asset full/hold Sharpe, fee@0.10 e @0.20 RT, turnover, time-in-market.""" + fulls, holds, f10, f20, turn, tim = [], [], [], [], [], [] + for a in ASSETS: + df = al.get(a, tf) + tgt = fn(df) + ev = al.eval_weights(df, tgt, fee_side=0.0005) # 0.10% RT + ev0 = al.eval_weights(df, tgt, fee_side=0.001) # 0.20% RT + fulls.append(ev["full"]["sharpe"]); holds.append(ev["holdout"].get("sharpe", 0.0)) + f10.append(ev["full"]["sharpe"]); f20.append(ev0["full"]["sharpe"]) + turn.append(ev["turnover_per_year"]); tim.append(ev["time_in_market"]) + return dict(tf=tf, min_full=round(min(fulls), 3), min_hold=round(min(holds), 3), + min_f10=round(min(f10), 3), min_f20=round(min(f20), 3), + turnover=round(float(np.mean(turn)), 1), tim=round(float(np.mean(tim)), 2)) + + +def screen_family(name, factory, grid, tfs=SCREEN_TFS): + """Esegue la griglia, ritorna lista di dict ordinata per min_hold (solo fee-surviving in cima).""" + rows = [] + for tf in tfs: + for params in grid: + fn = factory(tf=tf, **params) + m = _screen_cell(fn, tf) + m["params"] = params + m["fee_ok"] = bool(m["min_f20"] > 0) + rows.append(m) + rows.sort(key=lambda r: (r["fee_ok"], r["min_hold"]), reverse=True) + print(f"\n===== {name}: top celle (di {len(rows)}) =====") + print(f" {'tf':>4} {'minFull':>7} {'minHold':>7} {'f@.10':>6} {'f@.20':>6} " + f"{'turn/y':>7} {'tim':>5} feeOK params") + for r in rows[:10]: + print(f" {r['tf']:>4} {r['min_full']:+7.2f} {r['min_hold']:+7.2f} {r['min_f10']:+6.2f} " + f"{r['min_f20']:+6.2f} {r['turnover']:>7.0f} {r['tim']:>5.2f} " + f"{str(r['fee_ok']):>5} {r['params']}") + return rows + + +# =========================================================================== +# DEEP-DIVE sul vincitore: marginal vs TP01 + day_boundary + corr SKH01 + haircut $600. +# =========================================================================== +@lru_cache(maxsize=1) +def _skh_daily() -> pd.Series: + """Rendimenti giornalieri SKH01-V2-DD (50/50 BTC+ETH) dallo sleeve di progetto (read-only).""" + from src.portfolio.sleeves import _skyhook_returns + s = _skyhook_returns() + if s.index.tz is None: + s.index = s.index.tz_localize("UTC") + return s + + +def corr_to_skh(fn, tf) -> dict: + cand = al.candidate_daily(fn, tf=tf) + skh = _skh_daily() + J = pd.concat({"C": cand, "S": skh}, axis=1, join="inner").dropna() + JH = J[J.index >= al.HOLDOUT] + return dict(n=int(len(J)), + corr_full=round(float(J["C"].corr(J["S"])), 3) if len(J) > 5 else None, + corr_hold=round(float(JH["C"].corr(JH["S"])), 3) if len(JH) > 5 else None) + + +def haircut_600(fn, tf) -> dict: + """Sharpe onesto a $600: salta i ribilanci < $5 (eval_weights_smallcap), per asset + media.""" + out = {} + for a in ASSETS: + df = al.get(a, tf) + sc = al.eval_weights_smallcap(df, fn(df), capital=600.0, min_order=5.0) + out[a] = dict(modeled=sc["modeled"]["sharpe"], real=sc["realistic"]["sharpe"], + haircut=sc["sharpe_haircut"], n_tr=sc["n_executed_trades"]) + return out + + +def plateau_erm(tf="8h"): + """Plateau fine L_days x thr al TF vincente (min-asset full/hold/f@.20). Un edge vero ha un + PLATEAU, non una cella isolata.""" + print("\n" + "=" * 78) + print(f"PLATEAU ERM @ {tf} (min-asset; L_days righe, thr colonne) — full / hold / f@.20") + print("=" * 78) + Ls = (1.5, 2.0, 2.5, 3.0); thrs = (0.30, 0.35, 0.40, 0.45, 0.50) + print(" L\\thr " + "".join(f"{t:>16.2f}" for t in thrs)) + for L in Ls: + cells = [] + for t in thrs: + m = _screen_cell(make_erm(tf=tf, L_days=L, thr=t, long_flat=False), tf) + cells.append(f"{m['min_full']:+.2f}/{m['min_hold']:+.2f}/{m['min_f20']:+.2f}") + print(f" {L:>4.1f} " + "".join(f"{c:>16}" for c in cells)) + + +def vs_book(fn, tf): + """Il test decisivo del gate #5: ERM AGGIUNGE oltre il book esistente (TP01+SKH01), o e' + SKH01 travestito? Sharpe/DD full & hold dei blend incrementali su griglia giornaliera.""" + cand = al.candidate_daily(fn, tf=tf) + tp = al.tp01_baseline_daily() + skh = _skh_daily() + J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna() + JH = J[J.index >= al.HOLDOUT] + blends = [ + ("TP01", (1.0, 0.0, 0.0)), + ("TP01+SKH 75/25", (0.75, 0.25, 0.0)), + ("TP01+SKH+ERM 60/25/15", (0.60, 0.25, 0.15)), + ("TP01+SKH+ERM 55/20/25", (0.55, 0.20, 0.25)), + ] + print("\n" + "=" * 78) + print("vs BOOK ESISTENTE (TP01+SKH01) — ERM aggiunge oltre SKH? (gate #5)") + print("=" * 78) + print(f" {'blend':<26} {'FULL Sh':>8} {'FULL DD':>8} {'HOLD Sh':>8} {'HOLD DD':>8}") + for label, (wt, ws, wc) in blends: + bf = wt * J["T"] + ws * J["S"] + wc * J["C"] + bh = wt * JH["T"] + ws * JH["S"] + wc * JH["C"] + print(f" {label:<26} {al._sh(bf):>+8.2f} {al._dd_ret(bf) * 100:>7.1f}% " + f"{al._sh(bh):>+8.2f} {al._dd_ret(bh) * 100:>7.1f}%") + + +def deep_dive(name, fn, tf, calendar=False): + print("\n" + "#" * 78) + print(f"# DEEP-DIVE: {name} (tf={tf})") + print("#" * 78) + + caus = al.causality_ok(fn, tf=tf) + print(f"\n[CAUSALITA'] ok={caus['ok']} max_tail_diff={caus['max_tail_diff']} " + f"(checked={caus['checked']})") + + print("\n[FEE-SWEEP a frequenza reale] (study_weights su entrambi gli asset)") + sw = al.study_weights(name, fn, tfs=(tf,)) + print(al.fmt(sw)) + + print("\n[MARGINAL vs TP01]") + sm = al.study_marginal(name, fn, tf=tf) + print(al.fmt_marginal(sm)) + + sk = corr_to_skh(fn, tf) + print(f"\n[CORR con SKH01] full={sk['corr_full']} hold={sk['corr_hold']} " + f"(n_giorni={sk['n']})") + + if calendar: + print("\n[DAY-BOUNDARY ROBUST] (OBBLIGATORIO per effetti ora/sessione/giorno)") + else: + print("\n[DAY-BOUNDARY ROBUST] (sanity: un segnale di prezzo dev'essere ~INVARIANT)") + db = al.day_boundary_robust(fn, tf=tf) + print(f" verdict={db['verdict']} spread={db.get('spread')} " + f"min={db.get('min')} max={db.get('max')} per_offset={db.get('per_offset')}") + + print("\n[HAIRCUT $600] (eval_weights_smallcap: salta ribilanci < $5)") + hc = haircut_600(fn, tf) + for a, d in hc.items(): + print(f" {a}: modeled Sh {d['modeled']:+.2f} -> real Sh {d['real']:+.2f} " + f"(haircut {d['haircut']:+.2f}, trade eseguiti {d['n_tr']})") + + return dict(name=name, tf=tf, causal=caus["ok"], earns_slot=sm["earns_slot"], + marginal=sm["marginal_verdict"], corr_skh=sk, day_boundary=db["verdict"], + haircut=hc) + + +# =========================================================================== +# MAIN +# =========================================================================== +def main(): + print("=" * 78) + print("FILONE B — INTRADAY REGIME BTC/ETH (intraday_regime.py)") + print("=" * 78) + tp01 = al.tp01_baseline_daily() + print(f"Baseline TP01 (50/50) full Sharpe ~{al._sh(tp01):.2f} " + f"hold ~{al._sh(tp01[tp01.index >= al.HOLDOUT]):.2f} (riferimento marginale)") + + # ---- Griglie (compatte: plateau leggibile, no overfit di griglia gigante) ---- + erm_grid = [dict(L_days=L, thr=t, long_flat=lf) + for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)] + vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf) + for lm in (1.0, 3.0) for lf in (False, True)] + vbr_grid = [dict(k=k, atr_win=14, long_flat=lf) + for k in (0.5, 1.0, 1.5) for lf in (False, True)] + tod_grid = [dict(long_flat=lf) for lf in (False, True)] + + fam = { + "ERM": (make_erm, erm_grid, SCREEN_TFS), + "VEM": (make_vem, vem_grid, ("4h", "6h", "8h", "12h")), + "VBR": (make_vbr, vbr_grid, ("4h", "6h", "8h", "12h")), + "TOD": (make_tod, tod_grid, ("1h",)), + } + screens = {} + for name, (factory, grid, tfs) in fam.items(): + screens[name] = screen_family(name, factory, grid, tfs) + + # ---- Vincitore per famiglia (best min_hold tra le fee-surviving con min_full>0) ---- + print("\n" + "=" * 78) + print("VINCITORI PER FAMIGLIA (best min_hold tra fee-surviving, min_full>0)") + print("=" * 78) + winners = {} + for name, (factory, grid, tfs) in fam.items(): + ok = [r for r in screens[name] if r["fee_ok"] and r["min_full"] > 0] + pool = ok if ok else screens[name] + w = max(pool, key=lambda r: r["min_hold"]) + winners[name] = w + print(f" {name}: tf={w['tf']} {w['params']} minFull={w['min_full']:+.2f} " + f"minHold={w['min_hold']:+.2f} f@.20={w['min_f20']:+.2f} feeOK={w['fee_ok']}") + + # ---- Deep-dive sui due meccanismi piu' promettenti (per min_hold) + il controllo TOD ---- + ranked = sorted(["ERM", "VEM", "VBR"], + key=lambda n: winners[n]["min_hold"], reverse=True) + deep = [] + for name in ranked[:2]: + w = winners[name] + factory = fam[name][0] + fn = factory(tf=w["tf"], **w["params"]) + deep.append(deep_dive(f"{name} {w['params']}", fn, w["tf"], calendar=False)) + + # controllo calendario: TOD passa SEMPRE per day_boundary_robust + wt = winners["TOD"] + fn_tod = make_tod(tf=wt["tf"], **wt["params"]) + deep.append(deep_dive(f"TOD {wt['params']}", fn_tod, wt["tf"], calendar=True)) + + # ---- Analisi extra sul vincitore ERM (plateau fine + vs book TP01+SKH01) ---- + we = winners["ERM"] + fn_erm = make_erm(tf=we["tf"], **we["params"]) + plateau_erm(we["tf"]) + vs_book(fn_erm, we["tf"]) + + # ---- Verdetto sintetico ---- + print("\n" + "=" * 78) + print("SINTESI") + print("=" * 78) + for d in deep: + print(f" {d['name']:<26} tf={d['tf']:>3} | marginal={d['marginal']:<9} " + f"earns_slot={d['earns_slot']!s:<5} corrSKH(full/hold)=" + f"{d['corr_skh']['corr_full']}/{d['corr_skh']['corr_hold']} " + f"day_boundary={d['day_boundary']}") + any_slot = any(d["earns_slot"] for d in deep) + print(f"\n => earns_slot su qualche meccanismo? {any_slot}") + print(" (vedi diario docs/diary/2026-06-29-intraday-regime.md per il verdetto ragionato)") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/macro_regime_gate.py b/scripts/research/macro_regime_gate.py new file mode 100644 index 0000000..459ee20 --- /dev/null +++ b/scripts/research/macro_regime_gate.py @@ -0,0 +1,458 @@ +"""macro_regime_gate.py — Filone D: MACRO REGIME-GATE sul book crypto (eseguibile). + +TESI +---- +Usare segnali macro/cross-market — equity (SPY/QQQ/IWM), credito (HYG/LQD), oro (GLD/SLV), +tassi (TLT/IEF) — come GATE risk-on/risk-off applicato al book crypto (BTC/ETH) per migliorare +il TIMING del drawdown di TP01. Quando il regime macro e' risk-off (credito che cede, equity +sotto trend, fuga sui bond) -> riduci/azzera l'esposizione crypto; risk-on -> lascia agire TP01. +E' ESEGUIBILE perche' GATA solo BTC/ETH perp (non aggiunge gambe). + +NON e' un lead-lag direzionale (gia' esplorato e morto: vedi 2026-06-22/-23 leadlag diaries). +L'angolo nuovo = un OVERLAY binario/continuo di DE-RISK sul book. + +IL RISCHIO (da CLAUDE.md): il gate di de-risk rischia di essere RIDONDANTE col trend — TP01 e' +gia' long-flat e va a 0 nei crash (lezione DVOL-spike "ridondante col trend, Delta 0.00"). Questo +script DEVE dimostrare che il gate aggiunge OLTRE quel che TP01 fa da solo, altrimenti SCARTATO. + +CAUSALITA' (fusi orari, regola di prim'ordine) +---------------------------------------------- +- Barre equity: open-labeled a 00:00 del giorno di trading; il CLOSE e' ~20:00-21:00 UTC dello + STESSO giorno (NYSE 16:00 ET). +- Barre crypto 1d: open-labeled a 00:00; il CLOSE e' a 00:00 UTC del giorno DOPO. TP01 decide la + posizione a close[i] e la TIENE durante la barra i+1 (eval_weights shift-a per te). +- Quindi: gate[i] allineato (merge_asof backward, equity-label <= crypto-label day i) usa il + close equity del giorno i (noto ~20:00 day i) per la posizione tenuta durante la barra i+1 + (giorno i+1). Margine causale >= 4h. Leak-free. Variante STRICT (equity-label < crypto-label) + come margine extra. Verifica con al.causality_ok + day_boundary_robust. + +USO: uv run python scripts/research/macro_regime_gate.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(_ROOT)) +sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt")) + +import altlib as al # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 + +DATA = _ROOT / "data" / "raw" +HOLDOUT = al.HOLDOUT +ASSETS = ("BTC", "ETH") + + +# =========================================================================== +# MACRO FRAME — ETF daily, allineati causalmente sul calendario SPY (master). +# =========================================================================== +def _load_eq(sym: str) -> pd.DataFrame: + p = DATA / f"eq_{sym}_1d.parquet" + df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True) + df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + return df[["timestamp", "dt", "close"]].rename(columns={"close": sym}) + + +def macro_frame(syms=("spy", "qqq", "iwm", "hyg", "lqd", "gld", "slv", "tlt", "ief")) -> pd.DataFrame: + """Frame macro sul calendario NYSE (master = SPY). Ogni colonna e' il close equity, + allineato causale (merge_asof backward) -> nessun valore futuro per riga.""" + base = _load_eq("spy")[["timestamp", "dt"]].copy() + out = base + for s in syms: + e = _load_eq(s)[["timestamp", s]] + out = pd.merge_asof(out, e, on="timestamp", direction="backward") + return out + + +# =========================================================================== +# GATE BUILDERS — ognuno ritorna (timestamp_ms, gate in [0,1]) sul calendario equity. +# Tutti CAUSALI: la riga i usa solo close <= riga i (rolling/SMA, niente expanding-future). +# gate=1 => risk-on (TP01 pieno); gate=g_off (0 o 0.5) => risk-off (de-risk). +# =========================================================================== +def _sma(x: np.ndarray, n: int) -> np.ndarray: + return pd.Series(x).rolling(n, min_periods=n).mean().values + + +def _ret(x: np.ndarray, n: int) -> np.ndarray: + r = np.full(len(x), np.nan) + r[n:] = x[n:] / x[:-n] - 1.0 + return r + + +def gate_trend(mf: pd.DataFrame, col: str, n: int, g_off: float) -> pd.DataFrame: + """Risk-on se col_close > SMA(col, n). Filtro di trend classico (SPY200, HYG, ...).""" + c = mf[col].values.astype(float) + on = c > _sma(c, n) + g = np.where(on, 1.0, g_off) + g[~np.isfinite(c)] = np.nan + g[np.isnan(_sma(c, n))] = np.nan + return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g}) + + +def gate_ratio(mf: pd.DataFrame, num: str, den: str, n: int, g_off: float) -> pd.DataFrame: + """Risk-on se ratio num/den (proxy spread di credito) > la sua SMA(n). + HYG/LQD o HYG/IEF in calo = spread che si allarga = risk-off.""" + ratio = (mf[num].values.astype(float) / mf[den].values.astype(float)) + on = ratio > _sma(ratio, n) + g = np.where(on, 1.0, g_off) + g[~np.isfinite(ratio)] = np.nan + g[np.isnan(_sma(ratio, n))] = np.nan + return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g}) + + +def gate_combo(mf: pd.DataFrame, n: int, g_off: float, thr: float = 0.5, + continuous: bool = False) -> pd.DataFrame: + """Score di regime = media di 3 voti risk-on: SPY>SMA, HYG>SMA, HYG/LQD>SMA. + continuous=True -> gate = g_off + (1-g_off)*score (de-risk graduale). + continuous=False -> gate = 1 se score>=thr else g_off (binario su maggioranza).""" + spy = mf["spy"].values.astype(float) + hyg = mf["hyg"].values.astype(float) + ratio = hyg / mf["lqd"].values.astype(float) + votes = np.vstack([spy > _sma(spy, n), hyg > _sma(hyg, n), ratio > _sma(ratio, n)]).astype(float) + warm = np.isnan(_sma(spy, n)) | np.isnan(_sma(hyg, n)) | np.isnan(_sma(ratio, n)) + score = votes.mean(axis=0) + if continuous: + g = g_off + (1.0 - g_off) * score + else: + g = np.where(score >= thr, 1.0, g_off) + g[warm] = np.nan + return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g}) + + +def gate_flight(mf: pd.DataFrame, safe: str, risk: str, n: int, g_off: float) -> pd.DataFrame: + """Fuga verso la sicurezza: risk-off quando il safe-asset (TLT/GLD) sale MENTRE il risk + (SPY) scende sull'orizzonte n. Divergenza risk-off classica (flight-to-quality).""" + s = mf[safe].values.astype(float) + rk = mf[risk].values.astype(float) + off = (_ret(s, n) > 0) & (_ret(rk, n) < 0) + g = np.where(off, g_off, 1.0) + warm = np.isnan(_ret(s, n)) | np.isnan(_ret(rk, n)) + g[warm] = np.nan + return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g}) + + +def gate_eqvol(mf: pd.DataFrame, n: int, win: int, z: float, g_off: float) -> pd.DataFrame: + """Regime di vol equity: de-risk quando la vol realizzata SPY (win g) e' alta vs la sua + storia espandente-causale (z-score > z). Proxy 'VIX spike' senza VIX.""" + spy = mf["spy"].values.astype(float) + r = np.zeros(len(spy)); r[1:] = spy[1:] / spy[:-1] - 1.0 + rv = pd.Series(r).rolling(win, min_periods=win).std().values + zsc = al.zscore(rv, n) + off = zsc > z + g = np.where(off, g_off, 1.0) + g[~np.isfinite(zsc)] = np.nan + return pd.DataFrame({"timestamp": mf["timestamp"].values, "gate": g}) + + +# =========================================================================== +# ALIGN GATE -> CRYPTO (causale) + GATED TARGET +# =========================================================================== +def align_gate(gate_df: pd.DataFrame, crypto_df: pd.DataFrame, strict: bool = False) -> np.ndarray: + """Allinea il gate (calendario equity) alle barre crypto. merge_asof backward: + crypto-label day i -> ultimo gate equity con label <= day i (strict: < day i). + NaN pre-storia -> gate=1 (nessun de-risk quando non c'e' info).""" + left = pd.DataFrame({"timestamp": crypto_df["timestamp"].astype("int64").values}) + g = gate_df.dropna(subset=["gate"]).sort_values("timestamp") + m = pd.merge_asof(left, g, on="timestamp", direction="backward", + allow_exact_matches=not strict) + return pd.Series(m["gate"].values).ffill().fillna(1.0).values + + +def tp01_pos(df: pd.DataFrame) -> np.ndarray: + return TrendPortfolio(**CANONICAL).target_series(df) + + +def make_target_fn(gate_builder, strict: bool = False): + """Ritorna target_fn(df, asset) = posizione TP01 * gate macro allineato (causale). + gate_builder() costruisce il gate sul calendario equity una volta (cache esterna).""" + _MF = macro_frame() + gate_df = gate_builder(_MF) + + def target_fn(df: pd.DataFrame, asset: str = "") -> np.ndarray: + pos = tp01_pos(df) + g = align_gate(gate_df, df, strict=strict) + return pos * g + return target_fn, gate_df + + +# =========================================================================== +# EVALUATION — solo vs gated, combo 50/50 + per-asset, FULL/HOLD/DD/CAGR. +# =========================================================================== +def _combo_daily(target_fn) -> pd.Series: + series = {} + for a in ASSETS: + df = al.get(a, "1d") + ev = al.eval_weights(df, target_fn(df, a)) + series[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) + + +def _sh(s): + return al._sh(s) + + +def _metrics(s: pd.Series) -> dict: + sh = _sh(s) + eq = np.cumprod(1.0 + s.values) + pk = np.maximum.accumulate(eq) + dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0 + yrs = max((s.index[-1] - s.index[0]).days / 365.25, 1e-6) + cagr = eq[-1] ** (1 / yrs) - 1 if len(eq) and eq[-1] > 0 else -1.0 + return dict(sharpe=round(sh, 3), dd=round(dd, 4), cagr=round(cagr, 4)) + + +def eval_solo_vs_gated(target_fn): + """Combo 50/50: solo (gate=1) vs gated. Ritorna dict con FULL e HOLD per entrambi.""" + solo_fn = lambda df, a="": tp01_pos(df) + solo = _combo_daily(solo_fn) + gated = _combo_daily(target_fn) + J = pd.concat({"solo": solo, "gated": gated}, axis=1, join="inner").dropna() + JH = J[J.index >= HOLDOUT] + return dict( + full_solo=_metrics(J["solo"]), full_gated=_metrics(J["gated"]), + hold_solo=_metrics(JH["solo"]), hold_gated=_metrics(JH["gated"]), + n=len(J), nh=len(JH), solo_series=J["solo"], gated_series=J["gated"]) + + +def per_asset_table(target_fn) -> dict: + out = {} + for a in ASSETS: + df = al.get(a, "1d") + solo = al.eval_weights(df, tp01_pos(df)) + gated = al.eval_weights(df, target_fn(df, a)) + out[a] = dict( + full_solo=dict(sharpe=solo["full"]["sharpe"], dd=solo["full"]["maxdd"], cagr=solo["full"]["cagr"]), + full_gated=dict(sharpe=gated["full"]["sharpe"], dd=gated["full"]["maxdd"], cagr=gated["full"]["cagr"]), + hold_solo=dict(sharpe=solo["holdout"].get("sharpe", 0.0)), + hold_gated=dict(sharpe=gated["holdout"].get("sharpe", 0.0))) + return out + + +# =========================================================================== +# REDUNDANCY DIAGNOSTIC — il controllo DECISIVO contro il trend. +# =========================================================================== +def redundancy_diag(gate_df: pd.DataFrame) -> dict: + """Quanto FA davvero il gate, dato che TP01 e' gia' flat nei crash? + - exposure TP01 nei giorni risk-off (gate<1) vs risk-on: se gia' ~0 -> ridondante. + - quota di giorni in cui il gate riduce una posizione NON gia' flat (lavoro effettivo). + - corr fra (1-gate) e (1-exposure_norm).""" + lev = CANONICAL["leverage"] + rows = [] + for a in ASSETS: + df = al.get(a, "1d") + pos = tp01_pos(df) + expo = np.clip(np.abs(pos) / lev, 0, 1) # esposizione normalizzata 0..1 + g = align_gate(gate_df, df) + dt = pd.to_datetime(df["datetime"], utc=True) + m = pd.DataFrame({"dt": dt, "expo": expo, "g": g}) + m = m[m["dt"] >= dt.iloc[0]] # tutto + roff = m["g"] < 0.999 + ron = ~roff + flat_thr = 0.05 + # lavoro effettivo: gate<1 E TP01 non gia' flat + work = roff & (m["expo"] > flat_thr) + rows.append(dict( + asset=a, + expo_riskoff=round(float(m.loc[roff, "expo"].mean()) if roff.any() else 0.0, 3), + expo_riskon=round(float(m.loc[ron, "expo"].mean()) if ron.any() else 0.0, 3), + pct_days_riskoff=round(float(roff.mean()), 3), + pct_days_gate_works=round(float(work.mean()), 3), + corr_1mg_1mexpo=round(float(np.corrcoef(1 - m["g"], 1 - m["expo"])[0, 1]) + if m["g"].std() > 0 else float("nan"), 3), + )) + return {r["asset"]: r for r in rows} + + +# =========================================================================== +# RUNNER +# =========================================================================== +GATES = { + # binari classici + "SPY>MA200": lambda mf: gate_trend(mf, "spy", 200, 0.0), + "SPY>MA100": lambda mf: gate_trend(mf, "spy", 100, 0.0), + "SPY>MA50": lambda mf: gate_trend(mf, "spy", 50, 0.0), + "SPY>MA200_half": lambda mf: gate_trend(mf, "spy", 200, 0.5), + "HYG>MA100": lambda mf: gate_trend(mf, "hyg", 100, 0.0), + "HYG/LQD>MA50": lambda mf: gate_ratio(mf, "hyg", "lqd", 50, 0.0), + "HYG/LQD>MA100": lambda mf: gate_ratio(mf, "hyg", "lqd", 100, 0.0), + # combinati / score + "COMBO_maj100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.5), + "COMBO_all100": lambda mf: gate_combo(mf, 100, 0.0, thr=0.99), + "COMBO_cont100": lambda mf: gate_combo(mf, 100, 0.0, continuous=True), + "COMBO_cont100h": lambda mf: gate_combo(mf, 100, 0.5, continuous=True), + # flight-to-quality / vol + "TLTup&SPYdn20": lambda mf: gate_flight(mf, "tlt", "spy", 20, 0.0), + "GLDup&SPYdn20": lambda mf: gate_flight(mf, "gld", "spy", 20, 0.0), + "SPYvol_z1": lambda mf: gate_eqvol(mf, 250, 20, 1.0, 0.0), +} + + +def delever_frontier(target_vols=(0.10, 0.12, 0.14, 0.16, 0.18, 0.20)) -> dict: + """CONTROLLO DECISIVO (lezione DVOL-overlay): per meno DD la leva e' target_vol, non un + overlay. Frontiera DD/Sharpe di TP01 puro a target_vol decrescente. Se il miglior gate + sta SOPRA (DD piu' alto a parita' di Sharpe, o Sharpe piu' basso a parita' di DD) di questa + frontiera, il suo taglio di DD e' solo de-levering replicabile meglio senza macro.""" + out = {} + for tv in target_vols: + cfg = {**CANONICAL, "target_vol": tv} + fn = lambda df, a="", cfg=cfg: TrendPortfolio(**cfg).target_series(df) + c = _combo_daily(fn) + out[tv] = _metrics(c) + return out + + +def fmt_cmp(label, m_solo, m_gated) -> str: + ds = m_gated["sharpe"] - m_solo["sharpe"] + dd = m_gated["dd"] - m_solo["dd"] + dc = m_gated.get("cagr", 0) - m_solo.get("cagr", 0) + return (f" {label:5s} Sh {m_solo['sharpe']:+.2f}->{m_gated['sharpe']:+.2f} (d{ds:+.2f}) " + f"DD {m_solo['dd']*100:4.1f}%->{m_gated['dd']*100:4.1f}% (d{dd*100:+.1f}pp) " + f"CAGR {m_solo.get('cagr',0)*100:+5.1f}%->{m_gated.get('cagr',0)*100:+5.1f}% (d{dc*100:+.1f}pp)") + + +def main(): + pd.set_option("display.width", 160) + print("=" * 92) + print("MACRO REGIME-GATE sul book crypto (TP01 BTC/ETH) — Filone D") + print(f" TP01 CANONICAL = {CANONICAL}") + print(f" HOLD-OUT >= {HOLDOUT.date()} fee {al.FEE_SIDE*2*100:.2f}%RT") + mf = macro_frame() + print(f" Macro frame: {len(mf)} barre {mf['dt'].iloc[0].date()} -> {mf['dt'].iloc[-1].date()} " + f"cols={[c for c in mf.columns if c not in ('timestamp','dt')]}") + print("=" * 92) + + # ---- 1) SWEEP DI TUTTI I GATE (combo 50/50) ------------------------------------- + print("\n[1] SWEEP GATE — combo 50/50 BTC+ETH, FULL & HOLD-OUT, vs TP01-solo\n") + results = {} + for name, builder in GATES.items(): + tf, gate_df = make_target_fn(builder) + cmp = eval_solo_vs_gated(tf) + results[name] = (cmp, gate_df, tf) + print(f"GATE {name}") + print(fmt_cmp("FULL", cmp["full_solo"], cmp["full_gated"])) + print(fmt_cmp("HOLD", cmp["hold_solo"], cmp["hold_gated"])) + + # baseline (solo) numbers come from any cmp + any_cmp = next(iter(results.values()))[0] + print(f"\n [baseline TP01-solo] FULL Sh {any_cmp['full_solo']['sharpe']} DD {any_cmp['full_solo']['dd']*100:.1f}% " + f"CAGR {any_cmp['full_solo']['cagr']*100:.1f}% | HOLD Sh {any_cmp['hold_solo']['sharpe']} " + f"DD {any_cmp['hold_solo']['dd']*100:.1f}% CAGR {any_cmp['hold_solo']['cagr']*100:.1f}%") + + # ---- 2) SELEZIONE: miglior gate per HOLD-OUT Sharpe, poi per riduzione DD -------- + def score(name): + c = results[name][0] + return (c["hold_gated"]["sharpe"], -c["full_gated"]["dd"]) + best = max(results, key=score) + # anche il "miglior DD-cutter" che non peggiora troppo lo Sharpe FULL + dd_best = min(results, key=lambda n: results[n][0]["full_gated"]["dd"]) + print(f"\n[2] Miglior gate per HOLD-OUT Sharpe: {best}") + print(f" Miglior gate per DD FULL ridotto : {dd_best}") + + # ---- CONTROLLO DECISIVO: de-lever frontier (target_vol) ------------------------- + print("\n[2b] CONTROLLO DECISIVO — TP01 puro a target_vol piu' basso (de-lever) vs gate:") + fr = delever_frontier() + for tv, m in fr.items(): + print(f" target_vol {tv:.2f}: FULL Sh {m['sharpe']:+.2f} DD {m['dd']*100:4.1f}% CAGR {m['cagr']*100:+5.1f}%") + print(" -> i gate de-leveranti (COMBO_cont, SPYvol) vanno confrontati con QUESTA frontiera:") + for n in ("COMBO_cont100", "COMBO_cont100h", "SPYvol_z1", "SPY>MA200_half"): + if n in results: + g = results[n][0]["full_gated"] + print(f" {n:16s}: FULL Sh {g['sharpe']:+.2f} DD {g['dd']*100:4.1f}% CAGR {g['cagr']*100:+5.1f}%") + + for tag, name in [("BEST-HOLD", best), ("BEST-DD", dd_best)]: + if tag == "BEST-DD" and dd_best == best: + continue + deep_dive(tag, name, results) + + +def deep_dive(tag, name, results): + cmp, gate_df, tf = results[name] + print("\n" + "=" * 92) + print(f"[DEEP DIVE {tag}] GATE = {name}") + print("=" * 92) + + # per-asset + print("\n Per-asset (TP01-solo -> TP01+gate):") + pa = per_asset_table(tf) + for a in ASSETS: + d = pa[a] + print(f" {a}: FULL Sh {d['full_solo']['sharpe']:+.2f}->{d['full_gated']['sharpe']:+.2f} " + f"DD {d['full_solo']['dd']*100:.0f}%->{d['full_gated']['dd']*100:.0f}% " + f"CAGR {d['full_solo']['cagr']*100:+.0f}%->{d['full_gated']['cagr']*100:+.0f}% | " + f"HOLD Sh {d['hold_solo']['sharpe']:+.2f}->{d['hold_gated']['sharpe']:+.2f}") + + # ---- 3) CONTROLLO RIDONDANZA COL TREND ------------------------------------------ + print("\n [3] CONTROLLO RIDONDANZA COL TREND (il test decisivo):") + rd = redundancy_diag(gate_df) + for a in ASSETS: + r = rd[a] + print(f" {a}: exposure TP01 nei giorni risk-off={r['expo_riskoff']} vs risk-on={r['expo_riskon']} " + f"| giorni risk-off {r['pct_days_riskoff']*100:.0f}% " + f"giorni in cui il gate LAVORA (riduce pos non-flat) {r['pct_days_gate_works']*100:.0f}% " + f"| corr(1-gate, 1-expo)={r['corr_1mg_1mexpo']}") + print(" -> se exposure-risk-off ~ exposure-risk-on e 'gate-lavora' e' basso => RIDONDANTE col trend.") + + # ---- 4) MARGINAL SCORER ----------------------------------------------------------- + print("\n [4] MARGINAL SCORER vs TP01 (gate come candidato-sleeve):") + rep = al.study_marginal(f"GATE[{name}]", tf, tf="1d") + print(al.fmt_marginal(rep)) + + # overlay-delta: lo STREAM incrementale del gate = gated - solo (e' alpha o hedge?) + delta = (cmp["gated_series"] - cmp["solo_series"]).dropna() + print("\n overlay-delta (gated - solo) come stream a se':") + md = al.marginal_vs_tp01(delta) + print(f" verdict={md.get('marginal_verdict')} corr->TP01 {md.get('corr_full')} " + f"is_hedge={md.get('is_hedge')} uplift TP01-up {md.get('uplift_tp01_up')} / " + f"TP01-down {md.get('uplift_tp01_down')} cand-Sh full {md.get('cand_full_sharpe')}") + + # ---- 5) FEE SWEEP ----------------------------------------------------------------- + print("\n [5] FEE SWEEP (combo 50/50 gated, FULL Sharpe):") + for f in al.FEE_SWEEP: + series = {} + for a in ASSETS: + df = al.get(a, "1d") + ev = al.eval_weights(df, tf(df, a), fee_side=f) + series[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + d = al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) + print(f" {2*f*100:.2f}%RT -> Sh {_sh(d):+.2f}") + + # ---- 6) ESEGUIBILITA' a $600 ------------------------------------------------------ + print("\n [6] ESEGUIBILITA' a $600 (eval_weights_smallcap, haircut reale):") + for a in ASSETS: + df = al.get(a, "1d") + sc = al.eval_weights_smallcap(df, tf(df, a), capital=600, min_order=5) + print(f" {a}: modeled Sh {sc['modeled']['sharpe']:+.2f} -> real Sh {sc['realistic']['sharpe']:+.2f} " + f"haircut {sc['sharpe_haircut']:+.2f} trade eseguiti {sc['n_executed_trades']}") + + # ---- 7) LEAK / BOUNDARY ----------------------------------------------------------- + print("\n [7] LEAK-FREE & BOUNDARY:") + cz = al.causality_ok(tf, tf="1d") + print(f" causality_ok={cz['ok']} (max_tail_diff {cz['max_tail_diff']}, checked {cz['checked']})") + # variante STRICT (equity-label < crypto-label): margine causale extra + tf_strict, _ = make_target_fn(GATES[name], strict=True) + cmp_s = eval_solo_vs_gated(tf_strict) + print(f" STRICT align (1 barra equity extra di lag): FULL Sh {cmp_s['full_gated']['sharpe']:+.2f} " + f"(vs {cmp['full_gated']['sharpe']:+.2f}) HOLD Sh {cmp_s['hold_gated']['sharpe']:+.2f} " + f"(vs {cmp['hold_gated']['sharpe']:+.2f}) -> robusto se ~uguale") + db = al.day_boundary_robust(tf, tf="1d") + print(f" day_boundary_robust={db['verdict']} (spread {db.get('spread')}, per-offset {db.get('per_offset')})") + + # ---- 8) PLATEAU (solo per i trend MA) --------------------------------------------- + if name.startswith("SPY>MA") or name.startswith("HYG"): + print("\n [8] PLATEAU su finestra MA (SPY trend), g_off=0:") + for n in (50, 100, 150, 200, 250): + tfn, _ = make_target_fn(lambda mf, n=n: gate_trend(mf, "spy", n, 0.0)) + cc = eval_solo_vs_gated(tfn) + print(f" SPY>MA{n:3d}: FULL Sh {cc['full_gated']['sharpe']:+.2f} DD {cc['full_gated']['dd']*100:.1f}% " + f"HOLD Sh {cc['hold_gated']['sharpe']:+.2f} DD {cc['hold_gated']['dd']*100:.1f}%") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/xsec_v2_nonmom.py b/scripts/research/xsec_v2_nonmom.py new file mode 100644 index 0000000..70d46c7 --- /dev/null +++ b/scripts/research/xsec_v2_nonmom.py @@ -0,0 +1,433 @@ +"""XSEC v2 — segnali cross-sectional NON-MOMENTUM su 51 asset Hyperliquid (STAT-MODE). + +TESI (filone C). XS01 (sleeve attivo) e' momentum cross-sectional sui 19 major. Lezione del +progetto (diari 2026-06-19/20): ESPANDERE IL NUMERO di asset NON aiuta il momentum (gli small-cap +diluiscono/invertono il segnale). Quindi qui NON ri-proviamo l'espansione-universo: cerchiamo un +MECCANISMO DIVERSO dal momentum che, market-neutral e scorrelato, possa diversificare il portafoglio. + +Meccanismi provati (tutti L/S dollar-neutral, vol-target ~20%, ribilancio periodico, CAUSALI): + REV - short-term REVERSAL cross-sectional grezzo (long i loser di breve, short i winner). + IREV - REVERSAL IDIOSINCRATICO: reversal sul RESIDUO dopo aver tolto il mercato (beta-adjusted). + LOWVOL - factor LOW-VOL: long bassa vol realizzata / short alta vol (betting-against-vol). + IMOM - MOMENTUM IDIOSINCRATICO: momentum sul residuo (toglie il fattore mercato, != raw mom). + BAB - betting-against-beta: long basso beta / short alto beta. + MOM - (riferimento) momentum grezzo, per confronto. + +GATE (CLAUDE.md, metodologia obbligatoria): + 1. CAUSALE: score a close[i], peso tenuto in i+1 (l'engine shifta: W[i-1]*dret[i]); vol=0 gata. + 2. NETTO fee 0.10% RT su OGNI gamba a OGNI ribilancio + sweep fee. + 3. OOS hold-out 2025-01-01 + plateau su (lookback, H, k) + 2 universi (51 vs 19 major). + 4. Storia ~2.5 anni + molte config -> DEFLATED Sharpe (multiple-testing) e onesta' brutale. + 5. Confronto: Sharpe standalone FULL/HOLD/DD, corr vs XS01 e TP01, uplift del portafoglio a 4->5 + sleeve (portfolio.py, riusa active_sleeves senza modificarli). + 6. CAVEAT: book a molte gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, non deploy. + + uv run python scripts/research/xsec_v2_nonmom.py +""" +from __future__ import annotations +import sys, glob, math +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np +import pandas as pd +from scipy.stats import norm + +from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio +from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve, active_sleeves, XS_UNIVERSE + +RAW = PROJECT_ROOT / "data" / "raw" +FEE = 0.001 # 0.10% RT (Deribit taker): fee per gamba per lato = FEE/2 = 0.0005 +TV = 0.20 # vol-target annuo +DPY = 365.25 + + +# =========================================================================== +# DATI — matrice prezzi/volumi (outer-join: ragged start, NaN prima del listing) +# =========================================================================== +def load_matrix(universe=None): + px, vol = {}, {} + files = sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))) + for f in files: + sym = Path(f).stem.replace("hl_", "").replace("_1d", "").upper() + if universe is not None and sym not in universe: + continue + d = pd.read_parquet(f) + idx = pd.to_datetime(d["timestamp"], unit="ms", utc=True) + px[sym] = pd.Series(d["close"].values.astype(float), index=idx) + vol[sym] = pd.Series(d["volume"].values.astype(float), index=idx) + PX = pd.concat(px, axis=1).sort_index() + VOL = pd.concat(vol, axis=1).sort_index().reindex_like(PX) + return PX, VOL + + +# =========================================================================== +# ENGINE cross-sectional NaN-aware (causale). score_at(i)->(score[A], valid[A]). +# Convenzione UNICA: long alto score / short basso score. Ogni meccanismo passa +# lo score giusto (es. reversal = -ritorno; low-vol = -vol; bab = -beta). +# =========================================================================== +def xs_engine(PX, VOL, score_at, H, k, target_vol=TV, fee=FEE, min_assets=10, warmup=0): + px = PX.values + vol = VOL.values + n, A = px.shape + dret = np.full((n, A), np.nan) + dret[1:] = px[1:] / px[:-1] - 1.0 + W = np.zeros((n, A)) + w = np.zeros(A) + for i in range(n): + if i >= warmup and i % H == 0: + score, valid = score_at(i) + valid = valid & np.isfinite(score) & (vol[i] > 0) + idxv = np.where(valid)[0] + if len(idxv) >= min_assets: + kk = min(k, len(idxv) // 2) + order = idxv[np.argsort(score[idxv])] # ascendente + lo, hi = order[:kk], order[-kk:] # basso score / alto score + w = np.zeros(A) + w[hi] = 0.5 / kk # long alto score + w[lo] = -0.5 / kk # short basso score + else: + w = np.zeros(A) + W[i] = w + # rendimento book: W[i-1] guadagna dret[i]; NaN (asset non listato) -> 0 + gross = np.zeros(n) + gross[1:] = np.nansum(W[:-1] * np.nan_to_num(dret[1:]), axis=1) + turn = np.zeros(n) + turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (fee / 2.0) + s = pd.Series(net, index=PX.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(DPY) + scale = np.clip(np.nan_to_num(target_vol / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + turn_py = float(turn.sum() / (n / DPY)) if n else 0.0 + return pd.Series(s.values * scale, index=PX.index), turn_py + + +# =========================================================================== +# SCORE BUILDERS — ognuno ritorna una closure score_at(i) + warmup richiesto. +# Tutti CAUSALI: usano dati <= i (close[i] noto al momento della decisione). +# =========================================================================== +def _precompute(PX): + px = PX.values + n, A = px.shape + DR = PX.pct_change() # ritorni giornalieri (NaN ragged) + m = DR.mean(axis=1) # mercato equal-weight (skipna) + return px, n, A, DR, m + + +def make_mom(PX, L, sign=+1): + px, n, A, *_ = _precompute(PX) + def score_at(i): + if i - L < 0: + return np.full(A, np.nan), np.zeros(A, bool) + r = px[i] / px[i - L] - 1.0 + valid = np.isfinite(px[i]) & np.isfinite(px[i - L]) + return sign * r, valid + return score_at, L + 1 + + +def make_lowvol(PX, B): + px, n, A, DR, m = _precompute(PX) + RV = DR.rolling(B, min_periods=int(0.6 * B)).std().values + def score_at(i): + rv = RV[i] + valid = np.isfinite(rv) & np.isfinite(px[i]) + return -rv, valid # long bassa vol / short alta vol + return score_at, B + 1 + + +def _rolling_beta(DR, m, B): + mp = int(0.6 * B) + Em = m.rolling(B, min_periods=mp).mean() + Em2 = (m * m).rolling(B, min_periods=mp).mean() + varm = Em2 - Em ** 2 + Ex = DR.rolling(B, min_periods=mp).mean() + Exm = DR.mul(m, axis=0).rolling(B, min_periods=mp).mean() + beta = Exm.sub(Ex.mul(Em, axis=0)).div(varm.replace(0, np.nan), axis=0) + return beta.values, varm.values + + +def make_bab(PX, B): + px, n, A, DR, m = _precompute(PX) + beta, _ = _rolling_beta(DR, m, B) + def score_at(i): + b = beta[i] + valid = np.isfinite(b) & np.isfinite(px[i]) + return -b, valid # long basso beta / short alto beta + return score_at, B + 1 + + +def make_resid(PX, L, B, sign): + """Momentum/reversal IDIOSINCRATICO: residuo = ritorno - beta*mercato (beta su finestra B), + cumulato sugli ultimi L giorni. sign=+1 -> momentum residuo; sign=-1 -> reversal residuo.""" + px, n, A, DR, m = _precompute(PX) + beta, _ = _rolling_beta(DR, m, B) + SDR = DR.rolling(L, min_periods=int(0.8 * L)).sum().values # somma ritorni asset su L + SM = m.rolling(L, min_periods=int(0.8 * L)).sum().values # somma mercato su L + cnt = DR.rolling(L, min_periods=1).count().values + def score_at(i): + b = beta[i] + resid_cum = SDR[i] - b * SM[i] + valid = np.isfinite(resid_cum) & (cnt[i] >= 0.8 * L) & np.isfinite(px[i]) + return sign * resid_cum, valid + return score_at, max(L, B) + 1 + + +# Catalogo meccanismi: nome -> (builder, lista di config (param dict)). +def mechanisms(): + return { + "MOM": (lambda PX, p: make_mom(PX, p["L"], +1), + [dict(L=L, H=H, k=k) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]), + "REV": (lambda PX, p: make_mom(PX, p["L"], -1), + [dict(L=L, H=H, k=k) for L in (2, 3, 5, 7, 10) for H in (1, 2, 3, 5) for k in (5, 8)]), + "IREV": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), -1), + [dict(L=L, H=H, k=k, B=60) for L in (3, 5, 7, 10) for H in (2, 3, 5) for k in (5, 8)]), + "IMOM": (lambda PX, p: make_resid(PX, p["L"], p.get("B", 60), +1), + [dict(L=L, H=H, k=k, B=60) for L in (30, 60, 90) for H in (5, 10) for k in (5, 8)]), + "LOWVOL": (lambda PX, p: make_lowvol(PX, p["B"]), + [dict(B=B, H=H, k=k) for B in (20, 30, 60) for H in (5, 10) for k in (5, 8)]), + "BAB": (lambda PX, p: make_bab(PX, p["B"]), + [dict(B=B, H=H, k=k) for B in (30, 60) for H in (5, 10) for k in (5, 8)]), + } + + +# =========================================================================== +# METRICHE / STATISTICA +# =========================================================================== +def yr_breadth(daily): + yr = [float((1 + g).prod() - 1) for _, g in daily.groupby(daily.index.year)] + return (sum(v > 0 for v in yr) / len(yr) if yr else 0.0), yr + + +def deflated_sharpe(sr_ann, all_sr_ann, daily_ret): + """Deflated Sharpe Ratio (Bailey & Lopez de Prado): probabilita' che lo Sharpe vero superi + lo Sharpe-massimo atteso sotto il null di N trial indipendenti. Penalizza il multiple-testing. + sr_ann: Sharpe annualizzato della config scelta; all_sr_ann: tutti gli Sharpe testati; + daily_ret: serie ritorni giornalieri (per skew/kurt/T). Ritorna (DSR, sr0_ann).""" + r = np.asarray(pd.Series(daily_ret).dropna().values, float) + T = len(r) + if T < 30 or np.std(r) == 0: + return float("nan"), float("nan") + sr = sr_ann / math.sqrt(DPY) # per-osservazione + trials = np.asarray([s / math.sqrt(DPY) for s in all_sr_ann if np.isfinite(s)], float) + N = max(len(trials), 2) + var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0 + emc = 0.5772156649 + z1 = norm.ppf(1 - 1.0 / N) + z2 = norm.ppf(1 - 1.0 / (N * math.e)) + sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2) + sk = float(pd.Series(r).skew()) + ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess + den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2)) + dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den)) + return dsr, sr0 * math.sqrt(DPY) + + +def evalcfg(daily): + f = metrics(daily) + h = metrics(daily[daily.index >= HOLDOUT]) + pct, _ = yr_breadth(daily) + return f, h, pct + + +# =========================================================================== +# RUN griglia per meccanismo / universo +# =========================================================================== +def run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, label): + rows = [] + for p in cfgs: + score_at, warm = builder(PX, p) + daily, turn = xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm) + daily = to_daily(daily) + if daily.std() == 0 or len(daily) < 60: + continue + f, h, pct = evalcfg(daily) + cx = _corr(daily, xs_daily) + ct = _corr(daily, tp_daily) + rows.append(dict(cfg=p, daily=daily, full=f["sharpe"], hold=h["sharpe"], dd=f["maxdd"], + ret=f["ret"], pct=pct, corrXS=cx, corrTP=ct, turn=turn)) + return rows + + +def _corr(a, b): + J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna() + return float(J["a"].corr(J["b"])) if len(J) > 10 else float("nan") + + +def tag(p): + return " ".join(f"{k}{v}" for k, v in p.items()) + + +MOM_FAMILY = ("MOM", "IMOM") # momentum (anche residuo) -> NON e' "non-momentum" + + +def causality_prefix_check(PX, VOL, builder, cfg, frac=0.85, tail=60, tol=1e-9): + """Guard look-ahead per l'engine cross-sectional: ricostruisce la serie su un PREFISSO della + matrice (primi `frac`) e verifica che la coda combaci con la run completa sugli stessi indici. + Un feature non-causale (finestra centrata, statistica full-sample, shift(-k)) divergerebbe.""" + score_full, warm = builder(PX, cfg) + full, _ = xs_engine(PX, VOL, score_full, cfg["H"], cfg["k"], warmup=warm) + cut = int(len(PX) * frac) + PXc, VOLc = PX.iloc[:cut], VOL.iloc[:cut] + score_pre, warm2 = builder(PXc, cfg) + pre, _ = xs_engine(PXc, VOLc, score_pre, cfg["H"], cfg["k"], warmup=warm2) + lo = max(0, cut - tail) + a = full.values[lo:cut] + b = pre.values[lo:cut] + worst = float(np.max(np.abs(a - b))) if len(a) else float("nan") + return dict(ok=bool(worst <= tol), max_tail_diff=worst, cut=cut, tail=len(a)) + + +# =========================================================================== +# PORTAFOGLIO — uplift 4 -> 5 sleeve (riusa active_sleeves SENZA modificarli) +# =========================================================================== +def portfolio_uplift(cand_fn, fractions=(0.10, 0.15)): + base = active_sleeves() # 4 sleeve validati + pf0 = StrategyPortfolio(base) + bt0 = pf0.backtest() # popola le cache degli sleeve + base_full = metrics(pf0.combined_daily()) + base_hold = metrics(pf0.combined_daily(lo=HOLDOUT)) + out = {"base": (base_full, base_hold), "variants": {}} + for fr in fractions: + wraw = fr / (1.0 - fr) # cand_frac = wraw/(sum_base + wraw), sum_base=1 + cand = Sleeve("XSV2_cand", wraw, cand_fn) + pf1 = StrategyPortfolio(base + [cand]) # riusa le cache di base + cf = metrics(pf1.combined_daily()) + ch = metrics(pf1.combined_daily(lo=HOLDOUT)) + out["variants"][fr] = (cf, ch, pf1.weights().get("XSV2_cand", 0.0)) + return out + + +def main(): + print("=" * 100) + print(" XSEC v2 — CROSS-SECTIONAL NON-MOMENTUM su Hyperliquid (STAT-MODE, storia ~2.5 anni)") + print("=" * 100) + + tp_daily = tp01_sleeve().daily() + xs_daily = xsec_sleeve().daily() + print(f" riferimenti: TP01 (corr target) e XS01 (momentum, sleeve attivo).") + + universes = { + "51-all": None, + "19-major": XS_UNIVERSE, + } + mats = {} + for uname, u in universes.items(): + PX, VOL = load_matrix(u) + mats[uname] = (PX, VOL) + print(f" universo {uname:<9}: {PX.shape[1]} asset, {PX.shape[0]} giorni " + f"[{PX.index[0].date()} -> {PX.index[-1].date()}]") + + mechs = mechanisms() + all_sr = [] # per deflated-Sharpe (tutti i trial) + best_per_mech = {} # (uname, mech) -> best row by hold + for uname, (PX, VOL) in mats.items(): + print("\n" + "#" * 100) + print(f"# UNIVERSO {uname}") + print("#" * 100) + for mech_name, (builder, cfgs) in mechs.items(): + rows = run_grid(PX, VOL, mech_name, builder, cfgs, xs_daily, tp_daily, uname) + if not rows: + continue + all_sr.extend([r["full"] for r in rows]) + pos_full = sum(r["full"] > 0 for r in rows) + # migliore per HOLD-OUT (diversificatore: vogliamo OOS robusto) + best = max(rows, key=lambda r: r["hold"]) + best_per_mech[(uname, mech_name)] = best + print(f"\n [{mech_name}] {len(rows)} config | plateau full>0: {pos_full}/{len(rows)}" + f" | best-hold: {tag(best['cfg'])}") + print(f" {'cfg':<22}{'FULL':>7}{'HOLD':>7}{'DD%':>6}{'ret%':>7}{'anni+':>7}" + f"{'corrXS':>8}{'corrTP':>8}{'turn/y':>8}") + # mostra le top-3 per HOLD per leggere il plateau + for r in sorted(rows, key=lambda r: -r["hold"])[:3]: + print(f" {tag(r['cfg']):<22}{r['full']:>7.2f}{r['hold']:>7.2f}{r['dd']*100:>6.0f}" + f"{r['ret']*100:>+7.0f}{r['pct']*100:>6.0f}%{r['corrXS']:>+8.2f}{r['corrTP']:>+8.2f}" + f"{r['turn']:>8.0f}") + + # ------------------------------------------------------------------- + # SELEZIONE: miglior candidato NON-MOMENTUM (escluse le famiglie momentum MOM/IMOM). + # gate standalone: FULL>0.5, HOLD>0, |corrXS|<0.6 -> ranking per (FULL+HOLD)/2. + # IMOM/MOM restano in tabella come RIFERIMENTO (sono momentum, non il target del filone). + # ------------------------------------------------------------------- + print("\n" + "=" * 100) + print(" SELEZIONE CANDIDATO non-momentum — gate: FULL>0.5, HOLD>0, |corrXS|<0.6 (escluse MOM/IMOM)") + print("=" * 100) + nm = [s for s in all_sr if np.isfinite(s)] + pool = [(u, mn, r) for (u, mn), r in best_per_mech.items()] + nonmom = [(u, mn, r) for (u, mn, r) in pool if mn not in MOM_FAMILY] + elig = [(u, mn, r) for (u, mn, r) in nonmom + if r["full"] > 0.5 and r["hold"] > 0 and abs(r["corrXS"]) < 0.6] + elig.sort(key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"])) + for u, mn, r in sorted(pool, key=lambda x: -0.5 * (x[2]["full"] + x[2]["hold"])): + fam = "(momentum-ref)" if mn in MOM_FAMILY else "" + flag = "OK" if (u, mn, r) in elig else "--" + print(f" [{flag}] {mn:<7} {u:<9} {tag(r['cfg']):<20} FULL {r['full']:+.2f} HOLD {r['hold']:+.2f}" + f" DD {r['dd']*100:.0f}% corrXS {r['corrXS']:+.2f} corrTP {r['corrTP']:+.2f} {fam}") + + if not elig: + print("\n >>> NESSUN candidato NON-momentum supera il gate standalone. SCARTATO.") + _final_note() + return + + print(f"\n candidati idonei (non-momentum): {len(elig)}") + + # valuta UPLIFT PORTAFOGLIO per i top-3 idonei (LOWVOL/BAB/...): cache base riusata + base = active_sleeves() + pf0 = StrategyPortfolio(base); pf0.backtest() + bf = metrics(pf0.combined_daily()); bh = metrics(pf0.combined_daily(lo=HOLDOUT)) + print("\n UPLIFT PORTAFOGLIO (active_sleeves 4 -> 5 sleeve; candidato come 5o sleeve):") + print(f" BASE (4 sleeve) FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}%" + f" | HOLD Sh {bh['sharpe']:.2f} DD {bh['maxdd']*100:.0f}%") + uplifts = {} + for u, mn, r in elig[:3]: + cand_fn = (lambda d: (lambda: d))(r["daily"]) + best_var = None + for fr in (0.10, 0.15): + wraw = fr / (1.0 - fr) + cand = Sleeve("XSV2_cand", wraw, cand_fn) + pf1 = StrategyPortfolio(base + [cand]) + cf = metrics(pf1.combined_daily()); ch = metrics(pf1.combined_daily(lo=HOLDOUT)) + wgt = pf1.weights().get("XSV2_cand", 0.0) + print(f" +{mn:<6} [{u}] {tag(r['cfg']):<16} @{wgt*100:>4.1f}% " + f"FULL {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f}) DD {cf['maxdd']*100:.0f}%" + f" | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})") + d_full, d_hold = cf['sharpe'] - bf['sharpe'], ch['sharpe'] - bh['sharpe'] + if best_var is None or (d_full + d_hold) > best_var: + best_var = d_full + d_hold + uplifts[(u, mn)] = best_var + + # TOP candidato = miglior non-momentum idoneo + u, mn, best = elig[0] + daily = best["daily"] + f, h, pct = evalcfg(daily) + dsr, sr0 = deflated_sharpe(f["sharpe"], all_sr, daily) + caus = causality_prefix_check(*mats[u], mechs[mn][0], best["cfg"]) + print("\n" + "=" * 100) + print(f" TOP CANDIDATO non-momentum: {mn} [{u}] {tag(best['cfg'])}") + print("=" * 100) + print(f" FULL Sharpe {f['sharpe']:.2f} | HOLD {h['sharpe']:.2f} | DD {f['maxdd']*100:.0f}%" + f" | ret {f['ret']*100:+.0f}% | anni+ {pct*100:.0f}% | turnover/y {best['turn']:.0f}") + print(f" corr vs XS01 {best['corrXS']:+.2f} | corr vs TP01 {best['corrTP']:+.2f}") + print(f" CAUSALITA' (prefix-check): ok={caus['ok']} max_tail_diff={caus['max_tail_diff']:.2e}") + print(f" DEFLATED Sharpe (N={len(nm)} trial GLOBALI): {dsr:.3f}" + f" | soglia Sharpe-max-null annualizz. {sr0:.2f} (serve DSR>0.95)") + _, yrs = yr_breadth(daily) + per = [(int(y), round(v, 3)) for y, v in zip([yy for yy, _ in daily.groupby(daily.index.year)], yrs)] + print(f" per-anno: {per}") + + helps = (uplifts.get((u, mn), -9) or -9) > 0.10 # uplift combinato full+hold meaningful + robust = dsr > 0.95 and best["hold"] > 0.3 and best["full"] > 0.7 and caus["ok"] + print("\n VERDETTO INDICATIVO:", + "PASS-LEAD (forward-monitor)" if (helps and robust) else + ("DEBOLE/forward-monitor" if (helps or (best['full'] > 0.7 and best['hold'] > 0.3)) else "SCARTATO")) + _final_note() + + +def _final_note(): + print("\n CAVEAT (immutabili): storia ~2.5 anni (deflated-Sharpe + multiple-testing), book a molte") + print(" gambe NON eseguibile a $600 -> STAT-MODE / forward-monitor, MAI deploy. Nessuno sleeve") + print(" registrato: questo e' solo lavoro statistico (vincoli del filone C).") + + +if __name__ == "__main__": + main() diff --git a/tests/test_dvol_directional.py b/tests/test_dvol_directional.py new file mode 100644 index 0000000..1d262f6 --- /dev/null +++ b/tests/test_dvol_directional.py @@ -0,0 +1,57 @@ +"""Test del filone DVOL-DIREZIONALE (scripts/research/dvol_directional.py). + +Verifica le proprieta' che DEVONO valere per ogni segnale del progetto: + * il percentile espandente del DVOL e' CAUSALE (rank su un prefisso == rank(full)[:cut]); + * il leader (DVOL-fear long-flat) passa causality_ok di altlib (niente future-peeking); + * a $600 (eval_weights_smallcap) l'haircut e' trascurabile (segnale eseguibile, low-turnover); + * sign-falsification: la tesi (buy-the-fear) batte il suo flip (buy-the-calm) sull'era DVOL. +""" +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import altlib as al # noqa: E402 +from scripts.research import dvol_directional as dd # noqa: E402 + + +def test_expanding_rank_is_causal(): + """rank calcolato su un prefisso deve coincidere con rank(full) ristretto al prefisso.""" + x = np.asarray(al.dvol(al.get("BTC", "1d"), "BTC"), float) + full = dd._expanding_rank_arr(x) + cut = int(len(x) * 0.7) + pref = dd._expanding_rank_arr(x[:cut]) + a, b = full[:cut], pref + m = np.isfinite(a) & np.isfinite(b) + assert m.sum() > 100 + assert np.max(np.abs(a[m] - b[m])) < 1e-9 + + +def test_leader_causality_ok(): + """Il leader (DVOL-fear q0.4 long-flat) non deve avere look-ahead (altlib causality_ok).""" + fn = dd.make_dvol_level(0.4, "fear", True) + co = al.causality_ok(fn, tf="1d") + assert co["ok"], co + assert co["max_tail_diff"] <= 1e-6 + + +@pytest.mark.parametrize("asset", ["BTC", "ETH"]) +def test_leader_executable_at_600(asset): + """A $600 il segnale e' a basso turnover: haircut Sharpe trascurabile, trade eseguiti reali.""" + fn = dd.make_dvol_level(0.4, "fear", True) + df = al.get(asset, "1d") + sc = al.eval_weights_smallcap(df, al._call_target(fn, df, asset), capital=600, min_order=5) + assert sc["n_executed_trades"] > 10 + assert abs(sc["sharpe_haircut"]) < 0.10 + + +def test_sign_falsification(): + """La tesi (buy-the-fear) deve battere il flip (buy-the-calm) sull'era DVOL.""" + thesis = dd.era_full_sharpe(dd.make_dvol_level(0.5, "fear", True))["sharpe"] + flip = dd.era_full_sharpe(dd.make_dvol_level(0.5, "calm", True))["sharpe"] + assert thesis > flip diff --git a/tests/test_intraday_regime.py b/tests/test_intraday_regime.py new file mode 100644 index 0000000..c522086 --- /dev/null +++ b/tests/test_intraday_regime.py @@ -0,0 +1,59 @@ +"""Test del filone B — INTRADAY REGIME (scripts/research/intraday_regime.py). + +Verifica leggera e veloce (un solo asset) che: + * i target factory producano array della lunghezza giusta e CAUSALI (no look-ahead); + * il meccanismo ERM (efficiency-ratio regime momentum) sia long/short di natura; + * la cella canonica ERM 8h L=2 thr=0.35 abbia Sharpe FULL positivo netto fee (sanity dell'edge). +NB: NON costruisce la baseline TP01/SKH01 (lento) — quello e' nello script di ricerca. +""" +import sys +import importlib.util + +import numpy as np + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +_spec = importlib.util.spec_from_file_location( + "intraday_regime", "/opt/docker/PythagorasGoal/scripts/research/intraday_regime.py") +ir = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(ir) + + +def test_erm_target_shape_and_ls(): + df = al.get("BTC", "8h") + tgt = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False)(df) + assert len(tgt) == len(df) + assert np.isfinite(tgt).all() + # L/S: deve esistere sia esposizione long sia short (non e' long-only) + assert (tgt > 0).any() and (tgt < 0).any() + # long_flat=True -> nessuno short + tgt_lf = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=True)(df) + assert (tgt_lf >= -1e-9).all() + + +def test_erm_causal_no_leak(): + # causality_ok ricalcola il target su prefissi troncati e pretende che la coda combaci + res = al.causality_ok(ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False), + tf="8h", assets=("BTC",)) + assert res["ok"], f"look-ahead in ERM: {res}" + + +def test_erm_winner_positive_full_sharpe(): + fn = ir.make_erm(tf="8h", L_days=2.0, thr=0.35, long_flat=False) + for a in ("BTC", "ETH"): + df = al.get(a, "8h") + ev = al.eval_weights(df, fn(df), fee_side=0.0005) # 0.10% RT + assert ev["full"]["sharpe"] > 0.5, f"{a} full Sharpe {ev['full']['sharpe']}" + + +def test_vbr_and_tod_causal(): + for fn in (ir.make_vbr(tf="8h", k=1.0, atr_win=14, long_flat=False), + ir.make_tod(tf="1h", long_flat=True)): + res = al.causality_ok(fn, tf=("8h" if fn is not None else "1h"), + assets=("BTC",)) if False else None + # VBR causale (8h) e TOD causale (1h), un asset per velocita' + assert al.causality_ok(ir.make_vbr(tf="8h", k=1.0, atr_win=14, long_flat=False), + tf="8h", assets=("BTC",))["ok"] + assert al.causality_ok(ir.make_tod(tf="1h", long_flat=True), + tf="1h", assets=("BTC",))["ok"] diff --git a/tests/test_macro_regime_gate.py b/tests/test_macro_regime_gate.py new file mode 100644 index 0000000..69c07ff --- /dev/null +++ b/tests/test_macro_regime_gate.py @@ -0,0 +1,90 @@ +"""Test del filone D — MACRO REGIME-GATE (scripts/research/macro_regime_gate.py). + +Verifica leggera che: + * il frame macro (ETF daily) carichi e sia su un calendario monotono; + * i gate builder producano un gate in [g_off, 1] e CAUSALE (SMA/ratio rolling, no future); + * align_gate sia backward-only (la barra crypto i usa solo gate equity con label <= i); + * il VERDETTO del filone regga: il gate macro e' RIDONDANTE col trend di TP01 — "lavora" + (riduce una posizione TP01 NON gia' flat) solo in una piccola quota di giorni. +NB: un solo gate per i test lenti (costruisce TP01) -> velocita'. +""" +import sys +import importlib.util + +import numpy as np + +sys.path.insert(0, "/opt/docker/PythagorasGoal") +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +_spec = importlib.util.spec_from_file_location( + "macro_regime_gate", "/opt/docker/PythagorasGoal/scripts/research/macro_regime_gate.py") +mg = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(mg) + + +def test_macro_frame_loads_and_monotone(): + mf = mg.macro_frame() + for col in ("spy", "qqq", "hyg", "lqd", "gld", "tlt", "ief"): + assert col in mf.columns, f"colonna macro mancante: {col}" + ts = mf["timestamp"].values + assert (np.diff(ts) > 0).all(), "calendario macro non strettamente crescente" + # spy non-null dopo l'inizio (master del calendario) + assert np.isfinite(mf["spy"].values[-1]) + + +def test_gate_in_range_and_binary(): + mf = mg.macro_frame() + for gdf, g_off in ((mg.gate_trend(mf, "spy", 200, 0.0), 0.0), + (mg.gate_combo(mf, 200, 0.5), 0.5)): + g = gdf["gate"].values + fin = g[np.isfinite(g)] + assert len(fin) > 0 + assert (fin >= g_off - 1e-9).all() and (fin <= 1.0 + 1e-9).all(), \ + "gate fuori da [g_off, 1]" + assert len(gdf) == len(mf) + + +def test_gate_trend_causal_prefix(): + # gate_trend usa solo SMA rolling -> ricalcolato su un prefisso, la coda combacia + mf = mg.macro_frame() + full = mg.gate_trend(mf, "spy", 200, 0.0)["gate"].values + k = len(mf) - 300 + pref = mg.gate_trend(mf.iloc[:k].reset_index(drop=True), "spy", 200, 0.0)["gate"].values + a, b = full[:k], pref + both = np.isfinite(a) & np.isfinite(b) + assert both.any() + assert np.allclose(a[both], b[both]), "look-ahead nel gate_trend (prefix != coda)" + + +def test_align_gate_backward_no_future(): + # la barra crypto i deve mappare a un gate equity con timestamp <= timestamp crypto i + mf = mg.macro_frame() + gate_df = mg.gate_trend(mf, "spy", 200, 0.0) + df = al.get("BTC", "1d") + g = mg.align_gate(gate_df, df) + assert len(g) == len(df) + assert np.isfinite(g).all() + # nessun valore di gate puo' provenire dal futuro: per una manciata di barre crypto, + # il gate allineato deve coincidere con l'ultimo gate equity NON-NaN con ts <= ts_crypto + gd = gate_df.dropna(subset=["gate"]).sort_values("timestamp") + gt, gv = gd["timestamp"].values, gd["gate"].values + cts = df["timestamp"].astype("int64").values + for i in range(len(cts) - 1, max(len(cts) - 50, 0), -1): + prior = np.searchsorted(gt, cts[i], side="right") - 1 + if prior < 0: + continue + assert abs(g[i] - gv[prior]) < 1e-9, f"align_gate usa gate futuro a i={i}" + + +def test_verdict_gate_is_redundant_with_trend(): + # IL VERDETTO del filone D: TP01 e' gia' flat nei crash -> il gate macro "lavora" + # (riduce una posizione NON gia' flat) solo in una piccola quota di giorni. + mf = mg.macro_frame() + gate_df = mg.gate_combo(mf, 200, 0.0) # combo SPY+HYG+HYG/LQD, de-risk a 0 + diag = mg.redundancy_diag(gate_df) + for a in mg.ASSETS: + r = diag[a] + # esposizione TP01 nei giorni risk-off gia' bassa, e gate "lavora" raramente + assert r["pct_days_gate_works"] < 0.20, \ + f"{a}: gate lavora {r['pct_days_gate_works']} (atteso piccolo = ridondante col trend)" diff --git a/tests/test_xsec_v2.py b/tests/test_xsec_v2.py new file mode 100644 index 0000000..a144405 --- /dev/null +++ b/tests/test_xsec_v2.py @@ -0,0 +1,98 @@ +"""Test del filone C: cross-sectional NON-momentum su Hyperliquid (scripts/research/xsec_v2_nonmom). + +Verifica i GATE strutturali, non i numeri esatti (storia corta, ricerca): l'engine e' CAUSALE +(prefix-consistency, zero look-ahead), le fee MONOTONE (piu' fee -> Sharpe <=), il reversal grezzo +e' MORTO (plateau negativo), e il low-vol factor sui 19 major e' positivo in-sample (il LEAD). +""" +from __future__ import annotations +import sys +from pathlib import Path +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) +import numpy as np +import pytest + +from src.portfolio.portfolio import to_daily, metrics +from src.portfolio.sleeves import XS_UNIVERSE + +import importlib.util +_spec = importlib.util.spec_from_file_location( + "xsec_v2_nonmom", PROJECT_ROOT / "scripts" / "research" / "xsec_v2_nonmom.py") +xv = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(xv) + + +@pytest.fixture(scope="module") +def majors(): + return xv.load_matrix(XS_UNIVERSE) + + +def test_universe_loads_clean(majors): + PX, VOL = majors + assert PX.shape[1] == len(XS_UNIVERSE) + assert PX.shape[0] > 800 # ~2.5 anni a 1d + assert PX.index.is_monotonic_increasing + + +def test_engine_is_causal_no_lookahead(majors): + """L'engine NON deve guardare al futuro: ricostruito su un prefisso, la coda combacia + bit-a-bit con la run completa (gate #1 della metodologia).""" + PX, VOL = majors + builder, _ = xv.mechanisms()["LOWVOL"] + cfg = dict(B=30, H=10, k=5) + res = xv.causality_prefix_check(PX, VOL, builder, cfg) + assert res["ok"], f"look-ahead rilevato: max_tail_diff={res['max_tail_diff']}" + assert res["max_tail_diff"] == 0.0 + + # anche un meccanismo residuo (usa beta rolling + mercato) deve essere causale + builder_i, _ = xv.mechanisms()["IMOM"] + res_i = xv.causality_prefix_check(PX, VOL, builder_i, dict(L=30, H=5, k=8, B=60)) + assert res_i["ok"], f"IMOM non causale: {res_i['max_tail_diff']}" + + +def test_fee_is_monotone(majors): + """Piu' fee non puo' MAI alzare lo Sharpe (su una config con turnover non nullo).""" + PX, VOL = majors + builder, _ = xv.mechanisms()["LOWVOL"] + score_at, warm = builder(PX, dict(B=30, H=10, k=5)) + s0, t0 = xv.xs_engine(PX, VOL, score_at, 10, 5, fee=0.0, warmup=warm) + s2, t2 = xv.xs_engine(PX, VOL, score_at, 10, 5, fee=0.002, warmup=warm) + assert t0 > 0 + assert metrics(to_daily(s0))["sharpe"] >= metrics(to_daily(s2))["sharpe"] - 1e-9 + + +def test_raw_reversal_is_dead(majors): + """Reversal cross-sectional grezzo = NESSUN plateau positivo (coerente con la lezione del + progetto: la mean-reversion e' artefatto). Almeno meta' delle config dev'essere FULL<=0.""" + PX, VOL = majors + builder, cfgs = xv.mechanisms()["REV"] + neg = 0; tot = 0 + for p in cfgs: + score_at, warm = builder(PX, p) + d = to_daily(xv.xs_engine(PX, VOL, score_at, p["H"], p["k"], warmup=warm)[0]) + if d.std() == 0: + continue + tot += 1 + if metrics(d)["sharpe"] <= 0: + neg += 1 + assert tot > 0 + assert neg >= tot / 2, f"reversal inatteso: solo {neg}/{tot} config FULL<=0" + + +def test_lowvol_factor_positive_insample(majors): + """Il LEAD: low-vol factor sui 19 major (B30 H10 k5) ha FULL Sharpe positivo e robusto + (plateau 100% positivo). Numero non vincolato (ricerca), solo il segno/robustezza.""" + PX, VOL = majors + builder, cfgs = xv.mechanisms()["LOWVOL"] + score_at, warm = builder(PX, dict(B=30, H=10, k=5)) + d = to_daily(xv.xs_engine(PX, VOL, score_at, 10, 5, warmup=warm)[0]) + assert metrics(d)["sharpe"] > 0.5 + # plateau: ogni config LOWVOL deve avere FULL>0 (factor robusto ai parametri in-sample) + pos = 0; tot = 0 + for p in cfgs: + sa, w = builder(PX, p) + dd = to_daily(xv.xs_engine(PX, VOL, sa, p["H"], p["k"], warmup=w)[0]) + if dd.std() == 0: + continue + tot += 1; pos += metrics(dd)["sharpe"] > 0 + assert pos == tot, f"plateau low-vol non pieno: {pos}/{tot}" From b4ec92734c12cc25e4a6c922b6d129a1025dbda5 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 29 Jun 2026 20:04:36 +0000 Subject: [PATCH 4/4] =?UTF-8?q?research(intraday):=20de-bias=20del=20lead?= =?UTF-8?q?=20ERM=20(filone=20B)=20=E2=80=94=20falso=20positivo=20+=20gate?= =?UTF-8?q?=20selection-on-holdout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'analisi di robustezza affonda lo "earns_slot=True" di ERM: era prodotto da selezione-sull'hold-out + coda 2026 + multiple-testing non corretto. A) deflated-Sharpe FAIL: 0.00 (tutti 122 trial) / 0.16 (no-TOD) / 0.24 (solo-ERM) << 0.95 B) selezione in-sample-only -> ALTRA cella (long-flat, corr->TP01 0.53) = NEUTRAL, no slot C) ensemble del plateau (no cherry-pick) -> ADDS ma robust_oos=False -> no slot D) uplift FULL solo +0.10, negativo 2021/2022; uplift HOLD +0.30 concentrato nel 2026 => ERM SCARTATO come sleeve. Conferma ennesima del soffitto BTC/ETH-direzionale ~1.3. Lezione CODIFICATA in altlib (LESSON 4, test in tests/test_harness_realism.py): - deflated_sharpe() Bailey & Lopez de Prado, PASS >= 0.95 - select_cell_insample() scelta cella col solo Sharpe pre-HOLDOUT (no peeking) - study_family_honest() gate combinato: earns_slot[cella in-sample] AND DSR>=0.95 Regola: una strategia direzionale grid-searched si giudica con study_family_honest, non chiamando study_marginal sulla cella a max hold-out. Verificato end-to-end su ERM (earns_slot_honest=False). Chiude il punto cieco gemello di CC01. Diario aggiornato (verdetto downgrade), CLAUDE.md aggiornato. Test 119/119 verdi. Nessun impatto live (branch separato). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 11 + docs/diary/2026-06-29-intraday-regime.md | 57 +++- scripts/research/alt/altlib.py | 90 +++++++ scripts/research/intraday_regime_analysis.py | 267 +++++++++++++++++++ tests/test_harness_realism.py | 55 ++++ 5 files changed, 471 insertions(+), 9 deletions(-) create mode 100644 scripts/research/intraday_regime_analysis.py diff --git a/CLAUDE.md b/CLAUDE.md index 7bb9bac..a9e48fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,6 +178,17 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis migliaia di micro-trade sub-dollaro (tipici di un overlay vol-target) è **finzione**. Salta i sub-min_order e riporta lo **Sharpe haircut** reale vs modellato. **Vale per OGNI sleeve a questo capitale, TP01 incluso** — lo Sharpe netto onesto a $600 è quello small-cap, non quello modellato. +- **SELECTION-ON-HOLDOUT gate (codificato 2026-06-29, filone B intraday ERM)** — terzo gate in + `altlib.py`, test `tests/test_harness_realism.py`. Il lead ERM faceva `earns_slot=True` MA lo script + di scoperta sceglieva la cella per **`min_hold` massimo** su 60+ celle = **selezione-sull'hold-out**: + scegliendola in-sample-only ne esce un'altra (trend-beta corr→TP01 0.53, NEUTRAL) e il deflated-Sharpe + crolla (DSR 0.0-0.24 su 122 trial). `study_marginal` da solo non lo vede (giudica UNO stream, non *come* + è scelto). Tre funzioni: **`deflated_sharpe()`** (Bailey & Lopez de Prado, PASS ≥0.95), **`select_cell_insample()`** + (cella scelta col solo Sharpe pre-HOLDOUT), e il gate combinato **`study_family_honest(name, factory, grid, tfs)`** + → `earns_slot_honest = earns_slot[cella in-sample] AND deflated-Sharpe≥0.95`. **Regola: una strategia + direzionale grid-searched si giudica con `study_family_honest`, non chiamando `study_marginal` sulla + cella a max hold-out.** Chiude il punto cieco gemello di CC01 ("Sharpe implausibile"). Diario + `2026-06-29-intraday-regime.md` (analisi `scripts/research/intraday_regime_analysis.py`). - **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-29-intraday-regime.md b/docs/diary/2026-06-29-intraday-regime.md index 94422f5..07d61d6 100644 --- a/docs/diary/2026-06-29-intraday-regime.md +++ b/docs/diary/2026-06-29-intraday-regime.md @@ -92,15 +92,54 @@ dei segnali di prezzo (ERM/VBR) è reale, non rumore di calendario. gonfia. Storia sub-daily certificata utile ~quanto SKH01 → finestra non lunghissima. 4. **Esecuzione 8h** = complessità operativa reale (vedi sopra), oltre il modello a haircut nullo. -## Verdetto onesto: **LEAD forte / forward-monitor (research win).** Nessuno sleeve registrato. +## Analisi di robustezza / de-bias (`intraday_regime_analysis.py`) — il lead NON regge -ERM è il risultato sub-daily **più solido dai tempi di SKH01**: passa il marginal scorer indurito (edge -in-sample, multi-cut, non-hedge, ADDS), è leak-free, fee-survivente, eseguibile a haircut ~0, e migliora -il portafoglio **oltre** SKH01 (TP01+SKH+ERM 60/25/15 → FULL 1.88 / HOLD 1.46 / DD 8.9%). Ma il plateau -hold-out a singola riga + il multiple-testing non deflazionato lo tengono a **tier-SKH-al-momento-della- -scoperta**: si arma in **forward-monitor**, non si registra come sleeve finché (a) il plateau hold-out -non si allarga su L con dati nuovi, (b) il deflated-Sharpe sulle 102 celle non lo conferma, (c) l'esecuzione -8h non è validata a deploy. Gli altri tre meccanismi: VBR=NOISE (diversification-math, no in-sample edge), -VEM hold-out<0, TOD=controllo FAIL come atteso. +I caveat #1 (plateau hold-out single-row) e #3 (multiple-testing) erano i sospetti giusti. Tre test di +de-bias li trasformano da sospetto in **bocciatura** dello slot: + +| test | esito | +|---|---| +| **A) Deflated-Sharpe** (Bailey & Lopez de Prado) su 122 trial cercati | **FAIL.** DSR 0.000 (tutti) / **0.163 (escludendo i trap TOD)** / 0.241 (solo-ERM) — tutti ≪ 0.95. Lo Sharpe winner (0.92) è sotto lo Sharpe-max-atteso-null (1.16–2.51): il search ha trovato celle a 1.6 full / 1.7 in-sample, il winner 0.92 **non è eccezionale**. | +| **B) Selezione IN-SAMPLE-only** (scelgo la cella ERM col solo Sharpe < 2025) | **earns_slot=False.** La cella migliore pre-hold-out è un'**ALTRA** (8h L=2.0 thr=0.4 **long-flat**), con corr→TP01 **0.53** (è trend-beta travestito) → marginal=**NEUTRAL**. Il winner max-hold **non si seleziona senza guardare l'hold-out** → il suo `earns_slot=True` era **selezione-sull'hold-out**. | +| **C) Ensemble del plateau** (media 20 celle L×thr, niente cherry-pick) | **earns_slot=False.** marginal=ADDS, in-sample Sh 1.01, corr→TP01 0.18 — ma **`robust_oos=False`** (clean-year + jackknife): l'uplift hold-out è trascinato dal **2026 (+2.09 multicut)**, manciata di giorni. | + +**Dove vive l'(eventuale) edge** (per-anno, blend 3-way 60/25/15 vs 2-way 75/25): uplift FULL solo **+0.10**, +**negativo nel 2021 (−0.23) e 2022 (−0.15)**, positivo altrove; l'uplift HOLD **+0.30 è concentrato nel +2026 (+0.46)**. corr(ERM,SKH) 0.28 full (fino a 0.42 in alcuni anni) → **parziale sovrapposizione con SKH**, +non ortogonalità piena. + +**Lettura.** Il segnale efficiency-ratio non è rumore puro (l'ensemble ha in-sample Sh ~1.0, positivo nella +maggior parte degli anni), ma come **slot** fallisce ogni de-bias: il `earns_slot=True` della scoperta era +prodotto da **(1) selezione della cella sull'hold-out** + **(2) coda 2026** + **(3) multiple-testing non +corretto**. È lo stesso falso-positivo che l'alt-sweep 100-agent imparò a uccidere — qui ucciso dai gate. + +## Caveat originari (ora risolti dall'analisi sopra) + +1. ~~Plateau hold-out single-row~~ → **confermato fatale**: l'edge hold-out a L=2.0 è cell-selection. +2. ~~Multiple-testing non deflazionato~~ → **deflazionato: DSR FAIL** anche senza i trap. +3. Esecuzione 8h: irrilevante ormai (lo slot non c'è). + +## Verdetto onesto: **NON è uno slot. Falso positivo da selezione-hold-out + coda 2026.** SCARTATO come sleeve. + +Lo "earns_slot=True" della scoperta **non sopravvive** alla de-selezione: deflated-Sharpe FAIL (anche +escludendo i controlli), selezione in-sample-only → NEUTRAL su un'altra cella (trend-beta corr 0.53), +ensemble del plateau → robust_oos FAIL. **Conferma ennesima del soffitto direzionale BTC/ETH ~1.3**: un +segnale a 2 asset non lo supera; la via resta il cross-sectional (XS01). Resta al più una **curiosità in +forward-monitor** (l'efficiency-ratio ha un debole edge in-sample reale), ma da non armare come alpha. +Gli altri tre meccanismi: VBR=NOISE, VEM hold-out<0, TOD=controllo FAIL come atteso. + +**Lezione harness (CODIFICATA).** Lo script di scoperta selezionava il vincitore per `min_hold` massimo +sulla griglia = **selezione-sull'hold-out**, il punto cieco che ha generato il falso PASS. Il marginal +scorer da solo non basta se la *cella* è scelta guardando l'hold-out: serve **(a)** scegliere la cella +in-sample-only (o walk-forward) **prima** di valutare il marginal, e **(b)** deflazionare per il numero di +celle/famiglie testate. Stesso buco segnalato per CC01 ("Sharpe implausibile") e per l'alt-sweep +(hold-out-fitting): qui in forma "selection-on-holdout". Ora è **codice** in `altlib` (LESSON 4): tre +funzioni nuove — `deflated_sharpe()` (Bailey & Lopez de Prado), `select_cell_insample()` (scelta cella +col solo Sharpe pre-HOLDOUT), e il gate combinato **`study_family_honest()`** (`earns_slot_honest = +earns_slot[cella in-sample] AND deflated-Sharpe≥0.95`). Verificato: su ERM il gate ritorna +`earns_slot_honest=False` (cella in-sample = trend-beta NEUTRAL, DSR 0.74). Analisi completa in +`scripts/research/intraday_regime_analysis.py`; test in `tests/test_harness_realism.py`. +**Regola nuova: una strategia direzionale grid-searched si giudica con `study_family_honest`, non +chiamando `study_marginal` sulla cella a max hold-out.** Nessun impatto sul book live (branch separato, config canonica invariata). diff --git a/scripts/research/alt/altlib.py b/scripts/research/alt/altlib.py index a7ce9e4..ff42dd9 100644 --- a/scripts/research/alt/altlib.py +++ b/scripts/research/alt/altlib.py @@ -29,12 +29,14 @@ from __future__ import annotations import inspect import json +import math import sys from functools import lru_cache from pathlib import Path import numpy as np import pandas as pd +from scipy.stats import norm # --- make `from src...` work no matter where the agent's script lives ------- _ROOT = Path(__file__).resolve().parents[3] @@ -670,6 +672,94 @@ def causality_ok(target_fn, tf: str = "1h", assets=CERTIFIED, reason=("length-mismatch on prefix" if bad else None)) +# =========================================================================== +# SELECTION-ON-HOLDOUT GATE — codified 2026-06-29 from filone B (intraday ERM). +# +# LESSON 3: intraday_regime.py picked its "winner" cell by MAX hold-out Sharpe over a ~60-cell +# grid, then ran study_marginal on THAT cell -> earns_slot=True. But the slot was an artifact of +# SELECTING THE CELL ON THE HOLD-OUT: picking the cell IN-SAMPLE-ONLY (no peeking) lands on a +# DIFFERENT, TP01-correlated cell that scores NEUTRAL, and the standalone Sharpe deflates to +# DSR~0.0-0.24 over the trials searched. study_marginal alone can't catch this — it judges ONE +# stream and never sees how the cell was chosen. The fix is two-fold and lives here: +# (1) choose the cell IN-SAMPLE-ONLY (or walk-forward) BEFORE scoring the marginal, and +# (2) DEFLATE the standalone Sharpe for the number of cells/families searched. +# Twin of the CC01 ("implausible Sharpe -> hidden risk") and alt-sweep ("hold-out-fitting") blind +# spots, in its "selection-on-holdout" form. +# =========================================================================== +def deflated_sharpe(sr_ann, all_sr_ann, daily_ret, dpy: float = 365.25): + """Deflated Sharpe Ratio (Bailey & Lopez de Prado): P(true Sharpe > the MAX Sharpe expected + under the null of N independent trials). Penalizes multiple-testing — a standalone Sharpe ~1 + over a 100+ cell grid is routinely NOT significant once deflated. sr_ann = annualized Sharpe + of the CHOSEN config; all_sr_ann = the Sharpe of EVERY cell searched; daily_ret = the chosen + config's daily returns (for skew/kurt/T). Returns (DSR, expected_null_max_sharpe_ann); + PASS if DSR >= 0.95.""" + r = np.asarray(pd.Series(daily_ret).dropna().values, float) + T = len(r) + if T < 30 or np.std(r) == 0: + return float("nan"), float("nan") + sr = sr_ann / math.sqrt(dpy) + trials = np.asarray([s / math.sqrt(dpy) for s in all_sr_ann if np.isfinite(s)], float) + N = max(len(trials), 2) + var_tr = float(np.var(trials, ddof=1)) if N > 1 else 0.0 + emc = 0.5772156649 + z1 = norm.ppf(1 - 1.0 / N) + z2 = norm.ppf(1 - 1.0 / (N * math.e)) + sr0 = math.sqrt(var_tr) * ((1 - emc) * z1 + emc * z2) + sk = float(pd.Series(r).skew()) + ku = float(pd.Series(r).kurt()) + 3.0 # pandas kurt = excess + den = math.sqrt(max(1e-9, 1 - sk * sr + (ku - 1) / 4.0 * sr ** 2)) + dsr = float(norm.cdf((sr - sr0) * math.sqrt(T - 1) / den)) + return dsr, sr0 * math.sqrt(dpy) + + +def select_cell_insample(factory, grid, tfs, fee_side: float = FEE_SIDE) -> dict: + """Pick a config WITHOUT looking at the hold-out: rank grid cells by IN-SAMPLE (pre-HOLDOUT) + standalone Sharpe of the 50/50 candidate. `factory(tf=..., **params)` -> target_fn; each grid + item is a dict of factory kwargs (besides tf). Returns the in-sample-best cell, all rows + (sorted), and EVERY cell's FULL Sharpe (for deflated_sharpe). This is the honest replacement + for picking the max-hold-out cell.""" + rows = [] + for tf in tfs: + for params in grid: + try: + daily = candidate_daily(factory(tf=tf, **params), tf=tf, fee_side=fee_side) + except Exception: + continue + ins = daily[daily.index < HOLDOUT] + is_sh = _sh(ins) if len(ins) > 60 else float("nan") + rows.append(dict(tf=tf, params=params, insample_sharpe=round(is_sh, 3), + full_sharpe=round(_sh(daily), 3))) + valid = [r for r in rows if np.isfinite(r["insample_sharpe"])] + chosen = max(valid, key=lambda r: r["insample_sharpe"]) if valid else None + return dict(chosen=chosen, + rows=sorted(valid, key=lambda r: r["insample_sharpe"], reverse=True), + all_full_sharpe=[r["full_sharpe"] for r in rows]) + + +def study_family_honest(name: str, factory, grid, tfs, fee_side: float = FEE_SIDE, + dsr_min: float = 0.95) -> dict: + """HARDENED family gate. A grid-searched directional candidate earns a slot ONLY if, picking + the cell IN-SAMPLE-ONLY (no hold-out peeking), it STILL earns_slot via study_marginal AND its + standalone Sharpe survives deflation for the WHOLE grid searched. Use this INSTEAD of + cherry-picking the max-hold cell and calling study_marginal on it.""" + sel = select_cell_insample(factory, grid, tfs, fee_side=fee_side) + ch = sel["chosen"] + if ch is None: + return dict(name=name, chosen=None, earns_slot_honest=False, + reason="no valid in-sample cell") + fn = factory(tf=ch["tf"], **ch["params"]) + sm = study_marginal(f"{name} ISpick {ch['params']}", fn, tf=ch["tf"], fee_side=fee_side) + daily = candidate_daily(fn, tf=ch["tf"], fee_side=fee_side) + dsr, sr0 = deflated_sharpe(_sh(daily), sel["all_full_sharpe"], daily) + dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min) + return dict(name=name, n_cells=len(sel["all_full_sharpe"]), chosen=ch, rows=sel["rows"], + marginal=sm, earns_slot_marginal=bool(sm["earns_slot"]), + deflated_sharpe=round(dsr, 3) if np.isfinite(dsr) else None, + expected_null_max=round(sr0, 3) if np.isfinite(sr0) else None, + dsr_pass=dsr_pass, + earns_slot_honest=bool(sm["earns_slot"] and dsr_pass)) + + # =========================================================================== # DRIVERS — run a hypothesis across both assets, several TFs, with a fee sweep. # =========================================================================== diff --git a/scripts/research/intraday_regime_analysis.py b/scripts/research/intraday_regime_analysis.py new file mode 100644 index 0000000..f1ed18c --- /dev/null +++ b/scripts/research/intraday_regime_analysis.py @@ -0,0 +1,267 @@ +"""intraday_regime_analysis.py — ANALISI DI ROBUSTEZZA del LEAD ERM (filone B) — 2026-06-29. + +Il lead di B (ERM 8h L=2.0 thr=0.35 L/S) fa earns_slot=True, ma con 2 caveat NON quantificati +dallo script di scoperta `intraday_regime.py`: + (1) il VINCITORE e' selezionato per min_hold MASSIMO su ~60 celle -> selezione-sull'hold-out; + (2) il plateau hold-out e' a UNA SOLA RIGA (positivo solo a L~2.0; L>=2.5 va negativo sull'hold). +Insieme = rischio multiple-testing / overfit della finestra recente, mai deflazionato (a +differenza del filone C che ha il deflated-Sharpe). + +Questo script attacca esattamente quei nodi, SENZA toccare il live (read-only, branch separato): + + A) DEFLATED SHARPE (Bailey & Lopez de Prado) del vincitore vs TUTTI i trial realmente + cercati (ERM+VEM+VBR+TOD, tutte le celle/TF). Se DSR << 0.95 lo Sharpe non e' significativo + dopo la correzione per multiple-testing. + + B) SELEZIONE IN-SAMPLE-ONLY: ri-scelgo la cella ERM usando SOLO lo Sharpe PRE-2025 (mai + l'hold-out), poi ne valuto earns_slot sull'intera storia. Se una cella scelta SENZA vedere + l'hold-out continua ad ADDS, l'edge non e' hold-out-mined. + + C) ENSEMBLE DEL PLATEAU: invece della singola cella migliore, media i pesi su tutto il + plateau ERM 8h (L x thr) -> un candidato unico "non-cherry-picked" -> earns_slot. Se la + famiglia regge senza scegliere L, il caveat (1)+(2) si attenua. + + D) DOVE VIVE L'EDGE: Sharpe per-anno standalone + uplift per-anno del blend 3-way + (TP01+SKH+ERM) vs 2-way (TP01+SKH), e corr(ERM,SKH) per-anno (e' un hedge-di-SKH?). + +Esecuzione: uv run python scripts/research/intraday_regime_analysis.py +Idempotente, niente scritture su disco (solo report a stdout). +""" +from __future__ import annotations + +import sys + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 +from intraday_regime import make_erm, make_vem, make_vbr, make_tod, _skh_daily # noqa: E402 + +DPY = 365.25 +HOLD = al.HOLDOUT +ASSETS = ("BTC", "ETH") +SCREEN_TFS = ("1h", "4h", "6h", "8h", "12h") + +WIN = dict(tf="8h", L_days=2.0, thr=0.35, long_flat=False) # il lead di B + +deflated_sharpe = al.deflated_sharpe # gate canonico (codificato in altlib da questo filone) + + +# =========================================================================== +def all_trials(): + """Ricostruisce la griglia COMPLETA realmente cercata in intraday_regime.py, ritorna una + lista di (tag, factory, tf, params) per pesare il multiple-testing onestamente.""" + out = [] + erm_grid = [dict(L_days=L, thr=t, long_flat=lf) + for L in (1.0, 2.0, 3.0) for t in (0.35, 0.50) for lf in (False, True)] + for tf in SCREEN_TFS: + for p in erm_grid: + out.append(("ERM", make_erm, tf, p)) + # + il plateau fine a 8h (L x thr, lf=False) — anche quelle sono celle testate + for L in (1.5, 2.0, 2.5, 3.0): + for t in (0.30, 0.35, 0.40, 0.45, 0.50): + out.append(("ERM-plat", make_erm, "8h", dict(L_days=L, thr=t, long_flat=False))) + vem_grid = [dict(Lmom_days=lm, Lshort_days=2.0, Llong_days=10.0, long_flat=lf) + for lm in (1.0, 3.0) for lf in (False, True)] + for tf in ("4h", "6h", "8h", "12h"): + for p in vem_grid: + out.append(("VEM", make_vem, tf, p)) + vbr_grid = [dict(k=k, atr_win=14, long_flat=lf) for k in (0.5, 1.0, 1.5) for lf in (False, True)] + for tf in ("4h", "6h", "8h", "12h"): + for p in vbr_grid: + out.append(("VBR", make_vbr, tf, p)) + for lf in (False, True): + out.append(("TOD", make_tod, "1h", dict(long_flat=lf))) + return out + + +def cand_full_is_sharpe(factory, tf, params): + """(daily, full Sharpe, in-sample<2025 Sharpe) del candidato 50/50 di quella cella.""" + fn = factory(tf=tf, **params) + daily = al.candidate_daily(fn, tf=tf) + full = al._sh(daily) + ins = al._sh(daily[daily.index < HOLD]) if (daily.index < HOLD).sum() > 60 else float("nan") + return daily, full, ins + + +# =========================================================================== +def part_A_deflated(): + print("=" * 78) + print("A) DEFLATED SHARPE del vincitore vs TUTTI i trial cercati (multiple-testing)") + print("=" * 78) + trials = all_trials() + sr_all, sr_no_tod, sr_erm = [], [], [] + win_daily = win_full = None + for tag, factory, tf, params in trials: + try: + daily, full, _ = cand_full_is_sharpe(factory, tf, params) + except Exception: + continue + sr_all.append(full) + if tag != "TOD": + sr_no_tod.append(full) + if tag.startswith("ERM"): + sr_erm.append(full) + if tag == "ERM" and tf == WIN["tf"] and params.get("L_days") == WIN["L_days"] \ + and params.get("thr") == WIN["thr"] and params.get("long_flat") is False: + win_daily, win_full = daily, full + sr_arr = np.array([s for s in sr_all if np.isfinite(s)]) + print(f" N trial finiti : {len(sr_arr)}") + print(f" Sharpe winner (50/50) : {win_full:+.3f}") + print(f" Sharpe trial: mean {sr_arr.mean():+.2f} std {sr_arr.std():.2f} " + f"max {sr_arr.max():+.2f} >0: {int((sr_arr > 0).sum())}/{len(sr_arr)}") + dsr = None + for label, pool in (("TUTTI 122", sr_all), ("no-TOD", sr_no_tod), ("solo-ERM", sr_erm)): + d, sr0 = deflated_sharpe(win_full, pool, win_daily) + n = int(np.isfinite(np.array(pool)).sum()) + print(f" DSR [{label:>9} N={n:>3}]: {d:.3f} (Sh-max null {sr0:+.2f}) -> " + f"{'PASS' if d >= 0.95 else 'FAIL'}") + if label == "TUTTI 122": + dsr = d + return dsr, win_full, None + + +# =========================================================================== +def part_B_insample_pick(): + print("\n" + "=" * 78) + print("B) SELEZIONE IN-SAMPLE-ONLY (scelgo la cella ERM solo con Sharpe PRE-2025)") + print("=" * 78) + erm_grid = [dict(L_days=L, thr=t, long_flat=lf) + for L in (1.0, 1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.50) + for lf in (False, True)] + rows = [] + for tf in SCREEN_TFS: + for p in erm_grid: + try: + _, full, ins = cand_full_is_sharpe(make_erm, tf, p) + except Exception: + continue + rows.append((ins, full, tf, p)) + rows = [r for r in rows if np.isfinite(r[0])] + rows.sort(key=lambda r: r[0], reverse=True) # ordina per Sharpe IN-SAMPLE (no hold-out) + print(f" Top 5 celle per Sharpe IN-SAMPLE (<2025):") + for ins, full, tf, p in rows[:5]: + print(f" IS {ins:+.2f} FULL {full:+.2f} tf={tf:>3} {p}") + ins, full, tf, p = rows[0] + print(f"\n -> cella scelta SENZA vedere l'hold-out: tf={tf} {p}") + sm = al.study_marginal(f"ERM-ISpick {p}", make_erm(tf=tf, **p), tf=tf) + m = sm["marginal"] + print(f" earns_slot={sm['earns_slot']} marginal={m['marginal_verdict']} " + f"abs={sm['absolute']['verdict']['grade']}") + print(f" corr->TP01 {m['corr_full']} has_insample_edge={m['has_insample_edge']} " + f"is_hedge={m['is_hedge']} robust_oos={m['robust_oos']}") + print(f" blend w25: full uplift {m['blends']['w25']['uplift_full']:+.3f} " + f"hold uplift {m['blends']['w25']['uplift_hold']:+.3f}") + same = (tf == WIN["tf"] and abs(p["L_days"] - WIN["L_days"]) < 1e-9 + and abs(p["thr"] - WIN["thr"]) < 1e-9 and p["long_flat"] is False) + print(f" coincide col vincitore max-hold? {same}") + return sm["earns_slot"], (tf, p) + + +# =========================================================================== +def ensemble_target(tf, cells): + """Media (equal-weight) dei pesi vol-targeted su piu' celle ERM -> un unico stream per asset. + Ritorna un target_fn(df) che ricostruisce l'ensemble per quel df.""" + def fn(df): + ws = [make_erm(tf=tf, **c)(df) for c in cells] + return np.nanmean(np.vstack(ws), axis=0) + return fn + + +def part_C_plateau_ensemble(): + print("\n" + "=" * 78) + print("C) ENSEMBLE DEL PLATEAU ERM 8h (media celle L x thr, NIENTE cherry-pick)") + print("=" * 78) + cells = [dict(L_days=L, thr=t, long_flat=False) + for L in (1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.45, 0.50)] + fn = ensemble_target("8h", cells) + print(f" celle nell'ensemble: {len(cells)} (L 1.5-3.0 x thr 0.30-0.50, lf=False)") + caus = al.causality_ok(fn, tf="8h") + print(f" causale: ok={caus['ok']} max_tail_diff={caus['max_tail_diff']}") + sm = al.study_marginal("ERM-plateau-ens", fn, tf="8h") + m = sm["marginal"] + print(f" earns_slot={sm['earns_slot']} marginal={m['marginal_verdict']} " + f"abs={sm['absolute']['verdict']['grade']}") + print(f" standalone full {m['cand_full_sharpe']} hold {m['cand_hold_sharpe']} " + f"in-sample {m.get('cand_insample_sharpe')}") + print(f" corr->TP01 {m['corr_full']} has_insample_edge={m['has_insample_edge']} " + f"is_hedge={m['is_hedge']} robust_oos={m['robust_oos']}") + print(f" blend w25: full {m['blends']['w25']['full']} (uplift " + f"{m['blends']['w25']['uplift_full']:+.3f}) hold {m['blends']['w25']['hold']} " + f"(uplift {m['blends']['w25']['uplift_hold']:+.3f})") + print(f" multicut: {m['multicut_uplift']}") + return sm["earns_slot"] + + +# =========================================================================== +def part_D_where_edge(): + print("\n" + "=" * 78) + print("D) DOVE VIVE L'EDGE — per-anno standalone + uplift 3-way vs 2-way + corr(ERM,SKH)") + print("=" * 78) + fn = make_erm(**WIN) + cand = al.candidate_daily(fn, tf=WIN["tf"]) + tp = al.tp01_baseline_daily() + skh = _skh_daily() + J = pd.concat({"T": tp, "S": skh, "C": cand}, axis=1, join="inner").dropna() + two = 0.75 * J["T"] + 0.25 * J["S"] + three = 0.60 * J["T"] + 0.25 * J["S"] + 0.15 * J["C"] + print(f" {'anno':>5} {'ERM Sh':>7} {'TP+SKH':>7} {'+ERM':>7} {'Δuplift':>8} " + f"{'corr(ERM,SKH)':>14} {'corr(ERM,TP)':>13}") + for y in sorted(set(J.index.year)): + sub = J[J.index.year == y] + if len(sub) < 40: + continue + s2 = 0.75 * sub["T"] + 0.25 * sub["S"] + s3 = 0.60 * sub["T"] + 0.25 * sub["S"] + 0.15 * sub["C"] + print(f" {y:>5} {al._sh(sub['C']):>+7.2f} {al._sh(s2):>+7.2f} {al._sh(s3):>+7.2f} " + f"{al._sh(s3) - al._sh(s2):>+8.2f} {sub['C'].corr(sub['S']):>+14.2f} " + f"{sub['C'].corr(sub['T']):>+13.2f}") + print(f" {'FULL':>5} {al._sh(J['C']):>+7.2f} {al._sh(two):>+7.2f} {al._sh(three):>+7.2f} " + f"{al._sh(three) - al._sh(two):>+8.2f} {J['C'].corr(J['S']):>+14.2f} " + f"{J['C'].corr(J['T']):>+13.2f}") + JH = J[J.index >= HOLD] + h2 = 0.75 * JH["T"] + 0.25 * JH["S"] + h3 = 0.60 * JH["T"] + 0.25 * JH["S"] + 0.15 * JH["C"] + print(f" {'HOLD':>5} {al._sh(JH['C']):>+7.2f} {al._sh(h2):>+7.2f} {al._sh(h3):>+7.2f} " + f"{al._sh(h3) - al._sh(h2):>+8.2f} {JH['C'].corr(JH['S']):>+14.2f} " + f"{JH['C'].corr(JH['T']):>+13.2f}") + + +def part_E_codified_gate(): + """Validazione END-TO-END del gate appena codificato in altlib: study_family_honest sulla + famiglia ERM deve dare earns_slot_honest=False (sceglie in-sample-only + deflaziona).""" + print("\n" + "=" * 78) + print("E) GATE CODIFICATO (al.study_family_honest) sulla famiglia ERM — deve bocciare") + print("=" * 78) + grid = [dict(L_days=L, thr=t, long_flat=lf) + for L in (1.0, 1.5, 2.0, 2.5, 3.0) for t in (0.30, 0.35, 0.40, 0.50) + for lf in (False, True)] + rep = al.study_family_honest("ERM", make_erm, grid, SCREEN_TFS) + ch = rep["chosen"] + print(f" n_celle={rep['n_cells']} cella in-sample-best: tf={ch['tf']} {ch['params']}") + print(f" earns_slot_marginal={rep['earns_slot_marginal']} " + f"deflated_sharpe={rep['deflated_sharpe']} (dsr_pass={rep['dsr_pass']})") + print(f" => earns_slot_HONEST = {rep['earns_slot_honest']} " + f"(atteso False: slot bocciato dal gate)") + return rep["earns_slot_honest"] + + +def main(): + print("ANALISI ROBUSTEZZA LEAD ERM (filone B) — read-only, nessun impatto live\n") + dsr, win_full, sr0 = part_A_deflated() + es_is, is_cell = part_B_insample_pick() + es_ens = part_C_plateau_ensemble() + part_D_where_edge() + es_honest = part_E_codified_gate() + print("\n" + "=" * 78) + print("SINTESI ANALISI B") + print("=" * 78) + print(f" A) deflated-Sharpe winner = {dsr:.3f} ({'PASS' if dsr >= 0.95 else 'FAIL'} vs 0.95)") + print(f" B) cella scelta in-sample-only earns_slot = {es_is} (cella {is_cell})") + print(f" C) ensemble del plateau earns_slot = {es_ens}") + print(f" E) gate codificato earns_slot_honest = {es_honest}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_harness_realism.py b/tests/test_harness_realism.py index 64c67a9..c5db164 100644 --- a/tests/test_harness_realism.py +++ b/tests/test_harness_realism.py @@ -81,3 +81,58 @@ def test_causality_flags_lookahead(): return f r = al.causality_ok(leaky, tf="1h") assert r["ok"] is False + + +# --- LESSON 4: selection-on-holdout gate (codified 2026-06-29, filone B) ---------------- +def _mom_factory(): + """Tiny continuous momentum factory parametrized by SMA lookback (for grid tests).""" + def factory(tf, win): + def fn(df): + c = df["close"].values.astype(float) + return al.vol_target(np.tanh(3 * (c / al.sma(c, win) - 1)), df, 0.20, 30, 2.0) + return fn + return factory + + +def test_deflated_sharpe_penalizes_multiple_testing(): + """The SAME Sharpe deflates toward 0 when it was the best of MANY wide-dispersion trials, + but survives when it stood alone among a few tight ones (Bailey & Lopez de Prado).""" + rng = np.random.default_rng(0) + T = 1500 + idx = pd.date_range("2020-01-01", periods=T, freq="D", tz="UTC") + sr_target = 1.0 + ret = pd.Series(0.01 * (sr_target / np.sqrt(365.25) + rng.standard_normal(T)), index=idx) + sr_ann = al._sh(ret) + many = list(np.linspace(-3.0, 2.5, 120)) # 120 wide-spread trials + few = [0.1, 0.0, -0.1, 0.05] # 4 tight trials + dsr_many, sr0_many = al.deflated_sharpe(sr_ann, many, ret) + dsr_few, sr0_few = al.deflated_sharpe(sr_ann, few, ret) + assert sr0_many > sr0_few # more/wider search -> higher null max + assert dsr_many < dsr_few # multiple-testing is penalized + assert dsr_many < 0.5 and dsr_few > 0.8 + assert 0.0 <= dsr_many <= 1.0 and 0.0 <= dsr_few <= 1.0 + + +def test_select_cell_insample_ranks_by_insample_only(): + """The cell chosen must be the IN-SAMPLE (