fix(exec): verità contabile sul netting — filled_amount, gambe orfane, tick isolato

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 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 20:05:42 +00:00
parent 8a2b065dd7
commit 429fa01c97
9 changed files with 234 additions and 21 deletions
+20 -1
View File
@@ -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:
+30 -6
View File
@@ -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:
+25 -8
View File
@@ -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,
+19 -2
View File
@@ -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()})