From 429fa01c97a4f163ea384c7fce504c25920d8467 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 11 Jun 2026 20:05:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(exec):=20verit=C3=A0=20contabile=20sul=20ne?= =?UTF-8?q?tting=20=E2=80=94=20filled=5Famount,=20gambe=20orfane,=20tick?= =?UTF-8?q?=20isolato?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Da audit live (3 indagini parallele, diario 2026-06-11-system-audit.md): il modello quote-per-worker reduce-only si rompe con direzioni opposte sullo stesso strumento (pairs long ETH vs fade short ETH) — close cappati bookati pieni, 3 gambe pairs mai eseguite col PnL sim nel ledger reale, conto short 0.027 ETH oltre i libri (riallineato a mano + DSL orfano cancellato). - Fill.filled_amount (order.filled_amount): TUTTI i ledger usano il fillato reale - REAL_CLOSE_PARTIAL (log+alert) su close cappato; residuo orfano dichiarato - pairs: PnL solo per gambe verificate; gamba respinta -> orphan_legs persistito + alert PAIR_LEG_ORPHAN; applied solo con entrambe le gambe (else sim_fallback) - REAL_DIVERGENCE anche su jsonl (prima solo Telegram) - runner: tick isolato per-worker + WORKER_ERROR_STREAK a 5 fail consecutivi Strutturale APERTO (decisione utente, opzioni nel diario): position-manager centrale / sotto-conti per famiglia / status-quo monitorato. Test: +2 (partial-close, orphan-leg), fixture filled_amount -> 106 passed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 15 ++++++ docs/diary/2026-06-11-system-audit.md | 71 ++++++++++++++++++++++++++ src/live/execution.py | 21 +++++++- src/live/pairs_worker.py | 36 ++++++++++--- src/live/strategy_worker.py | 33 +++++++++--- src/portfolio/runner.py | 21 +++++++- tests/portfolio/test_real_close_dsl.py | 5 +- tests/portfolio/test_real_truth.py | 52 ++++++++++++++++++- tests/portfolio/test_tp_phantom.py | 1 + 9 files changed, 234 insertions(+), 21 deletions(-) create mode 100644 docs/diary/2026-06-11-system-audit.md diff --git a/CLAUDE.md b/CLAUDE.md index e99b3c1..6c94a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -451,6 +451,21 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN `PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET). Diario `docs/diary/2026-06-07-sweep-fixes.md`. +- **VERITÀ CONTABILE su netting di conto (v1.1.24, 2026-06-11, da audit live).** Il modello + "quote per-worker con reduce-only" si rompe quando worker dello stesso strumento hanno + direzioni OPPOSTE (pairs long ETH vs fade short ETH): Deribit cappa/respinge i reduce-only + in silenzio. L'audit ha trovato: close fillato 0.078 ma bookato 0.105 (`Fill.amount` era il + RICHIESTO), 3 gambe pairs mai eseguite col PnL sim sommato al ledger reale (gamba orfana sul + conto, ETH/SOL di fatto short nudo), e il conto short 0.027 ETH più dei libri (riallineato a + mano). Fix: (1) `Fill.filled_amount` (fonte `order.filled_amount`) usato da TUTTI i ledger; + (2) `REAL_CLOSE_PARTIAL` (log+alert) quando il close filla meno del residuo; (3) pairs: + PnL bookato SOLO per le gambe verificate, gamba respinta → `orphan_legs` persistito + + alert `PAIR_LEG_ORPHAN`, `applied` solo con ENTRAMBE le gambe (altrimenti sim_fallback + dichiarato); (4) `REAL_DIVERGENCE` anche su jsonl (prima solo Telegram); (5) runner: + tick isolato per-worker (un'eccezione non salta gli altri; alert `WORKER_ERROR_STREAK` a 5). + IRRISOLTO strutturale: finché pairs e fade condividono strumenti in direzioni opposte, le + chiusure possono orfanizzarsi — ora è VISIBILE (alert+persistito), non più silente. Opzioni + di design in docs/diary/2026-06-11-system-audit.md (decisione utente). - **TP_PHANTOM — il tocco TP va confermato dal book reale (v1.1.23, 2026-06-11).** Il feed testnet stampa wick anomali che (a) generano segnali fade su ETH e (b) "toccano" il TP intrabar della stessa barra: il sim bookava +4% fantasma a bars_held=0 e `_real_close` diff --git a/docs/diary/2026-06-11-system-audit.md b/docs/diary/2026-06-11-system-audit.md new file mode 100644 index 0000000..9f05b5c --- /dev/null +++ b/docs/diary/2026-06-11-system-audit.md @@ -0,0 +1,71 @@ +# 2026-06-11 — Audit di sistema + verità contabile sul netting (v1.1.24) + +Seconda tornata della giornata (dopo lo sweep strategie): analisi del SISTEMA live +(esecuzione, stato, feed, processo) con 3 indagini parallele + fix. + +## Audit live (dal reset 2026-06-10T21:24Z) + +- **Churn phantom-TP quantificato**: 20 round-trip fantasma sui fade ETH (2 finestre, + 13:06Z e 17:32-17:58Z, feed che stampava wick ~1640 con mercato a 1675-90 per 25 min). + Somma real_pnl −2.35 USD vs sim_pnl +80 USD: il real-truth ledger ha contenuto, il gate + TP_PHANTOM (v1.1.23) copre il pattern da stasera. +- **Il conto NON quadrava coi libri**: short 0.027 ETH in più (l'hedge long ETH del pair + ETH/SOL mangiato dai close reduce-only dei fade) e BTC flat con MR02_BTC convinto di + essere short (TP resting fillato da uno spike REALE del book a −3.8%, +6.6$ non bookati, + che il worker riconcilierà alla chiusura sim) col **disaster-SL residuo sul book a + posizione flat**. Bonifica eseguita: DSL `USDC-SLMB-26521` cancellato, riallineo + +0.027 ETH reduce-only (fill 1682.5) → conto == libri (verificato). + +## Root-cause strutturale + +Le **quote per-worker con ordini reduce-only su un conto a NETTING si rompono quando due +worker hanno direzioni opposte sullo stesso strumento** (pairs long ETH vs fade short ETH): +- un close reduce-only può essere **cappato** (Deribit riduce l'amount in silenzio) → il + ledger bookava la chiusura PIENA perché `Fill.amount` era il richiesto, non il fillato; +- un close reduce-only nel verso "sbagliato" rispetto al netto viene **respinto** → la + gamba pairs resta orfana sul conto, ma il worker bookava il PnL al prezzo sim e azzerava + lo stato (3 volte oggi: PnL fantasma nel ledger real-truth, ETH/SOL di fatto short nudo). + +## Fix (v1.1.24) — verità, non workaround + +1. **`Fill.filled_amount`** (da `order.filled_amount`, fallback trades/history): tutti i + ledger usano il fillato; nota "FILL PARZIALE" nel Fill. +2. **`REAL_CLOSE_PARTIAL`** (log + Telegram): close che filla meno del residuo → residuo + orfano dichiarato, `REAL_CLOSE verified=false`. +3. **Pairs per-gamba**: PnL bookato SOLO per gambe con fill verificato; gamba respinta → + record in `orphan_legs` (persistito nello status) + alert `PAIR_LEG_ORPHAN`; + `applied=True` (real-truth) solo con ENTRAMBE le gambe → altrimenti fallback sim + DICHIARATO (meglio del numero mezzo-reale di prima). +4. **`REAL_DIVERGENCE` anche su jsonl** (prima solo Telegram: l'audit ha dovuto + ricostruire gli episodi dai REAL_CLOSE). +5. **Runner: tick isolato per-worker** — un'eccezione in un worker non salta più gli + altri né l'update equity; streak per-worker con alert `WORKER_ERROR_STREAK` a 5. + +Test: 2 nuovi (partial-close, orphan-leg) + fixture aggiornate → 106 passed. + +## Decisione di design APERTA (per l'utente) + +Finché pairs e fade condividono strumenti in direzioni opposte, le chiusure possono +orfanizzarsi: ora è VISIBILE e contabilizzato giusto, ma non eliminato. Opzioni: + A) **Position-manager centrale per strumento** (il runner netta i delta di tutti i + worker e manda UN ordine; ricostruisce le quote contabilmente) — corretto ma + invasivo, da progettare con calma; + B) **Sotto-conti Deribit separati** per famiglia (pairs vs fade) — pulito, richiede + setup conto e client multi-token; + C) **Status quo monitorato**: alert PAIR_LEG_ORPHAN + riallineo manuale (oggi: 1 giro + in 2 minuti) — accettabile su testnet, NON per capitale vero. +Raccomandazione: C ora, A prima di passare a capitale reale. + +## Altri esiti della tornata + +- **Dedup engine gate** (TODO chiuso): `_port06_gate_common.py`, output 3 gate + byte-identici, nessun copy-drift trovato (la paura era fondata ma non ancora avverata). +- **Bug bfill `_daily_equity` quantificato** (TODO aggiornato): NON materiale — OOS + invariato per costruzione, FULL DD 3.46→3.67 col fix (l'attuale è lievemente ottimista), + nessun verdetto di gate a rischio. Lasciato documentato. +- **Drift monitor in produzione**: `drift_monitor.py` in crontab host (07:15 UTC, + Telegram): rolling 60/120g per famiglia vs distribuzione storica propria, warn < p5. + Oggi: FADE 120g al p2 (coda storica, nessun intervento), resto normale, XSEC p84. +- **Dati cerbero-bite refreshati**: catena opzioni a ~153k righe/asset (fino a oggi + 19:30), pannello regime denso (net-GEX 644/673 ultimi 7g). Sempre un solo regime: + niente validazione edge, valore forward. diff --git a/src/live/execution.py b/src/live/execution.py index 9609922..dfce277 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -119,6 +119,12 @@ class Fill: verified: bool # posizione/trade riscontrati su Deribit raw: dict[str, Any] = field(default_factory=dict) notes: str = "" + # amount REALMENTE fillato (order.filled_amount / somma trades) — puo' essere + # MINORE del richiesto: un reduce-only cappato dal netting di conto (worker + # long e short sullo stesso strumento) viene ridotto in silenzio da Deribit. + # Audit 2026-06-11: close 0.105, fillato 0.078, bookato pieno perche' il + # ledger usava `amount`. I ledger devono usare QUESTO campo. + filled_amount: float = 0.0 def _avg_fill_price(order: dict, trades: list[dict]) -> float | None: @@ -233,6 +239,15 @@ class ExecutionClient: fee_usd = fee_coin if spec.get("linear") else ( fee_coin * fill_price if (fee_coin and fill_price) else 0.0) + # amount REALMENTE fillato: order.filled_amount e' la fonte autorevole + # (Deribit RIDUCE in silenzio un reduce-only che eccede il netto di conto); + # fallback: somma trades del fill, poi trade history + filled = float(order.get("filled_amount") or 0) + if not filled: + filled = sum(float(t.get("amount", 0) or 0) for t in trades) + if not filled and th: + filled = float(th.get("amount") or 0) + # VERIFICA: market = ordine filled E fill riscontrato (trades o history); # limit = accettato in book ('open') o gia' eseguito ('filled'); # stop_market = trigger accettato ('untriggered' finche' il mark non tocca) @@ -242,9 +257,13 @@ class ExecutionClient: verified = state in ("untriggered", "open", "filled") else: verified = state in ("open", "filled") + notes = "" if verified else f"fill non verificato (state={state}, trades={len(trades)})" + if verified and order_type == "market" and filled < amount - 1e-12: + notes = (f"FILL PARZIALE: {filled} su {amount} richiesti " + "(reduce-only cappato dal netting di conto?)") return Fill(instrument, side, requested_notional, amount, fill_price, fee_coin, fee_usd, order_id, state, verified, raw=resp, - notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})") + notes=notes, filled_amount=filled) def open(self, instrument: str, side: str, notional_usd: float, label: str | None = None) -> Fill: diff --git a/src/live/pairs_worker.py b/src/live/pairs_worker.py index 5b2d55f..093b1d8 100644 --- a/src/live/pairs_worker.py +++ b/src/live/pairs_worker.py @@ -106,6 +106,7 @@ class PairsWorker: self.real_entry_fee = 0.0 self.real_trades = 0 self.real_first_notified = False + self.orphan_legs: list[dict] = [] # gambe respinte dal netting (persistite) self._load_state() self._save_state() @@ -146,6 +147,7 @@ class PairsWorker: self.real_entry_fee = s.get("real_entry_fee", 0.0) self.real_trades = s.get("real_trades", 0) self.real_first_notified = s.get("real_first_notified", False) + self.orphan_legs = s.get("orphan_legs", []) self._log("RESUME", {"capital": round(self.capital, 2), "total_trades": self.total_trades, "in_position": self.in_position, "real_capital": round(self.real_capital, 2), @@ -167,6 +169,7 @@ class PairsWorker: "real_notional_a": self.real_notional_a, "real_notional_b": self.real_notional_b, "real_entry_fee": self.real_entry_fee, "real_trades": self.real_trades, "real_first_notified": self.real_first_notified, + "orphan_legs": self.orphan_legs, } with open(self.status_path, "w") as f: json.dump(state, f, indent=2) @@ -247,23 +250,41 @@ class PairsWorker: pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a, self.real_side_b, self.real_amount_a, self.real_amount_b, label=self.worker_id) + # VERITA' PER-GAMBA (audit 2026-06-11): una gamba puo' essere RESPINTA dal + # netting di conto (reduce-only nel verso sbagliato quando un altro worker e' + # nella direzione opposta sullo stesso strumento). Prima il PnL veniva + # calcolato col prezzo SIM per la gamba mai eseguita e sommato al ledger + # 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) 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 da, db = self.real_dir, -self.real_dir - gross = (da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a - + db * (exit_b - self.real_entry_b) / self.real_entry_b * self.real_notional_b) + 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 - self.real_entry_fee - exit_fee + real_pnl = ((gross_a if ok_a else 0.0) + (gross_b if ok_b else 0.0) + - 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, "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}) - if not pf.verified: - self._notify("REAL_CLOSE_FAILED", {"worker": self.worker_id, "note": pf.notes}) + 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, + "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")}) self.real_in_position = False self.real_dir = 0 self.real_side_a = self.real_side_b = "" @@ -272,7 +293,10 @@ class PairsWorker: self.real_notional_a = self.real_notional_b = 0.0 self.real_entry_fee = 0.0 self._save_state() - return real_pnl, bool(pf.verified or pf.leg_a.verified or pf.leg_b.verified) + # applied (real-truth) SOLO se entrambe le gambe hanno chiuso verificate: + # con una gamba orfana il "PnL reale dello spread" non esiste -> meglio il + # fallback sim DICHIARATO che un numero mezzo-reale + return real_pnl, ok_a and ok_b def _close(self, ca: float, cb: float, z: float, reason: str): if not self.in_position: diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 34861c7..9164a77 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -249,13 +249,16 @@ class StrategyWorker: } if fill.verified: linear = contract_spec(self.exec_instrument).get("linear") + # amount FILLATO, non richiesto (audit 2026-06-11): il ledger deve + # seguire i contratti realmente sul conto + real_amt = fill.filled_amount or fill.amount self.real_in_position = True self.real_side = side - self.real_amount = fill.amount + self.real_amount = real_amt self.real_entry_price = fill.fill_price or sim_price self.real_entry_fee_usd = fill.fee_usd - self.real_entry_notional = (fill.amount * self.real_entry_price - if linear else fill.amount) + self.real_entry_notional = (real_amt * self.real_entry_price + if linear else real_amt) self.real_order_id = fill.order_id or "" self._log("REAL_OPEN", data) if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva @@ -264,6 +267,7 @@ class StrategyWorker: if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS: # sim e reale stanno tradando prezzi diversi (spike print/feed stantio): # il sim sta per bookare PnL che il reale non vede + self._log("REAL_DIVERGENCE", {"fase": "open", **data}) self._notify("REAL_DIVERGENCE", {"fase": "open", **data}) self._place_real_tp() self._place_disaster_sl() @@ -394,7 +398,17 @@ class StrategyWorker: if remainder >= step / 2: fill = self.executor.close_amount(self.exec_instrument, self.real_side, remainder, label=self.worker_id) - market_amt = fill.amount if (fill and fill.verified) else 0.0 + # 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 + if fill and fill.verified 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?)")} + self._log("REAL_CLOSE_PARTIAL", data) + self._notify("REAL_CLOSE_PARTIAL", data) # 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali parts = [(a, p) for a, p in ((tp_amt, tp_px), @@ -417,10 +431,13 @@ class StrategyWorker: slip_bps = ((exit_price / sim_exit - 1) * 1e4 if exit_price and sim_exit else None) if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS: - self._notify("REAL_DIVERGENCE", { - "fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2), - "real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2), - "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)}) + div = {"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2), + "real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2), + "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)} + # anche su jsonl, non solo Telegram: gli episodi di slippage estremo + # devono restare interrogabili (l'audit 2026-06-11 ha dovuto ricostruirli) + self._log("REAL_DIVERGENCE", div) + self._notify("REAL_DIVERGENCE", div) self._log("REAL_CLOSE", { "reason": reason, "order_id": fill.order_id if fill else tp_order_id, diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index bb19620..abed9d0 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -387,6 +387,7 @@ def run(config_path: str = "portfolios.yml"): # non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio. _OUTAGE_POLLS = 5 fail_streak = 0 + worker_err_streak: dict[str, int] = {} # errori consecutivi per worker (isolamento tick) def _outage_tick(failed: bool, streak: int, detail: str = "") -> int: """Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una @@ -468,11 +469,27 @@ def run(config_path: str = "portfolios.yml"): # single (fade/dip): StrategyWorker su feed live. w.tick(res[s.asset]) + # isolamento per-worker (audit 2026-06-11): un'eccezione in un tick NON + # deve saltare gli altri worker (= exit non valutati per tutti) ne' + # l'update dell'equity. Streak per-worker -> alert dopo 5 fail di fila. + def _tick_safe(s, w): + try: + _tick(s, w) + worker_err_streak.pop(s.sid, None) + 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: + from src.live.telegram_notifier import notify_event + notify_event("WORKER_ERROR_STREAK", + {"worker": s.sid, "streak": n, "errore": str(e)[:200]}) + for s in live_specs: - _tick(s, workers[s.sid]) + _tick_safe(s, workers[s.sid]) # PAPER: ticcati per statistica, MAI nel ledger del portafoglio for s in paper_specs: - _tick(s, paper_workers[s.sid]) + _tick_safe(s, paper_workers[s.sid]) ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()}) diff --git a/tests/portfolio/test_real_close_dsl.py b/tests/portfolio/test_real_close_dsl.py index c5f392f..e2fb8a0 100644 --- a/tests/portfolio/test_real_close_dsl.py +++ b/tests/portfolio/test_real_close_dsl.py @@ -28,7 +28,7 @@ class FakeExec: def close_amount(self, instrument, side, amount, label=None): return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0, - "oid-close", "filled", True) + "oid-close", "filled", True, filled_amount=amount) class FakeFullExec(FakeExec): @@ -53,7 +53,8 @@ class FakeFullExec(FakeExec): def close_amount(self, instrument, side, amount, label=None): self.close_amounts.append(amount) - return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close", "filled", True) + return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close", + "filled", True, filled_amount=amount) def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch): diff --git a/tests/portfolio/test_real_truth.py b/tests/portfolio/test_real_truth.py index dd603ca..e68bfe2 100644 --- a/tests/portfolio/test_real_truth.py +++ b/tests/portfolio/test_real_truth.py @@ -26,7 +26,7 @@ class FakeExec: def open(self, instrument, side, notional, label=None): amt = round(notional / self.open_px, 6) return Fill(instrument, side, notional, amt, self.open_px, 0.0, 0.05, - "oid-open", "filled", True) + "oid-open", "filled", True, filled_amount=amt) def place_tp_limit(self, *a, **k): return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False) @@ -42,7 +42,8 @@ class FakeExec: def close_amount(self, instrument, side, amount, label=None): return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount, - self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified) + self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified, + filled_amount=amount) def _sw(tmp_path, fake, real_truth=True): @@ -188,3 +189,50 @@ def test_pairs_fallback_sim_on_leg_fail(tmp_path): if '"CLOSE"' in l][-1]) assert c["pnl_source"] == "sim_fallback" assert c["pnl"] == c["sim_pnl"] + + +def test_partial_close_books_only_filled_amount(tmp_path): + """Reduce-only cappato dal netting (audit 2026-06-11): close richiesto 1.0 ma + fillato 0.5 -> il ledger booka SOLO il fillato, REAL_CLOSE verified=False e + evento REAL_CLOSE_PARTIAL col residuo orfano (niente piu' chiusure 'piene' finte).""" + class PartialFake(FakeExec): + def close_amount(self, instrument, side, amount, label=None): + return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount, + self.close_px, 0.0, 0.05, "oid-close", "filled", True, + filled_amount=amount * 0.5) + + fake = PartialFake(open_px=100.0, close_px=105.0) + w = _sw(tmp_path, fake) + _open_sim(w, 100.0) + w._close_position(105.0, "time_limit") + rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()] + rc = [r for r in rows if r.get("event") == "REAL_CLOSE"][-1] + assert rc["verified"] is False # chiusura NON completa + partial = [r for r in rows if r.get("event") == "REAL_CLOSE_PARTIAL"] + assert partial and partial[-1]["residuo_orfano"] > 0 + + +def test_pairs_orphan_leg_close_not_booked(tmp_path): + """Gamba A respinta in CHIUSURA (reduce-only vs netting, audit 2026-06-11): il suo + 'profitto' al prezzo sim NON va nel ledger reale, l'orfano e' registrato/persistito + e il real-truth ricade sul sim DICHIARATO (applied=False).""" + class LegFailClose(FakePairsExec): + def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None): + bad_a = Fill(inst_a, "sell", 0.0, amount_a, None, 0.0, 0.0, None, "error", False) + ok_b = self._fill(inst_b, "buy", amount_b, self.px["close_b"]) + return PairFill(False, bad_a, ok_b) + + fake = LegFailClose(open_a=100.0, close_a=110.0) # gamba A +10% ma MAI chiusa + w = _pw(tmp_path, fake) + w._open(1, 100.0, 50.0, -2.1) + na = w.real_notional_a + w._close(110.0, 50.0, 0.1, "mean_revert") + assert w.orphan_legs and w.orphan_legs[0]["instrument"] == "ETH_USDC-PERPETUAL" + rc = json.loads([l for l in w.trades_path.read_text().splitlines() + if '"REAL_CLOSE_PAIR"' in l][-1]) + assert rc["leg_a_ok"] is False and rc["leg_b_ok"] is True + # il +10% della gamba A (mai eseguita) NON e' nel capitale reale + assert w.real_capital < 100.0 + 0.10 * na - 0.05 + c = json.loads([l for l in w.trades_path.read_text().splitlines() + if '"event": "CLOSE"' in l][-1]) + assert c["pnl_source"] == "sim_fallback" diff --git a/tests/portfolio/test_tp_phantom.py b/tests/portfolio/test_tp_phantom.py index cab58db..3276c39 100644 --- a/tests/portfolio/test_tp_phantom.py +++ b/tests/portfolio/test_tp_phantom.py @@ -35,6 +35,7 @@ class _FakeExecutor: class F: verified = True amount = 0.01 + filled_amount = 0.01 fill_price = 100.0 fee_usd = 0.01 order_id = "X"