From c8d31bc94489241626e5478c46c15c8894f6246c Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 25 Jul 2026 22:12:42 +0000 Subject: [PATCH] research(skh): misura dedicata sugli INGRESSI da barra 230m parziale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chiude il follow-up dichiarato del 26/07 (misura T1). Il live valuta il segnale a ogni giro orario del cron su una barra 230m mediamente completa a meta'; il backtest solo a chiusura di bin. Domanda: di che segno e' il saldo. Confronto di due path identici in tutto (livelli pct-asimmetrici, uscite intra-barra, cap max_per_day, fee 0.10% RT) tranne quando si valuta l'ingresso. ΔSharpe (live - backtest), 3 offset x 2 asset: -0.01 / +0.04 / +0.44 / +0.32 / +0.59 / +0.98 -> 6/6 non negativi, mediana +0.38 Falsi ingressi ~5/anno/asset; cannibalizzano il cap 2-3 volte in 7 anni. Meccanismo: Donchian breakout — aspettare la chiusura del bin fa pagare il movimento gia' avvenuto (ingresso 0.25-0.26% peggiore), e su livelli percentuali quello 0.26% vale il 6-13% della distanza dallo SL contro il 2.6-3.3% da quella dal TP -> asimmetria a favore della sopravvivenza del trade. Due attacchi superati: - il divario NON e' concentrato: togliendo i 5 giorni migliori si ALLARGA (ETH@460 35.6x vs 3.0x); e' il backtest il path concentrato; - nessun look-ahead intra-bin: troncando i 5m alle sole barre gia' chiuse la risposta e' identica in 289/289 osservazioni (test permanente). Serviva perche' il self-check valida solo a CHIUSURA di bin. Verdetto: non e' un difetto da correggere, il verso e' lasciarlo. Ma live e backtest girano due strategie diverse e la differenza non e' neutra: sommato alla misura sulle uscite, il path live di SKH01 e' stato modellato in modo sistematicamente pessimistico su entrambi i lati. Book, pesi, cron, config INVARIATI. Due errori di metodo catturati in sessione e codificati come regole: - un self-check su eventi rari si campiona sugli EVENTI (il primo dava "80/80 OK" confrontando zeri con zeri, con la ricostruzione rotta); - un conteggio su segnale grezzo non e' un conteggio di trade (sovrastima ~20x dei falsi ingressi ignorando cap e non-overlap del live). Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 47 +- docs/diary/2026-07-26-skh-partial-entry.md | 202 +++++++++ scripts/research/r0726_skh_partial_entry.py | 474 ++++++++++++++++++++ tests/test_skh_partial_entry.py | 142 ++++++ 4 files changed, 859 insertions(+), 6 deletions(-) create mode 100644 docs/diary/2026-07-26-skh-partial-entry.md create mode 100644 scripts/research/r0726_skh_partial_entry.py create mode 100644 tests/test_skh_partial_entry.py diff --git a/CLAUDE.md b/CLAUDE.md index 6d87611..ab88b39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -603,12 +603,47 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis **Scelta dichiarata: ALLERTA, NON blocca** — bloccare fermerebbe anche TP01 (nettato sullo stesso strumento) per un guasto di rete, e forzare SKH flat chiuderebbe posizioni buone su un glitch. Test `tests/test_skh_feed_freshness.py` (11 casi). Strategia/pesi/cadenza INVARIATI. - ⚠️ **FOLLOW-UP DICHIARATO, non risolto:** la stessa barra parziale tocca anche gli **INGRESSI** — - `ent[n-1]` puo' venire da un breakout non confermato a fine barra → se evapora, il book apre e - richiude. Su 112 bin campionati: disaccordo parziale-vs-completa in **1 (~1%)**, ma con sole 3 - entry nel campione **la taglia non e' stimabile**. A differenza delle uscite qui il live DIVERGE - dal backtest in modo sostanziale (entra su un segnale che il backtest non vedrebbe): sistemarlo e' - un cambio di STRATEGIA, non di strumentazione → serve una misura dedicata prima di decidere il verso. + ✅ **FOLLOW-UP CHIUSO 2026-07-26 — la misura dedicata sugli INGRESSI e' fatta: NON e' un difetto, + il verso e' LASCIARLO.** `scripts/research/r0726_skh_partial_entry.py`, test + `tests/test_skh_partial_entry.py` (10), diario `2026-07-26-skh-partial-entry.md`. + Confronto di due path identici in tutto (livelli, uscite intra-barra, cap, fee) tranne **quando si + valuta l'ingresso**: LIVE = a ogni confine orario dentro il bin 230m (cio' che il cron fa oggi), + BACKTEST = solo a chiusura di bin. **ΔSharpe su 3 offset x 2 asset: −0.01/+0.04/+0.44/+0.32/+0.59/ + +0.98 → 6/6 non negativi, mediana +0.38.** All'offset 0 (l'ancora fortunata del backtest, 93-98° + pctl) l'effetto e' ~0 sul FULL ma l'hold-out BTC fa **1.12 live vs 0.71 backtest**. I falsi ingressi + (segnale che evapora) sono **~5/anno/asset**, e cannibalizzano il cap `max_per_day` solo 2-3 volte + in 7 anni (0.6-0.9% degli ingressi veri). **Meccanismo:** SKH01 e' un Donchian breakout — aspettare + fino a 230 min la chiusura del bin fa *pagare il movimento gia' avvenuto* (ingresso **0.25-0.26% + peggiore** in media); e siccome i livelli sono percentuali sull'ingresso (long sl4%/tp10%, short + sl2%/tp8%), lo stesso 0.26% vale il **6-13% della distanza dallo SL** contro il 2.6-3.3% di quella + dal TP → vantaggio **asimmetrico a favore della sopravvivenza del trade** (win rate +8pp ETH/+2pp BTC). + **Due attacchi superati:** (a) il divario NON e' concentrato — togliendo i 5 giorni migliori si + ALLARGA (ETH@460 35.6x vs 3.0x); e' il *backtest* il path concentrato (~39% del log-equity in 5 + giorni contro 17-22% del live); (b) **nessun look-ahead intra-bin** — troncando i 5m alle sole barre + gia' chiuse la risposta e' identica in **289/289** osservazioni intra-bin e il prezzo d'ingresso e' + sempre l'ultimo close 5m disponibile (test permanente `test_nessun_lookahead_intra_bin`; serviva + perche' il self-check valida solo a **chiusura** di bin → un leak solo-intra-bin gli sarebbe + invisibile per costruzione). + ⚠️ **La lettura che conta e' la seconda: live e backtest girano due strategie DIVERSE e la + differenza non e' neutra.** Ogni numero di SKH01 nel progetto (Sharpe standalone, peso 25%, audit + d'ancora 02/07, conferma peso/cadenza 24/07) e' calcolato sul path a chiusura di bin, che non e' + quello che gira. Sommato alla misura del 26/07 sulle uscite (+0.081 Sharpe FULL di book + sottostimato), **il path live di SKH01 e' stato modellato in modo sistematicamente pessimistico su + ENTRAMBI i lati.** Cio' che NON si conclude: che l'ingresso intra-bin sia un miglioramento + *validato* — e' stato misurato sugli stessi 7 anni su cui SKH01 e' stato selezionato, non e' passato + per `study_family_honest` ne' per un deflated-Sharpe, e la taglia varia molto fra ancore. Ma non + serve promuoverlo: **e' gia' cio' che il live fa**; l'azione e' smettere di trattare il numero del + backtest come l'aspettativa del live, NON "riparare" il live verso un backtest peggiore. + **Book, pesi, cron, config INVARIATI.** + ⚠️ **Regole nuove (due errori catturati in sessione, entrambi del tipo che passa i test pigri):** + (i) **un self-check su eventi rari si campiona sugli EVENTI, non sulla popolazione** — la prima + stesura stampava "BTC 80/80 OK" mentre la ricostruzione era rotta da un off-by-one di confine + (`obs//MS_LTF` a chiusura cade nel bin successivo, vuoto → segnale 0): con gli ingressi al ~2% dei + bin confrontava **zeri con zeri**, potenza zero, e le 2 sole divergenze ETH erano gli unici 2 bin + con segnale vero. (ii) **un conteggio di eventi su segnale GREZZO non e' un conteggio di trade** — + la prima scansione dava 1361 falsi ingressi e un costo inventato di −3%/anno di sleeve perche' + ignorava cap+non-overlap del live (**sovrastima ~20x**); la spia era 305 *giorni* con un falso + ingresso a fronte di 663 "eventi", impossibile con cap 1/giorno. (2) ✅ **T2 — DVOLSPREAD ESCE DAL LIMBO** (era fermo dal 21/06, unico sopravvissuto del marginal scorer indurito, mai ripreso: non nel book, non in monitor, non rifiutato). Passato ai due gate che nel giugno NON esistevano. ⚠️ La griglia dichiarata dall'agente ("72 celle") ne contiene diff --git a/docs/diary/2026-07-26-skh-partial-entry.md b/docs/diary/2026-07-26-skh-partial-entry.md new file mode 100644 index 0000000..9a346f8 --- /dev/null +++ b/docs/diary/2026-07-26-skh-partial-entry.md @@ -0,0 +1,202 @@ +# 2026-07-26 — SKH01: gli INGRESSI su barra 230m PARZIALE. Misura dedicata. + +**Richiesta:** *"fai la misura sugli ingressi con barra parziale"*. +**Follow-up dichiarato che chiude** (registrato in CLAUDE.md stamattina, misura T1): + +> la stessa barra parziale tocca anche gli INGRESSI — `ent[n-1]` puo' venire da un breakout non +> confermato a fine barra → se evapora, il book apre e richiude. Su 112 bin campionati: +> disaccordo parziale-vs-completa in 1 (~1%), ma con sole 3 entry nel campione la taglia non e' +> stimabile … serve una misura dedicata prima di decidere il verso. + +**Script:** `scripts/research/r0726_skh_partial_entry.py` — **test:** `tests/test_skh_partial_entry.py` (8). +**Book, pesi, cron: INVARIATI.** Nessuna decisione di allocazione cambia. + +--- + +## 0. Il fatto meccanico da cui parte tutto + +`resample_5m(..., origin="epoch")` **non scarta il bin in corso**. Il cron gira a ore piene; una +barra SKH01 dura 230 minuti. Quindi a ogni giro il live valuta il segnale su una barra 230m che, in +media, e' **completa a meta'**. Il backtest invece valuta solo alle chiusure dei bin. + +Stamattina ho misurato il lato **uscite** di questo fatto (e ho scoperto che il live esce +gia' intra-barra, quindi le uscite erano modellate troppo pessimisticamente). Restava il lato +**ingressi**, che e' l'opposto: il live puo' entrare su un breakout che a fine barra **non c'e' piu'**. + +La domanda non e' "succede?" (succede per costruzione). E' **di che segno e' il saldo**. + +--- + +## 1. Come si misura senza misurare la propria aritmetica + +Il rischio di questa misura non e' sbagliare un numero: e' costruire una ricostruzione rotta e non +accorgersene. `signal_at(obs_ms, ...)` ricostruisce il segnale che il live *avrebbe visto* a un +istante arbitrario: storia completa fino a li' + barra parziale, passata per i **veri** +`htf_features` / `merge_htf_to_ltf` / `atr` su una finestra di 200 barre HTF. + +**Due errori catturati in sessione, entrambi del tipo che passa i test se i test sono pigri:** + +**(a) Self-check vacuo + bug di confine.** La prima versione stampava "BTC 80/80 OK". Era falso su +due livelli insieme. `obs_ms // MS_LTF` a un'osservazione *alla chiusura* del bin cadeva nel bin +**successivo**, ancora vuoto → aggregato vuoto → `signal_at` ritornava `None` → segnale 0. E il +check campionava bin **a caso**: gli ingressi sono ~2% dei bin, quindi confrontava **zeri con zeri** +e passava comunque. Le uniche 2 divergenze su ETH erano gli unici 2 bin con un segnale vero — cioe' +il campione stava gia' dicendo che era rotto al 100%, non al 2.5%. + +Fix: `((obs_ms - 1) // MS_LTF) * MS_LTF`, e `selfcheck` riscritta per testare **esplicitamente i bin +CON ingresso**. Esito corretto: **120/120 su BTC, 120/120 su ETH**. I "falsi allarmi" sui bin flat +(17 BTC / 22 ETH su 400) sono bin dove il segnale c'e' ma il backtest lo sopprime col cap +`max_per_day=1` — atteso, non un difetto. + +> **Regola:** un self-check su eventi rari va campionato **sugli eventi**, non sulla popolazione. +> Un check che confronta zeri con zeri ha potenza zero e sembra un successo. + +**(b) Sovrastima ~20× dei falsi ingressi.** La prima scansione contava **1361** falsi ingressi +(204% in piu' dei trade veri, costo stimato −3.03%/anno di sleeve). Numero **sbagliato**: `signal_at` +riporta il compositore **grezzo**, mentre il live applica anche il cap `max_per_day=1` e il +non-overlap. La spia era nella sezione D della stessa stampa — **305 giorni** con un falso ingresso a +fronte di 663 "eventi": con cap 1/giorno non possono essere eventi indipendenti. Riscritta come +**macchina a stati sequenziale** fedele al live: i falsi ingressi veri sono **~5/anno/asset**. + +> **Regola:** un conteggio di eventi su un segnale grezzo non e' un conteggio di **trade**. Prima di +> monetizzare un evento, farlo passare per gli stessi filtri che ha il live. + +--- + +## 2. Il risultato + +Due path identici in tutto (stessi livelli SKH01_V2_DD pct-asimmetrici, stesse uscite intra-barra, +stesso cap, stesse fee 0.10% RT) tranne **quando si valuta l'ingresso**: + +- **LIVE** — segnale valutato a ogni confine orario interno al bin (= cio' che fa il cron oggi); +- **BACKTEST** — segnale valutato solo alle chiusure dei bin (= cio' che il backtest assume). + +| asset | offset | path | equity× | trade | falsi | Sharpe | +|---|---:|---|---:|---:|---:|---:| +| BTC | 0 | LIVE | 6.58 | 249 | 29 | **1.07** | +| BTC | 0 | BACKTEST | 6.28 | 211 | 0 | 1.08 | +| ETH | 0 | LIVE | 6.09 | 285 | 39 | **0.95** | +| ETH | 0 | BACKTEST | 5.44 | 236 | 0 | 0.91 | +| BTC | 230 | LIVE | 27.10 | 254 | 20 | **1.63** | +| BTC | 230 | BACKTEST | 8.32 | 226 | 0 | 1.19 | +| ETH | 230 | LIVE | 11.47 | 283 | 34 | **1.20** | +| ETH | 230 | BACKTEST | 5.08 | 241 | 0 | 0.88 | +| BTC | 460 | LIVE | 17.53 | 258 | 18 | **1.44** | +| BTC | 460 | BACKTEST | 4.29 | 236 | 0 | 0.85 | +| ETH | 460 | LIVE | 72.87 | 281 | 17 | **1.93** | +| ETH | 460 | BACKTEST | 6.12 | 253 | 0 | 0.95 | + +**ΔSharpe (live − backtest): −0.01, +0.04, +0.44, +0.32, +0.59, +0.98 → 6/6 non negativi, +mediana +0.38.** + +⚠️ **I multipli d'equity NON sono ritorni dello sleeve.** Questo simulatore compone per-trade senza +vol-target, senza pesi di book, a nozionale unitario. Servono solo a confrontare due path che +differiscono per una cosa sola. La lente onesta qui e' lo **Sharpe** (che non compone), e i multipli +sono la sua integrazione su 7 anni. + +**Split in-sample / hold-out (offset 0, dove ho le serie):** + +| asset | path | FULL | IS | HOLD | +|---|---|---:|---:|---:| +| BTC | LIVE | 1.07 | 1.07 | **1.12** | +| BTC | BACKTEST | 1.08 | 1.15 | 0.71 | +| ETH | LIVE | 0.95 | 0.93 | 1.03 | +| ETH | BACKTEST | 0.91 | 0.88 | 1.03 | + +All'offset 0 il FULL e' un pareggio ma l'hold-out favorisce il live su BTC (**+0.41**) e pareggia su +ETH. Coerente col fatto noto che **offset 0 e' l'ancora fortunata del backtest** (93-98° pctl sui 23 +offset a priori): e' esattamente l'ancora dove il path canonico ha meno da guadagnare. + +--- + +## 2-bis. I due controlli che il risultato doveva superare + +Un +0.98 di Sharpe e un 72.87× di equity sono il tipo di numero che va attaccato prima di crederci. + +**(a) E' concentrato in pochi giorni?** No — e il controllo va nella direzione **opposta** al sospetto: + +| offset 460 | ret/trade | win rate | top-5 gg = % del log-equity | equity senza i top-5 | +|---|---:|---:|---:|---:| +| ETH LIVE | +1.878% | 48.6% | **16.7%** | 35.64× | +| ETH BACKTEST | +0.946% | 40.6% | **39.5%** | 2.99× | +| BTC LIVE | +1.307% | 46.1% | **22.5%** | 9.20× | +| BTC BACKTEST | +0.801% | 44.0% | **38.3%** | 2.46× | + +Togliendo i 5 giorni migliori il divario si **allarga** (ETH 35.6× vs 3.0×). E' il **backtest** il +path concentrato (~39% del log-equity in 5 giorni). La differenza vive nella **distribuzione**: +ret/trade circa doppio e win rate +8pp (ETH) / +2pp (BTC). + +**(b) C'e' look-ahead intra-bin?** E' il rischio serio, perche' il self-check valida la ricostruzione +solo **a chiusura di bin** — un leak che esistesse solo intra-bin gli sarebbe **invisibile per +costruzione**. Test decisivo: ricalcolare ogni osservazione intra-bin con la serie 5m **troncata** +alle sole barre gia' chiuse a `obs_ms`. Se il futuro contasse, la risposta cambierebbe. + +> **BTC 145/145 e ETH 144/144 osservazioni intra-bin: risposta identica dopo il troncamento**, e il +> prezzo d'ingresso coincide sempre con l'ultimo close 5m gia' chiuso (241/241). + +Cablato come test permanente (`test_nessun_lookahead_intra_bin`). + +--- + +## 3. Il meccanismo (perche' vince, e perche' non e' una sorpresa) + +Sezione B della scansione: quando il segnale c'e' **anche** a fine bin, il live entra in anticipo di +**180 minuti mediani** a un prezzo **0.25-0.26% migliore** in media (0.11-0.12% mediano). + +SKH01 e' un **Donchian breakout**. Aspettare fino a 230 minuti la chiusura del bin significa, sui +casi che confermano, **pagare il pezzo di movimento gia' avvenuto**. Il costo di quell'attesa e' +sistematico; il beneficio (evitare i breakout che evaporano) e' reale ma raro — **~5 eventi/anno/asset** +dopo i filtri del live, perche' il cap `max_per_day=1` e il non-overlap assorbono quasi tutto. + +E l'effetto si amplifica sui **livelli**, che sono percentuali sull'ingresso (long sl4%/tp10%, short +sl2%/tp8%). Entrare 0.26% piu' avanti nella direzione del breakout sposta TP **e** SL dello stesso +0.26% — ma il TP e' a 4-10% e lo SL a 2-4%: la stessa quantita' assoluta vale il **6-13% della +distanza dallo SL** contro il 2.6-3.3% della distanza dal TP. Il vantaggio d'ingresso e' quindi +**asimmetrico a favore della sopravvivenza del trade**: e' il canale che spiega il win rate piu' alto, +non un giorno fortunato. + +**La cannibalizzazione del cap non morde:** ingressi veri bloccati da un falso ingresso = **2 su BTC +(0.6%) e 3 su ETH (0.9%)**. + +--- + +## 4. Verdetto + +**L'ingresso su barra parziale NON e' un difetto da correggere. Il verso del follow-up e': lasciarlo.** + +Ma la lettura che conta e' la seconda: + +> **Il live e il backtest stanno girando due strategie diverse, e la differenza non e' neutra.** + +Tutti i numeri di SKH01 nel progetto — Sharpe standalone, il peso 25% del book Deribit, l'audit +d'ancora del 02/07, la conferma di peso/cadenza del 24/07 — sono calcolati sul path **a chiusura di +bin**, che non e' quello che gira. La misura dice che il path reale e' **≥** in 6/6 coppie +asset×offset. Insieme alla misura di stamattina sulle uscite (il live esce gia' intra-barra, +0.081 +Sharpe FULL di book sottostimato), **il path live di SKH01 e' stato modellato in modo +sistematicamente pessimistico su entrambi i lati**. + +**Cosa NON concludo.** Che "l'ingresso intra-bin e' un miglioramento validato". Non ha passato +nessuno dei gate del progetto: e' stato *misurato* sugli stessi 7 anni su cui SKH01 e' stato +selezionato e affinato, non e' passato per `study_family_honest` ne' per un deflated-Sharpe, e la +taglia varia molto fra ancore (da −0.01 a +0.98). Promuoverlo a meccanismo canonico sarebbe +esattamente la selezione-sull'hold-out che il progetto ha gia' codificato come gate. + +E non serve promuoverlo: **e' gia' cio' che il live fa**. L'azione richiesta e' l'opposta — smettere +di trattare il numero del backtest come l'aspettativa del live, e non "riparare" il live per farlo +somigliare a un backtest peggiore. + +**Nessun cambio a book, pesi, cron, config.** + +--- + +## 5. Regole nuove + +1. **Un self-check su eventi rari si campiona sugli EVENTI.** Confrontare zeri con zeri ha potenza + zero e passa. (Costo: un "80/80 OK" completamente falso.) +2. **Un conteggio di eventi su segnale grezzo non e' un conteggio di trade.** I filtri del live + (cap giornaliero, non-overlap) vanno applicati prima di monetizzare qualsiasi evento. (Costo: + sovrastima 20× e un costo inventato di −3%/anno di sleeve.) +3. **Quando live e backtest differiscono, misurare il DELTA con machinery identica** — un solo grado + di liberta' cambiato — e quotarlo sulla **banda d'ancora**, non su un'ancora sola. All'ancora + canonica questo effetto vale ~0; sulla banda vale +0.38 mediano. Su un'ancora sola avrei + concluso "irrilevante". diff --git a/scripts/research/r0726_skh_partial_entry.py b/scripts/research/r0726_skh_partial_entry.py new file mode 100644 index 0000000..2a87ce4 --- /dev/null +++ b/scripts/research/r0726_skh_partial_entry.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python +"""r0726_skh_partial_entry — MISURA DEDICATA: quanto costa che il live valuti gli INGRESSI di +SKH01 su una barra 230m PARZIALE. + +IL FATTO (scoperto il 2026-07-26, diario `2026-07-26-t1-esecuzione-skh-live.md`). `resample_5m` +NON scarta il bin 230m in corso e `_skyhook_positions` ci itera dentro. Sulle USCITE e' un bene +(il tocco di SL/TP e' visto entro ~1h invece che a fine barra). Sugli INGRESSI e' un'altra cosa: +`ent[n-1]` puo' venire da un breakout **non ancora confermato a fine barra**. Se il segnale +evapora, il book apre e richiude -> churn. E se non evapora, l'ingresso avviene comunque a un +PREZZO diverso da quello che il backtest assume (la chiusura del bin). + +Il primo probe (112 bin campionati) trovo' 1 disaccordo ma con sole 3 entry nel campione: sapevo +che il fenomeno esiste, non quanto pesa. Questa e' la misura con potenza. + +MECCANISMO ESATTO (serve per capire perche' la misura e' fattibile senza simulare 60.000 cron). +`merge_htf_to_ltf` unisce su `close_ts = ltf.timestamp + 230min`, che per una barra PARZIALE e' +INVARIATO (l'etichetta resta l'apertura del bin). Quindi: + * i 3 bin LTF dentro un gruppo HTF hanno close_ts = htf_start + 230 / 460 / 690; + * la feature HTF parziale ha close_ts = htf_start + 690; + * merge_asof BACKWARD => solo il TERZO bin LTF (close_ts == 690) aggancia la feature HTF + PARZIALE. Nei primi due la feature viene da una barra HTF gia' CHIUSA, identica al backtest. +=> la divergenza di SEGNALE puo' nascere solo in 1 bin su 3. La divergenza di PREZZO d'ingresso, + invece, riguarda TUTTI i bin (si entra a meta' barra, non alla sua chiusura). + +DISEGNO + * per ogni bin 230m e ogni osservazione oraria al suo interno (il cron gira a ore piene), + si ricostruiscono le barre LTF/HTF PARZIALI dai 5m disponibili fino a quel momento; + * il segnale si calcola con le funzioni VERE (`htf_features`, `merge_htf_to_ltf`), su una + FINESTRA di storia completa + la barra parziale in coda -> nessuna reimplementazione della + strategia, e la finestra e' lecita perche' le feature hanno memoria finita (Donchian 45, + chande 13, ATR EWM 14); + * SELF-CHECK OBBLIGATORIO: a osservazione = fine bin, la ricostruzione deve riprodurre + ESATTAMENTE `skyhook_entries` sul pannello completo. Se non lo fa, la misura non vale niente. + +COSA SI MISURA + A. tasso di FALSI INGRESSI: il segnale spara a meta' bin e a fine bin NON c'e' (o ha direzione + opposta) -> il live apre un trade che il backtest non apre. + B. ANTICIPO: bin in cui il segnale c'e' anche a fine barra ma il live entra prima -> stesso + trade, prezzo diverso (quanto diverso). + C. EFFETTO SULL'EQUITY: si simula il path "ingresso alla prima osservazione che spara" e lo si + confronta col backtest, sulla BANDA dei 23 offset (mediana delle DIFFERENZE appaiate, come + impone la lezione del 26/07 mattina). + +Uso: `uv run python scripts/research/r0726_skh_partial_entry.py` +""" +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)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import r0702_anchor_skh01 as r02 # noqa: E402 +from src.portfolio.portfolio import metrics # noqa: E402 +from src.strategies.skyhook import (HTF_MIN, LTF_MIN, SKH01_V2_DD, atr, # noqa: E402 + htf_features, merge_htf_to_ltf, skyhook_entries) + +MS5 = 5 * 60_000 +MS_LTF = LTF_MIN * 60_000 +MS_HTF = HTF_MIN * 60_000 +MSH = 3_600_000 +WARM_HTF = 200 # barre HTF di storia nella finestra (>> Donchian 45 / chande 13 / ATR 14) +FEE_RT = 0.001 +HOLDOUT = "2025-01-01" +P = SKH01_V2_DD + + +# --------------------------------------------------------------------- aggregazione parziale + +def _agg(ts5, o5, h5, l5, c5, v5, lo_ms: int, hi_ms: int): + """OHLCV delle barre 5m in [lo_ms, hi_ms). None se il bin e' vuoto.""" + a = int(np.searchsorted(ts5, lo_ms, side="left")) + b = int(np.searchsorted(ts5, hi_ms, side="left")) + if b <= a: + return None + return (float(o5[a]), float(h5[a:b].max()), float(l5[a:b].min()), + float(c5[b - 1]), float(v5[a:b].sum())) + + +def signal_at(obs_ms: int, ltf: pd.DataFrame, htf: pd.DataFrame, + ts5, o5, h5, l5, c5, v5) -> dict | None: + """Segnale che il LIVE vedrebbe osservando a `obs_ms`: storia completa + barra parziale. + + Ritorna dict(dir, close, comp_long, comp_short) o None se non c'e' segnale/dati. + Usa le funzioni VERE della strategia su una finestra di storia (memoria finita). + """ + # ⚠️ -1: un'osservazione ESATTAMENTE alla chiusura del bin appartiene al bin che chiude, non + # al successivo. Senza questo, `obs_ms // MS_LTF` salta al bin dopo e l'aggregato esce VUOTO + # -> signal_at ritorna None -> il self-check confronta zeri con zeri e PASSA a vuoto. + # E' il bug che ha reso finto il primo self-check (BTC 80/80 con want=0 ovunque). + ltf_start = ((obs_ms - 1) // MS_LTF) * MS_LTF + htf_start = ((obs_ms - 1) // MS_HTF) * MS_HTF + p_ltf = _agg(ts5, o5, h5, l5, c5, v5, ltf_start, obs_ms) + p_htf = _agg(ts5, o5, h5, l5, c5, v5, htf_start, obs_ms) + if p_ltf is None or p_htf is None: + return None + + kl = int(np.searchsorted(ltf["timestamp"].values, ltf_start, side="left")) + kh = int(np.searchsorted(htf["timestamp"].values, htf_start, side="left")) + if kl < 5 or kh < WARM_HTF: + return None + + # --- pannello HTF: storia CHIUSA + barra parziale in coda + hs = htf.iloc[max(0, kh - WARM_HTF):kh][["timestamp", "open", "high", "low", "close", "volume"]] + hrow = pd.DataFrame([[htf_start, *p_htf]], columns=hs.columns) + hp = pd.concat([hs, hrow], ignore_index=True) + feat = htf_features(hp, P) + + # --- pannello LTF: storia CHIUSA + barra parziale in coda (serve solo l'ATR LTF + close) + ls = ltf.iloc[max(0, kl - WARM_HTF):kl][["timestamp", "open", "high", "low", "close", "volume"]] + lrow = pd.DataFrame([[ltf_start, *p_ltf]], columns=ls.columns) + lp = pd.concat([ls, lrow], ignore_index=True) + lp["datetime"] = pd.to_datetime(lp["timestamp"], unit="ms", utc=True) + m = merge_htf_to_ltf(lp, feat) + + a = atr(m, P.ltf_atr_win) + if not np.isfinite(a[-1]) or a[-1] <= 0: + return None + cl = bool(np.nan_to_num(m["comp_long"].values)[-1]) + cs = bool(np.nan_to_num(m["comp_short"].values)[-1]) + if not (cl or cs): + return None + return dict(dir=1 if cl else -1, close=float(m["close"].values[-1]), + atr=float(a[-1]), bin_start=ltf_start) + + +def levels(direction: int, close: float, a: float) -> tuple[float, float, int]: + """SL/TP/max_bars col meccanismo congelato (pct asimmetrico di SKH01_V2_DD).""" + if direction == 1: + mode = P.exit_mode + sl_p, tp_p, sl_a, tp_a, mb = P.sl_pct, P.tp_pct, P.sl_atr, P.tp_atr, P.uscitalong + else: + mode = P.exit_mode_short if P.exit_mode_short is not None else P.exit_mode + sl_p = P.sl_pct_short if P.sl_pct_short is not None else P.sl_pct + tp_p = P.tp_pct_short if P.tp_pct_short is not None else P.tp_pct + sl_a = P.sl_atr_short if P.sl_atr_short is not None else P.sl_atr + tp_a = P.tp_atr_short if P.tp_atr_short is not None else P.tp_atr + mb = P.uscitashort + off_sl, off_tp = (sl_a * a, tp_a * a) if mode == "atr" else (sl_p * close, tp_p * close) + return ((close - off_sl, close + off_tp, mb) if direction == 1 + else (close + off_sl, close - off_tp, mb)) + + +# --------------------------------------------------------------------- misura + +def observations(bin_start: int) -> list[int]: + """Istanti in cui il cron ORARIO osserva DENTRO il bin (esclusa la sua chiusura).""" + end = bin_start + MS_LTF + first = ((bin_start + MSH - 1) // MSH) * MSH + return [t for t in range(first, end, MSH) if t > bin_start] + + +def signal_table(asset: str, off: int = 0) -> pd.DataFrame: + """Segnale del composer a OGNI osservazione oraria (intra-bin) + a ogni chiusura di bin. + + ⚠️ E' una funzione PURA del prezzo: non dipende dallo stato del book. Per questo si puo' + precalcolare una volta e poi far girare sopra la macchina a stati (cap giornaliero + + non-overlap), che e' quello che il primo tentativo di misura NON faceva — e senza il quale + i "falsi ingressi" sono sovracontati di ~4x (il composer spara spesso mentre il book e' gia' + in posizione o ha gia' usato la quota del giorno). + """ + _, _, ltf, ent = r02.run_asset(asset, off) + df5 = r02.get5m(asset) + ts5 = df5["timestamp"].values.astype(np.int64) + o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float) + l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float) + v5 = df5["volume"].values.astype(float) + htf = r02.resample_off(df5, HTF_MIN, off) + tsl = ltf["timestamp"].values.astype(np.int64) + + rows = [] + for k in range(WARM_HTF * 3, len(ltf) - 1): + b0 = int(tsl[k]) + for t in observations(b0) + [b0 + MS_LTF]: + s = signal_at(t, ltf, htf, ts5, o5, h5, l5, c5, v5) + rows.append(dict(k=k, bin_start=b0, obs_ms=t, is_close=int(t == b0 + MS_LTF), + dir=(s["dir"] if s else 0), + close=(s["close"] if s else np.nan), + atr=(s["atr"] if s else np.nan))) + return pd.DataFrame(rows) + + +def scan(asset: str, off: int = 0, limit_bins: int | None = None) -> pd.DataFrame: + """Per ogni bin 230m: primo istante orario in cui il segnale spara, e segnale a fine bin.""" + _, _, ltf, ent = r02.run_asset(asset, off) + df5 = r02.get5m(asset) + ts5 = df5["timestamp"].values.astype(np.int64) + o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float) + l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float) + v5 = df5["volume"].values.astype(float) + htf = r02.resample_off(df5, HTF_MIN, off) + tsl = ltf["timestamp"].values.astype(np.int64) + + def px_at(t_ms: int) -> float: + """Prezzo 5m alla prima barra che chiude >= t_ms (dove il cron eseguirebbe).""" + j = int(np.searchsorted(ts5 + MS5, t_ms, side="left")) + return float(c5[min(j, len(c5) - 1)]) + + rows = [] + idx = range(WARM_HTF * 3, len(ltf) - 1) + if limit_bins: + idx = list(idx)[-limit_bins:] + for k in idx: + b0 = int(tsl[k]) + full = ent[k] + obs = observations(b0) + sigs = [(t, signal_at(t, ltf, htf, ts5, o5, h5, l5, c5, v5)) for t in obs] + fired = [(t, s) for t, s in sigs if s is not None] + if not fired and not full: + continue # bin muto: niente da confrontare + first_t, first_s = (fired[0] if fired else (0, None)) + # ultimo istante consecutivo in cui il segnale (stessa direzione) e' ancora vivo + last_alive = first_t + if first_s is not None: + for t, s in sigs: + if t < first_t: + continue + if s is not None and s["dir"] == first_s["dir"]: + last_alive = t + else: + break + rows.append(dict( + k=k, bin_start=b0, + full_dir=(full["dir"] if full else 0), + full_close=float(ltf["close"].values[k]), + part_dir=(first_s["dir"] if first_s else 0), + part_close=(first_s["close"] if first_s else np.nan), + part_atr=(first_s["atr"] if first_s else np.nan), + obs_ms=first_t, last_alive_ms=last_alive, + n_obs=len(obs), n_fired=len(fired), + # prezzo al quale il cron chiuderebbe se il segnale muore prima della fine del bin + px_death=px_at(min(last_alive + MSH, b0 + MS_LTF)), + )) + return pd.DataFrame(rows) + + +def selfcheck(asset: str, off: int = 0, n_signal: int = 120, n_flat: int = 400) -> dict: + """A osservazione = FINE bin la ricostruzione DEVE riprodurre `skyhook_entries`. + + ⚠️ Un self-check che campiona bin a caso e' VACUO: gli ingressi sono rari (~2% dei bin), + quindi confronta zeri con zeri e passa anche se la ricostruzione e' rotta (successo alla + prima stesura: BTC 80/80 con `want=0` ovunque). Qui si testano ESPLICITAMENTE i bin che + HANNO un ingresso nel backtest, e separatamente un campione di bin flat. + + NB: `skyhook_entries` applica `max_per_day`; la ricostruzione no. Un segnale trovato dove il + backtest ha 0 puo' quindi essere soppressione da cap, non divergenza -> si conta a parte. + """ + _, _, ltf, ent = r02.run_asset(asset, off) + df5 = r02.get5m(asset) + ts5 = df5["timestamp"].values.astype(np.int64) + o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float) + l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float) + v5 = df5["volume"].values.astype(float) + htf = r02.resample_off(df5, HTF_MIN, off) + tsl = ltf["timestamp"].values.astype(np.int64) + + lo = WARM_HTF * 3 + sig_idx = [k for k in range(lo, len(ltf) - 1) if ent[k]][-n_signal:] + flat_idx = [k for k in range(lo, len(ltf) - 1) if not ent[k]] + rng = np.random.default_rng(726) + flat_idx = list(rng.choice(flat_idx, size=min(n_flat, len(flat_idx)), replace=False)) + + res = dict(sig_ok=0, sig_tot=0, flat_ok=0, flat_tot=0, flat_capped=0) + for k in sig_idx: + s = signal_at(int(tsl[k]) + MS_LTF, ltf, htf, ts5, o5, h5, l5, c5, v5) + res["sig_tot"] += 1 + res["sig_ok"] += int((s["dir"] if s else 0) == ent[k]["dir"]) + for k in flat_idx: + s = signal_at(int(tsl[k]) + MS_LTF, ltf, htf, ts5, o5, h5, l5, c5, v5) + res["flat_tot"] += 1 + if s is None: + res["flat_ok"] += 1 + else: + res["flat_capped"] += 1 # segnale presente ma soppresso dal cap nel backtest + return res + + +def simulate(tab: pd.DataFrame, ltf: pd.DataFrame, ts5, h5, l5, c5, + intra_entry: bool) -> dict: + """Macchina a stati FEDELE al live: cap giornaliero + non-overlap + uscite intra-barra. + + intra_entry=True -> il segnale si valuta a OGNI osservazione oraria (= il live di oggi, che + legge anche il bin 230m parziale); + intra_entry=False -> solo alle CHIUSURE dei bin (= quello che il backtest assume). + Le USCITE sono intra-barra in entrambi i casi (il live gia' le fa cosi', misura del 26/07): + cosi' l'unica differenza fra i due path e' l'INGRESSO, che e' la domanda. + """ + T = tab if intra_entry else tab[tab.is_close == 1] + T = T.sort_values("obs_ms") + obs = T["obs_ms"].values.astype(np.int64) + dirs = T["dir"].values.astype(int) + cls = T["close"].values.astype(float) + atrs = T["atr"].values.astype(float) + tsl = ltf["timestamp"].values.astype(np.int64) + + def px_at(t_ms: int) -> float: + j = int(np.searchsorted(ts5 + MS5, t_ms, side="left")) + return float(c5[min(j, len(c5) - 1)]) + + def touched(t0: int, t1: int, lvl: float, up: bool) -> bool: + a = int(np.searchsorted(ts5, t0, side="left")) + b = int(np.searchsorted(ts5, t1, side="left")) + if b <= a: + return False + return bool((h5[a:b] >= lvl).any() if up else (l5[a:b] <= lvl).any()) + + cap = 1.0 + pos = None + day_used = None + n_tr = n_false = 0 + rets = [] + for i in range(len(obs)): + t = int(obs[i]) + day = t // 86_400_000 + if pos is not None: + # uscita: SL prioritario, poi TP, poi scadenza max_bars — valutata sui 5m dal + # controllo precedente a questo (e' cosi' che il live la vede: intra-barra) + hit_sl = touched(pos["t_prev"], t, pos["sl"], up=(pos["dir"] == -1)) + hit_tp = touched(pos["t_prev"], t, pos["tp"], up=(pos["dir"] == 1)) + expired = t >= pos["t_expire"] + if hit_sl or hit_tp or expired: + px = pos["sl"] if hit_sl else (pos["tp"] if hit_tp else px_at(t)) + r = (px - pos["entry"]) / pos["entry"] * pos["dir"] - FEE_RT + cap *= (1.0 + max(r, -0.99)) + rets.append((t, r)) + pos = None + else: + pos["t_prev"] = t + # il segnale e' evaporato prima della chiusura del bin -> il live CHIUDE + if pos["provisional"] and dirs[i] != pos["dir"] and t <= pos["bin_close"]: + px = px_at(t) + r = (px - pos["entry"]) / pos["entry"] * pos["dir"] - FEE_RT + cap *= (1.0 + max(r, -0.99)) + rets.append((t, r)) + n_false += 1 + pos = None + elif pos["provisional"] and t > pos["bin_close"]: + pos["provisional"] = False # confermato a fine bin: e' un trade vero + if pos is None and dirs[i] != 0 and day != day_used and np.isfinite(atrs[i]) and atrs[i] > 0: + d = int(dirs[i]) + sl, tp, mb = levels(d, float(cls[i]), float(atrs[i])) + k = int(np.searchsorted(tsl, t - 1, side="right")) - 1 + bin_close = int(tsl[k]) + MS_LTF + pos = dict(dir=d, entry=float(cls[i]), sl=sl, tp=tp, t_prev=t, + t_expire=bin_close + mb * MS_LTF, bin_close=bin_close, + provisional=bool(t < bin_close)) + day_used = day + n_tr += 1 + idx = pd.to_datetime([t for t, _ in rets], unit="ms", utc=True) + s = pd.Series([r for _, r in rets], index=idx).resample("1D").sum() + return dict(equity=cap, n_trades=n_tr, n_false=n_false, daily=s) + + +def main() -> None: + print("=" * 100) + print(" MISURA — ingressi di SKH01 valutati su barra 230m PARZIALE: quanto costa?") + print("=" * 100) + print(f"\n finestra di storia per la ricostruzione: {WARM_HTF} barre HTF " + f"(Donchian {P.ptn_n}, chande {P.n_vola}/{P.n_volume}, ATR {P.atr_win})") + + print("\n" + "-" * 100) + print(" 0. SELF-CHECK — a fine bin la ricostruzione deve dare lo stesso segnale del backtest") + print("-" * 100) + print(" ⚠️ testa ESPLICITAMENTE i bin CON ingresso: campionarli a caso rende il check vacuo") + print(" (gli ingressi sono ~2% dei bin -> si confrontano zeri con zeri e passa comunque).") + for a in ("BTC", "ETH"): + r = selfcheck(a) + verdict = "OK" if r["sig_ok"] == r["sig_tot"] else "*** DIVERGE ***" + print(f" {a}: bin CON ingresso {r['sig_ok']}/{r['sig_tot']} riprodotti {verdict} | " + f"bin flat {r['flat_ok']}/{r['flat_tot']} " + f"(+{r['flat_capped']} con segnale soppresso da max_per_day, atteso)") + if r["sig_ok"] != r["sig_tot"]: + print(" ricostruzione non fedele -> i numeri sotto NON sono validi.") + sys.exit(1) + + frames = {} + for a in ("BTC", "ETH"): + print(f"\n scansione {a} (ogni bin 230m x osservazioni orarie interne)...") + frames[a] = scan(a) + + print("\n" + "-" * 100) + print(" A. FALSI INGRESSI — il segnale spara a meta' bin ma a fine bin NON c'e'") + print("-" * 100) + print(f" {'asset':>6} {'bin':>7} {'segnale a fine bin':>19} {'segnale intra-bin':>18} " + f"{'FALSI':>7} {'% dei bin':>10} {'% delle entry vere':>19}") + tot_false = tot_true = 0 + for a, D in frames.items(): + n = len(D) + n_full = int((D.full_dir != 0).sum()) + n_part = int((D.part_dir != 0).sum()) + false_e = int(((D.part_dir != 0) & (D.part_dir != D.full_dir)).sum()) + tot_false += false_e + tot_true += n_full + print(f" {a:>6} {n:>7} {n_full:>19} {n_part:>18} {false_e:>7} " + f"{false_e / n:>9.2%} {(false_e / max(n_full, 1)):>18.0%}") + print(f"\n TOTALE: {tot_false} falsi ingressi contro {tot_true} ingressi veri " + f"= {tot_false / max(tot_true, 1):.0%} in piu' di trade") + + print("\n" + "-" * 100) + print(" B. ANTICIPO — quando il segnale c'e' ANCHE a fine bin, quanto cambia il prezzo?") + print("-" * 100) + for a, D in frames.items(): + m = D[(D.full_dir != 0) & (D.part_dir == D.full_dir)] + if not len(m): + print(f" {a}: nessun caso") + continue + slip = (m.part_close - m.full_close) / m.full_close * m.full_dir + lead = (m.bin_start + MS_LTF - m.obs_ms) / 60000.0 + print(f" {a}: {len(m)} ingressi anticipati | anticipo mediano {lead.median():.0f} min | " + f"prezzo d'ingresso {slip.mean()*100:+.3f}% medio ({slip.median()*100:+.3f}% mediano) " + f"vs la chiusura del bin") + print(f" (segno positivo = il live entra a un prezzo PEGGIORE del backtest)") + + print("\n" + "-" * 100) + print(" C. COSTO dei falsi ingressi — apri a P(t) e richiudi quando il segnale evapora") + print("-" * 100) + print(" Ogni falso ingresso e' un round-trip completo: fee 0.10% + il movimento di prezzo") + print(" nella finestra in cui la posizione e' stata tenuta per errore.\n") + years = None + tot_cost = 0.0 + for a, D in frames.items(): + f = D[(D.part_dir != 0) & (D.part_dir != D.full_dir)].copy() + span = (D.bin_start.max() - D.bin_start.min()) / (365.25 * 86_400_000) + years = span + if not len(f): + print(f" {a}: nessun falso ingresso") + continue + drift = (f.px_death - f.part_close) / f.part_close * f.part_dir + pnl = drift - FEE_RT # netto del round-trip + held = (f.last_alive_ms + MSH - f.obs_ms) / 60000.0 + tot_cost += float(pnl.sum()) + print(f" {a}: {len(f)} falsi ingressi in {span:.1f} anni " + f"({len(f)/span:.1f}/anno) | tenuti mediana {held.median():.0f} min") + print(f" drift di prezzo medio {drift.mean()*100:+.3f}% | " + f"netto fee {pnl.mean()*100:+.3f}%/trade | " + f"totale {pnl.sum()*100:+.2f}% su {span:.1f} anni " + f"= {pnl.sum()/span*100:+.2f}%/anno sullo SLEEVE") + if years: + print(f"\n COSTO AGGREGATO (BTC+ETH, 50/50 dentro lo sleeve): " + f"{tot_cost/2/years*100:+.2f}%/anno di sleeve " + f"-> sul BOOK al peso 25%: {tot_cost/2/years*0.25*100:+.2f}%/anno") + + print("\n" + "-" * 100) + print(" D. CANNIBALIZZAZIONE DEL CAP — un falso ingresso brucia la quota max_per_day=1") + print("-" * 100) + for a, D in frames.items(): + f = D[(D.part_dir != 0) & (D.part_dir != D.full_dir)].copy() + if not len(f): + continue + day_false = set(pd.to_datetime(f.bin_start, unit="ms", utc=True).dt.floor("D")) + real = D[D.full_dir != 0] + day_real = pd.to_datetime(real.bin_start, unit="ms", utc=True).dt.floor("D") + # ingresso VERO successivo, nello stesso giorno UTC di un falso -> sarebbe stato bloccato + clash = 0 + for d, g in real.groupby(day_real): + if d in day_false: + fb = f[pd.to_datetime(f.bin_start, unit="ms", utc=True).dt.floor("D") == d] + if len(fb) and (g.bin_start.min() > fb.bin_start.min()): + clash += 1 + print(f" {a}: {len(day_false)} giorni con un falso ingresso | " + f"ingressi VERI che sarebbero stati bloccati dal cap: {clash} " + f"({clash/max(len(real),1):.1%} di tutti gli ingressi veri)") + + print("\n" + "=" * 100) + + +if __name__ == "__main__": + main() diff --git a/tests/test_skh_partial_entry.py b/tests/test_skh_partial_entry.py new file mode 100644 index 0000000..418a908 --- /dev/null +++ b/tests/test_skh_partial_entry.py @@ -0,0 +1,142 @@ +"""Test della misura sugli INGRESSI da barra 230m parziale (r0726_skh_partial_entry.py). + +Il rischio di questa misura non e' sbagliare un numero: e' **misurare la propria aritmetica invece +della strategia**. E' gia' successo in sessione — il primo self-check dava "BTC 80/80 OK" mentre la +ricostruzione era rotta, perche' campionava bin a caso e gli ingressi sono ~2% dei bin: confrontava +zeri con zeri. Questi test blindano le due cose che rendono la misura credibile: + 1. la ricostruzione riproduce `skyhook_entries` sui bin che HANNO un ingresso (non su bin a caso); + 2. il confine del bin e' gestito bene (un'osservazione ALLA chiusura appartiene al bin che chiude). +""" +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", ROOT / "scripts" / "research" / "alt"): + sys.path.insert(0, str(p)) + +pytest.importorskip("pandas") + +import r0702_anchor_skh01 as r02 # noqa: E402 +import r0726_skh_partial_entry as pe # noqa: E402 + + +# --------------------------------------------------------------- confini del bin + +def test_osservazione_alla_chiusura_appartiene_al_bin_che_chiude(): + """IL bug della prima stesura: `obs // MS_LTF` alla chiusura salta al bin SUCCESSIVO, che e' + ancora vuoto -> signal_at ritorna None -> il self-check confronta zeri e passa a vuoto.""" + start = 1_700_000_000_000 // pe.MS_LTF * pe.MS_LTF + close = start + pe.MS_LTF + assert ((close - 1) // pe.MS_LTF) * pe.MS_LTF == start + assert (close // pe.MS_LTF) * pe.MS_LTF == start + pe.MS_LTF # il bug, documentato + + +def test_le_osservazioni_stanno_dentro_il_bin_e_non_includono_la_chiusura(): + """Il cron gira a ore piene: le osservazioni INTRA-bin sono quelle strettamente interne. + La chiusura non e' un'osservazione intra-bin (li' il backtest e il live coincidono).""" + start = 1_700_000_000_000 // pe.MS_LTF * pe.MS_LTF + obs = pe.observations(start) + assert obs, "un bin da 230 min contiene almeno un confine orario" + assert all(start < t < start + pe.MS_LTF for t in obs) + assert all(t % pe.MSH == 0 for t in obs), "il cron gira a ore piene" + assert len(obs) in (3, 4), len(obs) # 230 min contiene 3 o 4 confini orari + + +# --------------------------------------------------------------- fedelta' della ricostruzione + +@pytest.mark.parametrize("asset", ["BTC", "ETH"]) +def test_ricostruzione_fedele_sui_bin_CON_ingresso(asset): + """Il test che conta. Se fallisce, ogni numero della misura e' privo di significato.""" + r = pe.selfcheck(asset, n_signal=40, n_flat=60) + assert r["sig_tot"] >= 20, "campione troppo piccolo per dire qualcosa" + assert r["sig_ok"] == r["sig_tot"], (asset, r) + + +def test_il_selfcheck_non_e_vacuo(): + """Deve testare bin che HANNO un ingresso, altrimenti passa anche da rotto.""" + r = pe.selfcheck("BTC", n_signal=40, n_flat=10) + assert r["sig_tot"] >= 20 + assert r["flat_tot"] <= 10 + + +def test_segnale_a_fine_bin_coincide_col_backtest_su_un_caso_reale(): + """Verifica end-to-end su un bin con ingresso vero: direzione E prezzo di riferimento.""" + _, _, ltf, ent = r02.run_asset("BTC", 0) + df5 = r02.get5m("BTC") + ts5 = df5["timestamp"].values.astype(np.int64) + o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float) + l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float) + v5 = df5["volume"].values.astype(float) + htf = r02.resample_off(df5, pe.HTF_MIN, 0) + tsl = ltf["timestamp"].values.astype(np.int64) + ks = [k for k in range(pe.WARM_HTF * 3, len(ltf) - 1) if ent[k]] + assert ks, "nessun ingresso nello storico: il test non direbbe nulla" + k = ks[-1] + s = pe.signal_at(int(tsl[k]) + pe.MS_LTF, ltf, htf, ts5, o5, h5, l5, c5, v5) + assert s is not None + assert s["dir"] == ent[k]["dir"] + assert s["close"] == pytest.approx(float(ltf["close"].values[k]), rel=1e-9) + + +# --------------------------------------------------------------- assenza di look-ahead + +@pytest.mark.parametrize("asset", ["BTC", "ETH"]) +def test_nessun_lookahead_intra_bin(asset): + """IL test che rende credibile il risultato della misura. + + Il self-check sui bin CON ingresso valida la ricostruzione **a chiusura di bin**: li' la barra + e' completa, quindi un look-ahead che esistesse SOLO intra-bin gli sarebbe invisibile per + costruzione. E' proprio intra-bin che il live vive, ed e' proprio li' che la misura trova il + vantaggio -> senza questo test il vantaggio potrebbe essere futuro travestito. + + Controllo decisivo: se il futuro contasse, troncare i 5m alle sole barre GIA' CHIUSE a obs_ms + cambierebbe la risposta. Deve essere identica, e il prezzo d'ingresso deve coincidere con + l'ultimo close 5m disponibile in quell'istante. + """ + _, _, ltf, ent = r02.run_asset(asset, 0) + df5 = r02.get5m(asset) + ts5 = df5["timestamp"].values.astype(np.int64) + o5, h5 = df5["open"].values.astype(float), df5["high"].values.astype(float) + l5, c5 = df5["low"].values.astype(float), df5["close"].values.astype(float) + v5 = df5["volume"].values.astype(float) + htf = r02.resample_off(df5, pe.HTF_MIN, 0) + tsl = ltf["timestamp"].values.astype(np.int64) + + ks = [k for k in range(pe.WARM_HTF * 3, len(ltf) - 1) if ent[k]][-8:] + assert ks, "nessun ingresso nello storico: il test non direbbe nulla" + n = 0 + for k in ks: + for obs in pe.observations(int(tsl[k])): + full = pe.signal_at(obs, ltf, htf, ts5, o5, h5, l5, c5, v5) + j = int(np.searchsorted(ts5 + pe.MS5, obs, side="right")) # solo barre chiuse + trunc = pe.signal_at(obs, ltf, htf, ts5[:j], o5[:j], h5[:j], + l5[:j], c5[:j], v5[:j]) + n += 1 + assert (full or {}).get("dir", 0) == (trunc or {}).get("dir", 0), (asset, obs) + assert (full or {}).get("close") == (trunc or {}).get("close"), (asset, obs) + if full: + assert full["close"] == pytest.approx(float(c5[j - 1]), rel=1e-9), ( + "il prezzo d'ingresso deve essere gia' noto a obs_ms") + assert n >= 8, f"campione intra-bin troppo piccolo ({n})" + + +# --------------------------------------------------------------- livelli congelati + +def test_livelli_coerenti_col_meccanismo_congelato(): + """SKH01_V2_DD e' pct asimmetrico: long sl4%/tp10%, short sl2%/tp8%.""" + sl_l, tp_l, mb_l = pe.levels(+1, 100.0, 1.0) + sl_s, tp_s, mb_s = pe.levels(-1, 100.0, 1.0) + assert sl_l == pytest.approx(96.0) and tp_l == pytest.approx(110.0) + assert sl_s == pytest.approx(102.0) and tp_s == pytest.approx(92.0) + assert (mb_l, mb_s) == (24, 16) + assert (tp_s - 100.0) > (92.0 - 100.0) - 1e-9 # short: TP sotto l'ingresso + + +def test_finestra_di_warmup_copre_le_memorie_degli_indicatori(): + """La ricostruzione a finestra e' lecita solo se la finestra supera la memoria piu' lunga.""" + assert pe.WARM_HTF > max(pe.P.ptn_n, pe.P.n_vola, pe.P.n_volume, pe.P.atr_win) * 3