From 85043bf2d3155e89ec3cdc0b406ebba1248676f1 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 13 Jun 2026 09:43:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(reconcile):=20guard=20STALE=5FREAL=5FPOSIT?= =?UTF-8?q?ION=20=E2=80=94=20rileva=20posizioni=20reali=20non=20gestite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caso MR02_BTC 1h (2026-06-13): ritirato dallo swap a 15m mentre short reale -> short nudo (TP perso nel netting), reconciler cieco perche' lo status fermo contava ancora come libro. compute_stale_real_positions: worker con real_in_position e status.json fermo >15min = non gestito -> alert STALE_REAL_POSITION. Discriminante = staleness (venue-agnostico: cattura ritirati-da-swap/crashati). Gira dal cron host :40 (no rebuild). 3 test nuovi, suite 132 passed. Orfano chiuso a mano (testnet, +0.85 netto). Co-Authored-By: Claude Fable 5 --- docs/diary/2026-06-13-orphan-swap-guard.md | 41 +++++++++++++++++ scripts/analysis/reconcile_account.py | 52 ++++++++++++++++++++-- tests/portfolio/test_reconcile_resting.py | 37 +++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 docs/diary/2026-06-13-orphan-swap-guard.md diff --git a/docs/diary/2026-06-13-orphan-swap-guard.md b/docs/diary/2026-06-13-orphan-swap-guard.md new file mode 100644 index 0000000..e7e1326 --- /dev/null +++ b/docs/diary/2026-06-13-orphan-swap-guard.md @@ -0,0 +1,41 @@ +# 2026-06-13 — Orfano da swap: incidente + guard nel reconciler + +## Incidente + +Lo `stato trades` del mattino ha scoperto una **posizione reale orfana**: il +worker fade **MR02_BTC 1h** aveva aperto uno short reale (0.0028 BTC @ 64135.5) +ieri alle 15:01; lo **swap a 15m (v1.1.30, ~20:48) lo ha rimosso dal config +mentre era ancora in posizione** → da allora nessun runner lo gestiva. Stato: + +- conto Deribit: short 0.0028 BTC (il long di apertura del gemello 15m aveva + aperto e chiuso nettando via il resto) +- il **TP limit (63387.75) era sparito** dal book (cancellato durante il netting + della chiusura 15m) → short NUDO, protetto solo dal disaster-SL a +30% +- il **reconciler NON allarmava**: lo `status.json` del worker morto dichiarava + ancora `real_in_position: true` → conto == libri. Punto cieco: il reconciler + leggeva i libri dagli status ma non sapeva quali worker fossero VIVI. + +Chiusura manuale (testnet): buy 0.0028 reduce-only @63766.5 (~+$0.85 netto sullo +short), cancel disaster-SL, worker marcato flat, PnL bookato (real_capital +181.18→182.03). Conto verificato flat su BTC; SH01_ETH short intatto. + +## Guard implementato + +`reconcile_account.compute_stale_real_positions(max_age_min=15)`: segnala i +worker che dichiarano `real_in_position` ma il cui `status.json` è fermo da +oltre 15 min. Un worker vivo riscrive lo status a ogni poll (~60s) → la +**staleness** è il discriminante robusto e venue-agnostico (cattura +ritirati-da-swap, crashati, worker rimossi dal config). Alert Telegram +`STALE_REAL_POSITION` (con `--telegram`), incluso nell'exit code e nel verdetto. +Gira già al prossimo cron host (:40) — nessun rebuild (lo script gira dal +working tree). Test: `tests/portfolio/test_reconcile_resting.py` (stantio +flaggato / fresco no / flat-vecchio no). + +## Causa radice e direzione + +La feature `INIT_LINEAGE` di ieri trasferisce il *capitale* al gemello del nuovo +timeframe, ma non la *posizione*. Il guard di oggi è la **rete di sicurezza** +(rileva e allarma entro un'ora). La **prevenzione** vera — flattare/consegnare la +posizione reale del worker ritirato al boot del runner — resta da implementare +(va fatta lato runner, con cautela: piazza ordini reali all'avvio). Per ora: +swap a conto fade-flat quando possibile, e il reconciler copre il resto. diff --git a/scripts/analysis/reconcile_account.py b/scripts/analysis/reconcile_account.py index 5d34a18..23be32e 100644 --- a/scripts/analysis/reconcile_account.py +++ b/scripts/analysis/reconcile_account.py @@ -20,6 +20,7 @@ Nessun ordine, nessuna modifica di stato: SOLO lettura. """ from __future__ import annotations +import json import sys import time from pathlib import Path @@ -35,6 +36,8 @@ from src.live.books import real_books, account_net, expected_resting, PAPER RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto +STALE_REAL_MIN = 15 # un worker vivo aggiorna status.json ~ogni poll (60s); + # real_in_position con mtime oltre questo = NON gestito def compute_drift(client: CerberoClient | None = None) -> list[dict]: @@ -111,10 +114,36 @@ def compute_resting_drift(client: CerberoClient | None = None) -> list[dict]: return rows +def compute_stale_real_positions(max_age_min: int = STALE_REAL_MIN) -> list[dict]: + """Worker che dichiarano una posizione REALE ma il cui status.json e' fermo da + oltre max_age_min minuti = posizione NON gestita da nessun runner (caso + MR02_BTC 1h del 2026-06-12: ritirato dallo swap a 15m mentre short reale, con + TP perso nel netting -> short nudo su disaster-SL). Il drift di compute_drift + NON lo vede: lo status fermo conta ancora come 'libro' -> conto == libri. + Discriminante robusto e venue-agnostico: la STALENESS (un worker vivo scrive + a ogni poll), cattura ritirati-da-swap, crashati, worker rimossi dal config.""" + now = time.time() + out: list[dict] = [] + for sp in sorted(PAPER.glob("*/status.json")): + try: + st = json.loads(sp.read_text()) + except Exception: + continue + if not st.get("real_in_position"): + continue + age_min = (now - sp.stat().st_mtime) / 60.0 + if age_min > max_age_min: + out.append(dict(worker=sp.parent.name, age_min=round(age_min, 1), + real_amount=st.get("real_amount") or st.get("real_amount_a"), + real_side=st.get("real_side") or st.get("real_side_a", ""))) + return out + + def main(): client = CerberoClient() rows = compute_drift(client) resting = compute_resting_drift(client) + stale = compute_stale_real_positions() bad = [r for r in rows if not r["ok"]] bad_rest = [r for r in resting if r["status"] != "OK"] if bad or bad_rest: @@ -141,8 +170,25 @@ def main(): if not resting: print("(nessun ordine resting atteso ne' in book)") - print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest) - else f"⚠️ DRIFT PERSISTENTE (pos={len(bad)} resting={len(bad_rest)})") + print(f"\n{'worker stantio (pos. reale non gestita)':<45}{'amount':>10}{'eta_min':>9}") + for r in stale: + print(f"{r['worker']:<45}{(r['real_amount'] or 0):>10}{r['age_min']:>9.0f} ⚠️ STALE_REAL") + if not stale: + print("(nessuna posizione reale stantia)") + + print("\nESITO:", "OK — conto allineato ai libri" if not (bad or bad_rest or stale) + else f"⚠️ ANOMALIE (drift_pos={len(bad)} resting={len(bad_rest)} stale={len(stale)})") + + if stale and "--telegram" in sys.argv: + from src.live.telegram_notifier import notify_event + notify_event("STALE_REAL_POSITION", { + "worker": [{k: r.get(k) for k in ("worker", "real_amount", "real_side", + "age_min")} for r in stale], + "note": ("posizione REALE con status.json fermo da >%d min = worker non " + "gestito (ritirato da uno swap di timeframe / crashato): la " + "posizione e' nuda sul conto, flattare a mano. Caso MR02_BTC 1h " + "del 2026-06-12." % STALE_REAL_MIN)}) + print("[telegram] alert STALE_REAL_POSITION inviato") if (bad or bad_rest) and "--telegram" in sys.argv: from src.live.telegram_notifier import notify_event @@ -163,7 +209,7 @@ def main(): "(caso MR02_BTC 2026-06-12); MISSING = atteso ma non in book; " "STALE = in book senza libro corrispondente")}) print("[telegram] alert RESTING_DRIFT inviato") - return bool(bad or bad_rest) + return bool(bad or bad_rest or stale) if __name__ == "__main__": diff --git a/tests/portfolio/test_reconcile_resting.py b/tests/portfolio/test_reconcile_resting.py index 6573d7f..7918f67 100644 --- a/tests/portfolio/test_reconcile_resting.py +++ b/tests/portfolio/test_reconcile_resting.py @@ -94,3 +94,40 @@ def test_resting_stale_order_from_flat_worker(tmp_path, monkeypatch): rows = ra.compute_resting_drift(cl) assert len(rows) == 1 assert rows[0]["status"] == "STALE" and rows[0]["order_id"] == "OLD-7" + + +def _age(root: Path, wid: str, minutes: float): + """Invecchia lo status.json di un worker di `minutes` minuti (mtime).""" + import os + p = root / wid / "status.json" + t = p.stat().st_mtime - minutes * 60 + os.utime(p, (t, t)) + + +def test_stale_real_position_flagged(tmp_path, monkeypatch): + # worker con posizione reale ma status.json vecchio = non gestito (caso + # MR02_BTC 1h ritirato dallo swap a 15m mentre short reale) + ra = _setup(tmp_path, monkeypatch, { + "MR02_donchian_fade__BTC__1h": {"real_in_position": True, "real_amount": 0.0028, + "real_side": "sell"}}) + _age(ra.PAPER, "MR02_donchian_fade__BTC__1h", 800) + stale = ra.compute_stale_real_positions(max_age_min=15) + assert len(stale) == 1 + assert stale[0]["worker"] == "MR02_donchian_fade__BTC__1h" + assert stale[0]["real_amount"] == 0.0028 + + +def test_fresh_real_position_not_flagged(tmp_path, monkeypatch): + # worker vivo (status.json appena scritto) NON e' stantio anche se in posizione + ra = _setup(tmp_path, monkeypatch, { + "SH01_shape_ml__ETH__1h": {"real_in_position": True, "real_amount": 0.053, + "real_side": "sell"}}) + assert ra.compute_stale_real_positions(max_age_min=15) == [] + + +def test_flat_stale_worker_not_flagged(tmp_path, monkeypatch): + # worker vecchio MA flat: niente posizione reale -> non e' un orfano + ra = _setup(tmp_path, monkeypatch, { + "MR01_bollinger_fade__BTC__1h": {"real_in_position": False, "real_amount": 0.0}}) + _age(ra.PAPER, "MR01_bollinger_fade__BTC__1h", 800) + assert ra.compute_stale_real_positions(max_age_min=15) == []