diff --git a/CLAUDE.md b/CLAUDE.md index 332458f..bb47f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1934,6 +1934,52 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis Misura che ha guidato una scelta: la banda del versamento suggerito resta **±1% da 1.200 a 3.000 percorsi** → non domina il Monte Carlo ma la **granularita' della bisezione** (~€5) → percorsi tenuti bassi e incertezza **dichiarata**. +- ❌ **SOL COME TERZA GAMBA DIREZIONALE — SCARTATO (2026-08-22).** `scripts/research/r0822_sol_leg.py`, + test `tests/test_sol_leg.py` (5), diario `2026-08-22-sol-terza-gamba.md`. **Book/pesi/universo + direzionale/cron INVARIATI.** Nato da *"hai gia' analizzato XRP/SOL/UNI?"*: si', ma sempre dentro + panieri cross-sectional su HL — **SOL e' l'unico dei tre eseguibile su Deribit** e TP01/SKH01 non + erano mai stati misurati su di lui. Ipotesi a priori registrata **prima**: diluisce (trend + multi-asset 19/06, corr 0.74). Confermata. + (1) **Dato**: storico ricostruito (`SOL/USDC:USDC`, 466.776 barre 5m dal 2022-03-15, 0 gap) e + certificato → conferma esatta del 19/06: **>1% da Coinbase nell'1.3% (2022) / 0.6% (2023) delle + barre**, med 8.1→3.9 bps dal 2022 al 2026; flat 1h 0.2% (ok), 5m **20.8%** (run 177 barre). + Da qui **due lenti dichiarate prima di misurare**: L-FULL 2022-03+ e L-PULITA 2024+. + (2) **Gambe sole** (meccanismi CONGELATI): TP01 SOL Sh 0.91 / **hold-out −0.24**; SKH01 SOL + Sh 0.51 / **maxDD 40.5%**. ⚠️ **SKH01-V2-DD fu SELEZIONATA (23/06) sul criterio maxDD<30%** + (BTC 21%, ETH 27%): su un asset nuovo **il criterio per cui la variante esiste fallisce** → + era una proprieta' dei due asset di taratura, non del meccanismo. + (3) **Book, 24 ancore, mediana delle differenze APPAIATE:** dSharpe hold-out **−0.166, >0 in + 0/24 ancore in ENTRAMBE le lenti**; L-PULITA dSharpe FULL −0.099 (0/24) e dCAGR −1.34pp (0/24); + L-FULL dSharpe FULL +0.094 (24/24) ma dCAGR +0.10pp. Canonica: 2 gambe 1.44/−9.4%/14.7% → + 3 gambe 1.48/−7.5%/13.6% (L-FULL), 1.54 → **1.30** (L-PULITA). + (4) **Il DD scende ma e' DE-LEVERING (5ª occorrenza** dopo VRP-DD, TP01×DVOL, MAT01, azioni + intere UCITS**):** a pari maxDD, L-PULITA k=0.886 → Sh **1.54 vs 1.30** e CAGR 14.5% vs 12.6%. + **L'unica lente in cui SOL aggiunge e' quella costruita sui dati che la certificazione segnala.** + (5) 📌 **E dentro quella lente il guadagno e' UN ANNO:** dSh per anno 2022 **−0.91** · 2023 + **+1.06** · 2024 −0.29 · 2025 −0.20 · 2026 −0.22 → **4 anni su 5 negativi**; la gamba SOL da sola + fa Sh −1.99 / **+2.65** / +0.45 / +0.17 / +1.46. Il 2023 e' la risalita post-FTX da ~$8 a ~$100: + un evento, non un meccanismo. Corr col book **+0.404 L-FULL / +0.561 L-PULITA** — vicina allo + 0.74 che boccio' il trend multi-asset, **e in salita man mano che il dato migliora**. + (6) **L'eseguibilita' NON e' il vincolo:** `SOL_USDC-PERPETUAL` min 0.001 SOL = **$0.09** contro + il pavimento `min_order` $5 → **primo candidato bocciato senza che il muro sia la taglia del conto.** + (7) ⚠️ **EFFETTO COLLATERALE TROVATO SU ME STESSO: il "guardrail solo dati certi" NON e' codice.** + `load_data` **non ha whitelist**: `load_data("SOL") -> FileNotFoundError` funziona solo perche' il + file non c'e'. Ricostruendo SOL in `data/raw/sol_1h.parquet` **il guardrail si e' disattivato in + silenzio**, e quel file NON viene rinfrescato dal cron (`--asset BTC ETH`) → sarebbe diventato + **dato stantio con l'aspetto di dato attivo**. Riparato con la convenzione gia' in uso + (`hl_`/`eq_`/`eqx_`/`fut_`): SOL vive in **`data/raw/alt_sol_*.parquet`**, fuori dal namespace + nudo; guardrail verificato ripristinato e congelato in due test (uno **deriva** gli asset a + rischio da `rebuild_history.DERIBIT_INSTR`, l'altro segnala se `load_data` acquisisce una + whitelist vera). ⚠️ La prima stesura del test elencava i prefissi a mano e bocciava `eqx_`, + `fut_`, `vol_term_`, `fundnews_` — namespace veri: **l'invariante si deriva dal codice, non si + elenca** (stessa lezione di `fee_watch` il giorno prima). + **REGOLE:** (a) un meccanismo si trasferisce a un asset nuovo **coi parametri congelati**, o non + e' un trasferimento ma una nuova famiglia (→ `study_family_honest` + DSR); (b) **un criterio di + SELEZIONE va riprovato sull'asset nuovo** — quello di SKH01 non sopravvive; (c) quando il + risultato dipende dalla finestra, **guardare quale finestra la CERTIFICAZIONE approva**; + (d) **un contributo positivo si scompone per ANNO prima di crederci** — 24/24 ancore positive + sembravano un plateau ed erano un anno solo; (e) **ricostruire dati per un'analisi puo' + disattivare un guardrail che vive nell'assenza di un file**: chiedersi in quale namespace atterra. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-08-22-sol-terza-gamba.md b/docs/diary/2026-08-22-sol-terza-gamba.md new file mode 100644 index 0000000..5617abd --- /dev/null +++ b/docs/diary/2026-08-22-sol-terza-gamba.md @@ -0,0 +1,141 @@ +# 2026-08-22 — SOL come terza gamba: tutto il guadagno è un anno solo, e quell'anno il dato non è buono + +**Richiesta:** dopo *"hai già provato ad analizzare utilizzo di XRP o SOL o UNI?"* — sì, ma sempre +dentro panieri cross-sectional su Hyperliquid. SOL è l'**unico dei tre eseguibile su Deribit** e +TP01/SKH01 non erano mai stati misurati su di lui. *"si fai una analisi"*. + +**Esito: SCARTATO.** L'ipotesi a priori (*diluisce*) era registrata prima di guardare, ed è confermata. + +**Toccati:** `scripts/research/r0822_sol_leg.py` (nuovo), `tests/test_sol_leg.py` (5), `CLAUDE.md`, +`data/raw/alt_sol_*.parquet` (namespace nuovo). **Book, pesi, `config/live.json`, cron: INVARIATI.** + +--- + +## 0. Il dato, prima della strategia + +Storico ricostruito da Deribit mainnet: `SOL/USDC:USDC`, **466.776 barre 5m dal 2022-03-15**, +0 gap, coerenza resample maxΔ 0.00 bps. Poi certificato — ed è la certificazione a decidere il +disegno dell'esperimento, non il contrario. + +| | med bps vs Coinbase | p95 | max | >1% | +|---|---|---|---|---| +| 2022 | 8.1 | 40.1 | **2478.6** | 1.3% | +| 2023 | 7.6 | 30.0 | 1010.3 | 0.6% | +| 2024 | 5.9 | 22.7 | 109.4 | 0.0% | +| 2025 | 4.0 | 14.4 | 269.1 | 0.0% | +| 2026 | 3.9 | 12.4 | 51.7 | 0.0% | + +Conferma esatta del verdetto del 19/06 (*«SOL 🟡 pulito da ~2024; 2022-23 rumoroso»*). Flat a 1h +**0,2%** (usabile), a 5m **20,8%** con run fino a 177 barre (~15h) — lontano da LTC (82%) ma non +è BTC/ETH (~0%). + +Da qui **due lenti dichiarate prima di misurare**: `L-FULL` (2022-03+, usa gli anni segnalati) e +`L-PULITA` (2024+, dato buono ma quasi tutto hold-out). + +## 1. Le gambe da sole (meccanismi CONGELATI, nessuna ri-ottimizzazione) + +| gamba | Sharpe | maxDD | CAGR | Sh hold-out | +|---|---|---|---|---| +| TP01 BTC | 1.13 | −16.2% | 15.2% | +0.28 | +| TP01 ETH | 1.10 | −15.2% | 15.2% | +0.62 | +| **TP01 SOL** | **0.91** | −12.7% | 9.8% | **−0.24** | +| SKH01 BTC | 1.28 | −21.4% | 33.6% | +1.49 | +| SKH01 ETH | 1.05 | −27.4% | 30.9% | +1.49 | +| **SKH01 SOL** | **0.51** | **−40.5%** | 11.3% | +1.05 | + +⚠️ **SKH01-V2-DD su SOL fa 40,5% di drawdown.** La variante «-DD» esiste *perché* fu selezionata, +nella 2ª ondata del 23/06, sul criterio **maxDD < 30%** (BTC 21%, ETH 27%). Su un asset nuovo il +criterio stesso per cui la variante è stata scelta **fallisce**. Non è un dettaglio: è la firma di +un parametro tarato su due asset e presentato come proprietà del meccanismo. + +## 2. Il livello che conta: il book + +24 ancore, **mediana delle differenze appaiate** (regola 26/07: un Δ eredita la fortuna d'ancora). + +| | L-FULL (2022-03+) | L-PULITA (2024+) | +|---|---|---| +| dSharpe FULL | **+0.094** (>0 in **24/24**) | **−0.099** (>0 in **0/24**) | +| dSharpe hold-out | **−0.166** (>0 in **0/24**) | **−0.166** (>0 in **0/24**) | +| DD più basso di | +1.435 pp (24/24) | +1.470 pp (24/24) | +| d CAGR | +0.105 pp (58%) | **−1.339 pp** (0/24) | + +All'ancora canonica: 2 gambe Sh 1.44 / DD −9.4% / CAGR 14.7% → 3 gambe Sh 1.48 / DD −7.5% / +CAGR 13.6% su L-FULL; su L-PULITA 1.54 → **1.30**. + +## 3. Il drawdown scende — ma è de-levering (5ª occorrenza) + +Aggiungere SOL abbassa DD **e** CAGR insieme. Il null codificato chiede: *lo stesso DD si compra +più a buon mercato riducendo la size?* Si cerca `k` con `maxDD(k·book_2gambe) = maxDD(book_3gambe)`: + +| lente | k | Sh 3 gambe | Sh 2 gambe × k | CAGR 3g | CAGR 2g × k | verdetto | +|---|---|---|---|---|---|---| +| L-FULL | 0.793 | 1.48 | 1.44 | 13.6% | 11.5% | SOL aggiunge | +| **L-PULITA** | 0.886 | **1.30** | **1.54** | 12.6% | 14.5% | **de-levering** | + +**L'unica lente in cui SOL aggiunge è quella costruita sui dati che la certificazione segnala.** + +## 4. E dentro quella lente, il guadagno è un anno + +| anno | 2g Sh | 3g Sh | dSh | dCAGR | +|---|---|---|---|---| +| 2022 | 1.37 | 0.46 | **−0.91** | −6.0 pp | +| **2023** | 1.34 | 2.40 | **+1.06** | **+11.1 pp** | +| 2024 | 1.65 | 1.36 | −0.29 | −5.6 pp | +| 2025 | 1.00 | 0.80 | −0.20 | −2.7 pp | +| 2026 | 2.70 | 2.48 | −0.22 | −2.9 pp | + +Gamba SOL da sola: 2022 **−1.99** · 2023 **+2.65** · 2024 +0.45 · 2025 +0.17 · 2026 +1.46. + +**4 anni su 5 negativi.** Il 2023 è l'anno in cui SOL risalì da ~$8 a ~$100 dopo il crollo FTX: un +trend-follower su quel movimento guadagna una fortuna **una volta sola**. Non è un meccanismo che +trasferisce, è un evento. + +E la correlazione col book attuale è **+0.404** (L-FULL) e **+0.561** (L-PULITA) — vicina allo +0.74 per cui il trend multi-asset fu scartato il 19/06, **e in salita** man mano che il dato +migliora. + +## 5. Ciò che non è il vincolo + +**L'eseguibilità non c'entra:** `SOL_USDC-PERPETUAL` ha min 0.001 SOL = **$0.09**, contro il +pavimento `min_order` di $5 del book. SOL sarebbe tradabile oggi a $644. È la prima volta in questo +progetto che un candidato viene bocciato **senza** che il muro sia la taglia del conto. + +## 6. ⚠️ Effetto collaterale del mio stesso lavoro: un guardrail disattivato in silenzio + +CLAUDE.md dichiara: *«`load_data("SOL", ...)` → FileNotFoundError (guardrail: solo dati certi)»*. +**Non è codice.** `load_data` non ha alcuna whitelist: solleva l'eccezione solo perché il file non +esiste. Ricostruendo lo storico in `data/raw/sol_1h.parquet` **ho disattivato il guardrail senza +che nulla lo segnalasse** — e quel file non sarebbe stato rinfrescato dal cron (`--asset BTC ETH`), +quindi sarebbe diventato dato stantio con l'aspetto di dato attivo. + +È la stessa forma dei tre difetti dei giorni scorsi: un controllo che funziona per configurazione, +non per costruzione. + +**Riparato con la convenzione che il progetto già usa** (`hl_`, `eq_`, `eqx_`, `fut_`): SOL vive in +`data/raw/alt_sol_*.parquet`, fuori dal namespace nudo. Guardrail verificato ripristinato. + +⚠️ **Errore mio nel test che doveva sorvegliarlo:** la prima stesura elencava a mano i prefissi +leciti e bocciava `eqx_`, `fut_`, `vol_term_`, `fundnews_` — namespace veri che non conoscevo. +Riscritto per **derivare** gli asset a rischio da `rebuild_history.DERIBIT_INSTR`: l'invariante non +è «quali prefissi esistono» (lista che invecchia) ma «quali asset possono finire nel namespace +nudo», che sta nel codice. Stessa lezione di `fee_watch` il giorno prima. + +## VERDETTO + +**SOL come terza gamba direzionale: SCARTATO.** Hold-out negativo a 24/24 ancore in entrambe le +lenti; sulla finestra pulita peggiora tutto; il DD migliore è de-levering; il guadagno della +finestra sporca è un anno solo; la correlazione col book sale col migliorare del dato. **Book, +pesi, universo direzionale invariati.** + +## REGOLE + +- **Un meccanismo si trasferisce a un asset nuovo con i parametri CONGELATI, o non è un + trasferimento ma una nuova famiglia** — e allora servono `study_family_honest` e deflated-Sharpe. +- **Un criterio di selezione va riprovato sull'asset nuovo:** SKH01-V2-DD fu scelta per maxDD<30% + e su SOL fa 40,5%. Il criterio era una proprietà dei due asset su cui fu tarato, non del sistema. +- **Quando un risultato dipende dalla finestra, guardare quale finestra la CERTIFICAZIONE + approva** — qui le due si contraddicono e la lente che promuove è quella non certificabile. +- **Un contributo positivo si scompone per anno prima di crederci:** +0.094 di Sharpe mediano + sembra un plateau (24/24 ancore) ed è un anno solo con quattro anni negativi intorno. +- **Ricostruire dati per un'analisi può disattivare un guardrail** che vive nell'assenza di un + file. Prima di scaricare un asset fuori universo, chiedersi in quale namespace atterra. diff --git a/scripts/research/r0822_sol_leg.py b/scripts/research/r0822_sol_leg.py new file mode 100644 index 0000000..317bceb --- /dev/null +++ b/scripts/research/r0822_sol_leg.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python +"""r0822_sol_leg.py — SOL come TERZA GAMBA direzionale del book Deribit: aggiunge o diluisce? + +DOMANDA (operatore, 2026-08-22): XRP/SOL/UNI sono gia' stati analizzati, ma sempre dentro panieri +cross-sectional su Hyperliquid. SOL e' l'UNICO dei tre eseguibile su Deribit (SOL_USDC-PERPETUAL, +min 0.001 SOL ~ $0.08, molto sotto il pavimento $5 del book) e TP01/SKH01 non sono mai stati +misurati su di lui. Questo script chiude il buco. + +DISEGNO — cio' che NON si fa qui: + * NESSUNA ri-ottimizzazione su SOL. I meccanismi sono CONGELATI (TP01 `CANONICAL`, + SKH01 `SKH01_V2_DD`). Ritarare i parametri su un asset nuovo sarebbe una nuova famiglia e + richiederebbe `study_family_honest` + deflated-Sharpe; qui la domanda e' se il meccanismo + ESISTENTE trasferisce, che e' una domanda diversa e piu' onesta. + * NESSUNA scelta di finestra a posteriori: le due lenti sono dichiarate PRIMA (sotto). + +IPOTESI A PRIORI (registrata prima di guardare): DILUISCE. Il test trend-multi-asset del +2026-06-19 trovo' corr 0.74 con TP01 -> ridondante; SOL e' un beta alto di BTC. Se il risultato +fosse "aggiunge", va guardato con piu' sospetto, non con meno. + +DUE LENTI, dichiarate prima (la certificazione le impone, non le sceglie il risultato): + L-FULL finestra comune 2022-03+ -> usa 2022-23, che la certificazione segnala come rumorosi + (>1% da Coinbase nell'1.3% / 0.6% delle barre) + L-PULITA finestra 2024+ -> SOL a med 4-6 bps da Coinbase, ma e' quasi tutta hold-out + +ANCORA: ogni numero daily e' calcolato su TUTTE E 24 le ancore e riportato come MEDIANA DELLE +DIFFERENZE APPAIATE (regola codificata 2026-07-26: un Delta fra due varianti eredita la fortuna +d'ancora come ogni altro numero, e la mediana delle differenze != differenza delle mediane). + + uv run python scripts/research/r0822_sol_leg.py [--anchors N] +""" +from __future__ import annotations + +import sys +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + +from r0702_tp01_offset import resample_offset # noqa: E402 (importato, non riscritto) +from src.backtest.harness import backtest_signals # noqa: E402 +from src.data.downloader import load_data # noqa: E402 (solo BTC/ETH: vedi _alt sotto) +from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 + +# ⚠️ SOL vive in un NAMESPACE DEDICATO `alt_sol_*.parquet`, non in `sol_*.parquet`. +# Motivo (trovato in questa sessione): il "guardrail solo dati certi" documentato in CLAUDE.md +# — `load_data("SOL", ...) -> FileNotFoundError` — non e' codice, e' l'ASSENZA del file: +# `load_data` non ha alcuna whitelist. Ricostruendo lo storico di SOL in `data/raw/sol_*.parquet` +# il guardrail si disattiva IN SILENZIO, e per giunta quel file NON viene rinfrescato dal cron +# (`cron_daily.sh` fa `--asset BTC ETH`) -> diventerebbe dato stantio che sembra attivo. +# Il namespace dedicato e' la convenzione che il progetto usa gia' per `hl_` e `eq_`. +# Per rigenerarlo: uv run python scripts/analysis/rebuild_history.py --asset SOL +# poi rinominare sol_*.parquet -> alt_sol_*.parquet +ALT_NS = "alt_" + +def load_alt(asset: str, tf: str) -> pd.DataFrame: + """Legge un asset del namespace di ricerca (fuori dal feed attivo BTC/ETH).""" + path = ROOT / "data" / "raw" / f"{ALT_NS}{asset.lower()}_{tf}.parquet" + if not path.exists(): + raise FileNotFoundError( + f"{path} assente. Rigenera con:\n" + f" uv run python scripts/analysis/rebuild_history.py --asset {asset}\n" + f" poi rinomina data/raw/{asset.lower()}_*.parquet -> {ALT_NS}{asset.lower()}_*.parquet") + df = pd.read_parquet(path) + df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True) + return df + + +def dati(asset: str, tf: str) -> pd.DataFrame: + """BTC/ETH dal feed certificato attivo; tutto il resto dal namespace di ricerca.""" + return load_data(asset, tf) if asset in ("BTC", "ETH") else load_alt(asset, tf) + + +DUE = ("BTC", "ETH") +TRE = ("BTC", "ETH", "SOL") +W_TP, W_SKH = 0.75, 0.25 # pesi del book live Deribit +PULITA = pd.Timestamp("2024-01-01", tz="UTC") # dalla certificazione, non dal risultato +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +ANNO = 365.0 + + +def sh(s: pd.Series) -> float: + s = s.dropna() + return float(s.mean() / s.std() * np.sqrt(ANNO)) if len(s) > 30 and s.std() > 0 else float("nan") + + +def maxdd(s: pd.Series) -> float: + e = (1 + s.fillna(0)).cumprod() + return float((e / e.cummax() - 1).min()) + + +def cagr(s: pd.Series) -> float: + s = s.dropna() + if len(s) < 30: + return float("nan") + return float((1 + s).prod() ** (ANNO / len(s)) - 1) + + +@lru_cache(maxsize=8) +def _h1(asset: str) -> pd.DataFrame: + return dati(asset, "1h") + + +@lru_cache(maxsize=128) +def tp01_leg(asset: str, h: int) -> pd.Series: + """TP01 CONGELATO su un asset, ancora h. Rendimenti netti giornalieri, causale.""" + df = resample_offset(_h1(asset), h) + tp = TrendPortfolio(**CANONICAL) + px = df["close"].values.astype(float) + r = np.zeros(len(px)); r[1:] = px[1:] / px[:-1] - 1.0 + tgt = tp.target_series(df) + held = np.zeros(len(tgt)); held[1:] = tgt[:-1] + net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0 + return pd.Series(np.clip(net, -0.99, None), + index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))) + + +@lru_cache(maxsize=8) +def skh_leg(asset: str) -> pd.Series: + """SKH01-V2-DD CONGELATO su un asset, riportato su griglia giornaliera (come lo sleeve).""" + ltf, htf = build_frames(dati(asset, "5m")) + ent = skyhook_entries(ltf, htf, SKH01_V2_DD) + m = backtest_signals(ltf, ent, fee_rt=0.001, leverage=1.0, asset=asset, tf="230m") + s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) + return s.resample("1D").last().ffill().pct_change().dropna() + + +def ew(series: dict) -> pd.Series: + """Equal-weight sulle date COMUNI (inner): un book a N gambe si valuta dove ci sono tutte.""" + J = pd.concat(series, axis=1, join="inner").dropna() + return J.mean(axis=1) + + +def book(assets: tuple, h: int) -> pd.Series: + tp = ew({a: tp01_leg(a, h) for a in assets}) + sk = ew({a: skh_leg(a) for a in assets}) + J = pd.concat({"tp": tp, "sk": sk}, axis=1, join="inner").fillna(0.0) + return W_TP * J["tp"] + W_SKH * J["sk"] + + +def k_iso_dd(base: pd.Series, target: pd.Series, lo: float = 0.01, hi: float = 1.0) -> float: + """Fattore k per cui `k * base` ha lo STESSO maxDD di `target`. PURA. + + E' il cuore del NULL DEL DE-LEVERING, che in questo progetto e' il primo test dovuto a ogni + claim "meno drawdown" (5a occorrenza: VRP-DD 03/07, TP01xDVOL 26/06, MAT01 25/07, azioni + intere UCITS 26/07, e SOL qui). maxDD e' monotono in k, quindi la bisezione converge. + """ + obiettivo = maxdd(target) + for _ in range(60): + k = 0.5 * (lo + hi) + if maxdd(k * base) < obiettivo: # piu' profondo del bersaglio -> ridurre la size + hi = k + else: + lo = k + return 0.5 * (lo + hi) + + +def taglia(s: pd.Series, start=None) -> pd.Series: + return s if start is None else s[s.index >= start] + + +def riga(nome: str, s: pd.Series) -> str: + return (f" {nome:<26}{len(s):>7}{sh(s):>9.2f}{maxdd(s)*100:>9.1f}%{cagr(s)*100:>9.1f}%" + f"{sh(taglia(s, HOLDOUT)):>10.2f}") + + +def main() -> None: + n_anc = 24 + if "--anchors" in sys.argv: + n_anc = int(sys.argv[sys.argv.index("--anchors") + 1]) + anchors = list(range(0, 24, max(1, 24 // n_anc)))[:n_anc] + + print("=" * 104) + print(" r0822 — SOL COME TERZA GAMBA DEL BOOK DERIBIT (meccanismi CONGELATI)") + print("=" * 104) + print(" ipotesi a priori dichiarata: DILUISCE (trend multi-asset 19/06: corr 0.74 con TP01)") + print(f" ancore valutate: {len(anchors)} pesi book TP01 {W_TP} / SKH01 {W_SKH}") + + # ---------------------------------------------------------------- 1. le gambe da sole + print("\n [1/5] LE GAMBE DA SOLE (ancora canonica h=0, finestra propria di ogni asset)") + print(f"\n {'gamba':<26}{'barre':>7}{'Sharpe':>9}{'maxDD':>10}{'CAGR':>9}{'Sh hold':>10}") + for a in TRE: + print(riga(f"TP01 {a}", tp01_leg(a, 0))) + for a in TRE: + print(riga(f"SKH01 {a}", skh_leg(a))) + + # ---------------------------------------------------------------- 2. SOL nelle due lenti + print("\n [2/5] LA GAMBA SOL NELLE DUE LENTI DICHIARATE") + solb = W_TP * tp01_leg("SOL", 0).reindex(skh_leg("SOL").index).fillna(0.0) \ + + W_SKH * skh_leg("SOL") + print(f"\n {'lente':<26}{'barre':>7}{'Sharpe':>9}{'maxDD':>10}{'CAGR':>9}{'Sh hold':>10}") + print(riga("SOL book-leg L-FULL", solb)) + print(riga("SOL book-leg L-PULITA", taglia(solb, PULITA))) + b2 = book(DUE, 0) + comune = solb.index.intersection(b2.index) + if len(comune) > 100: + c = float(np.corrcoef(solb.loc[comune], b2.loc[comune])[0, 1]) + cp = comune[comune >= PULITA] + c2 = float(np.corrcoef(solb.loc[cp], b2.loc[cp])[0, 1]) if len(cp) > 100 else float("nan") + print(f"\n corr(gamba SOL, book attuale BTC/ETH): L-FULL {c:+.3f} L-PULITA {c2:+.3f}") + print(" (riferimento: il trend multi-asset del 19/06 fu scartato a corr 0.74)") + + # ---------------------------------------------------------------- 3. book 2 vs 3 gambe + print("\n [3/5] BOOK A 2 GAMBE vs 3 GAMBE — differenze APPAIATE per ancora") + for nome, start in (("L-FULL (2022-03+)", None), ("L-PULITA (2024+)", PULITA)): + d_sh, d_dd, d_cagr, d_hold = [], [], [], [] + for h in anchors: + A, B = book(DUE, h), book(TRE, h) + idx = A.index.intersection(B.index) # stessa finestra: SOL parte dopo + A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start) + if len(A) < 200: + continue + d_sh.append(sh(B) - sh(A)); d_dd.append((maxdd(B) - maxdd(A)) * 100) + d_cagr.append((cagr(B) - cagr(A)) * 100) + d_hold.append(sh(taglia(B, HOLDOUT)) - sh(taglia(A, HOLDOUT))) + if not d_sh: + print(f"\n {nome}: campione insufficiente") + continue + print(f"\n {nome} ({len(d_sh)} ancore)") + for lab, v, u in (("dSharpe FULL", d_sh, ""), ("dSharpe hold-out", d_hold, ""), + ("DD piu' BASSO di", d_dd, " pp"), ("d CAGR", d_cagr, " pp")): + v = np.array(v, float) + pos = np.mean(v > 0) * 100 + print(f" {lab:<20} mediana {np.median(v):+7.3f}{u} " + f"banda [{np.percentile(v, 10):+.3f}, {np.percentile(v, 90):+.3f}] " + f">0 nel {pos:.0f}% delle ancore") + + # ---------------------------------------------------------------- 3-bis. per anno + print("\n [3bis] DOVE NASCE IL GUADAGNO — book 2 vs 3 gambe, anno per anno (ancora h=0)") + A0, B0 = book(DUE, 0), book(TRE, 0) + i0 = A0.index.intersection(B0.index); A0, B0 = A0.loc[i0], B0.loc[i0] + print(f"\n {'anno':<7}{'2g Sh':>8}{'3g Sh':>8}{'dSh':>8}{'2g CAGR':>10}{'3g CAGR':>10}{'dCAGR':>9}") + for y in sorted(set(A0.index.year)): + a, b = A0[A0.index.year == y], B0[B0.index.year == y] + if len(a) < 60: + continue + print(f" {y:<7}{sh(a):>8.2f}{sh(b):>8.2f}{sh(b)-sh(a):>+8.2f}" + f"{cagr(a)*100:>9.1f}%{cagr(b)*100:>9.1f}%{(cagr(b)-cagr(a))*100:>+8.1f}pp") + solleg = W_TP * tp01_leg("SOL", 0).reindex(skh_leg("SOL").index).fillna(0.0) + W_SKH * skh_leg("SOL") + print(f"\n la sola gamba SOL: " + " ".join( + f"{y} Sh {sh(solleg[solleg.index.year == y]):+.2f}" + for y in sorted(set(solleg.index.year)) if len(solleg[solleg.index.year == y]) >= 60)) + + # ---------------------------------------------------------------- 4. null de-levering + print("\n [4/5] NULL DEL DE-LEVERING — il primo test dovuto a ogni claim 'meno drawdown'") + print(" Aggiungere SOL abbassa DD *e* CAGR insieme. La domanda non e' 'ha meno DD?' ma") + print(" 'lo stesso DD si compra piu' a buon mercato semplicemente riducendo la size?'.") + print(" Si cerca k tale che maxDD(k * book_2gambe) == maxDD(book_3gambe), poi si confronta.") + print(f"\n {'lente':<20}{'k':>7}{'Sh 3gambe':>11}{'Sh 2g x k':>11}{'CAGR 3g':>10}" + f"{'CAGR 2g x k':>13}{'verdetto':>14}") + for nome, start in (("L-FULL", None), ("L-PULITA", PULITA)): + A, B = book(DUE, 0), book(TRE, 0) + idx = A.index.intersection(B.index) + A, B = taglia(A.loc[idx], start), taglia(B.loc[idx], start) + k = k_iso_dd(A, B) + sh3, sh2 = sh(B), sh(k * A) + v = "SOL AGGIUNGE" if sh3 > sh2 + 0.02 else ("de-levering" if sh2 > sh3 + 0.02 else "pari") + print(f" {nome:<20}{k:>7.3f}{sh3:>11.2f}{sh2:>11.2f}{cagr(B)*100:>9.1f}%" + f"{cagr(k*A)*100:>12.1f}%{v:>14}") + + # ---------------------------------------------------------------- 5. eseguibilita' + print("\n [5/5] ESEGUIBILITA' a $644 (equity reale del conto)") + px = float(dati("SOL", "1h")["close"].iloc[-1]) + print(f" SOL_USDC-PERPETUAL min 0.001 SOL = ${0.001*px:.3f} << pavimento min_order $5") + print(f" a 3 gambe il target per asset scende da ~1/2 a ~1/3 dell'esposizione:") + for eq in (644.0, 2000.0, 6050.0): + print(f" equity ${eq:>7,.0f}: 2 gambe ${eq*0.5*0.5:>7,.0f}/asset " + f"3 gambe ${eq*0.5/3:>7,.0f}/asset") + print("\n" + "=" * 104) + + +if __name__ == "__main__": + main() diff --git a/tests/test_sol_leg.py b/tests/test_sol_leg.py new file mode 100644 index 0000000..acd3357 --- /dev/null +++ b/tests/test_sol_leg.py @@ -0,0 +1,110 @@ +"""Test dell'analisi "SOL come terza gamba" (scripts/research/r0822_sol_leg.py, 2026-08-22). + +Congela il MECCANISMO e le DECISIONI, non i numeri: i numeri stanno nel diario e li riproduce +lo script (che richiede il namespace di ricerca `data/raw/alt_sol_*.parquet`, rigenerabile). +Un test che dipendesse da quei parquet fallirebbe su una macchina pulita — e la lezione del +2026-08-07 (`test_gtaa_band_gate` rosso perche' il cron riscriveva i dati sotto) dice di non +legare un test a dati non versionati. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) + + +# =========================================================================== +# NULL DEL DE-LEVERING — il pezzo riusabile (5a occorrenza nel progetto) +# =========================================================================== +def test_k_iso_dd_trova_il_fattore_che_pareggia_il_drawdown(): + from r0822_sol_leg import k_iso_dd, maxdd + rng = np.random.default_rng(822) + idx = pd.date_range("2022-01-01", periods=1500, freq="D", tz="UTC") + base = pd.Series(rng.normal(0.0006, 0.02, len(idx)), index=idx) + target = 0.6 * base # per costruzione k deve tornare ~0.6 + k = k_iso_dd(base, target) + assert abs(k - 0.6) < 0.02, f"k atteso ~0.60, ottenuto {k:.3f}" + assert abs(maxdd(k * base) - maxdd(target)) < 1e-4 + + +def test_il_null_smaschera_un_falso_diversificatore(): + """Controllo POSITIVO: una serie che e' solo la stessa cosa a size ridotta DEVE risultare + 'de-levering', altrimenti il null non ha potenza e assolverebbe qualunque claim.""" + from r0822_sol_leg import k_iso_dd, sh + rng = np.random.default_rng(1) + idx = pd.date_range("2022-01-01", periods=1500, freq="D", tz="UTC") + A = pd.Series(rng.normal(0.0008, 0.02, len(idx)), index=idx) + finto = 0.7 * A # nessuna informazione nuova + k = k_iso_dd(A, finto) + assert abs(sh(finto) - sh(k * A)) < 0.05, "a pari DD un de-levering puro ha lo STESSO Sharpe" + + # e un vero diversificatore (rumore indipendente) deve invece BATTERE il de-levering + B = pd.Series(rng.normal(0.0008, 0.02, len(idx)), index=idx) + vero = 0.5 * A + 0.5 * B + k2 = k_iso_dd(A, vero) + assert sh(vero) > sh(k2 * A) + 0.05, "il null deve lasciar passare un diversificatore vero" + + +# =========================================================================== +# DECISIONI congelate +# =========================================================================== +def test_l_universo_direzionale_resta_BTC_ETH(): + """SOL come terza gamba e' stato misurato il 2026-08-22 ed e' SCARTATO (diluisce). + + Se qualcuno aggiunge un asset a `sleeves.ASSETS`, questo test glielo ricorda: la domanda + e' gia' stata posta e la risposta era no. Rifarla richiede r0822_sol_leg.py, non un'opinione. + """ + from src.portfolio.sleeves import ASSETS + assert tuple(ASSETS) == ("BTC", "ETH"), ( + "universo direzionale cambiato: rifare l'analisi marginale (r0822_sol_leg.py) " + "e il null del de-levering prima di toccare il book live") + + +def test_nessun_asset_fuori_universo_nel_namespace_NUDO(): + """⚠️ Il 'guardrail solo dati certi' di CLAUDE.md — load_data('SOL') -> FileNotFoundError — + NON e' codice: `load_data` non ha whitelist, funziona solo perche' il file non c'e'. + Ricostruendo SOL in `data/raw/sol_*.parquet` il guardrail si disattiva IN SILENZIO, e quel + file non viene rinfrescato dal cron (che fa `--asset BTC ETH`) -> dato stantio che sembra + attivo. La ricerca su asset fuori universo usa un namespace prefissato (`alt_`, come + `hl_`/`eq_`/`eqx_`/`fut_`). + + ⚠️ La prima stesura di questo test elencava a mano i prefissi leciti e bocciava `eqx_`, + `fut_`, `vol_term_`, `fundnews_` — namespace veri che non conoscevo. L'invariante non e' + 'quali prefissi esistono' (lista che invecchia) ma 'quali asset `rebuild_history` puo' + scrivere nel namespace nudo', che si DERIVA dal codice. + """ + from scripts.analysis.rebuild_history import DERIBIT_INSTR + from src.data.downloader import ASSETS as ATTIVI + + raw = ROOT / "data" / "raw" + if not raw.exists(): + return + scrivibili = set(DERIBIT_INSTR) | set(ATTIVI) + intrusi = sorted( + f"{a.lower()}_{tf}.parquet" + for a in scrivibili if a not in ("BTC", "ETH") + for tf in ("5m", "15m", "1h", "1d") + if (raw / f"{a.lower()}_{tf}.parquet").exists()) + assert not intrusi, ( + f"asset fuori universo nel namespace NUDO di data/raw: {intrusi}. Li' `load_data` li " + "raggiunge (nessuna whitelist) e il cron non li rinfresca: rinominarli con un prefisso " + "di ricerca, es. alt__.parquet (vedi r0822_sol_leg.py).") + + +def test_il_guardrail_di_load_data_e_per_ASSENZA_non_per_codice(): + """Congela il FATTO, cosi' non va riscoperto: se un giorno `load_data` prendesse una + whitelist vera, questo test lo segnala e la nota qui sopra va aggiornata.""" + import inspect + + from src.data.downloader import load_data + src = inspect.getsource(load_data) + assert "FileNotFoundError" in src + assert "ASSETS" not in src, ( + "load_data sembra avere ora una whitelist: bene, ma aggiorna la nota di r0822_sol_leg.py " + "e di CLAUDE.md, che dichiarano il guardrail come 'per assenza del file'")