feat(research): XSR01 "Cross-Sectional Residual" — candidato nuovo in forward-monitor
Primo candidato da molte ondate a superare marginale-ADDS, deflated-Sharpe 0.95, tre null avversariali e il test di lag, con corr ~0 a TUTTI e 5 gli sleeve attivi. COS'E'. Il meccanismo congelato di STATARB-RESID (W=45, sgn=+1, residuo OLS causale su BTC, z-score, tanh, vol-target 20%) applicato ai 50 alt HL, con posizioni DEMEANATE cross-sezionalmente ogni giorno. Il demeaning annulla ALGEBRICAMENTE la gamba BTC comune — sum(p_i-p̄)(r_i-r_btc) = sum(p_i-p̄)r_i — quindi non e' un paniere di coppie ma una strategia cross-sectional dollar-neutral, e l'ampiezza effettiva passa da 4.5 a 37.4. NB: non e' nato da un'idea nuova ma dalla DIAGNOSI di un fallimento (l'ampiezza effettiva 4.5 indicava un fattore comune da togliere). NUMERI (2.6 anni): Sharpe netta 1.82 (lorda 2.70), maxDD -2.6%, vol 2.3%. GATE: marginale ADDS (robust_oos + beats_noise_null + non-hedge + has_insample_edge); deflated-Sharpe 0.985 PASS; corr TP01 0.060 / XS01 -0.019 / VRP01 -0.001 / SKH01 0.001 / GTAA01 0.071; book a w=15% HOLD 2.36 -> 2.51, DD invariato. SCETTICO (r0725_statarb_demean_skeptic.py): 3 null A FEE-NEUTRALE (permutazione cross-sezionale / casuale / temporale a blocchi) centrati a ~0.01, candidato lordo 2.70 sopra il MASSIMO di 300 estrazioni (p<0.004); lag 1.82/1.19/0.81/0.51 a +0/1/2/3g = decadimento dolce di segnale lento, non firma di look-ahead; non ridondante con XS-momentum semplice (corr 0.17-0.29). Nota di metodo: i null vanno confrontati A FEE ZERO — permutare un segnale ne fa esplodere il turnover, quindi a fee piene il null perderebbe per COSTO invece che per assenza di informazione (p-value trionfale e falso). NON NEL BOOK, 3 motivi dichiarati: (a) storia 2.6a monoregime e CRESCENTE (Sh 2024 1.03 / 2025 1.98 / 2026 3.11) -> edge recente; (b) weights_tilt_null FALLITO (gate_pass=False a 10% e 15%); (c) margine di costo sottile — 1.82 a 0.05%/gamba, 0.93 a 0.10%, NEGATIVA a 0.20%, turnover 38.5% del lordo/g su 50 alt anche illiquidi con slippage NON modellato (rischio #1). ESEGUIBILITA': ticket/gamba $1.73 a $600 (sotto min-order $5 -> STAT-MODE oggi), $5.76 a $2000, $14.41 a $5000 -> diventa reale a ~$5k, non ai ~$20k di XS01. CABLATO: scripts/live/paper_xsr.py (config CONGELATA, 3 libri MODELED $2000 / REAL $600 / REAL $5000 con min-order per gamba, stato append-only) in cron_daily.sh; gate PRE-REGISTRATO a forward-day 0 (r0725_xsr_deploy_gate.py): decisione 2026-10-23, deploy solo se Sharpe>=1.0 E haircut di eseguibilita' a $5000 <=40% (se sfonda -> RITIRO a prescindere dallo Sharpe). tests/test_paper_xsr.py: 7 casi che bloccano config, dollar-neutralita', causalita' dello step, min-order e la trappola dei timestamp tz-aware (astype int64 -> epoche 1970, gemella di quella dell'ondata 2026-07-01). Book e pesi INVARIATI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""FORWARD-MONITOR — XSR01 (Cross-Sectional Residual, 50 alt Hyperliquid), PAPER.
|
||||
|
||||
NON e' esecuzione reale. E' il monitoraggio forward-only del candidato trovato il 2026-07-25
|
||||
(diario `2026-07-25-mat01-statarb-generalization.md`, sezione XSR01). Stesso trattamento che il
|
||||
progetto da' a ogni lead: config CONGELATA, finestra out-of-sample vera che parte da ora, nessun
|
||||
edge creduto prima.
|
||||
|
||||
COS'E'. Il meccanismo di STATARB-RESID (residuo OLS rolling causale di log(alt) su log(BTC),
|
||||
z-score su W, tanh, vol-target sullo spread) applicato a TUTTI i 50 alt certificati di Hyperliquid,
|
||||
con le posizioni DEMEANATE cross-sezionalmente ogni giorno. Il demeaning annulla algebricamente la
|
||||
gamba BTC comune ( sum_i (p_i - p̄)(r_i - r_btc) = sum_i (p_i - p̄) r_i ) e con essa il fattore
|
||||
che teneva l'ampiezza effettiva del paniere a 4.5: dopo il demeaning e' **37.4**. Non e' quindi un
|
||||
paniere di coppie ma una strategia CROSS-SECTIONAL dollar-neutral sui 50 alt.
|
||||
|
||||
PERCHE' E' IN MONITOR E NON NEL BOOK (i tre motivi, tutti dichiarati):
|
||||
1. **Storia 2.6 anni, un regime solo** (bear alt 2024-2026), e il rendimento e' CRESCENTE nel
|
||||
tempo (Sharpe 2024 1.03 / 2025 1.98 / 2026 3.11): la maggior parte dell'edge e' recente.
|
||||
2. **`weights_tilt_null` NON passa** (gate_pass=False a w=10% e 15%): delta_insample negativo,
|
||||
effetto strutturale della storia corta — ma un gate fallito resta fallito.
|
||||
3. **Margine di costo sottile**: netta 1.82 a 0.05%/gamba, 0.93 a 0.10%, NEGATIVA a 0.20%. Il
|
||||
turnover e' 38.5% del lordo al giorno su 50 alt fra cui parecchi illiquidi (GALA, BLUR, JTO,
|
||||
ORDI, WIF): lo **slippage non e' modellato** ed e' il rischio numero uno. Questo monitor
|
||||
serve soprattutto a misurare quanto costa davvero.
|
||||
|
||||
COSA HA GIA' SUPERATO (r0725_statarb_basket_gate.py + r0725_statarb_demean_skeptic.py):
|
||||
marginale vs TP01 = ADDS (robust_oos, beats_noise_null, non-hedge, has_insample_edge);
|
||||
deflated-Sharpe 0.985 PASS; corr ~0 a TUTTI e 5 gli sleeve attivi (max |0.071|);
|
||||
tre null a fee-neutrale (permutazione cross-sezionale, casuale, permutazione temporale a blocchi)
|
||||
tutti centrati a ~0.01 con il candidato LORDO 2.70 sopra il massimo di 300 estrazioni (p<0.004);
|
||||
decadimento con lag dolce (1.82 / 1.19 / 0.81 / 0.51 a +0/1/2/3g) = firma di segnale lento, NON
|
||||
di look-ahead; non ridondante con un momentum cross-sectional semplice (corr 0.17-0.29).
|
||||
|
||||
CONFIG CONGELATA (ogni ritocco AZZERA la finestra forward):
|
||||
W=45, sgn=+1, vol-target 20%, cap 2x, demean cross-sezionale giornaliero,
|
||||
universo = i 50 alt certificati in data/raw/hl_*_1d.parquet (BTC escluso), fee 0.05%/gamba.
|
||||
Riusa il segnale ESATTO di scripts/research/r0725_statarb_multi.signal (nessuna reimplementazione).
|
||||
|
||||
TRE LIBRI IN PARALLELO (onesta' sull'eseguibilita'):
|
||||
MODELED $2000 : ribilanciamento continuo di ogni gamba (limite superiore teorico).
|
||||
REAL $600 : min-order $5 per gamba. Ticket medio atteso $1.73 -> **quasi nulla si esegue**:
|
||||
a questo capitale la strategia e' STAT-MODE, e il libro lo mostra invece di
|
||||
nasconderlo.
|
||||
REAL $5000 : ticket medio ~$14 -> soglia realistica di eseguibilita'. E' il numero che dice
|
||||
a che capitale questa strategia diventa vera.
|
||||
|
||||
Stato: data/paper_xsr/{state.json, returns.jsonl} (append-only).
|
||||
|
||||
uv run python scripts/live/paper_xsr.py # avanza col dato disponibile
|
||||
uv run python scripts/live/paper_xsr.py --status # solo stato, non avanza
|
||||
uv run python scripts/live/paper_xsr.py --reset # azzera (riparte da ora)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
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))
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
|
||||
|
||||
# Segnale ESATTO dello studio (nessuna reimplementazione -> niente drift col backtest).
|
||||
from r0725_statarb_multi import BASE, MIN_BARS, load_hl, signal, universe # noqa: E402
|
||||
|
||||
STATE_DIR = PROJECT_ROOT / "data" / "paper_xsr"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
RETURNS_FILE = STATE_DIR / "returns.jsonl"
|
||||
|
||||
# --- CONFIG CONGELATA (frozen) -----------------------------------------------------------------
|
||||
FEE_LEG = 0.0005 # 0.05% per gamba sul nozionale mosso
|
||||
MODELED_CAPITAL = 2000.0
|
||||
REAL_BOOKS = ((600.0, "REAL-$600"), (5000.0, "REAL-$5000"))
|
||||
MIN_ORDER = 5.0
|
||||
ANN = np.sqrt(365.0)
|
||||
|
||||
|
||||
def build_panel():
|
||||
"""(ts, dt, W target [n x k], R_alt [n x k], r_btc [n], simboli). Tutto causale."""
|
||||
base_px = load_hl(BASE)
|
||||
pos_cols, ret_cols = {}, {}
|
||||
for sym in universe():
|
||||
try:
|
||||
tgt = load_hl(sym)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
ix = base_px.index.intersection(tgt.index)
|
||||
if len(ix) < MIN_BARS:
|
||||
continue
|
||||
p, _ = signal(base_px[ix], tgt[ix])
|
||||
pos_cols[sym] = pd.Series(p, index=ix)
|
||||
ret_cols[sym] = tgt[ix].pct_change().fillna(0.0)
|
||||
P = pd.concat(pos_cols, axis=1, sort=True).sort_index().fillna(0.0)
|
||||
R = pd.concat(ret_cols, axis=1, sort=True).sort_index().reindex(P.index).fillna(0.0)[P.columns]
|
||||
Q = P.sub(P.mean(axis=1), axis=0) # demean cross-sezionale
|
||||
W = Q / max(P.shape[1], 1) # peso di portafoglio per gamba
|
||||
rb = base_px.pct_change().fillna(0.0).reindex(P.index).fillna(0.0)
|
||||
# epoca ms ESPLICITA: `.astype("int64")`/`.view("int64")` su DatetimeIndex tz-aware non-ns
|
||||
# danno la scala sbagliata in pandas 2.x — trappola gia' pagata dal progetto (CLAUDE.md,
|
||||
# ondata 2026-07-01: merge_asof broadcastato = look-ahead invisibile a causality_ok).
|
||||
ts = np.array([int(t.timestamp() * 1000) for t in P.index], dtype="int64")
|
||||
return ts, P.index, W.to_numpy(float), R.to_numpy(float), rb.to_numpy(float), list(P.columns)
|
||||
|
||||
|
||||
def _state_io(write: dict | None = None):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if write is not None:
|
||||
STATE_FILE.write_text(json.dumps(write, indent=2, default=str))
|
||||
return write
|
||||
return json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None
|
||||
|
||||
|
||||
def _append(path: Path, rec: dict):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def _book(cap: float, k: int) -> dict:
|
||||
return dict(cap=cap, cap0=cap, peak=cap, dd=0.0, w=[0.0] * k, n_skip=0, n_fill=0)
|
||||
|
||||
|
||||
def init_state() -> dict:
|
||||
ts, _, W, _, _, syms = build_panel()
|
||||
return dict(start_ts=int(ts[-1]), last_ts=int(ts[-1]), n_bars=0, syms=syms,
|
||||
modeled=_book(MODELED_CAPITAL, len(syms)),
|
||||
reals={name: _book(c, len(syms)) for c, name in REAL_BOOKS})
|
||||
|
||||
|
||||
def _step(bk: dict, w_target: np.ndarray, r_alt: np.ndarray, r_btc: float,
|
||||
min_order: float | None) -> float:
|
||||
"""Un passo di un libro: rende il netto della barra e aggiorna capitale/peak/DD in place."""
|
||||
w_held = np.asarray(bk["w"], float)
|
||||
ret = float(np.dot(w_held, r_alt) - w_held.sum() * r_btc) # gamba BTC = -somma dei pesi alt
|
||||
if min_order is None:
|
||||
w_new = w_target.copy()
|
||||
else:
|
||||
move = np.abs(w_target - w_held) * bk["cap"] >= min_order
|
||||
w_new = np.where(move, w_target, w_held)
|
||||
bk["n_fill"] += int(move.sum())
|
||||
bk["n_skip"] += int((~move).sum())
|
||||
d_alt = np.abs(w_new - w_held)
|
||||
d_btc = abs(w_new.sum() - w_held.sum())
|
||||
net = ret - FEE_LEG * (float(d_alt.sum()) + d_btc)
|
||||
bk["cap"] *= (1.0 + max(net, -0.99))
|
||||
bk["peak"] = max(bk["peak"], bk["cap"])
|
||||
bk["dd"] = max(bk["dd"], (bk["peak"] - bk["cap"]) / bk["peak"] if bk["peak"] > 0 else 0.0)
|
||||
bk["w"] = w_new.tolist()
|
||||
return net
|
||||
|
||||
|
||||
def advance(st: dict) -> dict:
|
||||
ts, dt, W, R, rb, syms = build_panel()
|
||||
if syms != st["syms"]: # universo cambiato -> non si avanza
|
||||
print(f" [XSR01] universo cambiato ({len(st['syms'])} -> {len(syms)}): "
|
||||
"la finestra forward richiede universo costante. Usa --reset per ripartire.")
|
||||
return st
|
||||
new = [i for i in range(len(ts)) if ts[i] > st["last_ts"]]
|
||||
if not new:
|
||||
return st
|
||||
for i in new:
|
||||
nm = _step(st["modeled"], W[i], R[i], float(rb[i]), None)
|
||||
rec = dict(ts=int(ts[i]), dt=str(pd.Timestamp(dt[i])), net_modeled=round(nm, 6))
|
||||
for _, name in REAL_BOOKS:
|
||||
rec[f"net_{name}"] = round(_step(st["reals"][name], W[i], R[i], float(rb[i]),
|
||||
MIN_ORDER), 6)
|
||||
rec["gross"] = round(float(np.abs(W[i]).sum()), 4)
|
||||
_append(RETURNS_FILE, rec)
|
||||
st["last_ts"] = int(ts[new[-1]])
|
||||
st["n_bars"] = st.get("n_bars", 0) + len(new)
|
||||
return st
|
||||
|
||||
|
||||
def print_status(st: dict) -> None:
|
||||
ts, _, W, _, _, _ = build_panel()
|
||||
days = (int(ts[-1]) - st["start_ts"]) / 86_400_000
|
||||
print("\n XSR01 forward-monitor (PAPER — cross-sectional residual 50 alt HL, NON deploy)")
|
||||
print(" config CONGELATA: W=45 sgn=+1 demean cross-sezionale, vol-target 20%, fee 0.05%/gamba")
|
||||
print(f" forward da {pd.Timestamp(st['start_ts'], unit='ms', tz='UTC').date()} "
|
||||
f"({st['n_bars']} barre 1d ~{days:.0f}g) gambe: {len(st['syms'])}")
|
||||
print(f" lordo corrente {np.abs(W[-1]).sum()*100:.1f}% del capitale "
|
||||
f"(dollar-neutral: netto {W[-1].sum():+.1e})")
|
||||
m = st["modeled"]
|
||||
print(f" MODELED ($2000, ribil. continuo): {(m['cap']/m['cap0']-1)*100:+6.2f}% "
|
||||
f"eq ${m['cap']:.2f} maxDD {m['dd']*100:.1f}%")
|
||||
for _, name in REAL_BOOKS:
|
||||
b = st["reals"][name]
|
||||
tot = b["n_fill"] + b["n_skip"]
|
||||
print(f" {name:<12} (min-order $5) : {(b['cap']/b['cap0']-1)*100:+6.2f}% "
|
||||
f"eq ${b['cap']:.2f} maxDD {b['dd']*100:.1f}% "
|
||||
f"gambe eseguite {100*b['n_fill']/tot if tot else 0:.0f}%")
|
||||
print(" -> il divario MODELED-REAL e' l'haircut di eseguibilita'; a $600 e' atteso GRANDE")
|
||||
print(f" log: {RETURNS_FILE}\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--status", action="store_true")
|
||||
ap.add_argument("--reset", action="store_true")
|
||||
args = ap.parse_args()
|
||||
if args.reset:
|
||||
for p in (STATE_FILE, RETURNS_FILE):
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
st = _state_io(init_state())
|
||||
print("forward-monitor XSR01 inizializzato (forward-only da ora).")
|
||||
print_status(st); return
|
||||
st = _state_io()
|
||||
if st is None:
|
||||
st = _state_io(init_state())
|
||||
print("forward-monitor XSR01 inizializzato (forward-only da ora).")
|
||||
print_status(st); return
|
||||
if not args.status:
|
||||
st = advance(st); _state_io(st)
|
||||
print_status(st)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user