Files
PythagorasGoal/src/live/livefeed.py
T
Adriano Dal Pastro 7d64dd4c2b live(feed): l'allerta funzionava, la sua CAUSA era una riga cablata
Il 29/07 il feed 5m di SKH01 e' ricaduto sul certificato in 6 giri orari su 8
(eta' 265->685 min, +60 a ogni giro = firma esatta del fallback): latenza
d'uscita da ~1h a ~11h, book flat, nessuna posizione esposta. L'allerta del
26/07 ha segnalato 6/6, poi ha stampato un perche' che non aveva misurato —
la nota "fetch pubblico KO" era cablata, identica in ogni caso, compreso
quello in cui la coda fresca E' attaccata e il vecchio e' il certificato.

E la causa vera non era recuperabile per costruzione: _fetch_recent_5m ingoia
l'eccezione di pagina con un break e a prima pagina fallita ritorna un frame
vuoto, indistinguibile da "il venue non ha barre".

Cablato: livefeed.last_fetch_error() (registra E logga nel punto in cui
l'errore viene ingoiato) -> book_report.skh_feed_errors -> allerta con la
causa. Stesso buco chiuso sul ramo gemello "conto offline", che la ragione
l'aveva gia' in mark_src e non la stampava mai.

La causa del 29/07 resta IGNOTA e va citata cosi': una prima stesura la
attribuiva a un rate limit per-IP come se fosse un fatto -> rimossa, sarebbe
stato lo stesso difetto che stavo correggendo scritto meglio. Cron spostato
al minuto :07 come ripiego da UNA osservazione, dichiarato tale.

Test 11 -> 16 (incluso il caso a meta' paginazione: coda parziale attaccata,
mancano le barre PIU' recenti). Book/pesi/config/strategia INVARIATI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:20:42 +00:00

161 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""FEED LIVE EFFIMERO per il segnale SKH01 (book a 230m) — NON tocca i dati certificati su disco.
SKH01 decide su griglia 230m: per eseguirlo fedelmente il segnale serve fresco all'ultima barra
chiusa. Il rebuild certificato (rebuild_history.py) gira 1×/giorno e fa un rebuild COMPLETO (pesante):
girarlo ogni ora sarebbe sbagliato e violerebbe la regola "aggiornare lo storico SOLO con
rebuild_history + certificare". Quindi qui NON scriviamo su disco: carichiamo il 5m CERTIFICATO e gli
appendiamo IN MEMORIA una coda recente presa da Deribit PUBBLICO (ccxt, tokenless, STESSO simbolo
inverse del feed certificato -> prezzi entro ~3 bps). I dati certificati restano la verità su disco;
questa estensione vive solo nel processo live e per il calcolo del segnale.
Robusto ai fallimenti: qualunque errore di rete/fetch -> ritorna il feed certificato invariato (il
runner degrada a "fermo all'ultimo dato certificato", mai opera a cieco). Solo SKH01 ne ha bisogno:
TP01 è giornaliero e gira bene sul feed certificato.
"""
from __future__ import annotations
import logging
import time
import pandas as pd
from src.data.downloader import load_data
# STESSO simbolo del feed certificato (vedi scripts/analysis/rebuild_history.DERIBIT_INSTR):
# inverse USD perp, storia lunga, entro ~3 bps dal lineare USDC su cui eseguiamo.
DERIBIT_SYMBOL = {"BTC": "BTC/USD:BTC", "ETH": "ETH/USD:ETH"}
SCHEMA = ["timestamp", "open", "high", "low", "close", "volume"]
_LOG = logging.getLogger(__name__)
# CAUSA dell'ultimo fallback. Il fallback resta SILENZIOSO come comportamento (mai operare a cieco
# > mai operare, scelta del 26/07): quello che NON deve restare silenzioso e' il PERCHE'.
#
# Perche' esiste (2026-07-29): dopo il riavvio della VPS delle 04:11 UTC il feed e' ricaduto sul
# certificato in 6 giri orari su 8 fra le 05:00 e le 12:00 -> fino a 685 min di eta', cioe' la
# latenza d'uscita di SKH01 passata da ~1h a ~11h. L'allerta del 26/07 ha segnalato ogni volta
# (ha funzionato), ma la CAUSA non era recuperabile dai log: l'eccezione di pagina viene ingoiata
# con un `break` e `fresh_5m` ritorna il certificato senza lasciare traccia. L'unica riga sulla
# causa era CABLATA nell'allerta ("fetch pubblico KO") = una presunzione stampata come misura.
# ⚠️ La causa di QUEL giorno resta IGNOTA e tale deve restare scritta: le prove sono solo
# circostanziali (i giri falliti durano quanto quelli riusciti, 17-47s -> errore immediato, non
# timeout; nello stesso giorno il percorso del CONTO, rete diversa ma stesso venue a valle, dava
# ReadTimeout/404/502). Ipotesi non distinguibili a posteriori: rate limit per-IP del venue,
# contesa di rete/CPU sulla VPS al minuto tondo, degrado post-riavvio.
# Questa variabile serve a che la PROSSIMA occorrenza sia un dato e non un'ipotesi.
_LAST_ERROR: str | None = None
def last_fetch_error() -> str | None:
"""Perche' l'ultima `fresh_5m` non ha attaccato una coda fresca COMPLETA.
`None` = coda attaccata senza errori. Non-None = fallback al certificato **oppure** coda
troncata a meta' paginazione (la paginazione va in avanti: se cade a pagina N mancano le
barre piu' RECENTI, quindi in entrambi i casi il feed e' piu' vecchio di quanto sembri).
Chi decide guarda `feed_age_minutes`; questa dice il perche'."""
return _LAST_ERROR
def _note_error(msg: str) -> None:
"""Registra E logga la ragione del fallback. Non solleva: il chiamante prosegue sul certificato."""
global _LAST_ERROR
_LAST_ERROR = msg
_LOG.warning("fresh_5m: coda fresca non attaccata, fallback al feed certificato — %s", msg)
def _fetch_recent_5m(symbol: str, lookback_days: int) -> pd.DataFrame:
"""Coda recente di 5m da Deribit pubblico (ccxt). Paginazione in avanti. Solo letture pubbliche."""
import ccxt
ex = ccxt.deribit({"enableRateLimit": True})
tf_ms = 5 * 60 * 1000
since = int((time.time() - lookback_days * 86400) * 1000)
rows: dict[int, list] = {}
guard = 0
while guard < 200:
guard += 1
try:
r = ex.fetch_ohlcv(symbol, "5m", since=since, limit=1000)
except Exception as e:
# E' QUI che il guasto diventa invisibile: l'errore di pagina viene ingoiato e la
# funzione ritorna comunque cio' che ha raccolto finora. Se a fallire e' la PRIMA
# pagina il risultato e' un DataFrame VUOTO, che a valle e' indistinguibile da
# "Deribit non ha barre" — ed e' il ramo che il 29/07 ha cancellato la diagnosi.
_note_error(f"{type(e).__name__}: {e} (pagina {guard}, {symbol})")
break
r = [x for x in r if int(x[0]) >= since]
if not r:
break
for x in r:
t = int(x[0])
rows[t] = [t, float(x[1]), float(x[2]), float(x[3]), float(x[4]), float(x[5] or 0)]
nxt = int(r[-1][0]) + tf_ms
if nxt <= since:
break
since = nxt
if not rows:
return pd.DataFrame(columns=SCHEMA)
return pd.DataFrame(rows.values(), columns=SCHEMA).sort_values("timestamp").reset_index(drop=True)
def merge_tail(base: pd.DataFrame, tail: pd.DataFrame) -> pd.DataFrame:
"""Fonde la coda fresca sul feed certificato: concat, dedup per timestamp (la coda VINCE sui
duplicati, ma le barre certificate storiche restano), riordina. Mantiene lo schema di load_data
(inclusa 'datetime' se presente). PURA, testabile senza rete."""
if tail is None or tail.empty:
return base
cols = [c for c in SCHEMA if c in base.columns]
t = tail[[c for c in SCHEMA if c in tail.columns]].copy()
merged = pd.concat([base[cols], t], ignore_index=True)
merged = merged.drop_duplicates("timestamp", keep="last").sort_values("timestamp").reset_index(drop=True)
# ricostruisci 'datetime' coerente (build_frames non la usa, ma load_data la espone)
merged["datetime"] = pd.to_datetime(merged["timestamp"], unit="ms", utc=True)
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.
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. La RAGIONE del fallback resta
disponibile in `last_fetch_error()` fino alla chiamata successiva."""
global _LAST_ERROR
_LAST_ERROR = None
base = load_data(asset, "5m")
sym = DERIBIT_SYMBOL.get(asset)
if sym is None:
_note_error(f"nessun simbolo Deribit per l'asset {asset}")
return base
try:
tail = _fetch_recent_5m(sym, lookback_days)
except Exception as e:
_note_error(f"{type(e).__name__}: {e} ({sym})")
return base
if tail is None or len(tail) == 0:
# Coda vuota senza eccezione propagata: o _fetch_recent_5m ha gia' registrato l'errore
# di pagina, o Deribit ha davvero risposto senza barre. Sono due guasti diversi e vanno
# detti diversi, senno' si ricade nel "non vedo = va tutto bene" gia' pagato due volte.
if _LAST_ERROR is None:
_note_error(f"nessuna barra restituita da {sym} (risposta vuota, nessuna eccezione)")
return base
return merge_tail(base, tail)