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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-25 20:20:01 +00:00
parent 4b2de1ce87
commit 031b71bf54
9 changed files with 427 additions and 11 deletions
+31
View File
@@ -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
+3 -1
View File
@@ -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
}
@@ -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.
+24
View File
@@ -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:
+48 -3
View File
@@ -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())
+29 -5
View File
@@ -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),
)
+24 -1
View File
@@ -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:
+17 -1
View File
@@ -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)."""
+96
View File
@@ -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