From 82c05f6f8134733838d36180f3dd432a57e628e4 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 11 Jun 2026 21:36:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(exec):=20code-review=20serale=20=E2=80=94?= =?UTF-8?q?=20guard=20anti-posizione-nuda=20sul=20netting=20+=20verit?= =?UTF-8?q?=C3=A0=20per-frazione?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 finder paralleli sul diff della giornata (8adf388..HEAD), fix dei confermati: CRITICI (money-path): - close_amount: GUARD stato-stantio sul fallback netting — il residuo non-reduce-only e' consentito solo fino al gap (conto reale - libri degli altri worker - orfani) nella direzione della chiusura (src/live/books.py = fonte unica, usata anche dal reconciler). Senza guard, un close su stato stantio (DSL scattato in outage, flatten manuale) APRIVA una posizione nuda a taglia piena bookata come 'chiusura verificata'. Fail-safe se il gap non e' calcolabile. Check polvere PRIMA di _quantize_step (che clampa al lotto minimo: un residuo 1e-17 diventava un ordine nudo da un lotto). - _real_close: market_amt = filled_amount anche a merged verified=False (i contratti chiusi dal reduce-only non si buttano se il leg netting fallisce); REAL_CLOSE_PARTIAL non piu' gateato su verified (era soppresso proprio nel caso parziale reale). - pairs: verita' per-FRAZIONE di gamba (gross proporzionale al fillato, orfano = solo il residuo — prima falsava reconciler e real_capital della parte gia' chiusa); REAL_OPEN_PAIR booka filled_amount; docstring applied corretta. - open_pair unwind: chiude il FILLATO, non il richiesto (senza il cap silenzioso del reduce-only avrebbe mangiato quota altrui). - place_tp_limit: quantize CONSERVATIVO (sell=floor/buy=ceil) — il rounding al tick piu' vicino poteva mettere il resting oltre il TP sim -> tocco genuino classificato phantom sistematicamente. ROBUSTEZZA/OSSERVABILITA': - runner: WORKER_ERROR_STREAK a 5 e poi ogni 50 poll + recovery RIPRESO + flag real_in_position nell'alert (prima: one-shot a n==5, poi silenzio). - _tp_phantom: TTL 120s sul verdetto (era ~50 HTTP/h per worker a barra fantasma); merge notes con entrambi gli order_id (audit-trail). - reset_flatten: _quantize_step Decimal (round float produceva amount che Deribit rifiuta); hourly_report: book XS01 con gambe SHORT visibili (abs). - validate_xsec_worker: POS/LEV importati (non hardcoded); xs01_tranche: regression-lock ESEGUITO vs xsec_sim; reconcile su src/live/books; drift_monitor: rolling vettoriale + exit code 1 su warn. Test: +4 guard/dust, fixture filled_amount -> 114 passed. Deferiti (TODO): resting esposti al netting, lifecycle orphan_legs, finestra trade-history TP_PHANTOM, validazione feed a monte, dedup minori. Co-Authored-By: Claude Fable 5 --- docs/TODO.md | 27 +++++++++ scripts/analysis/drift_monitor.py | 8 ++- scripts/analysis/reconcile_account.py | 59 ++------------------ scripts/analysis/reset_flatten.py | 6 +- scripts/analysis/validate_xsec_worker.py | 7 ++- scripts/analysis/xs01_tranche_research.py | 22 ++++++++ scripts/portfolios/hourly_report.py | 4 +- src/live/books.py | 67 +++++++++++++++++++++++ src/live/execution.py | 65 +++++++++++++++++++--- src/live/pairs_worker.py | 47 +++++++++++----- src/live/strategy_worker.py | 27 ++++++--- src/portfolio/runner.py | 21 +++++-- tests/portfolio/test_netting_close.py | 51 ++++++++++++++++- tests/portfolio/test_pairs_execution.py | 3 +- tests/portfolio/test_real_truth.py | 3 +- 15 files changed, 321 insertions(+), 96 deletions(-) create mode 100644 src/live/books.py diff --git a/docs/TODO.md b/docs/TODO.md index ed6876f..2bb93bd 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -65,6 +65,33 @@ - [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE): ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config. +## Code-review 2026-06-11 sera (8a2b065..) — finding DEFERITI (i confermati critici sono fixati in v1.1.26) + +- [ ] **RESTING reduce-only esposti al netting** (TP limit + disaster-SL): se un worker opposto + apre DOPO il piazzamento, il resting può fillare parziale o essere respinto — il fallback + netting copre solo i close sincroni. È il pezzo "position manager / sotto-conti" già noto; + lo scenario peggiore è il disaster-SL cappato proprio nel crash per cui esiste. +- [ ] **Lifecycle `orphan_legs`**: append-only — un orfano risolto a mano (o da reset_flatten) + resta nello status e MASCHERA per compensazione un drift vero futuro (falso negativo del + reconciler). Serve un comando di risoluzione (`--resolve-orphan`) e/o reset_flatten che + azzeri anche lo stato reale nei status.json. Idem: il PnL della chiusura manuale di un + orfano non viene mai bookato in real_capital (diagnostica shadow divergente). +- [ ] **TP_PHANTOM residuo**: `resting_fills` guarda solo le ultime 100 righe di trade-history + per strumento — su conto molto attivo un fill TP reale può scivolare fuori finestra → + falso phantom persistente (sim resta in posizione, reale flat). Mitigato dal quantize + conservativo (v1.1.26); fix vero = endpoint order-state in cerbero-mcp. +- [ ] **Validazione feed a monte** (altitudine): TP_PHANTOM copre solo i tocchi TP dei single-leg; + le ENTRY spike-driven, lo SL close-confirm su close spike e lo z dei pairs restano esposti + ai wick fantasma del feed testnet. Un validatore barre nel data layer coprirebbe tutti i + consumer con un meccanismo solo (su mainnet il fenomeno non esiste: priorità bassa finché + si resta su testnet, gli eventi TP_PHANTOM/NET_CLOSE ne misurano la frequenza). +- [ ] **Contratto dello schema status.json**: reconcile (`src/live/books.py`), hourly_report e i + worker condividono lo schema per convenzione implicita — books.py è ora la fonte unica per + i campi `real_*`, ma un helper `worker.real_book()` usato da _save e dai reader chiuderebbe + la classe di bug. Pulizia: `_tp_hit` helper per i 4 siti di tocco TP duplicati; port_metrics + ri-biforcato in xs01_tranche_gate/pairs30m_gate (importare da _port06_gate_common); + splits3/metriche duplicate nei games engine (estrarre in scripts/games/engine.py). + ## Ricerca dispersion/correlation (2026-06-08, 165 agenti) — follow-up opzionale - [x] ~~Gate PORT06 di `index_comp_disp` W=168~~ — FATTO (2026-06-08): PROMOSSO MARGINALE. diff --git a/scripts/analysis/drift_monitor.py b/scripts/analysis/drift_monitor.py index ea4c5f6..0232226 100644 --- a/scripts/analysis/drift_monitor.py +++ b/scripts/analysis/drift_monitor.py @@ -55,7 +55,9 @@ def drift_rows(): rows = [] for name, r in family_returns().items(): for win in WINDOWS: - roll = (1 + r).rolling(win).apply(np.prod, raw=True) - 1 + # vettoriale (log1p+rolling sum) invece di apply(np.prod): identico + # numericamente, ~100x piu' veloce del callback Python per-finestra + roll = np.expm1(np.log1p(r).rolling(win).sum()) roll = roll.dropna() if len(roll) < 100: continue @@ -99,4 +101,6 @@ def main(): if __name__ == "__main__": - main() + # exit code 1 su warn: utilizzabile da cron/script come canale d'allarme + # (coerente con reconcile_account; prima il bool era calcolato e buttato via) + sys.exit(1 if main() else 0) diff --git a/scripts/analysis/reconcile_account.py b/scripts/analysis/reconcile_account.py index d7f4f86..f713822 100644 --- a/scripts/analysis/reconcile_account.py +++ b/scripts/analysis/reconcile_account.py @@ -20,7 +20,6 @@ Nessun ordine, nessuna modifica di stato: SOLO lettura. """ from __future__ import annotations -import json import sys import time from pathlib import Path @@ -30,63 +29,17 @@ sys.path.insert(0, str(PROJECT_ROOT)) from src.live.cerbero_client import CerberoClient from src.live.execution import contract_spec +from src.live.books import real_books, account_net # fonte UNICA dei libri (usata + # anche dal guard del netting) -PAPER = PROJECT_ROOT / "data" / "portfolio_paper" RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto -def _inst(asset: str) -> str: - return f"{asset}_USDC-PERPETUAL" - - -def expected_books() -> tuple[dict[str, float], dict[str, float]]: - """(atteso per strumento dai libri, quota orfana registrata per strumento). - Amount firmato in base-coin: buy=+, sell=-.""" - books: dict[str, float] = {} - orphans: dict[str, float] = {} - for sp in sorted(PAPER.glob("*/status.json")): - st = json.loads(sp.read_text()) - wid = sp.parent.name - parts = wid.split("__") - if st.get("real_in_position"): - if "real_amount_a" in st and st.get("real_amount_a"): - # pairs: asset da worker_id (…__ETH_BTC__1h) - a, b = parts[1].split("_") - for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]), - (b, st["real_side_b"], st["real_amount_b"])): - sgn = 1 if side == "buy" else -1 - books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt - elif st.get("real_amount"): - # single-leg: fade/DIP01/SH01 - sgn = 1 if st.get("real_side") == "buy" else -1 - books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"] - for o in st.get("orphan_legs", []): - # gamba che il libro ha chiuso ma il conto ha ancora (entry_side = verso - # in cui la posizione e' rimasta aperta sul conto) - sgn = 1 if o.get("entry_side") == "buy" else -1 - orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"]) - return books, orphans - - -def account_positions(client: CerberoClient) -> dict[str, float]: - """Posizioni reali per strumento, amount firmato in base-coin.""" - out: dict[str, float] = {} - for p in client.get_positions(currency="USDC") or []: - inst = p.get("instrument") - size = float(p.get("size") or 0) - mark = float(p.get("mark_price") or 0) - if not inst or not size or not mark: - continue - amt = size / mark - out[inst] = amt if p.get("direction") == "long" else -amt - return out - - -def compute_drift() -> list[dict]: - client = CerberoClient() - books, orphans = expected_books() - acct = account_positions(client) +def compute_drift(client: CerberoClient | None = None) -> list[dict]: + client = client or CerberoClient() + books, orphans = real_books() + acct = account_net(client) rows = [] for inst in sorted(set(books) | set(orphans) | set(acct)): exp = books.get(inst, 0.0) + orphans.get(inst, 0.0) diff --git a/scripts/analysis/reset_flatten.py b/scripts/analysis/reset_flatten.py index 80dc7b0..3cb6e51 100644 --- a/scripts/analysis/reset_flatten.py +++ b/scripts/analysis/reset_flatten.py @@ -44,7 +44,11 @@ def main(): step = contract_spec(inst)["step"] entry_side = "buy" if p["direction"] == "long" else "sell" units = size_usd / mark if mark else 0 - amount = round(units / step) * step + # _quantize_step (Decimal): round(units/step)*step in float binario + # produce artefatti (0.07200000000000001) che Deribit rifiuta con + # 'Invalid params' — il bug e' documentato in execution.py + from src.live.execution import _quantize_step + amount = _quantize_step(units, step, contract_spec(inst)["min"]) if units >= step / 2 else 0.0 if amount <= 0: print(f" {inst}: residuo {size_usd:.2f} USD sotto mezzo step, ok") continue diff --git a/scripts/analysis/validate_xsec_worker.py b/scripts/analysis/validate_xsec_worker.py index 3173a67..179e1ba 100644 --- a/scripts/analysis/validate_xsec_worker.py +++ b/scripts/analysis/validate_xsec_worker.py @@ -58,8 +58,11 @@ def main(): print(f" ESITO: {'OK (replay == backtest)' if ok1 else 'DIFF -> INDAGARE'}") # [2] K=3 (tranching live): parita' con l'unione delle fasi 0,4,8 (gate - # xs01_tranche_gate) — capitale comune, PnL/K per trade in ordine di exit + # xs01_tranche_gate) — capitale comune, PnL/K per trade in ordine di exit. + # POS/LEV importati dal modulo canonico, NON hardcoded: una costante stantia + # qui farebbe passare la validazione contro un sistema diverso (code-review) from scripts.analysis.xs01_tranche_research import xsec_trades + from scripts.strategies.XS01_cross_sectional import POS, LEV K = 3 w3 = _replay(M, dfs, {"lb": LB, "hold": HOLD, "tranches": K}) step = HOLD // K @@ -67,7 +70,7 @@ def main(): key=lambda t: t[1]) cap = 1000.0 for _, _, net in allt: - cap = max(cap + cap * 0.15 * 3.0 * net / K, 10.0) + cap = max(cap + cap * POS * LEV * net / K, 10.0) cap_ok3 = abs(w3.capital - cap) / cap < 0.02 trd_ok3 = abs(w3.total_trades - len(allt)) <= max(2, len(allt) * 0.02) print(f"\n [K=3] {'':<6}{'cap':>14}{'trades':>8}") diff --git a/scripts/analysis/xs01_tranche_research.py b/scripts/analysis/xs01_tranche_research.py index 075a52b..418b53c 100644 --- a/scripts/analysis/xs01_tranche_research.py +++ b/scripts/analysis/xs01_tranche_research.py @@ -102,6 +102,27 @@ def combined_metrics(branches, ts, yrs_span): sharpe=sharpe, dd=float(((pk - eq) / pk).max() * 100)) +def regression_lock(M=None): + """xsec_trades(phase=0) DEVE riprodurre xsec_sim (n trade e ret totale): + la copia e' load-bearing (la usano gate e validate_xsec_worker) — senza + lock eseguito un fix all'engine canonico non si propagherebbe e la + validazione live certificherebbe un engine stantio (code-review 2026-06-11).""" + from scripts.strategies.XS01_cross_sectional import xsec_sim + if M is None: + M = aligned_panel() + ts = pd.to_datetime(M.index, unit="ms", utc=True) + tr = xsec_trades(phase=0, M=M) + eq = equity_from(tr, ts) + ref = xsec_sim() + n_ok = len(tr) == ref["trades"] + ret = (eq.iloc[-1] / 1000 - 1) * 100 if len(eq) else 0.0 + ret_ok = abs(ret - ref["ret"]) < 1e-6 * max(1.0, abs(ref["ret"])) + if not (n_ok and ret_ok): + raise AssertionError(f"REGRESSION-LOCK FALLITO: trades {len(tr)} vs {ref['trades']}, " + f"ret {ret:.6f} vs {ref['ret']:.6f}") + print(f" regression-lock OK: {len(tr)} trade, ret {ret:+.2f}% == xsec_sim") + + def run(): M = aligned_panel() ts = pd.to_datetime(M.index, unit="ms", utc=True) @@ -109,6 +130,7 @@ def run(): print("=" * 96) print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata") print("=" * 96) + regression_lock(M) for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)): yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1 rows = [] diff --git a/scripts/portfolios/hourly_report.py b/scripts/portfolios/hourly_report.py index 709528a..65d8bc3 100644 --- a/scripts/portfolios/hourly_report.py +++ b/scripts/portfolios/hourly_report.py @@ -154,7 +154,9 @@ def multi_asset_section() -> str: book[a] = book.get(a, 0.0) + v / k if book is None: continue # single-leg/pairs: gia' coperti da collect() - held = {a: v for a, v in book.items() if v > 0} + # abs(): il book XS01 e' long/short market-neutral — col filtro v>0 le + # gambe SHORT sparivano e un book net-short appariva "flat" nel report + held = {a: v for a, v in book.items() if abs(v) > 1e-4} flip = "mai" tp = d / "trades.jsonl" if tp.exists(): diff --git a/src/live/books.py b/src/live/books.py new file mode 100644 index 0000000..652f29d --- /dev/null +++ b/src/live/books.py @@ -0,0 +1,67 @@ +"""Libri REALI per strumento dai status.json persistiti dei worker — fonte unica. + +Usato da: + - scripts/analysis/reconcile_account.py (audit orario conto vs libri) + - ExecutionClient.close_amount (guard del netting: il residuo non-reduce-only + e' consentito solo fino al gap conto-vs-libri-altrui — senza questo guard un + close su stato stantio APRIREBBE una posizione nuda, code-review 2026-06-11) + +Convenzione: amount firmato in base-coin (buy=+, sell=-), strumenti USDC lineari. +""" +from __future__ import annotations + +import json +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +PAPER = PROJECT_ROOT / "data" / "portfolio_paper" + + +def _inst(asset: str) -> str: + return f"{asset}_USDC-PERPETUAL" + + +def real_books(exclude_worker: str | None = None) -> tuple[dict[str, float], dict[str, float]]: + """(libri per strumento, orfani registrati per strumento), firmati. + + exclude_worker: worker_id da ESCLUDERE dai libri (per il guard del netting: + il close del worker X deve portare il conto al netto degli ALTRI). Gli + orphan_legs NON vengono mai esclusi: sono posizioni che il conto ha ancora + indipendentemente da chi le ha originate.""" + books: dict[str, float] = {} + orphans: dict[str, float] = {} + for sp in sorted(PAPER.glob("*/status.json")): + wid = sp.parent.name + try: + st = json.loads(sp.read_text()) + except Exception: + continue + if st.get("real_in_position") and wid != exclude_worker: + parts = wid.split("__") + if st.get("real_amount_a"): + a, b = parts[1].split("_") # pairs: …__ETH_BTC__1h + for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]), + (b, st["real_side_b"], st["real_amount_b"])): + sgn = 1 if side == "buy" else -1 + books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt + elif st.get("real_amount"): + sgn = 1 if st.get("real_side") == "buy" else -1 + books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"] + for o in st.get("orphan_legs", []): + sgn = 1 if o.get("entry_side") == "buy" else -1 + orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"]) + return books, orphans + + +def account_net(client) -> dict[str, float]: + """Posizioni reali per strumento dal conto (size USD / mark -> coin, firmato).""" + out: dict[str, float] = {} + for p in client.get_positions(currency="USDC") or []: + inst = p.get("instrument") + size = float(p.get("size") or 0) + mark = float(p.get("mark_price") or 0) + if not inst or not size or not mark: + continue + amt = size / mark + out[inst] = amt if p.get("direction") == "long" else -amt + return out diff --git a/src/live/execution.py b/src/live/execution.py index 516babe..53f1261 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -76,13 +76,24 @@ def _quantize_step(value: float, step: float, mn: float) -> float: return float(max(n_steps * Decimal(str(step)), Decimal(str(mn)))) -def quantize_price(instrument: str, price: float) -> float: +def quantize_price(instrument: str, price: float, side: str | None = None) -> float: """Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti - float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick).""" + float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick). + + side (code-review 2026-06-11): per un limit di USCITA il rounding al tick + piu' vicino puo' mettere il resting OLTRE il livello sim → un tocco esatto + del livello non filla MAI il resting e il gate TP_PHANTOM classificherebbe + phantom un tocco genuino, sistematicamente. 'sell' = floor (mai sopra il + livello), 'buy' = ceil (mai sotto): il resting e' sempre raggiungibile + quando il sim dichiara il tocco (costo: <=1 tick di PnL vs sim).""" + import math tick = contract_spec(instrument).get("tick") if not tick or price <= 0: return price - return float(round(price / tick) * Decimal(str(tick))) + n = (math.floor(price / tick) if side == "sell" + else math.ceil(price / tick) if side == "buy" + else round(price / tick)) + return float(n * Decimal(str(tick))) def notional_to_amount(instrument: str, notional_usd: float, @@ -143,8 +154,8 @@ def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill: ro.fee_coin + net.fee_coin, ro.fee_usd + net.fee_usd, net.order_id or ro.order_id, net.order_state or ro.order_state, verified, raw={"reduce_only": ro.raw, "net": net.raw}, - notes=(f"netting: reduce-only {fa}/{requested}, residuo {fb} " - f"market puro ({net.order_state})"), + notes=(f"netting: reduce-only {fa}/{requested} (oid {ro.order_id}), " + f"residuo {fb} market puro ({net.order_state}, oid {net.order_id})"), filled_amount=tot) @@ -316,13 +327,48 @@ class ExecutionClient: fill = self._submit(instrument, opp, amount, 0.0, reduce_only=True, label=label) residual = amount - fill.filled_amount - residual = _quantize_step(residual, spec["step"], spec["min"]) if residual > 0 else 0.0 + # check sulla POLVERE prima di quantizzare: _quantize_step clampa al lotto + # minimo, quindi un residuo da artefatto float (1e-17) diventerebbe un + # ordine nudo da un lotto intero (code-review 2026-06-11) if residual < spec["step"] / 2: return fill + residual = _quantize_step(residual, spec["step"], spec["min"]) + # GUARD stato-stantio (code-review 2026-06-11): il residuo NON-reduce-only + # e' consentito solo fino al gap (conto reale − libri degli ALTRI worker − + # orfani) nella direzione della chiusura. Se la nostra quota non esiste + # piu' sul conto (disaster-SL scattato in outage, flatten manuale, stato + # stantio al resume) il gap e' ~0 → NIENTE ordine nudo: si ritorna il + # parziale onesto (il chiamante alza REAL_CLOSE_PARTIAL). Se il guard non + # e' calcolabile (rete) si resta FAIL-SAFE: meglio un orfano tracciato + # che una posizione nuda non tracciata. + allowed = self._net_close_allowance(instrument, opp, label) + if allowed is None or allowed < spec["step"] / 2: + fill.notes = (fill.notes + " | " if fill.notes else "") + ( + f"netting NEGATO: residuo {residual} ma gap conto-libri " + f"{'non calcolabile' if allowed is None else round(allowed, 6)} " + "(stato stantio? quota gia' chiusa?)") + return fill + if allowed < residual: + residual = _quantize_step(allowed, spec["step"], spec["min"]) net = self._submit(instrument, opp, residual, 0.0, reduce_only=False, label=f"{label}|net" if label else "net") return _merge_close_fills(fill, net, amount) + def _net_close_allowance(self, instrument: str, close_side: str, + self_worker: str | None) -> float | None: + """Quanto residuo non-reduce-only e' GIUSTIFICATO dai libri: gap firmato + fra il conto reale e (libri degli altri worker + orfani registrati), + proiettato nella direzione della chiusura. None = non calcolabile.""" + try: + from src.live.books import real_books, account_net + books, orphans = real_books(exclude_worker=self_worker) + target = books.get(instrument, 0.0) + orphans.get(instrument, 0.0) + pos = account_net(self.client).get(instrument, 0.0) + gap = pos - target # quota nostra ancora sul conto (firmata) + return max(0.0, gap if close_side == "sell" else -gap) + except Exception: + return None + def place_tp_limit(self, instrument: str, entry_side: str, amount: float, tp_price: float, label: str | None = None) -> Fill: """LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata): @@ -333,7 +379,7 @@ class ExecutionClient: TP il limit crossa e filla subito (state 'filled'); la riconciliazione a valle (resting_fills) lo gestisce come un fill normale.""" opp = "sell" if entry_side == "buy" else "buy" - px = quantize_price(instrument, tp_price) + px = quantize_price(instrument, tp_price, side=opp) if amount <= 0 or px <= 0: return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0, None, None, False, notes="amount/tp non validi") @@ -474,7 +520,10 @@ class PairsExecutionClient: unwound = False for f, inst in ((fa, inst_a), (fb, inst_b)): if f.verified and f.amount > 0: - self.leg.close_amount(inst, f.side, f.amount, label=label) + # unwind del FILLATO, non del richiesto: col fallback netting un + # amount sovrastimato non viene piu' cappato in silenzio dal + # reduce-only — chiuderebbe quota altrui (code-review 2026-06-11) + self.leg.close_amount(inst, f.side, f.filled_amount, label=label) unwound = True return PairFill(False, fa, fb, unwound=unwound, notes=f"leg-fail (a={fa.verified} b={fb.verified}), unwound={unwound}") diff --git a/src/live/pairs_worker.py b/src/live/pairs_worker.py index 476b0cc..b516bf8 100644 --- a/src/live/pairs_worker.py +++ b/src/live/pairs_worker.py @@ -224,7 +224,9 @@ class PairsWorker: self.real_in_position = True self.real_dir = d self.real_side_a, self.real_side_b = pf.leg_a.side, pf.leg_b.side - self.real_amount_a, self.real_amount_b = pf.leg_a.amount, pf.leg_b.amount + # amount FILLATO, non richiesto (coerente con strategy_worker, 2026-06-11) + self.real_amount_a = pf.leg_a.filled_amount or pf.leg_a.amount + self.real_amount_b = pf.leg_b.filled_amount or pf.leg_b.amount self.real_entry_a = pf.leg_a.fill_price or sim_a self.real_entry_b = pf.leg_b.fill_price or sim_b self.real_notional_a = pf.leg_a.amount * self.real_entry_a @@ -240,11 +242,12 @@ class PairsWorker: def _real_close_pair(self, sim_a: float, sim_b: float, reason: str, sim_pnl: float) -> tuple[float | None, bool]: - """Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia - PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo. + """Chiusura REALE shadow: richiude entrambe le gambe (netting-aware), + riconcilia PnL reale per-gamba e fee, aggiorna il ledger reale parallelo. - Ritorna (real_pnl, applied): applied=True se almeno una gamba ha chiuso con - fill verificato (PnL reale utilizzabile come verita' in real-truth).""" + Ritorna (real_pnl, applied): applied=True SOLO se ENTRAMBE le gambe hanno + chiuso per intero con fill verificato — con una gamba orfana il "PnL dello + spread" non esiste e real-truth ricade sul sim DICHIARATO.""" if not self.real_in_position: return None, False pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a, @@ -257,12 +260,25 @@ class PairsWorker: # reale (3 PnL fantasma il 2026-06-11, gamba ETH orfana sul conto). # Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba # respinta diventa un ORFANO registrato (persistito) + alert Telegram. - ok_a, ok_b = bool(pf.leg_a.verified), bool(pf.leg_b.verified) + from src.live.execution import contract_spec for leg in (pf.leg_a, pf.leg_b): if "netting" in (getattr(leg, "notes", "") or ""): # reduce-only cappato/respinto, residuo in market puro (v1.1.25) self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes}) self._notify("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes}) + # verita' per-FRAZIONE di gamba (code-review 2026-06-11): una gamba puo' + # chiudere PARZIALMENTE (reduce-only cappato + netting negato/fallito) — + # si booka il gross della sola frazione FILLATA e l'orfano registra il + # solo RESIDUO (prima: gross binario tutto-o-niente e orfano a amount + # pieno, che falsava reconciler e real_capital della parte gia' chiusa). + filled_a = min(getattr(pf.leg_a, "filled_amount", 0.0), self.real_amount_a) + filled_b = min(getattr(pf.leg_b, "filled_amount", 0.0), self.real_amount_b) + step_a = contract_spec(self.inst_a).get("step", 0.001) + step_b = contract_spec(self.inst_b).get("step", 0.001) + ok_a = filled_a >= self.real_amount_a - step_a / 2 + ok_b = filled_b >= self.real_amount_b - step_b / 2 + frac_a = filled_a / self.real_amount_a if self.real_amount_a else 0.0 + frac_b = filled_b / self.real_amount_b if self.real_amount_b else 0.0 exit_a = pf.leg_a.fill_price or sim_a exit_b = pf.leg_b.fill_price or sim_b # PnL per gamba: dir A = +d (long ratio compra A), dir B = -d @@ -270,26 +286,31 @@ class PairsWorker: gross_a = da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a gross_b = db * (exit_b - self.real_entry_b) / self.real_entry_b * self.real_notional_b exit_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd - real_pnl = ((gross_a if ok_a else 0.0) + (gross_b if ok_b else 0.0) + real_pnl = (gross_a * frac_a + gross_b * frac_b - self.real_entry_fee - exit_fee) self.real_capital += real_pnl self.real_trades += 1 self._log("REAL_CLOSE_PAIR", { "reason": reason, "exit_a": exit_a, "exit_b": exit_b, "leg_a_ok": ok_a, "leg_b_ok": ok_b, + "filled_a": filled_a, "filled_b": filled_b, "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4), "entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5), "real_capital": round(self.real_capital, 4), "verified": pf.verified}) - for ok, inst, side, amt in ((ok_a, self.inst_a, self.real_side_a, self.real_amount_a), - (ok_b, self.inst_b, self.real_side_b, self.real_amount_b)): - if not ok and amt > 0: - orphan = {"instrument": inst, "entry_side": side, "amount": amt, + for ok, inst, side, amt, filled, step in ( + (ok_a, self.inst_a, self.real_side_a, self.real_amount_a, filled_a, step_a), + (ok_b, self.inst_b, self.real_side_b, self.real_amount_b, filled_b, step_b)): + residue = amt - filled + if not ok and residue >= step / 2: + orphan = {"instrument": inst, "entry_side": side, + "amount": round(residue, 8), "ts": datetime.now(timezone.utc).isoformat(), "reason": reason} self.orphan_legs.append(orphan) self._notify("PAIR_LEG_ORPHAN", { "worker": self.worker_id, **orphan, - "note": ("gamba NON chiusa (reduce-only respinto dal netting di " - "conto?): posizione orfana sul conto, intervento richiesto")}) + "note": ("gamba NON chiusa per il residuo indicato (netting " + "negato/fallito): posizione orfana sul conto — " + "risolvere e RIMUOVERE l'orfano dallo status")}) self.real_in_position = False self.real_dir = 0 self.real_side_a = self.real_side_b = "" diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index abf91b1..350e6bc 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -73,6 +73,7 @@ class StrategyWorker: self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito) self._tp_phantom_notified = False # alert Telegram una tantum per processo + self._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom self.worker_id = f"{strategy.name}__{asset}__{tf}" self.work_dir = data_dir / self.worker_id @@ -349,7 +350,9 @@ class StrategyWorker: # 0) disaster bracket: via dal book PRIMA di chiudere (se la cancel fallisce # lo stop potrebbe essere SCATTATO durante un outage: quota gia' chiusa → - # il market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False) + # il reduce-only a valle filla 0 e il GUARD del netting in close_amount + # nega il residuo non-reduce-only — gap conto-vs-libri ~0 → niente + # ordine nudo; REAL_CLOSE esce verified=False + REAL_CLOSE_PARTIAL) # NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della # cancel ('untriggered' = successo, verificato su testnet: il re-cancel da' # order_not_found). 'order_not_found' = ordine non piu' in book (probabile @@ -398,20 +401,21 @@ class StrategyWorker: if remainder >= step / 2: fill = self.executor.close_amount(self.exec_instrument, self.real_side, remainder, label=self.worker_id) - # amount FILLATO, non richiesto: un reduce-only cappato dal netting di conto - # (worker long e short sullo stesso strumento) filla MENO del chiesto — - # bookarlo pieno mentirebbe sul ledger (audit 2026-06-11: 0.078 vs 0.105) - market_amt = fill.filled_amount if (fill and fill.verified) else 0.0 + # amount FILLATO, non richiesto (audit 2026-06-11) — e si booka anche a + # verified=False: nel Fill merged dal netting filled_amount conta gia' + # solo i fill RISCONTRATI; azzerarlo quando il leg residuo fallisce + # butterebbe via contratti realmente chiusi (code-review 2026-06-11) + market_amt = fill.filled_amount if fill else 0.0 if fill and "netting" in (getattr(fill, "notes", "") or ""): # il reduce-only era cappato/respinto e il residuo e' andato in market # puro (netting contro quote opposte) — solo osservabilita' self._log("NET_CLOSE", {"note": fill.notes}) self._notify("NET_CLOSE", {"note": fill.notes}) - if fill and fill.verified and market_amt < remainder - step / 2: + if fill and market_amt < remainder - step / 2: data = {"requested": remainder, "filled": market_amt, "residuo_orfano": round(remainder - market_amt, 6), - "note": ("close reduce-only cappato dal netting di conto: quota " - "residua NON chiusa (direzioni opposte sullo strumento?)")} + "note": ("close non completato (netting negato/leg fallito): " + "quota residua NON chiusa — verificare col reconciler")} self._log("REAL_CLOSE_PARTIAL", data) self._notify("REAL_CLOSE_PARTIAL", data) @@ -489,6 +493,12 @@ class StrategyWorker: if (self.direction == 1 and current_price >= self.tp) or \ (self.direction == -1 and current_price <= self.tp): return False + # TTL: il wick fantasma resta nella barra in formazione per ~1h → senza + # cache sono ~45-55 HTTP consecutivi per worker per barra (code-review). + # 120s di validita': un fill reale tardivo viene visto al massimo 2 min dopo. + cache_bar, cache_t = self._tp_phantom_cache + if cache_bar == current_ts and time.monotonic() - cache_t < 120: + return True try: tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument, self.real_tp_order_id) @@ -496,6 +506,7 @@ class StrategyWorker: return False if tp_amt > 0: return False + self._tp_phantom_cache = (current_ts, time.monotonic()) if current_ts != self._tp_phantom_ts: self._tp_phantom_ts = current_ts data = {"tp": self.tp, "price": current_price, "direction": self.direction, diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index abed9d0..ea3a963 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -475,15 +475,28 @@ def run(config_path: str = "portfolios.yml"): def _tick_safe(s, w): try: _tick(s, w) - worker_err_streak.pop(s.sid, None) + n_prev = worker_err_streak.pop(s.sid, 0) + if n_prev >= 5: + # recovery dichiarato (code-review: alert one-shot senza + # ripresa = silenzio indistinguibile dal guasto) + from src.live.telegram_notifier import notify_event + notify_event("WORKER_ERROR_STREAK", + {"worker": s.sid, "status": "RIPRESO", + "dopo_streak": n_prev}) except Exception as e: n = worker_err_streak.get(s.sid, 0) + 1 worker_err_streak[s.sid] = n print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)") - if n == 5: + # alert a 5 e poi ogni 50 poll (~1h): un worker rotto per + # giorni non deve sparire dopo il primo Telegram. Include + # se ha una posizione REALE aperta (exit non valutati!) + if n == 5 or n % 50 == 0: from src.live.telegram_notifier import notify_event - notify_event("WORKER_ERROR_STREAK", - {"worker": s.sid, "streak": n, "errore": str(e)[:200]}) + notify_event("WORKER_ERROR_STREAK", { + "worker": s.sid, "streak": n, "errore": str(e)[:200], + "real_in_position": bool(getattr(w, "real_in_position", False)), + "in_position": bool(getattr(w, "in_position", False)), + "nota": "exit NON valutati per questo worker"}) for s in live_specs: _tick_safe(s, workers[s.sid]) diff --git a/tests/portfolio/test_netting_close.py b/tests/portfolio/test_netting_close.py index d5c66db..7068c3e 100644 --- a/tests/portfolio/test_netting_close.py +++ b/tests/portfolio/test_netting_close.py @@ -10,8 +10,9 @@ def _fill(inst, side, amount, filled, px, verified=True, state="filled", fee=0.0 f"oid-{side}-{filled}", state, verified, filled_amount=filled) -def _client_with(submits): - """ExecutionClient con _submit finto: consuma la lista `submits` e registra le chiamate.""" +def _client_with(submits, allowance=float("inf")): + """ExecutionClient con _submit finto e guard del netting stubbato (il guard + reale legge conto+libri via HTTP/filesystem; qui si inietta il gap).""" ec = ExecutionClient() calls = [] @@ -21,6 +22,7 @@ def _client_with(submits): return submits.pop(0)(instrument, side, amount) ec._submit = fake_submit + ec._net_close_allowance = lambda *a, **k: allowance return ec, calls @@ -80,3 +82,48 @@ def test_net_leg_also_fails_reports_partial(): assert len(calls) == 2 assert not f.verified assert abs(f.filled_amount - 0.05) < 1e-9 # solo il fillato vero nel ledger + + +def test_stale_state_guard_blocks_naked_order(): + # quota gia' chiusa (DSL in outage / flatten manuale): reduce-only filla 0 e + # il gap conto-vs-libri e' 0 -> NESSUN ordine market nudo (code-review 2026-06-11) + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0), + ], allowance=0.0) + f = ec.close_amount(INST, "buy", 0.105, label="w1") + assert len(calls) == 1 # solo il reduce-only, niente fallback + assert not f.verified and f.filled_amount == 0.0 + assert "netting NEGATO" in f.notes + + +def test_guard_unknown_fails_safe(): + # guard non calcolabile (rete): FAIL-SAFE, niente ordine nudo + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0), + ], allowance=None) + f = ec.close_amount(INST, "buy", 0.105, label="w1") + assert len(calls) == 1 + assert "non calcolabile" in f.notes + + +def test_guard_caps_residual_to_allowance(): + # gap parziale: il residuo nudo e' cappato a quanto i libri giustificano + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0), + lambda i, s, a: _fill(i, s, a, filled=a, px=1700.0), + ], allowance=0.027) + f = ec.close_amount(INST, "sell", 0.105, label="w1") + assert len(calls) == 2 + assert abs(calls[1]["amount"] - 0.027) < 1e-9 + assert not f.verified # 0.027 < 0.105: chiusura parziale onesta + assert abs(f.filled_amount - 0.027) < 1e-9 + + +def test_dust_residual_no_fallback(): + # residuo da artefatto float (1e-17): NON deve diventare un lotto minimo nudo + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=a - 1e-17, px=1700.0), + ]) + f = ec.close_amount(INST, "sell", 0.105, label="w1") + assert len(calls) == 1 + assert f.verified diff --git a/tests/portfolio/test_pairs_execution.py b/tests/portfolio/test_pairs_execution.py index bc5173e..9c75728 100644 --- a/tests/portfolio/test_pairs_execution.py +++ b/tests/portfolio/test_pairs_execution.py @@ -21,7 +21,8 @@ class FakePairsExec: def _fill(self, inst, side, price, amt=1.0, ok=True): return Fill(inst, side, 0.0, amt if ok else 0.0, price if ok else None, 0.0, 0.05 if ok else 0.0, "oid" if ok else None, - "filled" if ok else "error", ok) + "filled" if ok else "error", ok, + filled_amount=amt if ok else 0.0) def open_pair(self, inst_a, inst_b, direction, notional, label=None): side_a = "buy" if direction == 1 else "sell" diff --git a/tests/portfolio/test_real_truth.py b/tests/portfolio/test_real_truth.py index e68bfe2..fc6a762 100644 --- a/tests/portfolio/test_real_truth.py +++ b/tests/portfolio/test_real_truth.py @@ -134,7 +134,8 @@ class FakePairsExec: self.px = dict(open_a=open_a, open_b=open_b, close_a=close_a, close_b=close_b) def _fill(self, inst, side, amount, px): - return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True) + return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True, + filled_amount=amount) def open_pair(self, inst_a, inst_b, direction, notional, label=None): sa = "buy" if direction == 1 else "sell"