fix(exec): verità contabile sul netting — filled_amount, gambe orfane, tick isolato
Da audit live (3 indagini parallele, diario 2026-06-11-system-audit.md): il modello quote-per-worker reduce-only si rompe con direzioni opposte sullo stesso strumento (pairs long ETH vs fade short ETH) — close cappati bookati pieni, 3 gambe pairs mai eseguite col PnL sim nel ledger reale, conto short 0.027 ETH oltre i libri (riallineato a mano + DSL orfano cancellato). - Fill.filled_amount (order.filled_amount): TUTTI i ledger usano il fillato reale - REAL_CLOSE_PARTIAL (log+alert) su close cappato; residuo orfano dichiarato - pairs: PnL solo per gambe verificate; gamba respinta -> orphan_legs persistito + alert PAIR_LEG_ORPHAN; applied solo con entrambe le gambe (else sim_fallback) - REAL_DIVERGENCE anche su jsonl (prima solo Telegram) - runner: tick isolato per-worker + WORKER_ERROR_STREAK a 5 fail consecutivi Strutturale APERTO (decisione utente, opzioni nel diario): position-manager centrale / sotto-conti per famiglia / status-quo monitorato. Test: +2 (partial-close, orphan-leg), fixture filled_amount -> 106 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -451,6 +451,21 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale
|
|||||||
di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN
|
di parità della stessa tornata (v1.1.3): TR01 fee×leva + forming-bar TR01/Pairs + WARN
|
||||||
`PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
|
`PANEL_SHORT` su TSM01/ROT02; `hourly_report` ora mostra i multi-asset (sezione MULTI-ASSET).
|
||||||
Diario `docs/diary/2026-06-07-sweep-fixes.md`.
|
Diario `docs/diary/2026-06-07-sweep-fixes.md`.
|
||||||
|
- **VERITÀ CONTABILE su netting di conto (v1.1.24, 2026-06-11, da audit live).** Il modello
|
||||||
|
"quote per-worker con reduce-only" si rompe quando worker dello stesso strumento hanno
|
||||||
|
direzioni OPPOSTE (pairs long ETH vs fade short ETH): Deribit cappa/respinge i reduce-only
|
||||||
|
in silenzio. L'audit ha trovato: close fillato 0.078 ma bookato 0.105 (`Fill.amount` era il
|
||||||
|
RICHIESTO), 3 gambe pairs mai eseguite col PnL sim sommato al ledger reale (gamba orfana sul
|
||||||
|
conto, ETH/SOL di fatto short nudo), e il conto short 0.027 ETH più dei libri (riallineato a
|
||||||
|
mano). Fix: (1) `Fill.filled_amount` (fonte `order.filled_amount`) usato da TUTTI i ledger;
|
||||||
|
(2) `REAL_CLOSE_PARTIAL` (log+alert) quando il close filla meno del residuo; (3) pairs:
|
||||||
|
PnL bookato SOLO per le gambe verificate, gamba respinta → `orphan_legs` persistito +
|
||||||
|
alert `PAIR_LEG_ORPHAN`, `applied` solo con ENTRAMBE le gambe (altrimenti sim_fallback
|
||||||
|
dichiarato); (4) `REAL_DIVERGENCE` anche su jsonl (prima solo Telegram); (5) runner:
|
||||||
|
tick isolato per-worker (un'eccezione non salta gli altri; alert `WORKER_ERROR_STREAK` a 5).
|
||||||
|
IRRISOLTO strutturale: finché pairs e fade condividono strumenti in direzioni opposte, le
|
||||||
|
chiusure possono orfanizzarsi — ora è VISIBILE (alert+persistito), non più silente. Opzioni
|
||||||
|
di design in docs/diary/2026-06-11-system-audit.md (decisione utente).
|
||||||
- **TP_PHANTOM — il tocco TP va confermato dal book reale (v1.1.23, 2026-06-11).** Il feed
|
- **TP_PHANTOM — il tocco TP va confermato dal book reale (v1.1.23, 2026-06-11).** Il feed
|
||||||
testnet stampa wick anomali che (a) generano segnali fade su ETH e (b) "toccano" il TP
|
testnet stampa wick anomali che (a) generano segnali fade su ETH e (b) "toccano" il TP
|
||||||
intrabar della stessa barra: il sim bookava +4% fantasma a bars_held=0 e `_real_close`
|
intrabar della stessa barra: il sim bookava +4% fantasma a bars_held=0 e `_real_close`
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 2026-06-11 — Audit di sistema + verità contabile sul netting (v1.1.24)
|
||||||
|
|
||||||
|
Seconda tornata della giornata (dopo lo sweep strategie): analisi del SISTEMA live
|
||||||
|
(esecuzione, stato, feed, processo) con 3 indagini parallele + fix.
|
||||||
|
|
||||||
|
## Audit live (dal reset 2026-06-10T21:24Z)
|
||||||
|
|
||||||
|
- **Churn phantom-TP quantificato**: 20 round-trip fantasma sui fade ETH (2 finestre,
|
||||||
|
13:06Z e 17:32-17:58Z, feed che stampava wick ~1640 con mercato a 1675-90 per 25 min).
|
||||||
|
Somma real_pnl −2.35 USD vs sim_pnl +80 USD: il real-truth ledger ha contenuto, il gate
|
||||||
|
TP_PHANTOM (v1.1.23) copre il pattern da stasera.
|
||||||
|
- **Il conto NON quadrava coi libri**: short 0.027 ETH in più (l'hedge long ETH del pair
|
||||||
|
ETH/SOL mangiato dai close reduce-only dei fade) e BTC flat con MR02_BTC convinto di
|
||||||
|
essere short (TP resting fillato da uno spike REALE del book a −3.8%, +6.6$ non bookati,
|
||||||
|
che il worker riconcilierà alla chiusura sim) col **disaster-SL residuo sul book a
|
||||||
|
posizione flat**. Bonifica eseguita: DSL `USDC-SLMB-26521` cancellato, riallineo
|
||||||
|
+0.027 ETH reduce-only (fill 1682.5) → conto == libri (verificato).
|
||||||
|
|
||||||
|
## Root-cause strutturale
|
||||||
|
|
||||||
|
Le **quote per-worker con ordini reduce-only su un conto a NETTING si rompono quando due
|
||||||
|
worker hanno direzioni opposte sullo stesso strumento** (pairs long ETH vs fade short ETH):
|
||||||
|
- un close reduce-only può essere **cappato** (Deribit riduce l'amount in silenzio) → il
|
||||||
|
ledger bookava la chiusura PIENA perché `Fill.amount` era il richiesto, non il fillato;
|
||||||
|
- un close reduce-only nel verso "sbagliato" rispetto al netto viene **respinto** → la
|
||||||
|
gamba pairs resta orfana sul conto, ma il worker bookava il PnL al prezzo sim e azzerava
|
||||||
|
lo stato (3 volte oggi: PnL fantasma nel ledger real-truth, ETH/SOL di fatto short nudo).
|
||||||
|
|
||||||
|
## Fix (v1.1.24) — verità, non workaround
|
||||||
|
|
||||||
|
1. **`Fill.filled_amount`** (da `order.filled_amount`, fallback trades/history): tutti i
|
||||||
|
ledger usano il fillato; nota "FILL PARZIALE" nel Fill.
|
||||||
|
2. **`REAL_CLOSE_PARTIAL`** (log + Telegram): close che filla meno del residuo → residuo
|
||||||
|
orfano dichiarato, `REAL_CLOSE verified=false`.
|
||||||
|
3. **Pairs per-gamba**: PnL bookato SOLO per gambe con fill verificato; gamba respinta →
|
||||||
|
record in `orphan_legs` (persistito nello status) + alert `PAIR_LEG_ORPHAN`;
|
||||||
|
`applied=True` (real-truth) solo con ENTRAMBE le gambe → altrimenti fallback sim
|
||||||
|
DICHIARATO (meglio del numero mezzo-reale di prima).
|
||||||
|
4. **`REAL_DIVERGENCE` anche su jsonl** (prima solo Telegram: l'audit ha dovuto
|
||||||
|
ricostruire gli episodi dai REAL_CLOSE).
|
||||||
|
5. **Runner: tick isolato per-worker** — un'eccezione in un worker non salta più gli
|
||||||
|
altri né l'update equity; streak per-worker con alert `WORKER_ERROR_STREAK` a 5.
|
||||||
|
|
||||||
|
Test: 2 nuovi (partial-close, orphan-leg) + fixture aggiornate → 106 passed.
|
||||||
|
|
||||||
|
## Decisione di design APERTA (per l'utente)
|
||||||
|
|
||||||
|
Finché pairs e fade condividono strumenti in direzioni opposte, le chiusure possono
|
||||||
|
orfanizzarsi: ora è VISIBILE e contabilizzato giusto, ma non eliminato. Opzioni:
|
||||||
|
A) **Position-manager centrale per strumento** (il runner netta i delta di tutti i
|
||||||
|
worker e manda UN ordine; ricostruisce le quote contabilmente) — corretto ma
|
||||||
|
invasivo, da progettare con calma;
|
||||||
|
B) **Sotto-conti Deribit separati** per famiglia (pairs vs fade) — pulito, richiede
|
||||||
|
setup conto e client multi-token;
|
||||||
|
C) **Status quo monitorato**: alert PAIR_LEG_ORPHAN + riallineo manuale (oggi: 1 giro
|
||||||
|
in 2 minuti) — accettabile su testnet, NON per capitale vero.
|
||||||
|
Raccomandazione: C ora, A prima di passare a capitale reale.
|
||||||
|
|
||||||
|
## Altri esiti della tornata
|
||||||
|
|
||||||
|
- **Dedup engine gate** (TODO chiuso): `_port06_gate_common.py`, output 3 gate
|
||||||
|
byte-identici, nessun copy-drift trovato (la paura era fondata ma non ancora avverata).
|
||||||
|
- **Bug bfill `_daily_equity` quantificato** (TODO aggiornato): NON materiale — OOS
|
||||||
|
invariato per costruzione, FULL DD 3.46→3.67 col fix (l'attuale è lievemente ottimista),
|
||||||
|
nessun verdetto di gate a rischio. Lasciato documentato.
|
||||||
|
- **Drift monitor in produzione**: `drift_monitor.py` in crontab host (07:15 UTC,
|
||||||
|
Telegram): rolling 60/120g per famiglia vs distribuzione storica propria, warn < p5.
|
||||||
|
Oggi: FADE 120g al p2 (coda storica, nessun intervento), resto normale, XSEC p84.
|
||||||
|
- **Dati cerbero-bite refreshati**: catena opzioni a ~153k righe/asset (fino a oggi
|
||||||
|
19:30), pannello regime denso (net-GEX 644/673 ultimi 7g). Sempre un solo regime:
|
||||||
|
niente validazione edge, valore forward.
|
||||||
+20
-1
@@ -119,6 +119,12 @@ class Fill:
|
|||||||
verified: bool # posizione/trade riscontrati su Deribit
|
verified: bool # posizione/trade riscontrati su Deribit
|
||||||
raw: dict[str, Any] = field(default_factory=dict)
|
raw: dict[str, Any] = field(default_factory=dict)
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
# amount REALMENTE fillato (order.filled_amount / somma trades) — puo' essere
|
||||||
|
# MINORE del richiesto: un reduce-only cappato dal netting di conto (worker
|
||||||
|
# long e short sullo stesso strumento) viene ridotto in silenzio da Deribit.
|
||||||
|
# Audit 2026-06-11: close 0.105, fillato 0.078, bookato pieno perche' il
|
||||||
|
# ledger usava `amount`. I ledger devono usare QUESTO campo.
|
||||||
|
filled_amount: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
def _avg_fill_price(order: dict, trades: list[dict]) -> float | None:
|
def _avg_fill_price(order: dict, trades: list[dict]) -> float | None:
|
||||||
@@ -233,6 +239,15 @@ class ExecutionClient:
|
|||||||
fee_usd = fee_coin if spec.get("linear") else (
|
fee_usd = fee_coin if spec.get("linear") else (
|
||||||
fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
|
fee_coin * fill_price if (fee_coin and fill_price) else 0.0)
|
||||||
|
|
||||||
|
# amount REALMENTE fillato: order.filled_amount e' la fonte autorevole
|
||||||
|
# (Deribit RIDUCE in silenzio un reduce-only che eccede il netto di conto);
|
||||||
|
# fallback: somma trades del fill, poi trade history
|
||||||
|
filled = float(order.get("filled_amount") or 0)
|
||||||
|
if not filled:
|
||||||
|
filled = sum(float(t.get("amount", 0) or 0) for t in trades)
|
||||||
|
if not filled and th:
|
||||||
|
filled = float(th.get("amount") or 0)
|
||||||
|
|
||||||
# VERIFICA: market = ordine filled E fill riscontrato (trades o history);
|
# VERIFICA: market = ordine filled E fill riscontrato (trades o history);
|
||||||
# limit = accettato in book ('open') o gia' eseguito ('filled');
|
# limit = accettato in book ('open') o gia' eseguito ('filled');
|
||||||
# stop_market = trigger accettato ('untriggered' finche' il mark non tocca)
|
# stop_market = trigger accettato ('untriggered' finche' il mark non tocca)
|
||||||
@@ -242,9 +257,13 @@ class ExecutionClient:
|
|||||||
verified = state in ("untriggered", "open", "filled")
|
verified = state in ("untriggered", "open", "filled")
|
||||||
else:
|
else:
|
||||||
verified = state in ("open", "filled")
|
verified = state in ("open", "filled")
|
||||||
|
notes = "" if verified else f"fill non verificato (state={state}, trades={len(trades)})"
|
||||||
|
if verified and order_type == "market" and filled < amount - 1e-12:
|
||||||
|
notes = (f"FILL PARZIALE: {filled} su {amount} richiesti "
|
||||||
|
"(reduce-only cappato dal netting di conto?)")
|
||||||
return Fill(instrument, side, requested_notional, amount, fill_price,
|
return Fill(instrument, side, requested_notional, amount, fill_price,
|
||||||
fee_coin, fee_usd, order_id, state, verified, raw=resp,
|
fee_coin, fee_usd, order_id, state, verified, raw=resp,
|
||||||
notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})")
|
notes=notes, filled_amount=filled)
|
||||||
|
|
||||||
def open(self, instrument: str, side: str, notional_usd: float,
|
def open(self, instrument: str, side: str, notional_usd: float,
|
||||||
label: str | None = None) -> Fill:
|
label: str | None = None) -> Fill:
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ class PairsWorker:
|
|||||||
self.real_entry_fee = 0.0
|
self.real_entry_fee = 0.0
|
||||||
self.real_trades = 0
|
self.real_trades = 0
|
||||||
self.real_first_notified = False
|
self.real_first_notified = False
|
||||||
|
self.orphan_legs: list[dict] = [] # gambe respinte dal netting (persistite)
|
||||||
|
|
||||||
self._load_state()
|
self._load_state()
|
||||||
self._save_state()
|
self._save_state()
|
||||||
@@ -146,6 +147,7 @@ class PairsWorker:
|
|||||||
self.real_entry_fee = s.get("real_entry_fee", 0.0)
|
self.real_entry_fee = s.get("real_entry_fee", 0.0)
|
||||||
self.real_trades = s.get("real_trades", 0)
|
self.real_trades = s.get("real_trades", 0)
|
||||||
self.real_first_notified = s.get("real_first_notified", False)
|
self.real_first_notified = s.get("real_first_notified", False)
|
||||||
|
self.orphan_legs = s.get("orphan_legs", [])
|
||||||
self._log("RESUME", {"capital": round(self.capital, 2),
|
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||||
"total_trades": self.total_trades, "in_position": self.in_position,
|
"total_trades": self.total_trades, "in_position": self.in_position,
|
||||||
"real_capital": round(self.real_capital, 2),
|
"real_capital": round(self.real_capital, 2),
|
||||||
@@ -167,6 +169,7 @@ class PairsWorker:
|
|||||||
"real_notional_a": self.real_notional_a, "real_notional_b": self.real_notional_b,
|
"real_notional_a": self.real_notional_a, "real_notional_b": self.real_notional_b,
|
||||||
"real_entry_fee": self.real_entry_fee, "real_trades": self.real_trades,
|
"real_entry_fee": self.real_entry_fee, "real_trades": self.real_trades,
|
||||||
"real_first_notified": self.real_first_notified,
|
"real_first_notified": self.real_first_notified,
|
||||||
|
"orphan_legs": self.orphan_legs,
|
||||||
}
|
}
|
||||||
with open(self.status_path, "w") as f:
|
with open(self.status_path, "w") as f:
|
||||||
json.dump(state, f, indent=2)
|
json.dump(state, f, indent=2)
|
||||||
@@ -247,23 +250,41 @@ class PairsWorker:
|
|||||||
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
|
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
|
||||||
self.real_side_b, self.real_amount_a, self.real_amount_b,
|
self.real_side_b, self.real_amount_a, self.real_amount_b,
|
||||||
label=self.worker_id)
|
label=self.worker_id)
|
||||||
|
# VERITA' PER-GAMBA (audit 2026-06-11): una gamba puo' essere RESPINTA dal
|
||||||
|
# netting di conto (reduce-only nel verso sbagliato quando un altro worker e'
|
||||||
|
# nella direzione opposta sullo stesso strumento). Prima il PnL veniva
|
||||||
|
# calcolato col prezzo SIM per la gamba mai eseguita e sommato al ledger
|
||||||
|
# reale (3 PnL fantasma il 2026-06-11, gamba ETH orfana sul conto).
|
||||||
|
# Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba
|
||||||
|
# respinta diventa un ORFANO registrato (persistito) + alert Telegram.
|
||||||
|
ok_a, ok_b = bool(pf.leg_a.verified), bool(pf.leg_b.verified)
|
||||||
exit_a = pf.leg_a.fill_price or sim_a
|
exit_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
|
||||||
da, db = self.real_dir, -self.real_dir
|
da, db = self.real_dir, -self.real_dir
|
||||||
gross = (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
|
||||||
+ 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 - self.real_entry_fee - exit_fee
|
real_pnl = ((gross_a if ok_a else 0.0) + (gross_b if ok_b else 0.0)
|
||||||
|
- self.real_entry_fee - exit_fee)
|
||||||
self.real_capital += real_pnl
|
self.real_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,
|
||||||
"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})
|
||||||
if not pf.verified:
|
for ok, inst, side, amt in ((ok_a, self.inst_a, self.real_side_a, self.real_amount_a),
|
||||||
self._notify("REAL_CLOSE_FAILED", {"worker": self.worker_id, "note": pf.notes})
|
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b)):
|
||||||
|
if not ok and amt > 0:
|
||||||
|
orphan = {"instrument": inst, "entry_side": side, "amount": amt,
|
||||||
|
"ts": datetime.now(timezone.utc).isoformat(), "reason": reason}
|
||||||
|
self.orphan_legs.append(orphan)
|
||||||
|
self._notify("PAIR_LEG_ORPHAN", {
|
||||||
|
"worker": self.worker_id, **orphan,
|
||||||
|
"note": ("gamba NON chiusa (reduce-only respinto dal netting di "
|
||||||
|
"conto?): posizione orfana sul conto, intervento richiesto")})
|
||||||
self.real_in_position = False
|
self.real_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 = ""
|
||||||
@@ -272,7 +293,10 @@ class PairsWorker:
|
|||||||
self.real_notional_a = self.real_notional_b = 0.0
|
self.real_notional_a = self.real_notional_b = 0.0
|
||||||
self.real_entry_fee = 0.0
|
self.real_entry_fee = 0.0
|
||||||
self._save_state()
|
self._save_state()
|
||||||
return real_pnl, bool(pf.verified or pf.leg_a.verified or pf.leg_b.verified)
|
# applied (real-truth) SOLO se entrambe le gambe hanno chiuso verificate:
|
||||||
|
# con una gamba orfana il "PnL reale dello spread" non esiste -> meglio il
|
||||||
|
# fallback sim DICHIARATO che un numero mezzo-reale
|
||||||
|
return real_pnl, ok_a and ok_b
|
||||||
|
|
||||||
def _close(self, ca: float, cb: float, z: float, reason: str):
|
def _close(self, ca: float, cb: float, z: float, reason: str):
|
||||||
if not self.in_position:
|
if not self.in_position:
|
||||||
|
|||||||
@@ -249,13 +249,16 @@ class StrategyWorker:
|
|||||||
}
|
}
|
||||||
if fill.verified:
|
if fill.verified:
|
||||||
linear = contract_spec(self.exec_instrument).get("linear")
|
linear = contract_spec(self.exec_instrument).get("linear")
|
||||||
|
# amount FILLATO, non richiesto (audit 2026-06-11): il ledger deve
|
||||||
|
# seguire i contratti realmente sul conto
|
||||||
|
real_amt = fill.filled_amount or fill.amount
|
||||||
self.real_in_position = True
|
self.real_in_position = True
|
||||||
self.real_side = side
|
self.real_side = side
|
||||||
self.real_amount = fill.amount
|
self.real_amount = real_amt
|
||||||
self.real_entry_price = fill.fill_price or sim_price
|
self.real_entry_price = fill.fill_price or sim_price
|
||||||
self.real_entry_fee_usd = fill.fee_usd
|
self.real_entry_fee_usd = fill.fee_usd
|
||||||
self.real_entry_notional = (fill.amount * self.real_entry_price
|
self.real_entry_notional = (real_amt * self.real_entry_price
|
||||||
if linear else fill.amount)
|
if linear else real_amt)
|
||||||
self.real_order_id = fill.order_id or ""
|
self.real_order_id = fill.order_id or ""
|
||||||
self._log("REAL_OPEN", data)
|
self._log("REAL_OPEN", data)
|
||||||
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
|
if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva
|
||||||
@@ -264,6 +267,7 @@ class StrategyWorker:
|
|||||||
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
|
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
|
||||||
# sim e reale stanno tradando prezzi diversi (spike print/feed stantio):
|
# sim e reale stanno tradando prezzi diversi (spike print/feed stantio):
|
||||||
# il sim sta per bookare PnL che il reale non vede
|
# il sim sta per bookare PnL che il reale non vede
|
||||||
|
self._log("REAL_DIVERGENCE", {"fase": "open", **data})
|
||||||
self._notify("REAL_DIVERGENCE", {"fase": "open", **data})
|
self._notify("REAL_DIVERGENCE", {"fase": "open", **data})
|
||||||
self._place_real_tp()
|
self._place_real_tp()
|
||||||
self._place_disaster_sl()
|
self._place_disaster_sl()
|
||||||
@@ -394,7 +398,17 @@ 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)
|
||||||
market_amt = fill.amount if (fill and fill.verified) else 0.0
|
# amount FILLATO, non richiesto: un reduce-only cappato dal netting di conto
|
||||||
|
# (worker long e short sullo stesso strumento) filla MENO del chiesto —
|
||||||
|
# bookarlo pieno mentirebbe sul ledger (audit 2026-06-11: 0.078 vs 0.105)
|
||||||
|
market_amt = fill.filled_amount if (fill and fill.verified) else 0.0
|
||||||
|
if fill and fill.verified and market_amt < remainder - step / 2:
|
||||||
|
data = {"requested": remainder, "filled": market_amt,
|
||||||
|
"residuo_orfano": round(remainder - market_amt, 6),
|
||||||
|
"note": ("close reduce-only cappato dal netting di conto: quota "
|
||||||
|
"residua NON chiusa (direzioni opposte sullo strumento?)")}
|
||||||
|
self._log("REAL_CLOSE_PARTIAL", data)
|
||||||
|
self._notify("REAL_CLOSE_PARTIAL", data)
|
||||||
|
|
||||||
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
|
# 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali
|
||||||
parts = [(a, p) for a, p in ((tp_amt, tp_px),
|
parts = [(a, p) for a, p in ((tp_amt, tp_px),
|
||||||
@@ -417,10 +431,13 @@ class StrategyWorker:
|
|||||||
slip_bps = ((exit_price / sim_exit - 1) * 1e4
|
slip_bps = ((exit_price / sim_exit - 1) * 1e4
|
||||||
if exit_price and sim_exit else None)
|
if exit_price and sim_exit else None)
|
||||||
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
|
if slip_bps is not None and abs(slip_bps) >= DIVERGENCE_BPS:
|
||||||
self._notify("REAL_DIVERGENCE", {
|
div = {"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2),
|
||||||
"fase": "close", "reason": reason, "sim_exit": round(sim_exit, 2),
|
"real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2),
|
||||||
"real_fill": round(exit_price, 2), "slippage_bps": round(slip_bps, 2),
|
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)}
|
||||||
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4)})
|
# anche su jsonl, non solo Telegram: gli episodi di slippage estremo
|
||||||
|
# devono restare interrogabili (l'audit 2026-06-11 ha dovuto ricostruirli)
|
||||||
|
self._log("REAL_DIVERGENCE", div)
|
||||||
|
self._notify("REAL_DIVERGENCE", div)
|
||||||
self._log("REAL_CLOSE", {
|
self._log("REAL_CLOSE", {
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"order_id": fill.order_id if fill else tp_order_id,
|
"order_id": fill.order_id if fill else tp_order_id,
|
||||||
|
|||||||
+19
-2
@@ -387,6 +387,7 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
# non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio.
|
# non solleva eccezione ma i worker dell'asset mancante saltano il tick in silenzio.
|
||||||
_OUTAGE_POLLS = 5
|
_OUTAGE_POLLS = 5
|
||||||
fail_streak = 0
|
fail_streak = 0
|
||||||
|
worker_err_streak: dict[str, int] = {} # errori consecutivi per worker (isolamento tick)
|
||||||
|
|
||||||
def _outage_tick(failed: bool, streak: int, detail: str = "") -> int:
|
def _outage_tick(failed: bool, streak: int, detail: str = "") -> int:
|
||||||
"""Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una
|
"""Aggiorna lo streak e gestisce gli alert FEED_OUTAGE (start a soglia, una
|
||||||
@@ -468,11 +469,27 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
# single (fade/dip): StrategyWorker su feed live.
|
# single (fade/dip): StrategyWorker su feed live.
|
||||||
w.tick(res[s.asset])
|
w.tick(res[s.asset])
|
||||||
|
|
||||||
|
# isolamento per-worker (audit 2026-06-11): un'eccezione in un tick NON
|
||||||
|
# deve saltare gli altri worker (= exit non valutati per tutti) ne'
|
||||||
|
# l'update dell'equity. Streak per-worker -> alert dopo 5 fail di fila.
|
||||||
|
def _tick_safe(s, w):
|
||||||
|
try:
|
||||||
|
_tick(s, w)
|
||||||
|
worker_err_streak.pop(s.sid, None)
|
||||||
|
except Exception as e:
|
||||||
|
n = worker_err_streak.get(s.sid, 0) + 1
|
||||||
|
worker_err_streak[s.sid] = n
|
||||||
|
print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)")
|
||||||
|
if n == 5:
|
||||||
|
from src.live.telegram_notifier import notify_event
|
||||||
|
notify_event("WORKER_ERROR_STREAK",
|
||||||
|
{"worker": s.sid, "streak": n, "errore": str(e)[:200]})
|
||||||
|
|
||||||
for s in live_specs:
|
for s in live_specs:
|
||||||
_tick(s, workers[s.sid])
|
_tick_safe(s, workers[s.sid])
|
||||||
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio
|
# PAPER: ticcati per statistica, MAI nel ledger del portafoglio
|
||||||
for s in paper_specs:
|
for s in paper_specs:
|
||||||
_tick(s, paper_workers[s.sid])
|
_tick_safe(s, paper_workers[s.sid])
|
||||||
|
|
||||||
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
|
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class FakeExec:
|
|||||||
|
|
||||||
def close_amount(self, instrument, side, amount, label=None):
|
def close_amount(self, instrument, side, amount, label=None):
|
||||||
return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0,
|
return Fill(instrument, "sell", 0.0, amount, 100.0, 0.0, 0.0,
|
||||||
"oid-close", "filled", True)
|
"oid-close", "filled", True, filled_amount=amount)
|
||||||
|
|
||||||
|
|
||||||
class FakeFullExec(FakeExec):
|
class FakeFullExec(FakeExec):
|
||||||
@@ -53,7 +53,8 @@ class FakeFullExec(FakeExec):
|
|||||||
|
|
||||||
def close_amount(self, instrument, side, amount, label=None):
|
def close_amount(self, instrument, side, amount, label=None):
|
||||||
self.close_amounts.append(amount)
|
self.close_amounts.append(amount)
|
||||||
return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close", "filled", True)
|
return Fill(instrument, "sell", 0.0, amount, 105.0, 0.0, 0.05, "oid-close",
|
||||||
|
"filled", True, filled_amount=amount)
|
||||||
|
|
||||||
|
|
||||||
def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch):
|
def test_sh01_real_open_close_no_tp(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class FakeExec:
|
|||||||
def open(self, instrument, side, notional, label=None):
|
def open(self, instrument, side, notional, label=None):
|
||||||
amt = round(notional / self.open_px, 6)
|
amt = round(notional / self.open_px, 6)
|
||||||
return Fill(instrument, side, notional, amt, self.open_px, 0.0, 0.05,
|
return Fill(instrument, side, notional, amt, self.open_px, 0.0, 0.05,
|
||||||
"oid-open", "filled", True)
|
"oid-open", "filled", True, filled_amount=amt)
|
||||||
|
|
||||||
def place_tp_limit(self, *a, **k):
|
def place_tp_limit(self, *a, **k):
|
||||||
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
|
return Fill("x", "sell", 0.0, 0.0, None, 0.0, 0.0, None, None, False)
|
||||||
@@ -42,7 +42,8 @@ class FakeExec:
|
|||||||
|
|
||||||
def close_amount(self, instrument, side, amount, label=None):
|
def close_amount(self, instrument, side, amount, label=None):
|
||||||
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
|
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
|
||||||
self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified)
|
self.close_px, 0.0, 0.05, "oid-close", "filled", self.close_verified,
|
||||||
|
filled_amount=amount)
|
||||||
|
|
||||||
|
|
||||||
def _sw(tmp_path, fake, real_truth=True):
|
def _sw(tmp_path, fake, real_truth=True):
|
||||||
@@ -188,3 +189,50 @@ def test_pairs_fallback_sim_on_leg_fail(tmp_path):
|
|||||||
if '"CLOSE"' in l][-1])
|
if '"CLOSE"' in l][-1])
|
||||||
assert c["pnl_source"] == "sim_fallback"
|
assert c["pnl_source"] == "sim_fallback"
|
||||||
assert c["pnl"] == c["sim_pnl"]
|
assert c["pnl"] == c["sim_pnl"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_close_books_only_filled_amount(tmp_path):
|
||||||
|
"""Reduce-only cappato dal netting (audit 2026-06-11): close richiesto 1.0 ma
|
||||||
|
fillato 0.5 -> il ledger booka SOLO il fillato, REAL_CLOSE verified=False e
|
||||||
|
evento REAL_CLOSE_PARTIAL col residuo orfano (niente piu' chiusure 'piene' finte)."""
|
||||||
|
class PartialFake(FakeExec):
|
||||||
|
def close_amount(self, instrument, side, amount, label=None):
|
||||||
|
return Fill(instrument, "sell" if side == "buy" else "buy", 0.0, amount,
|
||||||
|
self.close_px, 0.0, 0.05, "oid-close", "filled", True,
|
||||||
|
filled_amount=amount * 0.5)
|
||||||
|
|
||||||
|
fake = PartialFake(open_px=100.0, close_px=105.0)
|
||||||
|
w = _sw(tmp_path, fake)
|
||||||
|
_open_sim(w, 100.0)
|
||||||
|
w._close_position(105.0, "time_limit")
|
||||||
|
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
|
||||||
|
rc = [r for r in rows if r.get("event") == "REAL_CLOSE"][-1]
|
||||||
|
assert rc["verified"] is False # chiusura NON completa
|
||||||
|
partial = [r for r in rows if r.get("event") == "REAL_CLOSE_PARTIAL"]
|
||||||
|
assert partial and partial[-1]["residuo_orfano"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pairs_orphan_leg_close_not_booked(tmp_path):
|
||||||
|
"""Gamba A respinta in CHIUSURA (reduce-only vs netting, audit 2026-06-11): il suo
|
||||||
|
'profitto' al prezzo sim NON va nel ledger reale, l'orfano e' registrato/persistito
|
||||||
|
e il real-truth ricade sul sim DICHIARATO (applied=False)."""
|
||||||
|
class LegFailClose(FakePairsExec):
|
||||||
|
def close_pair(self, inst_a, inst_b, side_a, side_b, amount_a, amount_b, label=None):
|
||||||
|
bad_a = Fill(inst_a, "sell", 0.0, amount_a, None, 0.0, 0.0, None, "error", False)
|
||||||
|
ok_b = self._fill(inst_b, "buy", amount_b, self.px["close_b"])
|
||||||
|
return PairFill(False, bad_a, ok_b)
|
||||||
|
|
||||||
|
fake = LegFailClose(open_a=100.0, close_a=110.0) # gamba A +10% ma MAI chiusa
|
||||||
|
w = _pw(tmp_path, fake)
|
||||||
|
w._open(1, 100.0, 50.0, -2.1)
|
||||||
|
na = w.real_notional_a
|
||||||
|
w._close(110.0, 50.0, 0.1, "mean_revert")
|
||||||
|
assert w.orphan_legs and w.orphan_legs[0]["instrument"] == "ETH_USDC-PERPETUAL"
|
||||||
|
rc = json.loads([l for l in w.trades_path.read_text().splitlines()
|
||||||
|
if '"REAL_CLOSE_PAIR"' in l][-1])
|
||||||
|
assert rc["leg_a_ok"] is False and rc["leg_b_ok"] is True
|
||||||
|
# il +10% della gamba A (mai eseguita) NON e' nel capitale reale
|
||||||
|
assert w.real_capital < 100.0 + 0.10 * na - 0.05
|
||||||
|
c = json.loads([l for l in w.trades_path.read_text().splitlines()
|
||||||
|
if '"event": "CLOSE"' in l][-1])
|
||||||
|
assert c["pnl_source"] == "sim_fallback"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class _FakeExecutor:
|
|||||||
class F:
|
class F:
|
||||||
verified = True
|
verified = True
|
||||||
amount = 0.01
|
amount = 0.01
|
||||||
|
filled_amount = 0.01
|
||||||
fill_price = 100.0
|
fill_price = 100.0
|
||||||
fee_usd = 0.01
|
fee_usd = 0.01
|
||||||
order_id = "X"
|
order_id = "X"
|
||||||
|
|||||||
Reference in New Issue
Block a user