diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index bd48073..fc3385a 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -14,6 +14,7 @@ null de-levering superato + eseguibilita' al capitale dichiarato. | 11 | VRP-QUOTE-VERE | **SCARTATO** + **1 difetto di RACCOLTA in produzione** | il campione contiene l'**astensione** della strategia (0/19 settimane passano IV-rank>0.30) e il titolo non sopravvive all'ora d'ingresso; ma soprattutto: **`collect_chain.py` raccoglie la famiglia di contratti che il conto NON puo' marginare** | | 7 | TERM-STRUCTURE | **SCARTATO** + 1 LEAD stretto (decisione 2027-02-01) | la pendenza e' un **termometro contemporaneo** (picco CCF a lag −1h/0h); il carry non paga il bid-ask (**18,6 giorni di roll-down per pagare UN round-trip**); come gate e' la **5ª occorrenza** della ridondanza col trend | | 6 | OI-PIN | **SCARTATO** | il max-pain batte uno strike casuale ma **non batte mai (0/24) la media a 7 giorni dello spot**, un livello che non usa NESSUN dato di opzioni: non e' pinning, e' reversione verso il centro recente con l'OI come stimatore rumoroso di quel centro | +| 8 | VOL-SIZE | **LEAD** (gate 2026-12-22) + 1 falsificazione | dare a SKH01 una size per-trade regge a **23/23 ancore**, 8/8 anni, null di permutazione e trasferimento su V1 — ma vale **+0,07 di Sharpe di libro, un terzo della fortuna d'ancora del libro stesso (+0,196)**. Il vol-target di **libro** e' invece falsificato: compra peso SKH gia' respinto e peggiora l'eseguibilita' | ## Note che sopravvivono ai singoli filoni @@ -241,3 +242,39 @@ recente, e l'OI e' solo uno stimatore rumoroso di quel centro. regola solo nel **14%** dei casi (5% per `jit_gex`): non era un null, era **la strategia invertita**, e usarlo fabbricava 3 celle "significative" e un contrasto spettacolare (−8,6 di Sharpe). Un null pulito sta a ~0,50; sotto ~0,35 il suo delta va letto come **~2x il segnale**. + +### 8 — VOL-SIZE (r0822_vol_size.py, 28 trial + 23 ancore) +✅ Sanity **bit-exact**: ricomposizione trade-by-trade `max|diff| = 0.0` contro `run_asset` e contro +`sleeves._skyhook_returns()`. +🚨 **DIFETTO DI METODO GENERALE, trovato dall'agente nella propria prima stesura e MISURATO invece +che cancellato: su uno sleeve a equity a GRADINO, un overlay giornaliero e' NON CAUSALE.** Tutto il +P&L di un trade SKH01 e' contabilizzato il giorno di **CHIUSURA**, fino a **3,83 giorni dopo +l'ingresso**; moltiplicare la serie giornaliera per `L_t` (che usa solo dati <= t−1) **passa +qualunque controllo di causalita' scritto sulla serie giornaliera** — il look-ahead sta nella +CONTABILITA', non nella formula. Costo misurato: **+0,04 di Sharpe hold-out fantasma**. +**REGOLA: un overlay si applica alla data d'INGRESSO, non a quella in cui il P&L viene contabilizzato.** +🚨 **REGOLA 2 — il null del de-levering e' DEGENERE quando la variante e' una ri-scalatura** +(`sh(k*base) === sh(base)`): il test che serve non e' sul DD ma **ISO-PESO sullo Sharpe**, altrimenti +si sta solo misurando di quanto e' cambiato il peso di uno sleeve. Qui era decisivo: quasi ogni +variante alzava il peso effettivo di SKH fino a 0,375, e **"alzare SKH01" e' gia' stato misurato e +respinto il 26/07**. +📌 **Il meccanismo NON e' quello ipotizzato:** scomponendo la vol trailing di uno sleeve all'88% di +zeri nei due canali, il guadagno FULL viene dalla **FREQUENZA** (il solo filtro di attivita' fa ++0,051 su +0,061) e **non dalla magnitudine** (+0,003). Non e' controllo del rischio: e' un +**filtro di frequenza**, e va chiamato cosi'. +- Baseline del libro, stima onesta (mediana banda): **1,626 FULL / 1,037 HOLD / DD 10,4%** — la + fortuna d'ancora del libro vale **+0,196 FULL / +0,448 HOLD**. +- Cella congelata del LEAD (`VTL tv20 w90`): size fissata **all'ingresso** = + `clip(0,20 / rv90(t−1), 0, 3)`. ISO dShFULL **+0,070 (23/23 ancore)**, dShHOLD +0,101 (19/23), + **8/8 anni positivi**, null di permutazione p~0,027 (e la mediana del null e' **−0,04**: + riassegnare a caso la stessa distribuzione di size **danneggia**). +- ⚠️ `DSR = 1,000` dichiarato **VACUO, non PASS**: lo passa anche il baseline — su una famiglia di + perturbazioni della stessa strategia il deflated-Sharpe non ha potenza. Onesta' rara. +- ⚠️ Punto piu' fragile, dichiarato: l'asse della finestra e' un **picco, non un plateau** + (w30 nettamente negativo, w180 ~0). +- **Gate pre-registrato — e non e' un forward** (con SE(Sharpe)~1,4 sei mesi non possono misurare + 0,07): e' un **secondo trasferimento**, la famiglia IV su SKH01_V1, dove il meccanismo dichiarato + prevede un effetto **minore** (lo stop e' gia' in ATR). **Se invece l'effetto e' uguale, la + spiegazione e' sbagliata anche se il numero regge.** Entro **2026-12-22**, prima di qualunque + modifica a peso/config di SKH01. Deploy solo se ISO dShFULL > 0 in >=20/23 ancore E + `weights_tilt_null` passa. **Book, pesi, cron, config INVARIATI.** diff --git a/scripts/research/r0822_basis_calendar.py b/scripts/research/r0822_basis_calendar.py index 2521f6e..6955f8a 100644 --- a/scripts/research/r0822_basis_calendar.py +++ b/scripts/research/r0822_basis_calendar.py @@ -383,11 +383,7 @@ def curve_frame(asset: str, wide: pd.DataFrame, meta: pd.DataFrame, contracts = list(wide.columns) tau = {c: (exps[c] - idx).total_seconds() / 3600.0 for c in contracts if c in exps} avail = {c: wide[c].notna().values for c in contracts} - for c in contracts: - if c not in exps: - continue - alive = avail[c] & (tau[c] >= roll_dte * 24) - # front = il primo (per scadenza) ancora vivo; i contratti sono ordinati per exp + # front = il primo (per scadenza) ancora vivo con dte >= roll_dte; back = il successivo order = [c for c in ok.contract if c in wide.columns] for i in range(n): picked = [] @@ -462,70 +458,89 @@ def _z(x: pd.Series, win: int) -> pd.Series: return ((x - m) / s.replace(0, np.nan)) -def run_strategy(cv: pd.DataFrame, wide: pd.DataFrame, family: str, sig: str, - win_h: int, thr: float, slip_bps: float, - fee_side: float = FEE_SIDE) -> 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. - Causalita': w deciso con dati <= t, TENUTO durante t -> t+1 (shift esplicito).""" - idx = cv.index - if family == "CAL": - base = cv["slope"] - else: # FvP: basis annualizzato del front - base = cv["c_front"] +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=idx) + w = pd.Series(1.0, index=cv.index) elif sig == "static_short": - w = pd.Series(-1.0, index=idx) - elif sig == "carry": # segno del carry corrente (causale) - w = -np.sign(base) # contango -> vendi il datato - elif sig == "zrev": + 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) - w = w.where(z.abs() >= thr, 0.0) - elif sig == "zmom": - z = _z(base, win_h) - w = np.tanh(z) + w = (-np.tanh(z)) if sig == "zrev" else np.tanh(z) w = w.where(z.abs() >= thr, 0.0) else: raise ValueError(sig) - w = pd.Series(np.asarray(w, float), index=idx).fillna(0.0).clip(-1, 1) + return np.nan_to_num(np.asarray(w, float), nan=0.0).clip(-1, 1) - # --- posizioni per CONTRATTO --- - cols = list(wide.columns) - cpos = {c: k for k, c in enumerate(cols)} - P = np.zeros((len(idx), len(cols))) - fr = cv["front"].values; bk = cv["back"].values - wv = w.values - for i in range(len(idx)): - if fr[i] == "" or bk[i] == "": - continue - if family == "CAL": # long spread = long back / short front - P[i, cpos[bk[i]]] = wv[i] - P[i, cpos[fr[i]]] = -wv[i] - else: # FvP: gamba datata; il perp e' a parte - P[i, cpos[fr[i]]] = wv[i] - Pd = pd.DataFrame(P, index=idx, columns=cols) - held = Pd.shift(1).fillna(0.0) # decisa a t, TENUTA in t->t+1 - R = wide.reindex(idx).pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) - gross = (held * R).sum(axis=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) -> 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) + + 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[1:] = P[:-1] # deciso a t, tenuto in t->t+1 + 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": - # gamba perp: nozionale opposto alla gamba datata + funding pagato/incassato - perp_r = pd.Series(cv["index"].pct_change().fillna(0.0).values, index=idx) - wheld = held.sum(axis=1) # = w shiftato (una sola gamba datata) - gross = gross - wheld * perp_r - wheld * (-1.0) * cv["f1h"].fillna(0.0) - # long datato (w>0) => short perp => INCASSA funding se f>0 - turn = (Pd.diff().abs().sum(axis=1) * 2.0) # datato + perp - else: - turn = Pd.diff().abs().sum(axis=1) + perp_r = np.nan_to_num(cv["index"].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 (gross - cost).fillna(0.0) + return pd.Series(gross - cost, index=idx).fillna(0.0) # ========================================================================== @@ -649,14 +664,18 @@ def main() -> None: 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) 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: - h = run_strategy(cvcache[(a, rl)], panels[a], fam, sig, win, th, slip_est) + 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) J = pd.concat(hs, axis=1).fillna(0.0) comb = J.mean(axis=1) # 50/50 BTC+ETH, come il resto del progetto @@ -727,8 +746,9 @@ def main() -> None: def by_off(off): hs = [] for a in ASSETS: - cv = cvcache[(a, int(b.roll))] - h = run_strategy(cv, panels[a], b.family, b.sig, int(b.win), float(b.thr), slip_est) + f_i, b_i = pidx[(a, int(b.roll))] + h = 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) hh = h.copy() mask = (hh.index.hour != off) hh[mask] = 0.0 # decide/agisce solo a quell'ora @@ -736,9 +756,13 @@ def main() -> None: return to_daily(pd.concat(hs, axis=1).fillna(0.0).mean(axis=1)) try: ab = A.anchor_luck_band(by_off, list(range(24)), canonical=0) - print(f" canonica {ab.get('canonical'):+.3f} MEDIANA(onesta) {ab.get('median'):+.3f} " - f"banda [{ab.get('p10'):+.3f},{ab.get('p90'):+.3f}] " - f"frazione>0 {ab.get('frac_positive'):.2f} gate_pass={ab.get('gate_pass')}") + 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})") @@ -754,8 +778,9 @@ def main() -> None: for sl in [0.0, 1.0, 2.5, 5.0, 7.5, 10.0, 20.0]: hs = [] for a in ASSETS: - hs.append(run_strategy(cvcache[(a, int(b.roll))], panels[a], b.family, - b.sig, int(b.win), float(b.thr), sl)) + 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(pd.concat(hs, axis=1).fillna(0.0).mean(axis=1))) 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} " diff --git a/scripts/research/r0822_hl_exec.py b/scripts/research/r0822_hl_exec.py index fa9357d..7ab27f1 100644 --- a/scripts/research/r0822_hl_exec.py +++ b/scripts/research/r0822_hl_exec.py @@ -52,7 +52,8 @@ MIN_ORDER_ASSUNTO = 5.0 # [D] pavimento DERIBIT usato da eval_weights_small MIN_ORDER_HL = 10.0 # [C] "Order must have minimum value of $10." (docs/error-responses) FEE_LEG_MODELLO = 0.0005 # [D] 0.05%/gamba, config CONGELATA di XSR01 XSR_SOGLIA_HAIRCUT = 0.40 # [D] soglia pre-registrata del gate 2026-10-23 -CAPITALI = (600.0, 1000.0, 2000.0, 3000.0, 5000.0, 10000.0, 20000.0, 50000.0) +CAPITALI = (600.0, 1000.0, 1500.0, 2000.0, 3000.0, 4000.0, 5000.0, 7500.0, + 10000.0, 20000.0, 50000.0) TICKETS = (50.0, 100.0, 300.0) ANN = np.sqrt(365.0) @@ -273,9 +274,10 @@ def sim_libro(Weff: np.ndarray, R: np.ndarray, r_hedge: np.ndarray | None, cap0: zero = move & (lots < 1) n_lot0 += int(zero.sum()) move = move & (lots >= 1) + need = np.abs(w_t - w) > 1e-12 # una gamba gia' a posto NON e' un ordine saltato w_new = np.where(move, w_t, w) - n_fill += int(move.sum()) - n_skip += int((~move).sum()) + n_fill += int((move & need).sum()) + n_skip += int(((~move) & need).sum()) d = np.abs(w_new - w) c = float((d * fee).sum()) if fee is not None else float(fee_leg) * float(d.sum()) if r_hedge is not None: # la gamba di copertura paga anch'essa