diff --git a/CLAUDE.md b/CLAUDE.md index 152700e..c7a5447 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/diary/2026-06-15-frozen-feed-gate.md b/docs/diary/2026-06-15-frozen-feed-gate.md new file mode 100644 index 0000000..e908069 --- /dev/null +++ b/docs/diary/2026-06-15-frozen-feed-gate.md @@ -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. diff --git a/portfolios.yml b/portfolios.yml index 9fb967e..b7ec908 100644 --- a/portfolios.yml +++ b/portfolios.yml @@ -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 diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 9f424e6..36f4bba 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -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: diff --git a/tests/portfolio/test_freeze_gate.py b/tests/portfolio/test_freeze_gate.py new file mode 100644 index 0000000..c53daa5 --- /dev/null +++ b/tests/portfolio/test_freeze_gate.py @@ -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