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:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user