research(sweep): 5 thread paralleli — 0 nuovi sleeve, STATARB-RESID LEAD ortogonale+eseguibile
Ricerca onesta su aree inesplorate (harness altlib+xsec_v2_nonmom, tutti i gate incl. study_family_honest anti-selection-on-holdout). Branch main, nessun impatto live, test 143/143. 1 XSEC low-risk cousins (MAX/idio-vol/Amihud) -> 1 LEAD (IVOL), STAT-MODE, DSR 0.37<0.95 2 XSEC momentum-structure vs XS01 -> tutto REDUNDANT (sostituire XS01 distrugge hold) 3 Meta-allocazione dinamica (4 sleeve) -> pesi fissi vincono (gia quasi risk-parity) 4 Segnali ortogonali ETH/BTC (2 gambe) -> STATARB-RESID + DVOLSPREAD LEAD 5 1-gamba a segnale (MACD/RSI/Supertrend/...) -> 0/12 earns_slot (trend=TP01, MR morta, hedge) LEAD principale STATARB-RESID (mean-rev residuo ETH-b*BTC, OLS rolling, 2 gambe): primo stream INSIEME ortogonale (corr->book 0.027, beta-mkt 0.013) ED eseguibile a $600 (haircut ~0, NON STAT-MODE) -> cadono i 2 muri di XS01/opzioni. Resta solo il muro dell'edge (Sharpe 0.84, DSR 0.929 same-sign <0.95). Causalita+fee verificate dal coordinatore. Forward-monitor, non sleeve. Soffitto direzionale ~1.3 riconfermato. Diario 2026-06-29-strategy-search-5threads.md, CLAUDE.md agg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,604 @@
|
||||
"""orthogonal_signals.py — SIGNAL-BASED, BY-CONSTRUCTION-ORTHOGONAL streams (2026-06-29).
|
||||
|
||||
TESI (richiesta utente). Il book attivo è TP01 (trend) + XS01 (cross-sectional) + VRP01
|
||||
(short-vol) + SKH01 (regime L/S). Tutti hanno beta direzionale o vol crypto. Cerchiamo uno
|
||||
stream a **beta di mercato ~0 e bassa corr al book** ma con un **EDGE DI SEGNALE reale** —
|
||||
NON la "diversification math" di uno stream a Sharpe~0 (il marginal scorer indurito la boccia:
|
||||
serve has_insample_edge, Sharpe standalone PRE-2025 >= 0.5, deflated-Sharpe sull'intera griglia,
|
||||
e selezione della cella IN-SAMPLE, mai sul max hold-out).
|
||||
|
||||
FOCUS PRIMARIO — RELATIVE-VALUE ETH/BTC (dollar-neutral, 2 gambe). La posizione è sul SPREAD
|
||||
long-ETH/short-BTC (o viceversa): r_spread[i] = pos[i-1]*(r_eth[i]-r_btc[i]) - fee*2*|Δpos|.
|
||||
Per costruzione beta_mercato ~0 -> scorrelato a TP01/SKH. VANTAGGIO: un book a 2 gambe su
|
||||
Deribit perp (BTC+ETH entrambi live) è MOLTO più vicino all'eseguibile a $600 di uno a 19 gambe
|
||||
(XS01 è STAT-MODE). Segnali sul ratio log(ETH/BTC), tutti CAUSALI (decisione <= close[i]):
|
||||
1. RATIO-MOM momentum del ratio (trend dello spread).
|
||||
2. RATIO-REV reversal di breve del ratio.
|
||||
3. RATIO-ACCEL accelerazione (2a differenza / curvatura) del ratio.
|
||||
4. VOLSPREAD vol realizzata relativa BTC vs ETH -> verso l'asset col profilo giusto.
|
||||
5. DVOLSPREAD vol IMPLICITA relativa (al.dvol BTC/ETH) -> re-valida l'ex-lead 'dvol_spread'.
|
||||
6. STATARB-RESID residuo di ETH dopo beta*BTC (rolling OLS causale) -> mean-revert il residuo.
|
||||
|
||||
SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT): long/short crypto vs hedge su momentum relativo,
|
||||
merge_asof backward (equity 5gg/sett vs crypto 24/7), niente look-ahead. Probabile debole.
|
||||
|
||||
GATE (tutti obbligatori, replicano altlib indurito):
|
||||
* CAUSALITÀ: prefix-check sul SPREAD (ricostruisci su prefisso, la coda deve combaciare).
|
||||
* NETTO fee 0.10% RT su 2 gambe + SWEEP (0.00-0.30% RT).
|
||||
* SELEZIONE CELLA IN-SAMPLE-ONLY (pre-2025), MAI sul max hold-out (punto cieco filone B).
|
||||
* DEFLATED-SHARPE su TUTTE le celle cercate (multiple-testing).
|
||||
* has_insample_edge: Sharpe standalone PRE-2025 >= 0.5 (no diversification-math).
|
||||
* OOS hold-out 2025+, plateau su griglia, per-anno.
|
||||
* corr vs BOOK 4-sleeve (|corr|<0.2 ideale) + beta vs mercato (50/50 BTC+ETH) ~0.
|
||||
* marginal_vs_tp01 (ADDS / HEDGE / NOISE / NEUTRAL) -> earns_slot_honest.
|
||||
* EXEC $600: haircut a 2 gambe (min $5/gamba, fee 0.10% RT) — il punto FORTE del filone.
|
||||
|
||||
ONESTÀ BRUTALE: bassa-corr da sola NON basta. dvol_spread era forward-monitor (storia DVOL
|
||||
corta + multiple-testing); ratio_accel era lead debole. Se è diversification-math o
|
||||
hold-out-fitting -> NOISE/SCARTATO con numeri.
|
||||
|
||||
Esecuzione: cd /opt/docker/PythagorasGoal && uv run python scripts/research/orthogonal_signals.py
|
||||
Idempotente, solo stdout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import math
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_ROOT / "scripts" / "research" / "alt"))
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
FEE_SIDE = al.FEE_SIDE # 0.0005 = 0.05%/side
|
||||
FEE_SWEEP = (0.0, 0.00025, 0.0005, 0.001, 0.0015) # per-side; ×2 legs ×2 (RT) for RT%
|
||||
HOLDOUT = al.HOLDOUT
|
||||
ANN = 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# JOINT FRAME + DOLLAR-NEUTRAL EVALUATOR (custom — eval_weights is single-asset)
|
||||
# ===========================================================================
|
||||
def build_joint(tf: str = "1d") -> pd.DataFrame:
|
||||
"""BTC/ETH allineati sull'indice comune (inner join su timestamp). Ritorna un frame con
|
||||
r_btc/r_eth (simple), close_*, log_ratio = log(ETH/BTC), datetime, timestamp."""
|
||||
b = al.get("BTC", tf)[["timestamp", "datetime", "close"]].rename(columns={"close": "cb"})
|
||||
e = al.get("ETH", tf)[["timestamp", "close"]].rename(columns={"close": "ce"})
|
||||
j = b.merge(e, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True)
|
||||
j["r_btc"] = al.simple_returns(j["cb"].values)
|
||||
j["r_eth"] = al.simple_returns(j["ce"].values)
|
||||
j["log_ratio"] = np.log(j["ce"].values / j["cb"].values)
|
||||
return j
|
||||
|
||||
|
||||
def spread_ret(j: pd.DataFrame) -> np.ndarray:
|
||||
"""Ritorno dollar-neutral per unità di gross-per-gamba: long $1 ETH, short $1 BTC."""
|
||||
return j["r_eth"].values - j["r_btc"].values
|
||||
|
||||
|
||||
def vol_target_spread(direction: np.ndarray, j: pd.DataFrame, target_vol: float = 0.20,
|
||||
win_days: int = 30, cap: float = 2.0) -> np.ndarray:
|
||||
"""Scala una direzione in [-1,1] a vol-target sullo SPREAD (causale: vol realizzata <= i)."""
|
||||
s = spread_ret(j)
|
||||
bpd = al.bars_per_day(j)
|
||||
bpy = bpd * ANN
|
||||
vol = al.realized_vol(s, max(2, win_days * bpd), bpy)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
|
||||
pos = np.clip(np.nan_to_num(direction) * scal, -cap, cap)
|
||||
pos[~np.isfinite(pos)] = 0.0
|
||||
return pos
|
||||
|
||||
|
||||
def eval_spread(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Backtest ONESTO del SPREAD. pos[i] decisa <= close[i], TENUTA durante la barra i+1
|
||||
(lo shift è qui -> niente leak). Fee su 2 GAMBE: ogni Δpos muove ETH e BTC -> 2×|Δpos|."""
|
||||
pos = np.nan_to_num(np.asarray(pos, float))
|
||||
s = spread_ret(j)
|
||||
held = np.zeros(len(pos)); held[1:] = pos[:-1]
|
||||
gross = held * s
|
||||
turn = np.abs(np.diff(held, prepend=0.0)) # turnover per-gamba
|
||||
net = gross - fee_side * 2.0 * turn # 2 gambe
|
||||
net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
|
||||
return dict(full=full, holdout=hold, yearly=al._yearly(net, idx),
|
||||
tim=round(float(np.mean(held != 0)), 3),
|
||||
turnover=round(float(turn.sum() / (len(turn) / (al.bars_per_day(j) * ANN))), 1),
|
||||
net=net, idx=idx)
|
||||
|
||||
|
||||
def spread_daily(j: pd.DataFrame, pos: np.ndarray, fee_side: float = FEE_SIDE) -> pd.Series:
|
||||
"""Serie NET giornaliera del candidato spread (compounded a 1d se TF sub-daily)."""
|
||||
ev = eval_spread(j, pos, fee_side=fee_side)
|
||||
s = pd.Series(ev["net"], index=ev["idx"])
|
||||
return al._to_daily(s)
|
||||
|
||||
|
||||
def eval_spread_smallcap(j: pd.DataFrame, pos: np.ndarray, capital: float = 600.0,
|
||||
min_order: float = 5.0, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Net REALISTICO a $600 su 2 GAMBE. Un Δpos il cui nozionale PER-GAMBA |Δpos|*capital < $5
|
||||
NON si esegue (held). Le due gambe cambiano dello stesso |Δ| -> il vincolo binding è
|
||||
|Δpos|*capital >= min_order. Riporta Sharpe modellato vs realistico + haircut + n trade."""
|
||||
pos = np.clip(np.nan_to_num(np.asarray(pos, float)), -10, 10)
|
||||
held = np.empty(len(pos)); cur = 0.0; n_tr = 0
|
||||
for i in range(len(pos)):
|
||||
if abs(pos[i] - cur) * capital >= min_order:
|
||||
cur = pos[i]; n_tr += 1
|
||||
held[i] = cur
|
||||
s = spread_ret(j)
|
||||
p = np.zeros(len(held)); p[1:] = held[:-1]
|
||||
turn = np.abs(np.diff(p, prepend=0.0))
|
||||
net = p * s - fee_side * 2.0 * turn; net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(j["datetime"], utc=True))
|
||||
real = al._metrics_from_net(net, idx)
|
||||
modeled = eval_spread(j, pos, fee_side=fee_side)["full"]
|
||||
return dict(realistic=real, modeled=modeled,
|
||||
sharpe_haircut=round(modeled["sharpe"] - real["sharpe"], 3),
|
||||
n_executed_trades=int(n_tr))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# CAUSALITY — prefix-check sul SPREAD (la coda del prefisso deve combaciare col full).
|
||||
# ===========================================================================
|
||||
def causality_spread(make_pos, tf: str = "1d", tail: int = 80, tol: float = 1e-6) -> dict:
|
||||
"""make_pos(j) -> pos sull'intero frame. Ricostruisce su prefissi; la coda deve combaciare."""
|
||||
j = build_joint(tf)
|
||||
full = np.nan_to_num(make_pos(j))
|
||||
n = len(j)
|
||||
worst = 0.0; bad = False; checked = 0
|
||||
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||
if cut <= tail + 5 or cut >= n:
|
||||
continue
|
||||
sub = j.iloc[:cut].reset_index(drop=True)
|
||||
s = np.nan_to_num(make_pos(sub))
|
||||
if len(s) != cut:
|
||||
bad = True; continue
|
||||
d = np.abs(s[cut - tail:cut] - full[cut - tail:cut])
|
||||
worst = max(worst, float(np.max(d)) if len(d) else 0.0)
|
||||
checked += 1
|
||||
return dict(ok=bool((not bad) and worst <= tol), max_tail_diff=round(worst, 10), checked=checked)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SIGNAL FACTORIES — factory(tf, **p) -> make_pos(j) -> vol-targeted position on the spread
|
||||
# (direzione in [-1,1] internamente, poi vol_target_spread). 'sgn' (+/-1) testa il verso.
|
||||
# ===========================================================================
|
||||
def f_ratio_mom(tf="1d", L=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = j["log_ratio"].values
|
||||
mom = lr - pd.Series(lr).shift(L).values # momentum L-barre del ratio
|
||||
z = al.zscore(mom, max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_ratio_rev(tf="1d", L=5, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = j["log_ratio"].values
|
||||
z = al.zscore(lr, L) # deviazione di breve dal trend locale
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_ratio_accel(tf="1d", L=20, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
lr = pd.Series(j["log_ratio"].values)
|
||||
accel = lr - 2 * lr.shift(L) + lr.shift(2 * L) # 2a differenza (curvatura)
|
||||
z = al.zscore(np.nan_to_num(accel.values), max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_volspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
bpd = al.bars_per_day(j); bpy = bpd * ANN
|
||||
vb = al.realized_vol(j["r_btc"].values, max(2, W * bpd), bpy)
|
||||
ve = al.realized_vol(j["r_eth"].values, max(2, W * bpd), bpy)
|
||||
z = al.zscore(np.nan_to_num(vb - ve), max(30, W)) # BTC più volatile di ETH?
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_dvolspread(tf="1d", W=30, sgn=1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
db = al.dvol(j, "BTC"); de = al.dvol(j, "ETH") # vol IMPLICITA causale (merge_asof)
|
||||
sp = np.nan_to_num(db - de)
|
||||
z = al.zscore(sp, max(30, W))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
def f_statarb_resid(tf="1d", W=60, sgn=-1, tv=0.20, vw=30, cap=2.0):
|
||||
def make(j):
|
||||
x = np.log(j["cb"].values); y = np.log(j["ce"].values) # OLS rolling causale y~a+b x
|
||||
sx = pd.Series(x); sy = pd.Series(y)
|
||||
mx = sx.rolling(W, min_periods=W).mean()
|
||||
my = sy.rolling(W, min_periods=W).mean()
|
||||
cov = (sx * sy).rolling(W, min_periods=W).mean() - mx * my
|
||||
var = (sx * sx).rolling(W, min_periods=W).mean() - mx * mx
|
||||
beta = (cov / var.replace(0, np.nan))
|
||||
resid = (sy - (my - beta * mx) - beta * sx).values # resid al tempo i (usa <= i)
|
||||
z = al.zscore(np.nan_to_num(resid), W)
|
||||
d = sgn * np.tanh(np.nan_to_num(z)) # mean-revert il residuo
|
||||
return vol_target_spread(d, j, tv, vw, cap)
|
||||
return make
|
||||
|
||||
|
||||
FAMILIES = {
|
||||
"RATIO-MOM": (f_ratio_mom, [dict(L=L, sgn=s) for L in (15, 30, 45, 60, 90) for s in (1, -1)], ("1d",)),
|
||||
"RATIO-REV": (f_ratio_rev, [dict(L=L, sgn=s) for L in (3, 5, 8, 12, 20) for s in (1, -1)], ("1d",)),
|
||||
"RATIO-ACCEL": (f_ratio_accel, [dict(L=L, sgn=s) for L in (10, 20, 30, 45) for s in (1, -1)], ("1d",)),
|
||||
"VOLSPREAD": (f_volspread, [dict(W=W, sgn=s) for W in (10, 20, 30, 60) for s in (1, -1)], ("1d",)),
|
||||
"DVOLSPREAD": (f_dvolspread, [dict(W=W, sgn=s) for W in (15, 30, 45, 60) for s in (1, -1)], ("1d",)),
|
||||
"STATARB-RESID": (f_statarb_resid, [dict(W=W, sgn=s) for W in (30, 45, 60, 90, 120) for s in (1, -1)], ("1d",)),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# BOOK + MARKET references (for corr / beta)
|
||||
# ===========================================================================
|
||||
def book_daily() -> pd.Series:
|
||||
"""Serie NET giornaliera del BOOK attivo a 4 sleeve (TP01+XS01+VRP01+SKH01)."""
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
from src.portfolio.portfolio import StrategyPortfolio
|
||||
pf = StrategyPortfolio(active_sleeves()); pf.backtest()
|
||||
s = pf.combined_daily()
|
||||
if not isinstance(s.index, pd.DatetimeIndex):
|
||||
s.index = pd.to_datetime(s.index, utc=True)
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return s.dropna()
|
||||
|
||||
|
||||
def market_daily() -> pd.Series:
|
||||
"""Mercato di riferimento: 50/50 BTC+ETH ritorni semplici giornalieri (per beta ~0)."""
|
||||
series = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = al.get(a, "1d")
|
||||
series[a] = pd.Series(al.simple_returns(df["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return (0.5 * J["BTC"] + 0.5 * J["ETH"]).dropna()
|
||||
|
||||
|
||||
def beta_to(cand: pd.Series, ref: pd.Series) -> tuple[float, float]:
|
||||
J = pd.concat({"c": cand, "r": ref}, axis=1, join="inner").dropna()
|
||||
if len(J) < 30 or J["r"].var() == 0:
|
||||
return float("nan"), float("nan")
|
||||
c, r = J["c"].values, J["r"].values
|
||||
beta = float(np.cov(c, r)[0, 1] / np.var(r))
|
||||
return round(beta, 3), round(float(J["c"].corr(J["r"])), 3)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# HONEST FAMILY STUDY for the SPREAD (replica study_family_honest a mano):
|
||||
# (1) cella scelta su Sharpe IN-SAMPLE (pre-HOLDOUT), MAI sul max hold-out;
|
||||
# (2) deflated-Sharpe su TUTTE le celle; (3) marginal_vs_tp01 sulla cella scelta.
|
||||
# ===========================================================================
|
||||
def _sh(s) -> float:
|
||||
return al._sh(s)
|
||||
|
||||
|
||||
def study_spread_family(name, factory, grid, tfs, refs, fee_side=FEE_SIDE, dsr_min=0.95) -> dict:
|
||||
rows = []
|
||||
for tf in tfs:
|
||||
for p in grid:
|
||||
try:
|
||||
j = build_joint(tf)
|
||||
pos = factory(tf=tf, **p)(j)
|
||||
daily = spread_daily(j, pos, fee_side=fee_side)
|
||||
except Exception as ex: # pragma: no cover
|
||||
rows.append(dict(tf=tf, params=p, err=str(ex)[:60])); continue
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = _sh(ins) if len(ins) > 60 else float("nan")
|
||||
rows.append(dict(tf=tf, params=p, daily=daily, insample_sharpe=round(is_sh, 3),
|
||||
full_sharpe=round(_sh(daily), 3)))
|
||||
valid = [r for r in rows if r.get("insample_sharpe") is not None
|
||||
and np.isfinite(r.get("insample_sharpe", np.nan))]
|
||||
if not valid:
|
||||
return dict(name=name, chosen=None, earns_slot_honest=False, reason="no valid in-sample cell")
|
||||
chosen = max(valid, key=lambda r: r["insample_sharpe"])
|
||||
all_full = [r["full_sharpe"] for r in rows if r.get("full_sharpe") is not None]
|
||||
|
||||
j = build_joint(chosen["tf"])
|
||||
pos = factory(tf=chosen["tf"], **chosen["params"])(j)
|
||||
daily = chosen["daily"]
|
||||
ev = eval_spread(j, pos, fee_side=fee_side)
|
||||
|
||||
# fee sweep (RT% = per-side ×2 legs? No: RT per leg = 2×side; on 2 legs the cost already
|
||||
# ×2 in eval_spread. Report per-side grid -> "X%RT/leg".)
|
||||
sweep = {}
|
||||
for f in FEE_SWEEP:
|
||||
sweep[f"{2*f*100:.2f}%RT/leg"] = round(eval_spread(j, pos, fee_side=f)["full"]["sharpe"], 3)
|
||||
fee_survives = sweep.get(f"{2*0.0015*100:.2f}%RT/leg", -9) > 0
|
||||
|
||||
# marginal vs TP01
|
||||
marg = al.marginal_vs_tp01(daily)
|
||||
# deflated Sharpe over the WHOLE grid (come da istruzione)
|
||||
dsr, sr0 = al.deflated_sharpe(_sh(daily), all_full, daily)
|
||||
dsr_pass = bool(np.isfinite(dsr) and dsr >= dsr_min)
|
||||
# SENSIBILITÀ: la griglia include sgn=+1 E sgn=-1 (specchi: +Sh e -Sh) -> raddoppia la
|
||||
# dispersione dei trial e gonfia il null-max. DSR "same-sign" = deflazione sul SOLO verso
|
||||
# scelto (conta i soli lookback davvero in competizione). È il limite ottimistico onesto.
|
||||
same = [r["full_sharpe"] for r in rows
|
||||
if r.get("full_sharpe") is not None and r["params"].get("sgn") == chosen["params"].get("sgn")]
|
||||
dsr_ss, sr0_ss = al.deflated_sharpe(_sh(daily), same if len(same) >= 2 else all_full, daily)
|
||||
dsr_ss_pass = bool(np.isfinite(dsr_ss) and dsr_ss >= dsr_min)
|
||||
|
||||
# corr/beta vs BOOK & MARKET
|
||||
bbeta, bcorr = beta_to(daily, refs["book"])
|
||||
mbeta, mcorr = beta_to(daily, refs["market"])
|
||||
btcbeta, btccorr = beta_to(daily, refs["btc"])
|
||||
ethbeta, ethcorr = beta_to(daily, refs["eth"])
|
||||
|
||||
# $600 executability (2 legs)
|
||||
sc = eval_spread_smallcap(j, pos)
|
||||
|
||||
earns = bool(marg.get("marginal_verdict") == "ADDS" and marg.get("robust_oos", False)
|
||||
and marg.get("has_insample_edge", False) and not marg.get("is_hedge", False)
|
||||
and dsr_pass and fee_survives)
|
||||
|
||||
return dict(name=name, n_cells=len(all_full), chosen=chosen, rows=valid,
|
||||
ev=ev, sweep=sweep, fee_survives=fee_survives, marginal=marg,
|
||||
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,
|
||||
dsr_samesign=round(dsr_ss, 3) if np.isfinite(dsr_ss) else None,
|
||||
expected_null_max_ss=round(sr0_ss, 3) if np.isfinite(sr0_ss) else None,
|
||||
dsr_ss_pass=dsr_ss_pass, n_cells_samesign=len(same),
|
||||
book=dict(beta=bbeta, corr=bcorr), market=dict(beta=mbeta, corr=mcorr),
|
||||
btc=dict(beta=btcbeta, corr=btccorr), eth=dict(beta=ethbeta, corr=ethcorr),
|
||||
smallcap=sc, earns_slot_honest=earns)
|
||||
|
||||
|
||||
def verdict_for(rep: dict) -> tuple[str, str]:
|
||||
if rep.get("chosen") is None:
|
||||
return "SCARTATO", "nessuna cella valida"
|
||||
m = rep["marginal"]
|
||||
is_edge = m.get("has_insample_edge"); is_sh = m.get("cand_insample_sharpe")
|
||||
dsr = rep.get("deflated_sharpe"); mv = m.get("marginal_verdict")
|
||||
ss = rep.get("dsr_samesign"); ss_tag = f"same-sign {ss} {'pass' if rep.get('dsr_ss_pass') else 'fail'}"
|
||||
if rep["earns_slot_honest"]:
|
||||
return "SLEEVE-CANDIDATE-eseguibile", "ADDS + robust_oos + edge in-sample + DSR pass + fee/exec ok"
|
||||
if mv == "DILUTES":
|
||||
return "SCARTATO", "DILUTES (abbassa il blend del book)"
|
||||
if m.get("is_hedge"):
|
||||
return "SCARTATO", "HEDGE (paga solo quando TP01 è debole, non alpha)"
|
||||
if not is_edge:
|
||||
return "NOISE", f"no edge in-sample (Sharpe<2025 {is_sh} < 0.5) -> diversification-math"
|
||||
# ha edge in-sample reale: il discriminante è se MIGLIORA il book (marginal ADDS)
|
||||
if mv == "ADDS":
|
||||
if not rep.get("dsr_pass"):
|
||||
return "LEAD-forward-monitor", (f"ADDS + edge in-sample {is_sh} + executable, MA deflated-Sharpe "
|
||||
f"full-grid {dsr} < 0.95 ({ss_tag}) -> multiple-testing")
|
||||
if not m.get("robust_oos"):
|
||||
return "LEAD-forward-monitor", "ADDS + edge ma non robust_oos (single-window / multi-cut debole)"
|
||||
return "LEAD-forward-monitor", "ADDS ma blocco fee/exec"
|
||||
# edge standalone reale ma marginal NEUTRAL/REDUNDANT: NON migliora il book -> niente slot
|
||||
return "NEUTRAL-standalone", (f"edge in-sample {is_sh} reale ma marginal={mv}: non migliora il book "
|
||||
f"(corr~0 senza uplift = diversification-math; hold-out debole)")
|
||||
|
||||
|
||||
def print_family(rep: dict):
|
||||
print("=" * 100)
|
||||
print(f"### {rep['name']}")
|
||||
if rep.get("chosen") is None:
|
||||
print(" SCARTATO:", rep.get("reason")); return
|
||||
ch = rep["chosen"]; ev = rep["ev"]; m = rep["marginal"]
|
||||
print(f" best cell (IN-SAMPLE pick): tf={ch['tf']} params={ch['params']} "
|
||||
f"[cercate {rep['n_cells']} celle]")
|
||||
print(f" in-sample Sharpe (pre-2025) {ch['insample_sharpe']} | FULL Sharpe {ev['full']['sharpe']} "
|
||||
f"DD {ev['full']['maxdd']*100:.1f}% ret {ev['full']['ret']*100:+.0f}% | "
|
||||
f"HOLD Sharpe {ev['holdout'].get('sharpe')} ret {ev['holdout'].get('ret',0)*100:+.0f}%")
|
||||
print(f" time-in-mkt {ev['tim']} turnover/yr {ev['turnover']}")
|
||||
print(f" per-anno: " + " ".join(f"{y}:{d['ret']*100:+.0f}%(dd{d['dd']*100:.0f})"
|
||||
for y, d in ev["yearly"].items()))
|
||||
print(f" fee sweep (per-leg RT): {rep['sweep']} fee_survives={rep['fee_survives']}")
|
||||
print(f" corr vs BOOK {rep['book']['corr']} (beta {rep['book']['beta']}) | "
|
||||
f"beta vs MERCATO(50/50) {rep['market']['beta']} (corr {rep['market']['corr']})")
|
||||
print(f" corr/beta vs BTC {rep['btc']['corr']}/{rep['btc']['beta']} "
|
||||
f"vs ETH {rep['eth']['corr']}/{rep['eth']['beta']}")
|
||||
print(f" MARGINAL vs TP01: verdict={m.get('marginal_verdict')} corr_full={m.get('corr_full')} "
|
||||
f"corr_hold={m.get('corr_hold')}")
|
||||
print(f" has_insample_edge={m.get('has_insample_edge')} (standalone Sh<2025 {m.get('cand_insample_sharpe')}) "
|
||||
f"robust_oos={m.get('robust_oos')} multicut={m.get('multicut_persistent')} {m.get('multicut_uplift')}")
|
||||
print(f" blend w25: full {m['blends']['w25']['full']} (uplift {m['blends']['w25']['uplift_full']:+.3f}) "
|
||||
f"hold {m['blends']['w25']['hold']} (uplift {m['blends']['w25']['uplift_hold']}) is_hedge={m.get('is_hedge')}")
|
||||
print(f" DEFLATED-Sharpe full-grid {rep['deflated_sharpe']} (null-max {rep['expected_null_max']}, "
|
||||
f"{rep['n_cells']} celle) pass={rep['dsr_pass']} | same-sign {rep['dsr_samesign']} "
|
||||
f"(null-max {rep['expected_null_max_ss']}, {rep['n_cells_samesign']} celle) pass={rep['dsr_ss_pass']}")
|
||||
sc = rep["smallcap"]
|
||||
print(f" EXEC $600 (2 gambe): modeled Sh {sc['modeled']['sharpe']} -> realistic {sc['realistic']['sharpe']} "
|
||||
f"(haircut {sc['sharpe_haircut']}) trade eseguiti {sc['n_executed_trades']}")
|
||||
v, why = verdict_for(rep)
|
||||
print(f" >>> VERDETTO: {v} — {why}")
|
||||
print(f" earns_slot_honest = {rep['earns_slot_honest']}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SECONDARIO — CRYPTO vs MACRO (GLD/QQQ/TLT) momentum relativo, merge_asof backward.
|
||||
# ===========================================================================
|
||||
def _eq_daily(sym: str) -> pd.Series:
|
||||
p = _ROOT / "data" / "raw" / f"eq_{sym}_1d.parquet"
|
||||
d = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
|
||||
idx = pd.DatetimeIndex(pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
return pd.Series(d["close"].values, index=idx)
|
||||
|
||||
|
||||
def study_macro_rv(hedge: str, L: int = 60, sgn: int = 1, fee_side: float = FEE_SIDE,
|
||||
refs: dict | None = None) -> dict:
|
||||
"""Long crypto (50/50 BTC+ETH) / short hedge ETF su momentum relativo L-giorni.
|
||||
Allineamento: calendario dell'EQUITY (trading days); crypto allineata backward (no look-ahead).
|
||||
Decisione <= close[i] del giorno di trading."""
|
||||
eqc = _eq_daily(hedge)
|
||||
# crypto close 50/50 (geometric proxy: media dei log-prezzi normalizzati)
|
||||
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
|
||||
cbi = pd.Series(cb["close"].values, index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
|
||||
cei = pd.Series(ce["close"].values, index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
|
||||
cj = pd.concat({"b": cbi, "e": cei}, axis=1, join="inner").dropna()
|
||||
crypto = np.exp(0.5 * np.log(cj["b"]) + 0.5 * np.log(cj["e"])) # crypto index level
|
||||
# merge_asof backward: per ogni giorno equity, l'ultimo close crypto <= quel giorno
|
||||
L_ = pd.DataFrame({"ts": eqc.index, "eq": eqc.values}).sort_values("ts")
|
||||
R_ = pd.DataFrame({"ts": crypto.index, "cr": crypto.values}).sort_values("ts")
|
||||
mg = pd.merge_asof(L_, R_, on="ts", direction="backward").dropna()
|
||||
eqv = mg["eq"].values; crv = mg["cr"].values; idx = pd.DatetimeIndex(mg["ts"])
|
||||
r_eq = al.simple_returns(eqv); r_cr = al.simple_returns(crv)
|
||||
s = r_cr - r_eq # long crypto / short hedge
|
||||
lr = np.log(crv) - np.log(eqv) # log relative level
|
||||
mom = lr - pd.Series(lr).shift(L).values
|
||||
z = al.zscore(np.nan_to_num(mom), max(20, L))
|
||||
d = sgn * np.tanh(np.nan_to_num(z))
|
||||
# vol target on the macro spread
|
||||
vol = al.realized_vol(s, 30, ANN)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), 0.20 / vol, 0.0)
|
||||
pos = np.clip(np.nan_to_num(d) * scal, -2.0, 2.0)
|
||||
held = np.zeros(len(pos)); held[1:] = pos[:-1]
|
||||
net = held * s - fee_side * 2.0 * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
|
||||
full = al._metrics_from_net(net, idx)
|
||||
hmask = idx >= HOLDOUT
|
||||
hold = al._metrics_from_net(net[hmask], idx[hmask]) if hmask.sum() > 3 else dict(sharpe=0.0, n=0)
|
||||
daily = al._to_daily(pd.Series(net, index=idx))
|
||||
ins = daily[daily.index < HOLDOUT]
|
||||
is_sh = round(_sh(ins), 3) if len(ins) > 60 else None
|
||||
bcorr = beta_to(daily, refs["book"])[1] if refs else None
|
||||
mbeta, mcorr = beta_to(daily, refs["market"]) if refs else (None, None)
|
||||
btcb = beta_to(daily, refs["btc"])[0] if refs else None
|
||||
return dict(hedge=hedge, L=L, sgn=sgn, full=full, holdout=hold,
|
||||
insample_sharpe=is_sh, book_corr=bcorr, mkt_beta=mbeta, mkt_corr=mcorr,
|
||||
btc_beta=btcb, yearly=al._yearly(net, idx), daily=daily)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# MAIN
|
||||
# ===========================================================================
|
||||
def main():
|
||||
print("#" * 100)
|
||||
print("# ORTHOGONAL SIGNALS — RELATIVE-VALUE ETH/BTC (dollar-neutral) + CRYPTO-vs-MACRO")
|
||||
print("#" * 100)
|
||||
j = build_joint("1d")
|
||||
print(f"Joint BTC/ETH 1d: {len(j)} barre {j['datetime'].min().date()} -> {j['datetime'].max().date()} "
|
||||
f"(hold-out {HOLDOUT.date()}+)")
|
||||
s = spread_ret(j)
|
||||
print(f"Spread r_eth-r_btc: vol annua {np.std(s)*math.sqrt(ANN)*100:.0f}% "
|
||||
f"corr(r_eth,r_btc)={np.corrcoef(j['r_eth'].values[1:], j['r_btc'].values[1:])[0,1]:.2f}")
|
||||
|
||||
# references
|
||||
print("\nCarico riferimenti BOOK / MERCATO ...")
|
||||
refs = dict(book=book_daily(), market=market_daily())
|
||||
cb = al.get("BTC", "1d"); ce = al.get("ETH", "1d")
|
||||
refs["btc"] = pd.Series(al.simple_returns(cb["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(cb["datetime"], utc=True)))
|
||||
refs["eth"] = pd.Series(al.simple_returns(ce["close"].values),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(ce["datetime"], utc=True)))
|
||||
print(f" BOOK 4-sleeve: {len(refs['book'])} giorni, Sharpe {_sh(refs['book']):.2f}")
|
||||
|
||||
# causality smoke-test on one cell per family
|
||||
print("\n--- CAUSALITÀ (prefix-check sullo SPREAD, tol 1e-6) ---")
|
||||
for name, (fac, grid, tfs) in FAMILIES.items():
|
||||
p0 = grid[0]
|
||||
ck = causality_spread(fac(tf=tfs[0], **p0), tf=tfs[0])
|
||||
print(f" {name:14s} ok={ck['ok']} max_tail_diff={ck['max_tail_diff']} checked={ck['checked']}")
|
||||
|
||||
# study each family
|
||||
print("\n" + "#" * 100)
|
||||
print("# RELATIVE-VALUE ETH/BTC — selezione cella IN-SAMPLE, deflated-Sharpe su tutta la griglia")
|
||||
print("#" * 100)
|
||||
summary = []
|
||||
for name, (fac, grid, tfs) in FAMILIES.items():
|
||||
rep = study_spread_family(name, fac, grid, tfs, refs)
|
||||
print_family(rep)
|
||||
v, why = verdict_for(rep)
|
||||
ch = rep.get("chosen")
|
||||
summary.append(dict(name=name, verdict=v,
|
||||
cell=(ch["params"] if ch else None),
|
||||
full=rep["ev"]["full"]["sharpe"] if ch else None,
|
||||
hold=rep["ev"]["holdout"].get("sharpe") if ch else None,
|
||||
is_sh=rep["marginal"].get("cand_insample_sharpe") if ch else None,
|
||||
book_corr=rep["book"]["corr"] if ch else None,
|
||||
mkt_beta=rep["market"]["beta"] if ch else None,
|
||||
dsr=rep.get("deflated_sharpe") if ch else None,
|
||||
marginal=rep["marginal"].get("marginal_verdict") if ch else None,
|
||||
earns=rep.get("earns_slot_honest") if ch else False,
|
||||
haircut=rep["smallcap"]["sharpe_haircut"] if ch else None))
|
||||
|
||||
# secondary: crypto vs macro
|
||||
print("\n" + "#" * 100)
|
||||
print("# SECONDARIO — CRYPTO vs MACRO (relative momentum, merge_asof backward)")
|
||||
print("#" * 100)
|
||||
macro_rows = []
|
||||
for hedge in ("gld", "qqq", "tlt"):
|
||||
best = None
|
||||
for L in (30, 60, 90):
|
||||
for sgn in (1, -1):
|
||||
r = study_macro_rv(hedge, L=L, sgn=sgn, refs=refs)
|
||||
if best is None or (r["insample_sharpe"] or -9) > (best["insample_sharpe"] or -9):
|
||||
best = r
|
||||
macro_rows.append(best)
|
||||
print(f" {hedge.upper():4s} bestIS L={best['L']} sgn={best['sgn']}: "
|
||||
f"FULL Sh {best['full']['sharpe']} DD {best['full']['maxdd']*100:.0f}% "
|
||||
f"HOLD Sh {best['holdout'].get('sharpe')} in-sample Sh {best['insample_sharpe']} "
|
||||
f"corr->BOOK {best['book_corr']} beta->MERCATO {best['mkt_beta']} (corr {best['mkt_corr']}) "
|
||||
f"beta->BTC {best['btc_beta']}")
|
||||
print(" NOTA: la vol crypto (~47%) domina GLD/TLT/QQQ (~15%) -> r_cr-r_hedge ≈ r_cr: la 'relative"
|
||||
" momentum' è di fatto MOMENTUM CRYPTO (la gamba hedge è troppo poco volatile per neutralizzare).")
|
||||
print(" Beta-mercato ~0 solo perché il momentum entra/esce dall'esposizione; ma la corr->BOOK"
|
||||
" 0.17-0.20 (vs ~0.02 degli spread ETH/BTC) tradisce l'overlap col trend di TP01 -> NON ortogonale.")
|
||||
# marginal gate onesto sul migliore macro (per smentire il numero di Sharpe tentatore)
|
||||
bm = max(macro_rows, key=lambda r: (r["insample_sharpe"] or -9))
|
||||
mg = al.marginal_vs_tp01(bm["daily"])
|
||||
print(f" MARGINAL gate sul migliore ({bm['hedge'].upper()}): verdict={mg.get('marginal_verdict')} "
|
||||
f"corr_full={mg.get('corr_full')} blend-uplift w25 full {mg['blends']['w25']['uplift_full']:+.3f} "
|
||||
f"hold {mg['blends']['w25']['uplift_hold']} robust_oos={mg.get('robust_oos')} "
|
||||
f"-> {'overlap col trend (no slot)' if mg.get('marginal_verdict') in ('NEUTRAL','REDUNDANT','DILUTES') else 'da gate completo'}")
|
||||
|
||||
# final table
|
||||
print("\n" + "#" * 100)
|
||||
print("# SINTESI")
|
||||
print("#" * 100)
|
||||
print(f"{'segnale':14s} {'verdetto':28s} {'FULL':>5s} {'HOLD':>5s} {'IS-Sh':>5s} "
|
||||
f"{'corrBK':>6s} {'mβ':>5s} {'DSR':>5s} {'marg':>9s} {'cut':>6s} earns")
|
||||
for r in summary:
|
||||
print(f"{r['name']:14s} {r['verdict']:28s} "
|
||||
f"{str(r['full']):>5s} {str(r['hold']):>5s} {str(r['is_sh']):>5s} "
|
||||
f"{str(r['book_corr']):>6s} {str(r['mkt_beta']):>5s} {str(r['dsr']):>5s} "
|
||||
f"{str(r['marginal']):>9s} {str(r['haircut']):>6s} {r['earns']}")
|
||||
print("\nMACRO (secondario): " + " | ".join(
|
||||
f"{m['hedge'].upper()} IS={m['insample_sharpe']} HOLD={m['holdout'].get('sharpe')} bkcorr={m['book_corr']}"
|
||||
for m in macro_rows))
|
||||
|
||||
winners = [r for r in summary if r["earns"]]
|
||||
leads = [r for r in summary if r["verdict"].startswith("LEAD")]
|
||||
standalone = [r for r in summary if r["verdict"].startswith("NEUTRAL")]
|
||||
print("\n" + "=" * 100)
|
||||
print("CONCLUSIONE — c'è uno stream scorrelato CON edge reale ED eseguibile a 2 gambe?")
|
||||
print("=" * 100)
|
||||
if winners:
|
||||
print("SI -> SLEEVE-CANDIDATE eseguibile a 2 gambe:", ", ".join(r["name"] for r in winners))
|
||||
else:
|
||||
print("NO sleeve pronto. L'ORTOGONALITÀ c'è (corr->BOOK ~0.02, beta-mercato ~0.01: il filone")
|
||||
print("relative-value ETH/BTC è scorrelato per costruzione, ed è ESEGUIBILE a $600 — haircut ~0,")
|
||||
print("fee-surviving a 0.30%RT/gamba). Ma l'EDGE non passa il deflated-Sharpe:")
|
||||
if leads:
|
||||
print(" LEAD (forward-monitor, ADDS + edge in-sample ma DSR<0.95):",
|
||||
", ".join(r["name"] for r in leads))
|
||||
if standalone:
|
||||
print(" NEUTRAL-standalone (edge reale, NON migliora il book):",
|
||||
", ".join(r["name"] for r in standalone))
|
||||
print("Dettaglio nei verdetti per-segnale sopra.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user