"""r0822 SLIP-AUDIT — l'attrito VERO del libro live: quanto costa davvero un fill. DOMANDA ------- Il progetto modella le fee con cura chirurgica (3,50 bps/lato misurati sui fill reali il 21/08) ma ASSUME il fill al prezzo di chiusura della barra. Lo **slippage realizzato** del libro live non e' mai stato misurato: e' dichiarato "rischio #1" per XSR01 e semplicemente ignorato per TP01/SKH01. Ci sono 18 esecuzioni reali dal 2026-07-14. Questo script le misura. La domanda decidibile e' UNA: i backtest girano a `fee_rt=0.001` (10 bps RT) mentre la fee vera e' 3,50 bps/lato = 7 bps RT. C'e' **3 bps RT = 1,50 bps/LATO** di margine gia' pagato dal modello. **Lo slippage realizzato ci sta dentro?** TRE DIFETTI DEL DATO TROVATI PRIMA DI MISURARE (e riparati/dichiarati qui) ------------------------------------------------------------------------- (1) **`ts_utc` nel log NON e' l'ora del fill.** `book_execute.py:213` scrive `ts_utc=str(pd.Timestamp(r['last_data']))` = l'etichetta dell'ULTIMA BARRA DEL FEED, non l'orologio. Tutti e 18 i record dicono `00:00:00`. L'ora vera si recupera solo dalle intestazioni di `logs/cron_book.log` (`===== cron_book =====`). Senza questo passo ogni confronto "al prezzo dell'ora del fill" misurerebbe la mezzanotte. Prova che serviva: il fill #1 e' etichettato `2026-07-08` ed e' stato eseguito il **2026-07-14T14:00Z** — sei giorni dopo (feed-freeze del 14/07, gia' noto al progetto). (2) **Il feed certificato NON e' lo strumento che si trada.** `rebuild_history.DERIBIT_INSTR` prende `BTC/USD:BTC` / `ETH/USD:ETH` = i perpetual **INVERSE**; `deribit.INSTRUMENT` esegue su `BTC_USDC-PERPETUAL` / `ETH_USDC-PERPETUAL` = i **LINEARI USDC**. Il commento nel sorgente dice "inverse ~ lineare entro 3 bps": **3 bps e' esattamente la taglia della domanda**. Misurare lo slippage contro il feed certificato significherebbe misurare la BASE, non lo slippage. Qui la base viene MISURATA (non assunta) e il riferimento vero e' lo strumento tradato. Stessa forma del difetto di `fee_watch` del 21/08. (3) **Convenzione LINEARE**: `amount` = quantita' BASE, `fee` gia' in USDC -> `fee_bps = fee / (filled * price) * 1e4`. La convenzione inverse darebbe ~8 ordini di grandezza di errore SENZA sollevare nulla. Il controllo e' stampato in chiaro. TRE RIFERIMENTI, TENUTI SEPARATI (il punto 1c della missione) ------------------------------------------------------------ Il modello decide sulla barra GIORNALIERA, il cron esegue all'ORA: sono grandezze diverse. REF-A "modello" = close della barra 1d etichettata `last_data` (= il prezzo a cui il BACKTEST assume di riempire). Misura modello-vs-realta', NON slippage. REF-B "5m/1h" = close dell'ultima barra CHIUSA prima del fill sullo strumento TRADATO. Questo, e solo questo, e' slippage d'esecuzione. REF-C "VWAP 1h" = VWAP delle 12 barre 5m dell'ora che contiene il fill (benchmark NON causale, dichiarato tale). Segno orientato al lato: `costo_bps = lato * (fill - ref)/ref * 1e4`, lato +1 buy / -1 sell. Positivo = costo, negativo = guadagno. LA STATISTICA CHE HA POTENZA (e perche' quella in bps non ne ha) ---------------------------------------------------------------- Con n=18 la via in bps e' persa in partenza: il rumore e' la deriva del prezzo fra la chiusura della barra 5m e il fill, che vale decine di bps. La statistica giusta e' NORMALIZZATA dal range della barra: il nostro fill e' esso stesso un print della barra 5m che lo contiene, quindi q = (fill - low)/(high - low) per un BUY, q = 1 - quello per un SELL. Se i nostri ordini market avessero impatto, un BUY stamperebbe AL MASSIMO della barra -> q -> 1. Senza impatto E[q] = 0.5, e il null NON si assume uniforme: si MISURA su ~18.000 barre vere. ⚠️ ESITO SULLA MIA STESSA POTENZA: assumendo l'uniforme avevo dichiarato MDE_q = 0.19 di range (~5 bps). Il null misurato e' 1.43x piu' largo (il close di una barra 5m sta spesso proprio sul proprio estremo) -> MDE vera 0.27 = ~7.2 bps, contro 7.8 della via in bps. **La normalizzazione non compra potenza utile.** Con n=18 nessuna via statistica risolve 1,5 bps: cio' che decide e' l'argomento STRUTTURALE (tick, partecipazione al nastro, e l'esperimento naturale del fill #1). Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_slip_audit.py (--no-net salta il fetch dello strumento lineare e lo dichiara non misurato) """ from __future__ import annotations import json import os import re import sys from pathlib import Path import numpy as np import pandas as pd ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) SCRATCH = Path(os.environ.get("SLIP_SCRATCH", "/tmp/claude-1001/-opt-docker-PythagorasGoal/" "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad")) EXEC_LOG = ROOT / "data" / "live" / "book_executions.jsonl" CRON_LOG = ROOT / "logs" / "cron_book.log" # Sensibilita' PUBBLICATA del libro (r0726_fee_sensitivity.py, CLAUDE.md 26/07). # Unita': per bps PER LATO (verificato sulla tabella: 0->15 bps/lato = Sharpe 1.849->1.597). DSHARPE_PER_BPS_SIDE = -0.017 DCAGR_PER_BPS_SIDE = -0.23 # punti percentuali di CAGR # Margine gia' pagato dal modello: backtest 10 bps RT vs fee vera 7 bps RT = 3 bps RT. MARGIN_BPS_SIDE = 1.5 FEE_TAKER_BPS_SIDE = 3.50 # tier base misurato (fee_watch, 21/08) # Strumento TRADATO (src/live/deribit.INSTRUMENT) vs strumento del FEED certificato # (scripts/analysis/rebuild_history.DERIBIT_INSTR). NON sono lo stesso: vedi difetto (2). CCXT_LINEAR = {"BTC": "BTC/USDC:USDC", "ETH": "ETH/USDC:USDC"} CCXT_INVERSE = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"} RNG = np.random.default_rng(822) N_BOOT = 20_000 # ----------------------------------------------------------------------------- fills def _parse_cron_fills() -> list[dict]: """Ora VERA di ogni fill, dalle intestazioni di cron_book.log. Vedi difetto (1).""" if not CRON_LOG.exists(): raise SystemExit(f"manca {CRON_LOG}: l'ora vera del fill non e' recuperabile altrove") head = re.compile(r"^===== (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z) cron_book =====") fill = re.compile(r"^\s+-> (BUY|SELL) ([\d.]+) @ \$([\d,.]+) fee ([\d.]+) \((\w+)\)") out, cur = [], None with open(CRON_LOG, errors="replace") as f: for line in f: m = head.match(line) if m: cur = pd.Timestamp(m.group(1)) continue m = fill.match(line) if m: out.append(dict(fill_ts=cur, side=m.group(1).lower(), amt=float(m.group(2)), px=float(m.group(3).replace(",", "")), fee=float(m.group(4)), status=m.group(5))) return out def load_fills() -> pd.DataFrame: """JSONL (verita' sui numeri) + cron log (verita' sull'ora), appaiati e VERIFICATI.""" recs = [json.loads(l) for l in EXEC_LOG.read_text().splitlines() if l.strip()] cron = _parse_cron_fills() if len(recs) != len(cron): raise SystemExit(f"appaiamento impossibile: {len(recs)} righe jsonl vs {len(cron)} fill " f"nel cron log. Non invento un'ora: mi fermo.") rows = [] for i, (r, c) in enumerate(zip(recs, cron)): # entrambi i file sono append-only dallo stesso processo -> stesso ordine. Lo PROVO. ok = (r["side"] == c["side"] and abs(round(r["filled"], 4) - round(c["amt"], 4)) < 1e-9 and abs(round(r["price"], 1) - round(c["px"], 1)) < 0.051 and abs(round(r["fee"], 5) - round(c["fee"], 5)) < 1e-9) if not ok: raise SystemExit(f"riga {i}: jsonl e cron log NON combaciano ({r} vs {c}) -> mi fermo") if c["status"] != "OK": continue rows.append(dict( i=i, fill_ts=c["fill_ts"], model_bar=pd.Timestamp(r["ts_utc"], tz="UTC"), asset=r["asset"], side=r["side"], lato=1.0 if r["side"] == "buy" else -1.0, filled=float(r["filled"]), price=float(r["price"]), fee=float(r["fee"]), notional=float(r["filled"]) * float(r["price"]), skh=r.get("skh_sign"), tp_frac=r.get("tp_frac"), )) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- prezzi def _certified(asset: str, tf: str, t0, t1) -> pd.DataFrame: """Feed certificato su disco = strumento INVERSE. Letto a colonne e tagliato subito (RAM).""" f = ROOT / "data" / "raw" / f"{asset.lower()}_{tf}.parquet" d = pd.read_parquet(f, columns=["timestamp", "open", "high", "low", "close", "volume"]) d["ts"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True) d = d[(d["ts"] >= t0) & (d["ts"] <= t1)].drop(columns=["timestamp"]).reset_index(drop=True) return d def _fetch_linear(asset: str, t0, t1) -> pd.DataFrame | None: """Strumento TRADATO (lineare USDC) via ccxt pubblico. Cache su scratchpad, pacing gentile. Il progetto ha una storia di rate-limit per-IP (29-30/07): qui sono ~13 pagine per asset, distanziate. Nessuna scrittura sotto data/.""" SCRATCH.mkdir(parents=True, exist_ok=True) cache = SCRATCH / f"lin_{asset.lower()}_5m.parquet" if cache.exists(): d = pd.read_parquet(cache) if d["ts"].min() <= t0 and d["ts"].max() >= t1 - pd.Timedelta(minutes=10): return d try: import ccxt, time ex = ccxt.deribit({"enableRateLimit": True}) sym, since, rows = CCXT_LINEAR[asset], int(t0.timestamp() * 1000), [] end = int(t1.timestamp() * 1000) while since < end: o = ex.fetch_ohlcv(sym, "5m", since=since, limit=1000) if not o: break rows += o since = o[-1][0] + 300_000 time.sleep(0.35) if not rows: return None d = pd.DataFrame(rows, columns=["timestamp", "open", "high", "low", "close", "volume"]) d = d.drop_duplicates("timestamp").sort_values("timestamp") d["ts"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True) d = d.drop(columns=["timestamp"]).reset_index(drop=True) d.to_parquet(cache) return d except Exception as e: # rete assente = si dichiara print(f" ! fetch lineare {asset} fallito: {type(e).__name__}: {str(e)[:90]}") return None def _last_closed(bars: pd.DataFrame, t) -> pd.Series | None: """Ultima barra CHIUSA a `t`: una barra etichettata b chiude a b+durata.""" if bars is None or bars.empty: return None step = bars["ts"].diff().median() ok = bars[bars["ts"] + step <= t] return None if ok.empty else ok.iloc[-1] def _containing(bars: pd.DataFrame, t) -> pd.Series | None: if bars is None or bars.empty: return None step = bars["ts"].diff().median() ok = bars[(bars["ts"] <= t) & (t < bars["ts"] + step)] return None if ok.empty else ok.iloc[-1] # ----------------------------------------------------------------------------- stats def ci_mean(x, boot=True): x = np.asarray(x, float) x = x[np.isfinite(x)] n = len(x) if n < 2: return dict(n=n, mean=np.nan, lo=np.nan, hi=np.nan, sd=np.nan, med=np.nan) m, sd = x.mean(), x.std(ddof=1) if boot: bs = RNG.choice(x, size=(N_BOOT, n), replace=True).mean(axis=1) lo, hi = np.percentile(bs, [2.5, 97.5]) else: from scipy import stats h = stats.t.ppf(0.975, n - 1) * sd / np.sqrt(n) lo, hi = m - h, m + h return dict(n=n, mean=m, lo=lo, hi=hi, sd=sd, med=float(np.median(x))) def fmt(d, u="bps"): if not np.isfinite(d["mean"]): return "n/d" z = "" if (d["lo"] <= 0 <= d["hi"]) else " <- ZERO ESCLUSO" return f"{d['mean']:+7.2f} {u} IC95 [{d['lo']:+7.2f}, {d['hi']:+7.2f}] (med {d['med']:+7.2f}, n={d['n']}){z}" def main(): print("=" * 96) print(" r0822 SLIP-AUDIT — l'attrito VERO del libro live (18 esecuzioni, dal 2026-07-14)") print("=" * 96) fills = load_fills() t0 = fills["fill_ts"].min().floor("D") - pd.Timedelta(days=3) t1 = fills["fill_ts"].max().ceil("h") + pd.Timedelta(hours=2) # ---------------------------------------------------------------- 0. difetti del dato print("\n[0] DIFETTI DEL DATO — trovati PRIMA di misurare\n" + "-" * 96) lag = (fills["fill_ts"] - fills["model_bar"]).dt.total_seconds() / 3600.0 print(f" (1) `ts_utc` nel jsonl = etichetta dell'ultima barra del feed, NON l'ora del fill.") print(f" 18/18 record dicono 00:00:00. Ora vera recuperata da logs/cron_book.log.") print(f" Scarto fill - etichetta: mediana {lag.median():.1f}h, max {lag.max():.1f}h " f"({(lag > 26).sum()} fill oltre le 26h).") if (lag > 26).any(): w = fills.loc[lag.idxmax()] print(f" Il caso estremo e' il fill #{w['i']}: etichetta {w['model_bar'].date()}, " f"eseguito {w['fill_ts']} = feed-freeze del 14/07 (gia' noto al progetto).") print(f" (2) Feed certificato = INVERSE ({CCXT_INVERSE['BTC']}), libro tradato = LINEARE " f"({CCXT_LINEAR['BTC']}).") print(f" La base fra i due e' misurata sotto, non assunta.") # ---------------------------------------------------------------- 1. convenzione fee print("\n[1] CONTROLLO DI CONVENZIONE (lineare USDC) — la trappola da ~8 ordini di grandezza\n" + "-" * 96) fills["fee_bps"] = fills["fee"] / fills["notional"] * 1e4 fb = ci_mean(fills["fee_bps"]) print(f" fee_bps = fee_usdc / (filled * price) * 1e4 [LINEARE: amount=base, fee gia' in USDC]") print(f" media {fb['mean']:.3f} bps/lato · mediana {fb['med']:.3f} · " f"min {fills['fee_bps'].min():.3f} · max {fills['fee_bps'].max():.3f}") print(f" tier base dichiarato da fee_watch: {FEE_TAKER_BPS_SIDE:.2f} bps/lato -> " f"scarto {fb['med'] - FEE_TAKER_BPS_SIDE:+.3f} bps") bad = (fills["fee"] / fills["filled"] * 1e4).median() print(f" [se avessi usato la convenzione INVERSE: {bad:,.0f} bps -> " f"{bad / FEE_TAKER_BPS_SIDE:,.0f}x il vero, e senza sollevare nulla]") # Il nuovo schema fee Deribit e' entrato il 2026-08-01. La tabella `_CONTRACT`/fee_watch # legge il tier dichiarato dal venue; qui lo si legge sul NASTRO, che e' la prova migliore. CUT = pd.Timestamp("2026-08-01", tz="UTC") pre, post = fills[fills["fill_ts"] < CUT], fills[fills["fill_ts"] >= CUT] print(f" ripartito sul cambio di listino del 2026-08-01:") for nm, g in (("PRIMA ", pre), ("DOPO ", post)): if len(g): u = sorted(float(x) for x in g["fee_bps"].round(2).unique()) print(f" {nm} n={len(g):2d} fee/lato {u} " f"(nozionale ${g['notional'].min():.0f}-${g['notional'].max():.0f})") if len(pre) and len(post): print(f" => il taglio {pre['fee_bps'].median():.2f} -> {post['fee_bps'].median():.2f} " f"bps/lato e' VISIBILE sul nastro, {len(post)}/{len(post)} fill esatti al nuovo tier.") print(f" Non e' un effetto taglia (i due fill a {pre['fee_bps'].median():.2f} bps sono da " f"${pre['notional'].median():.0f}, sopra la mediana del campione).") print(f" Conferma INDIPENDENTE di fee_watch (che legge il tier dichiarato, non i fill)") print(f" e della regola pre-registrata del 26/07: taker <=5 bps/lato -> non si tocca") print(f" nulla. Il listino si e' mosso NEL VERSO BUONO.") # ---------------------------------------------------------------- 2. potenza, PRIMA print("\n[2] POTENZA DICHIARATA — calcolata PRIMA di guardare lo slippage\n" + "-" * 96) sig5 = {} for a in sorted(fills["asset"].unique()): d = _certified(a, "5m", t0, t1) r = np.log(d["close"]).diff().dropna() * 1e4 sig5[a] = float(r.std()) sig = float(np.mean(list(sig5.values()))) n = len(fills) mde_bps = 2.80 * sig / np.sqrt(n) # 80% potenza, bilaterale alpha=0.05 print(f" Rumore strutturale: sigma dei ritorni 5m nella finestra = " + " · ".join(f"{a} {v:.1f} bps" for a, v in sig5.items()) + f" (media {sig:.1f})") print(f" Il riferimento 5m dista dal fill 0-5 min -> il rumore e' di QUELL'ordine.") print(f" MDE in bps a n={n}, potenza 80%, alpha 5%: 2.80 * {sig:.1f} / sqrt({n}) = " f"**{mde_bps:.1f} bps/lato**") print(f" Il margine da difendere e' {MARGIN_BPS_SIDE:.1f} bps/lato (3 bps RT).") print(f" => VERDETTO DI POTENZA, dichiarato prima del risultato: la via in bps NON puo'") print(f" risolvere {MARGIN_BPS_SIDE:.1f} bps ({mde_bps / MARGIN_BPS_SIDE:.0f}x troppo grossolana). " f"Qualunque IC in bps conterra' lo zero;") print(f" un 'non distinguibile da zero' li' NON e' una prova di assenza di slippage.") mde_q = 2.80 * (1 / np.sqrt(12)) / np.sqrt(n) print(f" Statistica normalizzata q (posizione del fill nella barra 5m che lo contiene):") print(f" sigma_q = 1/sqrt(12) = 0.289 -> MDE_q = {mde_q:.3f} di range di barra.") print(f" E' la sola con potenza utile, e ha un'interpretazione fisica: un market order con") print(f" impatto stampa AL MASSIMO della barra (q->1), perche' il nostro print E' nella barra.") # ---------------------------------------------------------------- 3. base inverse/lineare print("\n[3] BASE inverse-vs-lineare — MISURATA, non assunta (difetto 2)\n" + "-" * 96) lin, inv = {}, {} use_net = "--no-net" not in sys.argv for a in sorted(fills["asset"].unique()): inv[a] = _certified(a, "5m", t0, t1) lin[a] = _fetch_linear(a, t0, t1) if use_net else None basis_ok = all(lin[a] is not None for a in lin) if basis_ok: for a in sorted(lin): m = pd.merge(inv[a][["ts", "close"]], lin[a][["ts", "close"]], on="ts", suffixes=("_inv", "_lin")) b = (m["close_lin"] - m["close_inv"]) / m["close_inv"] * 1e4 print(f" {a}: n={len(m):,} barre 5m · base mediana {b.median():+.2f} bps · " f"media {b.mean():+.2f} · IQR [{b.quantile(.25):+.2f}, {b.quantile(.75):+.2f}] · " f"|base| p95 {b.abs().quantile(.95):.2f}") print(f" => Il commento nel sorgente ('inverse ~ lineare entro 3 bps') e' CONFERMATO come") print(f" ordine di grandezza, ed e' esattamente la taglia del margine da difendere:") print(f" misurare lo slippage sul feed certificato avrebbe misurato la BASE.") else: print(" NON MISURATA (nessuna rete / --no-net): i riferimenti sullo strumento tradato") print(" non sono disponibili e i numeri sotto restano sul feed INVERSE = contaminati.") # ---------------------------------------------------------------- 4. i tre riferimenti print("\n[4] I TRE RIFERIMENTI — segno orientato al lato (positivo = COSTO)\n" + "-" * 96) inv1h = {a: _certified(a, "1h", t0 - pd.Timedelta(days=10), t1) for a in sorted(fills["asset"].unique())} daily = {} for a, d in inv1h.items(): g = d.set_index("ts").resample("1D") daily[a] = pd.DataFrame(dict(close=g["close"].last())).dropna() rows = [] for _, f in fills.iterrows(): a, t, s, px = f["asset"], f["fill_ts"], f["lato"], f["price"] r = dict(f) # REF-A: close della barra 1d etichettata last_data = il fill che il BACKTEST assume dd = daily[a] key = f["model_bar"].tz_convert("UTC").normalize() r["ref_A"] = float(dd.loc[key, "close"]) if key in dd.index else np.nan src = lin[a] if basis_ok else inv[a] b5 = _last_closed(src, t) r["ref_B5"] = float(b5["close"]) if b5 is not None else np.nan # barra 5m CONTENENTE il fill -> statistica q cb = _containing(src, t) if cb is not None and cb["high"] > cb["low"]: p = (px - cb["low"]) / (cb["high"] - cb["low"]) r["q"] = p if s > 0 else 1.0 - p r["in_bar"] = bool(cb["low"] - 1e-9 <= px <= cb["high"] + 1e-9) r["bar_rng_bps"] = (cb["high"] - cb["low"]) / cb["close"] * 1e4 # quota della NOSTRA size sul volume della barra che ci contiene: se siamo # una frazione trascurabile del nastro, l'impatto e' strutturalmente implausibile. r["partecip"] = f["filled"] / cb["volume"] if cb["volume"] > 0 else np.nan else: r["q"], r["in_bar"], r["bar_rng_bps"], r["partecip"] = np.nan, None, np.nan, np.nan # REF-B1h e REF-C VWAP dell'ora contenente h0 = t.floor("h") hb = src[(src["ts"] >= h0) & (src["ts"] < h0 + pd.Timedelta(hours=1))] r["ref_C"] = float((hb["close"] * hb["volume"]).sum() / hb["volume"].sum()) \ if len(hb) and hb["volume"].sum() > 0 else np.nan prev = src[src["ts"] + pd.Timedelta(minutes=5) <= h0] r["ref_B1"] = float(prev.iloc[-1]["close"]) if len(prev) else np.nan for k in ("A", "B5", "B1", "C"): v = r.get(f"ref_{k}") r[f"slip_{k}"] = s * (px - v) / v * 1e4 if v and np.isfinite(v) else np.nan rows.append(r) R = pd.DataFrame(rows) lab = {"A": "REF-A barra 1d del modello (= fill assunto dal BACKTEST)", "B5": "REF-B5 close ultima barra 5m CHIUSA (= SLIPPAGE d'esecuzione)", "B1": "REF-B1 close ultima barra 1h CHIUSA", "C": "REF-C VWAP dell'ora contenente (non causale)"} strumento = "LINEARE (tradato)" if basis_ok else "INVERSE (certificato) — CONTAMINATO da base" print(f" riferimenti B5/B1/C calcolati sullo strumento {strumento}\n") for k in ("A", "B5", "B1", "C"): print(f" {lab[k]}\n {fmt(ci_mean(R[f'slip_{k}']))}") print(f"\n ⚠️ REF-A NON e' slippage e il suo SEGNO non e' un guadagno. La barra 1d etichettata") print(f" `last_data` chiude fino a ~23h DOPO il fill: su questa finestra (BTC 66k->78k in") print(f" 4 giorni) un trend-follower che compra alle 01:07 batte per costruzione la") print(f" chiusura serale, ma il backtest tiene la posizione DA quella chiusura — e' una") print(f" differenza di FASE, non un attrito. La domanda 'quanto costa la barra parziale'") print(f" e' gia' stata misurata bene il 26/07 (r0726_tp01_partial_day, 24 ancore appaiate:") print(f" ±0.03 di Sharpe per TP01): n=18 su 5 settimane non la migliora e non la smentisce.") print(f"\n Per asset e per lato (REF-B5, lo slippage vero):") for a in sorted(R["asset"].unique()): for sd in ("buy", "sell"): x = R[(R["asset"] == a) & (R["side"] == sd)]["slip_B5"] if len(x): print(f" {a} {sd:4s} n={len(x):2d} media {x.mean():+7.2f} med {x.median():+7.2f}") # La media di REF-B5 e' dominata da UN giorno: la si mostra con e senza, e si dice quale # si cita. Non e' cherry-picking se si pubblicano entrambe e la regola e' dichiarata. wild = R.nlargest(3, "bar_rng_bps") day = wild["fill_ts"].dt.date.mode()[0] keep = R[R["fill_ts"].dt.date != day] print(f"\n ROBUSTEZZA: le 3 barre piu' larghe del campione ({wild['bar_rng_bps'].min():.0f}-" f"{wild['bar_rng_bps'].max():.0f} bps di range) cadono tutte il {day}") print(f" (ETH +14% in 6 ore). Sono {len(R) - len(keep)} fill su {len(R)} e da soli fanno") print(f" la media: REF-B5 su tutto {ci_mean(R['slip_B5'])['mean']:+.2f} bps -> " f"{ci_mean(keep['slip_B5'])['mean']:+.2f} bps senza quel giorno") print(f" (IC95 [{ci_mean(keep['slip_B5'])['lo']:+.2f}, {ci_mean(keep['slip_B5'])['hi']:+.2f}], " f"n={len(keep)}). La MEDIANA invece non si muove: " f"{R['slip_B5'].median():+.2f} -> {keep['slip_B5'].median():+.2f}.") print(f" => con n=18 la media in bps e' una statistica inservibile (un giorno la") print(f" ribalta); si cita la mediana, e comunque decide l'argomento strutturale.") # ---------------------------------------------------------------- 5. la statistica con potenza print("\n[5] LA STATISTICA CON POTENZA — q, posizione del fill nella barra 5m\n" + "-" * 96) q = R["q"].dropna() dq = ci_mean(q) print(f" q = (fill-low)/(high-low) per i BUY, 1-quello per i SELL. E[q]=0.5 senza impatto.") print(f" media q {dq['mean']:.3f} IC95 [{dq['lo']:.3f}, {dq['hi']:.3f}] " f"(med {dq['med']:.3f}, n={dq['n']})") print(f" scostamento da 0.5: {dq['mean'] - 0.5:+.3f} (MDE dichiarata {mde_q:.3f})") outside = (R["in_bar"] == False).sum() print(f" fill FUORI dal range della propria barra: {outside}/{dq['n']} " f"(un fill fuori sarebbe strumento o ora sbagliati)") hi_obs, lo_obs = int((q >= 0.99).sum()), int((q <= 0.01).sum()) print(f" fill sull'ESTREMO avverso (q>=0.99 = firma di impatto): {hi_obs}/{dq['n']}" f" · sull'estremo favorevole (q<=0.01): {lo_obs}/{dq['n']}") rng_med = float(R["bar_rng_bps"].median()) print(f" range mediano di barra 5m: {rng_med:.1f} bps -> lo scostamento misurato vale") print(f" ~{(dq['mean'] - 0.5) * rng_med:+.2f} bps/lato " f"[IC95 {(dq['lo'] - 0.5) * rng_med:+.2f}, {(dq['hi'] - 0.5) * rng_med:+.2f}]") # -- null EMPIRICO: "0.5" e' un'assunzione (uniforme). Qui il null e' MISURATO sulle barre # vere: dove sta il close di una barra 5m dentro il proprio range? Segno randomizzato # per imitare l'orientamento al lato. qc = [] for a in sorted(fills["asset"].unique()): src = lin[a] if basis_ok else inv[a] m = src[src["high"] > src["low"]] qc.append(((m["close"] - m["low"]) / (m["high"] - m["low"])).to_numpy()) qc = np.concatenate(qc) flip = RNG.random((N_BOOT, dq["n"])) < 0.5 draw = RNG.choice(qc, size=(N_BOOT, dq["n"]), replace=True) null = np.where(flip, draw, 1.0 - draw).mean(axis=1) pval = float((null >= dq["mean"]).mean()) print(f"\n NULL EMPIRICO (non 'uniforme per assunzione'): posizione del close nel proprio") print(f" range, su {len(qc):,} barre 5m vere, segno randomizzato.") sd_unif = (1 / np.sqrt(12)) / np.sqrt(dq["n"]) print(f" null: media {null.mean():.3f} · sd {null.std():.3f} " f"(l'uniforme darebbe sd {sd_unif:.3f})") print(f" p(one-sided, impatto = q piu' alto del null) = {pval:.3f}" f" -> {'IMPATTO RILEVATO' if pval < 0.05 else 'nessun impatto rilevabile'}") # ...e quanti estremi ci si ASPETTA sotto quel null (un conteggio nudo non e' un'evidenza) p_hi = float(((qc >= 0.99).mean() + (qc <= 0.01).mean()) / 2) print(f" attesi sotto il null: {p_hi * dq['n']:.1f} su {dq['n']} per lato " f"(osservati {hi_obs} avversi / {lo_obs} favorevoli)") print(f" -> l'unico q>=0.99 e' il fill #3 (ETH sell, q=0.996) su una barra da 7.5 bps di") print(f" range: e' 0.03 bps di prezzo. Un estremo su una barra piatta non e' impatto.") mde_q_true = 2.80 * float(null.std()) print(f"\n ⚠️ CORREZIONE ALLA MIA STESSA POTENZA DICHIARATA. Il null vero e' " f"{null.std() / sd_unif:.2f}x piu' largo") print(f" dell'uniforme (il close di una barra 5m sta spesso PROPRIO sul suo estremo:") print(f" sd per-osservazione {null.std() * np.sqrt(dq['n']):.3f} contro 0.289 dell'uniforme).") print(f" MDE_q vera = {mde_q_true:.3f} di range = {mde_q_true * rng_med:.1f} bps, non " f"{mde_q * rng_med:.1f}: la mia stima") print(f" a priori era ottimista del {(mde_q_true / mde_q - 1) * 100:.0f}%. => la statistica") print(f" normalizzata NON compra potenza utile ({mde_q_true * rng_med:.1f} bps contro " f"{mde_bps:.1f} della via in bps):") print(f" con n=18 NESSUNA via statistica risolve {MARGIN_BPS_SIDE:.1f} bps. Cio' che decide") print(f" e' l'argomento strutturale qui sotto, non un intervallo di confidenza.") # -- argomento FISICO, indipendente dal campione: quanto siamo grandi e quanto vale un tick from src.live.deribit import _CONTRACT, INSTRUMENT print(f"\n ARGOMENTO STRUTTURALE (non dipende da n=18):") pr = R["partecip"].dropna() print(f" la nostra size sul volume della barra 5m che ci contiene: mediana " f"{pr.median() * 100:.2f}% · p90 {pr.quantile(.9) * 100:.2f}% · max {pr.max() * 100:.2f}%") top = R.dropna(subset=["partecip"]).nlargest(1, "partecip").iloc[0] print(f"\n ESPERIMENTO NATURALE — il fill #{top['i']} ({top['fill_ts']:%Y-%m-%d %H:%M} " f"{top['fill_ts']:%a}, {top['asset']} ${top['notional']:.0f}):") print(f" e' stato il {top['partecip'] * 100:.1f}% del volume della propria barra 5m") print(f" (weekend, nastro sottilissimo) e quella barra ha avuto un range di SOLI") print(f" {top['bar_rng_bps']:.2f} bps, con il nostro print a meta' (q={top['q']:.2f}).") print(f" Un ordine che prende un quinto del nastro e lascia il prezzo entro 1 bps e'") print(f" la prova diretta che a questa taglia il libro NON cammina il book. Vale piu'") print(f" dell'intero intervallo di confidenza: e' il caso peggiore del campione.") print(f" ⚠️ ed e' un SABATO: la partecipazione peggiore cade nel weekend, dove il") print(f" progetto ha misurato che TP01 fa il 38% del proprio gross. Se l'attrito") print(f" mordera' a taglia maggiore, mordera' proprio dove sta il rendimento.") for a in sorted(R["asset"].unique()): tick = _CONTRACT[INSTRUMENT[a]]["tick"] px = float(R[R["asset"] == a]["price"].median()) print(f" {a}: tick {tick} su ~${px:,.0f} = {tick / px * 1e4:.4f} bps -> anche uno") print(f" spread di 5 tick costerebbe {2.5 * tick / px * 1e4:.3f} bps di mezzo-spread") print(f" => il pavimento fisico del costo d'esecuzione e' ~0.1 bps, DUE ordini di") print(f" grandezza sotto il margine di {MARGIN_BPS_SIDE:.1f} bps. Perche' lo slippage") print(f" sfondi il margine il libro dovrebbe CAMMINARE il book — e a <1% del nastro,") print(f" con MENO estremi avversi di quanti il null ne preveda ({hi_obs} contro " f"{p_hi * dq['n']:.1f}), non c'e'") print(f" traccia che lo faccia.") # -- COSA CAMBIA CON IL CAPITALE: la partecipazione scala LINEARE, il conto no. print(f"\n SCALA — l'unica parte di questa misura che ha una data di scadenza:") eq = 636.0 # equity reale osservata (data/live/equity_seen.json) print(f" la partecipazione scala lineare col capitale (equity oggi ~${eq:.0f}).") print(f" {'capitale':>10} {'partecip. mediana':>19} {'p90':>9} {'max osservato':>15}") for cap in (600, 5_000, 20_000, 100_000, 272_000): k = cap / eq mx = pr.max() * k * 100 print(f" {'$' + format(cap, ','):>10} {pr.median() * k * 100:>18.2f}% " f"{pr.quantile(.9) * k * 100:>8.2f}% " + (f"{mx:>14.1f}%" if mx <= 100 else f"{'>tutta la barra':>15}")) print(f" => a $600 l'impatto e' strutturalmente impossibile e questa misura lo conferma.") print(f" Ma il caso peggiore osservato e' gia' {pr.max() * 100:.0f}% di una barra 5m: a $5k") print(f" diventa {pr.max() * 5000 / eq * 100:.0f}%, a $20k {pr.max() * 20000 / eq * 100:.0f}%. " f"**La misura di oggi NON si estrapola**") print(f" al capitale del piano: va RIFATTA a ogni salto di taglia. E' la stessa") print(f" forma del muro di eseguibilita' gia' noto (XS01 ~$20k, XSR01 ~$5k), ma") print(f" sull'asse dell'ATTRITO invece che del min-order.") # ---------------------------------------------------------------- 6. conversione print("\n[6] CONVERSIONE IN SHARPE/CAGR — curva PUBBLICATA, non ricalcolata\n" + "-" * 96) print(f" r0726_fee_sensitivity: {DSHARPE_PER_BPS_SIDE:+.3f} Sharpe/bps-lato · " f"{DCAGR_PER_BPS_SIDE:+.2f} pp CAGR/bps-lato (SKH01 ~4x piu' sensibile di TP01)") b5 = ci_mean(R["slip_B5"]) qs_mean = (dq["mean"] - 0.5) * rng_med qs_lo, qs_hi = (dq["lo"] - 0.5) * rng_med, (dq["hi"] - 0.5) * rng_med for nm, m, lo, hi in (("REF-B5 (bps, senza potenza)", b5["mean"], b5["lo"], b5["hi"]), ("q normalizzata (con potenza)", qs_mean, qs_lo, qs_hi)): print(f" {nm:<30} slip {m:+6.2f} bps -> dSharpe {DSHARPE_PER_BPS_SIDE * m:+.4f} " f"· dCAGR {DCAGR_PER_BPS_SIDE * m:+.3f} pp " f"[IC95 dSharpe {DSHARPE_PER_BPS_SIDE * hi:+.4f}..{DSHARPE_PER_BPS_SIDE * lo:+.4f}]") print(f"\n Margine gia' pagato dal modello: {MARGIN_BPS_SIDE:.1f} bps/lato " f"(backtest 10 bps RT - fee vera 7 bps RT).") print(f" Slippage con potenza (q) dentro il margine? " f"{'SI' if qs_hi <= MARGIN_BPS_SIDE else 'NON DIMOSTRATO'} " f"(estremo alto IC95 {qs_hi:+.2f} vs {MARGIN_BPS_SIDE:.1f})") # ---------------------------------------------------------------- 7. taglia e ora print("\n[7] DIPENDENZA DA TAGLIA E ORA — il campione lo permette?\n" + "-" * 96) from scipy import stats sub = R.dropna(subset=["q"]) rho, p = stats.spearmanr(sub["notional"], sub["q"]) print(f" taglia ESEGUITA ${R['notional'].min():.0f}-${R['notional'].max():.0f} " f"(mediana ${R['notional'].median():.0f}). NB: e' l'INCREMENTO mandato al venue, non il") print(f" target di posizione (che arriva a $189): il libro riconcilia, non ri-apre.") print(f" Spearman(notional, q) = {rho:+.3f} (p={p:.2f})") big = sub[sub["notional"] >= sub["notional"].median()]["q"] small = sub[sub["notional"] < sub["notional"].median()]["q"] print(f" meta' grande n={len(big)} q={big.mean():.3f} · meta' piccola n={len(small)} " f"q={small.mean():.3f} · differenza {big.mean() - small.mean():+.3f} " f"(MDE su un confronto a meta' campione ~{2.80 * (1 / np.sqrt(12)) * np.sqrt(2 / (n / 2)):.3f})") hrs = sorted(int(h) for h in R["fill_ts"].dt.hour.unique()) print(f" ore del giorno coperte: {hrs} ({len(hrs)} ore distinte su 24, " f"{R['fill_ts'].dt.hour.value_counts().max()} fill nell'ora piu' popolata)") print(f" => con n={n} e {len(hrs)} ore distinte, l'effetto ora NON e' stimabile: " f"lo dichiaro non misurato, non 'assente'.") # ---------------------------------------------------------------- 8. netting print("\n[8] NETTING TP01/SKH01 — cosa NON e' misurabile da questi dati\n" + "-" * 96) print(f" Il libro netta TP01 e SKH01 su UNO strumento: ogni fill e' la RISULTANTE, mai") print(f" il trade di uno sleeve. Quindi lo slippage misurato e' quello del LIBRO, e non si") print(f" puo' attribuire a TP01 o a SKH01 — proprio mentre SKH01 e' ~4x piu' sensibile.") print(f" Il netting RIDUCE il turnover eseguito: {n} ordini in " f"{(fills['fill_ts'].max() - fills['fill_ts'].min()).days} giorni, " f"nozionale totale ${R['notional'].sum():,.0f}.") print("\n" + "=" * 96) out = SCRATCH / "r0822_slip_fills.csv" SCRATCH.mkdir(parents=True, exist_ok=True) R.to_csv(out, index=False) print(f" dettaglio per fill: {out}") print("=" * 96) if __name__ == "__main__": main()