diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index 36bd8aa..f60e6c7 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -10,6 +10,7 @@ null de-levering superato + eseguibilita' al capitale dichiarato. | 2 | GROWTH-POLICY | **LEAD** (condizione, non data) | il libro gira al **7% di Kelly** e raccoglie il **15%** della crescita massima in log; il gradino eseguibile 1.00x->1.25-1.50x vale 14.7a -> 12.9-11.6a al muro. Ma un solo giorno -10% all'anno porta k* da ~10x a **2x**: non si decide su un conto vero con la lente close-only | | 12 | ORTHO-SCREEN | **SCARTATO 7/7** | nessuna famiglia arriva a ADDS+DSR; e il motivo e' **aritmetico**: su 168 trial il massimo atteso dal rumore e' Sharpe **1.572**, SOPRA il soffitto direzionale misurato (~1.3) -> uno screen largo su BTC/ETH direzionale **non puo'** passare un DSR, per costruzione | | 3 | XS-LITE | **SCARTATO** (come sleeve) + **1 soglia pubblicata falsificata** | concentrare XS01 non crea uno sleeve nuovo (corr 0.81-0.96 col canonico, DSR FAIL, de-levering non superato) — ma il muro *"XS01 serve ~$20k"* e' misurato su una diagnostica di TURNOVER: il ribilancio vero smette di passare sotto **~$109 di sleeve (~$730 di book), 27x piu' in basso** | +| 10 | FLOW-SQUEEZE | **SCARTATO** (2 motivi indipendenti) | meta' dell'ipotesi **non esiste nel dato** (`liquidation_*_risk` = una sola categoria in 17.229/17.229 righe); l'altra meta' e' sotto la propria soglia di potenza **dichiarata prima** (MDE 1,056% vs effetto 0,402%) ed e' comunque **morta alla nascita** (la colonna non e' ricostruibile) | ## Note che sopravvivono ai singoli filoni @@ -118,3 +119,28 @@ sta verificando i minimi veri del venue. `paper_xsr` da' **2.23**; 1.82 non e' nessuna delle due. Spiegazione piu' probabile: `data/raw` e' gitignored e il cron riscrive i parquet HL ogni notte -> **stesso codice, dati diversi** (identico allo scoperto del 07/08 su GTAA/TLT). **La decisione del 23/10 poggia su quel numero.** + +### 10 — FLOW-SQUEEZE (r0822_flow_squeeze.py, 118 trial dichiarati, griglia RIDOTTA per budget) +📌 **Sottoprodotto con potenza vera, e chiude un filone: il funding non predice ne' direzione ne' +volatilita'** — misurato su **53.430 ore / 3 anni** di funding Hyperliquid (li' la potenza c'e'): +eccesso |<=0,35%| a 24h con **segno instabile fra anni**, |ritorno| futuro piatto e non monotono. +**Il filone funding, gia' chiuso su 3 lati come livello di carry, si puo' dichiarare chiuso anche +come proxy di AFFOLLAMENTO. Quarto lato.** +- Il "segnale" migliore (Sharpe 3,25 su 90 giorni) e' **il massimo di 18 celle**: DSR **0,103** + (0,004 su 118 trial), `implausible_sharpe=True`, e su 114 giorni **il massimo atteso per caso e' + Sharpe 5,40** -> il candidato sta **sotto il proprio null**. Il 71% del P&L e' in giugno, il 60% + in **3 giorni su 114**. +- Il null "statica travestita" e' superato (sempre-short = −0,67), ma quello di **timing casuale + location-matched** e' al 99,7° pctl **che pero' e' il massimo di 18** -> P(per caso) = 0,06: + **non superato dopo il conto dei trial**. Esempio pulito di come si legge un percentile alto. +- ⚠️ **Ricostruibilita' MISURATA, non assunta:** l'OI della catena (opzioni) contro la colonna dello + snapshot (perpetual) da' **Pearson +0,03 BTC / +0,21 ETH** — non e' la stessa grandezza. +- 💡 **Azione possibile, costo quasi nullo (NON eseguita, tocca il cron di produzione):** accendere + un collettore orario dell'**OI dei perpetual Deribit** (una GET/ora, API pubblica). Non ripara + questo filone, ma da quel giorno la storia comincia; oggi ripartirebbe da zero. Il criterio di + falsificazione e' gia' scritto dall'agente: >=12 mesi, eccesso oltre la MDE (~0,3%) e presente in + >=3 trimestri su 4. +- ⚠️ **Distinzione metodologica utile** (nata contraddicendo il suggerimento del coordinatore): qui + il vincolo binding NON e' la concentrazione degli episodi (86-87 episodi corti, i primi 8 coprono + solo il 20-25% delle ore) ma la **lunghezza del calendario** (90 giorni). *Ampiezza scarsa* e + *calendario corto* sono due diagnosi diverse e si curano in modi diversi: piu' episodi vs piu' tempo. diff --git a/scripts/research/r0822_skew.py b/scripts/research/r0822_skew.py index 2c5b4e5..4885d84 100644 --- a/scripts/research/r0822_skew.py +++ b/scripts/research/r0822_skew.py @@ -68,6 +68,12 @@ def tick_of(prezzo: np.ndarray) -> np.ndarray: return np.where(np.asarray(prezzo, float) < 0.005, 0.0001, 0.0005) +def _ms(ix: pd.DatetimeIndex) -> np.ndarray: + """Epoca esplicita in millisecondi. Lezione 01/07: `.view("int64")` (e `.astype`) su un + DatetimeIndex tz-aware non-ns sbaglia scala o solleva -> merge_asof broadcasta in silenzio.""" + return pd.DatetimeIndex(ix).tz_convert("UTC").tz_localize(None).astype("datetime64[ms]").astype("int64").to_numpy() + + def rule(t: str) -> None: print("\n" + "=" * 78) print(t) @@ -142,38 +148,41 @@ def _iv_at(ad: np.ndarray, iv: np.ndarray, tgt: float) -> float: def build_smile(q: pd.DataFrame) -> pd.DataFrame: - """Per (asset, ora, scadenza): IV a |delta|=0.25 su entrambe le ali e ATM a |delta|=0.50.""" - cache = SCRATCH / "r0822_smile.parquet" - if cache.exists(): - try: - return pd.read_parquet(cache) - except Exception: - pass - rows = [] - for (asset, hr, exp), g in q.groupby(["asset", "hr", "exp"], sort=False): - c = g[g["option_type"] == "C"] - p = g[g["option_type"] == "P"] - if len(c) < 2 or len(p) < 2: - continue - c25 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.25) - p25 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.25) - if not (np.isfinite(c25) and np.isfinite(p25)): - continue - c50 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.50) - p50 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.50) - atm = np.nanmean([x for x in (c50, p50) if np.isfinite(x)]) if ( - np.isfinite(c50) or np.isfinite(p50)) else np.nan - rows.append((asset, hr, exp, float(g["dte"].iloc[0]), c25, p25, atm, - g["ts"].max(), int(len(g)))) - R = pd.DataFrame(rows, columns=["asset", "hr", "exp", "dte", "ivc25", "ivp25", - "ivatm", "ts_max", "n_gambe"]) + """Per (asset, ora, scadenza): IV a |delta|=0.25 su entrambe le ali e ATM a |delta|=0.50. + + Vettorizzato per bracketing (nessun ciclo per gruppo): per ogni chiave si prende la riga con + |delta| massimo SOTTO il bersaglio e quella con |delta| minimo SOPRA, poi si interpola fra + le due. Se una delle due manca, la cella si scarta -> non si estrapola MAI (una IV a 25 delta + ricavata estrapolando dall'ala e' un numero inventato, e sarebbe proprio dove il segnale + sembrerebbe piu' forte).""" + KEY = ["asset", "hr", "exp", "option_type"] + + def bracket(d: pd.DataFrame, tgt: float) -> pd.DataFrame: + d = d[["asset", "hr", "exp", "option_type", "ad", "iv"]] + lo = d[d["ad"] <= tgt].sort_values("ad").groupby(KEY, sort=False, observed=True).tail(1) + hi = d[d["ad"] >= tgt].sort_values("ad").groupby(KEY, sort=False, observed=True).head(1) + m = lo.merge(hi, on=KEY, suffixes=("_lo", "_hi")) + span = (m["ad_hi"] - m["ad_lo"]).to_numpy() + w = np.where(span > 1e-12, (tgt - m["ad_lo"].to_numpy()) / np.where(span > 1e-12, span, 1.0), 0.0) + m["iv_t"] = m["iv_lo"].to_numpy() * (1 - w) + m["iv_hi"].to_numpy() * w + return m[KEY + ["iv_t"]] + + # bastano le scadenze che possono ABBRACCIARE 7 o 30 giorni + d = q[q["dte"] <= 70.0] + b25 = bracket(d, 0.25).pivot_table(index=["asset", "hr", "exp"], columns="option_type", + values="iv_t", observed=True) + b50 = bracket(d, 0.50).pivot_table(index=["asset", "hr", "exp"], columns="option_type", + values="iv_t", observed=True) + R = pd.DataFrame(index=b25.index) + R["ivc25"] = b25.get("C") + R["ivp25"] = b25.get("P") + R = R.dropna(subset=["ivc25", "ivp25"]) # servono ENTRAMBE le ali, sempre + R["ivatm"] = b50.reindex(R.index)[[c for c in ("C", "P") if c in b50.columns]].mean(axis=1) + agg = d.groupby(["asset", "hr", "exp"], sort=False, observed=True).agg( + dte=("dte", "first"), ts_max=("ts", "max"), n_gambe=("iv", "size")) + R = R.join(agg, how="left").reset_index() R["rr"] = R["ivc25"] - R["ivp25"] R["bf"] = 0.5 * (R["ivc25"] + R["ivp25"]) - R["ivatm"] - try: - SCRATCH.mkdir(parents=True, exist_ok=True) - R.to_parquet(cache) - except Exception: - pass return R @@ -261,8 +270,8 @@ def daily_signal(S: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame: bpd = A.bars_per_day(df) chiusura = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) chiusura = chiusura + (pd.Timedelta("24h") - pd.Timedelta("1h") if bpd == 1 else pd.Timedelta(0)) - left = pd.DataFrame({"t_ms": chiusura.astype("datetime64[ms]").astype("int64")}) - right = pd.DataFrame({"t_ms": pd.DatetimeIndex(S["ts_max"]).astype("datetime64[ms]").astype("int64"), + left = pd.DataFrame({"t_ms": _ms(chiusura)}) + right = pd.DataFrame({"t_ms": _ms(pd.DatetimeIndex(S["ts_max"])), "rr": S["rr"].to_numpy(), "rr_n": S["rr_n"].to_numpy(), "bf": S["bf"].to_numpy(), "atm": S["atm"].to_numpy()}).sort_values("t_ms") m = pd.merge_asof(left.sort_values("t_ms"), right, on="t_ms", direction="backward",