diff --git a/CLAUDE.md b/CLAUDE.md index 6c94a44..9fcca58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,9 +463,16 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale 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). + **RISOLTO in v1.1.25 col NETTING delle chiusure**: `close_amount` tenta il reduce-only + (sicurezza storica: un bug di stato filla 0) e riesegue il RESIDUO cappato/respinto in + **market puro** — che muove il conto esattamente del delta del libro, cioè netta contro le + quote opposte → niente più gambe orfane/close cappati per costruzione (copre anche i pairs + via `close_pair`). Il chiamante riceve UN Fill combinato (prezzo medio pesato, fee sommate, + `notes` con 'netting'); evento `NET_CLOSE` (log+Telegram) a ogni fallback. La sicurezza + persa sul residuo è coperta dal **reconciler orario** (`reconcile_account.py`, cron host + :40, alert `ACCOUNT_DRIFT`): conto vs Σ libri+orfani, tolleranza 1.5×step, anti-race. + `orphan_legs`/`REAL_CLOSE_PARTIAL` restano come ultima difesa (se fallisce anche il market + puro). Test `tests/portfolio/test_netting_close.py`. - **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 index db0c2e7..ec21bcb 100644 --- a/docs/diary/2026-06-11-system-audit.md +++ b/docs/diary/2026-06-11-system-audit.md @@ -56,6 +56,17 @@ orfanizzarsi: ora è VISIBILE e contabilizzato giusto, ma non eliminato. Opzioni in 2 minuti) — accettabile su testnet, NON per capitale vero. Raccomandazione: C ora, A prima di passare a capitale reale. +**Secondo passo — FATTO (v1.1.25): NETTING delle chiusure market.** `close_amount` +tenta il reduce-only e riesegue il residuo cappato/respinto in market puro (= il +netting contro le quote opposte: il conto si muove del delta esatto del libro). +Un solo punto di fix (anche `close_pair` ci passa). Fill combinato per il chiamante +(prezzo pesato, fee sommate), evento `NET_CLOSE` su log+Telegram a ogni fallback, +4 test dedicati. Niente più orfani per costruzione; `orphan_legs` resta come ultima +difesa se fallisce anche il market puro. Effetto collaterale benefico: la chiusura +futura della gamba ETH di ETH_SOL#2 (che sarebbe stata respinta di nuovo) ora +eseguirà correttamente. La scelta A-vs-B-vs-C resta aperta solo per la parte +RESTING (TP/DSL su book condiviso) e per i multi-asset. + **Primo passo verso A — FATTO (sera stessa): reconciler read-only.** `scripts/analysis/reconcile_account.py`: per ogni strumento USDC confronta atteso (Σ quote reali dai status.json: single-leg + pairs 2 gambe + orphan_legs diff --git a/src/live/execution.py b/src/live/execution.py index dfce277..516babe 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -127,6 +127,27 @@ class Fill: filled_amount: float = 0.0 +def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill: + """Combina il reduce-only e il residuo netting in UN esito per il chiamante: + filled = somma, prezzo = media pesata sui fill, fee sommate. verified se i + contratti riscontrati coprono il richiesto (la stessa soglia del chiamante).""" + fa = ro.filled_amount if ro.verified else 0.0 + fb = net.filled_amount if net.verified else 0.0 + tot = fa + fb + parts = [(amt, f.fill_price) for amt, f in ((fa, ro), (fb, net)) + if amt > 0 and f.fill_price] + px = (sum(a * p for a, p in parts) / sum(a for a, _ in parts)) if parts else None + step = contract_spec(ro.instrument).get("step", 0.001) + verified = tot >= requested - step / 2 + return Fill(ro.instrument, ro.side, ro.requested_notional, requested, px, + 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})"), + filled_amount=tot) + + def _avg_fill_price(order: dict, trades: list[dict]) -> float | None: p = order.get("average_price") if p: @@ -274,16 +295,33 @@ class ExecutionClient: def close_amount(self, instrument: str, entry_side: str, amount: float, label: str | None = None) -> Fill: - """Chiude SOLO la quota del worker: market reduce_only di lato opposto, - stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche - le quote degli altri worker sullo stesso strumento).""" + """Chiude SOLO la quota del worker. Non usa close_position (flatterebbe + anche le quote degli altri worker sullo stesso strumento). + + NETTING (v1.1.25, 2026-06-11): prima tenta il market reduce-only (la + sicurezza storica: un bug di stato filla 0 invece di aprire posizioni). + Su un conto a NETTING pero' il reduce-only viene CAPPATO o RESPINTO quando + un altro worker e' in direzione OPPOSTA sullo stesso strumento (audit + 2026-06-11: 3 gambe pairs orfane + 1 close cappato) → il RESIDUO viene + rieseguito in MARKET PURO: muove il conto esattamente del delta del libro, + cioe' netta contro le quote opposte. La sicurezza persa sul residuo e' + coperta dal reconciler orario (reconcile_account.py, alert ACCOUNT_DRIFT). + Il chiamante riceve UN Fill combinato (prezzo medio pesato, fee sommate); + notes contiene 'netting' quando il fallback e' scattato.""" spec = contract_spec(instrument) # quantizza difensivamente: il chiamante puo' passare un residuo # (amount aperto − fill parziale del TP) con artefatti float amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0 opp = "sell" if entry_side == "buy" else "buy" - return self._submit(instrument, opp, amount, 0.0, + 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 + if residual < spec["step"] / 2: + return fill + 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 place_tp_limit(self, instrument: str, entry_side: str, amount: float, tp_price: float, label: str | None = None) -> Fill: diff --git a/src/live/pairs_worker.py b/src/live/pairs_worker.py index 093b1d8..476b0cc 100644 --- a/src/live/pairs_worker.py +++ b/src/live/pairs_worker.py @@ -258,6 +258,11 @@ class PairsWorker: # 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) + 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}) 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 diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 9164a77..abf91b1 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -402,6 +402,11 @@ class StrategyWorker: # (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 "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: data = {"requested": remainder, "filled": market_amt, "residuo_orfano": round(remainder - market_amt, 6), diff --git a/tests/portfolio/test_netting_close.py b/tests/portfolio/test_netting_close.py new file mode 100644 index 0000000..d5c66db --- /dev/null +++ b/tests/portfolio/test_netting_close.py @@ -0,0 +1,82 @@ +"""Netting delle chiusure market (v1.1.25): close_amount tenta il reduce-only e +riesegue il RESIDUO in market puro quando il netting di conto lo cappa/respinge +(audit 2026-06-11: 3 gambe pairs orfane + 1 close cappato). Il chiamante riceve +UN Fill combinato. Nessuna rete: _submit monkeypatchato.""" +from src.live.execution import ExecutionClient, Fill + + +def _fill(inst, side, amount, filled, px, verified=True, state="filled", fee=0.05): + return Fill(inst, side, 0.0, amount, px if filled else None, 0.0, fee, + 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.""" + ec = ExecutionClient() + calls = [] + + def fake_submit(instrument, side, amount, notional, reduce_only, label=None, + order_type="market", price=None): + calls.append(dict(amount=amount, reduce_only=reduce_only, label=label)) + return submits.pop(0)(instrument, side, amount) + + ec._submit = fake_submit + return ec, calls + + +INST = "ETH_USDC-PERPETUAL" + + +def test_full_reduce_only_no_fallback(): + # caso normale: il reduce-only filla tutto -> NESSUN secondo ordine + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=a, px=1700.0), + ]) + f = ec.close_amount(INST, "sell", 0.103, label="w1") + assert len(calls) == 1 and calls[0]["reduce_only"] is True + assert f.verified and abs(f.filled_amount - 0.103) < 1e-9 + assert "netting" not in (f.notes or "") + + +def test_capped_reduce_only_nets_residual(): + # reduce-only CAPPATO (filla 0.078 su 0.105) -> residuo 0.027 in market puro + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=0.078, px=1700.0), + lambda i, s, a: _fill(i, s, a, filled=a, px=1702.0), + ]) + f = ec.close_amount(INST, "sell", 0.105, label="w1") + assert len(calls) == 2 + assert calls[0]["reduce_only"] is True and calls[1]["reduce_only"] is False + assert abs(calls[1]["amount"] - 0.027) < 1e-9 + assert calls[1]["label"] == "w1|net" + assert f.verified and abs(f.filled_amount - 0.105) < 1e-9 + # prezzo medio pesato: (0.078*1700 + 0.027*1702) / 0.105 + exp_px = (0.078 * 1700.0 + 0.027 * 1702.0) / 0.105 + assert abs(f.fill_price - exp_px) < 1e-6 + assert abs(f.fee_usd - 0.10) < 1e-9 # fee sommate + assert "netting" in f.notes + + +def test_rejected_reduce_only_nets_full_amount(): + # reduce-only RESPINTO (error, 0 fill) -> tutto l'amount in market puro + 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=1701.0), + ]) + f = ec.close_amount(INST, "buy", 0.027, label="pair") + assert len(calls) == 2 and abs(calls[1]["amount"] - 0.027) < 1e-9 + assert f.verified and abs(f.filled_amount - 0.027) < 1e-9 + assert f.fill_price == 1701.0 + assert "netting" in f.notes + + +def test_net_leg_also_fails_reports_partial(): + # anche il market puro fallisce -> Fill combinato NON verified, filled = solo r-o + ec, calls = _client_with([ + lambda i, s, a: _fill(i, s, a, filled=0.05, px=1700.0), + lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0), + ]) + f = ec.close_amount(INST, "sell", 0.105, label="w1") + assert len(calls) == 2 + assert not f.verified + assert abs(f.filled_amount - 0.05) < 1e-9 # solo il fillato vero nel ledger