Compare commits
2 Commits
3f1feb1a6f
...
36ac2426a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 36ac2426a1 | |||
| 82c05f6f81 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = ""
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user