From 33e3e2a603312dbec17d700ad2085a0f673eb583 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Fri, 29 May 2026 01:07:08 +0200 Subject: [PATCH] feat(explore): esplora 9 famiglie alternative -> PAIRS (nuovo edge forte) + TSM01 Esplorazione onesta con agenti paralleli su harness condiviso (explore_lab.py): ingresso close[i], netto fee, OOS, DD basso, attenzione fee. 7 famiglie su 9 sono rumore (stagionalita' oraria/mensile, cross-sectional reversal, opening-range, lead-lag BTC->alt, continuation intraday) e l'harness le rifiuta senza falsi positivi. Due edge reali verificati indipendentemente: - PR01 Pairs: spread reversion market-neutral su log-ratio z-score (ETH/BTC, LTC/ETH, ADA/ETH). ETH/BTC CAGR 144% Sharpe 4.04 OOS DD 17% 8/9 anni, corr mercato ~0.02, no-look-ahead verificato, regge fee 0.40%/coppia. Fee su 2 gambe (worker da estendere). - TSM01: TSMOM multi-orizzonte 3/6/12m + risk-off, distinto da ROT02 (corr 0.53), DD 22%/12% OOS, mai un anno negativo, regge fee 0.40%. Payoff: aggiungere i pairs (quasi scorrelati ~0.05) al MASTER -> CAGR 47->66%, DD 5.2->3.8% full / 4.7->3.3% OOS, Sharpe OOS 4.33->6.86 (combine_v2.py). Fix: explore_lab.get_df ora produce timestamp ms reale per 1d/4h (era placeholder). Diario 2026-05-29-exploration.md + nota CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 16 ++ docs/diary/2026-05-29-exploration.md | 89 +++++++++++ scripts/analysis/combine_v2.py | 85 ++++++++++ scripts/analysis/explore_lab.py | 171 +++++++++++++++++++++ scripts/analysis/pairs_research.py | 133 ++++++++++++++++ scripts/analysis/tsmom_research.py | 87 +++++++++++ scripts/strategies/PR01_pairs_reversion.py | 57 +++++++ 7 files changed, 638 insertions(+) create mode 100644 docs/diary/2026-05-29-exploration.md create mode 100644 scripts/analysis/combine_v2.py create mode 100644 scripts/analysis/explore_lab.py create mode 100644 scripts/analysis/pairs_research.py create mode 100644 scripts/analysis/tsmom_research.py create mode 100644 scripts/strategies/PR01_pairs_reversion.py diff --git a/CLAUDE.md b/CLAUDE.md index 258533b..63c906e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,22 @@ honest), due script in `scripts/strategies/`: Come `PORT01`, sono meta-portafogli (script `run()` di report), non `Strategy` con `generate_signals`, quindi non nel `strategy_loader`. +**Esplorazione famiglie alternative (branch `strategy_explore`, 2026-05-29).** Esplorate +9 famiglie nuove con agenti paralleli su harness onesto condiviso +(`scripts/analysis/explore_lab.py`). 7 sono rumore (rifiutate: stagionalità oraria/mensile, +cross-sectional reversal, opening-range breakout, lead-lag BTC→alt, continuation intraday — +quest'ultima riconferma la dominanza mean-reversion). Due edge reali: +- **PR01 Pairs** (`scripts/strategies/PR01_pairs_reversion.py`): spread reversion + market-neutral sul log-ratio z-score di coppie cripto (ETH/BTC, LTC/ETH, ADA/ETH). + ETH/BTC: CAGR 144%, Sharpe 4.04, OOS DD 17%, 8/9 anni; corr col mercato ~0.02. Fee su + **2 gambe** (worker da estendere prima del live). Verifica: `pairs_research.py`. +- **TSM01** (`scripts/analysis/tsmom_research.py`): TSMOM multi-orizzonte 3/6/12m + risk-off, + marginale ma distinto (corr 0.53 con ROT02), DD 22%/12% OOS, mai un anno negativo. + +Aggiungere i **pairs** al MASTER (quasi scorrelati, ~0.05) è il free-lunch più grande: +MASTER+pairs (12 sleeve) → CAGR 47→66%, DD 5.2→3.8% full / 4.7→3.3% OOS, Sharpe OOS +4.33→**6.86** (`scripts/analysis/combine_v2.py`). Caveat: backtest leva 3x, OOS ~1.6 anni. + **Metodologia obbligatoria per ogni nuova strategia** (per non ripetere l'errore squeeze): 1. Ingresso eseguibile: direzione e prezzo decisi con dati **fino a `close[i]`**, mai `close[i-1]` con direzione da `i`. 2. Backtest **NETTO** dopo fee realistiche Deribit (**0.10% RT** taker, non 0.20%) + leva. diff --git a/docs/diary/2026-05-29-exploration.md b/docs/diary/2026-05-29-exploration.md new file mode 100644 index 0000000..91f1ed7 --- /dev/null +++ b/docs/diary/2026-05-29-exploration.md @@ -0,0 +1,89 @@ +# Diario — 2026-05-29 — Esplorazione di nuove famiglie di strategie + +## Obiettivo + +Trovare 5-10 nuove famiglie di strategie, diverse da quelle esistenti, migliori o +complementari, con DD basso e attenzione alle fee. Esplorazione onesta (no +look-ahead, netto fee, OOS) condotta con **agenti paralleli**, ognuno su una famiglia +indipendente, tutti sullo stesso harness condiviso (`scripts/analysis/explore_lab.py`). +Lavoro sul branch `strategy_explore`. + +## Famiglie esplorate (9) ed esito onesto + +| Famiglia | Esito | Note | +|---|---|---| +| **Pairs / spread reversion** | ✅ **VINCITORE** | Market-neutral, genuinamente nuova, decorrelata | +| **TSMOM multi-orizzonte** | ✅ diversificatore | Marginale ma distinto (corr 0.53 con ROT02), DD basso | +| Stagionalità settimanale | ⚠️ marginale/fragile | "Mercoledì-long-24h" 7/8 asset OOS+ ma effetto concentrato a 00:00 UTC | +| Vol-target BTC | ⚠️ marginale | Sharpe 0.94 vs 0.76 buy&hold, DD ancora 44% | +| Stagionalità intraday (ora) | ❌ rumore | L'edge orario muore sotto le fee | +| Stagionalità mensile/turn-of-month | ❌ rumore | Reale in-sample, morto OOS dal 2024 | +| Cross-sectional reversal | ❌ nessun edge | Perde vs equal-weight, corr 0.98 col momentum | +| Opening-range breakout | ❌ non generalizza | Solo BTC/ETH, alcuni regimi, fee-fragile | +| Lead-lag BTC→alt | ❌ nessun edge | Reazione contemporanea (corr lag+1 ≈ 0), non batte buy&hold | +| Momentum/continuation intraday | ❌ negativo | Conferma: il *fade* (mean-reversion) domina | + +7 famiglie su 9 sono rumore — e l'harness le ha rifiutate senza produrre falsi +positivi (segnale che la metodologia onesta funziona). Due edge reali emergono. + +## Vincitore 1 — PAIRS (market-neutral) — `PR01_pairs_reversion.py` + +Scommette sul rientro del log-ratio di due cripto verso la media (z-score). Quando +`z ≤ −2` → long A / short B; `z ≥ +2` → l'opposto; esce al rientro (`|z| ≤ 0.5`) o a +tempo. Engine onesto verificato in `pairs_research.py` (test esplicito no-look-ahead: +`z[i]` invariato perturbando il futuro). Fee contate su **2 gambe** (0.20% RT/coppia). + +Validazione (netto, leva 3x, OOS = ultimo 30%, 1h): + +| Coppia | CAGR | Sharpe | OOS DD | anni+ | +|---|--:|--:|--:|--:| +| ETH/BTC | 144% | 4.04 | 17% | 8/9 | +| LTC/ETH | 71% | 2.52 | 10% | 7/8 | +| ADA/ETH | 77% | 2.16 | 11% | 7/8 | + +Tutte le 10 coppie testate positive FULL+OOS, regge fee 0.40% RT/coppia, correlazione +col mercato ~0.02 (market-neutral confermato). DD pieno 42-49% (alto), ma OOS DD +10-17% (buono) e soprattutto **quasi-zero correlazione** col resto → diversificatore +eccezionale. Limite: 2 gambe (long+short), il worker live va esteso prima del live. + +## Vincitore 2 — TSM01 (TSMOM multi-orizzonte) — `tsmom_research.py` + +Long-only multi-crypto: tiene equal-weight gli asset con consenso pieno del segno di +momentum su 3/6/12 mesi, cash se BTC corr ~0 col mercato + - TSM01 (TSMOM multi-orizzonte + risk-off) -> corr ~0.53 con ROT02 + +Misura correlazione delle nuove sleeve vs esistenti e confronta MASTER-9 vs +MASTER-esteso su Ret/CAGR/DD/Sharpe, FULL e OOS (finestra comune 2021-2026). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.combine_portfolio import ( + build_all_sleeves, port_returns, metrics, yearly_returns, SPLIT, OOS_DATE, IDX, +) +from scripts.analysis.honest_improve2 import _daily_equity, _norm +from scripts.analysis.pairs_research import pairs_sim +from scripts.analysis.tsmom_research import tsmom_sim + + +def daily_from(eq_ts, eq_v): + return _norm(_daily_equity(eq_ts, eq_v, IDX)) + + +def main(): + print("Costruzione equity (puo' richiedere ~1-2 min)...\n") + S = build_all_sleeves() # 9 sleeve esistenti + + # nuove sleeve + new = {} + for a, b in [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH")]: + r = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72) + new[f"PR_{a}{b}"] = daily_from(r["eq_ts"], r["eq_v"]) + t = tsmom_sim() + new["TSM01"] = daily_from(t["eq_ts"], t["eq_v"]) + + allS = {**S, **new} + + # --- correlazione nuove vs esistenti --- + dr = pd.DataFrame({k: v.pct_change().fillna(0.0) for k, v in allS.items()}) + corr = dr.corr() + old_k = list(S); new_k = list(new) + print("=" * 88) + print(" CORRELAZIONE rendimenti giornalieri — NUOVE (righe) vs media esistenti") + print("=" * 88) + for nk in new_k: + avg = corr.loc[nk, old_k].mean() + mx = corr.loc[nk, old_k].abs().max() + print(f" {nk:<12s} corr media col MASTER-9 = {avg:+.2f} |max| = {mx:.2f}") + + # --- confronto portafogli --- + def line(label, members): + pr = port_returns(members) + f, o = metrics(pr), metrics(pr, lo=SPLIT) + print(f" {label:<26s}{f['ret']:>+9.0f}{f['cagr']:>7.0f}{f['dd']:>7.1f}{f['sharpe']:>7.2f}" + f" | {o['ret']:>+9.0f}{o['dd']:>7.1f}{o['sharpe']:>7.2f}") + return pr + + print("\n" + "=" * 96) + print(f" MASTER-9 vs MASTER-ESTESO (con pairs+TSM01) | OOS da {OOS_DATE} | equal-weight daily") + print("=" * 96) + print(f" {'portafoglio':<26s}{'Ret%':>9s}{'CAGR':>7s}{'DD%':>7s}{'Shrp':>7s}" + f" | {'oRet%':>9s}{'oDD%':>7s}{'oShrp':>7s}") + print(" " + "-" * 92) + line("MASTER-9 (base)", S) + line("MASTER +pairs (12)", {**S, **{k: v for k, v in new.items() if k.startswith('PR_')}}) + line("MASTER +TSM01 (10)", {**S, "TSM01": new["TSM01"]}) + pr_all = line("MASTER-esteso (13)", allS) + print(" " + "-" * 92) + pa = yearly_returns(pr_all) + print(" MASTER-esteso per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in pa.items())) + print("\n Se il MASTER-esteso ha DD piu' basso e/o Sharpe piu' alto del MASTER-9, le nuove") + print(" famiglie aggiungono valore (diversificazione da fonti scorrelate).") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/explore_lab.py b/scripts/analysis/explore_lab.py new file mode 100644 index 0000000..63326c8 --- /dev/null +++ b/scripts/analysis/explore_lab.py @@ -0,0 +1,171 @@ +"""Harness ONESTO condiviso per esplorare nuove famiglie di strategie. + +Regole NON negoziabili (per non ripetere l'errore squeeze look-ahead): + - direzione e prezzo decisi con dati FINO a close[i] incluso, mai con la barra i + usata per scegliere la direzione e poi entrare a i-1; + - ingresso ESEGUIBILE a close[i]; + - exit: take-profit / stop-loss intrabar (high/low) e/o time-limit max_bars; + tp/sl possono essere None -> exit solo a tempo (utile per stagionalita'); + - una posizione per volta (non-overlap), capitale composto; + - NETTO dopo fee round-trip (default 0.10% RT reale Deribit) e leva; + - validazione OOS (held-out, ultimo 30%) + sweep fee 0.00-0.20% RT. + +Le strategie ad alta frequenza muoiono di fee: ogni entry costa fee_rt*lev sul +notional. Tienine conto: meno operazioni e edge > costi. + +Asset disponibili: ADA BNB BTC DOGE ETH LTC SOL XRP (1h, 15m; BTC/ETH anche 5m). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.data.downloader import load_data + +FEE_RT = 0.001 # Deribit perp realistico: taker ~0.05%/lato = 0.10% RT +LEV = 3.0 +POS = 0.15 +OOS_FRAC = 0.30 +ASSETS = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"] +BARS_PER_YEAR = {"5m": 105120, "15m": 35040, "1h": 8760, "4h": 2190, "1d": 365} + + +# --------------------------- dati --------------------------- +def get_df(asset: str, tf: str) -> pd.DataFrame: + """OHLCV con colonna dt (UTC). tf nativo (5m,15m,1h) o resample da 1h (4h,1d). + timestamp resta ms-epoch reale anche dopo il resample (no placeholder).""" + if tf in ("5m", "15m", "1h"): + df = load_data(asset, tf).reset_index(drop=True) + else: + base = load_data(asset, "1h").copy() + base["dt"] = pd.to_datetime(base["timestamp"], unit="ms", utc=True) + base = base.set_index("dt") + rule = {"4h": "4h", "1d": "1D"}[tf] + agg = base.resample(rule).agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"} + ).dropna() + epoch = pd.Timestamp("1970-01-01", tz="UTC") # ms-epoch portabile (qualsiasi risoluzione) + agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") + df = agg.reset_index(drop=True) + df["dt"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + return df + + +def _dt(df: pd.DataFrame) -> pd.DatetimeIndex: + return pd.to_datetime(df["timestamp"], unit="ms", utc=True) + + +# --------------------------- indicatori --------------------------- +def atr(df: pd.DataFrame, n: int = 14) -> np.ndarray: + h, l, c = df["high"].values, df["low"].values, df["close"].values + pc = np.roll(c, 1); pc[0] = c[0] + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + return pd.Series(tr).rolling(n).mean().values + + +def ema(x: np.ndarray, n: int) -> np.ndarray: + return pd.Series(x).ewm(span=n, adjust=False).mean().values + + +def rsi(close: np.ndarray, n: int = 14) -> 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() + dn = pd.Series(np.where(d < 0, -d, 0.0)).ewm(alpha=1/n, adjust=False).mean() + rs = up / dn.replace(0, np.nan) + return (100 - 100 / (1 + rs)).values + + +# --------------------------- engine --------------------------- +def simulate(entries: list[dict], df: pd.DataFrame, fee_rt: float = FEE_RT, + lev: float = LEV, pos: float = POS, split: int = -1) -> dict: + """entries: dict con i(idx), d(+1/-1), max_bars; tp/sl opzionali (None=solo tempo). + split: se >0, conta solo entries con i>=split (finestra OOS).""" + h, l, c = df["high"].values, df["low"].values, df["close"].values + n = len(c) + ts = _dt(df) + cap = peak = 1000.0 + max_dd = 0.0 + fee = fee_rt * lev + trades = wins = 0 + last_exit = -1 + bars_in = 0 + yearly: dict[int, float] = {} + rets: list[float] = [] + + for e in entries: + i, d = e["i"], e["d"] + if i <= last_exit or i + 1 >= n or i < split: + continue + entry = c[i] + tp, sl, mb = e.get("tp"), e.get("sl"), e["max_bars"] + exit_p = c[min(i + mb, n - 1)] + j = min(i + mb, n - 1) + for k in range(1, mb + 1): + j = i + k + if j >= n: + exit_p = c[n - 1]; break + hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl)) + hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)) + if hit_sl: + exit_p = sl; break + if hit_tp: + exit_p = tp; break + if k == mb: + exit_p = c[j] + ret = (exit_p - entry) / entry * d * lev - fee + cb = cap + cap = max(cb + cb * pos * ret, 10.0) + peak = max(peak, cap); max_dd = max(max_dd, (peak - cap) / peak) + trades += 1; wins += ret > 0; bars_in += (j - i) + last_exit = j + rets.append(ret * pos) + yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100 + + sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(len(rets))) if len(rets) > 1 and np.std(rets) > 0 else 0.0 + return { + "trades": trades, + "win": wins / trades * 100 if trades else 0.0, + "ret": (cap / 1000 - 1) * 100, + "dd": max_dd * 100, + "sharpe": sharpe, + "yearly": yearly, + "exposure": bars_in / n * 100 if n else 0.0, + } + + +def evaluate(name: str, entries: list[dict], df: pd.DataFrame, + fees=(0.0, 0.0005, 0.001, 0.002)) -> dict: + """Valuta una lista di entries: FULL, OOS e sweep fee. Stampa una riga sintetica.""" + split = int(len(df) * (1 - OOS_FRAC)) + full = simulate(entries, df) + oos = simulate(entries, df, split=split) + sweep = {f: simulate(entries, df, fee_rt=f)["ret"] for f in fees} + sweep_oos = {f: simulate(entries, df, fee_rt=f, split=split)["ret"] for f in fees} + yrs = full["yearly"]; pos_yrs = sum(1 for v in yrs.values() if v > 0) + print(f" {name:<24s} trd={full['trades']:>5d} win={full['win']:>4.1f}% " + f"FULL={full['ret']:>+7.0f}% OOS={oos['ret']:>+7.0f}% DD={full['dd']:>4.0f}% " + f"oDD={oos['dd']:>4.0f}% Shrp={full['sharpe']:>4.2f} exp={full['exposure']:>4.1f}% " + f"anniPos={pos_yrs}/{len(yrs)} | fee0.2%: FULL={sweep[0.002]:>+6.0f} OOS={sweep_oos[0.002]:>+6.0f}") + return {"full": full, "oos": oos, "sweep": sweep, "sweep_oos": sweep_oos, "pos_yrs": pos_yrs, "n_yrs": len(yrs)} + + +def robust(res: dict) -> bool: + """Verdetto onesto: positivo FULL e OOS, regge a fee 0.20% RT, quasi tutti gli anni positivi.""" + return (res["full"]["ret"] > 0 and res["oos"]["ret"] > 0 + and res["sweep"][0.002] > 0 and res["sweep_oos"][0.002] > 0 + and res["pos_yrs"] >= max(res["n_yrs"] - 1, 1)) + + +if __name__ == "__main__": + # smoke test: una stagionalita' banale (hour-of-day) su BTC 1h + df = get_df("BTC", "1h"); ts = _dt(df) + ents = [{"i": i, "d": 1, "max_bars": 6, "tp": None, "sl": None} + for i in range(len(df) - 7) if ts.iloc[i].hour == 0] + print("smoke test — BTC long ad ogni 00:00 UTC, hold 6h:") + evaluate("seasonality_h0", ents, df) diff --git a/scripts/analysis/pairs_research.py b/scripts/analysis/pairs_research.py new file mode 100644 index 0000000..cf0af7b --- /dev/null +++ b/scripts/analysis/pairs_research.py @@ -0,0 +1,133 @@ +"""Verifica indipendente + ricerca PAIRS / SPREAD MEAN-REVERSION fra cripto. + +Famiglia nuova market-neutral (distinta da tutto l'esistente, single-asset). +Idea: il log-ratio di due cripto oscilla attorno alla media; z-score estremo -> rientra. + +Engine ONESTO (no look-ahead, verificato): + - r[i] = log(closeA[i]/closeB[i]); ma/sd = rolling(n) su r -> usano solo r[<=i]. + - z[i] = (r[i]-ma[i])/sd[i]. ENTRY a close[i] (eseguibile): + z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio. + - EXIT quando |z[j]| <= z_exit (rientro) o time-limit max_bars, a close[j]. + - pairs = 2 GAMBE -> fee = 2*fee_rt*lev (0.20% RT/coppia a fee_rt=0.001), il doppio + del single-asset. Rendimento neutral = retA*d - retB*d (notional uguale per gamba). + - non-overlap, capitale composto. Filtro candele sporche: salta salti |dr|>jump_max. + - Ritorno riportato come CAGR e Sharpe ANNUALIZZATO sul tempo reale (no sqrt(n_trade)). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from src.data.downloader import load_data + +FEE_RT, LEV, POS, OOS_FRAC = 0.001, 3.0, 0.15, 0.30 +BARS_YEAR = 8760 # 1h + + +def aligned(a: str, b: str, tf: str = "1h"): + da = load_data(a, tf)[["timestamp", "open", "high", "low", "close"]].rename(columns=lambda x: x + "_a" if x != "timestamp" else x) + db = load_data(b, tf)[["timestamp", "close"]].rename(columns={"close": "close_b"}) + m = da.merge(db, on="timestamp", how="inner").reset_index(drop=True) + m["dt"] = pd.to_datetime(m["timestamp"], unit="ms", utc=True) + return m + + +def pairs_sim(a, b, tf="1h", n=50, z_in=2.0, z_exit=0.5, max_bars=72, + jump_max=0.08, fee_rt=FEE_RT, lev=LEV, pos=POS, split_frac=0.0): + m = aligned(a, b, tf) + ca, cb = m["close_a"].values, m["close_b"].values + r = np.log(ca / cb) + dr = np.abs(np.diff(r, prepend=r[0])) # salto 1-bar del log-ratio + ma = pd.Series(r).rolling(n).mean().values + sd = pd.Series(r).rolling(n).std().values + z = (r - ma) / np.where(sd == 0, np.nan, sd) # causale: usa r[<=i] + ts = m["dt"]; N = len(r) + split = int(N * split_frac) + fee = 2 * fee_rt * lev # 2 gambe + cap = peak = 1000.0; dd = 0.0; last = -1 + trades = wins = 0; rets = []; yearly = {} + eq_ts: list = []; eq_v: list = [] + for i in range(n + 1, N - 1): + if i < split or np.isnan(z[i]) or dr[i] > jump_max: + continue + if i <= last: + continue + if z[i] <= -z_in: + d = 1 + elif z[i] >= z_in: + d = -1 + else: + continue + # exit: |z|<=z_exit o max_bars + j = min(i + max_bars, N - 1) + for k in range(1, max_bars + 1): + jj = i + k + if jj >= N: + j = N - 1; break + if abs(z[jj]) <= z_exit: + j = jj; break + j = jj + retA = (ca[j] - ca[i]) / ca[i] + retB = (cb[j] - cb[i]) / cb[i] + ret = (retA - retB) * d * lev - fee # long A / short B (o viceversa) + cap = max(cap + cap * pos * ret, 10.0) + peak = max(peak, cap); dd = max(dd, (peak - cap) / peak) + trades += 1; wins += ret > 0; rets.append(ret * pos); last = j + eq_ts.append(ts.iloc[j]); eq_v.append(cap) + yearly[ts.iloc[i].year] = yearly.get(ts.iloc[i].year, 0.0) + ret * 100 + yrs_span = (ts.iloc[-1] - ts.iloc[max(split, 0)]).days / 365.25 or 1 + sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(BARS_YEAR / np.mean([max_bars])) ) if len(rets) > 1 and np.std(rets) > 0 else 0.0 + # Sharpe annualizzato sul tempo reale: usa rendimenti per-trade scalati alla frequenza media + if len(rets) > 1 and np.std(rets) > 0: + trades_per_year = trades / yrs_span + sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades_per_year)) + ret_tot = (cap / 1000 - 1) * 100 + cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100 + return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot, cagr=cagr, + dd=dd * 100, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v) + + +def check_no_lookahead(): + """Perturba il FUTURO del ratio e verifica che z[i] non cambi (causalita').""" + m = aligned("ETH", "BTC") + r = np.log(m["close_a"].values / m["close_b"].values) + n = 50; i = 1000 + z_i = (r[i] - pd.Series(r).rolling(n).mean().values[i]) / pd.Series(r).rolling(n).std().values[i] + r2 = r.copy(); r2[i + 1:] += 0.5 # stravolge il futuro + z_i2 = (r2[i] - pd.Series(r2).rolling(n).mean().values[i]) / pd.Series(r2).rolling(n).std().values[i] + print(f" no-look-ahead: z[i]={z_i:.6f} vs z[i] con futuro perturbato={z_i2:.6f} -> " + f"{'OK (identico)' if abs(z_i - z_i2) < 1e-9 else 'VIOLAZIONE!'}") + + +def main(): + print("=" * 104) + print(f" PAIRS spread reversion — NETTO fee 0.20% RT/coppia (2 gambe), leva {LEV:.0f}x, OOS ultimo {int(OOS_FRAC*100)}%") + print("=" * 104) + check_no_lookahead() + pairs = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH"), ("SOL", "ETH"), + ("BNB", "BTC"), ("XRP", "BTC"), ("SOL", "BTC"), ("DOGE", "BTC")] + print(f"\n {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'FULL%':>8s}{'OOS%':>8s}{'CAGR%':>7s}" + f"{'DD%':>6s}{'oDD%':>6s}{'Shrp':>6s}{'anni+':>7s}{'fee0.4%RT':>11s}") + print(" " + "-" * 96) + for a, b in pairs: + f = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72) + o = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, split_frac=1 - OOS_FRAC) + hi = pairs_sim(a, b, n=50, z_in=2.0, z_exit=0.5, max_bars=72, fee_rt=0.002) # 0.4% RT/coppia + yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0) + print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['ret']:>+8.0f}{o['ret']:>+8.0f}" + f"{f['cagr']:>7.0f}{f['dd']:>6.0f}{o['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}" + f"{hi['ret']:>+11.0f}") + # correlazione con BTC daily (market-neutrality) sulla coppia migliore + print("\n Verifica market-neutrality ETH/BTC: per-anno") + f = pairs_sim("ETH", "BTC", n=50, z_in=2.0, z_exit=0.5, max_bars=72) + print(" " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(f["yearly"].items()))) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/tsmom_research.py b/scripts/analysis/tsmom_research.py new file mode 100644 index 0000000..36d4d29 --- /dev/null +++ b/scripts/analysis/tsmom_research.py @@ -0,0 +1,87 @@ +"""Verifica indipendente + ricerca TSM01 — Time-Series Momentum multi-orizzonte. + +Long-only, multi-crypto, bassa frequenza. Per ogni asset il segnale è il CONSENSO +dei segni del momentum su più orizzonti lunghi (3/6/12 mesi); si tengono equal-weight +gli asset con consenso pieno positivo. Overlay risk-off: cash se BTC < SMA100. + +Distinta da ROT02 (cross-sectional ranking): qui conta la PERSISTENZA assoluta lenta +di ogni asset, non la classifica relativa. Correlazione con ROT02 ~0.53 -> fattore +parzialmente indipendente, utile come diversificatore. DD basso (22% full / 12% OOS). + +Engine onesto: pesi a close[i] da soli rendimenti passati, realizzo i->i+1, fee +one-way fee_rt/2 sul turnover. NETTO, leva implicita gross. OOS = ultimo 30%. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.honest_lab import available_assets, FEE_RT +from scripts.analysis.honest_rotation import build_panel + +GROSS, OOS_FRAC = 0.45, 0.30 + + +def tsmom_sim(horizons=(63, 126, 252), thr=1.0, regime_n=100, gross=GROSS, + fee_rt=FEE_RT, oos_frac=0.0, cheat=False): + """horizons in giorni. thr=1.0 -> consenso pieno (tutti i segni positivi).""" + panel = build_panel(available_assets(), "1d") + cols = list(panel.columns); P = panel.values; T, N = P.shape + rets = np.zeros_like(P); rets[1:] = P[1:] / P[:-1] - 1 + years = panel.index.year.values + btc = P[:, cols.index("BTC")] + bma = pd.Series(btc).rolling(regime_n).mean().values + start = max(max(horizons) + 1, regime_n + 1, int(T * (1 - oos_frac)) if oos_frac else 0) + cap = 1000.0; w = np.zeros(N); eq = [cap]; yearly = {} + eq_ts: list = []; eq_v: list = [] + for i in range(start, T - 1): + risk_on = btc[i] > bma[i] if not np.isnan(bma[i]) else False + wi = i + 1 if cheat else i # cheat: usa il futuro (test no-look-ahead) + score = np.zeros(N) + for h in horizons: + score += np.sign(P[wi] / P[wi - h] - 1) + score /= len(horizons) + chosen = [j for j in range(N) if score[j] >= thr] if risk_on else [] + nw = np.zeros(N) + for j in chosen: + nw[j] = gross / len(chosen) + cap -= cap * np.abs(nw - w).sum() * (fee_rt / 2); w = nw + cap = max(cap * (1 + float(np.dot(w, rets[i + 1]))), 10.0) + eq.append(cap) + eq_ts.append(panel.index[i + 1]); eq_v.append(cap) + y = int(years[i]); yearly[y] = yearly.get(y, 0.0) + float(np.dot(w, rets[i + 1])) * 100 + eq = np.array(eq); peak = np.maximum.accumulate(eq) + dd = float(np.max((peak - eq) / peak) * 100) + yrs = (panel.index[-1] - panel.index[start]).days / 365.25 or 1 + rets_d = np.diff(eq) / eq[:-1] + sharpe = float(np.mean(rets_d) / np.std(rets_d) * np.sqrt(365)) if np.std(rets_d) > 0 else 0.0 + return dict(ret=(cap / 1000 - 1) * 100, cagr=((cap / 1000) ** (1 / yrs) - 1) * 100, + dd=dd, sharpe=sharpe, yearly=yearly, eq_ts=eq_ts, eq_v=eq_v, + pos_years=sum(1 for v in yearly.values() if v > 0), n_years=len(yearly)) + + +def main(): + print("=" * 90) + print(" TSM01 — TSMOM multi-orizzonte (3/6/12m consenso pieno) + risk-off SMA100") + print("=" * 90) + # no-look-ahead: cheat deve esplodere + base = tsmom_sim() + ch = tsmom_sim(cheat=True) + print(f" no-look-ahead: onesto FULL={base['ret']:+.0f}% vs cheat(futuro)={ch['ret']:+.0f}% -> " + f"{'OK (il cheat esplode -> niente leak)' if ch['ret'] > base['ret'] * 2 else 'CONTROLLARE'}") + o = tsmom_sim(oos_frac=1 - OOS_FRAC) + hi = tsmom_sim(fee_rt=0.002) + print(f"\n FULL {base['ret']:+.0f}% CAGR {base['cagr']:.0f}% DD {base['dd']:.0f}% " + f"Sharpe {base['sharpe']:.2f} anni+ {base['pos_years']}/{base['n_years']}") + print(f" OOS {o['ret']:+.0f}% DD {o['dd']:.0f}% | fee 0.40% RT: FULL {hi['ret']:+.0f}%") + print(" Per-anno: " + " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(base["yearly"].items()))) + + +if __name__ == "__main__": + main() diff --git a/scripts/strategies/PR01_pairs_reversion.py b/scripts/strategies/PR01_pairs_reversion.py new file mode 100644 index 0000000..81ffb62 --- /dev/null +++ b/scripts/strategies/PR01_pairs_reversion.py @@ -0,0 +1,57 @@ +"""PR01 — Pairs / Spread Mean-Reversion fra cripto (market-neutral). FAMIGLIA NUOVA. + +Distinta da tutto l'esistente (single-asset direzionale): scommette sul RIENTRO del +log-ratio di due cripto verso la sua media. Market-neutral (long A / short B) -> +correlazione ~0.02 col mercato -> diversificatore prezioso. + +Logica (engine onesto verificato in scripts/analysis/pairs_research.py): + r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale) + z <= -z_in -> LONG ratio (long A / short B) + z >= +z_in -> SHORT ratio (short A / long B) + EXIT: |z| <= z_exit (rientro) o time-limit max_bars. Ingresso/uscita a close. + Fee su 2 GAMBE = 2*fee_rt*lev (0.20% RT/coppia). Filtro candele sporche (salto>8%). + +Validazione (netto, fee 0.20% RT/coppia reale a 2 gambe, leva 3x, OOS = ultimo 30%, +n=50 z_in=2.0 z_exit=0.5 max_bars=72, 1h): + ETH/BTC : CAGR 144% / OOS DD 17% / Sharpe 4.04 / win 74% / 8/9 anni positivi + LTC/ETH : CAGR 71% / OOS DD 10% / Sharpe 2.52 / 7/8 anni positivi + ADA/ETH : CAGR 77% / OOS DD 11% / Sharpe 2.16 / 7/8 anni positivi + No look-ahead verificato (z[i] invariato perturbando il futuro). Regge fee 0.40% RT/coppia. + Correlazione con BTC daily ~0.02 -> davvero market-neutral. + +LIMITE OPERATIVO: e' una strategia a 2 gambe (long un perp + short l'altro), il worker +attuale e' single-leg. Per tradarla serve: (a) eseguibilita' short del perp B su +Deribit/Bybit, (b) gestione 2 ordini + fee doppie. Finche' il worker non supporta +2 gambe, PR01 resta validata in backtest ma non wired nel paper trader. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) + +from scripts.analysis.pairs_research import pairs_sim, OOS_FRAC # noqa: E402 + +PAIRS = [("ETH", "BTC"), ("LTC", "ETH"), ("ADA", "ETH")] +PARAMS = dict(n=50, z_in=2.0, z_exit=0.5, max_bars=72) + + +def run(): + print("=" * 92) + print(" PR01 — PAIRS spread reversion (market-neutral) | netto fee 0.20% RT/coppia, leva 3x") + print("=" * 92) + print(f" {'coppia':<10s}{'trd':>5s}{'win%':>6s}{'CAGR%':>7s}{'OOS DD%':>8s}{'DD%':>6s}{'Shrp':>6s}{'anni+':>7s}") + for a, b in PAIRS: + f = pairs_sim(a, b, **PARAMS) + o = pairs_sim(a, b, **PARAMS, split_frac=1 - OOS_FRAC) + yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0) + print(f" {a+'/'+b:<10s}{f['trades']:>5d}{f['win']:>6.1f}{f['cagr']:>7.0f}{o['dd']:>8.0f}" + f"{f['dd']:>6.0f}{f['sharpe']:>6.2f}{f'{pos_y}/{len(yrs)}':>7s}") + print("\n Market-neutral (corr ~0.02 col mercato) -> ottimo diversificatore di portafoglio.") + print(" NB: 2 gambe (long A / short B), fee doppie. Worker live da estendere prima del live.") + + +if __name__ == "__main__": + run()