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
+18
View File
@@ -598,6 +598,24 @@ long A / short B sullo z-score del log-ratio, fee su 2 gambe).
ogni formato (l'endpoint legacy `get_historical` è 404, rimosso). La voce `"30m"` in
`runner._SUBHOURLY` era **speculativa, mai testata live** → qualsiasi TF sub-orario oltre 5m/15m
va DERIVATO resamplando nel runner (come 4h/1d dal 1h), non fetchato diretto.
- **GATE FEED CONGELATO — sleeve ETH-leg sospesi (v1.1.32, 2026-06-15, scelta utente "congela
gli sleeve ETH-leg finché il feed non si sblocca").** Audit "stato ordini": il feed ETH congelato
stava generando **perdite REALI** (SH01_ETH 2.83 reali vs 0.09 sim su un close, poi riapertura
della stessa trappola; 4 pairs con gamba ETH entrati su z-score spuri 3/5/+5.6 = artefatto del
log-ratio con ETH pinnato). Fix: `runner._frozen_assets` + `_feed_gated_sids` — quando il feed di
decisione 1h di un asset è CONGELATO, gli sleeve CONCENTRATI (single/ml/pairs) che ne dipendono
**saltano il tick** (entry E exit) finché non si sblocca (come un outage; disaster-SL on-book =
protezione di coda). **Auto-guarente**: si rilascia alla prima barra COMPLETA non-flat (NON è
l'entry-guard post-flat bocciata: quella salta la candela-gap di ripresa, questo no). **Detector
guasto-vs-illiquido**: conta la run di close **INVARIATE** (prezzo mai cambiato), NON le barre flat
— sul feed reale ETH/BNB/DOGE hanno run 40-64 con 1-4 valori distinti/48h (MORTI) mentre
SOL/LTC/ADA sono flat ma VIVI (run ≤12, 5-31 valori/48h: si muovono ogni ~10 barre). Soglia
`feed_freeze_gate_bars=24` (1 giorno di prezzo immobile, configurabile, 0=off): gatea le **9 gambe
ETH esatte** (3 fade ETH 15m + SH_ETH + 5 pairs con gamba ETH) lasciando attivi BTC-only,
**PR_BTCLTC** (BTC vivo + LTC solo illiquido) e i multi-asset PAPER. Le posizioni ETH già aperte
NON vengono flattate (freeze=pausa: un close forzato passerebbe per lo stesso feed morto). Alert
Telegram `FEED_FROZEN_GATE` GATED/RIPRESO. Test `tests/portfolio/test_freeze_gate.py`, diario
`docs/diary/2026-06-15-frozen-feed-gate.md`. **Resta un cerotto testnet: il fix vero è mainnet.**
- **TIMING SWEEP pairs & honest (2026-06-14, NESSUN deploy).** Domanda utente: pairs/honest
beneficiano di TF più veloci come le fade (swap 1h→15m)? Esito: **no, niente deployato.** Vincolo
dati (alt 1h-only) → sweepabili solo **pairs ETH/BTC** e **DIP01 (BTC)**; TR01/ROT02 fuori scope
+85
View File
@@ -0,0 +1,85 @@
# 2026-06-15 — Gate del feed CONGELATO (freeze gate)
## Problema (da "stato ordini")
Audit degli ordini live: 5 sleeve in posizione reale, **4-5 contaminati dal feed ETH
congelato**. `ETH-PERPETUAL` (inverse, il feed di DECISIONE del runner) è fermo a
**1661.95 da 36h+** (100% barre flat, 57/57 nel windows 2g, **1 solo valore distinto**),
mentre il book reale dove si ESEGUE (`ETH_USDC-PERPETUAL`, lineare) si muove ~1717-1722.
Effetto misurato sui soldi reali (log container):
- **SH01_ETH** ha aperto short a sim 1661.95 / real 1717.10 (slippage 332 bps), chiuso a
time_limit con `sim_exit 1661.95` ma `real_fill 1719.21`**2.83 USD reali vs 0.09 sim**
→ e ha **riaperto subito** lo stesso short sulla trappola.
- I 4 pairs con gamba ETH (ETH/BTC 1h+15m, ETH/SOL, LTC/ETH, ADA/ETH) sono entrati con
z-score **estremi** (3.10, 2.96, +5.58, 2.56): artefatto MECCANICO del log-ratio con
ETH pinnato a 1661.95 mentre gli alt si muovono → falsa divergenza → carico di spread long-ETH.
È un GUASTO testnet, non di strategia: in mainnet l'arbitraggio tiene inverse ≈ lineare ≈ realtà.
## Decisione utente
> "congela gli sleeve ETH-leg finché il feed non si sblocca"
## Soluzione: gate del feed congelato nel runner (auto-guarente)
`src/portfolio/runner.py`: quando il feed di decisione 1h di un asset è **congelato**, gli
sleeve CONCENTRATI (single/ml/pairs) che ne dipendono **saltano il tick** (entry E exit)
finché il feed non si sblocca. Come un outage (i worker non valutano gli exit, protezione =
disaster-SL on-book). Si rilascia DA SOLO alla prima barra completa non-flat.
### Distinguere il GUASTO dall'ILLIQUIDITÀ (il punto critico)
Un detector flat-bar ingenuo sarebbe SBAGLIATO: sul feed reale (2026-06-15) gli alt sono
flat ma VIVI. Firma misurata (run finale di close invariate / valori distinti in 48h):
| Asset | run | distinti/48h | natura |
|-------|-----|--------------|--------|
| ETH | 64 | **1** | MORTO (guasto) |
| BNB | 64 | 2 | MORTO (testnet, solo paper) |
| DOGE | 42 | 4 | MORTO (testnet, solo paper) |
| XRP | 12 | 3 | illiquido |
| ADA | 11 | 5 | illiquido ma VIVO |
| LTC | 10 | 5 | illiquido ma VIVO |
| SOL | 1 | 31 | VIVO |
→ il detector conta la **run di close INVARIATE** (prezzo che non cambia MAI), non le barre
flat. Soglia **24** (`feed_freeze_gate_bars`, configurabile, 0=off): un giorno intero di
prezzo immobile separa nettamente le due popolazioni. ETH/BNB/DOGE (run 40-64) gateati,
ADA/LTC/XRP/SOL (run ≤12) no. **PR_BTCLTC resta attivo** (BTC vivo + LTC solo illiquido NON
deve sospendere il pair: si perderebbero segnali reali sulla gamba BTC).
### NON è l'entry-guard post-flat (BOCCIATA)
Si gatea **DURANTE** il freeze (ultima barra completa ferma). La barra di RIPRESA è non-flat
→ la run si azzera → il tick riprende SU di essa. NON si salta la candela-gap di ripresa
(quella è l'overshoot che la fade fada con profitto — l'entry-guard post-flat è bocciata,
CLAUDE.md / 2026-06-05). Rilascio solo su barra **completata** non-flat (la forming non basta,
coerente con EXIT-16).
### Scope
Solo sleeve `single`/`ml`/`pairs`. I multi-asset (basket/rotation/tsmom/xsec, tutti PAPER)
NON sono gateati: diversificati su 8 asset, un feed fermo non li compromette (e ROT02 ha ETH
nell'universo ma resta attivo). Osservabilità: log `FEED_FROZEN_GATE` + alert Telegram
GATED/RIPRESO, dedup per episodio.
## Esito (smoke feed reale, soglia 24)
- CONGELATI: ETH, BNB, DOGE.
- **GATEATI: le 9 gambe ETH esatte** — MR01/MR02/MR07_ETH, SH_ETH, PR_ETHBTC, PR_ETHBTC_15M,
PR_LTCETH, PR_ADAETH, PR_ETHSOL. BTC-only, PR_BTCLTC e i multi-asset restano attivi.
Le 5 posizioni ETH già aperte NON vengono flattate (freeze = pausa, non liquidazione): un
close forzato passerebbe per lo stesso feed congelato (= il close 2.83 di SH01). Restano
gestite alla ripresa del feed; protezione di coda = disaster-SL on-book a 30%.
Test: `tests/portfolio/test_freeze_gate.py` (detector guasto-vs-illiquido, rilascio su barra
completata, scope sui veri sleeve PORT06). Suite portfolio: 140/140 verdi.
## Lezione
Tre livelli di osservabilità del feed ora distinti: STALE_FEED (≥2 barre flat, alert umano),
FEED_BOOK_GAP (feed≠book d'esecuzione, alert), **FEED_FROZEN_GATE (≥24 barre invariate, AGISCE)**.
La firma che separa guasto da illiquidità è la **run di prezzo invariato**, non la barra flat:
gli alt illiquidi stampano barre flat ma il prezzo si muove ogni ~10 barre.
+8
View File
@@ -14,6 +14,14 @@ overrides:
leverage: 3
rebalance: 1D
poll_seconds: 60
# Gate feed CONGELATO (2026-06-15): salta entry+exit degli sleeve concentrati
# (single/ml/pairs) il cui feed di DECISIONE 1h e' fermo da >= N barre di close
# INVARIATA (guasto testnet: ETH-PERPETUAL a 1661.95 per 36h+ -> z-score pairs
# spuri e perdite reali, i fill avvengono al book vero). Distingue il guasto
# dall'illiquidita' (alt flat ma che si muovono ogni ~10 barre). Auto-guarente:
# si rilascia alla prima barra completa non-flat. 24 = un giorno di prezzo
# immobile (margine pulito tra le due popolazioni). 0 = disattivo.
feed_freeze_gate_bars: 24
# SLEEVE PAPER (2026-06-08): fuori dal pool/pesi/ledger — i €2000 si dividono SOLO
# tra i 14 sleeve REALI (fade+DIP01+pairs+SH01). I multi-asset (no esecuzione reale,
# bloccati dal capitale) girano in data/portfolio_paper_stats/ con capitale nozionale
+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:
+70
View File
@@ -0,0 +1,70 @@
"""Gate feed CONGELATO (2026-06-15): salta entry+exit degli sleeve concentrati
(single/ml/pairs) il cui feed di DECISIONE 1h e' fermo (guasto, es. ETH-PERPETUAL
a 1661.95 per 36h+). Distingue il GUASTO (close mai cambiata per decine di barre)
dall'ILLIQUIDITA' (SOL/LTC/ADA: molte barre flat ma il prezzo si muove -> run corte).
Auto-guarente: la barra di ripresa e' non-flat -> il gate si rilascia da solo."""
import pandas as pd
from src.portfolio.runner import _frozen_assets, _feed_gated_sids
def _df(completed, forming):
"""Serie 1h: barre complete (flat = O=H=L=C; non-flat quando il prezzo cambia)
+ una candela in formazione (ultima riga, esclusa dal detector)."""
end_ms = int(pd.Timestamp.now(tz="UTC").floor("h").timestamp() * 1000)
vals = list(completed) + [forming]
n = len(vals)
rows = []
for j, v in enumerate(vals):
ts = end_ms - (n - 1 - j) * 3_600_000
rows.append({"timestamp": ts, "open": v, "high": v, "low": v, "close": v, "volume": 0.0})
for j in range(1, n): # bar NON-flat quando il prezzo si muove
if rows[j]["close"] != rows[j - 1]["close"]:
rows[j]["high"] = rows[j]["close"] * 1.001
return pd.DataFrame(rows)
def test_frozen_feed_detected():
raw = {"ETH": _df([1661.95] * 40, 1661.95)} # prezzo MAI cambiato (guasto)
assert _frozen_assets(raw, 12) == {"ETH"}
def test_live_feed_not_frozen():
raw = {"BTC": _df([65000 + i * 5 for i in range(40)], 65200.0)} # sempre in movimento
assert _frozen_assets(raw, 12) == set()
def test_illiquid_but_alive_not_frozen():
# ultima barra completa FLAT ma il prezzo si e' mosso da poche barre (run < soglia):
# alt illiquido (SOL/LTC/ADA), NON un guasto -> non gateato.
raw = {"LTC": _df([44.0] * 30 + [45.0] * 5, 45.0)}
assert _frozen_assets(raw, 12) == set()
def test_resume_releases_gate():
# rilascio solo su una barra COMPLETATA non-flat (la forming non basta -> coerente
# con la valutazione su barre settled, EXIT-16). NON e' l'entry-guard post-flat: la
# barra di ripresa NON viene saltata, il tick riprende su di essa.
raw = {"ETH": _df([1661.95] * 40 + [1700.0], 1700.0)}
assert _frozen_assets(raw, 12) == set()
# finche' la ripresa e' solo nella candela in formazione, resta congelato
assert _frozen_assets({"ETH": _df([1661.95] * 40, 1700.0)}, 12) == {"ETH"}
def test_threshold_zero_disables():
raw = {"ETH": _df([1661.95] * 40, 1661.95)}
assert _frozen_assets(raw, 0) == set()
def test_gated_scope_matches_eth_legs():
"""Sui veri sleeve PORT06: con ETH congelato si gatano TUTTE e sole le gambe ETH
(3 fade ETH + SH_ETH + 5 pairs con gamba ETH). BTC-only, BTC/LTC e i multi-asset
(anche ROT02 che ha ETH nell'universo) restano attivi."""
from scripts.portfolios._defs import FADE, PAIRS, SHAPE, HONEST
specs = FADE + PAIRS + SHAPE + HONEST
g = _feed_gated_sids(specs, {"ETH"})
assert {"MR01_ETH", "MR02_ETH", "MR07_ETH", "SH_ETH",
"PR_ETHBTC", "PR_ETHBTC_15M", "PR_LTCETH", "PR_ADAETH", "PR_ETHSOL"} <= g
assert g.isdisjoint({"MR01_BTC", "MR02_BTC", "MR07_BTC", "SH_BTC",
"DIP01_BTC", "PR_BTCLTC"})
assert g.isdisjoint({"TR01_basket", "ROT02_rot"}) # multi-asset mai gateati
assert _feed_gated_sids(specs, set()) == set() # nessun congelato -> nessun gate