38c8cdf25b
Harness onesto research_lab.py (serie di posizione causale, fee-aware, null model a rotazione circolare, hold-out 2025+ bloccato; self-test cheat/noise che valida il banco). - Fase 1: triage superstiti (DIP, shape-ML) -> morti net-fee. - Fase 2: esplorazione famiglie (reversal morta; solo trend long-only/MA-cross passa i gate base). - Fase 3: conferma avversariale del trend -> regime-luck del toro, bocciato sul hold-out 2025-26. - Ricerca frattale multi-agente (Workflow, 63 agenti, 52 ipotesi dai due documenti) con guard anti-look-ahead (eval_signal.py) + hold-out + test cross-asset -> 0 edge robusto (l'unico "confermato" su ETH fallisce su BTC con lo stesso codice). - Analisi options: VRP reale +10/+14 vol pt ma finestra 6 sett. regime unico -> non validabile; ruolo solo overlay tail-cap, tenere cerbero-bite ad accumulare. Quinta conferma indipendente: su BTC/ETH-solo-prezzo non c'e' un edge facile. Il processo disciplinato ha evitato un falso "+49% vs -49%" che sul vecchio feed contaminato sarebbe finito in produzione. Diari docs/diary/2026-06-19-research-phase0-1 / -phase2-options / -phase3-confirm / -fractal-multiagent-search. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
222 lines
9.7 KiB
Python
222 lines
9.7 KiB
Python
"""FASE 2 — esplorazione larga per famiglie su BTC/ETH, harness onesto (research_lab).
|
|
|
|
Famiglie (serie di posizione, causali, netto fee, vs buy&hold + null p-value):
|
|
TSMOM (momentum) | REVERSAL | MA-cross | DONCHIAN breakout | VOL-TARGET overlay |
|
|
LEAD-LAG BTC<->ETH | HURST-gated momentum. Multi-TF dove sensato (1h + 15m).
|
|
|
|
La barra DA BATTERE è il buy&hold (Sharpe ~0.8 su BTC/ETH): una strategia di timing vale solo
|
|
se fa MEGLIO net-fee. Per ogni famiglia: scan griglia (FULL Sharpe), poi report onesto sulla
|
|
config migliore. Selezionare il best-di-griglia GONFIA -> i gate veri sono OOS-VAL + null p<0.05.
|
|
|
|
uv run python scripts/analysis/phase2_families.py
|
|
"""
|
|
from __future__ import annotations
|
|
import sys
|
|
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 src.data.downloader import load_data
|
|
from scripts.analysis.research_lab import (
|
|
backtest, buy_hold, mc_pvalue, window_mask, ts, VAL_START, HOLDOUT_START, BARS_PER_YEAR,
|
|
)
|
|
|
|
|
|
# --------------------------------- famiglie ---------------------------------
|
|
def tsmom(df, L, mode="ls"):
|
|
c = pd.Series(df["close"].values.astype(float))
|
|
pos = np.sign(np.nan_to_num((c / c.shift(L) - 1).values))
|
|
return np.maximum(pos, 0) if mode == "lo" else pos
|
|
|
|
|
|
def reversal(df, L, mode="ls"):
|
|
c = pd.Series(df["close"].values.astype(float))
|
|
pos = -np.sign(np.nan_to_num((c / c.shift(L) - 1).values))
|
|
return np.maximum(pos, 0) if mode == "lo" else pos
|
|
|
|
|
|
def ma_cross(df, fast, slow, mode="ls"):
|
|
c = pd.Series(df["close"].values.astype(float))
|
|
ef = c.ewm(span=fast, adjust=False).mean()
|
|
es = c.ewm(span=slow, adjust=False).mean()
|
|
pos = np.sign((ef - es).values)
|
|
return np.maximum(pos, 0) if mode == "lo" else pos
|
|
|
|
|
|
def donchian(df, L, mode="ls"):
|
|
h = pd.Series(df["high"].values.astype(float)).rolling(L).max().shift(1).values
|
|
l = pd.Series(df["low"].values.astype(float)).rolling(L).min().shift(1).values
|
|
c = df["close"].values.astype(float)
|
|
pos = np.zeros(len(c)); cur = 0
|
|
for i in range(len(c)):
|
|
if not np.isnan(h[i]) and c[i] > h[i]:
|
|
cur = 1
|
|
elif not np.isnan(l[i]) and c[i] < l[i]:
|
|
cur = -1 if mode == "ls" else 0
|
|
pos[i] = cur
|
|
return pos
|
|
|
|
|
|
def vol_target(df, tf, target=0.6, L=72):
|
|
"""Overlay SEMPRE-LONG con esposizione scalata dalla vol realizzata (target vol annua)."""
|
|
c = pd.Series(df["close"].values.astype(float))
|
|
rv_ann = c.pct_change().rolling(L).std().values * np.sqrt(BARS_PER_YEAR[tf])
|
|
pos = np.clip(np.nan_to_num(target / np.where(rv_ann > 0, rv_ann, np.nan), nan=0.0), 0, 1)
|
|
return pos
|
|
|
|
|
|
def rolling_hurst(c, W=120, step=6, lags=(2, 4, 8, 16, 32)):
|
|
logc = np.log(c); n = len(c); H = np.full(n, np.nan)
|
|
lg = np.log(lags)
|
|
for i in range(W, n, step):
|
|
seg = logc[i - W:i]
|
|
tau = [np.std(seg[lag:] - seg[:-lag]) for lag in lags]
|
|
if min(tau) > 0:
|
|
H[i] = np.polyfit(lg, np.log(tau), 1)[0]
|
|
return pd.Series(H).ffill().fillna(0.5).values
|
|
|
|
|
|
def hurst_mom(df, L=48, W=120, mode="ls"):
|
|
H = rolling_hurst(df["close"].values.astype(float), W)
|
|
return np.where(H > 0.5, tsmom(df, L, mode), 0.0)
|
|
|
|
|
|
def leadlag_df(target_df, other_df, L):
|
|
"""Costruisce un df col close del TARGET e la posizione = segno del rendimento a L barre
|
|
dell'ALTRO asset (allineato per timestamp). Ritorna (df_merged, pos)."""
|
|
a = target_df[["timestamp", "open", "high", "low", "close"]]
|
|
b = other_df[["timestamp", "close"]].rename(columns={"close": "other"})
|
|
m = a.merge(b, on="timestamp", how="inner").reset_index(drop=True)
|
|
o = pd.Series(m["other"].values.astype(float))
|
|
pos = np.sign(np.nan_to_num((o / o.shift(L) - 1).values))
|
|
return m, pos
|
|
|
|
|
|
# --------------------------------- reporting ---------------------------------
|
|
ROWS = []
|
|
|
|
|
|
def summarize(family, asset, tf, df, pos, mc_n=300):
|
|
full = backtest(df, pos, tf)
|
|
oos = backtest(df, pos, tf, lo=VAL_START, hi=HOLDOUT_START)
|
|
bh = buy_hold(df, tf)
|
|
gross = backtest(df, pos, tf, fee_rt=0.0).sharpe
|
|
_, p, _, _ = mc_pvalue(df, pos, tf, n=mc_n)
|
|
beats_bh = full.sharpe > bh.sharpe and oos.sharpe > 0
|
|
real = (full.sharpe > 0 and oos.sharpe > 0 and not np.isnan(p) and p < 0.05)
|
|
verdict = "★EDGE?" if (real and beats_bh) else ("real?" if real else "rumore")
|
|
ROWS.append(dict(fam=family, asset=asset, tf=tf, full=full.sharpe, oos=oos.sharpe,
|
|
gross=gross, bh=bh.sharpe, p=p, trd=full.ntrades, verdict=verdict))
|
|
print(f" {family:<16s} {asset} {tf:<3s} | FULL {full.sharpe:>5.2f} OOS {oos.sharpe:>5.2f} "
|
|
f"gross {gross:>5.2f} | B&H {bh.sharpe:>4.2f} | p {p:>.3f} | trd/y {full.ntrades:>6.0f} | {verdict}")
|
|
|
|
|
|
def scan_best(family, asset, tf, df, fn, grid, label_fn):
|
|
"""Scansiona la griglia (FULL Sharpe), stampa la riga compatta, ritorna la pos migliore."""
|
|
best = None
|
|
line = []
|
|
for params in grid:
|
|
pos = fn(df, *params)
|
|
s = backtest(df, pos, tf).sharpe
|
|
line.append(f"{label_fn(params)}={s:>4.1f}")
|
|
if best is None or s > best[0]:
|
|
best = (s, params, pos)
|
|
print(f" {asset} {tf} grid: " + " ".join(line))
|
|
return best[2], best[1]
|
|
|
|
|
|
def main():
|
|
print("=" * 100)
|
|
print(" FASE 2 — esplorazione famiglie BTC/ETH | netto fee 0.10% RT | barra = buy&hold | hold-out bloccato")
|
|
print("=" * 100)
|
|
D1 = {a: load_data(a, "1h") for a in ("BTC", "ETH")}
|
|
D15 = {a: load_data(a, "15m") for a in ("BTC", "ETH")}
|
|
|
|
def block(title):
|
|
print("\n" + "#" * 100 + f"\n {title}\n" + "#" * 100)
|
|
|
|
# ---- TSMOM (momentum) 1h + 15m, L/S e long-only ----
|
|
block("TSMOM (momentum)")
|
|
Ls = [(12,), (24,), (48,), (96,), (192,)]
|
|
for a in ("BTC", "ETH"):
|
|
pos, p = scan_best("TSMOM-LS", a, "1h", D1[a], lambda d, L: tsmom(d, L, "ls"), Ls, lambda x: f"L{x[0]}")
|
|
summarize("TSMOM-LS", a, "1h", D1[a], pos)
|
|
pos, p = scan_best("TSMOM-LO", a, "1h", D1[a], lambda d, L: tsmom(d, L, "lo"), Ls, lambda x: f"L{x[0]}")
|
|
summarize("TSMOM-LO", a, "1h", D1[a], pos)
|
|
pos, p = scan_best("TSMOM-LS", a, "15m", D15[a], lambda d, L: tsmom(d, L, "ls"), [(48,),(96,),(192,),(384,)], lambda x: f"L{x[0]}")
|
|
summarize("TSMOM-LS", a, "15m", D15[a], pos)
|
|
|
|
# ---- REVERSAL 1h + 15m ----
|
|
block("REVERSAL (mean-reversion breve)")
|
|
Lr = [(1,), (3,), (6,), (12,), (24,)]
|
|
for a in ("BTC", "ETH"):
|
|
pos, p = scan_best("REV-LS", a, "1h", D1[a], lambda d, L: reversal(d, L, "ls"), Lr, lambda x: f"L{x[0]}")
|
|
summarize("REV-LS", a, "1h", D1[a], pos)
|
|
pos, p = scan_best("REV-LS", a, "15m", D15[a], lambda d, L: reversal(d, L, "ls"), Lr, lambda x: f"L{x[0]}")
|
|
summarize("REV-LS", a, "15m", D15[a], pos)
|
|
|
|
# ---- MA cross ----
|
|
block("MA-CROSS (trend)")
|
|
g = [(12, 48), (24, 96), (48, 192), (24, 200)]
|
|
for a in ("BTC", "ETH"):
|
|
pos, p = scan_best("MAX-LS", a, "1h", D1[a], lambda d, f, s: ma_cross(d, f, s, "ls"), g, lambda x: f"{x[0]}/{x[1]}")
|
|
summarize("MAX-LS", a, "1h", D1[a], pos)
|
|
pos, p = scan_best("MAX-LO", a, "1h", D1[a], lambda d, f, s: ma_cross(d, f, s, "lo"), g, lambda x: f"{x[0]}/{x[1]}")
|
|
summarize("MAX-LO", a, "1h", D1[a], pos)
|
|
|
|
# ---- Donchian breakout ----
|
|
block("DONCHIAN breakout")
|
|
Ld = [(24,), (48,), (96,), (192,)]
|
|
for a in ("BTC", "ETH"):
|
|
pos, p = scan_best("DONCH-LS", a, "1h", D1[a], lambda d, L: donchian(d, L, "ls"), Ld, lambda x: f"L{x[0]}")
|
|
summarize("DONCH-LS", a, "1h", D1[a], pos)
|
|
pos, p = scan_best("DONCH-LO", a, "1h", D1[a], lambda d, L: donchian(d, L, "lo"), Ld, lambda x: f"L{x[0]}")
|
|
summarize("DONCH-LO", a, "1h", D1[a], pos)
|
|
|
|
# ---- Vol-target overlay (vs buy&hold) ----
|
|
block("VOL-TARGET overlay (sempre-long scalato) — riduce la vol/DD del buy&hold?")
|
|
for a in ("BTC", "ETH"):
|
|
pos, p = scan_best("VOLTGT", a, "1h", D1[a], lambda d, t: vol_target(d, "1h", t, 72),
|
|
[(0.4,), (0.6,), (0.8,), (1.0,)], lambda x: f"t{x[0]}")
|
|
summarize("VOLTGT", a, "1h", D1[a], pos)
|
|
|
|
# ---- Hurst-gated momentum ----
|
|
block("HURST-gated momentum (momentum solo in regime trending H>0.5)")
|
|
for a in ("BTC", "ETH"):
|
|
pos, p = scan_best("HURST-MOM", a, "1h", D1[a], lambda d, L: hurst_mom(d, L, 120, "ls"),
|
|
[(24,), (48,), (96,)], lambda x: f"L{x[0]}")
|
|
summarize("HURST-MOM", a, "1h", D1[a], pos)
|
|
|
|
# ---- Lead-lag BTC<->ETH ----
|
|
block("LEAD-LAG BTC<->ETH (posiziona un asset col rendimento passato dell'altro)")
|
|
for tgt, oth in (("ETH", "BTC"), ("BTC", "ETH")):
|
|
Ll = [1, 3, 6, 12, 24]
|
|
best = None; line = []
|
|
for L in Ll:
|
|
m, pos = leadlag_df(D1[tgt], D1[oth], L)
|
|
s = backtest(m, pos, "1h").sharpe
|
|
line.append(f"L{L}={s:>4.1f}")
|
|
if best is None or s > best[0]:
|
|
best = (s, L, m, pos)
|
|
print(f" {oth}->{tgt} 1h grid: " + " ".join(line))
|
|
_, L, m, pos = best
|
|
summarize(f"LL {oth}>{tgt}", tgt, "1h", m, pos)
|
|
|
|
# ---- classifica finale ----
|
|
print("\n" + "=" * 100)
|
|
print(" CLASSIFICA — net-fee FULL Sharpe (★EDGE? = batte B&H, OOS>0 e null p<0.05)")
|
|
print("=" * 100)
|
|
for r in sorted(ROWS, key=lambda r: -r["full"]):
|
|
print(f" {r['fam']:<16s} {r['asset']} {r['tf']:<3s} | FULL {r['full']:>5.2f} | OOS {r['oos']:>5.2f} | "
|
|
f"B&H {r['bh']:>4.2f} | p {r['p']:>.3f} | {r['verdict']}")
|
|
edges = [r for r in ROWS if r["verdict"] == "★EDGE?"]
|
|
print(f"\n Candidati che battono il buy&hold net-fee + OOS>0 + null p<0.05: {len(edges)}")
|
|
for r in edges:
|
|
print(f" -> {r['fam']} {r['asset']} {r['tf']}: FULL {r['full']:.2f} OOS {r['oos']:.2f} p {r['p']:.3f}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|