diff --git a/CLAUDE.md b/CLAUDE.md index 441f1e1..882ef1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -248,6 +248,34 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis (conferma indipendente del lead prevday in forward-monitor). Lezione: il timing-luck d'ancora è multiple-testing che il deflated-Sharpe NON conta (candidato gate futuro `anchor_luck_band`). Diario `2026-07-02-timing-crt-wave.md`; script `scripts/research/r0702_*.py` (9 file). +- **Ondata 2026-07-02-bis ("video claims": Elliott 3 filoni + Albimarini 2 + capital scaling) — 0 nuovi + sleeve, 0 forward-monitor, 1 azione config pendente sul deposito.** Meccanizzazione onesta di claim da + video didattici: (1) **Elliott range-cycle** (onda1 compressa→onda3 ampia): rumore, 0/24 celle a + Bonferroni, nessuna cella weekly regge a tutte le 7 ancore → SCARTATO. (2) **Confluenza Fibonacci**: + vs null ingenui sembra buona (pctl 0.82-1.00), vs null **location-matched** (Fib±jitter: "0.618 vs + 0.58?") crolla a 0.39-0.68 = l'apparente edge è la POSIZIONE dei livelli, non i numeri; confluenza + FAIL 4/4 → SCARTATO (il null location-matched è IL test per ogni claim su livelli "speciali"). + (3) **Tecnica del canale Elliott**: Donchian travestito — non batte il Donchian a pari geometria + (corr 0.43-0.53), DSR 0.685, cella in-sample collassa in hold-out (1.40→−0.87), target 1.618 = caso + (5/6 celle), anchor-luck di nuovo (4h banda hold [0.35,1.54], 00:00 la migliore) → SCARTATO. + (4) **Albimarini double-diagonal** (short T + long T+1, deep OTM, via motore DVOL di VRP01): il + condor stessa-scadenza la batte a ogni f (la long T+1 = assicurazione di coda +12/33bps nel tail, + ~1bps costo medio, non dominanza); senza gate IV-rank TUTTE le strutture perdono (3ª conferma: + l'alpha del VRP è il gate); su Deribit fee-negativa a QUALSIASI size (fee 8 gambe = 194-221% del + theta); celle deep-OTM 0-perdite/142 trade = 2° caso "Sharpe implausibile" dopo CC01 → gate + `implausible_sharpe` raccomandato con più forza. VRP01 resta superiore su tutta la banda skew → + nessun LEAD. (5) **Audit claims** (28 trade, 82% win, PF 5.16, "420%/anno"): consistente con ZERO + skill (P=20-45%; il 78.6% delle finestre 6-mesi 1996-2026 lo produce); replay con code reali = + rovina 1998/2002/2020 col sizing dichiarato; la diagonale lascia passare il 12-40% della perdita + naked. (6) **Capital scaling 600→2-5k** (`r0702_capital_scaling.py`): l'unico vincolo binding è + `max_notional_per_asset_usd=300` (a 5k il book live girerebbe al 49% del target) → **al deposito + alzare il cap a equity/2** ($1000/$1750/$2500 a 2k/3.5k/5k); min_order $5 da LASCIARE; tranching + K=2 non cablare (blocco feed intraday); opzioni ETH eseguibili da ~2.6k ma la regola no-short-vol- + da-modello non decade col capitale; XS01 ~20k confermata, CC01 fuori per struttura. Aspettativa + onesta col CAGR de-luckato (10-15%): 2k ≈ €0.6-0.8/g, 5k ≈ €1.4-2/g (€50/g resta ≈130k). + ⚠️ Lezione pandas: `resample("7D", origin=...)` IGNORA origin (pandas 2.x, solo RuntimeWarning) → + bande d'ancora weekly finte; usare `"168h"`. Diario `2026-07-02-elliott-albimarini-capital.md`; + script `scripts/research/r0702_{ell_*,alb_*,capital_scaling}.py` (6 file). - **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-07-02-elliott-albimarini-capital.md b/docs/diary/2026-07-02-elliott-albimarini-capital.md new file mode 100644 index 0000000..cfa5341 --- /dev/null +++ b/docs/diary/2026-07-02-elliott-albimarini-capital.md @@ -0,0 +1,168 @@ +# 2026-07-02 — Ondata "video claims": Elliott (3 filoni), Albimarini (2), capital scaling 2-5k + +**Contesto.** Su richiesta: meccanizzare e testare le claim falsificabili di 4 video didattici +italiani (3 sulle onde di Elliott, 1 ricostruzione della "strategia Albimarini" — double diagonal +a credito su SPX), più l'audit dei muri di scala visto l'annuncio che il conto live verrà portato +a **2.000-5.000 $**. Sei agenti indipendenti, harness altlib, fee 0.10% RT, hold-out 2025-26 mai +usato per selezionare, banda d'ancora obbligatoria (regola dello stesso giorno). Nessun PASS → +nessuno scettico necessario. Nessun file di produzione toccato. + +**Scripts:** `scripts/research/r0702_ell_{rangecycle,fibconfluence,channel}.py`, +`r0702_alb_{structure,claims}.py`, `r0702_capital_scaling.py`. + +--- + +## Verdetto in una riga + +**Elliott 3/3 SCARTATO** (range-cycle = rumore 0/24 Bonferroni; canale = Donchian travestito che +non batte il Donchian; Fibonacci = effetto posizione, non numeri magici). **Albimarini 2/2 +SCARTATO** (la diagonale non domina nemmeno nel modello ed è fee-negativa su Deribit; il track +record dichiarato è consistente con zero skill al 20-45%, e va in rovina in ogni finestra storica +con una coda). **Capital scaling: l'unico vincolo vero a 2-5k è il cap $300/asset** — da alzare a +equity/2 al momento del deposito; tutto il resto si scioglie da solo o resta fuori comunque. + +--- + +## ELL-A — "range-cycle" (onda 1 compressa → onda 3 ampia): RUMORE + +Claim di Frost&Prechter/Larry Williams meccanizzato senza conteggio soggettivo: 2 definizioni di +segmento (flip TSMOM, zigzag k·ATR causale), impulso iniziale (N∈{3,5,10} barre, range/ATR +pre-segmento) vs ampiezza del seguito (claim A) e troncatura dell'ultimo terzo (claim B). +BTC/ETH 1d+1w, Spearman con null permutato (2000), Bonferroni m=24. + +- **0/24 celle significative** (miglior p raw: A 0.061, B 0.058; Bonferroni 1.00 ovunque, FULL e + pre-2025). 7-9/12 celle col segno "giusto" = moneta su celle correlate. +- **Nessuna cella weekly regge a tutte e 7 le ancore** (p 0.02-0.05 di singole ancore = il + timing-luck d'ancora che da oggi non crediamo più; alcune celle flippano segno tra ancore). +- **Artefatto previsto e smascherato**: normalizzando col range POST-impulso la correlazione + diventa negativa per pura meccanica del denominatore — chi "conferma" il claim così misura + l'artefatto. +- Coerente con le famiglie di compressione già fallite nello sweep 104 (BRK08 NR7, BRK10 + BB-squeeze, CMB05, VOL12). Campione strutturalmente piccolo (mediana 78 segmenti daily, ~30 + weekly). Strategizzazione correttamente non eseguita (statistica prima della strategia). + +⚠️ **LEZIONE PANDAS (gemella di `view("int64")` del 2026-07-01):** `resample("7D", +origin=...)` in pandas 2.x **ignora silenziosamente `origin`** ("D" non è Tick-like, solo un +RuntimeWarning) → tutte le ancore weekly uscivano identiche e la banda d'ancora sarebbe stata una +banda finta di larghezza zero. Serve **`"168h"`**. Da ricordare per ogni futura banda d'ancora +settimanale. + +## ELL-B — Confluenza di Fibonacci: EFFETTO POSIZIONE, non numeri magici + +Zigzag causale non-repainting (livelli usabili solo dalla conferma del pivot, causality_ok +max_tail_diff=0.0), tocco fresh intrabar, reazione ATR-normalizzata a 5/20 barre, ~24k/14k tocchi +IS a 1h. **Tre null in scala di durezza**: (A) rapporti uniformi casuali, (B) ri-sorteggiati per +swing, **(C) location-matched** (Fib ± jitter 2-8%: "0.618 è speciale vs 0.58/0.66?"). + +- Vs null A/B i Fib sembrano buoni (pctl 0.82-1.00) — **vs null C crollano a 0.39-0.68 = puro + caso**. L'apparente vantaggio è interamente l'effetto di *posizione* (i rapporti Fib cadono in + zone mediamente decenti del range), non dei numeri di Fibonacci. +- **Confluenza** (2 griglie entro 0.25·ATR): FAIL 4/4 celle — non reagisce più delle confluenze + di rapporti casuali. Break-rate al tocco ~46-50% (moneta); su 1h i Fib veri vengono bucati più + spesso del placebo. +- In assoluto comunque nulla da tradare: +2.6/+5.6 bps a 5 barre (fee = 10 bps), negativo a 20 + barre e su 1d. Strategizzazione correttamente saltata da gate a priori. +- **Pattern di test riusabile**: il null location-matched è IL test giusto per ogni claim su + "livelli speciali" (Fib, pivot, round numbers) — separa la qualità della posizione dal numero. + +## ELL-C — Tecnica del canale (Ftaonline): DONCHIAN TRAVESTITO + +Implementazione onesta completa (zigzag causale, vincolo onda-2>origine, canale 0→min-2 con +parallela dal max-1, entry al close della barra fuori, target 1.618×, stop=invalidazione, +speculare short, 27 celle TF×k×varianti). + +- **family_honest boccia**: cella scelta in-sample (1h k4 long, IS 1.40) → hold-out **−0.87**; + DSR 0.685 < 0.95; earns_slot=False. Marginale vs TP01: uplift positivo solo 2020-21 = + beta-trend del toro (corr 0.35). +- **Il Donchian banale a pari geometria/frequenza eguaglia o batte il canale a ogni TF** + (corr 0.43-0.53): vincoli d'onda + 1.618 non aggiungono nulla. Il segnale-2 ("onda 5") è + quasi inerte (+0.00-0.07). +- **Target 1.618 = geometria, non magia**: toccato con la frequenza di distanze identiche + piazzate a caso (5/6 celle p>0.18). Claim discriminante "in canale = correttivo": + direzionalmente sensata, mai significativa (p 0.08-0.45); l'uscita dal canale è momentum da + breakout, già in casa. +- **Anchor-luck di nuovo**: la miglior cella 4h fa hold 1.54 all'ancora 00:00, la migliore delle + 4 (banda [0.35, 1.54]) — quarta conferma della firma nello stesso giorno. + +## ALB-A — Double diagonal "Albimarini" su BTC/ETH: NON DOMINA, niente LEAD + +Portata su Deribit (che ha le scadenze giornaliere per la long T+1) col motore DVOL di VRP01 +(riprodotto esatto: 1.09/0.59/11.8%), strike scalati in vol (2σ/5g ≈ il "9% su SPY"), gate +IV-rank canonico non riottimizzato, 2021-26 con LUNA/FTX/2022, banda skew f∈{0.6,0.8,1.0,1.3}. + +- **Il claim strutturale del corso è falsificato**: gated, l'iron condor stessa-scadenza batte la + diagonale a ogni f (1.05 vs 0.95 a f=1.0), Δ negativo ogni anno. La long T+1 è **assicurazione + di coda quantificata** (+12/+33 bps nei 5% peggiori vs ~1 bps di costo medio), non dominanza. +- **Senza gate tutte le strutture perdono** a f≤1.0 → terza conferma indipendente: nel VRP + l'alpha è il filtro di regime, non la vendita di premio. VRP01 resta superiore su tutta la + banda f (CAGR 8.3% vs 1.4%). +- **Il meccanismo dell'illusione riprodotto in vitro**: celle z≥2.5 = 0 perdite su 142 trade, + Sharpe 5.9-∞ (il punto cieco CC01 in forma opzioni); la best in-sample (7.88) crolla a −0.51 + in hold-out. Seconda occorrenza del pattern "0-perdite = Sharpe implausibile" in una settimana + → **si rafforza il caso per il gate `implausible_sharpe` in altlib** (raccomandato dal filone + CC01, ora con 2 casi d'uso). + +## ALB-B — Audit dei numeri dichiarati (28 trade, 82%, PF 5.16, "420% annuo"): ZERO SKILL + +SPY 1d 1996-2026 (parquet già in repo dal filone GTAA) + BTC/ETH certificati; MC 200k; replay +claim-anchored calibrato per riprodurre esattamente il video. + +- **L'82% è incoerente col "deep OTM" in entrambe le letture**: strike a 9% → win strutturale + 99.5% (P(|move 4td|>9%) = 0.53%, fat-tail 88× la normale); 82% letterale → strike a 2.63% ≈ + 1.2σ, non deep. In entrambi i casi win-rate = delta venduto. +- **P(track record del video senza edge) = 20-45%**; il **78.6%** di tutte le finestre 6-mesi + 1996-2026 lo avrebbe prodotto (lettura deep). Il PF ~5.1 esce per costruzione quando la coda + non è campionata. +- **Replay attraverso le code reali**: rovina nel 1998/2002/2020 col sizing 1→4 dichiarato; + perfino regalando +20% di premio, 1 contratto fisso fa CAGR +4.1% con maxDD −95%. La stessa + semestrale del video annualizza a +73-77%, non 420%. Su BTC/ETH a EV=0: +12%/DD−68% e + +0%/DD−88%. +- **La diagonale lascia passare il 12-40%** della perdita naked in un −10% overnight (dipende dal + vol-spike). Su Deribit la double diagonal è **fee-negativa a qualsiasi size** (fee 8 gambe = + 194-221% del theta raccolto a questi strike): il capitale sposta solo il muro di margine. +- Che cosa distingue VRP01 (sopravvissuto allo stesso tipo di audit): defined-risk per + costruzione, gate IV-rank che ribalta l'hold-out, sizing fisso — l'opposto esatto su tutti e + tre gli assi. + +## Capital scaling 600 → 2000/3500/5000 (r0702_capital_scaling.py) + +| capitale | TP01 haircut Sh | book investibile % (cap $300) | K=2 exec-turn % | statarb haircut | spread ETH conc. | XS01 exec-turn % | +|---|---|---|---|---|---|---| +| 600 | +0.010 | 100.0% | 90.5 | −0.004 | 0 | 56.9 | +| 2000 | −0.002 | 85.9% | 95.7 | 0.000 | 3 | 87.4 | +| 3500 | −0.002 | 63.7% | 97.5 | 0.000 | 5 | 89.3 | +| 5000 | −0.002 | **48.9%** | 98.2 | 0.000 | 7 | 90.8 | + +- **Il vincolo dominante è UNO: `max_notional_per_asset_usd`=300.** A 5k il book girerebbe a + metà del target (cap binding nel 39-46% delle barre). **Proposta config (da applicare AL + deposito): cap = equity/2** → $1000 a 2k, $1750 a 3.5k, $2500 a 5k. `min_order_usd`=5 va + LASCIATO (il fee drag risale solo al tetto noto ~0.4%/anno; l'isteresi artificiale costerebbe + più in lag — lezione ondata timing). +- Min-order/haircut TP01 ≈ 0 a ogni capitale (conferma indipendente del "−74% ordini" a 600 → + 39-43% saltati a 5k, tutti micro). Statarb: haircut 0 da 2k (il muro era l'edge, non la scala). +- Tranching K=2: matematicamente regge a 5k (98.2% turnover-tranche), ma il blocco vero resta il + feed intraday fuori path certificato + beneficio solo di varianza → **non cablare**. +- Opzioni: il muro d'ingresso ETH cade a ~2.6k (3-7 spread concorrenti a 2-5k, fee solo 5-6% del + credito grazie al cap 12.5%; rettifica vs ALB-A: margine spread ETH oggi $66-76). BTC granulare + da ~$4.7k. **La regola "niente short-vol da modello" non decade col capitale.** +- XS01: soglia ~20k confermata (a 5k il 9%+ del turnover non esegue). CC01: il capitale non è + mai stato il muro (fuori per ragioni strutturali). +- **Aspettativa onesta**: col CAGR de-luckato del book live (10-15%, audit SKH01): **2k ≈ + €0.55-0.82/giorno; 5k ≈ €1.37-2.05/giorno**. Il salto di capitale cambia cosa è eseguibile, + non l'ordine di grandezza del ritorno (€50/g ≈ 130k, invariato). + +## Lezioni + +1. **Il null giusto smonta il livello "speciale"**: location-matched (ELL-B) per i livelli, + Donchian-equivalente (ELL-C) per i pattern, EV=0-calibrato (ALB-B) per i track record. In + tutti e tre i casi il claim sopravviveva ai null ingenui e moriva su quello giusto. +2. **`resample("7D", origin=...)` ignora origin in pandas 2.x** → usare `"168h"` per le bande + d'ancora settimanali. +3. Secondo caso "0-perdite = Sharpe implausibile" (celle deep-OTM di ALB-A, dopo CC01) → il gate + `implausible_sharpe` in altlib sale in coda ai lavori insieme ad `anchor_luck_band`. +4. L'anchor-luck è comparso spontaneamente anche in questa ondata (celle weekly ELL-A, 4h ELL-C) + — la regola della banda d'ancora sta già pagando. + +**Stato:** nessun nuovo sleeve, nessun forward-monitor aggiunto, book live INVARIATO. Un'azione +operativa pende sul FUTURO deposito: alzare `max_notional_per_asset_usd` a equity/2 (decisione +utente al momento del funding). Test suite verde. 6 script committati. diff --git a/scripts/research/r0702_alb_claims.py b/scripts/research/r0702_alb_claims.py new file mode 100644 index 0000000..8fe4155 --- /dev/null +++ b/scripts/research/r0702_alb_claims.py @@ -0,0 +1,662 @@ +"""r0702_alb_claims — Audit STATISTICO delle claim "strategia Albimarini" (video didattici). + +CLAIM DICHIARATE (input, dal video): + - Struttura: double diagonal DEEP OTM su SPX/SPY (short strangle ~6DTE + long strangle + a scadenza successiva, strike piu' lontani), durata trade 3-6 giorni di calendario. + - Track record: n=28 trade, win-rate 82% (23W/5L), P&L medio +$113/trade, + perdite totali $765 (loss medio $153), profit factor 5.16, capitale ~$10k, + estrapolazione "420% annuo", sizing a compounding 1->2->3->4 contratti. + Derivati: totale +$3.164; gross win $3.929 (avg win $171); PF = 3929/765 = 5.14 ok. + +OBIETTIVO: QUANTIFICARE la critica (win-rate strutturale, coda non campionata, PF non +significativo su n=28), non riformularla. 6 test, ognuno con numeri. + +DATI: SOLO locali gia' certificati/presenti nel progetto: + - data/raw/eq_spy_1d.parquet (SPY daily 1996-2026, fetch IB del filone GTAA) + - BTC/ETH 1d via altlib (Deribit mainnet certificato) +Nessuna rete. Nessun file scritto fuori dallo scratchpad. Nessun DatetimeIndex.view. + +Convenzioni oneste: + - 6 giorni di calendario ~= 4 trading day (h=4 primario; h=2..5 come robustezza). + - Le probabilita' empiriche usano finestre rolling OVERLAPPING (stima della prob. + per-finestra); i test di significativita' usano le 28 finestre NON sovrapposte + del track record e cicli non sovrapposti nel replay. + - Il replay del Test 3 e' CLAIM-ANCHORED: usa l'economia per-trade dichiarata dal + video (massimo beneficio al claim); l'unico parametro nostro e' la severita' + della coda, calibrata in due modi (cap BS della diagonale / EV=0). + - BS senza skew nei Test 4-5: sottostima il premio degli OTM (a favore del claim). + +Uso: uv run python scripts/research/r0702_alb_claims.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import norm, t as student_t + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "research" / "alt")) +import altlib as al # noqa: E402 + +RNG = np.random.default_rng(20260702) +ROOT = Path(__file__).resolve().parents[2] + +# ---- claim dichiarate ------------------------------------------------------ +N_TRADES = 28 +WINRATE_CLAIM = 23 / 28 # 0.821 +AVG_PNL = 113.0 # $/trade +TOT_LOSS = 765.0 # $ totali (5 loss -> avg $153) +AVG_WIN = (N_TRADES * AVG_PNL + TOT_LOSS) / 23 # $171 +AVG_LOSS = TOT_LOSS / 5 # $153 +PF_CLAIM = (23 * AVG_WIN) / TOT_LOSS # 5.14 +CAPITAL = 10_000.0 +H_TD = 4 # 6 giorni calendario ~ 4 trading day + +OUT: dict = {} + + +def p(msg=""): + print(msg, flush=True) + + +# =========================================================================== +# BS helpers (no dividendi/tassi — orizzonte 6-13 giorni) +# =========================================================================== +def bs_put(S, K, T, sigma): + T = max(T, 1e-9) + d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return K * norm.cdf(-d2) - S * norm.cdf(-d1) + + +def bs_call(S, K, T, sigma): + T = max(T, 1e-9) + d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T)) + d2 = d1 - sigma * np.sqrt(T) + return S * norm.cdf(d1) - K * norm.cdf(d2) + + +# =========================================================================== +# DATA +# =========================================================================== +def load_spy() -> pd.DataFrame: + df = pd.read_parquet(ROOT / "data" / "raw" / "eq_spy_1d.parquet") + df = df.sort_values("timestamp").reset_index(drop=True) + df["datetime"] = pd.to_datetime(df["timestamp"].astype("int64"), unit="ms", utc=True) + return df + + +def hmoves(close: np.ndarray, h: int) -> np.ndarray: + return close[h:] / close[:-h] - 1.0 + + +def cal_moves(df: pd.DataFrame, days: int = 6) -> np.ndarray: + """Move su ESATTAMENTE `days` giorni di calendario (ultimo close <= t+days). + Epoca ms esplicita (mai .view su DatetimeIndex).""" + ts = df["timestamp"].to_numpy(dtype="int64") # ms + c = df["close"].to_numpy(dtype=float) + tgt = ts + days * 86_400_000 + j = np.searchsorted(ts, tgt, side="right") - 1 # ultimo close <= t+days + ok = j > np.arange(len(ts)) + return c[j[ok]] / c[ok] - 1.0 + + +# =========================================================================== +# TEST 1 — WIN-RATE STRUTTURALE +# =========================================================================== +def test1(): + p("=" * 88) + p("TEST 1 — WIN-RATE STRUTTURALE: P(move >9% in ~6 giorni) e strike impliciti dall'82%") + p("=" * 88) + spy = load_spy() + c, lo, hi = spy["close"].values, spy["low"].values, spy["high"].values + r1 = np.diff(np.log(c)) + vol_ann = r1.std() * np.sqrt(252) + + res = {} + for h in (2, 3, 4, 5): + m = hmoves(c, h) + res[h] = dict(p_abs9=float(np.mean(np.abs(m) > 0.09)), + p_dn9=float(np.mean(m < -0.09)), + sigma=float(m.std()), + p_2sig=float(np.mean(np.abs(m) > 2 * m.std()))) + m6c = cal_moves(spy, 6) + m4 = hmoves(c, H_TD) + + # touch (management intra-finestra): min low / max high nei prossimi h giorni + n = len(c) - H_TD + lo_w = np.array([lo[i + 1:i + 1 + H_TD].min() for i in range(n)]) + hi_w = np.array([hi[i + 1:i + 1 + H_TD].max() for i in range(n)]) + p_touch9 = float(np.mean((lo_w < c[:n] * 0.91) | (hi_w > c[:n] * 1.09))) + + # analitico: normale a vol 16% e alla vol campione; t-Student fittata sui move 4td + sig6_16 = 0.16 * np.sqrt(6 / 365) + p_norm16 = 2 * (1 - norm.cdf(0.09 / sig6_16)) + sig4 = res[4]["sigma"] + p_norm_emp = 2 * (1 - norm.cdf(0.09 / sig4)) + tdf, tloc, tscale = student_t.fit(m4) + p_t = float(2 * student_t.sf((0.09 - tloc) / tscale, tdf)) + + # strike implicito dall'82% di win (quantile 82% di |move|) e win-rate a 9% OTM + q82_4 = float(np.quantile(np.abs(m4), WINRATE_CLAIM)) + q82_6c = float(np.quantile(np.abs(m6c), WINRATE_CLAIM)) + win_at_9 = 1 - res[4]["p_abs9"] + + p(f"SPY 1996-2026 ({len(spy)} barre, vol ann {vol_ann:.1%})") + for h in (2, 3, 4, 5): + r = res[h] + p(f" h={h}td: P(|m|>9%)={r['p_abs9']:.4%} P(m<-9%)={r['p_dn9']:.4%} " + f"sigma={r['sigma']:.2%} P(|m|>2sig)={r['p_2sig']:.2%}") + p(f" 6 giorni CALENDARIO esatti: P(|m|>9%)={np.mean(np.abs(m6c) > 0.09):.4%} " + f"sigma={m6c.std():.2%}") + p(f" TOUCH intra-finestra (h=4td, strike a +-9%): P={p_touch9:.4%}") + p(f" Analitico: normale vol16% -> {p_norm16:.2e} | normale vol camp. -> {p_norm_emp:.2e} | " + f"t-Student fit (df={tdf:.2f}) -> {p_t:.4%} | empirico {res[4]['p_abs9']:.4%}") + p(f" -> fat-tail factor empirico/normale = {res[4]['p_abs9'] / p_norm_emp:,.0f}x") + p() + p(f" INCONSISTENZA QUANTIFICATA delle claim:") + p(f" (a) se gli strike sono DAVVERO ~9% OTM: win-rate strutturale a scadenza = " + f"{win_at_9:.2%} (>=99%), non 82% -> le 5 perdite non sono breach, sono gestione;") + p(f" (b) se il win-rate 82% e' letterale (breach a scadenza): gli strike distano " + f"q82(|m4|) = {q82_4:.2%} (6gg cal: {q82_6c:.2%}) ~= 1.2 sigma, NON 'deep OTM'.") + p(f" In entrambi i casi il win-rate e' il quantile della distribuzione (il delta " + f"venduto), non skill: qualunque venditore agli stessi strike ottiene lo stesso numero.") + + # BTC/ETH dai NOSTRI dati certificati + crypto = {} + for a in ("BTC", "ETH"): + d = al.get(a, "1d") + cc = d["close"].values + m6 = hmoves(cc, 6) + crypto[a] = dict(p_abs9=float(np.mean(np.abs(m6) > 0.09)), + sigma=float(m6.std()), + p_2sig=float(np.mean(np.abs(m6) > 2 * m6.std())), + q82=float(np.quantile(np.abs(m6), WINRATE_CLAIM)), + span=f"{d['datetime'].iloc[0].date()}->{d['datetime'].iloc[-1].date()}", + last=float(cc[-1])) + p(f" {a} (certificato, {crypto[a]['span']}): P(|m6d|>9%)={crypto[a]['p_abs9']:.1%} " + f"sigma6d={crypto[a]['sigma']:.1%} P(>2sig)={crypto[a]['p_2sig']:.1%} " + f"strike per win82% = {crypto[a]['q82']:.1%} OTM") + p(f" -> la stessa struttura trasposta su crypto: strike a 9% OTM = win-rate " + f"{1 - crypto['BTC']['p_abs9']:.0%} (BTC) / {1 - crypto['ETH']['p_abs9']:.0%} (ETH), " + f"cioe' il 9% che su SPY e' 'deep' su crypto e' ~1 sigma.") + + OUT["test1"] = dict(spy=res, spy_6cal_p9=float(np.mean(np.abs(m6c) > 0.09)), + p_touch9=p_touch9, p_norm16=float(p_norm16), + p_norm_emp=float(p_norm_emp), p_t=p_t, t_df=float(tdf), + q82_4td=q82_4, q82_6cal=q82_6c, win_at_9otm=float(win_at_9), + crypto=crypto) + return spy, m4, q82_4 + + +# =========================================================================== +# TEST 2 — SIGNIFICATIVITA' DEL PF 5.16 SU n=28 (null EV=0) +# =========================================================================== +def test2(spy: pd.DataFrame, q82: float): + p("\n" + "=" * 88) + p("TEST 2 — PF 5.16 su n=28: distribuzione sotto il null 'vendita premio a EV=0 netto'") + p("=" * 88) + c = spy["close"].values + m4 = hmoves(c, H_TD) + NSIM = 200_000 + W = 126 # 6 mesi di trading day + + def branch(name: str, thr: float) -> dict: + """Null EV=0: win +$171 (p=0.821), small loss -$153, tail loss -L con + p_tail = P(|m4|>thr) empirica; L calibrata perche' EV=0 netto.""" + p_loss = 1 - WINRATE_CLAIM + p_tail = float(np.mean(np.abs(m4) > thr)) + p_small = max(p_loss - p_tail, 0.0) + L_tail = (WINRATE_CLAIM * AVG_WIN - p_small * AVG_LOSS) / p_tail + wins = RNG.normal(AVG_WIN, 60, (NSIM, N_TRADES)).clip(20, None) + smalls = RNG.normal(AVG_LOSS, 60, (NSIM, N_TRADES)).clip(30, None) + u = RNG.random((NSIM, N_TRADES)) + is_win = u < min(WINRATE_CLAIM, 1 - p_tail) + is_tail = u > 1 - p_tail + pnl = np.where(is_win, wins, np.where(is_tail, -L_tail, -smalls)) + gp = np.where(pnl > 0, pnl, 0).sum(1) + gl = -np.where(pnl < 0, pnl, 0).sum(1) + pf = gp / np.maximum(gl, 1e-9) + wr = is_win.mean(1) + p_joint = float(np.mean((pf >= PF_CLAIM) & (wr >= WINRATE_CLAIM - 1e-9))) + p_notail = float(np.mean(~is_tail.any(1))) + tail_day = np.abs(m4) > thr + frac_clean = float(np.mean([not tail_day[i:i + W].any() + for i in range(0, len(tail_day) - W)])) + r = dict(name=name, thr=thr, p_tail=p_tail, p_small=p_small, + L_tail=float(L_tail), ev_check=float(pnl.mean()), + pf_median=float(np.median(pf)), + p_pf_ge=float(np.mean(pf >= PF_CLAIM)), p_joint=p_joint, + p_no_tail_28=p_notail, + p_no_tail_analytic=float((1 - p_tail) ** N_TRADES), + frac_6m_clean=frac_clean) + p(f" Branch {name}:") + p(f" tail = |m4| > {thr:.2%}: P(tail/trade)={p_tail:.2%} -> L_tail per EV=0 = " + f"${L_tail:,.0f} ({L_tail / CAPITAL:.0%} del capitale per unita' di size); " + f"EV MC {pnl.mean():+.1f}$/trade") + p(f" PF su 28 trade: mediana {r['pf_median']:.2f} | P(PF>=5.16)={r['p_pf_ge']:.1%} | " + f"P(PF>=5.16 E win>=82%)={p_joint:.1%}") + p(f" P(zero tail in 28 trade)={p_notail:.1%} (analitico {r['p_no_tail_analytic']:.1%}); " + f"quota storica finestre 6 mesi SENZA tail = {frac_clean:.1%}") + return r + + p(f"Null 'venditore di premio a EV=0 netto' (tanti +$171, -$153 gestite, code rare):") + b_obs = branch(f"OBS (strike {q82:.2%} OTM, breach oltre il long +2%)", q82 + 0.02) + b_deep = branch("DEEP (strike 9% OTM come dichiarato)", 0.09) + p(f" -> Il track record dichiarato (PF 5.16, win 82%, zero code) e' un esito da " + f"P={b_obs['p_joint']:.0%} (lettura OBS) a P={b_deep['p_joint']:.0%} (lettura DEEP) " + f"sotto ZERO skill: tra 'comune' e 'esito mediano'.") + p(f" Il PF senza coda campionata misura solo 23/5 * avg_win/avg_smallloss = " + f"{(23 * AVG_WIN) / (5 * AVG_LOSS):.2f}: e' un rapporto STRUTTURALE, non una statistica " + f"di edge (n=28 non tocca mai la coda che paga tutto).") + + OUT["test2"] = dict(obs=b_obs, deep=b_deep) + return b_obs["L_tail"] + + +# =========================================================================== +# TEST 3 — IL "420% ANNUO" ATTRAVERSO UNA CODA REALE (replay claim-anchored) +# =========================================================================== +def _replay(dates, moves, q82: float, width: float, L_tail: float, + win_amt: float, small_amt: float, + sizing: str = "video", start: float = CAPITAL, label: str = ""): + """Replay CLAIM-ANCHORED sulla sequenza REALE di move (cicli non sovrapposti). + PER CONTRATTO: + |m| <= q82 -> +win_amt (win, calibrato sui numeri del video) + q82 < |m| <= q82+w -> -small_amt (perdita gestita, come le 5 del video) + |m| > q82+w -> -L_tail (breach oltre il long: coda) + sizing: 'video' = +1 contratto ogni +$1000 di profitto (1->2->3->4 come + dichiarato), cap margine equity/$2500; 'prop' = leva costante iniziale + (n = equity//10k); 'fixed' = 1 contratto. Ruin se equity <= 0.""" + eq = start + path, pnl_hist, npos = [], [], [] + ruined = None + for i, m in enumerate(moves): + if abs(m) <= q82: + out = win_amt + elif abs(m) <= q82 + width: + out = -small_amt + else: + out = -L_tail + if sizing == "video": + n = int(np.clip(1 + max(eq - start, 0) // 1000, 1, max(1, eq // 2500))) + elif sizing == "prop": + n = int(max(1, eq // 10_000)) + else: + n = 1 + pnl = n * out + eq += pnl + pnl_hist.append(pnl) + npos.append(n) + path.append(eq) + if eq <= 0: + ruined = dates[i] + break + path = np.asarray(path, dtype=float) + peak = np.maximum.accumulate(np.maximum(path, 1e-9)) + maxdd = float((path / peak - 1.0).min()) if len(path) else 0.0 + last_i = len(path) - 1 + yrs = max(1e-9, (pd.Timestamp(dates[last_i]) - pd.Timestamp(dates[0])).days / 365.25) + cagr = float((max(path[-1], 1e-9) / start) ** (1 / yrs) - 1) if len(path) else 0.0 + wi = int(np.argmin(pnl_hist)) if pnl_hist else 0 + return dict(label=label, final=float(path[-1]) if len(path) else start, + cagr=cagr, maxdd=maxdd, + ruined=str(pd.Timestamp(ruined).date()) if ruined is not None else None, + worst_cycle=float(min(pnl_hist)) if pnl_hist else 0.0, + worst_date=str(pd.Timestamp(dates[wi]).date()) if pnl_hist else None, + n_at_worst=int(npos[wi]) if npos else 0, + n_cycles=len(pnl_hist), + n_tails=int(np.sum(np.abs(np.asarray(moves[:len(pnl_hist)])) > q82 + width)), + avg_pnl_cycle=float(np.mean(pnl_hist)) if pnl_hist else 0.0, + winrate=float(np.mean(np.asarray(pnl_hist) > 0)) if pnl_hist else 0.0, + years=float(yrs)) + + +def _cycles(df: pd.DataFrame, h: int): + c = df["close"].values + idx = np.arange(0, len(c) - h, h) + moves = c[idx + h] / c[idx] - 1.0 + dates = df["datetime"].values[idx + h] + return dates, moves + + +def _calib_win_per_contract() -> float: + """Calibra il win PER CONTRATTO cosi' che 28 cicli TUTTI win con il sizing + video (+1 contratto/$1000, 1->2->3->4) riproducano il totale dichiarato + (+$3.164): i +$171/+$113 del video sono medie a livello CONTO su 1-4 + contratti, non per contratto.""" + lo, hi = 10.0, 171.0 + for _ in range(60): + w = 0.5 * (lo + hi) + eq, tot = CAPITAL, 0.0 + for _k in range(N_TRADES): + n = int(np.clip(1 + max(eq - CAPITAL, 0) // 1000, 1, max(1, eq // 2500))) + eq += n * w + tot += n * w + if tot > N_TRADES * AVG_PNL: + hi = w + else: + lo = w + return round(0.5 * (lo + hi), 1) + + +def test3(spy: pd.DataFrame, q82: float): + p("\n" + "=" * 88) + p("TEST 3 — '420% ANNUO': la stessa strategia attraverso le code reali, con compounding") + p("=" * 88) + # Replay CLAIM-ANCHORED: economia PER CONTRATTO calibrata sui numeri del video, + # guidata dalla sequenza REALE dei move ~6gg. Unico parametro nostro: la + # severita' della coda per contratto, boxata da due letture convergenti: + # cap fisico = width 2% x notional $60k = $1.200 (cap della diagonale, Test 4) + # EV=0 = la severita' che rende il gioco fair alle p empiriche + w_c = _calib_win_per_contract() + s_c = round(w_c * AVG_LOSS / AVG_WIN, 1) + m4 = hmoves(spy["close"].values, H_TD) + p_tail = float(np.mean(np.abs(m4) > q82 + 0.02)) + p_small = max(1 - WINRATE_CLAIM - p_tail, 0.0) + L_ev0 = round((WINRATE_CLAIM * w_c - p_small * s_c) / p_tail, 0) + L_bs = 1200.0 + p(f"Replay claim-anchored (cicli 4td non sovrapposti, strike q82={q82:.2%}, long +2%):") + p(f"PER CONTRATTO: win +${w_c} (|m|<=q82), gestita -${s_c} (<=q82+2%), coda -$L") + p(f"(|m|>q82+2%). Calibrazione: 28 cicli senza coda col sizing video 1->4 = +$3.164") + p(f"(riproduce il video PER COSTRUZIONE: win 82%, PF 5.1). Severita' coda boxata:") + p(f"cap fisico width2%x$60k = ${L_bs:,.0f} | EV=0 = ${L_ev0:,.0f} (convergenti).") + p(f"Sizing 'video' = +1 contratto/$1000 profitto (cap eq/$2500); 'prop' = eq//10k.") + windows = [("FULL 1996-2026", None, None), + ("2019-2026 (era book)", "2019-01-01", None), + ("H2-2023 (finestra tipo video)", "2023-07-01", "2023-12-31"), + ("2020 (COVID)", "2020-01-01", "2020-12-31"), + ("2022 (bear)", "2022-01-01", "2022-12-31"), + ("2008 (GFC)", "2008-01-01", "2008-12-31")] + rows = [] + for sizing, L, tag in (("video", L_bs, "video, cap fisico $1.200"), + ("video", L_ev0, f"video, EV=0 ${L_ev0:,.0f}"), + ("prop", L_bs, "leva costante, cap fisico")): + p(f"\n -- sizing {tag} --") + for label, a, b in windows: + d = spy + if a: + d = d[d["datetime"] >= pd.Timestamp(a, tz="UTC")] + if b: + d = d[d["datetime"] <= pd.Timestamp(b, tz="UTC")] + dates, moves = _cycles(d.reset_index(drop=True), H_TD) + out = _replay(dates, moves, q82, 0.02, L, w_c, s_c, sizing=sizing, + label=f"{label} [{tag}]") + rows.append(out) + ann = f"{out['cagr']:+.0%}" if not out["ruined"] else "RUIN" + p(f" {label:30s} finale ${out['final']:>8,.0f} CAGR {ann:>7s} " + f"maxDD {out['maxdd']:>5.0%} win {out['winrate']:.0%} " + f"code {out['n_tails']:>2d} worst ${out['worst_cycle']:>7,.0f} " + f"({out['worst_date']}, {out['n_at_worst']}c)" + + (f" ** ROVINA {out['ruined']} **" if out["ruined"] else "")) + # controllo: size FISSA 1 contratto, e scenario 'premio generoso' (+20% sui win) + dates, moves = _cycles(spy, H_TD) + fx = _replay(dates, moves, q82, 0.02, L_ev0, w_c, s_c, sizing="fixed", + label="FULL fixed-1c EV=0") + gen_fx = _replay(dates, moves, q82, 0.02, L_ev0, w_c * 1.2, s_c, sizing="fixed", + label="FULL fixed-1c premio+20%") + gen = _replay(dates, moves, q82, 0.02, L_ev0, w_c * 1.2, s_c, sizing="video", + label="FULL video premio+20%") + p(f"\n Controlli (FULL 1996-2026, coda EV=0):") + p(f" - size FISSA 1 contratto: finale ${fx['final']:>8,.0f} CAGR {fx['cagr']:+.1%} " + f"maxDD {fx['maxdd']:.0%}" + (f" ROVINA {fx['ruined']}" if fx['ruined'] else "")) + p(f" - fixed-1c, premio +20%: finale ${gen_fx['final']:>8,.0f} CAGR " + f"{gen_fx['cagr']:+.1%} maxDD {gen_fx['maxdd']:.0%}" + + (f" ROVINA {gen_fx['ruined']}" if gen_fx['ruined'] else "")) + p(f" - sizing video, premio +20%: finale ${gen['final']:>8,.0f} CAGR " + f"{gen['cagr']:+.1%} maxDD {gen['maxdd']:.0%}" + + (f" ROVINA {gen['ruined']}" if gen['ruined'] else "")) + p(f" -> anche regalando un VRP persistente del +20%, il CAGR onesto multi-anno e'") + p(f" a una cifra, NON 420%; e il sizing del video lo converte comunque in rovina") + p(f" (scala dopo i win -> la coda colpisce sempre vicino alla size massima).") + rows += [fx, gen_fx, gen] + + # BTC/ETH dai NOSTRI dati: stesso 82% strutturale (q82 proprio), width vol-scalata, + # severita' EV=0 ricalibrata sulla p_tail dell'asset. + p() + for aname in ("BTC", "ETH"): + d = al.get(aname, "1d") + m6 = hmoves(d["close"].values, 6) + q82a = float(np.quantile(np.abs(m6), WINRATE_CLAIM)) + wa = float(0.9 * m6.std()) + p_tail_a = float(np.mean(np.abs(m6) > q82a + wa)) + La = round((WINRATE_CLAIM * w_c + - max(1 - WINRATE_CLAIM - p_tail_a, 0) * s_c) / p_tail_a, 0) + dates_a, moves_a = _cycles(d, 6) + out = _replay(dates_a, moves_a, q82a, wa, La, w_c, s_c, sizing="video", + label=f"{aname} strike {q82a:.0%} width {wa:.0%} EV=0") + rows.append(out) + ann = f"{out['cagr']:+.0%}" if not out["ruined"] else "RUIN" + p(f" {out['label']:38s} P(coda)={p_tail_a:.1%} L=${La:,.0f} -> finale " + f"${out['final']:>8,.0f} CAGR {ann:>6s} maxDD {out['maxdd']:>5.0%} " + f"code {out['n_tails']}" + + (f" ** ROVINA {out['ruined']} **" if out["ruined"] else "")) + p() + p(" -> Il '420%' e' l'annualizzazione della finestra senza coda con il sizing gia'") + p(" scalato. Sulla storia intera, con la STESSA economia dichiarata dal video e") + p(" una coda fair, il compounding del video incontra la coda alla size massima.") + OUT["test3"] = dict(win_per_contract=w_c, small_per_contract=s_c, + L_bs=L_bs, L_ev0=L_ev0, p_tail=p_tail, rows=rows) + + +# =========================================================================== +# TEST 4 — QUANTO COPRE LA DIAGONALE IL GIORNO DEL CRASH +# =========================================================================== +def test4(q82: float): + p("\n" + "=" * 88) + p("TEST 4 — DIAGONALE COME PROTEZIONE: loss_con_diagonale / loss_naked (BS, gap+vol spike)") + p("=" * 88) + S0 = 100.0 + iv0 = 0.16 + + def scenario(d_short, width, gap, iv1): + """Ritorna (loss_naked, loss_diag, ratio) in frazione di S0 (positivi=perdita).""" + Kp_s = S0 * (1 - d_short) + Kp_l = S0 * (1 - d_short - width) + v_s0 = bs_put(S0, Kp_s, 6 / 365, iv0) + v_l0 = bs_put(S0, Kp_l, 13 / 365, iv0) + S1 = S0 * (1 + gap) + v_s1 = bs_put(S1, Kp_s, 5 / 365, iv1) + v_l1 = bs_put(S1, Kp_l, 12 / 365, iv1) + loss_naked = float(v_s1 - v_s0) # mark contro lo short + loss_diag = float((v_s1 - v_s0) - (v_l1 - v_l0)) # long compensa + ratio = loss_diag / loss_naked if loss_naked > 1e-9 else np.nan + return loss_naked, loss_diag, ratio + + rows = [] + p(f"Struttura put-side branch OBS: short K={100 * (1 - q82):.1f} T=6g, long T=13g piu' OTM.") + p(f"Scenario: gap overnight (resta 1g di vita in meno), IV 16% -> IV_crash. Perdite % notional.") + p(f" {'gap':>5s} {'IV->':>5s} {'width':>6s} | {'naked':>7s} {'diag':>7s} {'ratio':>6s} " + f"{'equity hit @6x':>14s}") + for width in (0.02, 0.03): + for gap in (-0.05, -0.10, -0.15): + for iv1 in (0.16, 0.40, 0.50, 0.70): + ln, ld, ratio = scenario(q82, width, gap, iv1) + rows.append(dict(d=q82, width=width, gap=gap, iv1=iv1, + naked=ln, diag=ld, ratio=ratio)) + p(f" {gap:>5.0%} {iv1:>5.0%} {width:>6.0%} | {ln / S0:>7.2%} " + f"{ld / S0:>7.2%} {ratio:>6.0%} {6 * ld / S0:>13.1%}") + # branch DEEP (9% OTM come dichiarato), width 3% + deep = [] + for gap in (-0.10, -0.15): + for iv1 in (0.40, 0.50, 0.70): + ln, ld, ratio = scenario(0.09, 0.03, gap, iv1) + deep.append(dict(d=0.09, width=0.03, gap=gap, iv1=iv1, + naked=ln, diag=ld, ratio=ratio)) + p() + p(" Branch DEEP (short 9% OTM, long 12% OTM, width 3%):") + for r in deep: + p(f" {r['gap']:>5.0%} IV->{r['iv1']:.0%} | naked {r['naked'] / S0:>6.2%} " + f"diag {r['diag'] / S0:>6.2%} ratio {r['ratio']:>4.0%} " + f"equity hit @6x {6 * r['diag'] / S0:>6.1%}") + r10 = [r for r in rows if r["gap"] == -0.10 and r["iv1"] == 0.50] + r10_novol = [r for r in rows if r["gap"] == -0.10 and r["iv1"] == 0.16 + and r["width"] == 0.02][0] + p() + p(f" -> La diagonale NON e' un hedge pieno e NON e' gratis: a gap -10%/IV 50 lascia") + p(f" passare il {r10[0]['ratio']:.0%} (width 2%) / {r10[1]['ratio']:.0%} (width 3%) " + f"della perdita naked; SENZA vol spike (IV ferma) il {r10_novol['ratio']:.0%}.") + p(f" A 6x notional un -10% costa {6 * r10[0]['diag'] / S0:.0%}-" + f"{6 * r10_novol['diag'] / S0:.0%} di equity PER UNITA' (x4 unita' post-scaling = " + f"{24 * r10[0]['diag'] / S0:.0%}-{24 * r10_novol['diag'] / S0:.0%}).") + p(f" La protezione dipende dal vol-spike che gonfia il long (vega del T+7g): e'") + p(f" un cap alla ROVINA, non alla perdita — e il costo del cap (long ricomprato") + p(f" ogni ciclo) e' il motivo per cui l'EV netto resta ~0 (Test 2/3).") + OUT["test4"] = dict(obs=rows, deep=deep) + + +# =========================================================================== +# TEST 5 — ESEGUIBILITA' PER NOI (Deribit BTC/ETH, $600) +# =========================================================================== +def test5(): + p("\n" + "=" * 88) + p("TEST 5 — ESEGUIBILITA' su Deribit BTC/ETH al NOSTRO capitale ($600)") + p("=" * 88) + res = {} + for a, minsz in (("BTC", 0.1), ("ETH", 0.1)): + d = al.get(a, "1d") + S = float(d["close"].values[-1]) + m6 = hmoves(d["close"].values, 6) + q82a = float(np.quantile(np.abs(m6), WINRATE_CLAIM)) + leg_notional = minsz * S + sig = 0.55 if a == "BTC" else 0.70 + w = float(0.9 * m6.std()) # width vol-scalata (come il 2% su SPY) + short0 = bs_put(S, S * (1 - q82a), 6 / 365, sig) + bs_call(S, S * (1 + q82a), 6 / 365, sig) + long0 = (bs_put(S, S * (1 - q82a - w), 13 / 365, sig) + + bs_call(S, S * (1 + q82a + w), 13 / 365, sig)) + long7 = (bs_put(S, S * (1 - q82a - w), 7 / 365, sig) + + bs_call(S, S * (1 + q82a + w), 7 / 365, sig)) + # raccolto theta atteso per ciclo a mercato FERMO (spread 5%/gamba) + harvest = (0.95 * short0 - 1.05 * long0 + 0.95 * long7) / S * leg_notional + # fee Deribit opzioni: min(0.0003*S, 12.5% premio) per gamba; 8 esecuzioni/ciclo + avg_leg = (short0 / 2 + long0 / 2) / 2 / S * leg_notional + fee = 8 * min(0.0003 * S * minsz, 0.125 * max(avg_leg, 1e-9)) + # margine short (standard margin, senza netting sulle diagonali): + # ~max(0.15-OTM, 0.10)*S*size + mark, x2 gambe short + margin = 2 * (max(0.15 - q82a, 0.10) * S * minsz + short0 / 2 * minsz) + # tail netta: full-breach del cap (gap = -(q82+w), IV x1.5), lato put + gap_full = -(q82a + w) + Sc = S * (1 + gap_full) + vs = bs_put(Sc, S * (1 - q82a), 1 / 365, 1.5 * sig) + vl = bs_put(Sc, S * (1 - q82a - w), 8 / 365, 1.5 * sig) + vs0 = bs_put(S, S * (1 - q82a), 6 / 365, sig) + vl0 = bs_put(S, S * (1 - q82a - w), 13 / 365, sig) + tail = float(((vs - vs0) - (vl - vl0)) / S * leg_notional) + net_cycle = harvest - fee + fee_pct = f"{fee / harvest:.0%} del raccolto" if harvest > 0 else "raccolto <=0!" + res[a] = dict(S=S, q82=q82a, width=w, leg_notional=leg_notional, + harvest_cycle=float(harvest), fee_rt=float(fee), + net_cycle=float(net_cycle), margin=float(margin), tail_net=tail, + gap_full=float(gap_full)) + p(f" {a} (close {S:,.0f}, strike {q82a:.0%} OTM per win 82%, width {w:.0%}): " + f"min {minsz}/gamba -> {leg_notional:,.0f}$/gamba, 4 gambe = " + f"{4 * leg_notional:,.0f}$ notional") + p(f" raccolto theta/ciclo (mercato fermo, netto spread 5%) ~${harvest:.2f}, " + f"fee Deribit 8 gambe ~${fee:.2f} ({fee_pct}) -> NETTO ${net_cycle:+.2f}/ciclo;") + p(f" margine short ~${margin:,.0f}; tail netta (full-breach {gap_full:.0%} 6d, " + f"IV x1.5) ~${tail:,.0f}") + p() + b, e = res["BTC"], res["ETH"] + p(f" Fee strutturale: le fee Deribit sono PROPORZIONALI alla size -> il rapporto") + p(f" fee/raccolto ({b['fee_rt'] / max(b['harvest_cycle'], 1e-9):.0%} BTC, " + f"{e['fee_rt'] / max(e['harvest_cycle'], 1e-9):.0%} ETH) NON migliora scalando:") + p(f" a questi strike la DOUBLE DIAGONAL e' fee-negativa su Deribit A QUALSIASI size") + p(f" (netto {b['net_cycle']:+.2f}$/ciclo BTC, {e['net_cycle']:+.2f}$/ciclo ETH per " + f"unita' minima). Il capitale sposta il muro di margine, non questo.") + p() + # ---- soglie di capitale: $600 oggi, 2k/3.5k/5k prospettici -------------- + # Granularita' sensata = >=3 posizioni concorrenti + scalabile 1->2 contratti + # (>=6 slot di margine), budget margine 25% equity, rischio/posizione <=5%. + # ETH CREDIT SPREAD: numeri del filone gemello ALB-A (margine ~$107/spread = + # max-loss defined-risk, credito ~$2/spread) + fee 2 gambe. + sp_margin, sp_credit = 107.0, 2.0 + sp_fee = 2 * min(0.0003 * e["S"] * 0.1, 0.125 * sp_credit) # entry 2 gambe, 0.1 ETH + p(f" SOGLIE DI CAPITALE (budget margine 25%, rischio/pos <=5%, granularita' sensata") + p(f" = 3 posizioni concorrenti scalabili 1->2 = 6 slot):") + p(f" {'conto':>7s} | {'DD BTC (m.$' + format(b['margin'], ',.0f') + ')':>22s} | " + f"{'DD ETH min (m.$' + format(e['margin'], ',.0f') + ')':>22s} | " + f"{'spread ETH (m.$107)':>20s}") + tiers = {} + for cap in (600.0, 2000.0, 3500.0, 5000.0): + mbud = 0.25 * cap + n_btc = int(mbud // b["margin"]) + n_eth = int(mbud // e["margin"]) + n_sp = int(mbud // sp_margin) + risk_ok_sp = sp_margin <= 0.05 * cap # max-loss spread <= 5% equity + risk_ok_eth = e["tail_net"] <= 0.05 * cap + tiers[int(cap)] = dict(margin_budget=mbud, n_btc=n_btc, n_eth_dd=n_eth, + n_spread=n_sp, spread_risk_ok=bool(risk_ok_sp), + eth_dd_risk_ok=bool(risk_ok_eth), + spread_pct_per_pos=sp_margin / cap, + spread_income_yr=n_sp * (sp_credit - sp_fee) * 52) + p(f" {cap:>7,.0f} | {n_btc:>4d} unita' ({b['margin'] / cap:>4.0%}/u) | " + f"{n_eth:>4d} unita' ({e['margin'] / cap:>4.0%}/u) | " + f"{n_sp:>3d} spread ({sp_margin / cap:>4.0%}/u, risk5% " + f"{'OK' if risk_ok_sp else 'NO'})") + p() + p(f" Lettura (con upgrade conto 2-5k):") + p(f" - DOUBLE DIAGONAL BTC: margine ${b['margin']:,.0f}/unita' -> 1a unita' dentro il") + p(f" budget 25% solo da ~${4 * b['margin']:,.0f}; 'granularita' sensata' (6 slot) da " + f"~${24 * b['margin'] / 1000:,.0f}k. Nemmeno 5k basta: resta il muro gamma-scalp") + p(f" (diario 2026-06-26: 'opzione BTC min $5.968 >> $600'), che si sposta a ~10-30k.") + p(f" - DOUBLE DIAGONAL ETH min 0.1: gia' a 2k il margine non e' il vincolo " + f"({int(0.25 * 2000 / e['margin'])} unita' nel budget), ma resta FEE-NEGATIVA " + f"({e['net_cycle']:+.2f}$/ciclo): eseguibile != conveniente, a ogni soglia.") + p(f" - CREDIT SPREAD ETH (ALB-A): il PRIMO oggetto con soglia reale. A $600: 1 spread") + p(f" = {sp_margin / 600:.0%} del conto (max-loss {sp_margin / 600:.0%} > 5% -> NO). " + f"A 2k: {int(0.25 * 2000 // sp_margin)} spread nel budget, max-loss {sp_margin / 2000:.1%}") + p(f" ~ok ma niente scalata 1->2 su 3 posizioni; a 3.5k: {int(0.25 * 3500 // sp_margin)} slot " + f"(3 pos x2 non ancora); a 5k: {int(0.25 * 5000 // sp_margin)} slot -> granularita' OK") + p(f" (3 concorrenti scalabili 1->2, {6 * sp_margin / 5000:.0%} del conto impegnato).") + p(f" SOGLIA ~${int(np.ceil(6 * sp_margin / 0.25 / 100) * 100):,} per il set completo; " + f"~$2.1k per 5 slot senza scalata. Income lordo a 5k: " + f"{tiers[5000]['spread_income_yr']:.0f}$/anno ({tiers[5000]['spread_income_yr'] / 5000:.1%}) " + f"PRIMA delle code (Test 2-3: EV~0).") + p(f" - Confronto scala video: 10k su SPX = 1 contratto SPY (~$60k notional, 6x leva).") + p(f" L'equivalente Deribit a parita' di prudenza (tail 5%) e' ~${20 * e['tail_net']:,.0f}+") + p(f" su ETH e ~${20 * b['tail_net']:,.0f}+ su BTC: anche a 5k il conto e' SOTTO la") + p(f" scala minima del gioco del video su BTC, e ci sta su ETH solo in versione") + p(f" credit-spread (che e' VRP01, non 'Albimarini').") + OUT["test5"] = dict(assets=res, tiers=tiers, + spread=dict(margin=sp_margin, credit=sp_credit, fee=float(sp_fee))) + + +# =========================================================================== +# TEST 6 — CONFRONTO CON VRP01 (perche' un theta-harvest sopravvive) +# =========================================================================== +def test6(): + p("\n" + "=" * 88) + p("TEST 6 — Che cosa distingue un theta-harvest che sopravvive (VRP01) da uno che no") + p("=" * 88) + p(" VRP01 (audit superato, diario 2026-06-20): (1) DEFINED-RISK come struttura, non") + p(" come accessorio: il long wing cappa worst-week -16.6% -> -7.4% e DD 33% -> 21%;") + p(" (2) GATE di regime IV-rank>0.30 (vende solo vol RICCA): ribalta l'HOLD-OUT da") + p(" -0.25 a +0.28 — l'alpha e' il filtro, non il theta; (3) SIZING: sleeve al 12%") + p(" del book, niente compounding del numero di contratti; positivo/flat anche 2022.") + p(" La 'Albimarini' e' l'opposto quantificato: vende SEMPRE (nessun gate: incassa il") + p(" premio anche quando IV0.30 canonico NON si riottimizza) viene portata su BTC/ETH Deribit (che ha scadenze +giornaliere) e modellata onestamente sul nostro stack DVOL, contro il VRP01 canonico +(put credit spread settimanale -0.28/-0.10, gate vrp>0 + ivr>=0.30 + crash-skip 0.90). + +⚠️ CAVEAT SKEW — IN TESTA, NON IN FONDO: il pricing e' Black-Scholes FLAT sulla DVOL (IV ATM 30g). +Il deep-OTM (qui 1.5-3.0 sigma ~ 8-25% di distanza) e' ESATTAMENTE dove il flat-vol sbaglia di piu': +su crypto lo smile e' ripido su ENTRAMBE le ali (su equity solo put). Il premio reale delle ali e' +probabilmente > modello (f>1) per le put e variabile per le call; la calibrazione reale che abbiamo +(f~1.0) e' ATM-ish delta -0.28 su finestra calma, NON copre il deep-OTM. Quindi OGNI numero va letto +come BANDA sul fattore premio f in {0.6, 0.8, 1.0, 1.3} + uno scenario skew asimmetrico +(f_put=1.3 / f_call=0.7), MAI come stima puntuale. In piu' la DVOL e' IV a 30g usata per tenor 3-6g +(term structure ignorata; snapshot vol_term 2026-06: iv_7d vs iv_30d entro ~±3pt in calma, ma in +stress il front-end esplode -> il modello SOTTOSTIMA il mark-to-market avverso in crash). + +Distanza in unita' di VOL, non il 9% fisso di SPY: 9%/6g su SPY (IV~16%) = ~3.5-4 sigma; BTC si +muove del 9% in 6 giorni spesso. Cella centrale dichiarata A PRIORI: z=2.0 sigma, ali dz=+1.0 sigma, +tenor 5g (centro del range 3-6g del video). z=3.0 in griglia = cella "fedele al video". + +Riusa: options_vrp_lab (bs_put, load_series, per_year), options_vrp_v2 (vrp_spread_weekly = VRP01, +_ivrank, _rv30), altlib.marginal_vs_tp01. Fee Deribit opzioni per gamba: 0.03% del notional cap +12.5% del premio (+ delivery 0.015% cap 12.5% sull'ITM a scadenza; il diagonale paga anche la fee +di USCITA per vendere le long residue a T). NON deploy: regola standing "niente short-vol da +modello in deploy" — l'esito massimo e' conoscenza sulla struttura. + + uv run python scripts/research/r0702_alb_structure.py +""" +from __future__ import annotations +import sys +from pathlib import Path +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 numpy as np, pandas as pd +from scipy.stats import norm +from scripts.research.options_vrp_lab import bs_put, load_series, per_year +from scripts.research.options_vrp_v2 import vrp_spread_weekly, _ivrank, _rv30 + +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +DAY = 1.0 / 365.25 +# cella centrale DICHIARATA A PRIORI (prima di guardare qualsiasi risultato) +CENTRAL = dict(z=2.0, dz=1.0, tenor_d=5) +F_SWEEP = (0.6, 0.8, 1.0, 1.3) # banda skew simmetrica +F_SKEW = dict(f_put=1.3, f_call=0.7) # scenario skew crypto-shaped (put ricche, call povere) + + +def bs_call(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(S - K, 0.0) + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + return S * norm.cdf(d1) - K * norm.cdf(d1 - sig * np.sqrt(T)) # r=0 + + +def _fee_frac(prem_frac, notional_ratio=1.0, rate=0.0003): + """Fee Deribit per gamba come frazione di S0: rate*notional cap 12.5% del premio.""" + return min(rate * notional_ratio, 0.125 * max(prem_frac, 0.0)) + + +def run_structure(asset, kind, z=2.0, dz=1.0, tenor_d=5, f_put=1.0, f_call=1.0, + gated=False, collect=None): + """Vendita sistematica non-overlapping della struttura, cadenza = tenor_d. + + kind: 'diag' = double diagonal Albimarini (short T entrambi i lati, long T+1g piu' OTM) + 'condor' = iron condor STESSA scadenza (controllo del claim '+1 giorno') + 'vert' = vertical put credit spread deep-OTM (solo lato put, stessa scadenza) + Strike: K = S0*exp(±z·σ√T) (z in sigma dell'orizzonte SHORT); ali a z+dz. + gated=True -> gate CANONICO VRP01 (vrp>0 AND ivr>=0.30 AND ivr<=0.90), NON riottimizzato. + Ritorna Series di rendimenti per-periodo su capitale = S0 (spot a entry), indice = scadenza. + collect (dict) accumula diagnostica per la decomposizione diag-vs-condor.""" + J = load_series(asset) + px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index + n = len(px); T = tenor_d / 365.25 + T_long = T + DAY if kind == "diag" else T + has_call = kind in ("diag", "condor") + rets = {} + i = 60 + while i + tenor_d < n: + S0 = px[i]; sig = dv[i] + if gated: + rv = _rv30(px, i); ivr = _ivrank(dv, i) + skip = ((not np.isnan(rv) and (sig - rv) <= 0) + or (not np.isnan(ivr) and (ivr < 0.30 or ivr > 0.90))) + if skip: + rets[idx[i + tenor_d]] = 0.0 + i += tenor_d + continue + m = sig * np.sqrt(T) + Kp_s = S0 * np.exp(-z * m); Kp_l = S0 * np.exp(-(z + dz) * m) + Kc_s = S0 * np.exp(+z * m); Kc_l = S0 * np.exp(+(z + dz) * m) + # premi a entry (frazione di S0), f per lato + ps = bs_put(S0, Kp_s, T, sig) / S0 * f_put # short put + pl = bs_put(S0, Kp_l, T_long, sig) / S0 * f_put # long put (T o T+1g) + legs = [ps, pl] + cs = cl = 0.0 + if has_call: + cs = bs_call(S0, Kc_s, T, sig) / S0 * f_call + cl = bs_call(S0, Kc_l, T_long, sig) / S0 * f_call + legs += [cs, cl] + credit = (ps + cs) - (pl + cl) + # exit a scadenza degli short + j = i + tenor_d + S1 = px[j]; sig1 = dv[j] + short_pay = max(0.0, Kp_s - S1) / S0 + (max(0.0, S1 - Kc_s) / S0 if has_call else 0.0) + if kind == "diag": # long con 1 giorno residuo: mark BS alla DVOL di uscita (vega!) + lp = bs_put(S1, Kp_l, DAY, sig1) / S0 * f_put + lc = bs_call(S1, Kc_l, DAY, sig1) / S0 * f_call + long_val = lp + lc + exit_fee = sum(_fee_frac(v, notional_ratio=S1 / S0) for v in (lp, lc)) + else: # stessa scadenza: valore = solo intrinseco + long_val = max(0.0, Kp_l - S1) / S0 + (max(0.0, S1 - Kc_l) / S0 if has_call else 0.0) + exit_fee = 0.0 + entry_fee = sum(_fee_frac(v) for v in legs) + # delivery fee su gambe short ITM a scadenza (0.015% cap 12.5%) + deliv = _fee_frac(max(0.0, Kp_s - S1) / S0, rate=0.00015) + if has_call: + deliv += _fee_frac(max(0.0, S1 - Kc_s) / S0, rate=0.00015) + pnl = credit - short_pay + long_val - entry_fee - exit_fee - deliv + rets[idx[j]] = pnl + if collect is not None: + # costo extra del T+1 a entry e valore residuo recuperato a exit (vs intrinseco) + pl_T = bs_put(S0, Kp_l, T, sig) / S0 * f_put + cl_T = (bs_call(S0, Kc_l, T, sig) / S0 * f_call) if has_call else 0.0 + intr = max(0.0, Kp_l - S1) / S0 + (max(0.0, S1 - Kc_l) / S0 if has_call else 0.0) + collect.setdefault("extra_cost", []).append((pl + cl) - (pl_T + cl_T)) + collect.setdefault("recovered", []).append((long_val - intr) if kind == "diag" else 0.0) + collect.setdefault("short_pay", []).append(short_pay) + collect.setdefault("credit", []).append(credit) + i += tenor_d + return pd.Series(rets) + + +def book(kind, **kw): + rB = run_structure("BTC", kind, **kw); rE = run_structure("ETH", kind, **kw) + return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1) + + +def metrics(r, tenor_d): + """Metriche su rendimenti per-periodo (cadenza tenor_d). win/PF solo sui periodi ATTIVI.""" + r = r.dropna() + ppy = 365.25 / tenor_d + if len(r) < 3 or r.std() == 0: + return dict(sh=0.0, sh_h=0.0, cagr=0.0, dd=0.0, worst=0.0, win=0.0, pf=0.0, act=0.0) + def _sh(x): + return float(x.mean() / x.std() * np.sqrt(ppy)) if len(x) > 2 and x.std() > 0 else 0.0 + eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq) + yrs = len(r) / ppy + act = r[r != 0.0] + pos = act[act > 0].sum(); neg = -act[act < 0].sum() + return dict( + sh=_sh(r), sh_h=_sh(r[r.index >= HOLDOUT]), + cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else -1.0, + dd=float(np.max((pk - eq) / pk)), worst=float(r.min()), + win=float((act > 0).mean()) if len(act) else 0.0, + pf=float(pos / neg) if neg > 0 else float("inf"), + act=float((r != 0.0).mean()), n_act=int(len(act))) + + +def row(label, r, tenor_d): + mm = metrics(r, tenor_d) + pf = f"{mm['pf']:5.2f}" if np.isfinite(mm["pf"]) else " inf" + print(f" {label:<40} {mm['sh']:>6.2f} {mm['sh_h']:>6.2f} {mm['cagr']*100:>+6.1f}% " + f"{mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% {pf} {mm['win']*100:>4.0f}% {mm['act']*100:>4.0f}%") + return mm + + +def to_daily_lumped(wk): + """Rendimenti per-periodo -> griglia giornaliera con lump alla scadenza (convenzione + _vrp_combo_returns: preserva lo Sharpe annualizzato, niente smoothing).""" + wk = wk.sort_index() + days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC") + daily = pd.Series(0.0, index=days) + daily.loc[wk.index.normalize()] = wk.values + return daily + + +HDR = f" {'struttura':<40} {'ShF':>6} {'ShH':>6} {'CAGR':>7} {'maxDD':>6} {'worst':>7} {'PF':>5} {'win%':>5} {'att%':>5}" + + +def main(): + print("=" * 110) + print(" R0702 ALB-STRUCTURE — double diagonal deep-OTM (Albimarini) vs vertical vs condor vs VRP01") + print(" Capitale = SPOT a entry (S0) per le strutture nuove; VRP01 canonico = strike corto (sua convenzione).") + print(" ⚠️ SKEW: pricing BS FLAT su DVOL-30g; il deep-OTM e' banda-f, non stima puntuale. Term structure ignorata.") + print("=" * 110) + + z, dz, tn = CENTRAL["z"], CENTRAL["dz"], CENTRAL["tenor_d"] + for a in ("BTC", "ETH"): + J = load_series(a) + sig = J["dvol"].mean() / 100.0 + d5 = sig * np.sqrt(tn / 365.25) + print(f" {a}: DVOL media {sig*100:.0f}% -> 1σ su {tn}g = {d5*100:.1f}% | z=2.0 = {2*d5*100:.1f}% " + f"| z=3.0 = {3*d5*100:.1f}% (il '9% su SPY/6g' ≈ 3.5-4σ equity)") + + # ------------------------------------------------------------------ (1) VRP01 canonico + print(f"\n (1) VRP01 CANONICO (riproduzione options_vrp_v2 COMBO: spread -0.28/-0.10 7g, vrp>0+ivr30+cs90)") + print(HDR) + vrp = {} + for f in F_SWEEP: + vrp[f] = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90), + "E": vrp_spread_weekly("ETH", defined_risk=True, f=f, gate_vrp=True, + gate_ivr=0.30, crash_skip=0.90)}, + axis=1, join="inner").mean(axis=1) + row(f"VRP01 gated f={f}", vrp[f], 7) + vrp_nog = pd.concat({"B": vrp_spread_weekly("BTC", defined_risk=True, f=1.0), + "E": vrp_spread_weekly("ETH", defined_risk=True, f=1.0)}, + axis=1, join="inner").mean(axis=1) + row("VRP01 NO-gate f=1.0", vrp_nog, 7) + + # bridge di validazione del motore: vertical -0.28-equivalente (z~0.58, dz~0.70, 7g) ~ VRP01 + zb = float(-norm.ppf(0.28)); dzb = float(-norm.ppf(0.10)) - zb + br = book("vert", z=zb, dz=dzb, tenor_d=7, f_put=1.0, f_call=1.0, gated=True) + row(f"[bridge] vert z={zb:.2f} dz={dzb:.2f} 7g gated", br, 7) + print(" (bridge ~ VRP01 a meno di convenzione strike/capitale: valida il motore nuovo)") + + # ------------------------------------------------------------------ (2) tabella principale + print(f"\n (2) STRUTTURE ALLA CELLA CENTRALE A PRIORI (z={z}σ, ali +{dz}σ, tenor {tn}g) — banda f") + scen = [(f, f, f"f={f}") for f in F_SWEEP] + [(F_SKEW["f_put"], F_SKEW["f_call"], "SKEW fp=1.3/fc=0.7")] + streams = {} + for gated, gtag in ((False, "NO-GATE"), (True, "GATE canonico (vrp>0+ivr30+cs90)")): + print(f"\n --- {gtag} ---") + print(HDR) + for kind, ktag in (("diag", "DIAG double-diagonal T+1g"), + ("condor", "CONDOR iron condor stessa T"), + ("vert", "VERT put spread stessa T")): + for fp, fc, ftag in scen: + r = book(kind, z=z, dz=dz, tenor_d=tn, f_put=fp, f_call=fc, gated=gated) + row(f"{ktag} {ftag}", r, tn) + streams[(kind, gated, ftag)] = r + + # distanze alternative (sweep trasparente, selezione SOLO in-sample; f=1.0 gated) + print(f"\n (3) SWEEP DISTANZA/TENOR (gated, f=1.0) — selezione cella SOLO su Sharpe PRE-holdout") + print(f" {'cella':<40} {'ShF-IS':>7} {'ShH':>6} {'DD':>6} {'worst':>7} {'PF':>5} {'win%':>5}") + best = None + for kind in ("diag", "condor", "vert"): + for zz in (1.5, 2.0, 2.5, 3.0): + for tt in (3, 5): + r = book(kind, z=zz, dz=dz, tenor_d=tt, f_put=1.0, f_call=1.0, gated=True) + ris = r[r.index < HOLDOUT] + mm = metrics(r, tt); mi = metrics(ris, tt) + pf = f"{mm['pf']:5.2f}" if np.isfinite(mm["pf"]) else " inf" + act = r[r != 0.0] + nloss = int((act < 0).sum()) + tag = " <- video ~3.5σ" if zz == 3.0 and tt == 5 and kind == "diag" else "" + if nloss == 0: + tag += " ⚠️ 0 perdite su tutta la storia = coda MAI campionata (lezione CC01: Sharpe implausibile -> rischio nascosto)" + print(f" {kind:<7} z={zz} dz={dz} tenor={tt}g{'':<14} {mi['sh']:>7.2f} {mm['sh_h']:>6.2f} " + f"{mm['dd']*100:>5.1f}% {mm['worst']*100:>+6.2f}% {pf} {mm['win']*100:>4.0f}% n={len(act):>3}{tag}") + if best is None or mi["sh"] > best[1]: + best = ((kind, zz, tt), mi["sh"], mm["sh_h"]) + print(f" -> cella best IN-SAMPLE: {best[0]} (ShF-IS {best[1]:.2f}) | suo hold-out ShH {best[2]:.2f}") + print(" Le celle z>=2.5/5g vendono un evento ~1% mai occorso nel subsample gated (~140 trade):") + print(" Sharpe 'inf/5.9' = premio senza coda osservata, NON edge. E' il punto cieco CC01 in forma opzioni.") + + # ------------------------------------------------------------------ (4) claim del video + print(f"\n (4) TEST CLAIM VIDEO: la long a T+1g domina la long a STESSA T? (z={z}, dz={dz}, {tn}g, f=1.0)") + for gated in (False, True): + colD, colC = {}, {} + rD = pd.concat({a[0]: run_structure(a, "diag", z=z, dz=dz, tenor_d=tn, gated=gated, collect=colD) + for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1) + rC = pd.concat({a[0]: run_structure(a, "condor", z=z, dz=dz, tenor_d=tn, gated=gated, collect=colC) + for a in ("BTC", "ETH")}, axis=1, join="inner").mean(axis=1) + mD, mC = metrics(rD, tn), metrics(rC, tn) + ec = np.array(colD["extra_cost"]); rec = np.array(colD["recovered"]) + sp = np.array(colD["short_pay"]); crd = np.array(colD["credit"]) + crash = sp > np.quantile(sp, 0.95) + gt = "GATE" if gated else "NO-GATE" + print(f" [{gt}] DIAG ShF {mD['sh']:+.2f}/ShH {mD['sh_h']:+.2f} worst {mD['worst']*100:+.2f}% | " + f"CONDOR ShF {mC['sh']:+.2f}/ShH {mC['sh_h']:+.2f} worst {mC['worst']*100:+.2f}%") + print(f" costo extra T+1 a entry: {ec.mean()*1e4:+.1f} bps/trade | residuo recuperato a exit: " + f"{rec.mean()*1e4:+.1f} bps (nei 5% peggiori: {rec[crash].mean()*1e4:+.1f} bps vs extra {ec[crash].mean()*1e4:+.1f})") + print(f" trade a CREDITO netto: {(crd>0).mean()*100:.0f}% (credito medio {crd.mean()*1e4:+.1f} bps di S0)") + dY = per_year(rD); cY = per_year(rC) + print(" Δ(diag-condor) per anno: " + " ".join(f"{y}:{(dY[y]-cY.get(y,0))*100:+.2f}%" for y in sorted(dY))) + + # ------------------------------------------------------------------ (5) per-anno + print(f"\n (5) PER-ANNO (gated, f=1.0) — 2022 = LUNA+FTX e' il banco di prova") + for tag, r, tt in (("DIAG", streams[("diag", True, "f=1.0")], tn), + ("CONDOR", streams[("condor", True, "f=1.0")], tn), + ("VERT", streams[("vert", True, "f=1.0")], tn), + ("VRP01", vrp[1.0], 7)): + py = per_year(r) + print(f" {tag:<7} " + " ".join(f"{y}:{v*100:+5.1f}%" for y, v in sorted(py.items()))) + + # ------------------------------------------------------------------ (6) marginale + print(f"\n (6) MARGINALE vs TP01 e vs VRP01 (daily-lumped; corr su griglia settimanale)") + import altlib as al + tp = al.tp01_baseline_daily() + dv_daily = to_daily_lumped(streams[("diag", True, "f=1.0")]) + vr_daily = to_daily_lumped(vrp[1.0]) + tp_w = (1 + tp).resample("W").prod() - 1 + di_w = (1 + dv_daily).resample("W").prod() - 1 + vr_w = (1 + vr_daily).resample("W").prod() - 1 + Jw = pd.concat({"tp": tp_w, "di": di_w, "vr": vr_w}, axis=1, join="inner").dropna() + print(f" corr settimanale: DIAG~TP01 {Jw['tp'].corr(Jw['di']):+.2f} | DIAG~VRP01 {Jw['di'].corr(Jw['vr']):+.2f} " + f"| VRP01~TP01 {Jw['tp'].corr(Jw['vr']):+.2f}") + for name, dd in (("DIAG gated f=1.0", dv_daily), ("VRP01 gated f=1.0 (riferimento)", vr_daily)): + mv = al.marginal_vs_tp01(dd) + print(f" marginal_vs_tp01[{name}]: verdict={mv.get('marginal_verdict')} corr={mv.get('corr_full')} " + f"uplift w25 full/hold={mv['blends']['w25']['uplift_full']}/{mv['blends']['w25']['uplift_hold']} " + f"IS-Sh={mv.get('cand_insample_sharpe')} insample_edge={mv.get('has_insample_edge')} " + f"hedge={mv.get('is_hedge')} robust_oos={mv.get('robust_oos')} multicut={mv.get('multicut_uplift')}") + print(" ⚠️ Un verdetto ADDS qui NON promuove: lo stream vende coda che nel subsample gated non ha mai") + print(" colpito (hold-out Sh 3+ = assenza di eventi, non alpha) — vale la lezione CC01, e vale la regola") + print(" standing 'niente short-vol da modello in deploy'.") + + # ------------------------------------------------------------------ (7) eseguibilita' + print(f"\n (7) ESEGUIBILITA' DERIBIT (min 0.1 BTC / 1 ETH per gamba; diag = 4 gambe/asset, book = 8)") + for a, minc in (("BTC", 0.1), ("ETH", 1.0)): + J = load_series(a); S = float(J["px"].iloc[-1]); sig = float(J["dvol"].iloc[-1]) / 100.0 + w = dz * sig * np.sqrt(tn / 365.25) # larghezza ala in frazione di S + notional = minc * S + maxloss = w * notional # margine ~ max loss defined-risk (per lato) + col = {} + run_structure(a, "diag", z=z, dz=dz, tenor_d=tn, gated=True, collect=col) + cbps = np.mean(col["credit"]) * 1e4 if col.get("credit") else float("nan") + print(f" {a}: spot ~${S:,.0f} -> notional min {minc} = ${notional:,.0f}/gamba | ala {w*100:.1f}% " + f"-> margine/max-loss min ~${maxloss:,.0f} | credito tipico {cbps:+.0f} bps = ${notional*cbps/1e4:,.0f}/trade") + print(" -> a $600: UN diagonale BTC min-size impegna >50% del capitale su un trade 5g = NON eseguibile.") + print(" Scala minima: sleeve opzioni al ~12% con margine <= peso richiede >~$3-5k per il solo BTC") + print(" min-size; book 50/50 con granularita' (>=3-5 step di size) ~= $15-25k. STAT-MODE, come VRP01.") + + print("\n NB ONESTO: win-rate alto e' STRUTTURALE nel deep-OTM (vendi eventi rari), non e' edge. Il verdetto") + print(" sta in Sharpe/PF/coda attraverso la banda f e il 2022. Regola standing: niente short-vol da modello") + print(" in deploy — esito massimo = aggiornamento di conoscenza sulla STRUTTURA.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0702_capital_scaling.py b/scripts/research/r0702_capital_scaling.py new file mode 100644 index 0000000..c093408 --- /dev/null +++ b/scripts/research/r0702_capital_scaling.py @@ -0,0 +1,594 @@ +#!/usr/bin/env python +"""r0702_capital_scaling.py — RI-PRICING dei MURI DI SCALA a capitale {600, 2000, 3500, 5000}. + +NON e' ricerca di strategie nuove: e' la ri-quantificazione ONESTA dei vincoli di scala gia' +documentati (tutti quantificati a $600) in vista del funding del conto live a 2-5k $: + + (1) TP01 smallcap haircut — al.eval_weights_smallcap ai 4 capitali (budget per-asset = C/2, + book 50/50): haircut Sharpe modellato→eseguibile, ordini + eseguiti/saltati, turnover eseguito, fee drag (tetto noto + ~0.4%/anno: a $600 il min-order fa da banda d'isteresi gratuita + — ondata timing 2026-07-02 — a 2-5k l'effetto si riduce). + (2) Book live TP01+SKH01 75/25 — replica CONCETTUALE dei target di src/live/book.py (formula + net = clamp(0.5*E*(0.75*tp_frac + 0.25*skh_sign), ±cap)) sulla + griglia 230m storica, SENZA importare il modulo live: notional + tipici, % ordini sub-min, e quanto book resta NON investito se + il cap $300/asset non viene alzato. + (3) Tranching TP01 K=2/K=4 — (diario 2026-07-02: "NO deploy a $600, rivalutare a >=5-10k"): + % di ribilanciamenti-tranche >= min-order $5 a ogni capitale. + NB il blocco feed-intraday-fuori-path-certificato e' SEPARATO + e resta (dichiarato in output). + (4) STATARB-RESID — stesso smallcap a 2 gambe (eval_spread_smallcap di + orthogonal_signals, W=45/sgn=+1 CONGELATI come in + scripts/live/paper_statarb.py — qui solo LETTI, mai toccati). + (5) Opzioni Deribit — matematica STATICA (nessun backtest): min 0.1 BTC / 1 ETH per + gamba, margine defined-risk dello spread VRP01, fee Deribit + 0.03% notional cap 12.5% premio. Solo pricing del muro: regola + standing "niente short-vol da modello in deploy" INVARIATA. + (6) XS01 (19 gambe HL, "~20k") — replica della matrice pesi di sleeves._xsec_returns (copia + locale, sola lettura del modulo) + min order HL ~$10/gamba: + conferma/rettifica della soglia. CC01: conti statici gambe. + (7) SINTESI — tabella capitale × vincoli + raccomandazioni CONFIG (solo + proposte: config/live.json NON viene toccato) + aspettativa + onesta EUR/giorno col CAGR de-luckato del book (10-15%, audit + SKH01 2026-07-02 path orario — NON i numeri canonici). + +Causalita'/pandas: niente DatetimeIndex.view("int64") — epoca ms esplicita ovunque (timestamp gia' +int64 nei frame certificati). Fee 0.10% RT (0.05%/lato). Nessun file di produzione toccato. + + uv run python scripts/research/r0702_capital_scaling.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import norm + +ROOT = Path("/opt/docker/PythagorasGoal") +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT)) + +import altlib as al # noqa: E402 +from src.data.downloader import load_data # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 +from src.strategies.skyhook import LTF_MIN, SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402 +from orthogonal_signals import build_joint, eval_spread, eval_spread_smallcap, f_statarb_resid # noqa: E402 + +CAPITALS = (600.0, 2000.0, 3500.0, 5000.0) +MIN_ORDER = 5.0 # Deribit min order USD (config/live.json) +MIN_ORDER_HL = 10.0 # Hyperliquid min order USD (~$10) +CAP_NOW = 300.0 # max_notional_per_asset_usd corrente +ASSETS = ("BTC", "ETH") +MS_D = 86_400_000 +MS_LTF = LTF_MIN * 60_000 +FEE_SIDE = al.FEE_SIDE # 0.0005 + +TP = TrendPortfolio(**CANONICAL) + + +def _years(ts_ms: np.ndarray) -> float: + return max((int(ts_ms[-1]) - int(ts_ms[0])) / (MS_D * 365.25), 1e-9) + + +# =========================================================================== +# (1) TP01 smallcap haircut ai 4 capitali (budget per-asset = C/2, book 50/50) +# =========================================================================== +def smallcap_counts(tgt: np.ndarray, capital: float, min_order: float = MIN_ORDER) -> dict: + """Replica il path di skip di al.eval_weights_smallcap per CONTARE ordini eseguiti vs + desiderati (la funzione ufficiale riporta metriche, non i saltati).""" + tgt = np.clip(np.nan_to_num(np.asarray(tgt, float)), -10, 10) + cur = 0.0 + n_exec = 0 + n_want = 0 + for x in tgt: + d = abs(x - cur) + if d * capital >= min_order: + cur = x + n_exec += 1 + n_want += 1 + elif d * capital >= 0.01: # un cambio era desiderato ma sub-min-order + n_want += 1 + return dict(n_exec=n_exec, n_want=n_want) + + +def section1(): + print("=" * 100) + print("(1) TP01 CANONICO 1d — smallcap haircut ai 4 capitali (budget per-asset = C/2, book 50/50)") + print(" min order $5 Deribit; fee 0.05%/lato; fee-drag = fee_side * turnover-eseguito/anno") + print("=" * 100) + out = {} + d1 = {a: al.get(a, "1d") for a in ASSETS} + tgt = {a: np.nan_to_num(TP.target_series(d1[a])) for a in ASSETS} + modeled = {a: al.eval_weights(d1[a], tgt[a]) for a in ASSETS} + for a in ASSETS: + m = modeled[a] + print(f" [{a}] modellato (fiction ribilanciamento continuo): Sh FULL {m['full']['sharpe']:.2f} " + f"turnover {m['turnover_per_year']:.1f}x/anno -> fee drag modellato " + f"{m['turnover_per_year'] * FEE_SIDE * 100:.2f}%/anno") + hdr = (f" {'capitale':>8} {'asset':>5} {'Sh mod':>7} {'Sh real':>8} {'haircut':>8} " + f"{'ordini/anno':>12} {'saltati%':>9} {'turn exec':>10} {'feedrag%':>9}") + print(hdr) + for C in CAPITALS: + rows = [] + for a in ASSETS: + r = al.eval_weights_smallcap(d1[a], tgt[a], capital=C / 2.0, min_order=MIN_ORDER) + cnt = smallcap_counts(tgt[a], C / 2.0) + yrs = _years(d1[a]["timestamp"].values.astype("int64")) + skipped = 1.0 - cnt["n_exec"] / max(cnt["n_want"], 1) + drag = r["executed_turnover_per_year"] * FEE_SIDE * 100 + rows.append(dict(asset=a, mod=r["modeled"]["sharpe"], real=r["realistic"]["sharpe"], + hc=r["sharpe_haircut"], opy=cnt["n_exec"] / yrs, skip=skipped, + turn=r["executed_turnover_per_year"], drag=drag)) + print(f" {C:>8.0f} {a:>5} {r['modeled']['sharpe']:>7.2f} {r['realistic']['sharpe']:>8.2f} " + f"{r['sharpe_haircut']:>8.3f} {cnt['n_exec'] / yrs:>12.0f} {skipped * 100:>8.1f}% " + f"{r['executed_turnover_per_year']:>10.1f} {drag:>9.2f}") + out[C] = dict(haircut=float(np.mean([x["hc"] for x in rows])), + drag=float(np.mean([x["drag"] for x in rows])), + skipped=float(np.mean([x["skip"] for x in rows]))) + print(" NB contesto (ondata timing 2026-07-02): a $600 il min-order E' la banda d'isteresi ottimale") + print(" (ordini -74% a costo ~0). Ai capitali alti la banda implicita si stringe (5$/2500$ = 0.2% del") + print(" budget-asset) -> si eseguono quasi tutti i micro-ribilanci e il fee drag risale verso il") + print(" modellato (tetto noto ~0.4%/anno) — e' il costo, atteso e piccolo, della fedelta' al modello.") + return out + + +# =========================================================================== +# (2) BOOK LIVE TP01+SKH01 75/25 — replica concettuale dei target (griglia 230m) +# =========================================================================== +def skh_sign_series(asset: str) -> tuple[pd.DataFrame, np.ndarray]: + """Segno SKH01 (+1/-1/0) alla DECISIONE di ogni barra 230m chiusa, replicando la logica + non-overlap entry+exit (TP/SL/max_bars) di sleeves._skyhook_positions su tutta la storia.""" + ltf, htf = build_frames(load_data(asset, "5m")) + ent = skyhook_entries(ltf, htf, SKH01_V2_DD) + H = ltf["high"].values + L = ltf["low"].values + n = len(ltf) + sgn = np.zeros(n) + i = 0 + while i < n: + e = ent[i] + if e is None: + i += 1 + continue + d, sl, tp, mb = e["dir"], e["sl"], e["tp"], e["max_bars"] + exit_idx = None + for s in range(1, mb + 1): + j = i + s + if j >= n: + break + hit = (L[j] <= sl or H[j] >= tp) if d == 1 else (H[j] >= sl or L[j] <= tp) + if hit or s == mb: + exit_idx = j + break + if exit_idx is None: # trade ancora aperto a fine storia + sgn[i:] = d + break + sgn[i:exit_idx] = d # alla decisione di exit_idx il trade e' gia' chiuso + i = exit_idx + 1 + return ltf, sgn + + +def tp_frac_on_ltf(asset: str, ltf: pd.DataFrame) -> np.ndarray: + """tp_frac (target TP01 daily, causale) mappato sulla griglia 230m: per ogni chiusura 230m + l'ultimo target daily la cui CHIUSURA nominale (open-label + 24h, epoca ms) e' <= chiusura 230m.""" + d1 = al.get(asset, "1d") + tgt = np.nan_to_num(TP.target_series(d1)) + close_d = d1["timestamp"].values.astype("int64") + MS_D + close_l = ltf["timestamp"].values.astype("int64") + MS_LTF + idx = np.searchsorted(close_d, close_l, side="right") - 1 + return np.where(idx >= 0, tgt[np.maximum(idx, 0)], 0.0) + + +def book_sim(tpf: np.ndarray, sgn: np.ndarray, ts_ms: np.ndarray, + C: float, cap: float, min_order: float = MIN_ORDER) -> dict: + """Replica PURA della formula di book.book_net_target + build_book_order (senza import live): + equity fissa = C (isola l'effetto scala; il live usa l'equity reale), ordini market al delta.""" + raw = 0.5 * C * (0.75 * np.maximum(np.nan_to_num(tpf), 0.0) + 0.25 * np.nan_to_num(sgn)) + net = np.clip(raw, -cap, cap) + pos = 0.0 + executed = [] + n_skip = 0 + for x in net: + d = x - pos + if abs(d) >= min_order: + executed.append(abs(d)) + pos = x + elif abs(d) >= 0.01: + n_skip += 1 + yrs = _years(ts_ms) + ex = np.asarray(executed) if executed else np.asarray([0.0]) + nz = np.abs(raw) > 1e-9 + mean_raw = float(np.mean(np.abs(raw[nz]))) if nz.any() else 0.0 + mean_net = float(np.mean(np.minimum(np.abs(raw[nz]), cap))) if nz.any() else 0.0 + return dict(orders_py=len(executed) / yrs, med_order=float(np.median(ex)), + p90_order=float(np.percentile(ex, 90)), + sub_min=n_skip / max(n_skip + len(executed), 1), + cap_bind=float(np.mean(np.abs(raw) > cap)), + invested=(mean_net / mean_raw) if mean_raw > 0 else 1.0, + mean_raw=mean_raw) + + +def section2(): + print() + print("=" * 100) + print("(2) BOOK LIVE TP01+SKH01 75/25 — replica formula book.py su griglia 230m storica") + print(" net = clamp(0.5*C*(0.75*tp_frac + 0.25*skh_sign), +/-cap); equity fissa = C; min order $5") + print("=" * 100) + data = {} + for a in ASSETS: + ltf, sgn = skh_sign_series(a) + tpf = tp_frac_on_ltf(a, ltf) + data[a] = (ltf["timestamp"].values.astype("int64"), tpf, sgn) + print(f" [{a}] barre 230m: {len(sgn)} tempo con SKH aperto: {np.mean(sgn != 0) * 100:.1f}% " + f"tp_frac medio: {np.mean(np.maximum(tpf, 0)):.2f}") + out = {} + hdr = (f" {'capitale':>8} {'cap/asset':>10} {'asset':>5} {'ordini/anno':>12} {'medONot$':>9} " + f"{'p90$':>7} {'submin%':>8} {'capbind%':>9} {'investito%':>11}") + print(hdr) + for C in CAPITALS: + for cap, lbl in ((CAP_NOW, "300 (oggi)"), (C / 2.0, "C/2 (prop)")): + if C == 600.0 and cap != CAP_NOW: + continue # a 600 i due scenari coincidono + inv = [] + for a in ASSETS: + ts, tpf, sgn = data[a] + r = book_sim(tpf, sgn, ts, C, cap) + inv.append(r["invested"]) + print(f" {C:>8.0f} {lbl:>10} {a:>5} {r['orders_py']:>12.0f} {r['med_order']:>9.0f} " + f"{r['p90_order']:>7.0f} {r['sub_min'] * 100:>7.1f}% {r['cap_bind'] * 100:>8.1f}% " + f"{r['invested'] * 100:>10.1f}%") + out[(C, lbl)] = float(np.mean(inv)) + print(" Lettura: 'investito%' = quota del target-notional desiderato che il cap/asset lascia") + print(" effettivamente a mercato (media sulle barre con target != 0). Col cap fermo a $300 il book") + print(" a 2-5k gira sotto-investito in modo strutturale; col cap = C/2 il rapporto attuale (300/600)") + print(" e' preservato e il vincolo torna a mordere solo sulle leve alte (tp_frac -> 2x).") + return out + + +# =========================================================================== +# (3) TRANCHING TP01 K=2/K=4 — eseguibilita' delle tranche a 2-5k +# =========================================================================== +def section3(): + print() + print("=" * 100) + print("(3) TRANCHING TP01 (K ancore daily sfasate, 1/K del capitale per tranche)") + print(" ordine-tranche per asset = |Delta tgt| * C/(2K); eseguibile se >= $5") + print("=" * 100) + out = {} + dd = {} + for a in ASSETS: + d1 = al.get(a, "1d") + tgt = np.nan_to_num(TP.target_series(d1)) + d = np.abs(np.diff(tgt, prepend=0.0)) + dd[a] = d[d > 1e-12] # solo i giorni in cui un ribilancio e' desiderato + print(f" [{a}] |Delta tgt| giornaliero (giorni con cambio): mediana {np.median(dd[a]):.4f} " + f"p25 {np.percentile(dd[a], 25):.4f} p75 {np.percentile(dd[a], 75):.4f}") + alld = np.concatenate([dd[a] for a in ASSETS]) + print(f" {'capitale':>8} {'K':>3} {'$/tranche-asset':>16} {'ordine mediano $':>17} " + f"{'exec-eventi%':>13} {'exec-turnover%':>15}") + for C in CAPITALS: + for K in (1, 2, 4): + pt = C / (2.0 * K) + mask = alld * pt >= MIN_ORDER + ex_ev = float(np.mean(mask)) # % degli EVENTI di ribilancio + ex_tw = float(alld[mask].sum() / alld.sum()) # % del TURNOVER (massa) eseguibile + med = float(np.median(alld)) * pt + out[(C, K)] = ex_tw + print(f" {C:>8.0f} {K:>3} {pt:>16.0f} {med:>17.2f} {ex_ev * 100:>12.1f}% {ex_tw * 100:>14.1f}%") + print(" Lettura: 'exec-eventi%' basso e' in parte FISIOLOGICO (i micro-ribilanci vol-target saltati") + print(" = la banda d'isteresi gratuita); il degrado vero e' 'exec-turnover%': la quota della MASSA") + print(" di ribilanciamento che ogni tranche riesce a eseguire (i cambi grossi = entrate/uscite).") + print(" Il 'degenera in K=1 a $600' del diario e' la granularita' EVENTO: l'ordine-tranche mediano") + print(" K=2 resta sotto $5 perfino a 5k ($3.7) -> le tranche non fanno il fine-tuning giornaliero,") + print(" ma da ~2k in su eseguono >95% della massa (entrate/uscite) ciascuna alla propria ancora.") + print(" NB: distribuzione |Delta| presa dall'ancora canonica (proxy: le altre ancore hanno") + print(" distribuzioni simili — r0702_tp01_offset). BLOCCO SEPARATO E INVARIATO: il tranching") + print(" richiede decisioni intraday (ancore != 00:00) => feed intraday FUORI dal path certificato") + print(" daily del cron attuale. Anche dove il min-order non degenera piu' K=2 in K=1, il deploy") + print(" resta condizionato a quel lavoro di feed/infra (diario 2026-07-02-timing-crt-wave).") + return out + + +# =========================================================================== +# (4) STATARB-RESID — haircut REAL ai 4 capitali (2 gambe, W=45/sgn=+1 congelati) +# =========================================================================== +def section4(): + print() + print("=" * 100) + print("(4) STATARB-RESID (paper_statarb, config CONGELATA W=45 sgn=+1) — haircut min-order a scala") + print("=" * 100) + j = build_joint("1d") + pos = f_statarb_resid(W=45, sgn=+1)(j) # identica a paper_statarb (solo lettura parametri) + mod = eval_spread(j, pos) + print(f" modellato (2 gambe, fee 0.05%/lato x2): Sh FULL {mod['full']['sharpe']:.2f} " + f"turnover {mod['turnover']:.1f}x/anno (per gamba)") + out = {} + print(f" {'capitale':>8} {'Sh mod':>7} {'Sh real':>8} {'haircut':>8} {'trade eseguiti':>15}") + for C in CAPITALS: + r = eval_spread_smallcap(j, pos, capital=C, min_order=MIN_ORDER) + out[C] = r["sharpe_haircut"] + print(f" {C:>8.0f} {r['modeled']['sharpe']:>7.2f} {r['realistic']['sharpe']:>8.2f} " + f"{r['sharpe_haircut']:>8.3f} {r['n_executed_trades']:>15d}") + print(" (il vincolo binding e' il nozionale PER-GAMBA |Delta pos|*C >= $5; a 1d il turnover e'") + print(" bassissimo -> haircut gia' ~0 a $600, a 2-5k e' rumore. Il muro di statarb NON e' la scala:") + print(" e' l'EDGE — DSR 0.929 < 0.95, forward-monitor in corso.)") + return out + + +# =========================================================================== +# (5) OPZIONI DERIBIT — matematica statica del muro (VRP01 spread / min size) +# =========================================================================== +def _bs_put(S, K, T, sig): + if T <= 0 or sig <= 0: + return max(K - S, 0.0) + d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T)) + return K * norm.cdf(-(d1 - sig * np.sqrt(T))) - S * norm.cdf(-d1) + + +def _strike_from_delta(S, T, sig, target_delta): + return S * np.exp(0.5 * sig ** 2 * T - (-norm.ppf(-target_delta)) * sig * np.sqrt(T)) + + +def section5(): + print() + print("=" * 100) + print("(5) OPZIONI DERIBIT — pricing STATICO del muro (nessun backtest, nessuna proposta di deploy)") + print(" struttura VRP01: put credit spread 7g, short delta -0.28 / long -0.10, defined-risk") + print(" min size: 0.1 BTC/gamba, 1 ETH/gamba; fee 0.03% notional cap 12.5% premio (per gamba)") + print("=" * 100) + T = 7.0 / 365.25 + res = {} + for a, minc in (("ETH", 1.0), ("BTC", 0.1)): + d1 = al.get(a, "1d") + S = float(d1["close"].iloc[-1]) + dv = pd.read_parquet(ROOT / "data" / "raw" / f"dvol_{a.lower()}.parquet") + iv_last = float(dv["close"].iloc[-1]) / 100.0 + iv_med = float(dv["close"].iloc[-365:].median()) / 100.0 + rows = {} + for lbl, sig in (("DVOL oggi", iv_last), ("DVOL mediana 1y", iv_med)): + Ks = _strike_from_delta(S, T, sig, -0.28) + Kl = _strike_from_delta(S, T, sig, -0.10) + prem_s = _bs_put(S, Ks, T, sig) * minc + prem_l = _bs_put(S, Kl, T, sig) * minc + credit = prem_s - prem_l + width = (Ks - Kl) * minc + maxloss = width - credit # margine defined-risk ~ max loss + fee = (min(0.0003 * S * minc, 0.125 * prem_s) + + min(0.0003 * S * minc, 0.125 * prem_l)) + rows[lbl] = dict(S=S, sig=sig, credit=credit, width=width, maxloss=maxloss, + fee=fee, net=credit - fee) + print(f" [{a} x{minc}] {lbl}: spot ${S:,.0f} IV {sig * 100:.0f}% strike {Ks:,.0f}/{Kl:,.0f}" + f" credito ${credit:,.2f} width ${width:,.2f} margine/max-loss ${maxloss:,.2f}" + f" fee 2 gambe ${fee:,.2f} credito NETTO ${credit - fee:,.2f}" + f" ({(credit - fee) / max(credit, 1e-9) * 100:.0f}% del lordo)") + res[a] = rows["DVOL mediana 1y"] + print(f" + fee delivery a scadenza se ITM: 0.015% notional cap 12.5% (non inclusa sopra).") + m_eth = res["ETH"]["maxloss"] + m_btc = res["BTC"]["maxloss"] + print(f"\n {'capitale':>8} {'spread ETH conc. @12% peso':>27} {'@100% conto':>12} " + f"{'% conto/spread':>15}") + out = {} + for C in CAPITALS: + n12 = int((0.12 * C) // m_eth) + nfull = int(C // m_eth) + out[C] = n12 + print(f" {C:>8.0f} {n12:>27d} {nfull:>12d} {m_eth / C * 100:>14.1f}%") + c_btc_1 = m_btc / 0.12 + c_btc_3 = 3 * m_btc / 0.12 + print(f"\n BTC options (0.1 BTC min): margine/max-loss ~${m_btc:,.0f}/spread -> a peso 12% servono" + f" ~${c_btc_1:,.0f} per 1 spread, ~${c_btc_3:,.0f} per granularita' minima (3 step di size).") + print(" Muro fee: il credito ETH sopravvive alle fee (cap 12.5% del premio ~ perdi al massimo un") + print(" quarto del credito con entry+delivery; il numero esatto sopra). Il muro VERO resta la regola") + print(" standing: NIENTE short-vol da modello in deploy (f di stress reale mai osservato).") + return out, m_eth, m_btc + + +# =========================================================================== +# (6) XS01 (19 gambe Hyperliquid) e CC01 — soglie di scala con conti espliciti +# =========================================================================== +def xs01_positions() -> tuple[pd.DatetimeIndex, np.ndarray]: + """COPIA di sola-lettura della matrice pesi di src/portfolio/sleeves._xsec_returns + (stessi parametri XS_CFG / XS_UNIVERSE), che ritorna le POSIZIONI finali per gamba + P = W * scale(vol-target) invece dei rendimenti. Nessun modulo di produzione modificato.""" + from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _HL_DIR + cols = {} + for sym in XS_UNIVERSE: + p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet" + if p.exists(): + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + C = pd.concat(cols, axis=1, join="inner").sort_index().dropna() + px = C.values + n, A = px.shape + lookbacks, H, k = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"] + mode, tv = XS_CFG["mode"], XS_CFG["target_vol"] + disp_pct = XS_CFG.get("disp_pct", 0) + minhist = XS_CFG.get("disp_minhist", 20) + mlb = max(lookbacks) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + W = np.zeros((n, A)) + w = np.zeros(A) + disp_hist = [] + for i in range(n): + if i >= mlb and i % H == 0: + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = np.percentile(disp_hist, disp_pct) if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf + if disp_i >= thr: + score = np.zeros(A) + cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd + cnt += 1 + if cnt: + score /= cnt + order = np.argsort(score) + w = np.zeros(A) + lo, hi = order[:k], order[-k:] + if mode == "mom": + w[hi] = 0.5 / k + w[lo] = -0.5 / k + else: + w[lo] = 0.5 / k + w[hi] = -0.5 / k + else: + w = np.zeros(A) + disp_hist.append(disp_i) + W[i] = w + gross = np.zeros(n) + gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n) + turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (0.001 / 2.0) + s = pd.Series(net, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + return C.index, W * scale[:, None] + + +def xs01_exec(P: np.ndarray, idx: pd.DatetimeIndex, sleeve_cap: float, + min_order: float = MIN_ORDER_HL) -> dict: + """Skip sequenziale per gamba: un Delta di nozionale < min_order NON si esegue.""" + n, A = P.shape + yrs = max((idx[-1] - idx[0]).days / 365.25, 1e-9) + tot_mod = 0.0 + n_orders = 0 + orders = [] + for a in range(A): + tgt = P[:, a] + tot_mod += float(np.abs(np.diff(tgt, prepend=0.0)).sum()) + cur = 0.0 + for x in tgt: + d = abs(x - cur) + if d * sleeve_cap >= min_order: + orders.append(d * sleeve_cap) + cur = x + n_orders += 1 + exec_turn = float(np.sum(orders) / sleeve_cap) if orders else 0.0 + return dict(exec_share=exec_turn / tot_mod if tot_mod > 0 else 0.0, + orders_py=n_orders / yrs, + med_order=float(np.median(orders)) if orders else 0.0) + + +def section6(): + print() + print("=" * 100) + print("(6) XS01 (19 gambe HL, min order ~$10) e CC01 — le soglie '~20k' ricontate") + print("=" * 100) + idx, P = xs01_positions() + gross = np.abs(P).sum(axis=1) + print(f" XS01: gross tipico {np.median(gross[gross > 0]):.2f}x del capitale sleeve; " + f"10 gambe attive (5 long + 5 short da 19), ribilancio ogni 10g + vol-target giornaliero") + print(f" {'capitale':>8} {'sleeve@15%':>11} {'exec-turnover%':>15} {'ordini/anno':>12} {'medONot$':>9}") + out = {} + for Ctot in list(CAPITALS) + [10000.0, 20000.0, 50000.0]: + sc = 0.15 * Ctot + r = xs01_exec(P, idx, sc) + out[Ctot] = r["exec_share"] + tag = " <- dichiarato" if Ctot == 20000 else "" + print(f" {Ctot:>8.0f} {sc:>11.0f} {r['exec_share'] * 100:>14.1f}% {r['orders_py']:>12.0f} " + f"{r['med_order']:>9.0f}{tag}") + print(" Lettura: exec-turnover% = quota del turnover modellato che supera il min-order $10 per gamba.") + print(" Sotto ~80-90% il libro reale diverge dal backtest (tracking error non modellato).") + + print("\n CC01 (cash-and-carry HL, spot+perp stesso asset — Sharpe modellato = ARTEFATTO, v. diario):") + for Ctot in CAPITALS: + for N in (2, 4, 19): + D = 0.75 * Ctot # deploy carry: spot cash-funded + margine perp ~D/3 + per_leg = D / (2 * N) + yld = (0.08 * D, 0.14 * D) + if N == 4: + print(f" C={Ctot:>5.0f} N={N:>2} asset ({2 * N} gambe): ${per_leg:,.0f}/gamba; " + f"funding 8-14% su ${D:,.0f} = ${yld[0]:,.0f}-{yld[1]:,.0f}/anno") + print(" -> il MIN-ORDER non e' il muro di CC01 gia' a 2k (gambe > $90); i muri sono strutturali:") + print(" (a) Sharpe 11-13 artefatto (manca il 2022: deleveraging/funding-negativo/basis blowout),") + print(" (b) funding HL non eseguibile da Deribit (secondo venue + travaso capitale),") + print(" (c) liquidazione short e slippage non modellati. A 5k il carry atteso ($300-525/anno") + print(" lordi, prociclico) NON paga il rischio operativo: resta LEAD da rivedere a ~20k+.") + return out + + +# =========================================================================== +# (7) SINTESI OPERATIVA +# =========================================================================== +def book_cagr_canonical() -> float: + """CAGR canonico del book Deribit (0.75*TP01 + 0.25*SKH01, daily) — SOLO per confronto col + band de-luckato 10-15% (audit SKH01 2026-07-02: hourly-path + fase mediana).""" + from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # sola lettura + tp = _tp01_returns() + sk = _skyhook_returns() + if tp.index.tz is None: + tp.index = tp.index.tz_localize("UTC") + J = pd.concat({"tp": tp, "sk": sk}, axis=1, join="inner").fillna(0.0) + r = 0.75 * J["tp"] + 0.25 * J["sk"] + eq = float(np.prod(1.0 + r.values)) + yrs = len(r) / 365.25 + return eq ** (1.0 / yrs) - 1.0 + + +def section7(s1, s2, s3, s4, s5, s6): + n_eth_12, m_eth, m_btc = s5 + print() + print("=" * 100) + print("(7) SINTESI OPERATIVA — capitale x vincoli (numeri dalle sezioni sopra)") + print("=" * 100) + hdr = (f" {'capitale':>8} | {'TP01 haircut':>12} | {'book inv.% cap300':>17} | {'K=2 turn%':>9} | " + f"{'statarb hc':>10} | {'spreadETH@12%':>13} | {'XS01 exec%':>10}") + print(hdr) + print(" " + "-" * (len(hdr) - 2)) + for C in CAPITALS: + inv300 = s2.get((C, "300 (oggi)"), float("nan")) + print(f" {C:>8.0f} | {s1[C]['haircut']:>12.3f} | {inv300 * 100:>16.1f}% | " + f"{s3[(C, 2)] * 100:>8.1f}% | {s4[C]:>10.3f} | {n_eth_12[C]:>13d} | " + f"{s6[C] * 100:>9.1f}%") + try: + cagr = book_cagr_canonical() + print(f"\n CAGR canonico book Deribit (0.75 TP01 + 0.25 SKH01): {cagr * 100:.1f}%/anno " + f"(lens research, ancora canonica)") + except Exception as e: # pragma: no cover — il resto del report resta valido + print(f"\n (CAGR canonico book non calcolabile in questo run: {e})") + print(" ASPETTATIVA ONESTA (CAGR de-luckato 10-15%/anno — audit SKH01 2026-07-02, path orario +") + print(" fase mediana, NON i numeri canonici):") + for C in (2000.0, 5000.0): + lo, hi = C * 0.10 / 365.25, C * 0.15 / 365.25 + print(f" a ${C:,.0f}: ~EUR {lo:.2f}-{hi:.2f}/giorno (=${C * 0.10:,.0f}-{C * 0.15:,.0f}/anno)") + print(" Regola di onesta' del progetto INVARIATA: EUR 50/giorno richiede ~130k di capitale a questo") + print(" CAGR — il funding a 2-5k NON cambia l'ordine di grandezza, cambia solo cosa e' ESEGUIBILE.") + print() + print(" RACCOMANDAZIONI CONFIG (SOLO PROPOSTE — config/live.json non viene toccato da questo script):") + print(" 1. max_notional_per_asset_usd: alzarlo INSIEME al funding mantenendo il rapporto attuale") + print(" cap = equity/2 -> $1000 a 2k, $1750 a 3.5k, $2500 a 5k. Col cap fermo a $300 il book") + print(" resta strutturalmente sotto-investito (vedi colonna 'book inv.% cap300' sopra).") + print(" 2. min_order_usd $5: lasciarlo (floor Deribit). Nessuna banda artificiale in piu': il fee") + print(" drag risale verso il modellato (~0.4%/anno max, sez.1) ed e' il costo corretto della") + print(" fedelta' al target (l'ondata timing ha mostrato che il lag costa piu' del risparmio).") + print(" 3. Tranching K=2: la matematica degli ordini regge da ~2k (exec-turnover >95%/tranche,") + print(" sez.3) e a 5k e' matura (98%), MA (a) il beneficio atteso e' solo riduzione di varianza") + print(" della STIMA (Delta-Sharpe n.s., diario timing) e (b) il blocco feed-intraday-fuori-path-") + print(" certificato resta intero -> NON cablarlo ora; rivalutare solo con feed intraday validato.") + print(" 4. Opzioni: a 3.5-5k il singolo spread ETH min-size e' sostenibile a peso ~12% (sez.5),") + print(" ma la regola standing 'niente short-vol da modello' NON decade col capitale.") + print(" 5. XS01/CC01: restano STAT-MODE/LEAD anche a 5k (sez.6). Soglia ~20k confermata per XS01") + print(" come ordine di grandezza; per CC01 il capitale non e' comunque il muro binding.") + + +def main(): + s1 = section1() + s2 = section2() + s3 = section3() + s4 = section4() + s5 = section5() + s6 = section6() + section7(s1, s2, s3, s4, s5, s6) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0702_ell_channel.py b/scripts/research/r0702_ell_channel.py new file mode 100644 index 0000000..88f841b --- /dev/null +++ b/scripts/research/r0702_ell_channel.py @@ -0,0 +1,692 @@ +"""r0702_ell_channel — "tecnica del canale" Elliott (Ftaonline): falsificazione onesta. + +FILONE (2026-07-02): l'unica parte pienamente meccanica/falsificabile del metodo Elliott +dell'analista Ftaonline: + - Swing meccanici (zigzag causale k*ATR): pivot 0 (origine), massimo "onda 1", minimo + "onda 2" (vincolo: sopra l'origine, altrimenti conteggio NULLO). + - Canale: retta 0 -> minimo onda 2; parallela dal massimo di onda 1. + - SEGNALE 1: close FUORI dal lato alto del canale = "onda 3" -> long al close della barra + di rottura; target = min(onda2) + 1.618 * ampiezza(onda 1); stop = min(onda 2). + - SEGNALE 2 (variante): rottura del massimo di onda 3 dopo un pivot di onda 4 che NON + sovrappone il territorio di onda 1 -> target = min(onda4) + 1.0 * ampiezza(onda 1). + - REGOLA DISCRIMINANTE: movimento che NON esce mai dal canale = correttivo (nessun trade; + segnale opposto alla violazione della base). Testata separatamente con null permutato. + - Speculare per lo short. + +COVERAGE (scripts/research/alt/runs, sweep 104 famiglie 2026-06-20): BRK01 (Donchian LS/LF), +BRK02 (Donchian+chandelier), BRK03 (Keltner), BRK04 (Bollinger), BRK05 (ATR-range), BRK08 +(NR7), BRK09 (inside-bar), BRK10 (squeeze) + SKH01 coprono la famiglia breakout-canale, ma +NESSUNO costruisce canali da pivot zigzag con vincoli d'onda e target 1.618 -> non identico, +pero' stessa famiglia: per giudizio si confronta ANCHE contro un Donchian a pari geometria +(stop = base canale, target = base + 1.618*larghezza) e pari frequenza di trade. + +ONESTA': + - Pivot noti solo alla CONFERMA (reversal k*ATR dal running extreme); il canale usa solo + pivot confermati al tempo t. Guard: al.causality_ok (prefix-recompute). + - Entry a close[i] della barra che CHIUDE fuori dal canale (mai fill sull'estremo). + - Exit a target/stop/timeout ESEGUITE AL CLOSE della barra che li tocca (gap-through-stop + reale, lezione SKH01). Il fill-al-livello e' riportato SOLO come lens ottimista dichiarata. + - Fee 0.10% RT + sweep 0.00-0.20% (al.study_weights). + - Selezione cella IN-SAMPLE-ONLY + deflated Sharpe su TUTTA la griglia + (al.study_family_honest); marginale vs TP01 (al.study_marginal). + - 4h: banda d'ancora su offset 0/1/2/3h (regola anchor-luck 2026-07-02). Epoca ms + ESPLICITA nel resample (MAI DatetimeIndex.view("int64")). + - Hold-out 2025+ mai usato per selezionare. Timeout 150 barre FISSO (non cercato). + +Output temporanei: scratchpad ell_c_*. Diario: da scrivere a valle (a cura del chiamante). +""" +from __future__ import annotations + +import json +import sys +import time + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +SCRATCH = ("/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad") +TFS = ("1d", "4h", "1h") +KS = (2.0, 3.0, 4.0) # zigzag: reversal = k * ATR(14) +VARIANTS = ("s1_long", "s1_ls", "s12_ls") +TIMEOUT = 150 # barre, fisso e dichiarato (non cercato) +ATR_WIN = 14 +TGT1, TGT2 = 1.618, 1.0 +SEED = 20260702 + +_ZZ_CACHE: dict = {} +_SIM_CACHE: dict = {} + + +def _dfkey(df: pd.DataFrame, asset: str): + return (asset, int(df["timestamp"].iloc[0]), int(df["timestamp"].iloc[-1]), len(df)) + + +# =========================================================================== +# 1) ZIGZAG CAUSALE — pivot confermato SOLO quando il close ritraccia k*ATR +# dal running extreme. Ogni valore usa dati <= i (prefix-stable). +# =========================================================================== +def zigzag(df: pd.DataFrame, k: float, asset: str = "?"): + """Ritorna lista di pivot confermati (conf_i, piv_i, piv_price, kind); kind +1=high, + -1=low. Un pivot e' utilizzabile solo da conf_i in poi.""" + key = _dfkey(df, asset) + (k,) + if key in _ZZ_CACHE: + return _ZZ_CACHE[key] + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + a = al.atr(df, ATR_WIN) + n = len(c) + piv = [] + dir_ = 0 # 0=unknown, +1=gamba su (caccio un high), -1=gamba giu + hi_v, hi_i = h[0], 0 + lo_v, lo_i = l[0], 0 + for i in range(1, n): + thr = k * a[i] + if dir_ >= 0 and h[i] > hi_v: + hi_v, hi_i = h[i], i + if dir_ <= 0 and l[i] < lo_v: + lo_v, lo_i = l[i], i + if dir_ >= 0 and c[i] < hi_v - thr: + piv.append((i, hi_i, float(hi_v), +1)) + dir_ = -1 + j0 = hi_i + 1 + if j0 <= i: + seg = l[j0:i + 1] + off = int(np.argmin(seg)) + lo_v, lo_i = float(seg[off]), j0 + off + else: + lo_v, lo_i = l[i], i + elif dir_ <= 0 and c[i] > lo_v + thr: + piv.append((i, lo_i, float(lo_v), -1)) + dir_ = +1 + j0 = lo_i + 1 + if j0 <= i: + seg = h[j0:i + 1] + off = int(np.argmax(seg)) + hi_v, hi_i = float(seg[off]), j0 + off + else: + hi_v, hi_i = h[i], i + _ZZ_CACHE[key] = piv + return piv + + +# =========================================================================== +# 2) STATE MACHINE canale Elliott — forward-only, un trade alla volta. +# =========================================================================== +def simulate(df: pd.DataFrame, k: float, variant: str, asset: str = "?") -> dict: + key = _dfkey(df, asset) + (k, variant) + if key in _SIM_CACHE: + return _SIM_CACHE[key] + piv = zigzag(df, k, asset) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + n = len(c) + pos = np.zeros(n) + trades: list = [] + events: list = [] + cnt = dict(setups_long=0, setups_short=0, null_count=0, superseded=0) + allow_short = variant in ("s1_ls", "s12_ls") + allow_s2 = variant == "s12_ls" + LS = SS = None # setup long / short attivo + S2L = S2S = None # stato onda-5 (dopo S1 chiuso a target) + tr = None # trade aperto + pi = 0 + for i in range(1, n): + # --- (a) gestione trade aperto: exit AL CLOSE della barra che tocca ------------ + if tr is not None and i > tr["e"]: + reason = None + if tr["dir"] == +1: + if l[i] <= tr["stp"]: + reason = "stop" # stop prioritario se tocca entrambi + elif h[i] >= tr["tgt"]: + reason = "target" + else: + if h[i] >= tr["stp"]: + reason = "stop" + elif l[i] <= tr["tgt"]: + reason = "target" + if reason is None and i - tr["e"] >= TIMEOUT: + reason = "timeout" + if reason: + tr["x"], tr["exit_px"], tr["reason"] = i, float(c[i]), reason + trades.append(tr) + if allow_s2 and tr["sig"] == "S1" and reason == "target": + if tr["dir"] == +1: + S2L = dict(stage="w3h", P1p=tr["P1p"], amp=tr["amp"], after=tr["e"]) + else: + S2S = dict(stage="w3l", P1p=tr["P1p"], amp=tr["amp"], after=tr["e"]) + tr = None + pos[i] = 0.0 + else: + pos[i] = tr["dir"] + # --- (b) pivot confermati a questa barra ---------------------------------------- + while pi < len(piv) and piv[pi][0] == i: + _, p_i, p_px, kind = piv[pi] + pi += 1 + if kind == -1: # nuovo pivot LOW + if S2L is not None and S2L.get("stage") == "w4l" and p_i > S2L["w3h_i"]: + if p_px > S2L["P1p"]: # onda 4 NON sovrappone onda 1 + S2L["w4l_px"], S2L["stage"] = p_px, "brk" + else: + S2L = None + if S2S is not None and S2S.get("stage") == "w3l" and p_i > S2S["after"]: + S2S["w3l_px"], S2S["w3l_i"], S2S["stage"] = p_px, p_i, "w4h" + if pi >= 3: + t0, t1, t2 = piv[pi - 3], piv[pi - 2], piv[pi - 1] + if t0[3] == -1 and t1[3] == +1 and t2[3] == -1: + if t2[2] > t0[2]: # vincolo: onda 2 sopra l'origine + if LS is not None and not LS["done"]: + cnt["superseded"] += 1 + m = (t2[2] - t0[2]) / (t2[1] - t0[1]) + LS = dict(P0i=t0[1], P0p=t0[2], P1i=t1[1], P1p=t1[2], + P2i=t2[1], P2p=t2[2], m=m, conf=i, done=False) + cnt["setups_long"] += 1 + else: + cnt["null_count"] += 1 + LS = None + else: # nuovo pivot HIGH + if S2L is not None and S2L.get("stage") == "w3h" and p_i > S2L["after"]: + S2L["w3h_px"], S2L["w3h_i"], S2L["stage"] = p_px, p_i, "w4l" + if S2S is not None and S2S.get("stage") == "w4h" and p_i > S2S["w3l_i"]: + if p_px < S2S["P1p"]: + S2S["w4h_px"], S2S["stage"] = p_px, "brk" + else: + S2S = None + if pi >= 3: + t0, t1, t2 = piv[pi - 3], piv[pi - 2], piv[pi - 1] + if t0[3] == +1 and t1[3] == -1 and t2[3] == +1: + if t2[2] < t0[2]: # speculare: onda 2 sotto l'origine + if SS is not None and not SS["done"]: + cnt["superseded"] += 1 + m = (t2[2] - t0[2]) / (t2[1] - t0[1]) + SS = dict(P0i=t0[1], P0p=t0[2], P1i=t1[1], P1p=t1[2], + P2i=t2[1], P2p=t2[2], m=m, conf=i, done=False) + cnt["setups_short"] += 1 + else: + cnt["null_count"] += 1 + SS = None + # --- (c) monitoraggio setup + entry a close[i] ---------------------------------- + if LS is not None and not LS["done"]: + up = LS["P1p"] + LS["m"] * (i - LS["P1i"]) + base = LS["P0p"] + LS["m"] * (i - LS["P0i"]) + if c[i] > up: + LS["done"] = True + events.append(dict(kind="impulse", side=+1, bar=i)) + if tr is None: + amp = LS["P1p"] - LS["P0p"] + tr = dict(dir=+1, sig="S1", e=i, entry_px=float(c[i]), + tgt=LS["P2p"] + TGT1 * amp, stp=LS["P2p"], + P1p=LS["P1p"], amp=amp) + pos[i] = 1.0 + elif c[i] < base: + LS["done"] = True + events.append(dict(kind="corrective", side=+1, bar=i)) + if SS is not None and not SS["done"]: + dn = SS["P1p"] + SS["m"] * (i - SS["P1i"]) + base = SS["P0p"] + SS["m"] * (i - SS["P0i"]) + if c[i] < dn: + SS["done"] = True + events.append(dict(kind="impulse", side=-1, bar=i)) + if tr is None and allow_short: + amp = SS["P0p"] - SS["P1p"] + tr = dict(dir=-1, sig="S1", e=i, entry_px=float(c[i]), + tgt=SS["P2p"] - TGT1 * amp, stp=SS["P2p"], + P1p=SS["P1p"], amp=amp) + pos[i] = -1.0 + elif c[i] > base: + SS["done"] = True + events.append(dict(kind="corrective", side=-1, bar=i)) + # --- (d) SEGNALE 2 (onda 5) ----------------------------------------------------- + if allow_s2 and S2L is not None and S2L.get("stage") == "brk": + if c[i] < S2L["w4l_px"]: + S2L = None + elif c[i] > S2L["w3h_px"] and tr is None: + tr = dict(dir=+1, sig="S2", e=i, entry_px=float(c[i]), + tgt=S2L["w4l_px"] + TGT2 * S2L["amp"], stp=S2L["w4l_px"], + P1p=S2L["P1p"], amp=S2L["amp"]) + pos[i] = 1.0 + S2L = None + if allow_s2 and S2S is not None and S2S.get("stage") == "brk": + if c[i] > S2S["w4h_px"]: + S2S = None + elif c[i] < S2S["w3l_px"] and tr is None: + tr = dict(dir=-1, sig="S2", e=i, entry_px=float(c[i]), + tgt=S2S["w4h_px"] - TGT2 * S2S["amp"], stp=S2S["w4h_px"], + P1p=S2S["P1p"], amp=S2S["amp"]) + pos[i] = -1.0 + S2S = None + out = dict(pos=pos, trades=trades, events=events, counters=cnt) + _SIM_CACHE[key] = out + return out + + +def make_target(k: float, variant: str): + def target_fn(df, asset): + return simulate(df, k, variant, asset)["pos"] + return target_fn + + +def factory(tf=None, k=3.0, variant="s1_ls"): + # tf consumata da study_family_honest/candidate_daily (carica il df giusto) + return make_target(k, variant) + + +# =========================================================================== +# 3) COMPARATORE DONCHIAN "banale" a pari geometria (stop=base canale, +# target = base + 1.618*larghezza) — stesso engine close-exec. +# =========================================================================== +def donch_sim(df: pd.DataFrame, N: int, allow_short: bool, asset: str = "?") -> dict: + key = _dfkey(df, asset) + ("donch", N, allow_short) + if key in _SIM_CACHE: + return _SIM_CACHE[key] + hi, lo = al.donchian(df, N) # shiftati -> causali + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + n = len(c) + pos = np.zeros(n) + trades = [] + tr = None + for i in range(1, n): + if tr is not None and i > tr["e"]: + reason = None + if tr["dir"] == +1: + if l[i] <= tr["stp"]: + reason = "stop" + elif h[i] >= tr["tgt"]: + reason = "target" + else: + if h[i] >= tr["stp"]: + reason = "stop" + elif l[i] <= tr["tgt"]: + reason = "target" + if reason is None and i - tr["e"] >= TIMEOUT: + reason = "timeout" + if reason: + tr["x"], tr["exit_px"], tr["reason"] = i, float(c[i]), reason + trades.append(tr) + tr = None + pos[i] = 0.0 + else: + pos[i] = tr["dir"] + if tr is None and np.isfinite(hi[i]) and np.isfinite(lo[i]): + W = hi[i] - lo[i] + if c[i] > hi[i] and W > 0: + tr = dict(dir=+1, sig="D", e=i, entry_px=float(c[i]), + tgt=lo[i] + TGT1 * W, stp=lo[i]) + pos[i] = 1.0 + elif allow_short and c[i] < lo[i] and W > 0: + tr = dict(dir=-1, sig="D", e=i, entry_px=float(c[i]), + tgt=hi[i] - TGT1 * W, stp=hi[i]) + pos[i] = -1.0 + out = dict(pos=pos, trades=trades) + _SIM_CACHE[key] = out + return out + + +# =========================================================================== +# 4) 4h con ancora spostata (offset 0/1/2/3h) — epoca ms ESPLICITA. +# =========================================================================== +def get_4h_anchor(asset: str, off: int) -> pd.DataFrame: + g = al.get(asset, "1h").copy() + idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True) + idx.name = "dt" + g.index = idx + out = g.resample("4h", label="left", closed="left", + offset=pd.Timedelta(hours=off)).agg( + {"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}) + out = out.dropna(subset=["open"]) + out["datetime"] = out.index + epoch = pd.Timestamp("1970-01-01", tz="UTC") + out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64") + return out.reset_index(drop=True)[ + ["timestamp", "open", "high", "low", "close", "volume", "datetime"]] + + +# =========================================================================== +# 5) STATISTICHE: claim discriminante + target 1.618 vs null vol-matched. +# =========================================================================== +def discriminant_test(tf: str, k: float, H: int = 10, B: int = 2000) -> dict: + """Claim: un movimento che NON esce dal canale (violazione base senza uscita alta) e' + correttivo -> follow-through nella direzione del conteggio NEGATIVO (reversal). + Statistica: media del forward-return H-barre ALLINEATO al conteggio, normalizzato ATR, + vs null di barre casuali (stessi segni). p_low piccolo => claim supportata.""" + rng = np.random.default_rng(SEED) + out = {} + for a in al.CERTIFIED: + df = al.get(a, tf) + sim = simulate(df, k, "s1_ls", a) + c = df["close"].values.astype(float) + atr_ = al.atr(df, ATR_WIN) + n = len(c) + f = np.full(n, np.nan) + f[:n - H] = (c[H:] / c[:n - H] - 1.0) / np.maximum(atr_[:n - H] / c[:n - H], 1e-9) / np.sqrt(H) + res = {} + for kind in ("corrective", "impulse"): + ev = [(e["bar"], e["side"]) for e in sim["events"] + if e["kind"] == kind and e["bar"] + H < n and e["bar"] >= 50] + if len(ev) < 5: + res[kind] = dict(n=len(ev), mean=None, p_low=None, p_high=None) + continue + bars = np.array([b for b, _ in ev]) + sides = np.array([s for _, s in ev], float) + obs = float(np.mean(f[bars] * sides)) + valid = np.arange(50, n - H - 1) + draws = rng.choice(valid, size=(B, len(ev))) + null = (f[draws] * sides[None, :]).mean(axis=1) + res[kind] = dict(n=len(ev), mean=round(obs, 4), + null_mean=round(float(np.mean(null)), 4), + p_low=round(float(np.mean(null <= obs)), 4), + p_high=round(float(np.mean(null >= obs)), 4)) + out[a] = res + return out + + +def _first_touch(h, l, s, dirn, tgt, stp): + wh = h[s + 1:s + 1 + TIMEOUT] + wl = l[s + 1:s + 1 + TIMEOUT] + if dirn == +1: + mT, mS = wh >= tgt, wl <= stp + else: + mT, mS = wl <= tgt, wh >= stp + iT = int(np.argmax(mT)) if mT.any() else 10 ** 9 + iS = int(np.argmax(mS)) if mS.any() else 10 ** 9 + return 1 if iT < iS else 0 # pari barra -> stop prioritario (come l'engine) + + +def target_hit_vs_null(tf: str, k: float, variant: str, B: int = 300) -> dict: + """Freq. con cui il target 1.618 viene toccato prima dello stop nei trade REALI vs + null: stessa geometria (distanze % target/stop, direzione, timeout) da barre CASUALI. + p_high piccolo => la struttura d'onda tempa meglio del caso a parita' di geometria.""" + rng = np.random.default_rng(SEED + 1) + out = {} + for a in al.CERTIFIED: + df = al.get(a, tf) + sim = simulate(df, k, variant, a) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + n = len(c) + trs = [t for t in sim["trades"] if t["sig"] == "S1"] + if len(trs) < 5: + out[a] = dict(n=len(trs), real=None) + continue + real = float(np.mean([1 if t["reason"] == "target" else 0 for t in trs])) + geo = [(t["dir"], abs(t["tgt"] / t["entry_px"] - 1.0), + abs(1.0 - t["stp"] / t["entry_px"])) for t in trs] + if len(geo) > 300: # cap dichiarato per il bootstrap (compute) + sel = rng.choice(len(geo), size=300, replace=False) + geo = [geo[int(s)] for s in sel] + nulls = np.empty(B) + lo_s, hi_s = 50, n - TIMEOUT - 2 + for b in range(B): + starts = rng.integers(lo_s, hi_s, size=len(geo)) + hits = 0 + for (dirn, dT, dS), s in zip(geo, starts): + e = c[s] + if dirn == +1: + hits += _first_touch(h, l, s, +1, e * (1 + dT), e * (1 - dS)) + else: + hits += _first_touch(h, l, s, -1, e * (1 - dT), e * (1 + dS)) + nulls[b] = hits / len(geo) + out[a] = dict(n=len(trs), real=round(real, 3), + null_mean=round(float(np.mean(nulls)), 3), + p_high=round(float(np.mean(nulls >= real)), 4), + p_low=round(float(np.mean(nulls <= real)), 4)) + return out + + +# =========================================================================== +# 6) LENS OTTIMISTA (fill al livello) — dichiarata, solo per confronto. +# =========================================================================== +def lens_compare(tf: str, k: float, variant: str, fee_rt: float = 0.001) -> dict: + out = {} + for a in al.CERTIFIED: + df = al.get(a, tf) + trs = simulate(df, k, variant, a)["trades"] + if not trs: + out[a] = dict(n=0) + continue + close_r, level_r = [], [] + for t in trs: + rc = t["dir"] * (t["exit_px"] / t["entry_px"] - 1.0) - fee_rt + if t["reason"] == "target": + px = t["tgt"] + elif t["reason"] == "stop": + px = t["stp"] + else: + px = t["exit_px"] + rl = t["dir"] * (px / t["entry_px"] - 1.0) - fee_rt + close_r.append(rc) + level_r.append(rl) + out[a] = dict(n=len(trs), + exp_close=round(float(np.mean(close_r)), 5), + exp_level=round(float(np.mean(level_r)), 5), + tot_close=round(float(np.prod(1 + np.array(close_r)) - 1), 4), + tot_level=round(float(np.prod(1 + np.array(level_r)) - 1), 4)) + return out + + +# =========================================================================== +# MAIN +# =========================================================================== +def main(): + t0 = time.time() + report = [] + + def say(s=""): + print(s, flush=True) + report.append(s) + + say("=" * 88) + say("r0702_ell_channel — canale Elliott (Ftaonline), falsificazione onesta") + say("=" * 88) + + # ---------- TABELLA CELLE ---------------------------------------------------------- + rows = [] + say("\n[1] TABELLA CELLE (TF x k x variante) — net 0.10% RT, exit al close (onesto)") + hdr = (f"{'tf':>4} {'k':>3} {'variante':>8} | " + f"{'BTC f/h':>12} {'nT':>4} {'DD%':>5} | {'ETH f/h':>12} {'nT':>4} {'DD%':>5} | " + f"{'COMB full':>9} {'hold':>6} {'inS':>6} {'DD%':>5}") + say(hdr) + say("-" * len(hdr)) + for tf in TFS: + for k in KS: + for v in VARIANTS: + per, parts = {}, {} + for a in al.CERTIFIED: + df = al.get(a, tf) + sim = simulate(df, k, v, a) + ev = al.eval_weights(df, sim["pos"]) + per[a] = dict(full=ev["full"], hold=ev["holdout"], + ntr=len(sim["trades"]), cnt=sim["counters"]) + parts[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(parts, axis=1, join="inner").fillna(0.0) + comb = al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"]) + ins = comb[comb.index < al.HOLDOUT] + hold = comb[comb.index >= al.HOLDOUT] + r = dict(tf=tf, k=k, var=v, + btc_full=per["BTC"]["full"]["sharpe"], + btc_hold=per["BTC"]["hold"].get("sharpe", 0.0), + btc_dd=per["BTC"]["full"]["maxdd"], btc_n=per["BTC"]["ntr"], + eth_full=per["ETH"]["full"]["sharpe"], + eth_hold=per["ETH"]["hold"].get("sharpe", 0.0), + eth_dd=per["ETH"]["full"]["maxdd"], eth_n=per["ETH"]["ntr"], + comb_full=round(al._sh(comb), 3), comb_hold=round(al._sh(hold), 3), + comb_ins=round(al._sh(ins), 3), comb_dd=round(al._dd_ret(comb), 4), + counters=dict(BTC=per["BTC"]["cnt"], ETH=per["ETH"]["cnt"])) + rows.append(r) + flag = " (<30 trade!)" if min(r["btc_n"], r["eth_n"]) < 30 else "" + say(f"{tf:>4} {k:>3.0f} {v:>8} | " + f"{r['btc_full']:>+5.2f}/{r['btc_hold']:>+5.2f} {r['btc_n']:>4d} " + f"{r['btc_dd']*100:>5.1f} | " + f"{r['eth_full']:>+5.2f}/{r['eth_hold']:>+5.2f} {r['eth_n']:>4d} " + f"{r['eth_dd']*100:>5.1f} | " + f"{r['comb_full']:>+9.2f} {r['comb_hold']:>+6.2f} {r['comb_ins']:>+6.2f} " + f"{r['comb_dd']*100:>5.1f}{flag}") + say(f"\n (tempo tabella: {time.time()-t0:.0f}s)") + + # ---------- FAMILY HONEST (selezione in-sample + deflated Sharpe) ------------------ + say("\n[2] study_family_honest — cella scelta IN-SAMPLE-ONLY + DSR su tutta la griglia") + grid = [dict(k=k, variant=v) for k in KS for v in VARIANTS] + fam = al.study_family_honest("ELLCH", factory, grid, TFS) + ch = fam["chosen"] + say(f" celle valutate: {fam['n_cells']} cella in-sample: tf={ch['tf']} " + f"params={ch['params']} (inS Sharpe {ch['insample_sharpe']}, full {ch['full_sharpe']})") + say(f" deflated Sharpe = {fam['deflated_sharpe']} (null max atteso " + f"{fam['expected_null_max']}) dsr_pass={fam['dsr_pass']}") + say(f" earns_slot_marginal={fam['earns_slot_marginal']} " + f"EARNS_SLOT_HONEST={fam['earns_slot_honest']}") + say(al.fmt_marginal(fam["marginal"])) + ck, cv, ctf = ch["params"]["k"], ch["params"]["variant"], ch["tf"] + + # ---------- CAUSALITA' ------------------------------------------------------------- + say("\n[3] causality_ok (prefix-recompute) sulla cella scelta") + for tf_chk in {ctf, "1h"}: + cz = al.causality_ok(make_target(ck, cv), tf=tf_chk) + say(f" tf={tf_chk}: ok={cz['ok']} max_tail_diff={cz['max_tail_diff']} " + f"checked={cz['checked']}") + + # ---------- FEE SWEEP + SMALLCAP sulla cella scelta --------------------------------- + say("\n[4] fee sweep 0.00-0.20% RT + haircut small-cap ($600, min order $5) — cella scelta") + sw = al.study_weights(f"ELLCH k={ck} {cv}", make_target(ck, cv), tfs=(ctf,)) + cell = sw["cells"][0] + for a in al.CERTIFIED: + say(f" {a}: fee_sweep={cell['per_asset'][a]['fee_sweep']}") + df = al.get(a, ctf) + sc = al.eval_weights_smallcap(df, simulate(df, ck, cv, a)["pos"]) + say(f" smallcap: modeled Sh {sc['modeled']['sharpe']} -> real " + f"{sc['realistic']['sharpe']} (haircut {sc['sharpe_haircut']}, " + f"{sc['n_executed_trades']} ordini)") + + # ---------- COMPARATORE DONCHIAN ---------------------------------------------------- + say("\n[5] Donchian 'banale' a pari geometria (stop=base, target=base+1.618*W), " + "close-exec, stesso timeout") + donch_rows = [] + allow_short = cv in ("s1_ls", "s12_ls") + for tf in TFS: + ell_r = next(r for r in rows if r["tf"] == tf and r["k"] == ck and r["var"] == cv) + ell_n = (ell_r["btc_n"] + ell_r["eth_n"]) / 2 + for N in (20, 55, 100): + per, parts = {}, {} + for a in al.CERTIFIED: + df = al.get(a, tf) + sim = donch_sim(df, N, allow_short, a) + ev = al.eval_weights(df, sim["pos"]) + per[a] = dict(full=ev["full"]["sharpe"], hold=ev["holdout"].get("sharpe", 0.0), + ntr=len(sim["trades"])) + parts[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(parts, axis=1, join="inner").fillna(0.0) + comb = al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"]) + dn = (per["BTC"]["ntr"] + per["ETH"]["ntr"]) / 2 + donch_rows.append(dict(tf=tf, N=N, per=per, ntr=dn, + comb_full=round(al._sh(comb), 3), + comb_hold=round(al._sh(comb[comb.index >= al.HOLDOUT]), 3), + comb=comb, freq_gap=abs(dn - ell_n))) + best = min([d for d in donch_rows if d["tf"] == tf], key=lambda d: d["freq_gap"]) + # corr Elliott(cella scelta k,cv su questo tf) vs Donchian matched + parts_e = {} + for a in al.CERTIFIED: + df = al.get(a, tf) + ev = al.eval_weights(df, simulate(df, ck, cv, a)["pos"]) + parts_e[a] = pd.Series(ev["net"], index=ev["idx"]) + JE = pd.concat(parts_e, axis=1, join="inner").fillna(0.0) + comb_e = al._to_daily(0.5 * JE["BTC"] + 0.5 * JE["ETH"]) + JJ = pd.concat({"E": comb_e, "D": best["comb"]}, axis=1, join="inner").dropna() + corr = round(float(JJ["E"].corr(JJ["D"])), 3) if len(JJ) > 30 else None + for d in [x for x in donch_rows if x["tf"] == tf]: + mark = " <-- freq-matched" if d is best else "" + say(f" {tf:>4} N={d['N']:>3d}: comb full {d['comb_full']:>+5.2f} hold " + f"{d['comb_hold']:>+5.2f} nT/asset~{d['ntr']:.0f}{mark}") + say(f" {tf:>4} ELLIOTT (k={ck:.0f},{cv}): comb full {ell_r['comb_full']:>+5.2f} " + f"hold {ell_r['comb_hold']:>+5.2f} nT/asset~{ell_n:.0f} " + f"corr(Elliott,Donch-matched)={corr}") + + # ---------- BANDA D'ANCORA 4h ------------------------------------------------------- + say("\n[6] Banda d'ancora 4h (offset 0/1/2/3h) — cella 4h migliore IN-SAMPLE") + r4 = [r for r in rows if r["tf"] == "4h"] + best4 = max(r4, key=lambda r: r["comb_ins"]) + say(f" cella 4h in-sample: k={best4['k']:.0f} var={best4['var']} " + f"(inS {best4['comb_ins']}, full {best4['comb_full']}, hold {best4['comb_hold']})") + anchor = {} + for off in (0, 1, 2, 3): + parts = {} + for a in al.CERTIFIED: + df = get_4h_anchor(a, off) + sim = simulate(df, best4["k"], best4["var"], f"{a}@+{off}h") + ev = al.eval_weights(df, sim["pos"]) + parts[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(parts, axis=1, join="inner").fillna(0.0) + comb = al._to_daily(0.5 * J["BTC"] + 0.5 * J["ETH"]) + anchor[off] = dict(full=round(al._sh(comb), 3), + hold=round(al._sh(comb[comb.index >= al.HOLDOUT]), 3)) + say(f" offset +{off}h: comb full {anchor[off]['full']:>+5.2f} " + f"hold {anchor[off]['hold']:>+5.2f}") + fulls = [v["full"] for v in anchor.values()] + holds = [v["hold"] for v in anchor.values()] + say(f" banda full [{min(fulls):+.2f},{max(fulls):+.2f}] " + f"hold [{min(holds):+.2f},{max(holds):+.2f}]") + + # ---------- CLAIM DISCRIMINANTE ----------------------------------------------------- + say("\n[7] Claim discriminante: 'mai fuori dal canale = correttivo' (fwd 10 barre " + "allineato al conteggio, ATR-norm, null permutato B=2000)") + disc = {} + for tf in TFS: + disc[tf] = discriminant_test(tf, ck) + for a in al.CERTIFIED: + d = disc[tf][a] + co, im = d["corrective"], d["impulse"] + say(f" {tf:>4} {a}: corrective n={co['n']} mean={co.get('mean')} " + f"(null {co.get('null_mean')}) p_low={co.get('p_low')} | " + f"impulse n={im['n']} mean={im.get('mean')} " + f"(null {im.get('null_mean')}) p_high={im.get('p_high')}") + + # ---------- TARGET 1.618 vs NULL ---------------------------------------------------- + say("\n[8] Target 1.618 toccato prima dello stop: freq reale vs null vol/geometry-" + "matched (B=300 bootstrap, stessa distanza %/direzione/timeout da barre casuali)") + thit = {} + for tf in TFS: + thit[tf] = target_hit_vs_null(tf, ck, cv) + for a in al.CERTIFIED: + t = thit[tf][a] + if t.get("real") is None: + say(f" {tf:>4} {a}: n={t['n']} (troppo pochi trade)") + else: + say(f" {tf:>4} {a}: n={t['n']} real={t['real']} null={t['null_mean']} " + f"p_high={t['p_high']} p_low={t['p_low']}") + + # ---------- LENS OTTIMISTA ---------------------------------------------------------- + say("\n[9] Lens fill-al-livello (OTTIMISTA, dichiarata) vs close-exec — cella scelta, " + f"tf={ctf}") + lc = lens_compare(ctf, ck, cv) + for a in al.CERTIFIED: + d = lc[a] + if d.get("n", 0) == 0: + say(f" {a}: 0 trade") + else: + say(f" {a}: n={d['n']} expectancy/trade close={d['exp_close']:+.4f} " + f"level={d['exp_level']:+.4f} tot close={d['tot_close']:+.2%} " + f"level={d['tot_level']:+.2%}") + + say(f"\n(tempo totale {time.time()-t0:.0f}s)") + + # ---------- SALVATAGGI --------------------------------------------------------------- + donch_save = [{kk: vv for kk, vv in d.items() if kk not in ("comb", "freq_gap")} + for d in donch_rows] + payload = dict(rows=rows, family=al._clean(fam), donchian=al._clean(donch_save), + anchor_4h=anchor, discriminant=disc, target_hit=thit, lens=lc, + chosen=dict(tf=ctf, k=ck, variant=cv)) + with open(f"{SCRATCH}/ell_c_results.json", "w") as f: + json.dump(al._clean(payload), f, default=str, indent=1) + with open(f"{SCRATCH}/ell_c_report.txt", "w") as f: + f.write("\n".join(report)) + say(f"salvato: {SCRATCH}/ell_c_results.json + ell_c_report.txt") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0702_ell_fibconfluence.py b/scripts/research/r0702_ell_fibconfluence.py new file mode 100644 index 0000000..1c6d762 --- /dev/null +++ b/scripts/research/r0702_ell_fibconfluence.py @@ -0,0 +1,562 @@ +"""r0702_ell_fibconfluence.py — FIBONACCI CONFLUENCE: il prezzo reagisce ai livelli Fib +piu' che a livelli QUALSIASI? (2026-07-02, filone ELL-B) + +CLAIM (video didattico, Casario): proiettando su swing di prezzo griglie di ritracciamento +(0.382/0.5/0.618) ed estensione (1.272/1.414/1.618/2.618) di Fibonacci, i livelli — e in +particolare le CONFLUENZE dove due griglie convergono — sarebbero zone di reazione del prezzo. +Claim testabile: la reazione ai livelli Fib batte quella a livelli con rapporti CASUALI +proiettati sugli STESSI swing? IL NULL E' TUTTO: senza placebo, "il prezzo ha reagito al 61.8%" +e' solo apofenia (qualunque livello dentro il range viene "toccato e rispettato" a volte). + +METODO (tutto a priori, dichiarato PRIMA di guardare i numeri): + * Swing MECCANICI e causali: zigzag a soglia k*ATR14 (k in {2,4}), NON-REPAINTING — un pivot + e' confermato solo quando close si e' mosso k*ATR dall'estremo; i livelli dello swing + (A->B) sono utilizzabili solo da confirm_idx+1 (timestamp di conferma esplicito). + * Griglia attiva per finestra [confirm(B)+1, confirm(pivot successivo)]: ritracciamenti + L = pB - r*M e estensioni L = pA + e*M (M = pB-pA). Finestre disgiunte per costruzione. + * TOCCO = low<=L<=high con la barra precedente che NON conteneva L (fresh touch); direzione + attesa dal lato di approccio (close[i-1]>L -> supporto -> atteso rimbalzo SU; giu'). + Decisione a close[i] (high/low noti a fine barra), reazione misurata da close[i] a + close[i+H], H in {5,20} barre — ESEGUIBILE (niente entry sugli estremi). La variante + "fill al livello" (da L invece che da close) e' riportata SOLO come lens ottimista. + * Statistica primaria: media della reazione segnata NORMALIZZATA per ATR (react/(ATR/close)) + sui tocchi IN-SAMPLE (pre-2025); raw bps riportati. Il drift da buy-the-dip e' identico + per Fib e placebo -> il percentile vs placebo lo neutralizza. + * NULL (a): 100 set di rapporti PLACEBO fissi — 3 ritracciamenti uniformi in [0.2,0.9] + + 4 estensioni uniformi in [1.05,3.0], esclusa banda max(0.02, 2%rel) attorno ai Fib veri — + proiettati sugli STESSI swing. NULL (b): 100 repliche con rapporti casuali RI-SORTEGGIATI + per ogni swing (stessa densita', nessuna coerenza cross-swing). NULL (c) — AGGIUNTO dopo + il run 1 come indurimento dichiarato: 100 set LOCATION-MATCHED (ogni rapporto = Fib vero + +/- jitter appena fuori la banda esclusa, entro ~6-8%) — il null uniforme (a) non e' + density-matched (la reazione media dipende da DOVE sta il rapporto, non solo da quale + numero e'), quindi (c) e' il test affilato di "0.618 e' speciale vs 0.58/0.66". + Verdetto = percentile del Fib vero nelle tre distribuzioni placebo. + * CONFLUENZA: ritracciamenti dello swing corrente x estensioni dello swing s-2 (stesso + verso, il precedente ha verso opposto e proietta sempre FUORI dal range corrente — bug + geometrico del run 1, corretto e dichiarato) entro eps=0.25*ATR(w0); reazione in zona + confluente vs livelli singoli delle stesse griglie, stesso placebo. + * MULTIPLE TESTING: 16 celle (2 asset x 2 TF x 2 k x 2 H). Soglia single-cell Bonferroni + ~0.997; verdetto famiglia (a priori): esiste (k,H) con percentile POOLED IS >= 0.99 su + ENTRAMBI i null E tutte le 4 celle asset x TF a pctl >= 0.90. Solo allora si strategizza + (fade ai livelli, fee 0.10% RT) con al.study_family_honest + al.study_marginal. + * Hold-out 2025-26 MAI usato per selezionare (qui non si seleziona nulla: rapporti fissati + dal claim; k/H riportati per cella, il verdetto e' sul pooled IS). + +CAUSALITA': pivot ricalcolati su prefisso troncato == pivot full con confirm list: + """Pivot alternati H/L: (pivot_idx, price, type +1=high/-1=low, confirm_idx). + Un pivot e' confermato quando close si muove k*ATR14[i] oltre l'estremo corrente. + Causale: usa solo dati <= i; il pivot NON viene mai spostato dopo la conferma.""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + a = al.atr(df, atr_win) + n = len(c) + piv: list = [] + start = atr_win + if n <= start + 2: + return piv + ehi, ehi_i = h[start], start + elo, elo_i = l[start], start + dir_ = 0 + i = start + 1 + while i < n and dir_ == 0: # bootstrap: direzione ignota + if h[i] > ehi: + ehi, ehi_i = h[i], i + if l[i] < elo: + elo, elo_i = l[i], i + if c[i] <= ehi - k_atr * a[i]: + piv.append((ehi_i, float(ehi), +1, i)) + dir_ = -1 + seg = l[ehi_i:i + 1] + elo_i = ehi_i + int(np.argmin(seg)); elo = float(seg.min()) + elif c[i] >= elo + k_atr * a[i]: + piv.append((elo_i, float(elo), -1, i)) + dir_ = +1 + seg = h[elo_i:i + 1] + ehi_i = elo_i + int(np.argmax(seg)); ehi = float(seg.max()) + i += 1 + while i < n: + if dir_ == -1: # in discesa: cerco swing low + if l[i] < elo: + elo, elo_i = l[i], i + if c[i] >= elo + k_atr * a[i]: + piv.append((elo_i, float(elo), -1, i)) + dir_ = +1 + seg = h[elo_i:i + 1] + ehi_i = elo_i + int(np.argmax(seg)); ehi = float(seg.max()) + else: # in salita: cerco swing high + if h[i] > ehi: + ehi, ehi_i = h[i], i + if c[i] <= ehi - k_atr * a[i]: + piv.append((ehi_i, float(ehi), +1, i)) + dir_ = -1 + seg = l[ehi_i:i + 1] + elo_i = ehi_i + int(np.argmin(seg)); elo = float(seg.min()) + i += 1 + return piv + + +def build_segments(piv: list, n: int) -> list: + """Segmenti: swing A->B (pivot s-1 -> s), griglia attiva in [confirm(B)+1, confirm(next)]. + prev2 = (qA, qB) dello swing s-2 (STESSO verso del corrente, per la confluenza: lo swing + s-1 ha verso opposto e le sue estensioni proiettano sempre fuori dal range corrente).""" + segs = [] + for s in range(1, len(piv)): + A, B = piv[s - 1], piv[s] + w0 = B[3] + 1 + w1 = piv[s + 1][3] if s + 1 < len(piv) else n - 1 + prev2 = (piv[s - 3][1], piv[s - 2][1]) if s >= 3 else None + segs.append(dict(w0=w0, w1=w1, pA=A[1], pB=B[1], prev2=prev2)) + return segs + + +# =========================================================================== +# RATIO SET (vero + placebo) +# =========================================================================== +def _draw_valid(rng, lo: float, hi: float, size, forbid) -> np.ndarray: + """Uniformi in [lo,hi] con banda esclusa max(0.02, 2% relativo) attorno ai Fib veri.""" + out = rng.uniform(lo, hi, size=size) + for _ in range(200): + bad = np.zeros(out.shape, bool) + for f in forbid: + bad |= np.abs(out - f) < max(0.02, 0.02 * f) + if not bad.any(): + break + out[bad] = rng.uniform(lo, hi, size=int(bad.sum())) + return out + + +def make_placebo_a(rng): + """(N_PA,3) ritracciamenti + (N_PA,4) estensioni: set FISSI cross-swing.""" + r = _draw_valid(rng, *RET_RANGE, (N_PA, len(RET_TRUE)), RET_TRUE) + e = _draw_valid(rng, *EXT_RANGE, (N_PA, len(EXT_TRUE)), EXT_TRUE) + return r, e + + +def make_placebo_b(rng, n_seg: int): + """(n_seg,N_PB,3) + (n_seg,N_PB,4): rapporti RI-SORTEGGIATI per ogni swing.""" + r = _draw_valid(rng, *RET_RANGE, (n_seg, N_PB, len(RET_TRUE)), RET_TRUE) + e = _draw_valid(rng, *EXT_RANGE, (n_seg, N_PB, len(EXT_TRUE)), EXT_TRUE) + return r, e + + +def make_placebo_c(rng): + """Set fissi LOCATION-MATCHED: ogni rapporto = Fib vero +/- jitter appena FUORI la banda + esclusa (max(0.02,2%rel)) ed entro ~6-8% -> vicini di casa dei Fib. Se i Fib sono davvero + speciali (il mercato ancora ESATTAMENTE a 0.618), devono battere anche questi.""" + def jit(true_vals, lo, hi): + f = np.array(true_vals, float)[None, :] + band = np.maximum(0.02, 0.02 * f) + delta = band + rng.uniform(0.0, 0.06 * np.maximum(1.0, f), size=(N_PC, f.shape[1])) + sign = rng.choice([-1.0, 1.0], size=(N_PC, f.shape[1])) + return np.clip(f + sign * delta, lo, hi) + return jit(RET_TRUE, *RET_RANGE), jit(EXT_TRUE, *EXT_RANGE) + + +# =========================================================================== +# TOUCH SCAN vettoriale + accumulo +# =========================================================================== +def _touch_scan(h, l, c, w0: int, w1: int, L: np.ndarray, ids: np.ndarray): + """Fresh touch dei livelli L nella finestra [w0,w1]: ritorna (bar globali, direzione + attesa dal lato di approccio, livello, id-set). Direzione: sign(close[i-1] - L).""" + lo = l[w0:w1 + 1]; hi = h[w0:w1 + 1] + contains = (lo[:, None] <= L[None, :]) & (hi[:, None] >= L[None, :]) + fresh = contains.copy() + fresh[1:] &= ~contains[:-1] + fresh[0] &= ~((l[w0 - 1] <= L) & (h[w0 - 1] >= L)) + tt, kk = np.nonzero(fresh) + if not len(tt): + z = np.zeros(0) + return z.astype(int), z, z, z.astype(int) + gi = w0 + tt + Lk = L[kk] + dirs = np.sign(c[gi - 1] - Lk) + m = dirs != 0 + return gi[m], dirs[m], Lk[m], ids[kk[m]] + + +def _new_store(): + return dict( + acc={(H, s): dict(sn=np.zeros(N_SETS), sr=np.zeros(N_SETS), + sl=np.zeros(N_SETS), c=np.zeros(N_SETS)) + for H in HORIZONS for s in ("IS", "FULL")}, + brk={s: dict(b=np.zeros(N_SETS), t=np.zeros(N_SETS)) for s in ("IS", "FULL")}, + ) + + +def _acc_touches(store, gi, dirs, Lk, sid, c, a14, is_pre, n): + if not len(gi): + return + relatr = a14[gi] / c[gi] + ok = np.isfinite(relatr) & (relatr > 0) + gi, dirs, Lk, sid, relatr = gi[ok], dirs[ok], Lk[ok], sid[ok], relatr[ok] + if not len(gi): + return + broke = ((dirs > 0) & (c[gi] < Lk)) | ((dirs < 0) & (c[gi] > Lk)) + for scope, sm in (("FULL", np.ones(len(gi), bool)), ("IS", is_pre[gi])): + b = store["brk"][scope] + np.add.at(b["t"], sid[sm], 1.0) + np.add.at(b["b"], sid[sm & broke], 1.0) + for H in HORIZONS: + m = sm & ((gi + H) < n) + if not m.any(): + continue + g2, d2 = gi[m], dirs[m] + react = d2 * (c[g2 + H] / c[g2] - 1.0) + a = store["acc"][(H, scope)] + np.add.at(a["sn"], sid[m], react / relatr[m]) + np.add.at(a["sr"], sid[m], react) + np.add.at(a["sl"], sid[m], d2 * (c[g2 + H] / Lk[m] - 1.0)) + np.add.at(a["c"], sid[m], 1.0) + + +def scan_config(df: pd.DataFrame, segs: list, PA_r, PA_e, PB_r, PB_e, PC_r, PC_e) -> dict: + """Scansione completa di un config (asset,tf,k): test principale (griglia dello swing + corrente: ret+ext) e confluenza (ret corrente x ext dello swing s-2, stesso verso).""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + a14 = al.atr(df, ATR_WIN) + n = len(c) + is_pre = (pd.to_datetime(df["datetime"], utc=True) < al.HOLDOUT).values + R_true_r = np.array(RET_TRUE)[None, :] + R_true_e = np.array(EXT_TRUE)[None, :] + main, conf, sing = _new_store(), _new_store(), _new_store() + czc = np.zeros(N_SETS) # numero di zone confluenti per set + set_rep = np.repeat(np.arange(N_SETS), NL) + for si, seg in enumerate(segs): + w0, w1 = seg["w0"], seg["w1"] + if w0 < 1 or w1 < w0 or w0 >= n: + continue + w1 = min(w1, n - 1) + pA, pB = seg["pA"], seg["pB"] + M = pB - pA + if abs(M) < 1e-12: + continue + Rr = np.vstack([R_true_r, PA_r, PB_r[si], PC_r]) # (N_SETS,3) + Re = np.vstack([R_true_e, PA_e, PB_e[si], PC_e]) # (N_SETS,4) + L = np.concatenate([pB - Rr * M, pA + Re * M], axis=1).ravel() + _acc_touches(main, *_touch_scan(h, l, c, w0, w1, L, set_rep), c, a14, is_pre, n) + # ------- confluenza: ret(cur) x ext(swing s-2, stesso verso) entro eps ------- + if seg["prev2"] is None: + continue + qA, qB = seg["prev2"] + Mp = qB - qA + if abs(Mp) < 1e-12: + continue + eps = CONF_EPS_ATR * a14[w0] + if not np.isfinite(eps) or eps <= 0: + continue + Lr = pB - Rr * M # (N_SETS,3) + Le = qA + Re * Mp # (N_SETS,4) + dmat = np.abs(Lr[:, :, None] - Le[:, None, :]) # (N_SETS,3,4) + cs, cr, ce = np.nonzero(dmat <= eps) + if len(cs): + Lc = 0.5 * (Lr[cs, cr] + Le[cs, ce]) + np.add.at(czc, cs, 1.0) + _acc_touches(conf, *_touch_scan(h, l, c, w0, w1, Lc, cs), c, a14, is_pre, n) + used_r = np.zeros((N_SETS, Lr.shape[1]), bool); used_r[cs, cr] = True + used_e = np.zeros((N_SETS, Le.shape[1]), bool); used_e[cs, ce] = True + sr_, rr_ = np.nonzero(~used_r) + se_, ee_ = np.nonzero(~used_e) + Ls = np.concatenate([Lr[sr_, rr_], Le[se_, ee_]]) + ids_s = np.concatenate([sr_, se_]) + _acc_touches(sing, *_touch_scan(h, l, c, w0, w1, Ls, ids_s), c, a14, is_pre, n) + return dict(main=main, conf=conf, sing=sing, czc=czc, n_seg=len(segs)) + + +# =========================================================================== +# STATISTICHE dai contatori +# =========================================================================== +def _means(store, H: int, scope: str, field: str = "sn"): + a = store["acc"][(H, scope)] + with np.errstate(invalid="ignore", divide="ignore"): + return np.where(a["c"] > 0, a[field] / a["c"], np.nan) + + +def _pctl(true_val: float, plc: np.ndarray): + v = plc[np.isfinite(plc)] + if len(v) < 20 or not np.isfinite(true_val): + return None + return float(np.mean(v <= true_val)) + + +IDX_A = slice(1, 1 + N_PA) +IDX_B = slice(1 + N_PA, 1 + N_PA + N_PB) +IDX_C = slice(1 + N_PA + N_PB, N_SETS) + + +def _cell_stats(store, H: int): + out = {} + for scope in ("IS", "FULL"): + mn = _means(store, H, scope) + mr = _means(store, H, scope, "sr") + ml = _means(store, H, scope, "sl") + cnt = store["acc"][(H, scope)]["c"] + out[scope] = dict( + n=int(cnt[0]), + raw_bps=float(mr[0] * 1e4) if np.isfinite(mr[0]) else None, + lvl_bps=float(ml[0] * 1e4) if np.isfinite(ml[0]) else None, + norm=float(mn[0]) if np.isfinite(mn[0]) else None, + plcA_med=float(np.nanmedian(mn[IDX_A])), + plcC_med=float(np.nanmedian(mn[IDX_C])), + pctlA=_pctl(mn[0], mn[IDX_A]), + pctlB=_pctl(mn[0], mn[IDX_B]), + pctlC=_pctl(mn[0], mn[IDX_C]), + ) + b = store["brk"]["IS"] + with np.errstate(invalid="ignore", divide="ignore"): + rate = np.where(b["t"] > 0, b["b"] / b["t"], np.nan) + out["brk_rate"] = float(rate[0]) if np.isfinite(rate[0]) else None + # "il livello tiene" = break-rate BASSO -> pctl_hold alto se true < placebo + v = rate[IDX_A][np.isfinite(rate[IDX_A])] + out["brk_pctl_hold"] = (float(np.mean(v >= rate[0])) + if len(v) >= 20 and np.isfinite(rate[0]) else None) + return out + + +def _pool_stores(stores: list): + """Somma i contatori di piu' config (stesso significato dei set -> pooling coerente).""" + pooled = _new_store() + for st in stores: + for key, a in st["acc"].items(): + for f in ("sn", "sr", "sl", "c"): + pooled["acc"][key][f] += a[f] + for s, b in st["brk"].items(): + pooled["brk"][s]["b"] += b["b"] + pooled["brk"][s]["t"] += b["t"] + return pooled + + +# =========================================================================== +# STRATEGIA (solo se il claim batte il placebo) — fade ai livelli Fib +# =========================================================================== +def fib_fade_positions(df: pd.DataFrame, k_atr: float, H: int) -> np.ndarray: + """Posizione long/short: al fresh-touch di un livello Fib vero prendi la direzione + del rimbalzo atteso e tienila H barre (tocchi sovrapposti sommati, clip a +/-1). + Decisione a close[i] -> eval_weights la applica dalla barra i+1 (nessun leak).""" + piv = zigzag_pivots(df, k_atr) + n = len(df) + segs = build_segments(piv, n) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + sig = np.zeros(n) + ids0 = np.zeros(NL, dtype=int) + for seg in segs: + w0, w1 = seg["w0"], min(seg["w1"], n - 1) + if w0 < 1 or w1 < w0: + continue + pA, pB = seg["pA"], seg["pB"] + M = pB - pA + if abs(M) < 1e-12: + continue + L = np.concatenate([pB - np.array(RET_TRUE) * M, pA + np.array(EXT_TRUE) * M]) + gi, dirs, _, _ = _touch_scan(h, l, c, w0, w1, L, ids0) + np.add.at(sig, gi, dirs) + return np.clip(pd.Series(sig).rolling(H, min_periods=1).sum().values, -1.0, 1.0) + + +def make_fib_target(tf: str = "1d", k_atr: float = 2.0, H: int = 20): + def target(df): + return fib_fade_positions(df, k_atr, H) + return target + + +# =========================================================================== +# MAIN +# =========================================================================== +def main(): + print("=" * 104) + print("r0702 ELL-B FIBONACCI CONFLUENCE — reazione ai livelli Fib vs rapporti placebo " + "(il null e' tutto)") + print(f"zigzag k*ATR14 k={K_GRID}, ratios ret={RET_TRUE} ext={EXT_TRUE}, H={HORIZONS} barre," + f" placebo A={N_PA} set fissi / B={N_PB} per-swing / C={N_PC} location-matched," + f" seed={SEED}") + print("statistica = media reazione segnata/ATR sui tocchi IS (pre-2025); " + "pctl alto = Fib meglio del placebo") + print("=" * 104) + + # ---------- [0] CAUSALITY: pivot su prefisso troncato + target strategy ---------- + print("\n[0] CAUSALITY CHECK zigzag (pivot confermati < cut identici su prefisso 90%)") + for a in ASSETS: + for tf in TFS: + df = al.get(a, tf) + cut = int(len(df) * 0.9) + pf = [p for p in zigzag_pivots(df, K_GRID[0]) if p[3] < cut] + pp = [p for p in zigzag_pivots(df.iloc[:cut].reset_index(drop=True), K_GRID[0]) + if p[3] < cut] + ok = pf == pp + print(f" {a} {tf}: pivots full single-cell sig ~>=0.997") + hdr = (f" {'asset':<5s} {'tf':<3s} {'k':>2s} {'H':>3s} {'nIS':>6s} {'nFULL':>6s} " + f"{'rawIS(bps)':>10s} {'lvlIS(bps)':>10s} {'normIS':>8s} {'plcAmed':>8s} " + f"{'plcCmed':>8s} {'pctlA_IS':>8s} {'pctlB_IS':>8s} {'pctlC_IS':>8s} " + f"{'pctlA_F':>8s} {'brkIS':>6s} {'brkPctl':>7s}") + print(hdr) + cells = {} + for (a, tf, k), res in results.items(): + for H in HORIZONS: + st = _cell_stats(res["main"], H) + cells[(a, tf, k, H)] = st + i_, f_ = st["IS"], st["FULL"] + print(f" {a:<5s} {tf:<3s} {k:>2.0f} {H:>3d} {i_['n']:>6d} {f_['n']:>6d} " + f"{i_['raw_bps']:>10.1f} {i_['lvl_bps']:>10.1f} {i_['norm']:>8.3f} " + f"{i_['plcA_med']:>8.3f} {i_['plcC_med']:>8.3f} " + f"{i_['pctlA']:>8.2f} {i_['pctlB']:>8.2f} {i_['pctlC']:>8.2f} " + f"{f_['pctlA']:>8.2f} {st['brk_rate']:>6.2f} " + f"{st['brk_pctl_hold'] if st['brk_pctl_hold'] is not None else float('nan'):>7.2f}") + + # ---------- [3] POOLED per (k,H) su asset x TF ---------- + print("\n[3] POOLED (somma tocchi su 2 asset x 2 TF) — il test famiglia") + pooled_pass = {} + for k in K_GRID: + pooled = _pool_stores([results[(a, tf, k)]["main"] for a in ASSETS for tf in TFS]) + for H in HORIZONS: + st = _cell_stats(pooled, H) + i_ = st["IS"] + cell_ps = [cells[(a, tf, k, H)]["IS"]["pctlA"] for a in ASSETS for tf in TFS + if cells[(a, tf, k, H)]["IS"]["n"] >= MIN_CELL_TOUCH] + min_cell = min(cell_ps) if cell_ps else None + ok = (i_["pctlA"] is not None and i_["pctlA"] >= 0.99 + and i_["pctlB"] is not None and i_["pctlB"] >= 0.99 + and i_["pctlC"] is not None and i_["pctlC"] >= 0.95 + and min_cell is not None and min_cell >= 0.90) + pooled_pass[(k, H)] = ok + print(f" k={k:.0f} H={H:>2d}: nIS={i_['n']:>6d} normIS={i_['norm']:+.3f} " + f"(plcA med {i_['plcA_med']:+.3f} / plcC med {i_['plcC_med']:+.3f}) " + f"pctlA_IS={i_['pctlA']:.2f} pctlB_IS={i_['pctlB']:.2f} " + f"pctlC_IS={i_['pctlC']:.2f} pctlA_FULL={st['FULL']['pctlA']:.2f} " + f"min_cell_pctlA={min_cell if min_cell is not None else 'n/a'} " + f"PASS(A,B>=0.99 & C>=0.95 & cells>=0.90)={ok}") + + # ---------- [4] CONFLUENZA vs livello singolo ---------- + print("\n[4] CONFLUENZA (ret swing corrente x ext swing s-2 stesso verso, eps=0.25*ATR)") + print(" diff = norm(conf) - norm(single); pctl del diff vero vs placebo A") + conf_ok_cells = {} + for (a, tf, k), res in results.items(): + for H in HORIZONS: + mc = _means(res["conf"], H, "IS") + ms = _means(res["sing"], H, "IS") + nc = int(res["conf"]["acc"][(H, "IS")]["c"][0]) + ns = int(res["sing"]["acc"][(H, "IS")]["c"][0]) + diff = mc - ms + p = _pctl(diff[0], diff[IDX_A]) + czc_true = int(res["czc"][0]) + czc_plc = float(np.mean(res["czc"][IDX_A])) + conf_ok_cells[(a, tf, k, H)] = (p is not None and p >= 0.95 + and nc >= MIN_CONF_TOUCH) + d0 = f"{diff[0]:+.3f}" if np.isfinite(diff[0]) else " n/a" + print(f" {a} {tf} k={k:.0f} H={H:>2d}: zone true={czc_true} (plcA med~{czc_plc:.0f}) " + f"tocchiIS conf={nc} sing={ns} " + f"norm conf={mc[0] if np.isfinite(mc[0]) else float('nan'):+.3f} " + f"sing={ms[0] if np.isfinite(ms[0]) else float('nan'):+.3f} diff={d0} " + f"pctl_diff={p if p is not None else 'n/a'}") + conf_real = {} + for k in K_GRID: + for H in HORIZONS: + conf_real[(k, H)] = all(conf_ok_cells[(a, tf, k, H)] for a in ASSETS for tf in TFS) + print(" verdetto confluenza per (k,H) [tutte le 4 celle pctl>=0.95 e n>=30]: " + + " ".join(f"k={k:.0f},H={H}:{'PASS' if v else 'FAIL'}" + for (k, H), v in conf_real.items())) + + # ---------- [5] VERDETTO + eventuale strategizzazione ---------- + fib_real = any(pooled_pass.values()) + any_conf = any(conf_real.values()) + print("\n[5] VERDETTO") + print(f" fib_real (pooled IS: A,B>=0.99, C>=0.95, tutte le celle >=0.90, " + f"per qualche k,H): {fib_real} -> {pooled_pass}") + print(f" confluenza > singolo livello (robusto 4/4 celle): {any_conf}") + if fib_real: + print("\n[6] STRATEGIA (il claim regge il placebo) — fade ai livelli, " + "study_family_honest + marginal") + grid = [dict(k_atr=k, H=H) for k in K_GRID for H in HORIZONS] + fam = al.study_family_honest("FIB-FADE", make_fib_target, grid, tfs=TFS) + ch = fam.get("chosen") + print(f" chosen(IS)={ch} n_cells={fam.get('n_cells')} " + f"DSR={fam.get('deflated_sharpe')} (null-max {fam.get('expected_null_max')}) " + f"dsr_pass={fam.get('dsr_pass')}") + print(f" earns_slot_marginal={fam.get('earns_slot_marginal')} " + f"EARNS_SLOT_HONEST={fam.get('earns_slot_honest')}") + if fam.get("marginal"): + print(al.fmt_marginal(fam["marginal"])) + else: + print("\n[6] STRATEGIA: SALTATA — la reazione ai livelli Fib NON batte i rapporti " + "casuali in modo robusto (regola a priori). Nessun edge da strategizzare.") + + print("\nFine. Selezione: nessuna (rapporti dal claim, verdetto su pooled IS); " + "hold-out mai usato per scegliere.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0702_ell_rangecycle.py b/scripts/research/r0702_ell_rangecycle.py new file mode 100644 index 0000000..2555e3e --- /dev/null +++ b/scripts/research/r0702_ell_rangecycle.py @@ -0,0 +1,414 @@ +"""r0702_ell_rangecycle — Test ONESTO del claim "Elliott/Larry Williams range-cycle". + +CLAIM (da video didattico, operazionalizzato SENZA conteggio soggettivo di onde): + (A) un segmento di trend che inizia con un impulso a RANGE COMPRESSO tende a produrre + un'estensione successiva AMPIA ("onda 3 larga"); + (B) un segmento che inizia con impulso a RANGE AMPIO produce una fase finale TRONCATA + (ultimo terzo debole -> uscire prima). + +Entrambi i claim, come operazionalizzati, predicono rho(impulso, seguito) NEGATIVA: + A: rho(range_impulso_norm, ritorno_normalizzato del RESTO del segmento) < 0 + B: rho(range_impulso_norm, ritorno_normalizzato dell'ULTIMO TERZO) < 0 + +PRIOR ART (sweep 104-famiglie 2026-06-20): la compressione come *setup di breakout* è +già stata testata e NON regge — BRK08 (NR7), BRK10 (BB-squeeze long), CMB05 (squeeze-> +breakout), VOL12 (low-vol timing). Qui la domanda è DIVERSA e a livello di SEGMENTO: +il range dell'impulso iniziale predice l'ampiezza/troncatura del seguito? Statistica +PRIMA della strategia: si strategizza SOLO se il segnale regge Bonferroni sul pre-2025. + +METODO: + - Dati certificati BTC/ETH 1d (al.get) + settimanali costruiti dal 1d con resample + "7D", label='left', closed='left', origin ESPLICITA (7 ancore, banda riportata). + Niente DatetimeIndex.view("int64") (lezione pandas 2026-07-01). + - 2 definizioni MECCANICHE di segmento: + (i) TSMOM-flip: run massimali di sign(close[t]-close[t-L]) (L=30 bar 1d, 4 bar 1w). + Causale sia in start sia in end. + (ii) zigzag causale k*ATR: pivot = estremo confermato solo dopo retracement k*ATR + (k=3.0/ATR14 su 1d, k=2.0/ATR8 su 1w — FISSATI a priori, non cercati). + NB: il pivot è noto solo DOPO (lag di conferma) -> le statistiche sono + event-aligned ex-post (test di ASSOCIAZIONE); un overlay tradabile dovrebbe + scontare il lag. L'impulso (prime N barre) precede comunque il seguito -> + nessuna sovrapposizione meccanica impulso/seguito. + - Impulso = mean(TrueRange prime N barre) / ATR[pre-segmento], N in {3,5,10}. + - Seguito A = dir*(close_end - close_impulso) / (ATR_norm * sqrt(n_resto)) + Seguito B = dir*(close_end - close_2/3) / (ATR_norm * sqrt(n_ultimo_terzo)) + Normalizzazione PRIMARIA con ATR pre-segmento (stesso denominatore di impulso -> + bias comune-divisore POSITIVO su rho = CONSERVATIVO vs claim che predicono rho<0). + Sensibilità: anche ATR post-impulso (ANTI-conservativa: un impulso ampio gonfia + l'ATR post -> sgonfia il seguito -> fabbrica rho<0). Riportate entrambe, si testa + la primaria. + - Test: Spearman pooled BTC+ETH con NULL PERMUTATO (shuffle seguito<->impulso ENTRO + asset, 2000 permutazioni, two-sided). Secondario: differenza terzili (compresso - + ampio, terzili entro asset). Bonferroni su 24 celle primarie (2 def x 2 TF x 3 N x + 2 claim; weekly = ancora lunedì, le altre 6 ancore = banda robustezza). + - Campione PICCOLO per costruzione (decine di segmenti, weekly ~390 barre) -> n + riportato per cella; p marginali NON vanno creduti. + +Output: tabella celle + CSV nello scratchpad (ell_a_cells.csv). Strategizzazione +(study_marginal / study_family_honest) SOLO se una cella passa Bonferroni con il segno +giusto sul campione pre-2025. +""" +import sys + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") + +import numpy as np +import pandas as pd + +import altlib as al + +SCRATCH = ("/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad") +SEED = 20260702 +N_PERM = 2000 +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +N_LIST = (3, 5, 10) +# Parametri di segmentazione FISSATI A PRIORI (nessuna ricerca su di essi): +TSMOM_L = {"1d": 30, "1w": 4} # 30 giorni / 4 settimane (~28g) +ZZ_K = {"1d": 3.0, "1w": 2.0} # soglia retracement in multipli di ATR +ZZ_ATRWIN = {"1d": 14, "1w": 8} +BONF_M = 24 # 2 def x 2 TF x 3 N x 2 claim (weekly=ancora lun) + + +# --------------------------------------------------------------------------- +# Dati +# --------------------------------------------------------------------------- +def daily(asset: str) -> pd.DataFrame: + df = al.get(asset, "1d") + return df[["datetime", "open", "high", "low", "close", "volume"]].copy() + + +def weekly_from_daily(dfd: pd.DataFrame, anchor_day: int) -> pd.DataFrame: + """Barre settimanali dal 1d. anchor_day=0 -> settimane che iniziano lunedì + (origin=2018-01-01, un lunedì); anchor_day=k sposta l'origine di k giorni. + resample('168h', label='left', closed='left', origin=esplicita) — '168h' e non + '7D': in pandas 2.x 'D' non è Tick-like e IGNOREREBBE origin (warning silenzioso + -> tutte le ancore identiche). Bins parziali (<7 giorni) scartati.""" + origin = pd.Timestamp("2018-01-01", tz="UTC") + pd.Timedelta(days=anchor_day) + x = dfd.set_index(pd.DatetimeIndex(pd.to_datetime(dfd["datetime"], utc=True))) + g = x.resample("168h", label="left", closed="left", origin=origin) + w = g.agg(open=("open", "first"), high=("high", "max"), low=("low", "min"), + close=("close", "last"), volume=("volume", "sum")) + cnt = g["close"].count() + w = w[cnt >= 7].dropna().reset_index(names="datetime") + return w + + +def true_range(df: pd.DataFrame) -> np.ndarray: + h, l, c = (df[k].values.astype(float) for k in ("high", "low", "close")) + pc = np.roll(c, 1) + pc[0] = c[0] + return np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + + +# --------------------------------------------------------------------------- +# Definizioni di segmento (meccaniche) +# --------------------------------------------------------------------------- +def tsmom_segments(close: np.ndarray, L: int) -> list[tuple[int, int, int]]: + """Run massimali di sign(close[i]-close[i-L]). Ritorna (start, end, dir) inclusivi. + Causale: il segno a i usa solo close<=i; start noto allo start, end noto all'end.""" + n = len(close) + s = np.zeros(n) + s[L:] = np.sign(close[L:] - close[:-L]) + for i in range(1, n): # 0 -> segno precedente (nessun flip) + if s[i] == 0: + s[i] = s[i - 1] + segs, start = [], L + for i in range(L + 1, n + 1): + if i == n or s[i] != s[start]: + if s[start] != 0: + segs.append((start, i - 1, int(s[start]))) + start = i + return segs + + +def zigzag_segments(df: pd.DataFrame, k: float, atr_win: int) -> list[tuple[int, int, int]]: + """Zigzag causale a soglia k*ATR: pivot (estremo) confermato solo quando il prezzo + ritraccia k*ATR[i] dall'estremo corrente. Segmento = (pivot_prec+1 .. pivot), dir = + segno del leg. ⚠️ Il pivot è noto solo alla CONFERMA (lag): qui usato solo per + statistiche di associazione ex-post, non come segnale tradabile.""" + c = df["close"].values.astype(float) + a = al.atr(df, atr_win) + n = len(c) + piv: list[int] = [] + hi, hi_i, lo, lo_i, d = c[0], 0, c[0], 0, 0 + for i in range(1, n): + if d >= 0: + if c[i] > hi: + hi, hi_i = c[i], i + if d == 0 and c[i] < lo: + lo, lo_i = c[i], i + if hi - c[i] > k * a[i]: + piv.append(hi_i) + d = -1 + lo, lo_i = c[i], i + continue + if d == 0 and c[i] - lo > k * a[i]: + piv.append(lo_i) + d = 1 + hi, hi_i = c[i], i + else: + if c[i] < lo: + lo, lo_i = c[i], i + if c[i] - lo > k * a[i]: + piv.append(lo_i) + d = 1 + hi, hi_i = c[i], i + segs = [] + for j in range(1, len(piv)): + p, q = piv[j - 1], piv[j] + if q > p + 1: + segs.append((p + 1, q, int(np.sign(c[q] - c[p])))) + return [s for s in segs if s[2] != 0] + + +# --------------------------------------------------------------------------- +# Misure per segmento +# --------------------------------------------------------------------------- +def measure(df: pd.DataFrame, segs, N: int, atr_win: int): + """Per ogni segmento: impulso (range prime N barre / ATR pre-segmento) e seguito + per claim A (resto) e B (ultimo terzo). Normalizzazione primaria = ATR pre-segmento + (conservativa); sensibilità = ATR post-impulso. Ritorna DataFrame righe-segmento.""" + c = df["close"].values.astype(float) + a = al.atr(df, atr_win) + tr = true_range(df) + ts = pd.to_datetime(df["datetime"], utc=True).values + rows = [] + for (s, e, d) in segs: + seg_len = e - s + 1 + if s - 1 < atr_win or seg_len < N + 3: + continue + a_pre = a[s - 1] + if not np.isfinite(a_pre) or a_pre <= 0: + continue + imp = float(np.mean(tr[s:s + N]) / a_pre) + i_end = s + N - 1 # ultima barra dell'impulso + a_post = a[i_end] if a[i_end] > 0 else np.nan + # Claim A: resto del segmento (da fine impulso a fine segmento), in dir. trend + n_rest = e - i_end + move_a = d * (c[e] - c[i_end]) + seqA_pre = float(move_a / (a_pre * np.sqrt(n_rest))) + seqA_post = float(move_a / (a_post * np.sqrt(n_rest))) if np.isfinite(a_post) else np.nan + # ampiezza massima favorevole del resto (descrittiva) + if d > 0: + mfe = np.max(df["high"].values[i_end + 1:e + 1]) - c[i_end] + else: + mfe = c[i_end] - np.min(df["low"].values[i_end + 1:e + 1]) + mfe_pre = float(mfe / (a_pre * np.sqrt(n_rest))) + # Claim B: ultimo terzo del segmento (deve iniziare dopo l'impulso) + l0 = s + int(np.ceil(seg_len * 2.0 / 3.0)) # prima barra dell'ultimo terzo + seqB_pre = seqB_post = np.nan + if l0 - 1 >= i_end and e - (l0 - 1) >= 2: + n_last = e - (l0 - 1) + move_b = d * (c[e] - c[l0 - 1]) + a_b = a[l0 - 1] + seqB_pre = float(move_b / (a_pre * np.sqrt(n_last))) + seqB_post = float(move_b / (a_b * np.sqrt(n_last))) if a_b > 0 else np.nan + rows.append(dict(t0=ts[s], seg_len=seg_len, dir=d, imp=imp, + seqA=seqA_pre, seqA_post=seqA_post, mfe=mfe_pre, + seqB=seqB_pre, seqB_post=seqB_post)) + return pd.DataFrame(rows) + + +# --------------------------------------------------------------------------- +# Statistica: Spearman pooled + null permutato entro-asset; terzili +# --------------------------------------------------------------------------- +def _spearman(x: np.ndarray, y: np.ndarray) -> float: + rx = pd.Series(x).rank().values + ry = pd.Series(y).rank().values + sx, sy = rx.std(), ry.std() + if sx == 0 or sy == 0: + return 0.0 + return float(np.corrcoef(rx, ry)[0, 1]) + + +def _tercile_diff(imp, seq, aid): + """mean(seguito | terzile compresso) - mean(seguito | terzile ampio), terzili + per-asset (>=9 segmenti per asset, altrimenti asset escluso). Claim -> diff>0.""" + lo_v, hi_v = [], [] + for a in np.unique(aid): + m = aid == a + if m.sum() < 9: + continue + q1, q2 = np.quantile(imp[m], [1 / 3, 2 / 3]) + lo_v.append(seq[m & (imp <= q1)]) + hi_v.append(seq[m & (imp >= q2)]) + if not lo_v: + return np.nan + return float(np.concatenate(lo_v).mean() - np.concatenate(hi_v).mean()) + + +def perm_test(imp, seq, aid, n_perm=N_PERM, seed=SEED): + """Shuffle del seguito rispetto all'impulso ENTRO asset. p two-sided su Spearman + (primario) e su tercile diff (secondario).""" + ok = np.isfinite(imp) & np.isfinite(seq) + imp, seq, aid = imp[ok], seq[ok], aid[ok] + n = len(imp) + if n < 12: + return dict(rho=np.nan, p=np.nan, ter=np.nan, p_ter=np.nan, n=n) + rho = _spearman(imp, seq) + ter = _tercile_diff(imp, seq, aid) + rng = np.random.default_rng(seed) + masks = [aid == a for a in np.unique(aid)] + cnt_r = cnt_t = 0 + n_t = 0 + for _ in range(n_perm): + sp = seq.copy() + for m in masks: + sp[m] = rng.permutation(sp[m]) + if abs(_spearman(imp, sp)) >= abs(rho): + cnt_r += 1 + if np.isfinite(ter): + tp = _tercile_diff(imp, sp, aid) + if np.isfinite(tp): + n_t += 1 + if abs(tp) >= abs(ter): + cnt_t += 1 + p = (1 + cnt_r) / (n_perm + 1) + p_ter = (1 + cnt_t) / (n_t + 1) if (np.isfinite(ter) and n_t > 0) else np.nan + return dict(rho=round(rho, 3), p=p, ter=(round(ter, 3) if np.isfinite(ter) else np.nan), + p_ter=p_ter, n=n) + + +# --------------------------------------------------------------------------- +# Celle +# --------------------------------------------------------------------------- +def build_frames(tf: str, anchor: int): + out = {} + for a in ("BTC", "ETH"): + d = daily(a) + out[a] = d if tf == "1d" else weekly_from_daily(d, anchor) + return out + + +def segments_for(df, sdef: str, tf: str): + if sdef == "tsmom": + return tsmom_segments(df["close"].values.astype(float), TSMOM_L[tf]) + return zigzag_segments(df, ZZ_K[tf], ZZ_ATRWIN[tf]) + + +def cell_rows(frames, sdef: str, tf: str, N: int) -> pd.DataFrame: + parts = [] + for i, (a, df) in enumerate(frames.items()): + m = measure(df, segments_for(df, sdef, tf), N, ZZ_ATRWIN[tf]) + if len(m): + m["aid"] = i + m["asset"] = a + parts.append(m) + return pd.concat(parts, ignore_index=True) if parts else pd.DataFrame() + + +def run_cell(rows: pd.DataFrame, claim: str, pre2025: bool, norm: str = "pre"): + if rows.empty: + return dict(rho=np.nan, p=np.nan, ter=np.nan, p_ter=np.nan, n=0) + r = rows + if pre2025: + r = r[pd.to_datetime(r["t0"], utc=True) < HOLDOUT] + col = {"A": {"pre": "seqA", "post": "seqA_post"}, + "B": {"pre": "seqB", "post": "seqB_post"}}[claim][norm] + return perm_test(r["imp"].values.astype(float), r[col].values.astype(float), + r["aid"].values) + + +def main(): + print(__doc__.split("\n")[0]) + print(f"seed={SEED} n_perm={N_PERM} Bonferroni m={BONF_M} (celle primarie)") + results = [] + frames_1d = build_frames("1d", 0) + frames_1w = {k: build_frames("1w", k) for k in range(7)} + for a, df in frames_1d.items(): + print(f" {a} 1d: {len(df)} barre {df['datetime'].iloc[0]} -> {df['datetime'].iloc[-1]}" + f" | 1w(lun): {len(frames_1w[0][a])} barre") + + for sdef in ("tsmom", "zigzag"): + for tf in ("1d", "1w"): + fr = frames_1d if tf == "1d" else frames_1w[0] + # conteggio segmenti grezzi (informativo) + nseg = {a: len(segments_for(df, sdef, tf)) for a, df in fr.items()} + print(f"\n[{sdef} {tf}] segmenti grezzi: {nseg}") + for N in N_LIST: + rows = cell_rows(fr, sdef, tf, N) + for claim in ("A", "B"): + full = run_cell(rows, claim, pre2025=False) + ins = run_cell(rows, claim, pre2025=True) + post = run_cell(rows, claim, pre2025=False, norm="post") + rec = dict(sdef=sdef, tf=tf, N=N, claim=claim, + n=full["n"], rho=full["rho"], p=full["p"], + p_bonf=min(1.0, full["p"] * BONF_M) if np.isfinite(full["p"]) else np.nan, + ter=full["ter"], p_ter=full["p_ter"], + rho_is=ins["rho"], p_is=ins["p"], n_is=ins["n"], + rho_postnorm=post["rho"]) + # banda ancore weekly (tutte e 7, stessa cella) + if tf == "1w": + rhos, ps = [], [] + for k in range(7): + rr = run_cell(cell_rows(frames_1w[k], sdef, tf, N), claim, False) + rhos.append(float(rr["rho"]) if np.isfinite(rr["rho"]) else np.nan) + ps.append(float(rr["p"]) if np.isfinite(rr["p"]) else np.nan) + if np.any(np.isfinite(rhos)): + med = float(np.nanmedian(rhos)) + rec["anchor_rho_band"] = (round(float(np.nanmin(rhos)), 3), + round(med, 3), + round(float(np.nanmax(rhos)), 3)) + rec["anchor_p_band"] = (round(float(np.nanmin(ps)), 4), + round(float(np.nanmax(ps)), 4)) + rec["anchor_sign_flips"] = int(np.nansum( + np.sign(rhos) != np.sign(med))) + else: + rec["anchor_rho_band"] = rec["anchor_p_band"] = None + rec["anchor_sign_flips"] = None + results.append(rec) + + R = pd.DataFrame(results) + R.to_csv(f"{SCRATCH}/ell_a_cells.csv", index=False) + + print("\n" + "=" * 118) + print("TABELLA CELLE (norm primaria = ATR pre-segmento; claim A/B predicono rho<0; p permutato two-sided)") + print("=" * 118) + hdr = (f"{'def':7}{'tf':4}{'N':3}{'cl':3}{'n':5}{'rho':>7}{'p':>8}{'p_bonf':>8}" + f"{'ter(c-a)':>9}{'p_ter':>8}{'rho_IS':>8}{'p_IS':>8}{'n_IS':>5}{'rho_post':>9} anchors(1w)") + print(hdr) + for _, r in R.iterrows(): + anch = "" + if r["tf"] == "1w" and r["anchor_rho_band"] is not None: + anch = (f"rho[min/med/max]={r['anchor_rho_band']} " + f"p[{r['anchor_p_band'][0]}..{r['anchor_p_band'][1]}] " + f"sign_flips={r['anchor_sign_flips']}") + print(f"{r['sdef']:7}{r['tf']:4}{r['N']:<3}{r['claim']:3}{r['n']:<5}" + f"{r['rho']:>7}{r['p']:>8.4f}{r['p_bonf']:>8.3f}" + f"{str(r['ter']):>9}{r['p_ter']:>8.4f}{str(r['rho_is']):>8}{r['p_is']:>8.4f}{r['n_is']:>5}" + f"{str(r['rho_postnorm']):>9} {anch}") + + # ------------------------------------------------------------------ + # Verdetti + # ------------------------------------------------------------------ + print("\n" + "=" * 118) + for claim in ("A", "B"): + sub = R[R["claim"] == claim] + sig = sub[(sub["p_bonf"] < 0.05) & (sub["rho"] < 0)] + sig_is = sub[(sub["p_is"] * BONF_M < 0.05) & (sub["rho_is"] < 0)] + neg = int((sub["rho"] < 0).sum()) + print(f"CLAIM {claim}: celle={len(sub)} rho<0 (segno del claim)={neg}/{len(sub)} " + f"pass Bonferroni FULL={len(sig)} pass Bonferroni pre-2025={len(sig_is)}") + if len(sig) == 0 and len(sig_is) == 0: + print(f" -> NESSUNA cella regge Bonferroni. Claim {claim} NON supportato: " + f"si strategizza solo con evidenza statistica -> STOP qui per questo claim.") + else: + print(f" -> celle sopravvissute:\n{sig.to_string()}") + print(" -> procedere a strategizzazione SOLO su selezione pre-2025 " + "(study_family_honest).") + + n_med = int(R["n"].median()) + print(f"\nCAVEAT CAMPIONE: n mediano per cella = {n_med} segmenti " + f"(weekly ~{int(R[R['tf'] == '1w']['n'].median())}). Con decine di osservazioni " + "un p marginale (0.01-0.05 raw) è indistinguibile dal multiple-testing: " + "24 celle primarie + 2 claim correlati + 7 ancore weekly.") + print("NOTE: (1) zigzag = associazione ex-post (pivot noto solo a conferma); " + "(2) norm 'post' (ATR post-impulso) è ANTI-conservativa (divisore contaminato " + "dall'impulso) -> se rho<0 appare solo lì, è artefatto di normalizzazione; " + "(3) daily = ancora 00:00 UTC fissa (feed certificato).") + print(f"CSV: {SCRATCH}/ell_a_cells.csv") + + +if __name__ == "__main__": + main()