From 031b71bf54473ad44050fe61421c062a1abf19ec Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 25 Jul 2026 20:20:01 +0000 Subject: [PATCH] ops(live): sorveglianza freschezza feed SKH + correzione dei docstring sulla latenza d'uscita T1 (TP di SKH01 come limit resting on-book) NON e' stato implementato, per misura. Leggendo il codice di produzione: TP01 e SKH01 tradano lo stesso strumento con una sola posizione netta Deribit, quindi un ordine on-book al livello di SKH chiuderebbe anche quota TP01. Misurati i due ostacoli: (A) segno compatibile nel 97% dei trade che escono in TP = risolvibile; (B) divergenza modello/live apparentemente bloccante. Ma (B) non esiste: resample_5m NON scarta il bin 230m in corso (21 barre 5m su 46 nell'ultimo bin) e _skyhook_positions ci itera dentro -> il live rileva gia' SL/TP intra-barra, entro ~1h dal tocco. I docstring che dicevano "usa solo barre chiuse" e "latenza fino alla chiusura della barra 230m" erano FALSI e hanno guidato tre analisi (02/07, 24/07, T1). Corretti sul posto. Conseguenza: la lente `hourly` sottostima il path live di +0.081 Sharpe FULL di book (23/23 offset, banda appaiata); il live vero sta sopra il canonical sul FULL, e il fix richiesto sarebbe un declassamento (+0.054 vs +0.081) in cambio di ordini parziali sul netto in un percorso con soldi veri. Cablato invece il problema vero trovato per strada: fresh_5m fallisce in SILENZIO (fallback al certificato, rigenerato 1x/giorno) -> la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione, e nessun controllo esistente scatta (il gate di staleness guarda il feed di TP01). Stesso schema del feed-freeze del 14/07. * livefeed.feed_age_minutes: pura, eta' dalla CHIUSURA della barra, None = non misurata, clamp sugli skew d'orologio * book_report espone skh_feed_age_min (max fra gli asset) * book_execute stampa lo stato e allerta su Telegram sopra skh_feed_max_age_min (30m) Scelta dichiarata: ALLERTA, NON blocca. Bloccare fermerebbe anche TP01 (nettato sullo stesso strumento) per un guasto di rete; forzare SKH flat chiuderebbe posizioni buone su un glitch. Follow-up dichiarato: la stessa barra parziale tocca gli INGRESSI (ent[n-1] da breakout non confermato). Disaccordo in 1 bin su 112, ma con 3 entry nel campione la taglia non e' stimabile. E' un cambio di strategia, non di strumentazione -> misura dedicata prima. Strategia, pesi e cadenza del cron INVARIATI. Nessun ordine inviato. Suite 266 verdi. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 31 ++++ config/live.json | 4 +- .../2026-07-26-t1-esecuzione-skh-live.md | 155 ++++++++++++++++++ scripts/live/book_execute.py | 24 +++ scripts/research/r0726_skh_onbook.py | 51 +++++- src/live/book.py | 34 +++- src/live/livefeed.py | 25 ++- src/portfolio/sleeves.py | 18 +- tests/test_skh_feed_freshness.py | 96 +++++++++++ 9 files changed, 427 insertions(+), 11 deletions(-) create mode 100644 docs/diary/2026-07-26-t1-esecuzione-skh-live.md create mode 100644 tests/test_skh_feed_freshness.py diff --git a/CLAUDE.md b/CLAUDE.md index c74d1fa..6d87611 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -578,6 +578,37 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis dell'operatore): TP come limit resting reduce-only, SL strategico NON on-book.** E' un cambio di esecuzione (non passa `weights_tilt_null`) ma piccolo, da pesare contro ordini orfani/doppio fill. Il disaster-SL −30% on-book resta com'e'. + ❌ **T1 NON IMPLEMENTATO — e il perche' e' una CORREZIONE DI MODELLO** (2026-07-26, diario + `2026-07-26-t1-esecuzione-skh-live.md`). Leggendo il codice di produzione: TP01 e SKH01 tradano + lo **stesso strumento** con **una sola posizione netta** Deribit → un ordine on-book al livello di + SKH chiuderebbe anche quota TP01. Misurati i 2 ostacoli: (A) segno compatibile nel **97%** dei + trade che escono in TP (long 100%, short 94%) = risolvibile; (B) divergenza modello/live che + sembrava bloccante (ritardo mediano 115 min, **70%** dei TP con ≥1 cron dentro la finestra). + ⚠️ **MA (B) NON ESISTE: `resample_5m` NON scarta il bin 230m in corso** (verificato: 21 barre 5m + su 46 nell'ultimo bin) **e `_skyhook_positions` ci itera dentro** → il live rileva gia' SL/TP + **intra-barra**, entro ~1h dal tocco. I docstring che dicevano "usa solo barre chiuse" / + "latenza fino alla chiusura della barra 230m" erano **FALSI** e hanno guidato 3 analisi + (02/07, 24/07, T1) — **corretti sul posto**. Conseguenza: la lente `hourly` **sottostima il path + live di +0.081 Sharpe FULL di book (23/23 offset, banda appaiata)**, il live vero sta **sopra il + canonical** sul FULL, e **il fix richiesto sarebbe un DECLASSAMENTO** (+0.054 vs +0.081) in cambio + di ordini parziali sul netto in un percorso con soldi veri. → non fatto, per misura, non per + difficolta'. + ✅ **CABLATO INVECE il problema vero trovato per strada:** `fresh_5m` **fallisce in SILENZIO** + (fallback al feed certificato, rigenerato 1x/giorno) → la latenza d'uscita di SKH01 passa da ~1h + a **~1 giorno** senza che nulla lo segnali (conto online, posizione leggibile, e il gate di + staleness guarda il feed di TP01: **nessun controllo esistente scatta**). Stesso schema del + feed-freeze del 14/07, su un altro feed. Aggiunti: `livefeed.feed_age_minutes` (pura, eta' dalla + CHIUSURA della barra, `None`=non misurata, clamp sugli skew), `book_report.skh_feed_age_min` + (max fra gli asset), allerta Telegram in `book_execute` sopra `skh_feed_max_age_min`=**30 min**. + **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. (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/config/live.json b/config/live.json index 1cf4be4..ae9f098 100644 --- a/config/live.json +++ b/config/live.json @@ -7,5 +7,7 @@ "min_order_usd": 5, "disaster_sl_pct": 0.3, "_nota_stale": "Staleness-gate (2026-07-25): se l'ultima barra del feed certificato e' piu' vecchia di max_data_age_days, book_execute NON invia ordini e allerta su Telegram. Il 2026-07-14 il book compro' ETH con il feed fermo da 6 giorni (conto online e posizione leggibile -> gli altri due gate non scattavano). Follow-up raccomandato nel diario 2026-07-15-feed-freeze, ora cablato.", - "max_data_age_days": 2 + "max_data_age_days": 2, + "_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.", + "skh_feed_max_age_min": 30 } diff --git a/docs/diary/2026-07-26-t1-esecuzione-skh-live.md b/docs/diary/2026-07-26-t1-esecuzione-skh-live.md new file mode 100644 index 0000000..39c1f41 --- /dev/null +++ b/docs/diary/2026-07-26-t1-esecuzione-skh-live.md @@ -0,0 +1,155 @@ +# 2026-07-26 — T1 nel live: il fix richiesto NON va fatto, e il perché è una correzione di modello + +**Richiesta:** implementare nel live il fix T1 (TP di SKH01 come limit resting on-book). +**Esito: NON implementato**, con i numeri sotto. **Implementato invece** ciò che le misure +sostengono: la sorveglianza della freschezza del feed 5m di SKH01, e la correzione dei docstring +che hanno causato tre settimane di misure sbagliate. + +**File toccati (produzione):** `src/live/livefeed.py`, `src/live/book.py`, `src/portfolio/sleeves.py` +(solo docstring), `scripts/live/book_execute.py`, `config/live.json`. +**Test:** `tests/test_skh_feed_freshness.py` (11 casi) · suite **266 verdi**. +**Strategia, pesi, cadenza del cron: INVARIATI.** Nessun ordine inviato. + +--- + +## 1. Cosa ho trovato leggendo il codice di produzione + +Il fix T1 nasceva da una simulazione in cui SKH01 esce per conto suo. Il live non funziona così, e +il vincolo era scritto nel codice: + +> `book.py`: *«gli exit sono SOFTWARE (no bracket on-book per SKH, sennò chiuderebbero anche la +> quota TP01)»* + +TP01 e SKH01 tradano **lo stesso strumento** e Deribit tiene **una sola posizione netta**. Un +ordine on-book al livello di TP di SKH chiuderebbe una fetta della posizione netta, che è per il +75% di TP01. Ho quindi misurato i due ostacoli invece di assumerli: + +**(A) Compatibilità di segno — risolvibile.** Un ordine reduce-only può muovere la posizione solo +verso lo zero. Se SKH è short mentre il netto è long (perché TP01 pesa di più), chiudere lo short +significa *aumentare* il netto: non esprimibile. Frequenza sui trade che escono in TP — gli unici +che il fix tocca: **97% compatibili** (SKH long 100%, SKH short 94%). Il 3% si sarebbe potuto +saltare con fallback all'uscita software. + +**(B) Divergenza modello/live — sembrava bloccante.** Se il limit riempie a metà barra ma il +modello non "vede" l'uscita, il cron successivo ricalcola il target includendo ancora la quota SKH +e **ricompra ciò che il TP ha appena chiuso**. Misurato assumendo la rilevazione a fine barra 230m: +ritardo mediano **115 min**, p90 **210**, e nel **70% dei TP almeno un cron gira nella finestra** +(mediana 1.9 cron, p90 3.5). + +## 2. …e poi ho scoperto che il problema (B) non esiste + +Il problema (B) presuppone che il modello veda le uscite solo a barra 230m chiusa. È ciò che dicono +i docstring: + +> `sleeves._skyhook_positions`: *«fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse»* +> `book.py`: *«latenza fino alla chiusura della barra 230m corrente»* + +**Sono falsi.** `resample_5m` **non scarta il bin in corso** — aggrega le barre 5m che ci sono +finora — e il ciclo di `_skyhook_positions` itera fino a `n-1`, quindi valuta gli hit di SL/TP +anche sul bin **parziale**, col suo high/low corrente. Verificato sul feed reale: l'ultimo bin 230m +conteneva **21 barre 5m su 46** (105 minuti su 230). + +Quindi il live rileva il tocco **entro il primo cron dopo il tocco (~1h)**, non a fine barra. E non +è look-ahead: il livello è fissato all'ingresso da barre chiuse, e osservare che il prezzo l'ha già +toccato è esattamente ciò che fa qualunque stop reale. + +### La conseguenza: il path live era modellato male da tre settimane + +La lente `hourly` — usata dall'audit del 02/07, dal follow-up del 24/07 e dal mio stesso T1 di +stamattina — assume la rilevazione a fine barra. Il modello fedele è un altro (`fastdetect`: +rilevazione intra-barra a 5m, uscita a mercato al primo cron successivo). Sulla banda appaiata dei +23 offset, **relativo al path live come veniva modellato**: + +| modalità | ΔFULL mediana [min,max] | ΔHOLD mediana | offset migliorati (FULL) | +|---|---|---|---| +| **`fastdetect` = il live VERO** | **+0.081** [+0.00,+0.14] | +0.039 | **23/23** | +| `canonical` (tetto teorico del backtest) | +0.054 [−0.05,+0.12] | +0.099 | — | +| `onbook_tp` (**il fix richiesto**) | +0.054 [−0.03,+0.07] | +0.061 | 19/23 | +| `onbook` (TP+SL on-book) | +0.024 [−0.07,+0.11] | +0.031 | 17/23 | + +Due letture, entrambe importanti: + +1. **Il "−0.35 di degrado del path live" è in gran parte un artefatto di modello**, sopra + l'artefatto di ancora già trovato stamattina. Sul FULL il live vero sta **sopra** il canonical. +2. **Il fix richiesto sarebbe un declassamento**: +0.054 contro i +0.081 che il live già fa — e in + cambio si porterebbero in un percorso con soldi veri ordini parziali sul netto, un 3% di casi + non esprimibili, e la riconciliazione fra fill del broker e stato del modello. + +**Per questo non l'ho implementato.** Non è una difficoltà tecnica: è che la misura, fatta sulla +lente giusta, dice che peggiorerebbe. + +--- + +## 3. Il problema vero, trovato per strada + +`fresh_5m` è il pezzo che tiene bassa la latenza d'uscita. E **fallisce in silenzio**: + +```python +try: + tail = _fetch_recent_5m(sym, lookback_days) +except Exception: + return base # ← nessun log, nessun errore, nessuna traccia +``` + +`_fetch_recent_5m` inghiotte a sua volta le eccezioni (`except Exception: break`). Se il fetch +pubblico Deribit non risponde, il segnale SKH gira sul **feed certificato**, che il cron ricostruisce +**una volta al giorno**. La latenza d'uscita passa da **~1 ora a ~1 giorno**, e niente lo segnala: +il conto è online, la posizione è leggibile, il gate di staleness guarda il feed *di TP01* — nessuno +dei controlli esistenti scatta. È lo stesso schema del feed-freeze del 2026-07-14, su un altro feed. + +E conta esattamente quanto il resto di questo lavoro: la latenza d'uscita **è** dove vive la qualità +del path live. + +### Cosa ho cablato + +- **`livefeed.feed_age_minutes(df5)`** — pura, testabile senza rete: età in minuti dell'ultima barra + 5m, misurata dalla sua **chiusura**, con `None` per input non interpretabili (`None` = "non + misurata", da trattare come sospetta: se tornasse `0.0` sembrerebbe fresca) e clamp a zero sugli + skew d'orologio. +- **`book.book_report(live_feed=True)`** avvolge il loader per misurare la freschezza del feed + **effettivamente usato**, e la espone come `skh_feed_age_min` (massimo fra gli asset: se anche uno + solo è stantio, il netto è sospetto). +- **`book_execute`** stampa lo stato e **allerta su Telegram** sopra `skh_feed_max_age_min` (30 min, + in `config/live.json`). + +**Scelta dichiarata: allerta, NON blocca.** Bloccare fermerebbe anche il ribilancio di TP01 — sono +nettati sullo stesso strumento — per un guasto di rete; e forzare SKH a flat chiuderebbe posizioni +buone su un glitch. Non ho evidenza su quale politica di blocco sia giusta, quindi la decisione +resta all'operatore, informato. + +### Docstring corretti + +I due docstring falsi sono stati corretti *sul posto*, con la misura e il perché. Erano loro a +sostenere la catena di ragionamenti sbagliata, ed erano la cosa più economica da riparare. + +--- + +## 4. Follow-up dichiarato, non risolto + +La stessa barra parziale che migliora le **uscite** tocca anche gli **ingressi**: `ent[n-1]` può +venire da un bin parziale, cioè da un breakout non ancora confermato a fine barra. Se il segnale +evapora, il book apre e richiude — churn puro. + +Misurato su **112 bin campionati**: entry sulla barra completa in 3, sulla parziale in 2, +**disaccordo in 1 (~1% dei bin)**. Ma con sole 3 entry nel campione la **taglia dell'effetto non è +stimabile**: so che esiste, non so quanto pesa. Non l'ho toccato — a differenza delle uscite, qui +il comportamento del live **diverge dal backtest in modo sostanziale** (entra su un segnale che il +backtest non avrebbe visto), e sistemarlo è un cambio di strategia, non di strumentazione. +Serve una misura dedicata prima di decidere in che verso. + +--- + +## 5. Lezioni + +1. **Prima di implementare una raccomandazione, leggere il codice che dovrà ospitarla.** T1 era + corretto sul suo modello e inapplicabile sull'architettura reale — e leggendo quel codice è + saltato fuori che il modello stesso era sbagliato. La ricerca del mattino non lo poteva sapere: + simulava lo sleeve, non il book nettato. +2. **Un docstring falso costa più di un bug**, perché nessun test lo cattura e tutti ci ragionano + sopra. Questi due hanno guidato tre analisi (02/07, 24/07, T1) verso una latenza che il codice + non aveva. +3. **Un fallback silenzioso è un guasto silenzioso.** `fresh_5m` fa la cosa giusta (mai operare a + cieco) ma non dice di averla fatta: la scelta giusta è tenere il fallback **e** misurarlo. +4. Terzo caso in due giorni in cui **il numero di partenza era peggio di com'era stato scritto**: + la fortuna d'ancora sul degrado (stamattina), la lente `hourly` (qui), il wick indipendente + (ieri). Ogni volta il difetto stava nella **lente**, non nella strategia. diff --git a/scripts/live/book_execute.py b/scripts/live/book_execute.py index d099398..d7b1684 100644 --- a/scripts/live/book_execute.py +++ b/scripts/live/book_execute.py @@ -44,6 +44,7 @@ def load_config() -> dict: cfg.setdefault("min_order_usd", 5.0) cfg.setdefault("disaster_sl_pct", 0.30) cfg.setdefault("max_data_age_days", 2.0) + cfg.setdefault("skh_feed_max_age_min", 30.0) return cfg @@ -92,6 +93,29 @@ def _run(): print(f" ⚠️ SKH FEED ERRORE (SKH forzato flat!): {r['skh_error']}") notify("⚠️ BOOK — SKH feed fallito (sleeve forzato flat)", {"error": r["skh_error"]}) + # --- FRESCHEZZA del feed 5m usato per il segnale SKH (2026-07-26) -------------------------- + # `fresh_5m` ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e + # il certificato lo ricostruisce il cron una volta al giorno. Non solleva, non logga: senza + # questo controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 GIORNO senza che nulla lo + # dica. E' proprio la latenza d'uscita dove vive la qualita' del path live (misura 2026-07-26). + # SCELTA DICHIARATA: allerta, NON blocca. Bloccare fermerebbe anche il ribilancio di TP01 + # (sono nettati sullo stesso strumento) per un guasto di rete; e forzare SKH flat chiuderebbe + # posizioni buone su un glitch. La decisione resta all'operatore. + skh_age = r.get("skh_feed_age_min") + max_skh_age = float(cfg.get("skh_feed_max_age_min", 30.0)) + if skh_age is None: + print(" ⚠️ freschezza feed SKH NON MISURATA (segnale su feed certificato?)") + elif skh_age > max_skh_age: + print(f" ⚠️ FEED SKH STANTIO: ultima barra 5m di {skh_age:.0f} min fa " + f"(soglia {max_skh_age:.0f}) -> le uscite SKH sono in ritardo, NON blocco.") + if do_execute: + notify("⚠️ BOOK — feed SKH stantio (uscite in ritardo)", + {"eta_min": round(skh_age), "soglia_min": round(max_skh_age), + "effetto": "SL/TP di SKH01 rilevati in ritardo", + "nota": "fresh_5m e' ricaduto sul feed certificato (fetch pubblico KO)"}) + else: + print(f" feed SKH : fresco ({skh_age:.0f} min)") + if not r["online"]: print(" conto non leggibile (offline) -> stop, non eseguo a cieco.") if do_execute: diff --git a/scripts/research/r0726_skh_onbook.py b/scripts/research/r0726_skh_onbook.py index 9718b48..da1a0ad 100644 --- a/scripts/research/r0726_skh_onbook.py +++ b/scripts/research/r0726_skh_onbook.py @@ -131,6 +131,50 @@ def sim_equity_exec(ltf: pd.DataFrame, ent: list, mode: str, entry = c[i] if mode == "canonical" else px_hour(close_ts[i]) fee_entry = FEE_TAKER_SIDE + if mode == "fastdetect": + # RILEVAZIONE INTRA-BARRA (proposta per il live, 2026-07-26): il livello SL/TP e' + # fissato all'INGRESSO da barre chiuse, quindi osservare che il prezzo l'ha gia' + # toccato NON e' look-ahead — e' cio' che fa qualunque stop reale. Oggi invece + # `_skyhook_positions` guarda solo barre 230m CHIUSE, e il tocco resta invisibile + # per una mediana di 115 minuti (p90 210). + # Qui: si trova il tocco a 5m, e si esegue al PROSSIMO multiplo orario dopo il tocco + # (non al livello: resta un'uscita a mercato del cron). + t_end_bar = int(ltf["timestamp"].values[min(i + max_bars, n - 1)]) + MS_LTF + a5 = int(np.searchsorted(ts5_close, close_ts[i], side="left")) + b5 = int(np.searchsorted(ts5_close, t_end_bar, side="right")) + hit_j = None + for j5 in range(a5, min(b5 + 1, len(c5))): + hs = (l5[j5] <= sl) if direction == 1 else (h5[j5] >= sl) + ht = (h5[j5] >= tp) if direction == 1 else (l5[j5] <= tp) + if hs or ht: + hit_j = j5 + break + # la barra 230m in cui cade il tocco resta quella che chiude il trade (stesse + # boundaries del canonico: cambia SOLO il momento dell'esecuzione) + for j in range(i + 1, min(i + max_bars + 1, n)): + hit_sl = sl is not None and ( + (direction == 1 and lo[j] <= sl) or (direction == -1 and h[j] >= sl)) + hit_tp = tp is not None and ( + (direction == 1 and h[j] >= tp) or (direction == -1 and lo[j] <= tp)) + if hit_sl or hit_tp: + exit_idx, kind = j, ("sl" if hit_sl else "tp") + break + exit_idx = j + kind = "time" + else: + exit_idx = min(i + max_bars, n - 1) + kind = "time" + stats[kind] += 1 + if hit_j is not None and kind in ("sl", "tp"): + exit_price = px_hour(int(ts5_close[hit_j])) # primo cron DOPO il tocco + else: + exit_price = px_hour(close_ts[exit_idx]) + gross = (exit_price - entry) / entry * direction + capital = max(capital + capital * (gross - 2 * FEE_TAKER_SIDE), 1.0) + equity[i:exit_idx + 1] = capital + busy_until = exit_idx + continue + # --- detection identica al canonico (SL prioritario) exit_idx = min(i + max_bars, n - 1) exit_lvl = c[exit_idx] @@ -218,9 +262,10 @@ def sh3(s: pd.Series) -> tuple[float, float, float, float]: return m_all["sharpe"], m_is["sharpe"], m_ho["sharpe"], m_all["maxdd"] -MODES = ("canonical", "hourly", "onbook_tp", "onbook") +MODES = ("canonical", "hourly", "fastdetect", "onbook_tp", "onbook") LABEL = {"canonical": "canonical (backtest, fill-al-livello)", "hourly": "hourly (PATH LIVE DI OGGI)", + "fastdetect": "fastdetect (rilevazione INTRA-BARRA, exit a mercato)", "onbook_tp": "onbook_tp (solo TP a limite)", "onbook": "onbook (TP limite + SL stop-market)"} @@ -297,7 +342,7 @@ def main() -> None: # per-offset, non la differenza delle mediane (che confronta offset diversi fra loro). print("\n differenze APPAIATE per-offset (mediana [min, max] sui 23 offset):") print(f" {'':>40} {'dFULL':>22} {'dHOLD':>22} {'dDD':>20}") - for m in ("canonical", "onbook_tp", "onbook"): + for m in ("canonical", "fastdetect", "onbook_tp", "onbook"): d0 = A[m][:, 0] - A["hourly"][:, 0] d2 = A[m][:, 2] - A["hourly"][:, 2] d3 = A[m][:, 3] - A["hourly"][:, 3] @@ -314,7 +359,7 @@ def main() -> None: print(f" recuperato dal solo TP a limite: {dtp:+.3f} = {dtp/dmax:>5.0%}") print(f" recuperato da TP + SL on-book : {dob:+.3f} = {dob/dmax:>5.0%}") print("\n in quanti dei 23 offset la modalita' MIGLIORA il path live di oggi?") - for m in ("onbook_tp", "onbook"): + for m in ("fastdetect", "onbook_tp", "onbook"): nf = int((A[m][:, 0] - A["hourly"][:, 0] > 0).sum()) nh = int((A[m][:, 2] - A["hourly"][:, 2] > 0).sum()) nd = int((A[m][:, 3] - A["hourly"][:, 3] < 0).sum()) diff --git a/src/live/book.py b/src/live/book.py index e82772c..cdf1b35 100644 --- a/src/live/book.py +++ b/src/live/book.py @@ -21,9 +21,19 @@ deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): GLI EXIT DI SKH01 SONO IMPLICITI: _skyhook_positions() replica il book (ingressi + SL/TP/max_bars + non-overlap) sulla feed certificata fresca -> quando il trade va chiuso ritorna 'flat' -> skh_sign=0 --> il target netto si aggiorna -> il reconciler chiude la quota SKH. NB: gli exit sono SOFTWARE -(no bracket on-book per SKH, sennò chiuderebbero anche la quota TP01) -> latenza fino alla chiusura -della barra 230m corrente. Solo il disaster-SL (-30%) resta on-book, sulla posizione NETTA. +-> il target netto si aggiorna -> il reconciler chiude la quota SKH. Gli exit sono SOFTWARE (no +bracket on-book per SKH, sennò chiuderebbero anche la quota TP01). Solo il disaster-SL (-30%) resta +on-book, sulla posizione NETTA. + +⚠️ CORREZIONE 2026-07-26 — questo docstring diceva "latenza fino alla chiusura della barra 230m +corrente". E' FALSO, e l'errore e' costato tre settimane di misure sbagliate (audit 02/07, follow-up +24/07): `resample_5m` NON scarta il bin 230m in corso, e il loop di `_skyhook_positions` ci itera +dentro usando il suo high/low CORRENTE -> il tocco di SL/TP e' visto **entro il primo cron dopo il +tocco (~1h)**, non a fine barra (mediana 115 min, p90 210 di ritardo in piu'). Misurato: modellare +il live come "detection a fine barra" (lente `hourly`) lo SOTTOSTIMA di +0.081 di Sharpe FULL sul +book, in 23/23 offset. Diario `2026-07-26-t1-esecuzione-skh-live.md`. +La latenza reale dipende quindi dalla FRESCHEZZA del feed 5m, non dalla griglia 230m -> vedi +`skh_feed_age_min` nel report e l'allerta in scripts/live/book_execute.py. Questo modulo NON invia nulla: costruisce solo il report/ordine. L'invio è in scripts/live/book_execute.py (doppio gate). Causale: usa solo barre chiuse (eredita la causalità di TP01 e di _skyhook_positions). @@ -118,9 +128,18 @@ def book_report(offline: bool = False, equity_override: float | None = None, equity = sh["equity"] cap = _cap(equity=equity, real_equity=sh.get("real_equity"), eq_fallback=sh.get("eq_fallback")) load5m = None + feed_ages: dict[str, float | None] = {} if live_feed: - from src.live.livefeed import fresh_5m - load5m = fresh_5m + from src.live.livefeed import feed_age_minutes, fresh_5m + + def load5m(a: str): + """Wrapper che MISURA la freschezza del feed effettivamente usato per il segnale. + `fresh_5m` ricade sul certificato in SILENZIO se il fetch pubblico fallisce: senza + questa misura la latenza d'uscita di SKH01 passerebbe da ~1h a ~1 giorno senza che + nulla lo segnali (vedi feed_age_minutes).""" + df = fresh_5m(a) + feed_ages[a] = feed_age_minutes(df) + return df skh_error = None try: skh = _skyhook_positions(load5m=load5m) @@ -154,5 +173,10 @@ def book_report(offline: bool = False, equity_override: float | None = None, cap_per_asset=cap, weights=dict(TP01=W_TP01, SKH01=W_SKH), assets=assets, orders=orders, skh_error=skh_error, pos_error=sh.get("pos_error"), eq_fallback=sh.get("eq_fallback"), + # eta' (minuti) del feed 5m REALMENTE usato per il segnale SKH: None se non misurata + # (live_feed=False) o non interpretabile. Vale il MASSIMO fra gli asset: se anche uno solo + # e' stantio, il segnale netto e' sospetto. + skh_feed_age_min=(max((v for v in feed_ages.values() if v is not None), default=None) + if feed_ages else None), flat=all(abs(x["net_target"]) < FLAT_USD for x in assets), ) diff --git a/src/live/livefeed.py b/src/live/livefeed.py index f8d56b9..d186aef 100644 --- a/src/live/livefeed.py +++ b/src/live/livefeed.py @@ -70,8 +70,31 @@ def merge_tail(base: pd.DataFrame, tail: pd.DataFrame) -> pd.DataFrame: return merged +def feed_age_minutes(df5: pd.DataFrame, now_ms: int | None = None) -> float | None: + """Eta' in MINUTI dell'ultima barra 5m del feed passato. None se non interpretabile. + + ⚠️ E' la metrica che dice se `fresh_5m` ha davvero attaccato la coda fresca o se e' ricaduta + in SILENZIO sul certificato: il fallback non solleva e non logga, quindi senza questa misura + un fetch pubblico rotto e' invisibile. Conta perche' la latenza d'uscita di SKH01 e' dove vive + la qualita' del path live (misurata il 2026-07-26): col feed fresco l'uscita e' rilevata entro + ~1 ora dal tocco del livello, sul solo certificato si allunga fino a ~1 GIORNO (il rebuild + certificato gira 1x/giorno). PURA, testabile senza rete.""" + if df5 is None or len(df5) == 0 or "timestamp" not in df5: + return None + try: + last = int(df5["timestamp"].iloc[-1]) + except (ValueError, TypeError): + return None + now = int(pd.Timestamp.now(tz="UTC").timestamp() * 1000) if now_ms is None else int(now_ms) + return max(0.0, (now - (last + 5 * 60_000)) / 60_000.0) + + def fresh_5m(asset: str, lookback_days: int = 12) -> pd.DataFrame: - """Feed 5m certificato + coda recente effimera (in-memory). Fallback al certificato su errore.""" + """Feed 5m certificato + coda recente effimera (in-memory). Fallback al certificato su errore. + + NB: il fallback e' SILENZIOSO per scelta (mai operare a cieco > mai operare). Chi esegue deve + misurare la freschezza di cio' che riceve con `feed_age_minutes` — vedi book.book_report, che + la espone come `skh_feed_age_min`, e book_execute, che allerta.""" base = load_data(asset, "5m") sym = DERIBIT_SYMBOL.get(asset) if sym is None: diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index b86ee93..7f4d4a0 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -239,7 +239,23 @@ def _skyhook_returns() -> pd.Series: def _skyhook_positions(load5m=None) -> dict: """Stato corrente del book Skyhook per asset (introspezione live): se c'e' un trade APERTO ORA -> dir/entry/sl/tp/barre-trascorse; altrimenti 'flat'. Replica la logica non-overlap di - entry+exit (TP/SL/max_bars) fino all'ultima barra 230m chiusa. Causale: usa solo barre chiuse. + entry+exit (TP/SL/max_bars). + + ⚠️ CORREZIONE 2026-07-26 — qui c'era scritto "fino all'ultima barra 230m CHIUSA. Causale: usa + solo barre chiuse". E' FALSO ed e' stato creduto per settimane: `resample_5m` NON scarta il bin + 230m in corso (verificato: 21 barre 5m su 46 nell'ultimo bin), e il ciclo qui sotto itera fino a + `n-1`, quindi valuta gli hit di SL/TP anche sul bin PARZIALE, col suo high/low corrente. + Conseguenze, entrambe reali: + * USCITE (bene): il tocco del livello e' visto entro il primo cron dopo il tocco (~1h) invece + che a fine barra 230m -> il path live e' MIGLIORE di come lo modellavano l'audit 02/07 e il + follow-up 24/07 (+0.081 Sharpe FULL di book sulla banda appaiata, 23/23 offset). + NON e' look-ahead: il livello e' fissato all'INGRESSO da barre chiuse, e osservare che il + prezzo l'ha gia' toccato e' esattamente cio' che fa uno stop reale. + * INGRESSI (da tenere d'occhio): anche `ent[n-1]` puo' venire da un bin PARZIALE, cioe' da un + breakout non ancora confermato a fine barra -> se il segnale evapora il book apre e richiude. + Misurato su 112 bin campionati: disaccordo parziale-vs-completa in 1 (~1%), ma con sole 3 + entry nel campione la TAGLIA non e' stimabile -> follow-up dichiarato, non risolto. + Diario `2026-07-26-t1-esecuzione-skh-live.md`. load5m: callable(asset)->df5 opzionale (per il live: feed certificato + coda fresca effimera, vedi src/live/livefeed.fresh_5m). Default = feed certificato su disco (load_data).""" diff --git a/tests/test_skh_feed_freshness.py b/tests/test_skh_feed_freshness.py new file mode 100644 index 0000000..22bc671 --- /dev/null +++ b/tests/test_skh_feed_freshness.py @@ -0,0 +1,96 @@ +"""Test della misura di FRESCHEZZA del feed 5m usato dal segnale SKH01 (2026-07-26). + +Perche' esiste questo file: `src.live.livefeed.fresh_5m` ricade sul feed certificato **in +silenzio** se il fetch pubblico Deribit fallisce — non solleva, non logga. Il feed certificato si +rigenera una volta al giorno, quindi in quel caso la latenza d'uscita di SKH01 passa da ~1 ora a +~1 giorno senza che nulla lo segnali. Ed e' proprio la latenza d'uscita dove vive la qualita' del +path live (misura del 2026-07-26: modellarla male sottostimava il book di +0.081 Sharpe FULL). + +I test blindano che la misura ci sia, sia corretta nei casi limite, e arrivi fino al report. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +pytest.importorskip("pandas") +import pandas as pd # noqa: E402 + +from src.live.livefeed import SCHEMA, feed_age_minutes, merge_tail # noqa: E402 + + +def _df(ts_list): + return pd.DataFrame([[t, 1.0, 1.0, 1.0, 1.0, 1.0] for t in ts_list], columns=SCHEMA) + + +NOW = 1_800_000_000_000 # riferimento fisso: i test non devono dipendere dall'orologio + + +def test_feed_fresco_ha_eta_zero(): + """Barra chiusa proprio adesso -> eta' 0 (l'eta' si misura dalla CHIUSURA, non dall'apertura).""" + assert feed_age_minutes(_df([NOW - 5 * 60_000]), now_ms=NOW) == pytest.approx(0.0) + + +def test_eta_misurata_dalla_chiusura_della_barra(): + """Una barra 5m aperta 35 min fa e' chiusa da 30 -> eta' 30, non 35.""" + assert feed_age_minutes(_df([NOW - 35 * 60_000]), now_ms=NOW) == pytest.approx(30.0) + + +def test_feed_di_un_giorno_supera_di_gran_lunga_la_soglia(): + """E' lo scenario da intercettare: fallback silenzioso sul certificato (rebuild 1x/giorno).""" + age = feed_age_minutes(_df([NOW - 24 * 60 * 60_000]), now_ms=NOW) + assert age == pytest.approx(24 * 60 - 5) + assert age > 30.0, "il caso che l'allerta deve prendere" + + +def test_eta_non_negativa_con_barra_nel_futuro(): + """Uno skew d'orologio non deve produrre un'eta' negativa che passerebbe ogni soglia.""" + assert feed_age_minutes(_df([NOW + 60 * 60_000]), now_ms=NOW) == 0.0 + + +@pytest.mark.parametrize("bad", [None, pd.DataFrame(), pd.DataFrame({"x": [1]})]) +def test_input_non_interpretabile_da_None_non_zero(bad): + """None = 'non misurata' e va trattata come sospetta; se tornasse 0.0 sembrerebbe fresca.""" + assert feed_age_minutes(bad, now_ms=NOW) is None + + +def test_merge_tail_vince_sui_duplicati_e_ringiovanisce_il_feed(): + """La coda fresca deve davvero spostare in avanti l'ultima barra: e' il meccanismo che + tiene bassa la latenza d'uscita.""" + base = _df([NOW - 3 * 86_400_000, NOW - 2 * 86_400_000]) + tail = _df([NOW - 2 * 86_400_000, NOW - 5 * 60_000]) + m = merge_tail(base, tail) + assert len(m) == 3, "il duplicato dev'essere collassato" + assert feed_age_minutes(m, now_ms=NOW) == pytest.approx(0.0) + assert feed_age_minutes(base, now_ms=NOW) > 30.0 + + +def test_merge_tail_vuota_lascia_il_base_intatto(): + """Fetch fallito -> coda vuota -> il feed resta il certificato: e' il fallback silenzioso, + e il test lo fissa come comportamento ATTESO (non e' un bug, e' il motivo della misura).""" + base = _df([NOW - 86_400_000]) + assert merge_tail(base, pd.DataFrame(columns=SCHEMA)).equals(base) + assert merge_tail(base, None).equals(base) + + +def test_book_report_espone_la_chiave(monkeypatch): + """La misura deve arrivare fino al report, altrimenti l'esecutore non puo' allertare. + Senza live_feed la chiave esiste ma vale None ('non misurata').""" + from src.live import book as bk + r = bk.book_report(offline=True, equity_override=600.0) + assert "skh_feed_age_min" in r + assert r["skh_feed_age_min"] is None + + +def test_soglia_di_config_presente_e_sensata(): + """La soglia dev'essere piu' larga della cadenza del cron (orario) e molto piu' stretta + di un giorno, sennò non distingue 'fresco' da 'ricaduto sul certificato'.""" + import json + cfg = json.loads((ROOT / "config" / "live.json").read_text()) + thr = float(cfg["skh_feed_max_age_min"]) + assert 10.0 <= thr <= 120.0, thr