976 lines
46 KiB
Python
976 lines
46 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
r0822_basis_calendar.py — filone BASIS-CALENDAR (ondata 2026-08-22)
|
|
|
|
DOMANDA
|
|
Il progetto ha chiuso il FUNDING su tre lati (CC01 carry spot-perp, time-series,
|
|
cross-sectional) ma non ha mai guardato la STRUTTURA A TERMINE dei futures datati
|
|
di Deribit — l'unico strumento delta-neutro eseguibile sul NOSTRO venue, a 2 gambe.
|
|
Q1 Il basis (datato vs indice/perp) e la sua pendenza sono PREVEDIBILI, cioe'
|
|
esiste un roll-down incassabile, o e' un martingala rumoroso?
|
|
Q2 Un CALENDAR SPREAD (front vs back) su segnale di pendenza produce un edge
|
|
netto di fee e spread bid-ask REALI?
|
|
Q3 E' ortogonale al libro ed eseguibile a $600-3.000?
|
|
|
|
IL DATO — costruito qui, non esisteva
|
|
`data/raw/fut_*` sono futures INDICE AZIONARIO da IB (ES/NQ/DAX...), niente a che
|
|
vedere. Su Deribit non c'era nulla. Ma:
|
|
* /public/get_instruments?expired=true ritorna UN SOLO strumento (Deribit purga
|
|
subito le liste), quindi la via ovvia non funziona;
|
|
* i nomi dei trimestrali sono DETERMINISTICI (ultimo venerdi' di MAR/GIU/SET/DIC,
|
|
08:00 UTC) e `get_tradingview_chart_data` serve la storia COMPLETA di un
|
|
contratto SCADUTO se il nome lo si costruisce a mano.
|
|
Verificato: 30/30 trimestrali 2019Q1..2026Q2 rispondono `status=ok`.
|
|
=> **7+ anni di storia**, che includono il deleveraging 2022. E' esattamente la coda
|
|
che a CC01 mancava per costruzione (funding HL dal 2023-05): qui il 2022 c'e'.
|
|
|
|
La chiamata funding porta in regalo l'INDICE Deribit orario (`index_price`), quindi
|
|
spot e funding arrivano insieme e il basis si misura sull'indice vero, non su una proxy.
|
|
|
|
DISCIPLINA DI RETE (la VPS ha un IP solo e ci gira il libro)
|
|
<=2 richieste/s, e il fetcher DORME nella finestra :25-:29 di ogni ora, che e'
|
|
`cron_chain` (il collettore della catena opzioni). Il progetto ha gia' pagato un'ora
|
|
di guasto per un IP saturato. Tutto e' in cache su scratchpad: un ri-run non tocca la rete.
|
|
|
|
CONVENZIONE DI ROLL — dichiarata, e mai una serie incollata
|
|
I prezzi NON vengono mai splicciati. La posizione e' su una COPPIA DI CONTRATTI
|
|
specifica; al roll si chiude e si riapre pagando il round-trip pieno su entrambe le
|
|
gambe. L'incollaggio avviene solo nella serie dei RENDIMENTI, che e' l'unico posto in
|
|
cui e' lecito: e' l'errore che il progetto ha gia' commesso con le serie equity
|
|
ri-aggiustate, e qui e' strutturalmente impossibile.
|
|
|
|
USO
|
|
nice -n 19 timeout 900 uv run python scripts/research/r0822_basis_calendar.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
import datetime as dt
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
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" / "alt"))
|
|
|
|
import altlib as A # noqa: E402
|
|
|
|
SCRATCH = Path(
|
|
os.environ.get(
|
|
"R0822_SCRATCH",
|
|
"/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
|
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad",
|
|
)
|
|
)
|
|
CACHE = SCRATCH / "basis_cache"
|
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
|
|
API = "https://www.deribit.com/api/v2"
|
|
UTC = dt.timezone.utc
|
|
MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
|
|
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
|
|
|
|
ASSETS = ("BTC", "ETH")
|
|
FEE_SIDE = 0.00035 # 3,5 bps/lato per GAMBA (taker Deribit, letto dal venue)
|
|
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
|
HOURS_Y = 365.25 * 24
|
|
|
|
_last_call = [0.0]
|
|
|
|
|
|
# ==========================================================================
|
|
# 0. FETCHER — rate-limited, cache-first, consapevole dei cron della VPS
|
|
# ==========================================================================
|
|
def _polite_sleep() -> None:
|
|
"""<=2 req/s E fuori dalla finestra :25-:29 (cron_chain, stesso IP)."""
|
|
now = dt.datetime.now(UTC)
|
|
if 25 <= now.minute <= 29:
|
|
wait = (30 - now.minute) * 60 - now.second
|
|
print(f" [rete] finestra cron_chain (:25-:29) -> dormo {wait}s")
|
|
time.sleep(max(1, wait))
|
|
gap = time.time() - _last_call[0]
|
|
if gap < 0.55:
|
|
time.sleep(0.55 - gap)
|
|
_last_call[0] = time.time()
|
|
|
|
|
|
def _api(path: str, **kw) -> dict:
|
|
url = API + path + "?" + urllib.parse.urlencode(kw)
|
|
for attempt in range(4):
|
|
_polite_sleep()
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=30) as r:
|
|
return json.load(r)
|
|
except Exception as exc: # noqa: BLE001
|
|
if attempt == 3:
|
|
raise
|
|
print(f" [rete] {type(exc).__name__} -> retry {attempt+1}/3")
|
|
time.sleep(2.0 * (attempt + 1))
|
|
raise RuntimeError("unreachable")
|
|
|
|
|
|
def last_friday(year: int, month: int) -> dt.date:
|
|
d = dt.date(year, month, calendar.monthrange(year, month)[1])
|
|
while d.weekday() != 4:
|
|
d -= dt.timedelta(days=1)
|
|
return d
|
|
|
|
|
|
def quarterly_universe(asset: str, y0: int = 2019, y1: int = 2027) -> list[tuple[str, pd.Timestamp]]:
|
|
"""Nomi DETERMINISTICI dei trimestrali Deribit: ultimo venerdi' di MAR/GIU/SET/DIC 08:00 UTC."""
|
|
out = []
|
|
for y in range(y0, y1 + 1):
|
|
for m in (3, 6, 9, 12):
|
|
lf = last_friday(y, m)
|
|
exp = pd.Timestamp(lf.year, lf.month, lf.day, 8, tz="UTC")
|
|
out.append((f"{asset}-{lf.day}{MONTHS[m-1]}{str(y)[2:]}", exp))
|
|
return out
|
|
|
|
|
|
def fetch_ohlc(instrument: str, t0: pd.Timestamp, t1: pd.Timestamp) -> pd.DataFrame:
|
|
"""OHLCV orario di UNO strumento (anche SCADUTO), a chunk di 90 giorni. Cache su disco."""
|
|
cf = CACHE / f"ohlc_{instrument.replace('/', '_')}.parquet"
|
|
if cf.exists():
|
|
return pd.read_parquet(cf)
|
|
frames = []
|
|
cur = t0
|
|
while cur < t1:
|
|
end = min(cur + pd.Timedelta(days=90), t1)
|
|
try:
|
|
res = _api("/public/get_tradingview_chart_data",
|
|
instrument_name=instrument, resolution="60",
|
|
start_timestamp=int(cur.timestamp() * 1000),
|
|
end_timestamp=int(end.timestamp() * 1000)).get("result", {})
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" {instrument}: chunk {cur.date()} FALLITO ({type(exc).__name__})")
|
|
cur = end
|
|
continue
|
|
ticks = res.get("ticks") or []
|
|
if res.get("status") == "ok" and ticks:
|
|
frames.append(pd.DataFrame({
|
|
"ts": pd.to_datetime(ticks, unit="ms", utc=True),
|
|
"open": res["open"], "high": res["high"],
|
|
"low": res["low"], "close": res["close"],
|
|
"volume": res.get("volume", [np.nan] * len(ticks)),
|
|
}))
|
|
cur = end
|
|
df = (pd.concat(frames).drop_duplicates("ts").sort_values("ts").reset_index(drop=True)
|
|
if frames else pd.DataFrame(columns=["ts", "open", "high", "low", "close", "volume"]))
|
|
df.to_parquet(cf, index=False)
|
|
return df
|
|
|
|
|
|
def fetch_funding_and_index(asset: str, t0: pd.Timestamp, t1: pd.Timestamp) -> pd.DataFrame:
|
|
"""Funding orario del perp E indice Deribit (la chiamata li porta insieme)."""
|
|
cf = CACHE / f"fund_{asset}.parquet"
|
|
if cf.exists():
|
|
return pd.read_parquet(cf)
|
|
frames, cur = [], t0
|
|
while cur < t1:
|
|
end = min(cur + pd.Timedelta(days=30), t1)
|
|
try:
|
|
res = _api("/public/get_funding_rate_history",
|
|
instrument_name=f"{asset}-PERPETUAL",
|
|
start_timestamp=int(cur.timestamp() * 1000),
|
|
end_timestamp=int(end.timestamp() * 1000)).get("result", [])
|
|
except Exception: # noqa: BLE001
|
|
cur = end
|
|
continue
|
|
if res:
|
|
frames.append(pd.DataFrame({
|
|
"ts": pd.to_datetime([r["timestamp"] for r in res], unit="ms", utc=True),
|
|
"index": [r["index_price"] for r in res],
|
|
"f1h": [r["interest_1h"] for r in res],
|
|
}))
|
|
cur = end
|
|
df = (pd.concat(frames).drop_duplicates("ts").sort_values("ts").reset_index(drop=True)
|
|
if frames else pd.DataFrame(columns=["ts", "index", "f1h"]))
|
|
df.to_parquet(cf, index=False)
|
|
return df
|
|
|
|
|
|
def fetch_live_books(instruments: list[str]) -> pd.DataFrame:
|
|
"""Calibrazione DATATA dello spread bid-ask: il book VIVO di oggi.
|
|
Non e' storia — e' un punto di ancoraggio per una stima, e va detto."""
|
|
cf = CACHE / "live_books.parquet"
|
|
if cf.exists():
|
|
return pd.read_parquet(cf)
|
|
rows = []
|
|
for nm in instruments:
|
|
try:
|
|
r = _api("/public/get_order_book", instrument_name=nm, depth=5)["result"]
|
|
bid, ask = r.get("best_bid_price"), r.get("best_ask_price")
|
|
if bid and ask and bid > 0:
|
|
rows.append(dict(instrument=nm, bid=bid, ask=ask,
|
|
mid=(bid + ask) / 2,
|
|
spread_bps=1e4 * (ask - bid) / ((ask + bid) / 2),
|
|
bid_sz=r.get("best_bid_amount"), ask_sz=r.get("best_ask_amount")))
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
df = pd.DataFrame(rows)
|
|
df.to_parquet(cf, index=False)
|
|
return df
|
|
|
|
|
|
# ==========================================================================
|
|
# 1. PANEL + CERTIFICAZIONE (prima di ogni strategia — regola di prim'ordine)
|
|
# ==========================================================================
|
|
def build_panel(asset: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
"""Ritorna (wide, meta). `wide` = indice orario x contratto -> close.
|
|
`meta` = una riga per contratto con scadenza, barre, copertura, quota barre FLAT."""
|
|
uni = quarterly_universe(asset)
|
|
today = pd.Timestamp.now(tz="UTC")
|
|
closes, vols, meta = {}, {}, []
|
|
for nm, exp in uni:
|
|
if exp > today + pd.Timedelta(days=400):
|
|
continue
|
|
t0 = exp - pd.Timedelta(days=400)
|
|
t1 = min(exp + pd.Timedelta(hours=2), today)
|
|
if t1 <= t0:
|
|
continue
|
|
df = fetch_ohlc(nm, t0, t1)
|
|
if df.empty or len(df) < 200:
|
|
meta.append(dict(contract=nm, exp=exp, bars=len(df), status="VUOTO/CORTO"))
|
|
continue
|
|
s = df.set_index("ts")["close"].astype(float)
|
|
s = s[s > 0]
|
|
closes[nm] = s
|
|
vols[nm] = df.set_index("ts")["volume"].astype(float)
|
|
flat = float((s.diff() == 0).mean())
|
|
meta.append(dict(contract=nm, exp=exp, bars=len(s),
|
|
first=s.index.min(), last=s.index.max(),
|
|
flat_frac=flat, status="ok"))
|
|
wide = pd.DataFrame(closes).sort_index()
|
|
vwide = pd.DataFrame(vols).sort_index()
|
|
return wide, vwide, pd.DataFrame(meta)
|
|
|
|
|
|
def certify(asset: str, wide: pd.DataFrame, vwide: pd.DataFrame, meta: pd.DataFrame,
|
|
fund: pd.DataFrame, perp: pd.Series) -> dict:
|
|
"""Quattro difetti sorvegliati. Un edge su un book fermo NON e' un edge (regola 4)."""
|
|
print(f"\n --- CERTIFICAZIONE {asset} ---")
|
|
ok = meta[meta.status == "ok"]
|
|
print(f" contratti con dato: {len(ok)}/{len(meta)} "
|
|
f"finestra {wide.index.min().date()} .. {wide.index.max().date()}")
|
|
|
|
# (a) barre FLAT per bucket di giorni-a-scadenza = liquidita' vera del datato
|
|
rows = []
|
|
for nm in wide.columns:
|
|
exp = ok.loc[ok.contract == nm, "exp"]
|
|
if exp.empty:
|
|
continue
|
|
s = wide[nm].dropna()
|
|
dte = (exp.iloc[0] - s.index).total_seconds() / 86400.0
|
|
flat = (s.diff() == 0).astype(float)
|
|
for lo, hi, lab in [(0, 30, "0-30g"), (30, 90, "30-90g"),
|
|
(90, 180, "90-180g"), (180, 400, ">180g")]:
|
|
m = (dte >= lo) & (dte < hi)
|
|
if m.sum() > 50:
|
|
rows.append(dict(bucket=lab, flat=float(flat[m].mean()), n=int(m.sum())))
|
|
fb = pd.DataFrame(rows).groupby("bucket").agg(flat=("flat", "mean"), n=("n", "sum"))
|
|
order = ["0-30g", "30-90g", "90-180g", ">180g"]
|
|
print(" barre FLAT (proxy di illiquidita') per giorni-a-scadenza:")
|
|
for b in order:
|
|
if b in fb.index:
|
|
print(f" {b:9s} flat={fb.loc[b,'flat']*100:5.1f}% barre={int(fb.loc[b,'n']):,}")
|
|
|
|
# (b) volume orario per bucket (il flat da solo non basta).
|
|
# ATTENZIONE: il `volume` di get_tradingview_chart_data e' in VALUTA BASE (BTC/ETH),
|
|
# non in USD -> va moltiplicato per il prezzo, o si sbaglia di ~5 ordini di grandezza.
|
|
vrows = []
|
|
for nm in vwide.columns:
|
|
exp = ok.loc[ok.contract == nm, "exp"]
|
|
if exp.empty:
|
|
continue
|
|
v = vwide[nm].dropna()
|
|
px = wide[nm].reindex(v.index)
|
|
usd = (v * px).dropna() # <- conversione in USD
|
|
dte = (exp.iloc[0] - usd.index).total_seconds() / 86400.0
|
|
for lo, hi, lab in [(0, 30, "0-30g"), (30, 90, "30-90g"),
|
|
(90, 180, "90-180g"), (180, 400, ">180g")]:
|
|
mm = (dte >= lo) & (dte < hi)
|
|
if mm.sum() > 50:
|
|
vrows.append(dict(bucket=lab, med=float(usd[mm].median()),
|
|
zero=float((usd[mm] == 0).mean())))
|
|
vb = pd.DataFrame(vrows).groupby("bucket").agg(med=("med", "median"), zero=("zero", "mean"))
|
|
print(" volume orario mediano in USD (volume base x prezzo) e quota ore a volume ZERO:")
|
|
for b in order:
|
|
if b in vb.index:
|
|
print(f" {b:9s} mediano=${vb.loc[b,'med']:>12,.0f}/ora ore a vol 0 = {vb.loc[b,'zero']*100:5.1f}%")
|
|
|
|
# (c) accordo con l'INDICE alla scadenza: il datato DEVE convergere
|
|
conv = []
|
|
for nm in wide.columns:
|
|
e = ok.loc[ok.contract == nm, "exp"]
|
|
if e.empty:
|
|
continue
|
|
exp = e.iloc[0]
|
|
s = wide[nm].dropna()
|
|
tail = s[s.index >= exp - pd.Timedelta(hours=6)]
|
|
idx = fund.set_index("ts")["index"].reindex(tail.index, method="nearest", tolerance=pd.Timedelta("2h"))
|
|
if len(tail) and idx.notna().any():
|
|
conv.append(float(np.abs(np.log(tail / idx)).dropna().iloc[-1]) * 1e4)
|
|
if conv:
|
|
print(f" convergenza a scadenza |ln(F/indice)| ultima ora: "
|
|
f"mediana {np.median(conv):.1f} bps, max {np.max(conv):.1f} bps (n={len(conv)})")
|
|
|
|
# (c-bis) scarto PERP vs INDICE: la gamba FvP lo assume piccolo, quindi si misura
|
|
pj = perp.reindex(fund.set_index("ts").index).dropna()
|
|
if len(pj) > 1000:
|
|
ii = fund.set_index("ts")["index"].reindex(pj.index)
|
|
pb = (np.log(pj / ii) * 1e4).replace([np.inf, -np.inf], np.nan).dropna()
|
|
print(f" perp vs indice: mediana {pb.median():+.1f} bps, p95 |scarto| "
|
|
f"{pb.abs().quantile(0.95):.1f} bps (n={len(pb):,}) — la gamba FvP e' sul PERP")
|
|
|
|
# (d) CROSS-CHECK INDIPENDENTE: il forward implicito nella catena opzioni
|
|
xchk = cross_check_option_forward(asset, wide, ok)
|
|
return dict(flat=fb, vol=vb, conv=conv, xchk=xchk)
|
|
|
|
|
|
def cross_check_option_forward(asset: str, wide: pd.DataFrame, meta: pd.DataFrame) -> dict:
|
|
"""Il `underlying_price` di un'opzione Deribit E' il forward della SUA scadenza.
|
|
La catena raccolta dal 2026-07-30 e' quindi una MISURA INDIPENDENTE del basis:
|
|
se il mio F(T) e il forward delle opzioni sulla stessa scadenza non coincidono,
|
|
uno dei due e' sbagliato. (L'archivio ereditato da bite ha underlying_price NULL.)"""
|
|
import glob
|
|
files = sorted(glob.glob(str(PROJECT_ROOT / "data/raw/cb_chain/2026-*.parquet")))
|
|
if not files:
|
|
return dict(status="catena assente")
|
|
keep = []
|
|
for f in files[-30:]:
|
|
try:
|
|
d = pd.read_parquet(f, columns=["asset", "exp", "ts", "underlying_price", "index_price"])
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
d = d[(d.asset == asset) & d.underlying_price.notna()]
|
|
if len(d):
|
|
keep.append(d)
|
|
if not keep:
|
|
return dict(status="nessun underlying_price")
|
|
ch = pd.concat(keep)
|
|
ch["ts_h"] = ch["ts"].dt.floor("1h")
|
|
# forward mediano per (ora, scadenza) — tutti gli strike condividono lo stesso forward
|
|
fw = ch.groupby(["ts_h", "exp"]).agg(fwd=("underlying_price", "median")).reset_index()
|
|
rows = []
|
|
for nm in wide.columns:
|
|
e = meta.loc[meta.contract == nm, "exp"]
|
|
if e.empty:
|
|
continue
|
|
exp = e.iloc[0]
|
|
sub = fw[fw.exp == exp]
|
|
if sub.empty:
|
|
continue
|
|
mine = wide[nm].dropna()
|
|
j = sub.set_index("ts_h")["fwd"].reindex(mine.index).dropna()
|
|
if len(j) < 5:
|
|
continue
|
|
dev = (np.log(mine.reindex(j.index) / j) * 1e4).dropna()
|
|
rows.append(dict(contract=nm, n=len(dev), med_bps=float(dev.median()),
|
|
p95_bps=float(dev.abs().quantile(0.95))))
|
|
return dict(status="ok", table=pd.DataFrame(rows))
|
|
|
|
|
|
# ==========================================================================
|
|
# 2. CURVA — front/back trimestrali, basis annualizzato, slope forward
|
|
# ==========================================================================
|
|
def curve_frame(asset: str, wide: pd.DataFrame, meta: pd.DataFrame,
|
|
fund: pd.DataFrame, roll_dte: int,
|
|
perp: pd.Series | None = None) -> pd.DataFrame:
|
|
"""Per ogni ora: front = trimestrale piu' vicino con dte >= roll_dte, back = il successivo.
|
|
Ritorna prezzi, tau, basis annualizzati, slope forward e funding."""
|
|
ok = meta[meta.status == "ok"].sort_values("exp")
|
|
exps = {r.contract: r.exp for r in ok.itertuples()}
|
|
idx = wide.index
|
|
F = fund.set_index("ts").reindex(idx).ffill(limit=2)
|
|
n = len(idx)
|
|
order = [c for c in ok.contract if c in wide.columns] # ordine di SCADENZA
|
|
if len(order) < 2:
|
|
raise RuntimeError("meno di due contratti utilizzabili")
|
|
|
|
# ALIVE[i, k] = il k-esimo contratto (per scadenza) e' quotato a i E ha dte >= roll
|
|
TAU = np.column_stack([(exps[c] - idx).total_seconds().values / 3600.0 for c in order])
|
|
AV = np.column_stack([wide[c].notna().values for c in order])
|
|
ALIVE = AV & (TAU >= roll_dte * 24)
|
|
|
|
any1 = ALIVE.any(axis=1)
|
|
k1 = np.argmax(ALIVE, axis=1) # primo vivo = FRONT
|
|
A2 = ALIVE.copy()
|
|
A2[np.arange(n), k1] = False
|
|
any2 = A2.any(axis=1)
|
|
k2 = np.argmax(A2, axis=1) # secondo vivo = BACK
|
|
m = any1 & any2
|
|
|
|
front = np.where(m, np.array(order, dtype=object)[k1], "")
|
|
back = np.where(m, np.array(order, dtype=object)[k2], "")
|
|
rows = np.arange(n)
|
|
Wv = wide[order].values.astype(float)
|
|
Ff = np.where(m, Wv[rows, k1], np.nan)
|
|
Fb = np.where(m, Wv[rows, k2], np.nan)
|
|
tf = np.where(m, TAU[rows, k1], np.nan)
|
|
tb = np.where(m, TAU[rows, k2], np.nan)
|
|
|
|
out = pd.DataFrame(index=idx)
|
|
out["front"], out["back"] = front, back
|
|
out["F_front"], out["F_back"] = Ff, Fb
|
|
out["tau_front_h"], out["tau_back_h"] = tf, tb
|
|
out["index"] = F["index"].values
|
|
out["f1h"] = F["f1h"].values
|
|
# gamba perp: il PERP CERTIFICATO, non l'indice (l'indice non si compra).
|
|
# fallback all'indice solo se il perp non copre l'ora, e lo si dichiara.
|
|
if perp is not None:
|
|
pp = perp.reindex(idx)
|
|
out["perp"] = pp.where(pp.notna(), out["index"]).values
|
|
out["perp_from_index"] = pp.isna().values
|
|
else:
|
|
out["perp"] = out["index"].values
|
|
out["perp_from_index"] = True
|
|
# basis annualizzato di ciascuna gamba vs INDICE
|
|
out["c_front"] = np.log(Ff / out["index"]) * HOURS_Y / tf
|
|
out["c_back"] = np.log(Fb / out["index"]) * HOURS_Y / tb
|
|
# slope = tasso forward annualizzato FRA le due scadenze (stazionario, interpretabile)
|
|
out["slope"] = np.log(Fb / Ff) * HOURS_Y / (tb - tf)
|
|
out["s_raw"] = np.log(Fb / Ff)
|
|
return out
|
|
|
|
|
|
def describe_curve(asset: str, cv: pd.DataFrame) -> None:
|
|
"""Q1 — descrittiva. Il basis e' positivo? persiste? il suo CAMBIO e' prevedibile?"""
|
|
c = cv.dropna(subset=["c_front", "slope"])
|
|
print(f"\n --- Q1 CURVA {asset} (n={len(c):,} ore) ---")
|
|
print(f" basis annualizzato del FRONT: mediana {c.c_front.median()*100:+6.2f}%/anno "
|
|
f"media {c.c_front.mean()*100:+6.2f}% sd {c.c_front.std()*100:5.2f} "
|
|
f"contango {float((c.c_front>0).mean())*100:.1f}% del tempo")
|
|
print(f" slope forward front->back : mediana {c.slope.median()*100:+6.2f}%/anno "
|
|
f"media {c.slope.mean()*100:+6.2f}% sd {c.slope.std()*100:5.2f} "
|
|
f"positiva {float((c.slope>0).mean())*100:.1f}% del tempo")
|
|
fnd = cv["f1h"].dropna() * HOURS_Y
|
|
print(f" funding perp annualizzato : mediana {fnd.median()*100:+6.2f}%/anno "
|
|
f"media {fnd.mean()*100:+6.2f}%")
|
|
print(" per anno (mediane annualizzate):")
|
|
g = c.groupby(c.index.year).agg(basis_front=("c_front", "median"),
|
|
slope=("slope", "median"))
|
|
fy = (cv["f1h"] * HOURS_Y).groupby(cv.index.year).median()
|
|
for y, r in g.iterrows():
|
|
print(f" {y} basis_front {r.basis_front*100:+7.2f}% slope {r.slope*100:+7.2f}% "
|
|
f"funding {fy.get(y, np.nan)*100:+7.2f}%")
|
|
# prevedibilita': mean-reversion dello slope a 7 giorni (AR sul livello)
|
|
for h, lab in [(24, "1g"), (24 * 7, "7g"), (24 * 30, "30g")]:
|
|
x = c["slope"]
|
|
dx = x.shift(-h) - x
|
|
j = pd.concat([x, dx], axis=1).dropna()
|
|
if len(j) > 500:
|
|
b = np.polyfit(j.iloc[:, 0], j.iloc[:, 1], 1)[0]
|
|
rho = float(j.iloc[:, 0].corr(j.iloc[:, 1]))
|
|
print(f" mean-reversion slope a {lab:>3s}: beta {b:+.3f} corr {rho:+.3f} "
|
|
f"(beta -1 = reversione totale, 0 = martingala)")
|
|
|
|
|
|
def basis_vs_funding(asset: str, cv: pd.DataFrame) -> None:
|
|
"""Q1, nella forma decidibile: il basis del front e' il premio ANNUALIZZATO che
|
|
incassi se vendi il datato e lo tieni a scadenza. La copertura e' il perp, su cui
|
|
PAGHI il funding realizzato nello stesso periodo. Quindi il roll-down incassabile
|
|
NON e' il basis: e' `basis - funding realizzato fino a scadenza`.
|
|
Se il basis e' un previsore NON DISTORTO del funding (a~0, b~1) il premio a termine
|
|
esiste come LIVELLO ma non e' estraibile: e' gia' il prezzo della copertura."""
|
|
d = cv[["c_front", "f1h", "tau_front_h"]].dropna()
|
|
if len(d) < 5000:
|
|
print(f" {asset}: campione insufficiente")
|
|
return
|
|
f = d["f1h"].values
|
|
cum = np.concatenate([[0.0], np.cumsum(f)]) # somma cumulata del funding orario
|
|
n = len(d)
|
|
tau = d["tau_front_h"].values
|
|
end = np.minimum(np.arange(n) + tau.astype(int), n - 1)
|
|
hrs = np.maximum(end - np.arange(n), 1)
|
|
realized = (cum[end] - cum[np.arange(n)]) / hrs * HOURS_Y # funding realizzato ANNUALIZZATO
|
|
implied = d["c_front"].values
|
|
m = np.isfinite(realized) & np.isfinite(implied) & (hrs > 24 * 5)
|
|
x, y = implied[m], realized[m]
|
|
b1, b0 = np.polyfit(x, y, 1)
|
|
prem = x - y # premio effettivamente incassabile
|
|
# IC bootstrap a BLOCCHI (le ore sono fortemente autocorrelate: un IC iid mentirebbe)
|
|
rng = np.random.default_rng(822)
|
|
bl = 24 * 30
|
|
nb = max(1, len(prem) // bl)
|
|
boots = []
|
|
for _ in range(400):
|
|
st = rng.integers(0, max(1, len(prem) - bl), nb)
|
|
boots.append(np.mean(np.concatenate([prem[i:i + bl] for i in st])))
|
|
lo, hi = np.percentile(boots, [2.5, 97.5])
|
|
print(f" {asset}: basis implicito medio {np.mean(x)*100:+6.2f}%/anno "
|
|
f"funding realizzato a scadenza {np.mean(y)*100:+6.2f}%/anno")
|
|
print(f" regressione realizzato ~ a + b*implicito: b = {b1:+.3f} a = {b0*100:+.2f}%"
|
|
f" (b~1 e a~0 = previsore non distorto => niente da incassare)")
|
|
print(f" PREMIO INCASSABILE = implicito - realizzato = {np.mean(prem)*100:+6.2f}%/anno"
|
|
f" IC95% a blocchi [{lo*100:+.2f}%, {hi*100:+.2f}%] (n={m.sum():,} ore)")
|
|
|
|
|
|
# ==========================================================================
|
|
# 3. STRATEGIE — posizioni in spazio CONTRATTO (il roll si paga da solo)
|
|
# ==========================================================================
|
|
def _z(x: pd.Series, win: int) -> pd.Series:
|
|
m = x.rolling(win, min_periods=win // 3).mean()
|
|
s = x.rolling(win, min_periods=win // 3).std()
|
|
return ((x - m) / s.replace(0, np.nan))
|
|
|
|
|
|
def asset_ctx(wide: pd.DataFrame) -> dict:
|
|
"""Precalcolo per asset: matrice dei rendimenti per contratto + mappa colonne.
|
|
Fatto UNA volta e riusato da tutte le celle (altrimenti la griglia non sta nel budget)."""
|
|
Wv = wide.values.astype(float)
|
|
R = np.zeros_like(Wv)
|
|
R[1:] = Wv[1:] / Wv[:-1] - 1.0
|
|
R[~np.isfinite(R)] = 0.0
|
|
return dict(R=R, cpos={c: k for k, c in enumerate(wide.columns)},
|
|
ncol=Wv.shape[1], idx=wide.index)
|
|
|
|
|
|
def pair_idx(cv: pd.DataFrame, ctx: dict) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Indici di colonna di front/back per ogni ora (-1 = coppia non disponibile)."""
|
|
cp = ctx["cpos"]
|
|
fi = np.array([cp.get(x, -1) for x in cv["front"].values], dtype=int)
|
|
bi = np.array([cp.get(x, -1) for x in cv["back"].values], dtype=int)
|
|
return fi, bi
|
|
|
|
|
|
def signal_w(cv: pd.DataFrame, family: str, sig: str, win_h: int, thr: float) -> np.ndarray:
|
|
base = cv["slope"] if family == "CAL" else cv["c_front"]
|
|
if sig == "static_long":
|
|
w = pd.Series(1.0, index=cv.index)
|
|
elif sig == "static_short":
|
|
w = pd.Series(-1.0, index=cv.index)
|
|
elif sig == "carry": # contango -> vendi il datato
|
|
w = -np.sign(base)
|
|
elif sig in ("zrev", "zmom"):
|
|
z = _z(base, win_h)
|
|
w = (-np.tanh(z)) if sig == "zrev" else np.tanh(z)
|
|
w = w.where(z.abs() >= thr, 0.0)
|
|
else:
|
|
raise ValueError(sig)
|
|
return np.nan_to_num(np.asarray(w, float), nan=0.0).clip(-1, 1)
|
|
|
|
|
|
def run_strategy(cv: pd.DataFrame, ctx: dict, family: str, sig: str,
|
|
win_h: int, thr: float, slip_bps: float,
|
|
fee_side: float = FEE_SIDE, fi=None, bi=None, lag: int = 1,
|
|
dec_hour: int | None = None) -> pd.Series:
|
|
"""Ritorno ORARIO netto per $1 di nozionale LORDO PER GAMBA.
|
|
|
|
Le posizioni vivono in spazio CONTRATTO: quando la coppia cambia (roll) la variazione
|
|
per contratto e' automaticamente piena => il roll paga il round-trip completo su
|
|
entrambe le gambe. Nessuna serie di prezzi viene MAI incollata: si incollano solo i
|
|
RENDIMENTI, che e' l'unico posto in cui e' lecito.
|
|
|
|
Causalita': w e' deciso con dati <= t e TENUTO durante t -> t+1 (shift esplicito qui,
|
|
non nel segnale) => non si puo' sbagliare per distrazione a valle."""
|
|
idx = ctx["idx"]
|
|
n, ncol = len(idx), ctx["ncol"]
|
|
if fi is None or bi is None:
|
|
fi, bi = pair_idx(cv, ctx)
|
|
w = signal_w(cv, family, sig, win_h, thr)
|
|
if dec_hour is not None:
|
|
# ANCORA: la posizione si aggiorna SOLO a quell'ora del giorno e si TIENE il resto
|
|
# (non "si guadagna solo a quell'ora", che sarebbe un'altra strategia).
|
|
ws = pd.Series(w, index=idx).where(idx.hour == dec_hour)
|
|
w = ws.ffill().fillna(0.0).values
|
|
|
|
P = np.zeros((n, ncol))
|
|
rows = np.arange(n)
|
|
if family == "CAL":
|
|
m = (fi >= 0) & (bi >= 0)
|
|
P[rows[m], bi[m]] = w[m] # long spread = long back
|
|
P[rows[m], fi[m]] = -w[m] # / short front
|
|
else: # FvP: solo la gamba datata
|
|
m = fi >= 0
|
|
P[rows[m], fi[m]] = w[m]
|
|
|
|
held = np.zeros_like(P)
|
|
held[lag:] = P[:-lag] if lag else P # deciso a t, tenuto in t->t+lag
|
|
gross = (held * ctx["R"]).sum(axis=1)
|
|
|
|
dP = np.zeros_like(P)
|
|
dP[1:] = P[1:] - P[:-1]
|
|
dP[0] = P[0]
|
|
turn = np.abs(dP).sum(axis=1)
|
|
|
|
if family == "FvP":
|
|
perp_r = np.nan_to_num(cv["perp"].pct_change().values, nan=0.0)
|
|
f1h = np.nan_to_num(cv["f1h"].values, nan=0.0)
|
|
wheld = held.sum(axis=1) # una sola gamba datata
|
|
# long datato => short perp: guadagna -w*perp_r e INCASSA w*funding
|
|
gross = gross - wheld * perp_r + wheld * f1h
|
|
turn = turn * 2.0 # datato + gamba perp
|
|
|
|
cost = turn * (fee_side + slip_bps * 1e-4)
|
|
return pd.Series(gross - cost, index=idx).fillna(0.0)
|
|
|
|
|
|
# ==========================================================================
|
|
# 4. METRICHE / GATE
|
|
# ==========================================================================
|
|
def combine(hs: list[pd.Series]) -> pd.Series:
|
|
"""50/50 BTC+ETH sulle ore COMUNI (inner join, come tp01_baseline_daily)."""
|
|
J = pd.concat(hs, axis=1, join="inner").fillna(0.0)
|
|
return J.mean(axis=1)
|
|
|
|
|
|
def to_daily(h: pd.Series) -> pd.Series:
|
|
return ((1.0 + h.fillna(0.0)).resample("1D").prod() - 1.0).dropna()
|
|
|
|
|
|
def stats(daily: pd.Series) -> dict:
|
|
r = daily.dropna()
|
|
if len(r) < 30 or r.std() == 0:
|
|
return dict(sharpe=0.0, holdout=0.0, maxdd=0.0, cagr=0.0, n=len(r), vol=0.0)
|
|
sh = float(r.mean() / r.std() * math.sqrt(365.25))
|
|
eq = (1 + r).cumprod()
|
|
dd = float(((eq.cummax() - eq) / eq.cummax()).max())
|
|
yrs = (r.index[-1] - r.index[0]).days / 365.25
|
|
cagr = float(eq.iloc[-1] ** (1 / yrs) - 1) if yrs > 0.5 and eq.iloc[-1] > 0 else float("nan")
|
|
ho = r[r.index >= HOLDOUT]
|
|
sho = float(ho.mean() / ho.std() * math.sqrt(365.25)) if len(ho) > 30 and ho.std() > 0 else float("nan")
|
|
yrs_ = max(len(r) / 365.25, 1e-9)
|
|
return dict(sharpe=sh, holdout=sho, maxdd=dd, cagr=cagr, n=len(r),
|
|
vol=float(r.std() * math.sqrt(365.25)),
|
|
se_ann=float(r.std() * math.sqrt(365.25) / math.sqrt(yrs_)))
|
|
|
|
|
|
def delevering_null(cand: pd.Series, base: pd.Series) -> dict:
|
|
"""NULL DEL DE-LEVERING (5 occorrenze in questo progetto). Esiste k<1 che,
|
|
applicato al BASELINE, da' lo STESSO maxDD del candidato con Sharpe MIGLIORE?
|
|
Se si', il candidato non protegge: e' solo meno leva."""
|
|
cs = stats(cand)
|
|
best = None
|
|
for k in np.arange(0.05, 1.01, 0.05):
|
|
s = stats(base * k)
|
|
if s["maxdd"] <= cs["maxdd"] + 1e-9:
|
|
best = (float(k), s)
|
|
break
|
|
if best is None:
|
|
return dict(refuted=False, note="nessun k<=1 raggiunge quel DD sul baseline")
|
|
k, s = best
|
|
return dict(refuted=bool(s["sharpe"] > cs["sharpe"]), k=k,
|
|
base_sharpe_at_k=s["sharpe"], cand_sharpe=cs["sharpe"],
|
|
base_dd_at_k=s["maxdd"], cand_dd=cs["maxdd"])
|
|
|
|
|
|
# ==========================================================================
|
|
# 5. MAIN
|
|
# ==========================================================================
|
|
def main() -> None:
|
|
t_start = time.time()
|
|
print("=" * 78)
|
|
print("r0822 BASIS-CALENDAR — struttura a termine dei futures datati Deribit")
|
|
print("=" * 78)
|
|
|
|
today = pd.Timestamp.now(tz="UTC")
|
|
panels, funds, metas, vwides = {}, {}, {}, {}
|
|
|
|
# ---------------- STEP 1: dato ----------------
|
|
print("\n[1] DATO — ricostruzione da Deribit pubblico (cache su scratchpad)")
|
|
for a in ASSETS:
|
|
print(f" {a}: trimestrali (ultimo venerdi' MAR/GIU/SET/DIC 08:00 UTC)...")
|
|
wide, vwide, meta = build_panel(a)
|
|
fund = fetch_funding_and_index(a, pd.Timestamp("2019-01-01", tz="UTC"), today)
|
|
panels[a], vwides[a], metas[a], funds[a] = wide, vwide, meta, fund
|
|
ok = meta[meta.status == "ok"]
|
|
print(f" {len(ok)} contratti, {int(meta.bars.sum()):,} barre orarie, "
|
|
f"funding+indice {len(fund):,} ore")
|
|
|
|
# ---------------- STEP 2: certificazione ----------------
|
|
print("\n[2] CERTIFICAZIONE (regola 4: un edge su un book fermo non e' un edge)")
|
|
certs, perps = {}, {}
|
|
for a in ASSETS:
|
|
pdf = A.get(a, "1h")
|
|
perps[a] = pd.Series(
|
|
pdf["close"].astype(float).values,
|
|
index=pd.DatetimeIndex(pd.to_datetime(pdf["datetime"], utc=True))).sort_index()
|
|
perps[a] = perps[a][~perps[a].index.duplicated()]
|
|
certs[a] = certify(a, panels[a], vwides[a], metas[a], funds[a], perps[a])
|
|
x = certs[a]["xchk"]
|
|
if x.get("status") == "ok" and len(x["table"]):
|
|
t = x["table"]
|
|
print(f" CROSS-CHECK INDIPENDENTE vs forward della catena opzioni "
|
|
f"({int(t.n.sum())} ore, {len(t)} scadenze):")
|
|
print(f" deviazione mediana {t.med_bps.median():+.1f} bps, "
|
|
f"p95 |dev| {t.p95_bps.max():.1f} bps")
|
|
else:
|
|
print(f" CROSS-CHECK catena: {x.get('status')}")
|
|
|
|
# ---------------- STEP 3: Q1 curva ----------------
|
|
print("\n[3] Q1 — LA CURVA E' PREVEDIBILE?")
|
|
curves = {}
|
|
for a in ASSETS:
|
|
cv = curve_frame(a, panels[a], metas[a], funds[a], roll_dte=7, perp=perps[a])
|
|
curves[a] = cv
|
|
describe_curve(a, cv)
|
|
print("\n --- Q1-bis: il basis e' un PREVISORE del funding che paghi sulla copertura? ---")
|
|
for a in ASSETS:
|
|
basis_vs_funding(a, curves[a])
|
|
|
|
# ---------------- STEP 3b: spread bid-ask (calibrazione DATATA) ----------------
|
|
print("\n[3b] SPREAD BID-ASK — calibrazione sul book VIVO di oggi (non e' storia)")
|
|
live = []
|
|
for a in ASSETS:
|
|
for nm, exp in quarterly_universe(a):
|
|
if today < exp <= today + pd.Timedelta(days=400):
|
|
live.append(nm)
|
|
lb = fetch_live_books(live + [f"{a}-PERPETUAL" for a in ASSETS])
|
|
slip_est = 5.0
|
|
if len(lb):
|
|
for r in lb.itertuples():
|
|
print(f" {r.instrument:16s} bid {r.bid:>10,.1f} ask {r.ask:>10,.1f} "
|
|
f"spread {r.spread_bps:6.1f} bps size {r.bid_sz:>8,.0f}/{r.ask_sz:>8,.0f}")
|
|
dated = lb[~lb.instrument.str.contains("PERPETUAL")]
|
|
if len(dated):
|
|
slip_est = float(dated.spread_bps.median()) / 2.0
|
|
print(f" => mezzo-spread MEDIANO del datato = {slip_est:.1f} bps/lato "
|
|
f"(STIMA datata oggi, NON storia: il book storico dei datati non e' disponibile)")
|
|
|
|
# ---------------- STEP 4: griglia ----------------
|
|
print("\n[4] GRIGLIA — dichiarata PRIMA, contata AL RIALZO")
|
|
FAM = {"CAL": ["static_long", "static_short", "zrev", "zmom"],
|
|
"FvP": ["carry", "static_long", "static_short", "zrev", "zmom"]}
|
|
WINS = [720, 2160, 4320] # 30 / 90 / 180 giorni in ore
|
|
ROLLS = [7, 21]
|
|
THRS = [0.0, 1.0]
|
|
cells = [(f, s, w, rl, th) for f, sigs in FAM.items() for s in sigs
|
|
for w in WINS for rl in ROLLS for th in THRS]
|
|
print(f" famiglie {list(FAM)} x segnali x finestre {WINS} x roll {ROLLS} x soglie {THRS}")
|
|
print(f" = {len(cells)} celle valutate (le varianti statiche non usano W/soglia:"
|
|
f" le conto lo stesso, al rialzo)")
|
|
|
|
ctxs = {a: asset_ctx(panels[a]) for a in ASSETS}
|
|
cvcache = {(a, rl): curve_frame(a, panels[a], metas[a], funds[a], rl, perp=perps[a])
|
|
for a in ASSETS for rl in ROLLS}
|
|
pidx = {(a, rl): pair_idx(cvcache[(a, rl)], ctxs[a]) for a in ASSETS for rl in ROLLS}
|
|
|
|
rows = []
|
|
for (fam, sig, win, rl, th) in cells:
|
|
hs = []
|
|
for a in ASSETS:
|
|
f_i, b_i = pidx[(a, rl)]
|
|
h = run_strategy(cvcache[(a, rl)], ctxs[a], fam, sig, win, th, slip_est,
|
|
fi=f_i, bi=b_i)
|
|
hs.append(h)
|
|
d = to_daily(combine(hs))
|
|
st = stats(d)
|
|
rows.append(dict(family=fam, sig=sig, win=win, roll=rl, thr=th,
|
|
daily=d, **st))
|
|
G = pd.DataFrame([{k: v for k, v in r.items() if k != "daily"} for r in rows])
|
|
dailies = {i: r["daily"] for i, r in enumerate(rows)}
|
|
|
|
print(f"\n distribuzione dello Sharpe FULL sulle {len(G)} celle: "
|
|
f"min {G.sharpe.min():+.2f} p25 {G.sharpe.quantile(.25):+.2f} "
|
|
f"mediana {G.sharpe.median():+.2f} p75 {G.sharpe.quantile(.75):+.2f} "
|
|
f"max {G.sharpe.max():+.2f} sd {G.sharpe.std():.2f}")
|
|
degen = G[(G.vol < 0.005) | (G.n < 400)]
|
|
if len(degen):
|
|
print(f" ATTENZIONE: {len(degen)} celle DEGENERI (vol<0.5%/anno o <400 giorni attivi):")
|
|
print(f" Sharpe di quelle celle: {sorted(np.round(degen.sharpe.values,2))[:10]}")
|
|
print(" gonfiano la varianza dei trial e quindi il null del deflated-Sharpe:")
|
|
print(" il DSR viene riportato ANCHE sulla sola sottogriglia non degenere.")
|
|
|
|
print("\n MIGLIORE CELLA PER FAMIGLIA (Q2 riguarda il CALENDAR: va letto separato):")
|
|
for fam in FAM:
|
|
sub = G[G.family == fam]
|
|
i = sub.sharpe.idxmax()
|
|
r = G.loc[i]
|
|
print(f" [{fam}] {r.sig:12s} W={int(r.win):5d}h roll={int(r.roll):2d}g thr={r.thr:.1f} "
|
|
f"| Sh {r.sharpe:+6.2f} hold {r.holdout:+6.2f} DD {r.maxdd*100:5.2f}% "
|
|
f"CAGR {r.cagr*100:+6.2f}% vol {r.vol*100:5.2f}%")
|
|
ii = A.implausible_sharpe(dailies[i])
|
|
print(f" implausible_sharpe={ii['implausible']} "
|
|
f"(barre in perdita {ii.get('loss_frac', float('nan'))*100:.1f}%) "
|
|
f"-> {'coda ASSENTE, rischio fuori dal campione' if ii['implausible'] else 'coda PRESENTE'}")
|
|
|
|
print("\n LE CELLE CANONICHE (quelle che un lettore chiede per nome), roll=7g:")
|
|
for fam, sig, lab in [
|
|
("FvP", "carry", "vendi il datato quando e' in CONTANGO / compra perp (il carry classico)"),
|
|
("FvP", "static_short", "SEMPRE corto il datato / lungo perp = LA RACCOLTA DEL CONTANGO"),
|
|
("FvP", "static_long", "SEMPRE lungo il datato / corto perp (il contrario)"),
|
|
("CAL", "static_long", "calendar: lungo back / corto front"),
|
|
("CAL", "static_short", "calendar: corto back / lungo front")]:
|
|
sub = G[(G.family == fam) & (G.sig == sig) & (G.roll == 7)]
|
|
if len(sub):
|
|
r = G.loc[sub.index[0]]
|
|
print(f" {fam}/{sig:12s} Sh {r.sharpe:+6.2f} hold {r.holdout:+6.2f} "
|
|
f"CAGR {r.cagr*100:+6.2f}% (+-{r.se_ann*100:.2f} SE) DD {r.maxdd*100:5.2f}% "
|
|
f"vol {r.vol*100:5.2f}% <- {lab}")
|
|
|
|
print("\n migliori 8 celle per Sharpe FULL (NON e' la selezione — solo panorama):")
|
|
for i in G.sharpe.nlargest(8).index:
|
|
r = G.loc[i]
|
|
print(f" {r.family:4s} {r.sig:12s} W={int(r.win):5d}h roll={int(r.roll):2d}g "
|
|
f"thr={r.thr:.1f} | Sh {r.sharpe:+6.2f} hold {r.holdout:+6.2f} "
|
|
f"DD {r.maxdd*100:5.2f}% CAGR {r.cagr*100:+6.2f}% vol {r.vol*100:5.2f}%")
|
|
|
|
# ---------------- STEP 5: selezione IN-SAMPLE ONLY ----------------
|
|
print("\n[5] SELEZIONE IN-SAMPLE-ONLY (mai la cella col miglior hold-out)")
|
|
ins = {}
|
|
for i, d in dailies.items():
|
|
pre = d[d.index < HOLDOUT]
|
|
ins[i] = (float(pre.mean() / pre.std() * math.sqrt(365.25))
|
|
if len(pre) > 60 and pre.std() > 0 else -9.9)
|
|
best_i = max(ins, key=ins.get)
|
|
b = G.loc[best_i]
|
|
bd = dailies[best_i]
|
|
bs = stats(bd)
|
|
print(f" cella scelta AL BUIO: {b.family} {b.sig} W={int(b.win)}h roll={int(b.roll)}g thr={b.thr}")
|
|
print(f" Sharpe in-sample {ins[best_i]:+.2f} -> FULL {bs['sharpe']:+.2f} "
|
|
f"HOLD-OUT {bs['holdout']:+.2f} maxDD {bs['maxdd']*100:.2f}% "
|
|
f"CAGR {bs['cagr']*100:+.2f}% (+-{bs['se_ann']*100:.2f} SE annuo) "
|
|
f"vol {bs['vol']*100:.2f}%")
|
|
print(f" NB con {bs['n']/365.25:.1f} anni e vol {bs['vol']*100:.1f}% l'errore standard del "
|
|
f"rendimento annuo e' {bs['se_ann']*100:.2f}pp: sotto ~{2*bs['se_ann']*100:.1f}pp/anno "
|
|
f"NIENTE e' distinguibile da zero, ne' in un verso ne' nell'altro.")
|
|
best_full = G.sharpe.idxmax()
|
|
print(f" (per confronto, la cella col miglior FULL sarebbe stata "
|
|
f"{G.loc[best_full,'family']} {G.loc[best_full,'sig']} "
|
|
f"Sh {G.loc[best_full,'sharpe']:+.2f} — NON e' quella scelta)")
|
|
|
|
# ---------------- STEP 6: GATE ----------------
|
|
print("\n[6] GATE")
|
|
dsr, sr0 = A.deflated_sharpe(bs["sharpe"], list(G.sharpe.values), bd)
|
|
print(f" deflated-Sharpe : DSR {dsr:.3f} (null max atteso {sr0:+.2f} su {len(G)} trial) "
|
|
f"-> {'PASS' if dsr >= 0.95 else 'FAIL'}")
|
|
nd = G[(G.vol >= 0.005) & (G.n >= 400)]
|
|
if len(nd) >= 5 and len(nd) < len(G):
|
|
d2, s2_ = A.deflated_sharpe(bs["sharpe"], list(nd.sharpe.values), bd)
|
|
print(f" sulla sola sottogriglia NON degenere ({len(nd)} trial): "
|
|
f"DSR {d2:.3f} (null max {s2_:+.2f}) -> {'PASS' if d2 >= 0.95 else 'FAIL'}")
|
|
|
|
imp = A.implausible_sharpe(bd)
|
|
print(f" implausible_sharpe: implausible={imp['implausible']} "
|
|
f"Sh {imp.get('sharpe', float('nan')):+.2f} maxDD {imp.get('maxdd', float('nan'))*100:.2f}% "
|
|
f"quota barre in perdita {imp.get('loss_frac', float('nan'))*100:.1f}% "
|
|
f"Calmar {imp.get('calmar', float('nan')):.1f}")
|
|
if imp["reasons"]:
|
|
for rr in imp["reasons"]:
|
|
print(f" - {rr}")
|
|
|
|
tp = A.tp01_baseline_daily()
|
|
try:
|
|
mg = A.marginal_vs_tp01(bd)
|
|
print(f" marginal_vs_tp01 : {mg.get('marginal_verdict')} "
|
|
f"corr_full {mg.get('corr_full')} corr_hold {mg.get('corr_hold')} "
|
|
f"robust_oos={mg.get('robust_oos')} insample_edge={mg.get('has_insample_edge')} "
|
|
f"is_hedge={mg.get('is_hedge')} beats_noise={mg.get('beats_noise_null')}")
|
|
w25 = mg.get("w25") or (mg.get("blends", {}) or {}).get("w25", {})
|
|
print(f" uplift blend w=25%: full {w25.get('uplift_full')} "
|
|
f"hold {w25.get('uplift_hold')}")
|
|
except Exception as exc: # noqa: BLE001
|
|
mg = {}
|
|
print(f" marginal_vs_tp01 : NON GIRATO ({type(exc).__name__}: {exc})")
|
|
|
|
dn = delevering_null(bd, tp)
|
|
print(f" null de-levering : refuted={dn.get('refuted')} {dn}")
|
|
|
|
# anchor: l'ora del giorno in cui si valuta il segnale
|
|
print(" anchor_luck_band (ora del giorno di decisione, 24 ancore):")
|
|
def by_off(off):
|
|
hs = []
|
|
for a in ASSETS:
|
|
f_i, b_i = pidx[(a, int(b.roll))]
|
|
hs.append(run_strategy(cvcache[(a, int(b.roll))], ctxs[a], b.family, b.sig,
|
|
int(b.win), float(b.thr), slip_est,
|
|
fi=f_i, bi=b_i, dec_hour=off))
|
|
return to_daily(combine(hs))
|
|
try:
|
|
ab = A.anchor_luck_band(by_off, list(range(24)), canonical=0)
|
|
if "median" in ab:
|
|
print(f" canonica {ab['canonical']:+.3f} (pctl {ab['canonical_pctl']*100:.0f}°) "
|
|
f"MEDIANA(stima onesta) {ab['median']:+.3f} banda [{ab['lo']:+.3f},{ab['hi']:+.3f}] "
|
|
f"frazione>0 {ab['frac_positive']:.2f} fortuna {ab['luck']:+.3f} "
|
|
f"gate_pass={ab['gate_pass']}")
|
|
else:
|
|
print(f" {ab}")
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" NON GIRATO ({type(exc).__name__}: {exc})")
|
|
|
|
try:
|
|
print(f" causality_ok : la posizione e' shiftata in run_strategy "
|
|
f"(held = pos.shift(1)); verifica strutturale, non A.causality_ok "
|
|
f"(che vuole un target_fn su BTC/ETH direzionale)")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
# ---------------- STEP 6b: RITARDO D'ESECUZIONE (il test decisivo qui) ----------------
|
|
print("\n[6b] RITARDO D'ESECUZIONE — il test decisivo di questo filone")
|
|
print(" Un datato lontano ha prezzi STANTII: una mean-reversion su una serie stantia")
|
|
print(" mostra profitti FINTI (il prezzo 'reverte' solo perche' si aggiorna in ritardo).")
|
|
print(" Un edge di STRUTTURA A TERMINE e' lento e sopravvive a un'ora in piu' di ritardo;")
|
|
print(" un artefatto di microstruttura muore. (Stessa lente del decadimento di XSR01.)")
|
|
lag_tab = []
|
|
for lg in [1, 2, 3, 6, 12, 24]:
|
|
hs = []
|
|
for a in ASSETS:
|
|
f_i, b_i = pidx[(a, int(b.roll))]
|
|
hs.append(run_strategy(cvcache[(a, int(b.roll))], ctxs[a], b.family, b.sig,
|
|
int(b.win), float(b.thr), slip_est, fi=f_i, bi=b_i, lag=lg))
|
|
sl_ = stats(to_daily(combine(hs)))
|
|
lag_tab.append((lg, sl_["sharpe"], sl_["cagr"]))
|
|
print(f" ritardo {lg:2d}h -> Sharpe {sl_['sharpe']:+6.2f} CAGR {sl_['cagr']*100:+6.2f}%")
|
|
s1 = lag_tab[0][1]; s2 = lag_tab[1][1]
|
|
if s1 > 0:
|
|
keep = s2 / s1
|
|
print(f" con UNA sola ora in piu' resta il {keep*100:.0f}% dello Sharpe "
|
|
f"({s1:+.2f} -> {s2:+.2f})")
|
|
print(" -> " + ("compatibile con un edge lento di struttura a termine"
|
|
if keep > 0.6 else
|
|
"CROLLO: firma di microstruttura/prezzo stantio, NON di term structure"))
|
|
|
|
# ---------------- STEP 7: quanto spread uccide l'edge ----------------
|
|
print("\n[7] A CHE SPREAD MUORE L'EDGE (la stima di slippage e' il rischio #1)")
|
|
for sl in [0.0, 1.0, 2.5, 5.0, 7.5, 10.0, 20.0]:
|
|
hs = []
|
|
for a in ASSETS:
|
|
f_i, b_i = pidx[(a, int(b.roll))]
|
|
hs.append(run_strategy(cvcache[(a, int(b.roll))], ctxs[a], b.family,
|
|
b.sig, int(b.win), float(b.thr), sl, fi=f_i, bi=b_i))
|
|
s2 = stats(to_daily(combine(hs)))
|
|
mark = " <= stima di oggi" if abs(sl - slip_est) < 0.6 else ""
|
|
print(f" mezzo-spread {sl:5.1f} bps/lato -> Sharpe {s2['sharpe']:+6.2f} "
|
|
f"CAGR {s2['cagr']*100:+6.2f}%{mark}")
|
|
|
|
# ---------------- STEP 8: eseguibilita' ----------------
|
|
print("\n[8] Q3 — ESEGUIBILITA' a $600-3.000")
|
|
print(" lotti minimi letti dal VENUE (non da una tabella cablata):")
|
|
px = {a: float(curves[a]["index"].dropna().iloc[-1]) for a in ASSETS}
|
|
for a in ASSETS:
|
|
for nm in (f"{a}-25DEC26", f"{a}_USDC-25DEC26"):
|
|
try:
|
|
sp = _api("/public/get_instrument", instrument_name=nm)["result"]
|
|
lin = sp.get("settlement_currency") == "USDC"
|
|
lot = (sp["min_trade_amount"] * px[a]) if lin else sp["min_trade_amount"]
|
|
print(f" {nm:22s} {'LINEARE' if lin else 'INVERSE':8s} "
|
|
f"min {sp['min_trade_amount']:<10.4g} = ${lot:>8,.2f} di nozionale "
|
|
f"tick {sp['tick_size']} taker {sp['taker_commission']*1e4:.1f} bps")
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" {nm:22s} specs non lette ({type(exc).__name__})")
|
|
print(" (il libro live trada i LINEARI USDC; i trimestrali studiati qui sono gli INVERSE,")
|
|
print(" che hanno 7 anni di storia. Le due famiglie hanno lotti minimi DIVERSI.)")
|
|
print(f" vol ANNUA del candidato per $1 di nozionale lordo/gamba: {bs['vol']*100:.2f}%")
|
|
if bs["vol"] > 0:
|
|
lev = 0.20 / bs["vol"]
|
|
print(f" per portarlo al vol-target 20% del progetto servirebbe leva "
|
|
f"{lev:.1f}x per gamba = ${lev*2:,.0f} di nozionale LORDO per $1 di conto")
|
|
for cap in (600, 3000):
|
|
print(f" a ${cap}: nozionale lordo ${lev*2*cap:,.0f} "
|
|
f"(margine ~2-4%/gamba => ${lev*2*cap*0.03:,.0f} di IM richiesto)")
|
|
|
|
print(f"\n[fine] {time.time()-t_start:.0f}s")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|