fix(exec): code-review serale — guard anti-posizione-nuda sul netting + verità per-frazione

7 finder paralleli sul diff della giornata (8adf388..HEAD), fix dei confermati:

CRITICI (money-path):
- close_amount: GUARD stato-stantio sul fallback netting — il residuo
  non-reduce-only e' consentito solo fino al gap (conto reale - libri degli
  altri worker - orfani) nella direzione della chiusura (src/live/books.py =
  fonte unica, usata anche dal reconciler). Senza guard, un close su stato
  stantio (DSL scattato in outage, flatten manuale) APRIVA una posizione nuda
  a taglia piena bookata come 'chiusura verificata'. Fail-safe se il gap non
  e' calcolabile. Check polvere PRIMA di _quantize_step (che clampa al lotto
  minimo: un residuo 1e-17 diventava un ordine nudo da un lotto).
- _real_close: market_amt = filled_amount anche a merged verified=False (i
  contratti chiusi dal reduce-only non si buttano se il leg netting fallisce);
  REAL_CLOSE_PARTIAL non piu' gateato su verified (era soppresso proprio nel
  caso parziale reale).
- pairs: verita' per-FRAZIONE di gamba (gross proporzionale al fillato, orfano
  = solo il residuo — prima falsava reconciler e real_capital della parte gia'
  chiusa); REAL_OPEN_PAIR booka filled_amount; docstring applied corretta.
- open_pair unwind: chiude il FILLATO, non il richiesto (senza il cap silenzioso
  del reduce-only avrebbe mangiato quota altrui).
- place_tp_limit: quantize CONSERVATIVO (sell=floor/buy=ceil) — il rounding al
  tick piu' vicino poteva mettere il resting oltre il TP sim -> tocco genuino
  classificato phantom sistematicamente.

ROBUSTEZZA/OSSERVABILITA':
- runner: WORKER_ERROR_STREAK a 5 e poi ogni 50 poll + recovery RIPRESO +
  flag real_in_position nell'alert (prima: one-shot a n==5, poi silenzio).
- _tp_phantom: TTL 120s sul verdetto (era ~50 HTTP/h per worker a barra
  fantasma); merge notes con entrambi gli order_id (audit-trail).
- reset_flatten: _quantize_step Decimal (round float produceva amount che
  Deribit rifiuta); hourly_report: book XS01 con gambe SHORT visibili (abs).
- validate_xsec_worker: POS/LEV importati (non hardcoded); xs01_tranche:
  regression-lock ESEGUITO vs xsec_sim; reconcile su src/live/books;
  drift_monitor: rolling vettoriale + exit code 1 su warn.

Test: +4 guard/dust, fixture filled_amount -> 114 passed.
Deferiti (TODO): resting esposti al netting, lifecycle orphan_legs, finestra
trade-history TP_PHANTOM, validazione feed a monte, dedup minori.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 21:36:30 +00:00
parent 3f1feb1a6f
commit 82c05f6f81
15 changed files with 321 additions and 96 deletions
+27
View File
@@ -65,6 +65,33 @@
- [ ] **epoche hardcoded in `hourly_report.lossguard_section`** (LOSSGUARD_SINCE, TRENDSWAP_SINCE):
ogni nuova epoca-filtro richiede di editare la funzione. Derivarle da deploy history/config.
## Code-review 2026-06-11 sera (8a2b065..) — finding DEFERITI (i confermati critici sono fixati in v1.1.26)
- [ ] **RESTING reduce-only esposti al netting** (TP limit + disaster-SL): se un worker opposto
apre DOPO il piazzamento, il resting può fillare parziale o essere respinto — il fallback
netting copre solo i close sincroni. È il pezzo "position manager / sotto-conti" già noto;
lo scenario peggiore è il disaster-SL cappato proprio nel crash per cui esiste.
- [ ] **Lifecycle `orphan_legs`**: append-only — un orfano risolto a mano (o da reset_flatten)
resta nello status e MASCHERA per compensazione un drift vero futuro (falso negativo del
reconciler). Serve un comando di risoluzione (`--resolve-orphan`) e/o reset_flatten che
azzeri anche lo stato reale nei status.json. Idem: il PnL della chiusura manuale di un
orfano non viene mai bookato in real_capital (diagnostica shadow divergente).
- [ ] **TP_PHANTOM residuo**: `resting_fills` guarda solo le ultime 100 righe di trade-history
per strumento — su conto molto attivo un fill TP reale può scivolare fuori finestra →
falso phantom persistente (sim resta in posizione, reale flat). Mitigato dal quantize
conservativo (v1.1.26); fix vero = endpoint order-state in cerbero-mcp.
- [ ] **Validazione feed a monte** (altitudine): TP_PHANTOM copre solo i tocchi TP dei single-leg;
le ENTRY spike-driven, lo SL close-confirm su close spike e lo z dei pairs restano esposti
ai wick fantasma del feed testnet. Un validatore barre nel data layer coprirebbe tutti i
consumer con un meccanismo solo (su mainnet il fenomeno non esiste: priorità bassa finché
si resta su testnet, gli eventi TP_PHANTOM/NET_CLOSE ne misurano la frequenza).
- [ ] **Contratto dello schema status.json**: reconcile (`src/live/books.py`), hourly_report e i
worker condividono lo schema per convenzione implicita — books.py è ora la fonte unica per
i campi `real_*`, ma un helper `worker.real_book()` usato da _save e dai reader chiuderebbe
la classe di bug. Pulizia: `_tp_hit` helper per i 4 siti di tocco TP duplicati; port_metrics
ri-biforcato in xs01_tranche_gate/pairs30m_gate (importare da _port06_gate_common);
splits3/metriche duplicate nei games engine (estrarre in scripts/games/engine.py).
## Ricerca dispersion/correlation (2026-06-08, 165 agenti) — follow-up opzionale
- [x] ~~Gate PORT06 di `index_comp_disp` W=168~~ — FATTO (2026-06-08): PROMOSSO MARGINALE.
+6 -2
View File
@@ -55,7 +55,9 @@ def drift_rows():
rows = []
for name, r in family_returns().items():
for win in WINDOWS:
roll = (1 + r).rolling(win).apply(np.prod, raw=True) - 1
# vettoriale (log1p+rolling sum) invece di apply(np.prod): identico
# numericamente, ~100x piu' veloce del callback Python per-finestra
roll = np.expm1(np.log1p(r).rolling(win).sum())
roll = roll.dropna()
if len(roll) < 100:
continue
@@ -99,4 +101,6 @@ def main():
if __name__ == "__main__":
main()
# exit code 1 su warn: utilizzabile da cron/script come canale d'allarme
# (coerente con reconcile_account; prima il bool era calcolato e buttato via)
sys.exit(1 if main() else 0)
+6 -53
View File
@@ -20,7 +20,6 @@ Nessun ordine, nessuna modifica di stato: SOLO lettura.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
@@ -30,63 +29,17 @@ sys.path.insert(0, str(PROJECT_ROOT))
from src.live.cerbero_client import CerberoClient
from src.live.execution import contract_spec
from src.live.books import real_books, account_net # fonte UNICA dei libri (usata
# anche dal guard del netting)
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi
TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto
def _inst(asset: str) -> str:
return f"{asset}_USDC-PERPETUAL"
def expected_books() -> tuple[dict[str, float], dict[str, float]]:
"""(atteso per strumento dai libri, quota orfana registrata per strumento).
Amount firmato in base-coin: buy=+, sell=-."""
books: dict[str, float] = {}
orphans: dict[str, float] = {}
for sp in sorted(PAPER.glob("*/status.json")):
st = json.loads(sp.read_text())
wid = sp.parent.name
parts = wid.split("__")
if st.get("real_in_position"):
if "real_amount_a" in st and st.get("real_amount_a"):
# pairs: asset da worker_id (…__ETH_BTC__1h)
a, b = parts[1].split("_")
for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]),
(b, st["real_side_b"], st["real_amount_b"])):
sgn = 1 if side == "buy" else -1
books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt
elif st.get("real_amount"):
# single-leg: fade/DIP01/SH01
sgn = 1 if st.get("real_side") == "buy" else -1
books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"]
for o in st.get("orphan_legs", []):
# gamba che il libro ha chiuso ma il conto ha ancora (entry_side = verso
# in cui la posizione e' rimasta aperta sul conto)
sgn = 1 if o.get("entry_side") == "buy" else -1
orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"])
return books, orphans
def account_positions(client: CerberoClient) -> dict[str, float]:
"""Posizioni reali per strumento, amount firmato in base-coin."""
out: dict[str, float] = {}
for p in client.get_positions(currency="USDC") or []:
inst = p.get("instrument")
size = float(p.get("size") or 0)
mark = float(p.get("mark_price") or 0)
if not inst or not size or not mark:
continue
amt = size / mark
out[inst] = amt if p.get("direction") == "long" else -amt
return out
def compute_drift() -> list[dict]:
client = CerberoClient()
books, orphans = expected_books()
acct = account_positions(client)
def compute_drift(client: CerberoClient | None = None) -> list[dict]:
client = client or CerberoClient()
books, orphans = real_books()
acct = account_net(client)
rows = []
for inst in sorted(set(books) | set(orphans) | set(acct)):
exp = books.get(inst, 0.0) + orphans.get(inst, 0.0)
+5 -1
View File
@@ -44,7 +44,11 @@ def main():
step = contract_spec(inst)["step"]
entry_side = "buy" if p["direction"] == "long" else "sell"
units = size_usd / mark if mark else 0
amount = round(units / step) * step
# _quantize_step (Decimal): round(units/step)*step in float binario
# produce artefatti (0.07200000000000001) che Deribit rifiuta con
# 'Invalid params' — il bug e' documentato in execution.py
from src.live.execution import _quantize_step
amount = _quantize_step(units, step, contract_spec(inst)["min"]) if units >= step / 2 else 0.0
if amount <= 0:
print(f" {inst}: residuo {size_usd:.2f} USD sotto mezzo step, ok")
continue
+5 -2
View File
@@ -58,8 +58,11 @@ def main():
print(f" ESITO: {'OK (replay == backtest)' if ok1 else 'DIFF -> INDAGARE'}")
# [2] K=3 (tranching live): parita' con l'unione delle fasi 0,4,8 (gate
# xs01_tranche_gate) — capitale comune, PnL/K per trade in ordine di exit
# xs01_tranche_gate) — capitale comune, PnL/K per trade in ordine di exit.
# POS/LEV importati dal modulo canonico, NON hardcoded: una costante stantia
# qui farebbe passare la validazione contro un sistema diverso (code-review)
from scripts.analysis.xs01_tranche_research import xsec_trades
from scripts.strategies.XS01_cross_sectional import POS, LEV
K = 3
w3 = _replay(M, dfs, {"lb": LB, "hold": HOLD, "tranches": K})
step = HOLD // K
@@ -67,7 +70,7 @@ def main():
key=lambda t: t[1])
cap = 1000.0
for _, _, net in allt:
cap = max(cap + cap * 0.15 * 3.0 * net / K, 10.0)
cap = max(cap + cap * POS * LEV * net / K, 10.0)
cap_ok3 = abs(w3.capital - cap) / cap < 0.02
trd_ok3 = abs(w3.total_trades - len(allt)) <= max(2, len(allt) * 0.02)
print(f"\n [K=3] {'':<6}{'cap':>14}{'trades':>8}")
+22
View File
@@ -102,6 +102,27 @@ def combined_metrics(branches, ts, yrs_span):
sharpe=sharpe, dd=float(((pk - eq) / pk).max() * 100))
def regression_lock(M=None):
"""xsec_trades(phase=0) DEVE riprodurre xsec_sim (n trade e ret totale):
la copia e' load-bearing (la usano gate e validate_xsec_worker) — senza
lock eseguito un fix all'engine canonico non si propagherebbe e la
validazione live certificherebbe un engine stantio (code-review 2026-06-11)."""
from scripts.strategies.XS01_cross_sectional import xsec_sim
if M is None:
M = aligned_panel()
ts = pd.to_datetime(M.index, unit="ms", utc=True)
tr = xsec_trades(phase=0, M=M)
eq = equity_from(tr, ts)
ref = xsec_sim()
n_ok = len(tr) == ref["trades"]
ret = (eq.iloc[-1] / 1000 - 1) * 100 if len(eq) else 0.0
ret_ok = abs(ret - ref["ret"]) < 1e-6 * max(1.0, abs(ref["ret"]))
if not (n_ok and ret_ok):
raise AssertionError(f"REGRESSION-LOCK FALLITO: trades {len(tr)} vs {ref['trades']}, "
f"ret {ret:.6f} vs {ref['ret']:.6f}")
print(f" regression-lock OK: {len(tr)} trade, ret {ret:+.2f}% == xsec_sim")
def run():
M = aligned_panel()
ts = pd.to_datetime(M.index, unit="ms", utc=True)
@@ -109,6 +130,7 @@ def run():
print("=" * 96)
print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata")
print("=" * 96)
regression_lock(M)
for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)):
yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1
rows = []
+3 -1
View File
@@ -154,7 +154,9 @@ def multi_asset_section() -> str:
book[a] = book.get(a, 0.0) + v / k
if book is None:
continue # single-leg/pairs: gia' coperti da collect()
held = {a: v for a, v in book.items() if v > 0}
# abs(): il book XS01 e' long/short market-neutral — col filtro v>0 le
# gambe SHORT sparivano e un book net-short appariva "flat" nel report
held = {a: v for a, v in book.items() if abs(v) > 1e-4}
flip = "mai"
tp = d / "trades.jsonl"
if tp.exists():
+67
View File
@@ -0,0 +1,67 @@
"""Libri REALI per strumento dai status.json persistiti dei worker — fonte unica.
Usato da:
- scripts/analysis/reconcile_account.py (audit orario conto vs libri)
- ExecutionClient.close_amount (guard del netting: il residuo non-reduce-only
e' consentito solo fino al gap conto-vs-libri-altrui — senza questo guard un
close su stato stantio APRIREBBE una posizione nuda, code-review 2026-06-11)
Convenzione: amount firmato in base-coin (buy=+, sell=-), strumenti USDC lineari.
"""
from __future__ import annotations
import json
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
def _inst(asset: str) -> str:
return f"{asset}_USDC-PERPETUAL"
def real_books(exclude_worker: str | None = None) -> tuple[dict[str, float], dict[str, float]]:
"""(libri per strumento, orfani registrati per strumento), firmati.
exclude_worker: worker_id da ESCLUDERE dai libri (per il guard del netting:
il close del worker X deve portare il conto al netto degli ALTRI). Gli
orphan_legs NON vengono mai esclusi: sono posizioni che il conto ha ancora
indipendentemente da chi le ha originate."""
books: dict[str, float] = {}
orphans: dict[str, float] = {}
for sp in sorted(PAPER.glob("*/status.json")):
wid = sp.parent.name
try:
st = json.loads(sp.read_text())
except Exception:
continue
if st.get("real_in_position") and wid != exclude_worker:
parts = wid.split("__")
if st.get("real_amount_a"):
a, b = parts[1].split("_") # pairs: …__ETH_BTC__1h
for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]),
(b, st["real_side_b"], st["real_amount_b"])):
sgn = 1 if side == "buy" else -1
books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt
elif st.get("real_amount"):
sgn = 1 if st.get("real_side") == "buy" else -1
books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"]
for o in st.get("orphan_legs", []):
sgn = 1 if o.get("entry_side") == "buy" else -1
orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"])
return books, orphans
def account_net(client) -> dict[str, float]:
"""Posizioni reali per strumento dal conto (size USD / mark -> coin, firmato)."""
out: dict[str, float] = {}
for p in client.get_positions(currency="USDC") or []:
inst = p.get("instrument")
size = float(p.get("size") or 0)
mark = float(p.get("mark_price") or 0)
if not inst or not size or not mark:
continue
amt = size / mark
out[inst] = amt if p.get("direction") == "long" else -amt
return out
+57 -8
View File
@@ -76,13 +76,24 @@ def _quantize_step(value: float, step: float, mn: float) -> float:
return float(max(n_steps * Decimal(str(step)), Decimal(str(mn))))
def quantize_price(instrument: str, price: float) -> float:
def quantize_price(instrument: str, price: float, side: str | None = None) -> float:
"""Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti
float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick)."""
float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick).
side (code-review 2026-06-11): per un limit di USCITA il rounding al tick
piu' vicino puo' mettere il resting OLTRE il livello sim → un tocco esatto
del livello non filla MAI il resting e il gate TP_PHANTOM classificherebbe
phantom un tocco genuino, sistematicamente. 'sell' = floor (mai sopra il
livello), 'buy' = ceil (mai sotto): il resting e' sempre raggiungibile
quando il sim dichiara il tocco (costo: <=1 tick di PnL vs sim)."""
import math
tick = contract_spec(instrument).get("tick")
if not tick or price <= 0:
return price
return float(round(price / tick) * Decimal(str(tick)))
n = (math.floor(price / tick) if side == "sell"
else math.ceil(price / tick) if side == "buy"
else round(price / tick))
return float(n * Decimal(str(tick)))
def notional_to_amount(instrument: str, notional_usd: float,
@@ -143,8 +154,8 @@ def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill:
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})"),
notes=(f"netting: reduce-only {fa}/{requested} (oid {ro.order_id}), "
f"residuo {fb} market puro ({net.order_state}, oid {net.order_id})"),
filled_amount=tot)
@@ -316,13 +327,48 @@ class ExecutionClient:
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
# check sulla POLVERE prima di quantizzare: _quantize_step clampa al lotto
# minimo, quindi un residuo da artefatto float (1e-17) diventerebbe un
# ordine nudo da un lotto intero (code-review 2026-06-11)
if residual < spec["step"] / 2:
return fill
residual = _quantize_step(residual, spec["step"], spec["min"])
# GUARD stato-stantio (code-review 2026-06-11): il residuo NON-reduce-only
# e' consentito solo fino al gap (conto reale libri degli ALTRI worker
# orfani) nella direzione della chiusura. Se la nostra quota non esiste
# piu' sul conto (disaster-SL scattato in outage, flatten manuale, stato
# stantio al resume) il gap e' ~0 → NIENTE ordine nudo: si ritorna il
# parziale onesto (il chiamante alza REAL_CLOSE_PARTIAL). Se il guard non
# e' calcolabile (rete) si resta FAIL-SAFE: meglio un orfano tracciato
# che una posizione nuda non tracciata.
allowed = self._net_close_allowance(instrument, opp, label)
if allowed is None or allowed < spec["step"] / 2:
fill.notes = (fill.notes + " | " if fill.notes else "") + (
f"netting NEGATO: residuo {residual} ma gap conto-libri "
f"{'non calcolabile' if allowed is None else round(allowed, 6)} "
"(stato stantio? quota gia' chiusa?)")
return fill
if allowed < residual:
residual = _quantize_step(allowed, spec["step"], spec["min"])
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 _net_close_allowance(self, instrument: str, close_side: str,
self_worker: str | None) -> float | None:
"""Quanto residuo non-reduce-only e' GIUSTIFICATO dai libri: gap firmato
fra il conto reale e (libri degli altri worker + orfani registrati),
proiettato nella direzione della chiusura. None = non calcolabile."""
try:
from src.live.books import real_books, account_net
books, orphans = real_books(exclude_worker=self_worker)
target = books.get(instrument, 0.0) + orphans.get(instrument, 0.0)
pos = account_net(self.client).get(instrument, 0.0)
gap = pos - target # quota nostra ancora sul conto (firmata)
return max(0.0, gap if close_side == "sell" else -gap)
except Exception:
return None
def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
tp_price: float, label: str | None = None) -> Fill:
"""LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata):
@@ -333,7 +379,7 @@ class ExecutionClient:
TP il limit crossa e filla subito (state 'filled'); la riconciliazione a
valle (resting_fills) lo gestisce come un fill normale."""
opp = "sell" if entry_side == "buy" else "buy"
px = quantize_price(instrument, tp_price)
px = quantize_price(instrument, tp_price, side=opp)
if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/tp non validi")
@@ -474,7 +520,10 @@ class PairsExecutionClient:
unwound = False
for f, inst in ((fa, inst_a), (fb, inst_b)):
if f.verified and f.amount > 0:
self.leg.close_amount(inst, f.side, f.amount, label=label)
# unwind del FILLATO, non del richiesto: col fallback netting un
# amount sovrastimato non viene piu' cappato in silenzio dal
# reduce-only — chiuderebbe quota altrui (code-review 2026-06-11)
self.leg.close_amount(inst, f.side, f.filled_amount, label=label)
unwound = True
return PairFill(False, fa, fb, unwound=unwound,
notes=f"leg-fail (a={fa.verified} b={fb.verified}), unwound={unwound}")
+34 -13
View File
@@ -224,7 +224,9 @@ class PairsWorker:
self.real_in_position = True
self.real_dir = d
self.real_side_a, self.real_side_b = pf.leg_a.side, pf.leg_b.side
self.real_amount_a, self.real_amount_b = pf.leg_a.amount, pf.leg_b.amount
# amount FILLATO, non richiesto (coerente con strategy_worker, 2026-06-11)
self.real_amount_a = pf.leg_a.filled_amount or pf.leg_a.amount
self.real_amount_b = pf.leg_b.filled_amount or pf.leg_b.amount
self.real_entry_a = pf.leg_a.fill_price or sim_a
self.real_entry_b = pf.leg_b.fill_price or sim_b
self.real_notional_a = pf.leg_a.amount * self.real_entry_a
@@ -240,11 +242,12 @@ class PairsWorker:
def _real_close_pair(self, sim_a: float, sim_b: float, reason: str,
sim_pnl: float) -> tuple[float | None, bool]:
"""Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia
PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo.
"""Chiusura REALE shadow: richiude entrambe le gambe (netting-aware),
riconcilia PnL reale per-gamba e fee, aggiorna il ledger reale parallelo.
Ritorna (real_pnl, applied): applied=True se almeno una gamba ha chiuso con
fill verificato (PnL reale utilizzabile come verita' in real-truth)."""
Ritorna (real_pnl, applied): applied=True SOLO se ENTRAMBE le gambe hanno
chiuso per intero con fill verificato — con una gamba orfana il "PnL dello
spread" non esiste e real-truth ricade sul sim DICHIARATO."""
if not self.real_in_position:
return None, False
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
@@ -257,12 +260,25 @@ class PairsWorker:
# 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)
from src.live.execution import contract_spec
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})
# verita' per-FRAZIONE di gamba (code-review 2026-06-11): una gamba puo'
# chiudere PARZIALMENTE (reduce-only cappato + netting negato/fallito) —
# si booka il gross della sola frazione FILLATA e l'orfano registra il
# solo RESIDUO (prima: gross binario tutto-o-niente e orfano a amount
# pieno, che falsava reconciler e real_capital della parte gia' chiusa).
filled_a = min(getattr(pf.leg_a, "filled_amount", 0.0), self.real_amount_a)
filled_b = min(getattr(pf.leg_b, "filled_amount", 0.0), self.real_amount_b)
step_a = contract_spec(self.inst_a).get("step", 0.001)
step_b = contract_spec(self.inst_b).get("step", 0.001)
ok_a = filled_a >= self.real_amount_a - step_a / 2
ok_b = filled_b >= self.real_amount_b - step_b / 2
frac_a = filled_a / self.real_amount_a if self.real_amount_a else 0.0
frac_b = filled_b / self.real_amount_b if self.real_amount_b else 0.0
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
@@ -270,26 +286,31 @@ class PairsWorker:
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_a if ok_a else 0.0) + (gross_b if ok_b else 0.0)
real_pnl = (gross_a * frac_a + gross_b * frac_b
- 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,
"filled_a": filled_a, "filled_b": filled_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})
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,
for ok, inst, side, amt, filled, step in (
(ok_a, self.inst_a, self.real_side_a, self.real_amount_a, filled_a, step_a),
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b, filled_b, step_b)):
residue = amt - filled
if not ok and residue >= step / 2:
orphan = {"instrument": inst, "entry_side": side,
"amount": round(residue, 8),
"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")})
"note": ("gamba NON chiusa per il residuo indicato (netting "
"negato/fallito): posizione orfana sul conto"
"risolvere e RIMUOVERE l'orfano dallo status")})
self.real_in_position = False
self.real_dir = 0
self.real_side_a = self.real_side_b = ""
+19 -8
View File
@@ -73,6 +73,7 @@ class StrategyWorker:
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
self._tp_phantom_notified = False # alert Telegram una tantum per processo
self._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom
self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id
@@ -349,7 +350,9 @@ class StrategyWorker:
# 0) disaster bracket: via dal book PRIMA di chiudere (se la cancel fallisce
# lo stop potrebbe essere SCATTATO durante un outage: quota gia' chiusa →
# il market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False)
# il reduce-only a valle filla 0 e il GUARD del netting in close_amount
# nega il residuo non-reduce-only — gap conto-vs-libri ~0 → niente
# ordine nudo; REAL_CLOSE esce verified=False + REAL_CLOSE_PARTIAL)
# NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della
# cancel ('untriggered' = successo, verificato su testnet: il re-cancel da'
# order_not_found). 'order_not_found' = ordine non piu' in book (probabile
@@ -398,20 +401,21 @@ class StrategyWorker:
if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id)
# 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
# amount FILLATO, non richiesto (audit 2026-06-11) — e si booka anche a
# verified=False: nel Fill merged dal netting filled_amount conta gia'
# solo i fill RISCONTRATI; azzerarlo quando il leg residuo fallisce
# butterebbe via contratti realmente chiusi (code-review 2026-06-11)
market_amt = fill.filled_amount if fill 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:
if fill 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?)")}
"note": ("close non completato (netting negato/leg fallito): "
"quota residua NON chiusa — verificare col reconciler")}
self._log("REAL_CLOSE_PARTIAL", data)
self._notify("REAL_CLOSE_PARTIAL", data)
@@ -489,6 +493,12 @@ class StrategyWorker:
if (self.direction == 1 and current_price >= self.tp) or \
(self.direction == -1 and current_price <= self.tp):
return False
# TTL: il wick fantasma resta nella barra in formazione per ~1h → senza
# cache sono ~45-55 HTTP consecutivi per worker per barra (code-review).
# 120s di validita': un fill reale tardivo viene visto al massimo 2 min dopo.
cache_bar, cache_t = self._tp_phantom_cache
if cache_bar == current_ts and time.monotonic() - cache_t < 120:
return True
try:
tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument,
self.real_tp_order_id)
@@ -496,6 +506,7 @@ class StrategyWorker:
return False
if tp_amt > 0:
return False
self._tp_phantom_cache = (current_ts, time.monotonic())
if current_ts != self._tp_phantom_ts:
self._tp_phantom_ts = current_ts
data = {"tp": self.tp, "price": current_price, "direction": self.direction,
+17 -4
View File
@@ -475,15 +475,28 @@ def run(config_path: str = "portfolios.yml"):
def _tick_safe(s, w):
try:
_tick(s, w)
worker_err_streak.pop(s.sid, None)
n_prev = worker_err_streak.pop(s.sid, 0)
if n_prev >= 5:
# recovery dichiarato (code-review: alert one-shot senza
# ripresa = silenzio indistinguibile dal guasto)
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK",
{"worker": s.sid, "status": "RIPRESO",
"dopo_streak": n_prev})
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:
# alert a 5 e poi ogni 50 poll (~1h): un worker rotto per
# giorni non deve sparire dopo il primo Telegram. Include
# se ha una posizione REALE aperta (exit non valutati!)
if n == 5 or n % 50 == 0:
from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK",
{"worker": s.sid, "streak": n, "errore": str(e)[:200]})
notify_event("WORKER_ERROR_STREAK", {
"worker": s.sid, "streak": n, "errore": str(e)[:200],
"real_in_position": bool(getattr(w, "real_in_position", False)),
"in_position": bool(getattr(w, "in_position", False)),
"nota": "exit NON valutati per questo worker"})
for s in live_specs:
_tick_safe(s, workers[s.sid])
+49 -2
View File
@@ -10,8 +10,9 @@ def _fill(inst, side, amount, filled, px, verified=True, state="filled", fee=0.0
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."""
def _client_with(submits, allowance=float("inf")):
"""ExecutionClient con _submit finto e guard del netting stubbato (il guard
reale legge conto+libri via HTTP/filesystem; qui si inietta il gap)."""
ec = ExecutionClient()
calls = []
@@ -21,6 +22,7 @@ def _client_with(submits):
return submits.pop(0)(instrument, side, amount)
ec._submit = fake_submit
ec._net_close_allowance = lambda *a, **k: allowance
return ec, calls
@@ -80,3 +82,48 @@ def test_net_leg_also_fails_reports_partial():
assert len(calls) == 2
assert not f.verified
assert abs(f.filled_amount - 0.05) < 1e-9 # solo il fillato vero nel ledger
def test_stale_state_guard_blocks_naked_order():
# quota gia' chiusa (DSL in outage / flatten manuale): reduce-only filla 0 e
# il gap conto-vs-libri e' 0 -> NESSUN ordine market nudo (code-review 2026-06-11)
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
], allowance=0.0)
f = ec.close_amount(INST, "buy", 0.105, label="w1")
assert len(calls) == 1 # solo il reduce-only, niente fallback
assert not f.verified and f.filled_amount == 0.0
assert "netting NEGATO" in f.notes
def test_guard_unknown_fails_safe():
# guard non calcolabile (rete): FAIL-SAFE, niente ordine nudo
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=0.0, px=None, verified=False, state="error", fee=0.0),
], allowance=None)
f = ec.close_amount(INST, "buy", 0.105, label="w1")
assert len(calls) == 1
assert "non calcolabile" in f.notes
def test_guard_caps_residual_to_allowance():
# gap parziale: il residuo nudo e' cappato a quanto i libri giustificano
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=1700.0),
], allowance=0.027)
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 2
assert abs(calls[1]["amount"] - 0.027) < 1e-9
assert not f.verified # 0.027 < 0.105: chiusura parziale onesta
assert abs(f.filled_amount - 0.027) < 1e-9
def test_dust_residual_no_fallback():
# residuo da artefatto float (1e-17): NON deve diventare un lotto minimo nudo
ec, calls = _client_with([
lambda i, s, a: _fill(i, s, a, filled=a - 1e-17, px=1700.0),
])
f = ec.close_amount(INST, "sell", 0.105, label="w1")
assert len(calls) == 1
assert f.verified
+2 -1
View File
@@ -21,7 +21,8 @@ class FakePairsExec:
def _fill(self, inst, side, price, amt=1.0, ok=True):
return Fill(inst, side, 0.0, amt if ok else 0.0, price if ok else None,
0.0, 0.05 if ok else 0.0, "oid" if ok else None,
"filled" if ok else "error", ok)
"filled" if ok else "error", ok,
filled_amount=amt if ok else 0.0)
def open_pair(self, inst_a, inst_b, direction, notional, label=None):
side_a = "buy" if direction == 1 else "sell"
+2 -1
View File
@@ -134,7 +134,8 @@ class FakePairsExec:
self.px = dict(open_a=open_a, open_b=open_b, close_a=close_a, close_b=close_b)
def _fill(self, inst, side, amount, px):
return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True)
return Fill(inst, side, 0.0, amount, px, 0.0, 0.02, "oid", "filled", True,
filled_amount=amount)
def open_pair(self, inst_a, inst_b, direction, notional, label=None):
sa = "buy" if direction == 1 else "sell"