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): - [ ] **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. 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 ## 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. - [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 = [] rows = []
for name, r in family_returns().items(): for name, r in family_returns().items():
for win in WINDOWS: 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() roll = roll.dropna()
if len(roll) < 100: if len(roll) < 100:
continue continue
@@ -99,4 +101,6 @@ def main():
if __name__ == "__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 from __future__ import annotations
import json
import sys import sys
import time import time
from pathlib import Path 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.cerbero_client import CerberoClient
from src.live.execution import contract_spec 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 RECHECK_SLEEP = 10 # anti-race: secondi fra i due passaggi
TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto TOL_STEPS = 1.5 # tolleranza = 1.5 × step contratto
def _inst(asset: str) -> str: def compute_drift(client: CerberoClient | None = None) -> list[dict]:
return f"{asset}_USDC-PERPETUAL" client = client or CerberoClient()
books, orphans = real_books()
acct = account_net(client)
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)
rows = [] rows = []
for inst in sorted(set(books) | set(orphans) | set(acct)): for inst in sorted(set(books) | set(orphans) | set(acct)):
exp = books.get(inst, 0.0) + orphans.get(inst, 0.0) 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"] step = contract_spec(inst)["step"]
entry_side = "buy" if p["direction"] == "long" else "sell" entry_side = "buy" if p["direction"] == "long" else "sell"
units = size_usd / mark if mark else 0 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: if amount <= 0:
print(f" {inst}: residuo {size_usd:.2f} USD sotto mezzo step, ok") print(f" {inst}: residuo {size_usd:.2f} USD sotto mezzo step, ok")
continue continue
+5 -2
View File
@@ -58,8 +58,11 @@ def main():
print(f" ESITO: {'OK (replay == backtest)' if ok1 else 'DIFF -> INDAGARE'}") 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 # [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.analysis.xs01_tranche_research import xsec_trades
from scripts.strategies.XS01_cross_sectional import POS, LEV
K = 3 K = 3
w3 = _replay(M, dfs, {"lb": LB, "hold": HOLD, "tranches": K}) w3 = _replay(M, dfs, {"lb": LB, "hold": HOLD, "tranches": K})
step = HOLD // K step = HOLD // K
@@ -67,7 +70,7 @@ def main():
key=lambda t: t[1]) key=lambda t: t[1])
cap = 1000.0 cap = 1000.0
for _, _, net in allt: 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 cap_ok3 = abs(w3.capital - cap) / cap < 0.02
trd_ok3 = abs(w3.total_trades - len(allt)) <= max(2, len(allt) * 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}") 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)) 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(): def run():
M = aligned_panel() M = aligned_panel()
ts = pd.to_datetime(M.index, unit="ms", utc=True) ts = pd.to_datetime(M.index, unit="ms", utc=True)
@@ -109,6 +130,7 @@ def run():
print("=" * 96) print("=" * 96)
print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata") print(" XS01 phase-tranching — sensibilita' di fase + ensemble K=2/3 | griglia pre-registrata")
print("=" * 96) print("=" * 96)
regression_lock(M)
for tag, split in (("FULL", 0.0), ("OOS", 1 - OOS_FRAC)): 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 yrs_span = (ts[-1] - ts[max(int(n * split), LB)]).days / 365.25 or 1
rows = [] rows = []
+3 -1
View File
@@ -154,7 +154,9 @@ def multi_asset_section() -> str:
book[a] = book.get(a, 0.0) + v / k book[a] = book.get(a, 0.0) + v / k
if book is None: if book is None:
continue # single-leg/pairs: gia' coperti da collect() 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" flip = "mai"
tp = d / "trades.jsonl" tp = d / "trades.jsonl"
if tp.exists(): 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)))) 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 """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") tick = contract_spec(instrument).get("tick")
if not tick or price <= 0: if not tick or price <= 0:
return price 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, 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, 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, net.order_id or ro.order_id, net.order_state or ro.order_state,
verified, raw={"reduce_only": ro.raw, "net": net.raw}, verified, raw={"reduce_only": ro.raw, "net": net.raw},
notes=(f"netting: reduce-only {fa}/{requested}, residuo {fb} " notes=(f"netting: reduce-only {fa}/{requested} (oid {ro.order_id}), "
f"market puro ({net.order_state})"), f"residuo {fb} market puro ({net.order_state}, oid {net.order_id})"),
filled_amount=tot) filled_amount=tot)
@@ -316,13 +327,48 @@ class ExecutionClient:
fill = self._submit(instrument, opp, amount, 0.0, fill = self._submit(instrument, opp, amount, 0.0,
reduce_only=True, label=label) reduce_only=True, label=label)
residual = amount - fill.filled_amount 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: if residual < spec["step"] / 2:
return fill 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, net = self._submit(instrument, opp, residual, 0.0, reduce_only=False,
label=f"{label}|net" if label else "net") label=f"{label}|net" if label else "net")
return _merge_close_fills(fill, net, amount) 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, def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
tp_price: float, label: str | None = None) -> Fill: tp_price: float, label: str | None = None) -> Fill:
"""LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata): """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 TP il limit crossa e filla subito (state 'filled'); la riconciliazione a
valle (resting_fills) lo gestisce come un fill normale.""" valle (resting_fills) lo gestisce come un fill normale."""
opp = "sell" if entry_side == "buy" else "buy" 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: if amount <= 0 or px <= 0:
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0, return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
None, None, False, notes="amount/tp non validi") None, None, False, notes="amount/tp non validi")
@@ -474,7 +520,10 @@ class PairsExecutionClient:
unwound = False unwound = False
for f, inst in ((fa, inst_a), (fb, inst_b)): for f, inst in ((fa, inst_a), (fb, inst_b)):
if f.verified and f.amount > 0: 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 unwound = True
return PairFill(False, fa, fb, unwound=unwound, return PairFill(False, fa, fb, unwound=unwound,
notes=f"leg-fail (a={fa.verified} b={fb.verified}), 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_in_position = True
self.real_dir = d self.real_dir = d
self.real_side_a, self.real_side_b = pf.leg_a.side, pf.leg_b.side 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_a = pf.leg_a.fill_price or sim_a
self.real_entry_b = pf.leg_b.fill_price or sim_b self.real_entry_b = pf.leg_b.fill_price or sim_b
self.real_notional_a = pf.leg_a.amount * self.real_entry_a 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, def _real_close_pair(self, sim_a: float, sim_b: float, reason: str,
sim_pnl: float) -> tuple[float | None, bool]: sim_pnl: float) -> tuple[float | None, bool]:
"""Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia """Chiusura REALE shadow: richiude entrambe le gambe (netting-aware),
PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo. 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 Ritorna (real_pnl, applied): applied=True SOLO se ENTRAMBE le gambe hanno
fill verificato (PnL reale utilizzabile come verita' in real-truth).""" 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: if not self.real_in_position:
return None, False return None, False
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a, 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). # 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 # Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba
# respinta diventa un ORFANO registrato (persistito) + alert Telegram. # 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): for leg in (pf.leg_a, pf.leg_b):
if "netting" in (getattr(leg, "notes", "") or ""): if "netting" in (getattr(leg, "notes", "") or ""):
# reduce-only cappato/respinto, residuo in market puro (v1.1.25) # reduce-only cappato/respinto, residuo in market puro (v1.1.25)
self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes}) self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
self._notify("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_a = pf.leg_a.fill_price or sim_a
exit_b = pf.leg_b.fill_price or sim_b exit_b = pf.leg_b.fill_price or sim_b
# PnL per gamba: dir A = +d (long ratio compra A), dir B = -d # 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_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 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 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_entry_fee - exit_fee)
self.real_capital += real_pnl self.real_capital += real_pnl
self.real_trades += 1 self.real_trades += 1
self._log("REAL_CLOSE_PAIR", { self._log("REAL_CLOSE_PAIR", {
"reason": reason, "exit_a": exit_a, "exit_b": exit_b, "reason": reason, "exit_a": exit_a, "exit_b": exit_b,
"leg_a_ok": ok_a, "leg_b_ok": ok_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), "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), "entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5),
"real_capital": round(self.real_capital, 4), "verified": pf.verified}) "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), for ok, inst, side, amt, filled, step in (
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b)): (ok_a, self.inst_a, self.real_side_a, self.real_amount_a, filled_a, step_a),
if not ok and amt > 0: (ok_b, self.inst_b, self.real_side_b, self.real_amount_b, filled_b, step_b)):
orphan = {"instrument": inst, "entry_side": side, "amount": amt, 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} "ts": datetime.now(timezone.utc).isoformat(), "reason": reason}
self.orphan_legs.append(orphan) self.orphan_legs.append(orphan)
self._notify("PAIR_LEG_ORPHAN", { self._notify("PAIR_LEG_ORPHAN", {
"worker": self.worker_id, **orphan, "worker": self.worker_id, **orphan,
"note": ("gamba NON chiusa (reduce-only respinto dal netting di " "note": ("gamba NON chiusa per il residuo indicato (netting "
"conto?): posizione orfana sul conto, intervento richiesto")}) "negato/fallito): posizione orfana sul conto"
"risolvere e RIMUOVERE l'orfano dallo status")})
self.real_in_position = False self.real_in_position = False
self.real_dir = 0 self.real_dir = 0
self.real_side_a = self.real_side_b = "" 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.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_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
self._tp_phantom_notified = False # alert Telegram una tantum per processo 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.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id 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 # 0) disaster bracket: via dal book PRIMA di chiudere (se la cancel fallisce
# lo stop potrebbe essere SCATTATO durante un outage: quota gia' chiusa → # 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 # NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della
# cancel ('untriggered' = successo, verificato su testnet: il re-cancel da' # cancel ('untriggered' = successo, verificato su testnet: il re-cancel da'
# order_not_found). 'order_not_found' = ordine non piu' in book (probabile # order_not_found). 'order_not_found' = ordine non piu' in book (probabile
@@ -398,20 +401,21 @@ class StrategyWorker:
if remainder >= step / 2: if remainder >= step / 2:
fill = self.executor.close_amount(self.exec_instrument, self.real_side, fill = self.executor.close_amount(self.exec_instrument, self.real_side,
remainder, label=self.worker_id) remainder, label=self.worker_id)
# amount FILLATO, non richiesto: un reduce-only cappato dal netting di conto # amount FILLATO, non richiesto (audit 2026-06-11) — e si booka anche a
# (worker long e short sullo stesso strumento) filla MENO del chiesto — # verified=False: nel Fill merged dal netting filled_amount conta gia'
# bookarlo pieno mentirebbe sul ledger (audit 2026-06-11: 0.078 vs 0.105) # solo i fill RISCONTRATI; azzerarlo quando il leg residuo fallisce
market_amt = fill.filled_amount if (fill and fill.verified) else 0.0 # 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 ""): if fill and "netting" in (getattr(fill, "notes", "") or ""):
# il reduce-only era cappato/respinto e il residuo e' andato in market # il reduce-only era cappato/respinto e il residuo e' andato in market
# puro (netting contro quote opposte) — solo osservabilita' # puro (netting contro quote opposte) — solo osservabilita'
self._log("NET_CLOSE", {"note": fill.notes}) self._log("NET_CLOSE", {"note": fill.notes})
self._notify("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, data = {"requested": remainder, "filled": market_amt,
"residuo_orfano": round(remainder - market_amt, 6), "residuo_orfano": round(remainder - market_amt, 6),
"note": ("close reduce-only cappato dal netting di conto: quota " "note": ("close non completato (netting negato/leg fallito): "
"residua NON chiusa (direzioni opposte sullo strumento?)")} "quota residua NON chiusa — verificare col reconciler")}
self._log("REAL_CLOSE_PARTIAL", data) self._log("REAL_CLOSE_PARTIAL", data)
self._notify("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 \ if (self.direction == 1 and current_price >= self.tp) or \
(self.direction == -1 and current_price <= self.tp): (self.direction == -1 and current_price <= self.tp):
return False 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: try:
tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument, tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument,
self.real_tp_order_id) self.real_tp_order_id)
@@ -496,6 +506,7 @@ class StrategyWorker:
return False return False
if tp_amt > 0: if tp_amt > 0:
return False return False
self._tp_phantom_cache = (current_ts, time.monotonic())
if current_ts != self._tp_phantom_ts: if current_ts != self._tp_phantom_ts:
self._tp_phantom_ts = current_ts self._tp_phantom_ts = current_ts
data = {"tp": self.tp, "price": current_price, "direction": self.direction, 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): def _tick_safe(s, w):
try: try:
_tick(s, w) _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: except Exception as e:
n = worker_err_streak.get(s.sid, 0) + 1 n = worker_err_streak.get(s.sid, 0) + 1
worker_err_streak[s.sid] = n worker_err_streak[s.sid] = n
print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)") 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 from src.live.telegram_notifier import notify_event
notify_event("WORKER_ERROR_STREAK", notify_event("WORKER_ERROR_STREAK", {
{"worker": s.sid, "streak": n, "errore": str(e)[:200]}) "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: for s in live_specs:
_tick_safe(s, workers[s.sid]) _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) f"oid-{side}-{filled}", state, verified, filled_amount=filled)
def _client_with(submits): def _client_with(submits, allowance=float("inf")):
"""ExecutionClient con _submit finto: consuma la lista `submits` e registra le chiamate.""" """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() ec = ExecutionClient()
calls = [] calls = []
@@ -21,6 +22,7 @@ def _client_with(submits):
return submits.pop(0)(instrument, side, amount) return submits.pop(0)(instrument, side, amount)
ec._submit = fake_submit ec._submit = fake_submit
ec._net_close_allowance = lambda *a, **k: allowance
return ec, calls return ec, calls
@@ -80,3 +82,48 @@ def test_net_leg_also_fails_reports_partial():
assert len(calls) == 2 assert len(calls) == 2
assert not f.verified assert not f.verified
assert abs(f.filled_amount - 0.05) < 1e-9 # solo il fillato vero nel ledger 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): 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, 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, 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): def open_pair(self, inst_a, inst_b, direction, notional, label=None):
side_a = "buy" if direction == 1 else "sell" 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) 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): 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): def open_pair(self, inst_a, inst_b, direction, notional, label=None):
sa = "buy" if direction == 1 else "sell" sa = "buy" if direction == 1 else "sell"