feat(runner): gate feed CONGELATO — sospende gli sleeve ETH-leg finche' il feed non si sblocca

Audit "stato ordini": il feed ETH-PERPETUAL congelato a 1661.95 (36h+) generava
perdite REALI (SH01_ETH -2.83 reali vs -0.09 sim su un close + riapertura; 4 pairs
con gamba ETH entrati su z-score spuri -3/-5/+5.6 = artefatto del log-ratio con ETH
pinnato mentre gli alt si muovono).

_frozen_assets + _feed_gated_sids: quando il feed di decisione 1h di un asset e'
congelato, gli sleeve concentrati (single/ml/pairs) che ne dipendono saltano il tick
(entry+exit) finche' non si sblocca (come outage; disaster-SL on-book = coda).
Auto-guarente: rilascio alla prima barra COMPLETA non-flat (NON l'entry-guard
post-flat bocciata). Detector guasto-vs-illiquido: conta la run di close INVARIATE
(ETH/BNB/DOGE run 40-64, 1-4 val/48h = morti; SOL/LTC/ADA run <=12, 5-31 val = vivi).
Soglia feed_freeze_gate_bars=24 -> gatea le 9 gambe ETH esatte, PR_BTCLTC e i
multi-asset restano attivi. Alert Telegram FEED_FROZEN_GATE GATED/RIPRESO.

Test test_freeze_gate.py (6, detector+scope+rilascio). Suite portfolio 140/140.
Cerotto testnet: il fix vero e' mainnet. Diario 2026-06-15-frozen-feed-gate.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-15 08:44:39 +00:00
parent c21e1dc635
commit 4c184d5cbc
5 changed files with 267 additions and 0 deletions
+86
View File
@@ -229,6 +229,68 @@ def _check_stale_feed(asset: str, df: pd.DataFrame, alerted: set[str]):
"gap_pct": round(gap, 2), "prezzo": float(c[i])})
# --- Gate feed CONGELATO (2026-06-15) -----------------------------------------
# Distinto da STALE_FEED (osservabilita') e FEED_BOOK_GAP (divergenza esecuzione):
# qui si AGISCE. Quando il feed di DECISIONE 1h di un asset e' CONGELATO (guasto
# testnet: ETH-PERPETUAL inverse fermo a 1661.95 per 36h+, prezzo MAI cambiato) gli
# sleeve CONCENTRATI che ne dipendono (single/ml/pairs) decidono entry/exit su un
# prezzo morto -> segnali spuri (z-score pairs estremi -3/-5, SH01 short a prezzo
# fermo) e perdite REALI (i fill avvengono al book vero ~1717, l'uscita sim al prezzo
# congelato: SH01_ETH ha realizzato -2.83 reali vs -0.09 sim su un solo close). Si
# SALTA il tick (entry E exit) finche' il feed non si sblocca: come un outage (i worker
# non valutano gli exit, protezione = disaster-SL on-book), auto-guarente.
#
# DISTINGUERE guasto da ILLIQUIDITA': SOL/LTC/ADA stampano molte barre flat (O=H=L=C,
# 50-95%) ma il prezzo SI muove ogni poche ore (run di close identiche CORTE, ~2-5). Il
# guasto e' il prezzo che non cambia MAI: run di close INVARIATE >= soglia (il freeze
# reale dura decine di barre). Un detector flat-bar ingenuo gaterebbe gli alt
# illiquidi-ma-VIVI -> si conta la run di close invariate, NON le barre flat.
#
# NB POST-FLAT: si gatea DURANTE il freeze (ultima barra completa ferma). La barra di
# RIPRESA e' non-flat -> la run si azzera -> il tick riprende SU di essa. NON e'
# l'entry-guard post-flat (BOCCIATA: la candela-gap e' l'overshoot che la fade fada con
# profitto, CLAUDE.md / 2026-06-05): quello salta la barra di ripresa, questo no.
#
# SOGLIA (misurata sul feed reale 2026-06-15): le due popolazioni sono ben separate ->
# MORTO ETH run 64 (1 val distinto/48h), BNB 64, DOGE 42 (feed pinnato a un valore)
# ILLIQUIDO LTC run 10, ADA 11, XRP 12, SOL 1 (5-31 val distinti/48h: SI muovono)
# A 24 (un giorno intero di prezzo immobile) il guasto multi-giorno e' preso con ampio
# margine senza gateare gli alt illiquidi-ma-VIVI (importante per PR_BTCLTC: BTC vivo +
# LTC solo illiquido NON deve sospendere il pair). ETH (run 64) gatea comunque subito.
_FREEZE_GATE_BARS = 24 # run di close INVARIATE sull'ultima barra completa = freeze
def _frozen_assets(raw1h: dict[str, pd.DataFrame], threshold: int) -> set[str]:
"""Asset col feed di decisione 1h CONGELATO: ultima barra completa flat (O=H=L=C)
E close invariata da >= `threshold` barre complete consecutive. 0 = gate disattivo."""
from src.live.bars import last_settled_idx
frozen: set[str] = set()
if threshold <= 0:
return frozen
for asset, df in raw1h.items():
o, h, l, c = (df[k].values for k in ("open", "high", "low", "close"))
i = len(c) + last_settled_idx(df["timestamp"].values)
if i < 1 or not (o[i] == h[i] == l[i] == c[i]):
continue # ultima barra completa non flat -> vivo
run = 1
while i - run >= 0 and c[i - run] == c[i]:
run += 1
if run >= threshold:
frozen.add(asset)
return frozen
def _feed_gated_sids(live_specs, frozen: set[str]) -> set[str]:
"""sid degli sleeve CONCENTRATI (single/ml/pairs) che dipendono da un asset col feed
congelato. I multi-asset (basket/rotation/tsmom/xsec, tutti PAPER) NON sono gateati:
diversificati su 8 asset, un singolo feed fermo non li compromette."""
if not frozen:
return set()
return {s.sid for s in live_specs
if s.kind in ("single", "ml", "pairs")
and any(a in frozen for a in _spec_assets_tf(s)[0])}
_GAP_BPS_DEFAULT = 150.0 # |close feed - mark book| oltre cui il feed non e' affidabile
@@ -431,6 +493,11 @@ def run(config_path: str = "portfolios.yml"):
# guard feed-vs-book (2026-06-12): soglia bps in overrides.feed_book_gap_bps (0 = off)
gap_bps = float(_ov.get("feed_book_gap_bps", _GAP_BPS_DEFAULT))
gap_alerted: set[str] = set()
# gate feed CONGELATO (2026-06-15): salta gli sleeve concentrati su un asset col feed
# fermo. Soglia (barre 1h di close invariata) in overrides.feed_freeze_gate_bars (0 = off).
freeze_gate_bars = int(_ov.get("feed_freeze_gate_bars", _FREEZE_GATE_BARS))
spec_by_id = {s.sid: s for s in live_specs}
frozen_gated: set[str] = set() # sleeve gateati ora (dedup alert + recovery)
# Osservabilita' outage (improvement-sweep 2026-06-06): il poll-loop intero e' in un
# try/except → durante un outage i worker NON valutano gli exit. Alert Telegram dopo
# _OUTAGE_POLLS poll falliti/DEGRADATI consecutivi + notifica di ripresa con durata.
@@ -486,6 +553,23 @@ def run(config_path: str = "portfolios.yml"):
if exec_enabled and gap_bps > 0:
_check_feed_book_gap(client, raw1h, exec_instr, gap_bps, gap_alerted)
# gate feed CONGELATO: sleeve concentrati su un asset col feed fermo -> tick saltato
frozen = _frozen_assets(raw1h, freeze_gate_bars)
now_gated = _feed_gated_sids(live_specs, frozen)
if now_gated != frozen_gated:
from src.live.telegram_notifier import notify_event
for sid in sorted(now_gated - frozen_gated):
fa = sorted(set(_spec_assets_tf(spec_by_id[sid])[0]) & frozen)
print(f"[runner] FEED_FROZEN_GATE {sid}: asset congelati {fa} -> tick sospeso")
notify_event("FEED_FROZEN_GATE", {
"sleeve": sid, "asset_congelati": ", ".join(fa), "stato": "GATED",
"nota": "feed di decisione fermo: entry+exit sospesi finche' non si "
"sblocca (disaster-SL on-book protegge le posizioni reali)"})
for sid in sorted(frozen_gated - now_gated):
print(f"[runner] FEED_FROZEN_GATE {sid}: feed ripreso -> tick riattivato")
notify_event("FEED_FROZEN_GATE", {"sleeve": sid, "stato": "RIPRESO"})
frozen_gated = now_gated
# fetch DIRETTO dei timeframe sub-orari (15m...) per (asset, tf)
raw_sub: dict[tuple[str, str], pd.DataFrame] = {}
for (asset, tf), days in subhourly_needs.items():
@@ -553,6 +637,8 @@ def run(config_path: str = "portfolios.yml"):
"nota": "exit NON valutati per questo worker"})
for s in live_specs:
if s.sid in now_gated:
continue # feed di decisione congelato -> entry+exit sospesi
_tick_safe(s, workers[s.sid])
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio
for s in paper_specs: