diff --git a/CLAUDE.md b/CLAUDE.md index ab88b39..1410cc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -557,6 +557,58 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis ACCOPPIAMENTO al rendimento del giorno** — qui un test "i percentili coincidono" sarebbe PASSATO con la stima sbagliata di 2-3×. Ogni regola valutata sul minimo (daily-loss, trailing DD, stop di conto) va misurata su **tuple accoppiate**, mai su un wick estratto a parte. +- **Ondata 2026-07-26-bis (TP01 su barra parziale + i 2 gate mai costruiti) — 0 sleeve nuovi, + 1 audit sul libro live, 2 gate codificati, 1 falso positivo MIO su uno sleeve in produzione.** + Script `r0726_tp01_partial_day.py` + `r0726_gates_retro.py`, test + `tests/test_gates_implausible_anchor.py` (13), diario `2026-07-26-wave-tp01-partial-gates.md`. + **Book/pesi/cron/config INVARIATI.** + (1) **T1 — anche TP01 legge una barra giornaliera PARZIALE nel live, ma non conta.** Stesso fatto + strutturale di SKH01: `resample_tf` non scarta il giorno in corso e `current_target` prende `[-1]`; + il feed si ricostruisce alle 00:30 UTC → tutto il giorno il book vede oggi come **1 barra oraria + su 24** (verificato `barre1h=1/24`). Docstring "ultima barra CHIUSA" **falso**, corretto (3ª volta + in 2 giorni che un docstring di produzione dichiara una causalita' che il codice non ha). + Tre path a un grado di liberta' per volta, 24 ancore, **differenze appaiate**: *barra parziale* + ΔFULL **−0.031** (pos. 7/24) / ΔHOLD **+0.118** (pos. 19/24); *ritardo d'esecuzione 1h* **−0.004** + (11/24 = moneta); leva gonfiata **+0.4%**. → **trascurabile, nessun cambio al live.** + ⚠️ All'ancora canonica sembra peggio del vero: a offset 0 la parziale costa **−0.230** di hold-out + = **il minimo dell'intera banda** (mediana +0.118) → guardando solo l'ancora canonica avrei + concluso "il live degrada TP01": speculare alla lezione del 26/07 (li' l'ancora canonica + *nascondeva* un vantaggio, qui *inventa* un danno). + **REGOLA NUOVA (il risultato trasferibile):** la parzialita' dell'ultima barra conta **in + proporzione a quanto il segnale pesa la barra piu' recente**. SKH01 = Donchian breakout su 230m, + la barra corrente E' il segnale → **+0.38**; TP01 = TSMOM 30/90/180g, l'ora mancante e' 1/24 di + UNA osservazione su 30-180 → **±0.03**. **La conclusione non si trasferisce fra sleeve.** + (2) **T2 — `implausible_sharpe` e `anchor_luck_band` CODIFICATI in `altlib.py`** (debito + raccomandato 3 volte — 26/06 CC01, 02/07 Albimarini "con piu' forza" — e mai scritto). + `implausible_sharpe`: Sharpe>3, perdite/attive<2%, Calmar>20 o maxDD~0, **regola del tre** + (0 perdite su N → tasso vero fino a 3/N: e' perche' "0/142" non si legge "senza rischio"). + `anchor_luck_band`: la griglia d'ancore NON e' una griglia di parametri → **il deflated-Sharpe non + la conta**; riporta stima onesta = **mediana della banda** e fortuna = canonica − mediana. + `anchor_luck_delta`: **mediana delle differenze appaiate** (codifica l'errore del 26/07). + ⚠️ **Il gate ha segnalato VRP01 e il difetto era MIO:** perdite contate su TUTTE le barre, ma + VRP01 e' settimanale su griglia giornaliera = **94.2% di zeri** → "0.96%, coda assente" su uno + sleeve **in produzione**; sulle barre **ATTIVE** e' **16.5%**, la forma giusta di un credit spread + a rischio definito. **E la lezione era gia' cablata il giorno prima** nel monitor DVOLSPREAD + ("barre attive, non giorni di calendario") — imparata in un contesto, non applicata nell'altro. + Zeri per sleeve: VRP01 94.2 / SKH01 87.6 / XS01 39.6 / GTAA01 31.9 / TP01 30.7%. + **Applicazione retroattiva 7/7 — tutti ok:** TP01 1.29, XS01 1.36, VRP01 1.08, SKH01 1.45, + GTAA01 1.01, **XSR01 1.79 (DD 2.5% ma 47.8% di barre attive in perdita → il DD basso e' vol bassa + + dollar-neutrality, NON una coda mancante: per il gate del 23/10 il rischio #1 resta lo slippage)**, + DVOLSPREAD 0.68. Controlli positivi obbligatori superati (firme CC01 e deep-OTM segnalate, rumore + Sh 0.62 no) — *un gate che non segnala nulla puo' essere semplicemente rotto*. + ✅ **Replica indipendente del finding d'ancora del 02/07:** il gate applicato alla cieca a TP01 + ritrova canonica **+0.237 = 92° pctl** delle 24, **mediana onesta +0.056**, banda [−0.087,+0.247], + fortuna +0.182 — contro mediana 0.04 / banda [−0.13,+0.30] misurate il 02/07 con implementazione + SEPARATA. **L'hold-out onesto di TP01 e' ~+0.05, non 0.31** (il valore del sleeve resta il taglio + del DD ~6× vs buy&hold, che non e' ancorato). + (3) **NON fatto e perche':** il book ricalcolato sul **path live** (sintesi naturale: SKH01 live > + modello su entrambi i lati, TP01 live ≈ modello) e' bloccato da una **incompatibilita' di lenti** — + il simulatore SKH01 compone per-trade a nozionale unitario, lo sleeve e' vol-targeted; combinarle + darebbe un numero preciso e falso. **Follow-up dichiarato:** serve la versione vol-targeted del + path live di SKH01 prima di toccare il fattore de-luck. Conseguenza gia' solida: il **×0.6** fu + scelto assumendo che il live degradi, ma su SKH01 e' misurato il contrario e su TP01 e' ~zero → + **il ×0.6 e' probabilmente CONSERVATIVO** (la nota "rischia di contare due volte la degradazione + SKH01" e' confermata, non ipotetica). - **Ondata 2026-07-26 (3 filoni: esecuzione SKH01 / DVOLSPREAD / XSR01 fuori dal crypto) — 0 sleeve nuovi, 1 raccomandazione operativa aperta, 1 lead promosso, 1 falsificazione.** Script `r0726_{skh_onbook,dvolspread_gate,xsr_equity}.py`, test `tests/test_wave_0726.py` (11 casi), diff --git a/docs/diary/2026-07-26-wave-tp01-partial-gates.md b/docs/diary/2026-07-26-wave-tp01-partial-gates.md new file mode 100644 index 0000000..68a08a4 --- /dev/null +++ b/docs/diary/2026-07-26-wave-tp01-partial-gates.md @@ -0,0 +1,172 @@ +# 2026-07-26-bis — Ondata: TP01 su barra parziale + i due gate mai costruiti + +**Richiesta:** *"fai un'altra ondata di ricerca"*. +**Esito: 0 sleeve nuovi, 1 audit sul libro live, 2 gate codificati, 1 falso positivo del mio +stesso gate su uno sleeve in produzione (catturato e corretto), 1 replica indipendente di un +finding di tre settimane fa.** +**Book, pesi, cron, config: INVARIATI.** + +Script: `scripts/research/r0726_tp01_partial_day.py`, `scripts/research/r0726_gates_retro.py`. +Test: `tests/test_gates_implausible_anchor.py` (13). Gate in `scripts/research/alt/altlib.py`. + +Da dove nasce: il 26/07 ho misurato che il path live di SKH01 divergeva dal modello su entrambi +i lati (uscite e ingressi) e che **il modello era pessimistico**. Due domande restavano aperte, ed +erano piu' interessanti di un'ennesima caccia al segnale: *lo stesso difetto esiste sullo sleeve +che pesa il 75% del book?* e *perche' i gate che il progetto si e' raccomandato tre volte non +esistono ancora?* + +--- + +## T1 — TP01 nel live legge una barra giornaliera fatta di UN'ORA + +**Il fatto, verificato non dedotto.** `resample_tf(..., label="left", closed="left")` non scarta il +giorno in corso; `TrendPortfolio.current_target` prende `target_series(df)[-1]`. Il feed certificato +si ricostruisce alle 00:30 UTC, quindi per tutta la giornata il book vede il giorno corrente come +**1 barra oraria su 24** (misurato: `barre1h=1/24`). Il docstring diceva "ultima barra CHIUSA": +**falso nel live**, ed e' la terza volta in due giorni che un docstring di produzione afferma una +causalita' che il codice non ha. + +**Due canali con segno atteso opposto**, quindi vanno separati: +- *esecuzione ritardata* — il modello ribilancia al confine 00:00, il live al primo cron dopo il + rebuild (~01:00); +- *barra parziale* — `c[t]` e' il prezzo delle 01:00 e `r[t]` e' un rendimento di **un'ora** contato + come giornaliero nella finestra di vol a 30 barre → vol sottostimata → **sovra-leva sistematica**. + +**Tre path, un grado di liberta' per volta**, su griglia oraria (i confini sono diversi: solo +l'orario li rende confrontabili): + +| offset 0 (ancora canonica) | FULL | HOLD | maxDD | +|---|---:|---:|---:| +| MODEL — `tgt[t-1]` dal confine 00:00 | 1.30 | 0.24 | 16.7% | +| CLOSED@+1h — scarta la parziale, ora del live | 1.27 | 0.16 | 17.5% | +| LIVE — `tgt[t]` da barra parziale, +1h | 1.25 | −0.07 | 17.5% | + +**Banda delle 24 ancore, mediana delle DIFFERENZE APPAIATE:** + +| effetto | FULL | HOLD | +|---|---|---| +| barra parziale (LIVE − CLOSED) | **−0.031** [−0.135,+0.065], pos. 7/24 | **+0.118** [−0.230,+0.277], pos. 19/24 | +| ritardo 1h (CLOSED − MODEL) | −0.004 [−0.053,+0.034], pos. 11/24 | −0.007, pos. 9/24 | +| totale (LIVE − MODEL) | −0.046, pos. 7/24 | +0.128, pos. 18/24 | + +Leva LIVE/MODEL: **1.004** (mediana, invariata su tutta la banda). + +**Verdetto: trascurabile.** Il ritardo d'esecuzione e' letteralmente una moneta (11/24). La barra +parziale muove ±0.03 su FULL e la sua "vittoria" su HOLD (+0.118) e' su una finestra sola e con +segno opposto su FULL — cioe' esattamente il profilo che il progetto ha gia' codificato come +sospetto. La sovra-leva prevista esiste ma vale **0.4%**. Nessun cambio al live. + +**⚠️ Da notare: all'ancora canonica il quadro sembra peggiore del vero.** A offset 0 la barra +parziale costa **−0.230** sull'hold-out, che e' il **minimo dell'intera banda** — mentre la mediana +e' +0.118. Guardando solo l'ancora canonica avrei concluso "il live degrada TP01 di 0.23 sull'hold-out" +e sarebbe stato l'artefatto di un'ancora. E' la stessa lezione del 26/07 in versione speculare +(li' l'ancora canonica *nascondeva* un vantaggio, qui *inventa* un danno). + +**Il risultato trasferibile non e' il numero, e' il perche'.** La parzialita' dell'ultima barra conta +in proporzione a **quanto il segnale pesa la barra piu' recente**: + +> SKH01 e' un Donchian breakout su barra 230m: **la barra corrente E' il segnale** → stesso difetto, +> **+0.38** di Sharpe mediano. TP01 e' un TSMOM 30/90/180 giorni: l'ora mancante e' 1/24 di **una** +> osservazione su 30-180 → **±0.03**. + +Corollario operativo: la conclusione di uno sleeve su questo tema **non si trasferisce** all'altro, +e la domanda "il mio live legge barre parziali?" ha una risposta diversa per ogni sleeve a seconda +della lunghezza del lookback. Cablato nei docstring di `current_target` e `resample_tf`. + +--- + +## T2 — I due gate raccomandati tre volte e mai scritti + +Debito dichiarato: `implausible_sharpe` (raccomandato il 26/06 su CC01, ri-raccomandato "con piu' +forza" il 02/07 su Albimarini) e `anchor_luck_band` (dichiarato "candidato gate futuro" il 02/07, +dopo il timing-luck trovato su 4/4 sleeve ancorati). Tre occorrenze, tre diagnosi a mano, zero codice. + +### `implausible_sharpe` +Uno Sharpe/track troppo bello significa che **il rischio e' fuori dal dataset**, non che la strategia +sia buona. Trigger: Sharpe > 3, frazione di barre attive in perdita < 2%, Calmar > 20 o maxDD ~0, e +la **regola del tre** (0 perdite su N trade lascia un tasso vero fino a ~3/N — su un payoff deep-OTM +basta a ribaltare l'expectancy: e' il motivo per cui "0/142" non si legge come "senza rischio"). + +### `anchor_luck_band` + `anchor_luck_delta` +La griglia di ancore **non e' una griglia di parametri**, quindi il deflated-Sharpe non la conta: e' +multiple-testing invisibile. Il gate riporta la stima onesta (**mediana della banda**) e la fortuna +(canonica − mediana). `anchor_luck_delta` codifica l'errore che ho commesso stamattina: fra offset +appaiati la statistica e' la **mediana delle differenze**, non la differenza delle mediane — le due +mediane cadono su offset diversi e il verdetto puo' ribaltarsi. + +### ⚠️ Il gate ha segnalato VRP01. Il difetto era MIO. +Prima stesura: perdite contate su **tutte** le barre. VRP01 e' settimanale su griglia giornaliera → +**94.2% di zeri** → "0.96% di perdite, coda sinistra assente" su uno sleeve **in produzione al 12%**. +Sulle barre **ATTIVE** le perdite sono **16.5%**, che e' esattamente la forma attesa di un credit +spread a rischio definito. + +Verifica: zeri per sleeve — VRP01 94.2%, SKH01 87.6%, XS01 39.6%, GTAA01 31.9%, TP01 30.7%. Un gate +che conta gli zeri come "non perdite" avrebbe finito per segnalare mezzo book. + +Ed e' la parte che vale la pena ricordare: **avevo gia' imparato questa lezione ieri**, cablando la +contabilita' a 3 stati del monitor DVOLSPREAD ("finestra misurata in barre attive, non giorni di +calendario"), e l'ho ripetuta il giorno dopo in un contesto diverso. Ora e' un test. + +### Esito dell'applicazione retroattiva — copertura 7/7 + +| | Sharpe | maxDD | perdite/attive | esito | +|---|---:|---:|---:|---| +| TP01 | 1.29 | 14.3% | 48.7% (1865) | ok | +| XS01 | 1.36 | 10.9% | 46.6% (566) | ok | +| VRP01 | 1.08 | 11.9% | 16.5% (109) | ok | +| SKH01 | 1.45 | 18.1% | 55.6% (333) | ok | +| GTAA01 | 1.01 | 8.9% | 45.2% (1831) | ok | +| XSR01 (monitor) | 1.79 | 2.5% | 47.8% (892) | ok | +| DVOLSPREAD (monitor) | 0.68 | 25.5% | 50.8% (1949) | ok | + +Controlli positivi (obbligatori: un gate che non segnala nulla puo' essere semplicemente rotto): +firma CC01 e firma deep-OTM **segnalate** con le motivazioni giuste; rumore a Sharpe 0.62 **non** +segnalato. + +**Nota che vale per XSR01**, il cui gate di deploy scade il 23/10: il suo maxDD del 2.5% *non* e' +una coda mancante — il **47.8%** delle barre attive e' in perdita. Il DD basso viene da vol bassa +(2.3%) e dollar-neutrality. Il rischio #1 di XSR01 resta lo slippage non modellato, non un modo di +perdita nascosto. + +### Replica indipendente del finding d'ancora del 02/07 +Il gate, applicato alla cieca a TP01 sulle 24 ancore orarie, ritrova da solo cio' che il 02/07 era +stato diagnosticato a mano: + +> canonica (00:00) **+0.237** = **92° percentile** delle 24 | mediana onesta **+0.056** | +> banda [−0.087, +0.247] | fortuna **+0.182** + +Il 02/07 riportava mediana 0.04 e banda [−0.13, +0.30] con un'implementazione **separata**. Accordo +buono. **Il numero onesto dell'hold-out di TP01 e' ~+0.05, non 0.31.** (Il valore del sleeve resta +quello gia' stabilito e non ancorato: il taglio del drawdown ~6× vs buy&hold.) + +--- + +## Cosa NON e' stato fatto, e perche' + +**Il book ricalcolato sul path LIVE.** Sarebbe la sintesi naturale (SKH01 live > modello su entrambi +i lati; TP01 live ≈ modello) e cambierebbe la stima de-luckata del book, che oggi applica un ×0.6 +assunto. Non l'ho fatto perche' le due misure vivono su **lenti diverse**: il simulatore di SKH01 +compone per-trade a nozionale unitario, lo sleeve di `sleeves.py` e' vol-targeted. Combinarle +produrrebbe un numero che sembra preciso e non lo e'. **Follow-up dichiarato con l'ostacolo nominato:** +serve una versione vol-targeted del path live di SKH01 prima di toccare il fattore di de-luck. + +Resta pero' una conseguenza qualitativa gia' solida: il ×0.6 fu scelto assumendo che il live +**degradi**. Su SKH01 e' misurato il contrario, su TP01 e' ~zero. **Il ×0.6 e' probabilmente +conservativo**, e la nota gia' presente in CLAUDE.md ("il ×0.6 sopra il path live rischia di contare +due volte la degradazione SKH01") va letta come confermata, non come cautela ipotetica. + +--- + +## Regole nuove + +1. **La parzialita' dell'ultima barra conta in proporzione a quanto il segnale pesa la barra piu' + recente.** Breakout/livelli: massima. Momentum a lookback lungo: ~nulla. Non trasferire la + conclusione fra sleeve. +2. **Ogni statistica di "frazione in perdita" si calcola sulle barre ATTIVE.** Uno sleeve flat la + maggior parte del tempo non ha una coda assente, ha meno osservazioni. (E: una lezione imparata + in un contesto non si applica da sola in un altro — questa era gia' cablata nel monitor + DVOLSPREAD il giorno prima.) +3. **Un gate nuovo si valida su un controllo positivo prima di credere ai suoi "ok".** Un gate rotto + e un gate soddisfatto producono lo stesso output. +4. **Anche un DANNO misurato a un'ancora sola e' fortuna d'ancora.** A offset 0 la barra parziale + sembrava costare −0.23 di hold-out: era il minimo della banda, mediana +0.12. diff --git a/scripts/research/alt/altlib.py b/scripts/research/alt/altlib.py index ff42dd9..79d0003 100644 --- a/scripts/research/alt/altlib.py +++ b/scripts/research/alt/altlib.py @@ -782,6 +782,148 @@ def _verdict(per_cell: list[dict]) -> dict: n_positive_cells=len(ok), n_cells=len(per_cell)) +def implausible_sharpe(daily, n_trades: int | None = None, n_losing_trades: int | None = None, + sharpe_max: float = 3.0, min_loss_frac: float = 0.02, + calmar_max: float = 20.0) -> dict: + """GATE: a Sharpe/track too good to be true means the RISK IS OUTSIDE THE DATASET. + + Codifies a blind spot the project hit THREE times and each time diagnosed by hand: + - CC01 cash-and-carry: modelled Sharpe 11-13, maxDD 0.3% — it passed EVERY gate of the + marginal scorer. The premium was real; the model simply had no 2022, no liquidation, no + basis blow-out. Real basis trades run Sharpe ~1-3 with abrupt tails. + - Albimarini deep-OTM cells: 0 losses in 142 trades. + - half of the 288-structure VRP grid: same 0-loss signature. + + The point is NOT that a high Sharpe is impossible. It is that a track with no left tail is + evidence about the SAMPLE, not about the strategy: the loss mode exists and did not occur. + A flagged candidate is at best STAT-MODE — never a deploy — until the missing tail is priced. + + `n_trades` / `n_losing_trades` enable the RULE OF THREE: observing 0 losses in N trades is + consistent with a true loss rate up to ~3/N. On a payoff that loses many multiples of the + premium (deep-OTM short options) that residual rate is enough to flip expectancy — which is + exactly why "0/142" must not read as "riskless". + + Returns dict(implausible: bool, reasons: [...], ...). ANY trigger sets implausible=True. + """ + s = pd.Series(daily) + if not isinstance(s.index, pd.DatetimeIndex): + raise TypeError("implausible_sharpe vuole una serie a indice temporale " + "(altrimenti l'annualizzazione e' arbitraria)") + s = _to_daily(s) # normalizza a giornaliero: _sh annualizza a 365.25 + r = np.asarray(s.dropna().values, float) + out = dict(implausible=False, reasons=[], n_obs=int(len(r))) + if len(r) < 30: + out["reasons"].append(f"campione troppo corto per giudicare (n={len(r)})") + return out + sh, dd = _sh(s), _dd_ret(s) + ann = float(np.mean(r) * 365.25) + # ⚠️ la frazione di perdite si misura sulle barre ATTIVE, non su tutte. Uno sleeve che sta + # flat la maggior parte del tempo (VRP01 e' settimanale su griglia giornaliera: 94% di zeri; + # SKH01 e' flat l'88% dei giorni) farebbe scattare "coda sinistra assente" per COSTRUZIONE. + # E' lo stesso errore che il monitor DVOLSPREAD evita contando in barre attive: qui l'avevo + # ripetuto, e il gate segnalava VRP01 (perdite 0.96% su tutte -> 16.5% sulle attive). + act = r[r != 0.0] + loss_frac = float((act < 0).mean()) if len(act) else float("nan") + calmar = ann / dd if dd > 1e-9 else float("inf") + out.update(sharpe=sh, maxdd=dd, ann_return=ann, loss_frac=loss_frac, calmar=calmar, + n_active=int(len(act)), active_frac=float(len(act) / len(r))) + + if len(act) < 30: + out["reasons"].append( + f"solo {len(act)} barre ATTIVE: la coda non e' misurabile, non 'assente' " + f"(giudizio sospeso, non promozione)") + out["implausible"] = True + return out + + if sh > sharpe_max: + out["reasons"].append( + f"Sharpe {sh:.2f} > {sharpe_max} — nessuna strategia eseguibile lo sostiene su anni; " + f"il candidato piu' probabile e' un rischio non nel campione") + if loss_frac < min_loss_frac: + out["reasons"].append( + f"solo il {loss_frac*100:.2f}% delle {len(act)} barre ATTIVE in perdita " + f"(<{min_loss_frac*100:.0f}%) — coda sinistra assente: e' una proprieta' del " + f"CAMPIONE, non della strategia") + if np.isfinite(calmar) and calmar > calmar_max: + out["reasons"].append( + f"Calmar {calmar:.1f} > {calmar_max} (ret {ann*100:.1f}%/a su maxDD {dd*100:.2f}%) — " + f"il drawdown misurato non contiene il modo di perdita") + elif not np.isfinite(calmar): + out["reasons"].append("maxDD ~0: il modo di perdita non si e' MAI manifestato nel campione") + + if n_trades and n_trades > 0 and n_losing_trades is not None and n_losing_trades == 0: + ub = 3.0 / n_trades # regola del tre + out["loss_rate_ub95"] = float(ub) + out["reasons"].append( + f"0 perdite su {n_trades} trade: la regola del tre lascia un tasso di perdita VERO " + f"fino a {ub*100:.2f}% — su un payoff asimmetrico basta a ribaltare l'expectancy") + + out["implausible"] = bool(out["reasons"]) + return out + + +def anchor_luck_band(fn_by_offset, offsets, canonical=0, metric=_sh, + min_frac_positive: float = 0.5) -> dict: + """GATE: quanta parte del titolo e' FORTUNA DELL'ANCORA (candidato dichiarato il 02/07). + + Il timing-luck d'ancora e' multiple-testing che il deflated-Sharpe NON conta: la griglia di + ancore non e' una griglia di PARAMETRI, quindi nessuno la dichiara come trial, ma scegliere + (anche implicitamente) l'ancora 00:00 e' un max-of-k. Misurato su 4/4 sleeve ancorati: + TP01 hold-out 0.31 = migliore delle 24; SKH01 minHold +1.26 = 93-98° pctl dei 23 offset; + XS01 fase 0 al 15° pctl di DD; VRP01 e' l'unico SENZA firma (la sua fase e' la PEGGIORE). + + `fn_by_offset(off)` -> serie di rendimenti (giornalieri o piu' fini). `metric` -> scalare. + La stima ONESTA e' la **mediana della banda**, non il valore canonico. + gate_pass = la metrica e' positiva alla mediana E in almeno `min_frac_positive` delle ancore + (necessario, non sufficiente: dice che il segno non dipende dall'ancora). + """ + vals = {} + for o in offsets: + try: + vals[o] = float(metric(fn_by_offset(o))) + except Exception: # un'ancora rotta non deve uccidere + continue + if len(vals) < 3: + return dict(gate_pass=False, reason=f"solo {len(vals)} ancore valutabili") + arr = np.array(sorted(vals.values()), float) + can = vals.get(canonical, float("nan")) + pctl = float((arr < can).mean()) if np.isfinite(can) else float("nan") + med = float(np.median(arr)) + frac_pos = float((arr > 0).mean()) + return dict(gate_pass=bool(med > 0 and frac_pos >= min_frac_positive), + canonical=can, canonical_pctl=pctl, median=med, + lo=float(arr.min()), hi=float(arr.max()), + frac_positive=frac_pos, n_anchors=len(vals), + luck=float(can - med) if np.isfinite(can) else float("nan"), + per_offset=vals) + + +def anchor_luck_delta(fn_a_by_offset, fn_b_by_offset, offsets, metric=_sh) -> dict: + """Confronto di DUE varianti sulla banda d'ancora: **mediana delle DIFFERENZE APPAIATE**. + + Errore catturato in sessione il 26/07 e qui codificato: confrontare `mediana(A) - mediana(B)` + mette a confronto ancore DIVERSE (la mediana di A e quella di B cadono su offset diversi) e + puo' RIBALTARE il verdetto — successe misurando il recupero on-book di SKH01. Gli offset sono + coppie: la statistica corretta e' la mediana di (A_o - B_o). + + Corollario della lezione del 26/07: *se si de-lucka una strategia va de-luckato anche il suo + DEGRADO* — ogni Δ fra due varianti misurato a un'ancora sola eredita la fortuna di quell'ancora. + """ + d = {} + for o in offsets: + try: + d[o] = float(metric(fn_a_by_offset(o))) - float(metric(fn_b_by_offset(o))) + except Exception: + continue + if len(d) < 3: + return dict(gate_pass=False, reason=f"solo {len(d)} ancore appaiate") + arr = np.array(list(d.values()), float) + return dict(gate_pass=bool(np.median(arr) > 0 and (arr > 0).mean() >= 0.5), + median_paired=float(np.median(arr)), mean_paired=float(np.mean(arr)), + lo=float(arr.min()), hi=float(arr.max()), + n_positive=int((arr > 0).sum()), n_anchors=len(d), per_offset=d) + + def study_weights(name: str, target_fn, tfs=("1d", "12h"), assets=CERTIFIED, fee_sweep=FEE_SWEEP) -> dict: """Run a CONTINUOUS-position hypothesis on each (asset,tf), report robustness. diff --git a/scripts/research/r0726_gates_retro.py b/scripts/research/r0726_gates_retro.py new file mode 100644 index 0000000..5e547aa --- /dev/null +++ b/scripts/research/r0726_gates_retro.py @@ -0,0 +1,134 @@ +"""I due gate MAI COSTRUITI, ora codificati e applicati RETROATTIVAMENTE (ondata 2026-07-26-bis, T2). + +Debito dichiarato dal progetto e mai saldato: + + `implausible_sharpe` — raccomandato il 26/06 (CC01: Sharpe modellato 11-13, maxDD 0.3%, passa + OGNI gate del marginal scorer -> "punto cieco") e RI-raccomandato "con piu' forza" il 02/07 + (celle Albimarini deep-OTM: 0 perdite su 142 trade; meta' della griglia VRP a 288 strutture con + la stessa firma). Tre occorrenze, tre diagnosi a mano, zero codice. + + `anchor_luck_band` — dichiarato "candidato gate futuro" il 02/07, dopo che il timing-luck + d'ancora era stato trovato su 4/4 sleeve ancorati. Motivo per cui serve un gate DEDICATO: la + griglia di ancore non e' una griglia di parametri, quindi **il deflated-Sharpe non la conta**. + +Questo script (a) prova che i gate FUNZIONANO su controlli positivi noti, poi (b) li passa su +tutto cio' che oggi e' nel book o in forward-monitor. Un gate che non segnala nulla non e' una +buona notizia finche' non si e' dimostrato che sa segnalare. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +for p in (ROOT, ROOT / "scripts" / "research", ROOT / "scripts" / "research" / "alt"): + sys.path.insert(0, str(p)) + +import altlib as al # noqa: E402 +from src.portfolio import sleeves as sl # noqa: E402 + + +def line(t: str) -> None: + print("\n" + "-" * 100 + f"\n {t}\n" + "-" * 100) + + +def show(name: str, rep: dict) -> None: + flag = "*** IMPLAUSIBILE ***" if rep.get("implausible") else "ok" + if "sharpe" in rep: + print(f" {name:<16} Sh {rep['sharpe']:>6.2f} DD {rep['maxdd']*100:>5.2f}% " + f"perdite/attive {rep['loss_frac']*100:>5.1f}% ({rep['n_active']} att.) Calmar {rep['calmar']:>7.2f} {flag}") + else: + print(f" {name:<16} {rep.get('reasons')} {flag}") + for r in rep.get("reasons", []): + print(f" - {r}") + + +def main() -> None: + print("=" * 100) + print(" T2 — `implausible_sharpe` e `anchor_luck_band`: codificati e applicati retroattivamente") + print("=" * 100) + + # ------------------------------------------------------------------ controlli positivi + line("0. CONTROLLI POSITIVI — il gate DEVE segnalare questi, sennò e' rotto") + idx = pd.date_range("2020-01-01", periods=900, freq="1D", tz="UTC") + rng = np.random.default_rng(726) + + # (a) firma CC01: cashflow liscio, quasi nessuna perdita, DD ~0 + cc = pd.Series(0.0006 + rng.normal(0, 0.00012, len(idx)), index=idx) + show("CC01-like", al.implausible_sharpe(cc)) + + # (b) firma Albimarini deep-OTM: 0 perdite su 142 trade + alb = pd.Series(np.abs(rng.normal(0.0008, 0.0003, len(idx))), index=idx) + show("deepOTM-like", al.implausible_sharpe(alb, n_trades=142, n_losing_trades=0)) + + # (c) controllo NEGATIVO: rumore normale a Sharpe ~1 non deve essere segnalato + ok = pd.Series(rng.normal(0.0006, 0.011, len(idx)), index=idx) + show("rumore Sh~1", al.implausible_sharpe(ok)) + + # ------------------------------------------------------------------ book + monitor + line("1. `implausible_sharpe` su TUTTO cio' che e' nel book o in forward-monitor") + series: dict[str, pd.Series] = {} + for nm, fn in (("TP01", sl._tp01_returns), ("XS01", sl._xsec_returns), + ("VRP01", sl._vrp_combo_returns), ("SKH01", sl._skyhook_returns), + ("GTAA01", sl._gtaa_daily_returns)): + try: + series[nm] = fn() + except Exception as e: # uno sleeve rotto non ferma l'audit + print(f" {nm:<16} non valutabile: {type(e).__name__}: {e}") + for nm, s in series.items(): + show(nm, al.implausible_sharpe(s)) + + # candidati in forward-monitor, ricostruiti dai rispettivi script di scoperta + line("2. `implausible_sharpe` sui CANDIDATI in forward-monitor") + try: + import r0725_statarb_basket_gate as xg # XSR01 = versione DEMEANATA + P, S = xg.pair_frames() + show("XSR01", al.implausible_sharpe(xg.basket_from_positions(P, S, demean=True))) + except Exception as e: + print(f" XSR01 non ricostruibile qui: {type(e).__name__}: {e}") + try: + import r0726_dvolspread_gate as dg + # config ONESTA congelata nel monitor (`paper_dvolspread.FROZEN`), non la cella pubblicata + show("DVOLSPREAD", al.implausible_sharpe( + dg.daily_returns(zwin=180, tanh_k=2.0, lw=0.6, zw=1.1, tgt=0.17, svw=60))) + except Exception as e: + print(f" DVOLSPREAD non ricostruibile qui: {type(e).__name__}: {e}") + + # ------------------------------------------------------------------ anchor band + line("3. `anchor_luck_band` — ri-verifica del finding 02/07 col gate ORA CODIFICATO") + print(" TP01 sulle 24 ancore orarie (metrica: Sharpe hold-out, il numero che il 02/07 era") + print(" risultato il MIGLIORE delle 24). Se il gate e' giusto deve ritrovarlo da solo.") + import r0726_tp01_partial_day as pd_mod + + cache: dict[int, dict] = {} + + def model_at(off: int) -> pd.Series: + if off not in cache: + cache[off] = pd_mod.evaluate(off) + s = cache[off]["MODEL"] + return s[s.index >= pd_mod.HOLDOUT] + + band = al.anchor_luck_band(model_at, range(24), canonical=0, + metric=lambda s: pd_mod.sharpe(s)) + print(f"\n canonica (00:00) {band['canonical']:+.3f} | pctl nella banda " + f"{band['canonical_pctl']*100:.0f}° | mediana {band['median']:+.3f} " + f"| banda [{band['lo']:+.3f},{band['hi']:+.3f}]") + print(f" positiva in {band['frac_positive']*100:.0f}% delle ancore | " + f"fortuna = canonica - mediana = {band['luck']:+.3f} | gate_pass={band['gate_pass']}") + + print("\n E il confronto APPAIATO fra due varianti (live con barra parziale vs modello):") + delta = al.anchor_luck_delta( + lambda o: cache.setdefault(o, pd_mod.evaluate(o))["LIVE"], + lambda o: cache.setdefault(o, pd_mod.evaluate(o))["MODEL"], + range(24), metric=lambda s: pd_mod.sharpe(s)) + print(f" mediana delle DIFFERENZE appaiate {delta['median_paired']:+.3f} " + f"[{delta['lo']:+.3f},{delta['hi']:+.3f}] positiva in " + f"{delta['n_positive']}/{delta['n_anchors']} gate_pass={delta['gate_pass']}") + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0726_tp01_partial_day.py b/scripts/research/r0726_tp01_partial_day.py new file mode 100644 index 0000000..0c651c2 --- /dev/null +++ b/scripts/research/r0726_tp01_partial_day.py @@ -0,0 +1,254 @@ +"""TP01 nel LIVE: la barra GIORNALIERA e' PARZIALE. Quanto conta? (ondata 2026-07-26-bis, T1) + +CONTESTO. Il 26/07 ho misurato che il live di SKH01 valuta il segnale su una barra 230m parziale +(uscite e ingressi) e che il backtest, modellandolo a chiusura di bin, lo sottostimava su entrambi +i lati. Questo script chiede la STESSA cosa allo sleeve che pesa il **75% del book Deribit**. + +IL FATTO (verificato, non dedotto): + `resample_tf(..., label="left", closed="left")` NON scarta il giorno in corso, e + `TrendPortfolio.current_target` prende `target_series(df)[-1]` — cioe' **l'ultima barra, che e' + parziale**. Il feed certificato viene ricostruito da `cron_daily` alle 00:30 UTC, quindi per tutta + la giornata il live vede il giorno corrente come una barra da **1 ora su 24** (misurato oggi: + `barre1h=1/24`). + Il docstring di `current_target` dice "ultima barra CHIUSA": e' un'assunzione sul chiamante, e nel + live NON e' verificata. + +DUE CANALI, che vanno separati perche' hanno segno atteso opposto: + (a) ESECUZIONE RITARDATA — il modello ribilancia al confine 00:00, il live al primo giro di cron + utile dopo il rebuild (~01:00). Un'ora di ritardo su uno sleeve daily: atteso ~rumore. + (b) BARRA PARZIALE — il target del live usa c[t] = prezzo delle 01:00 al posto della chiusura + piena del giorno, e r[t] = un rendimento di UN'ORA contato come rendimento GIORNALIERO dentro + la finestra di vol a 30 barre. La vol realizzata esce **sottostimata** -> `target_vol/vol` + esce **sovrastimata** -> il live **sovra-leva** in modo sistematico. Direzione attesa: negativa. + +TRE PATH, che isolano i due canali (differenza fra due path = un solo grado di liberta'): + MODEL tgt_full[t-1] tenuto in [t 00:00, t+1 00:00) = il backtest canonico + CLOSED@+1h tgt_full[t-1] tenuto in [t 01:00, t+1 01:00) = scarta la parziale, stessa ora del live + LIVE tgt_live[t] tenuto in [t 01:00, t+1 01:00) = cio' che gira oggi + LIVE - CLOSED = effetto puro della BARRA PARZIALE + CLOSED - MODEL = effetto puro del RITARDO d'esecuzione + +BANDA D'ANCORA. TP01 e' gia' noto per anchor timing-luck (02/07: l'ancora 00:00 e' la migliore +delle 24). Lezione codificata stamattina: *se si de-lucka una strategia va de-luckato anche il suo +degrado*. Quindi ogni Δ e' riportato sulle 24 ancore, con la **mediana delle DIFFERENZE APPAIATE** +(non la differenza delle mediane: gli offset sono coppie). + +Tutto su griglia ORARIA: i tre path hanno confini diversi e solo l'orario li rende confrontabili. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from src.data.downloader import load_data # noqa: E402 +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402 + +ASSETS = ("BTC", "ETH") +HOLDOUT = "2025-01-01" +MS_H = 3_600_000 +MS_D = 86_400_000 +FEE_SIDE = float(CANONICAL.get("fee_side", 0.0005)) +EXEC_LAG_H = 1 # il primo giro di cron_book utile dopo il rebuild delle 00:30 + + +# --------------------------------------------------------------------------- dati + +def hourly(asset: str) -> tuple[np.ndarray, np.ndarray]: + df = load_data(asset, "1h") + return (df["timestamp"].values.astype(np.int64), + df["close"].values.astype(float)) + + +def daily_from_hourly(ts: np.ndarray, c: np.ndarray, off_h: int) -> dict: + """Barre giornaliere ancorate a `off_h` (UTC), piu' il prezzo alla PRIMA ora del giorno. + + Ritorna: day_start (ms), close pieno, close parziale (dopo 1 ora), n. di barre 1h per giorno. + """ + day = (ts - off_h * MS_H) // MS_D + s = pd.DataFrame({"day": day, "ts": ts, "c": c}) + g = s.groupby("day", sort=True) + full = g["c"].last().values + first = g["c"].first().values # close della PRIMA barra 1h -> la "parziale" a +1h + n = g["c"].size().values + start = (g["ts"].min().values // MS_H) * MS_H + return dict(start=start, full=full, partial=first, n=n) + + +# --------------------------------------------------------------------------- target + +def targets_full(cf: np.ndarray, tp: TrendPortfolio) -> np.ndarray: + """target_series su barre giornaliere PIENE (bpd=1 -> orizzonti in giorni).""" + d = pd.DataFrame({"close": cf, + "datetime": pd.to_datetime(np.arange(len(cf)) * MS_D, unit="ms", utc=True)}) + return tp.target_series(d) + + +def targets_live(cf: np.ndarray, cp: np.ndarray, tp: TrendPortfolio) -> np.ndarray: + """Ricostruisce cio' che il live calcola: giorni 0..t-1 PIENI + giorno t PARZIALE, indice [-1]. + + Riproduce a mano la formula di `target_series` sostituendo il SOLO ultimo elemento — che e' + esattamente cio' che cambia fra la vista del live e quella del backtest. + """ + n = len(cf) + vw = tp.vol_win_days # bpd=1 su barre giornaliere + hz = tuple(tp.horizons_days) + bpy = 365.25 + r_full = np.zeros(n) + r_full[1:] = cf[1:] / cf[:-1] - 1.0 + out = np.zeros(n) + for t in range(n): + # direzione: sign(c[t]/c[t-h]) con c[t] = PARZIALE + acc = cnt = 0.0 + for h in hz: + if t - h >= 0: + acc += np.sign(cp[t] / cf[t - h] - 1.0) + cnt += 1 + if cnt == 0: + continue + direction = acc / cnt + if tp.long_only: + direction = max(direction, 0.0) + # vol: finestra a vw barre che finisce in t, con r[t] = rendimento di UN'ORA + a = max(0, t - vw + 1) + w = r_full[a:t + 1].copy() + if t >= 1: + w[-1] = cp[t] / cf[t - 1] - 1.0 + if len(w) < max(1, vw // 2): + continue + sd = float(np.std(w, ddof=1)) if len(w) > 1 else 0.0 + vol = sd * np.sqrt(bpy) + if not np.isfinite(vol) or vol <= 0: + continue + out[t] = float(np.clip(direction * (tp.target_vol / vol), -tp.leverage, tp.leverage)) + return out + + +# --------------------------------------------------------------------------- path orari + +def hourly_position(ts: np.ndarray, dstart: np.ndarray, tgt: np.ndarray, + shift_h: int) -> np.ndarray: + """Posizione oraria: `tgt[k]` in vigore da (dstart[k] + shift_h ore) fino al successivo.""" + edges = dstart + shift_h * MS_H + j = np.searchsorted(edges, ts, side="right") - 1 + pos = np.where(j >= 0, tgt[np.clip(j, 0, len(tgt) - 1)], 0.0) + return pos + + +def path_returns(ts: np.ndarray, c: np.ndarray, pos: np.ndarray) -> pd.Series: + """Rendimenti orari netti fee sul turnover, indicizzati per timestamp.""" + r = np.zeros(len(c)) + r[1:] = c[1:] / c[:-1] - 1.0 + held = np.zeros(len(pos)) + held[1:] = pos[:-1] # tenuta durante l'ora i = decisa a i-1 + turn = np.abs(np.diff(held, prepend=0.0)) + net = held * r - FEE_SIDE * turn + return pd.Series(net, index=pd.to_datetime(ts, unit="ms", utc=True)) + + +def sharpe(s: pd.Series) -> float: + x = s.values[np.isfinite(s.values)] + if len(x) < 50 or np.std(x) == 0: + return 0.0 + return float(np.mean(x) / np.std(x) * np.sqrt(24 * 365.25)) + + +def maxdd(s: pd.Series) -> float: + eq = np.cumprod(1.0 + np.clip(s.values, -0.99, None)) + return float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq))) + + +# --------------------------------------------------------------------------- valutazione + +def evaluate(off_h: int) -> dict: + """I tre path per una data ancora, combinati 50/50 BTC+ETH come fa lo sleeve.""" + tp = TrendPortfolio(**CANONICAL) + legs: dict[str, list] = {"MODEL": [], "CLOSED": [], "LIVE": []} + lev: dict[str, list] = {"MODEL": [], "LIVE": []} + for a in ASSETS: + ts, c = hourly(a) + d = daily_from_hourly(ts, c, off_h) + cf, cp, dstart = d["full"], d["partial"], d["start"] + tf = targets_full(cf, tp) + tl = targets_live(cf, cp, tp) + # MODEL: tgt[t-1] tenuto dal confine del giorno t -> shift di 1 giorno = tgt spostato avanti + tf_prev = np.concatenate([[0.0], tf[:-1]]) + legs["MODEL"].append(path_returns(ts, c, hourly_position(ts, dstart, tf_prev, 0))) + legs["CLOSED"].append(path_returns(ts, c, hourly_position(ts, dstart, tf_prev, EXEC_LAG_H))) + legs["LIVE"].append(path_returns(ts, c, hourly_position(ts, dstart, tl, EXEC_LAG_H))) + lev["MODEL"].append(tf_prev) + lev["LIVE"].append(tl) + out = {} + for k, v in legs.items(): + J = pd.concat(v, axis=1, join="inner").fillna(0.0) + out[k] = 0.5 * J.iloc[:, 0] + 0.5 * J.iloc[:, 1] + # diagnostica di leva: quanto sovra-leva il live rispetto al modello? + ml = np.concatenate(lev["MODEL"]); ll = np.concatenate(lev["LIVE"]) + m = (ml > 0) & (ll > 0) + out["_lev_ratio"] = float(np.median(ll[m] / ml[m])) if m.any() else float("nan") + return out + + +def report(off_h: int, res: dict) -> dict: + row = dict(off=off_h, lev=res["_lev_ratio"]) + for k in ("MODEL", "CLOSED", "LIVE"): + s = res[k] + row[f"{k}_full"] = sharpe(s) + row[f"{k}_hold"] = sharpe(s[s.index >= HOLDOUT]) + row[f"{k}_dd"] = maxdd(s) + return row + + +def main() -> None: + print("=" * 100) + print(" TP01 nel LIVE — la barra giornaliera e' PARZIALE (1h su 24). Quanto conta?") + print("=" * 100) + print("\n MODEL = tgt[t-1] dal confine 00:00 (backtest canonico)") + print(" CLOSED@+1h = tgt[t-1] dal confine +1h (scarta la parziale, ora del live)") + print(" LIVE = tgt[t] da barra PARZIALE, +1h (cio' che gira oggi)") + print(" LIVE-CLOSED = effetto BARRA PARZIALE | CLOSED-MODEL = effetto RITARDO\n") + + rows = [report(o, evaluate(o)) for o in range(24)] + df = pd.DataFrame(rows) + + print("-" * 100) + print(" ancora canonica (offset 0) — quella su cui sono calcolati TUTTI i numeri di TP01") + print("-" * 100) + r0 = df[df.off == 0].iloc[0] + print(f" {'path':<12}{'FULL':>8}{'HOLD':>8}{'maxDD':>9}") + for k in ("MODEL", "CLOSED", "LIVE"): + print(f" {k:<12}{r0[f'{k}_full']:>8.2f}{r0[f'{k}_hold']:>8.2f}{r0[f'{k}_dd']*100:>8.1f}%") + print(f"\n leva del LIVE / leva del MODEL (mediana sui giorni entrambi investiti): " + f"{r0['lev']:.3f}") + + print("\n" + "-" * 100) + print(" BANDA DELLE 24 ANCORE — mediana delle DIFFERENZE APPAIATE (non differenza di mediane)") + print("-" * 100) + for lab, a, b in (("BARRA PARZIALE (LIVE - CLOSED)", "LIVE", "CLOSED"), + ("RITARDO 1h (CLOSED - MODEL)", "CLOSED", "MODEL"), + ("TOTALE (LIVE - MODEL)", "LIVE", "MODEL")): + for w in ("full", "hold"): + d = df[f"{a}_{w}"] - df[f"{b}_{w}"] + print(f" {lab:<34} {w.upper():<5} mediana {d.median():+.3f} " + f"[{d.min():+.3f},{d.max():+.3f}] positivo in {int((d > 0).sum())}/24") + print(f"\n leva LIVE/MODEL sulla banda: mediana {df['lev'].median():.3f} " + f"[{df['lev'].min():.3f},{df['lev'].max():.3f}]") + + print("\n" + "-" * 100) + print(" per-ancora (FULL)") + print("-" * 100) + print(f" {'off':>4}{'MODEL':>8}{'CLOSED':>8}{'LIVE':>8}{'ΔparzialeF':>12}{'ΔparzialeH':>12}{'lev':>7}") + for _, r in df.iterrows(): + print(f" {int(r.off):>4}{r.MODEL_full:>8.2f}{r.CLOSED_full:>8.2f}{r.LIVE_full:>8.2f}" + f"{r.LIVE_full - r.CLOSED_full:>+12.3f}{r.LIVE_hold - r.CLOSED_hold:>+12.3f}" + f"{r.lev:>7.3f}") + print() + + +if __name__ == "__main__": + main() diff --git a/src/strategies/trend_portfolio.py b/src/strategies/trend_portfolio.py index a5c9515..fd0295a 100644 --- a/src/strategies/trend_portfolio.py +++ b/src/strategies/trend_portfolio.py @@ -115,7 +115,23 @@ class TrendPortfolio: return tgt def current_target(self, df: pd.DataFrame) -> float: - """Posizione-bersaglio decisa all'ultima barra CHIUSA (per il paper/live).""" + """Posizione-bersaglio all'ULTIMA BARRA DEL df — che nel live e' PARZIALE (per paper/live). + + ⚠️ Il docstring precedente diceva "ultima barra CHIUSA": e' un'assunzione sul chiamante e + nel live **non e' verificata**. `resample_tf` non scarta la barra in corso e il feed + certificato viene ricostruito alle 00:30 UTC, quindi per tutta la giornata il book vede il + giorno corrente come una barra da **1 ora su 24** (verificato 26/07: `barre1h=1/24`). + + MISURATO (`scripts/research/r0726_tp01_partial_day.py`, 24 ancore, differenze appaiate): + l'effetto e' **trascurabile** — barra parziale ΔSharpe FULL −0.031 / HOLD +0.118, ritardo + d'esecuzione di 1h ~0 (11/24 = moneta), leva gonfiata **+0.4%**. Nessun cambio richiesto. + + Il perche' e' la regola generale: la parzialita' dell'ultima barra conta in proporzione a + **quanto il segnale pesa la barra piu' recente**. TP01 e' un TSMOM 30/90/180 giorni: l'ora + mancante e' 1/24 di UNA osservazione su 30-180. SKH01 e' un Donchian breakout su barra + 230m, dove la barra corrente E' il segnale: li' lo stesso difetto vale +0.38 di Sharpe + (misura del 26/07). Non si trasferisce una conclusione fra i due. + """ return float(self.target_series(df)[-1]) def net_returns(self, df: pd.DataFrame) -> tuple[np.ndarray, pd.Series]: @@ -179,7 +195,10 @@ def _bars_per_year(idx: pd.DatetimeIndex) -> float: def resample_tf(df_1h: pd.DataFrame, rule: str) -> pd.DataFrame: """Resample 1h -> rule (confini 00:00 UTC). Schema con 'datetime'. NB: usare SOLO per-singolo-TF (qui leak-free); MAI ffill/combine mixed-TF su questi - timestamp open-labeled (label='left') -> look-ahead. Deploy a >=12h (vedi docstring modulo).""" + timestamp open-labeled (label='left') -> look-ahead. Deploy a >=12h (vedi docstring modulo). + ⚠️ NON scarta la barra IN CORSO: l'ultima riga puo' essere parziale. E' irrilevante nel + backtest (l'ultima barra e' l'ultima) ma NON nel live, dove `current_target` legge proprio + quella — vedi il suo docstring per la misura dell'effetto.""" g = df_1h.copy() idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True) idx.name = "dt" diff --git a/tests/test_gates_implausible_anchor.py b/tests/test_gates_implausible_anchor.py new file mode 100644 index 0000000..93b9181 --- /dev/null +++ b/tests/test_gates_implausible_anchor.py @@ -0,0 +1,172 @@ +"""Test dei due gate codificati il 2026-07-26-bis: `implausible_sharpe` e `anchor_luck_band`. + +Entrambi erano DEBITO DICHIARATO del progetto (raccomandati piu' volte, mai scritti). Un gate e' +utile solo se ha **potenza** (segnala cio' che deve) E **specificita'** (non segnala il resto): +un gate che non scatta mai passerebbe inosservato per mesi sembrando una buona notizia. Quindi +qui si testano sempre le due facce. + +Il caso piu' importante e' `test_sleeve_flat_la_maggior_parte_del_tempo_non_e_implausibile`: +la prima stesura del gate contava le perdite su TUTTE le barre e segnalava VRP01 (settimanale su +griglia giornaliera, 94% di zeri) — un falso positivo su uno sleeve IN PRODUZIONE. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +for p in (ROOT, ROOT / "scripts" / "research" / "alt"): + sys.path.insert(0, str(p)) + +pytest.importorskip("pandas") +import pandas as pd # noqa: E402 + +import altlib as al # noqa: E402 + + +def _idx(n): + return pd.date_range("2020-01-01", periods=n, freq="1D", tz="UTC") + + +def _noise(n=900, mu=0.0006, sd=0.011, seed=1): + rng = np.random.default_rng(seed) + return pd.Series(rng.normal(mu, sd, n), index=_idx(n)) + + +# --------------------------------------------------------------- implausible_sharpe: POTENZA + +def test_firma_cc01_viene_segnalata(): + """CC01 (Sharpe modellato 11-13, maxDD 0.3%) passava OGNI gate del marginal scorer: e' il + punto cieco che questo gate esiste per coprire.""" + rng = np.random.default_rng(0) + s = pd.Series(0.0006 + rng.normal(0, 0.00012, 900), index=_idx(900)) + r = al.implausible_sharpe(s) + assert r["implausible"] + assert any("Sharpe" in x for x in r["reasons"]) + + +def test_zero_perdite_su_molti_trade_attiva_la_regola_del_tre(): + """0/142 non significa 'senza rischio': lascia un tasso vero fino a ~3/142 = 2.1%, e su un + payoff deep-OTM quel residuo ribalta l'expectancy.""" + s = pd.Series(np.abs(_noise(900, 0.0008, 0.0003, seed=2).values), index=_idx(900)) + r = al.implausible_sharpe(s, n_trades=142, n_losing_trades=0) + assert r["implausible"] + assert r["loss_rate_ub95"] == pytest.approx(3.0 / 142) + assert any("regola del tre" in x for x in r["reasons"]) + + +def test_calmar_assurdo_viene_segnalato_anche_a_sharpe_moderato(): + """Il modo di perdita puo' mancare senza che lo Sharpe esploda: un DD ~0 basta da solo.""" + v = np.full(900, 0.0004) + v[::120] = -0.00005 # perdite presenti ma minuscole -> DD ~0 + r = al.implausible_sharpe(pd.Series(v, index=_idx(900))) + assert r["implausible"] + assert any("Calmar" in x or "maxDD" in x for x in r["reasons"]) + + +# --------------------------------------------------------------- implausible_sharpe: SPECIFICITA' + +def test_rumore_a_sharpe_uno_non_viene_segnalato(): + """Specificita': senza questo, un gate sempre-acceso sarebbe inutile quanto uno sempre-spento.""" + assert not al.implausible_sharpe(_noise())["implausible"] + + +def test_sleeve_flat_la_maggior_parte_del_tempo_non_e_implausibile(): + """IL falso positivo della prima stesura. VRP01 e' settimanale su griglia giornaliera: 94% di + zeri. Contando le perdite su TUTTE le barre esce 0.96% ('coda assente') invece del 16.5% reale + sulle barre ATTIVE -> il gate segnalava uno sleeve in PRODUZIONE.""" + rng = np.random.default_rng(3) + v = np.zeros(900) + act = np.arange(0, 900, 7) # una barra attiva a settimana + v[act] = np.where(rng.random(len(act)) < 0.17, + -rng.uniform(0.02, 0.05, len(act)), # ~17% di settimane in perdita + rng.uniform(0.004, 0.012, len(act))) + r = al.implausible_sharpe(pd.Series(v, index=_idx(900))) + assert r["n_active"] == len(act) + assert 0.05 < r["loss_frac"] < 0.35, r["loss_frac"] + assert not r["implausible"], r["reasons"] + + +def test_poche_barre_attive_sospende_il_giudizio_invece_di_promuovere(): + """Con pochissime osservazioni attive la coda non e' 'assente', e' NON MISURABILE: il gate + deve bloccare, non assolvere.""" + v = np.zeros(900) + v[::100] = 0.01 + r = al.implausible_sharpe(pd.Series(v, index=_idx(900))) + assert r["implausible"] + assert any("non e' misurabile" in x for x in r["reasons"]) + + +def test_indice_non_temporale_e_un_errore_esplicito(): + """Senza indice temporale l'annualizzazione sarebbe arbitraria: meglio alzare che indovinare.""" + with pytest.raises(TypeError): + al.implausible_sharpe(pd.Series(np.zeros(100) + 0.001)) + + +# --------------------------------------------------------------- anchor_luck_band + +def test_banda_dancora_riconosce_il_massimo_come_fortuna(): + """Se l'ancora canonica e' la MIGLIORE della griglia, il gate deve dirlo: pctl alto e + stima onesta = mediana, non il valore canonico.""" + vals = {o: 0.05 * o for o in range(10)} # 9 = il migliore + b = al.anchor_luck_band(lambda o: vals[o], range(10), canonical=9, metric=lambda x: x) + assert b["canonical_pctl"] >= 0.85 + assert b["median"] == pytest.approx(0.225) + assert b["luck"] > 0 + + +def test_banda_dancora_non_penalizza_chi_e_al_centro(): + """VRP01 e' l'unico sleeve la cui ancora canonica NON e' fortunata: il gate non deve + inventare una penalita' dove non c'e'.""" + vals = {o: 1.0 + 0.01 * o for o in range(9)} + b = al.anchor_luck_band(lambda o: vals[o], range(9), canonical=4, metric=lambda x: x) + assert 0.3 <= b["canonical_pctl"] <= 0.7 + assert abs(b["luck"]) < 1e-9 + assert b["gate_pass"] + + +def test_segno_che_dipende_dallancora_non_passa(): + """Il senso del gate: se la meta' delle ancore e' negativa, il titolo e' una scelta d'ancora.""" + vals = {o: (1.0 if o % 2 else -1.0) for o in range(10)} + b = al.anchor_luck_band(lambda o: vals[o], range(10), canonical=1, metric=lambda x: x) + assert not b["gate_pass"] + + +def test_ancore_rotte_non_uccidono_la_banda(): + """Un'ancora che solleva (dati mancanti a quell'offset) va saltata, non propagata.""" + def f(o): + if o == 3: + raise ValueError("feed mancante") + return 1.0 + b = al.anchor_luck_band(f, range(8), canonical=0, metric=lambda x: x) + assert b["n_anchors"] == 7 and b["gate_pass"] + + +# --------------------------------------------------------------- anchor_luck_delta + +def test_delta_appaiato_ribalta_la_differenza_di_mediane(): + """IL motivo per cui questa funzione esiste (errore commesso il 26/07 sul recupero on-book + di SKH01): le mediane di A e B cadono su offset DIVERSI, quindi la loro differenza confronta + ancore diverse e puo' RIBALTARE il verdetto. + + NB: la costruzione non e' banale — se A battesse B a OGNI offset le mediane non potrebbero + invertirsi (la mediana e' monotona). Il caso reale, e quello qui: A vince nella MAGGIORANZA + degli offset (4/5) ma la sua mediana cade sotto quella di B.""" + A = {0: 1.0, 1: 1.0, 2: 3.0, 3: 5.0, 4: 6.0} # mediana 3 + B = {0: 0.0, 1: 0.0, 2: 4.0, 3: 4.5, 4: 5.0} # mediana 4 + naive = float(np.median(list(A.values())) - np.median(list(B.values()))) + d = al.anchor_luck_delta(lambda o: A[o], lambda o: B[o], range(5), metric=lambda x: x) + assert naive < 0, "il test non direbbe nulla se la statistica ingenua non sbagliasse" + assert d["median_paired"] > 0 + assert d["n_positive"] == 4 and d["gate_pass"] + + +def test_delta_appaiato_non_promuove_una_vittoria_di_una_sola_ancora(): + """Un vantaggio concentrato su un'ancora sola non e' un vantaggio: e' la fortuna di quell'ancora.""" + A = {0: 5.0, 1: 0.9, 2: 0.8, 3: 0.7} + B = {0: 1.0, 1: 1.0, 2: 1.0, 3: 1.0} + d = al.anchor_luck_delta(lambda o: A[o], lambda o: B[o], range(4), metric=lambda x: x) + assert d["median_paired"] < 0 and not d["gate_pass"]