feat(exec): netting delle chiusure market — il residuo reduce-only cappato/respinto va in market puro

close_amount ora: (1) tenta il market reduce-only (sicurezza storica: un bug di
stato filla 0 invece di aprire posizioni); (2) il residuo cappato/respinto dal
netting di conto (worker in direzioni opposte sullo stesso strumento) viene
rieseguito in MARKET PURO con label '|net' — muove il conto esattamente del
delta del libro = netta contro le quote opposte. Niente piu' gambe pairs orfane
ne' close cappati per costruzione (close_pair passa da close_amount).

- _merge_close_fills: il chiamante riceve UN Fill (prezzo medio pesato sui fill,
  fee sommate, filled_amount totale, verified se copre il richiesto, notes
  'netting' quando il fallback scatta)
- worker single-leg + pairs: evento NET_CLOSE (log jsonl + Telegram) a ogni
  fallback — osservabilita' della frequenza dei conflitti di netting
- sicurezza persa sul residuo coperta dal reconciler orario (ACCOUNT_DRIFT);
  orphan_legs/REAL_CLOSE_PARTIAL restano come ultima difesa
- 4 test nuovi (full r-o senza fallback, cappato, respinto, doppio fail) -> 110 passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 20:54:43 +00:00
parent 22b77668f1
commit 844a9d0e22
6 changed files with 155 additions and 7 deletions
+10 -3
View File
@@ -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`
+11
View File
@@ -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
+42 -4
View File
@@ -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:
+5
View File
@@ -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
+5
View File
@@ -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),
+82
View File
@@ -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