From abd396fa5415fcc96a6fd2259620668c9ef85e93 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Thu, 4 Jun 2026 19:24:07 +0000 Subject: [PATCH] feat(live): TP reale = LIMIT reduce-only al livello (fix +235bps slippage exit) All'apertura reale il worker piazza un limit reduce-only al TP della strategia (quota del SOLO worker, prezzo quantizzato al tick); alla chiusura sim cancella il resting, riconcilia i fill (anche parziali) via get_trade_history per order_id e chiude a market solo il residuo. real_tp_order_id persistito (resume-safe). SL resta market-on-poll (deliberato: trigger Deribit = nuovo order_id al trigger, fill non verificabile; e il rimbalzo sul SL lavora a favore). Smoke testnet 2 scenari OK (resting+cancel / fill immediato). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + docs/diary/2026-06-04-shadow-divergence.md | 53 +++++++++++ scripts/analysis/live_shadow_smoke.py | 45 ++++++--- src/live/cerbero_client.py | 6 ++ src/live/execution.py | 102 ++++++++++++++++++--- src/live/strategy_worker.py | 91 ++++++++++++++++-- 6 files changed, 264 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69ad07e..5a50c6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -316,6 +316,7 @@ queste fade, ma va confermato col paper trader live prima di rischiare capitale - **Altri fix StrategyWorker (2026-06-01).** Exit a orizzonte puro per strategie senza TP/SL (`elif self.max_bars`, SH01 esce a H=12 non hold_bars=3); `is_win = net > 0` (win NETTO fee, non lordo); filtro `min_tp_frac` (salta micro-scalp col TP entro le fee); loss-guard `hurst_max=0.55` sulle fade (vedi sopra). - **Exit intrabar (fase 3, risolto):** lo `StrategyWorker` ora esce sui TP/SL toccati INTRABAR (high/low della barra, al livello, SL prioritario) come il backtest — non più solo sul close. Allinea fade/DIP01 live al backtest intrabar (`tests/portfolio/test_intrabar_exit.py`). Caveat residuo onesto: nel paper trading l'high/low usato è quello della barra in corso al poll; su un fill reale conterebbe il momento del tocco. - **ESECUZIONE REALE — shadow (v1.0.3, 2026-06-03).** I **6 fade** (MR01/MR02/MR07 × BTC/ETH) eseguono ordini **REALI su Deribit testnet** accanto al fill simulato (*shadow*: sim + reale in parallelo, il sim resta la verità che guida le decisioni). `src/live/execution.py` `ExecutionClient`: `open` (market) + `close_amount` (market **reduce-only della SOLA quota del worker** — i 3 fade BTC condividono lo strumento e le posizioni si nettano per conto, quindi NON si usa `close_position` che flatterebbe le quote altrui); **verifica l'esecuzione sul TRADE** (order_id in `get_trade_history`, non sulla size netta aggregata); **fee REALI lette dai `trades[]`**. Strumenti = **lineari USDC** (`BTC_USDC`/`ETH_USDC-PERPETUAL`, amount nel base-coin, step 0.0001/0.001): scelti perché il **payoff lineare == matematica del backtest** (l'inverse `*-PERPETUAL` introdurrebbe una base 1/prezzo) e fee/PnL sono in USDC. Lo `StrategyWorker` tiene un **ledger reale parallelo** (`real_capital`, persistito) e logga `REAL_OPEN`/`REAL_CLOSE` col confronto **slippage** (prezzo sim vs eseguito) e **fee** (assunta 0.10% vs reale). Config: `portfolios.yml` → `overrides.execution {enabled, sleeves:[MR01,MR02,MR07], instruments:{BTC:BTC_USDC-PERPETUAL, ETH:ETH_USDC-PERPETUAL}}`; pairs/rotation/tsmom/shape/dip restano **simulati**. **Fee reali misurate = 0.05%/lato = 0.10% RT** (== assunto del backtest, su ETH; BTC inverse era ~0.094%). **Alert Telegram:** `REAL_EXEC_LIVE` (primo ordine reale verificato per worker) + `REAL_OPEN_FAIL`. Smoke (testnet, €0): `scripts/analysis/live_exec_smoke.py` (layer: ordine→verifica→fee) e `live_shadow_smoke.py` (catena worker open/close). **Capitale live portato a 2000** (notional fade ~$35) per ridurre il rumore di arrotondamento su BTC (step lineare ~$6.7). NB: ledger reale ≠ ledger sim — i worker già in posizione sim a un restart non hanno quota reale corrispondente; lo shadow reale parte pulito dalla prossima apertura. +- **TP reale = LIMIT reduce-only AL LIVELLO (2026-06-04).** Misurati +235 bps di slippage medio sulle uscite take-profit market-on-poll (sim esce al livello intrabar, il reale chiudeva al poll post-rimbalzo: sim +11.85 vs reale +0.62 USD sui primi 7 close). Fix: a ogni `REAL_OPEN` il worker piazza un **limit reduce-only al TP** (`ExecutionClient.place_tp_limit`, prezzo quantizzato al tick, SOLA quota del worker) → `REAL_TP_RESTING`; a ogni chiusura sim `_real_close` **cancella il resting → riconcilia i fill (anche parziali) via `get_trade_history` per order_id → market reduce-only solo del residuo** → ledger su prezzo combinato. `real_tp_order_id` persistito in `status.json` (resume-safe). Lo **SL resta market-on-poll** (deliberato: i trigger Deribit generano un nuovo order_id al trigger → fill non verificabile per order_id; e sul SL il rimbalzo lavora a favore). Fill da resting = fee **maker ~0%**. Smoke: `live_shadow_smoke.py` (2 scenari, testnet). Diario `docs/diary/2026-06-04-shadow-divergence.md`. - **Limite noto:** al ribilancio le posizioni APERTE restano sul loro notional (non travasate); fedele al backtest daily-rebalanced entro il turnover infragiornaliero. ## Multi-Strategy Paper Trader diff --git a/docs/diary/2026-06-04-shadow-divergence.md b/docs/diary/2026-06-04-shadow-divergence.md index 31637e8..d457bf5 100644 --- a/docs/diary/2026-06-04-shadow-divergence.md +++ b/docs/diary/2026-06-04-shadow-divergence.md @@ -37,3 +37,56 @@ Dettagli da considerare: - i 3 fade BTC condividono lo strumento (posizioni nettate per conto): il limit reduce-only deve coprire la SOLA quota del worker, come già fa `close_amount`; - verificare il fill del resting order via `get_trade_history` (order_id), come per i market. + +## IMPLEMENTATO (sera): LIMIT reduce-only al TP + +**Flusso nuovo.** A ogni `REAL_OPEN` verificato il worker piazza subito un +**limit reduce-only al livello TP** (lato opposto, stesso `amount` della SOLA +quota del worker — strumento condiviso fra i 3 fade per coin) → evento +`REAL_TP_RESTING` (o `REAL_TP_FAIL` → si resta sul vecchio market-on-poll). +Alla chiusura sim, qualunque sia la ragione, `_real_close` ora: +1. **cancella** il resting (innocuo se già fillato — e dopo il cancel nessun + fill può più arrivare, niente race); +2. **riconcilia i fill** (anche parziali) dal `get_trade_history` per order_id + (stessa fonte autorevole dei market), con retry se il cancel risulta + "già fillato" ma la history è in ritardo; +3. chiude a **market reduce-only solo la quota residua** (exit non-TP: + stop_loss/time_limit, o TP non raggiunto sullo strumento d'esecuzione); +4. ledger reale su **prezzo d'uscita combinato** (media pesata fill-TP + + market) e fee sommate. `REAL_CLOSE` logga `tp_order_id`, + `tp_filled_amount`, `market_amount`. + +**Dettagli del TODO coperti:** +- `max_bars`/`stop_loss` → il cancel è il passo 1 di ogni chiusura; +- restart → `real_tp_order_id` è **persistito** in `status.json`: al resume il + resting resta in book e la prima chiusura lo riconcilia normalmente; +- quota del worker → il limit usa lo stesso `amount` dell'apertura (mai + `close_position`); il residuo è ri-quantizzato allo step (Decimal) dentro + `close_amount` (niente artefatti float tipo `0.072-0.024`); +- prezzi limit quantizzati al **tick** dello strumento (`quantize_price`, + BTC_USDC 0.5 / ETH_USDC 0.05 — Deribit rifiuta prezzi off-tick). + +**SL: niente stop-market, decisione deliberata.** (a) Su Deribit un trigger +order, quando scatta, genera l'ordine eseguito con un **nuovo order_id** → il +fill non sarebbe verificabile via `get_trade_history` per order_id (il +requisito di verifica); (b) la misura di stamattina mostra che sul SL il +market-on-poll lavora **a favore** (il rimbalzo riduce la perdita). Lo SL resta +quindi market-on-poll. + +**Bonus fee.** Quando il TP filla da resting il fill è **maker** (~0% su +Deribit) invece di taker 0.05% — il reale farà meglio dell'assunto 0.10% RT +sulle uscite TP. + +**Verifica.** `live_shadow_smoke.py` esteso ai due percorsi e passato su +testnet: (A) resting in book → exit `time_limit` → cancel + market fallback, +conto flat; (B) TP già crossato → fill immediato del limit, chiusura +riconciliata dalla history **senza ordine market** (`market_amount=0`), +prezzo persino migliore del TP (+48 bps, taker sul cross). 54 test pytest OK. + +**Caveat residuo onesto.** Se il processo muore DOPO il fill del resting e +PRIMA della chiusura sim, il worker al resume riconcilia al prossimo exit sim +(corretto); ma nell'intervallo la quota reale è già chiusa mentre il sim la +crede aperta — finestra breve e solo contabile. Inoltre il TP del sim è sul +feed (`BTC-PERPETUAL` inverse) mentre il limit vive sul lineare USDC: la base +fra i due può far fillare/non fillare il limit a cavallo del livello — il +fallback market copre il caso. diff --git a/scripts/analysis/live_shadow_smoke.py b/scripts/analysis/live_shadow_smoke.py index ee42983..4ae49cd 100644 --- a/scripts/analysis/live_shadow_smoke.py +++ b/scripts/analysis/live_shadow_smoke.py @@ -1,8 +1,13 @@ """Smoke della catena SHADOW dentro lo StrategyWorker (testnet, ordini reali minimi). -Apre e chiude la quota di UN worker fade come farebbe il runner: - _open_position (sim + REAL_OPEN reale su BTC_USDC) → _close_position (sim + - REAL_CLOSE reduce-only) → controlla che real_capital sia aggiornato dal fill reale. +Apre e chiude la quota di UN worker fade come farebbe il runner, esercitando i +DUE percorsi del LIMIT reduce-only al TP (fix divergenza sim/reale 2026-06-04): + + A) TP lontano → REAL_TP_RESTING in book; exit sim non-TP → cancel + market + reduce-only di fallback (REAL_CLOSE con tp_filled_amount=0); + B) TP gia' oltre il prezzo → il limit crossa e filla SUBITO; la chiusura + riconcilia il fill dal trade history (order_id) SENZA ordine market + (REAL_CLOSE con market_amount=0). Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0. @@ -40,27 +45,45 @@ def main() -> None: ) print(f"execution_enabled={w.execution_enabled} notional atteso=${100*0.15*2:.0f}") - # OPEN long (sim + reale) - sig = Signal(idx=0, direction=1, entry_price=price, metadata={}) + # --- Scenario A: TP lontano → resting in book, exit non-TP → cancel + market --- + print("\n[A] TP lontano (resting) → exit time_limit → cancel + market fallback") + sig = Signal(idx=0, direction=1, entry_price=price, + metadata={"tp": price * 1.05, "sl": price * 0.50, "max_bars": 6}) w._open_position(sig, price) print(f" real_in_position={w.real_in_position} side={w.real_side} " f"amount={w.real_amount} entry={w.real_entry_price} " f"entry_fee=${w.real_entry_fee_usd:.5f} notional=${w.real_entry_notional:.2f}") assert w.real_in_position, "OPEN reale non verificato" + print(f" TP resting order_id={w.real_tp_order_id!r}") + assert w.real_tp_order_id, "LIMIT reduce-only al TP non piazzato" - # CLOSE (sim + reale reduce-only) a un prezzo leggermente diverso - exit_price = (w.entry_price or price) * 1.001 cap_before = w.real_capital - w._close_position(exit_price, "smoke_close") + w._close_position((w.entry_price or price) * 1.001, "time_limit") print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} " f"(Δ {w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}") assert not w.real_in_position, "posizione reale non chiusa" + assert not w.real_tp_order_id, "order_id TP non resettato dopo la chiusura" + + # --- Scenario B: TP gia' oltre il prezzo → il limit crossa e filla subito --- + print("\n[B] TP gia' crossato (fill immediato del limit) → close riconcilia da history") + price = ex._mark_price(instrument) or price + sig = Signal(idx=0, direction=1, entry_price=price, + metadata={"tp": price * 0.995, "sl": price * 0.50, "max_bars": 6}) + w._open_position(sig, price) + assert w.real_in_position, "OPEN reale non verificato (B)" + assert w.real_tp_order_id, "LIMIT TP non piazzato (B)" + + cap_before = w.real_capital + w._close_position(w.tp, "take_profit") + print(f" real_capital {cap_before:.4f} -> {w.real_capital:.4f} " + f"(Δ {w.real_capital - cap_before:+.4f}) real_trades={w.real_trades}") + assert not w.real_in_position, "posizione reale non chiusa (B)" # verifica finale: il conto e' flat sullo strumento (nessuna quota residua del worker) pos = ex._position_size(instrument) - print(f" posizione netta {instrument}: {pos}") - print("✓ catena shadow OK — ordine reale aperto, verificato, chiuso reduce-only, " - "fee reali nel ledger reale") + print(f"\n posizione netta {instrument}: {pos}") + print("✓ catena shadow OK — open reale, LIMIT TP resting (A: cancel+market, " + "B: fill immediato riconciliato), fee reali nel ledger reale") if __name__ == "__main__": diff --git a/src/live/cerbero_client.py b/src/live/cerbero_client.py index 2929005..82cf18b 100644 --- a/src/live/cerbero_client.py +++ b/src/live/cerbero_client.py @@ -116,6 +116,12 @@ class CerberoClient: payload["reduce_only"] = True return self._post("/mcp-deribit/tools/place_order", payload) + def cancel_order(self, order_id: str) -> dict: + """Cancella un ordine resting (es. limit reduce-only al TP). Ritorna + {order_id, state}; state='error' se l'ordine non e' piu' open (gia' + fillato/cancellato) — il chiamante riconcilia via get_trade_history.""" + return self._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id}) + def close_position(self, instrument: str) -> dict: return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument}) diff --git a/src/live/execution.py b/src/live/execution.py index 826cd36..35c4419 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -27,10 +27,10 @@ from src.live.cerbero_client import CerberoClient # LINEAR (USDC): amount nel base-coin, step nel base-coin (BTC 0.0001, ETH 0.001); # il notional USD si converte col prezzo. Fee/settle in USDC. _CONTRACT: dict[str, dict[str, Any]] = { - "BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0}, - "ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0}, - "BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "settle": "USDC"}, - "ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "settle": "USDC"}, + "BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5}, + "ETH-PERPETUAL": {"linear": False, "min": 1.0, "step": 1.0, "tick": 0.05}, + "BTC_USDC-PERPETUAL": {"linear": True, "min": 0.0001, "step": 0.0001, "tick": 0.5, "settle": "USDC"}, + "ETH_USDC-PERPETUAL": {"linear": True, "min": 0.001, "step": 0.001, "tick": 0.05, "settle": "USDC"}, } @@ -54,6 +54,15 @@ 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: + """Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti + float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick).""" + tick = contract_spec(instrument).get("tick") + if not tick or price <= 0: + return price + return float(round(price / tick) * Decimal(str(tick))) + + def notional_to_amount(instrument: str, notional_usd: float, price: float | None = None) -> float: """Notional USD → `amount` Deribit, arrotondato allo step e clampato al minimo. @@ -157,17 +166,20 @@ class ExecutionClient: def _submit(self, instrument: str, side: str, amount: float, requested_notional: float, reduce_only: bool, - label: str | None) -> Fill: - """Market order REALE + parsing del fill. Verifica per-worker basata sul - TRADE (order_id/trades), non sulla size netta — lo strumento e' condiviso - fra piu' worker e la posizione su Deribit e' aggregata per conto.""" + label: str | None, order_type: str = "market", + price: float | None = None) -> Fill: + """Ordine REALE (market o limit resting) + parsing del fill. Verifica + per-worker basata sul TRADE (order_id/trades), non sulla size netta — lo + strumento e' condiviso fra piu' worker e la posizione su Deribit e' + aggregata per conto. Per i limit la verifica e' l'ACCETTAZIONE in book + (state 'open', o 'filled' se crossa subito).""" spec = contract_spec(instrument) if amount <= 0: return Fill(instrument, side, requested_notional, 0.0, None, 0.0, 0.0, None, None, False, notes="notional sotto il minimo contratto") - resp = self.client.place_order(instrument, side, amount, order_type="market", - label=label, reduce_only=reduce_only) + resp = self.client.place_order(instrument, side, amount, order_type=order_type, + price=price, label=label, reduce_only=reduce_only) if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp: return Fill(instrument, side, requested_notional, amount, None, 0.0, 0.0, None, "error", False, raw=resp if isinstance(resp, dict) else {}, @@ -181,8 +193,11 @@ class ExecutionClient: # fee reale dai trade del fill (coin di settlement) fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades) - # riconciliazione su trade history per order_id (fonte autorevole) - th = self._trade_by_order(instrument, order_id) + # riconciliazione su trade history per order_id (fonte autorevole) — + # inutile per un limit ancora in book senza fill + th = None + if order_type == "market" or trades: + th = self._trade_by_order(instrument, order_id) if fee_coin == 0 and th and th.get("fee") is not None: fee_coin = float(th["fee"]) if fill_price is None and th: @@ -191,8 +206,12 @@ class ExecutionClient: fee_usd = fee_coin if spec.get("linear") else ( fee_coin * fill_price if (fee_coin and fill_price) else 0.0) - # VERIFICA esecuzione = ordine filled E fill riscontrato (trades o trade history) - verified = (state == "filled") and (bool(trades) or th is not None) + # VERIFICA: market = ordine filled E fill riscontrato (trades o history); + # limit = accettato in book ('open') o gia' eseguito ('filled') + if order_type == "market": + verified = (state == "filled") and (bool(trades) or th is not None) + else: + verified = state in ("open", "filled") return Fill(instrument, side, requested_notional, amount, fill_price, fee_coin, fee_usd, order_id, state, verified, raw=resp, notes="" if verified else f"fill non verificato (state={state}, trades={len(trades)})") @@ -209,10 +228,65 @@ class ExecutionClient: """Chiude SOLO la quota del worker: market reduce_only di lato opposto, stesso `amount` dell'apertura. Non usa close_position (flatterebbe anche le quote degli altri worker sullo stesso strumento).""" + spec = contract_spec(instrument) + # quantizza difensivamente: il chiamante puo' passare un residuo + # (amount aperto − fill parziale del TP) con artefatti float + amount = _quantize_step(amount, spec["step"], spec["min"]) if amount > 0 else 0.0 opp = "sell" if entry_side == "buy" else "buy" return self._submit(instrument, opp, amount, 0.0, reduce_only=True, label=label) + 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): + il fill reale replica l'assunzione intrabar del backtest (exit AL livello) + invece del market-on-poll post-rimbalzo (+235 bps misurati il 2026-06-04). + Copre la SOLA quota del worker (stesso `amount` dell'apertura) — lo + strumento e' condiviso fra piu' worker. NB: se il prezzo e' gia' oltre il + 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) + 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") + return self._submit(instrument, opp, amount, 0.0, reduce_only=True, + label=label, order_type="limit", price=px) + + def cancel_order(self, order_id: str) -> dict: + """Cancella un ordine resting. {'state': 'cancelled'} su successo; + 'error' se l'ordine non e' piu' open (es. gia' fillato) — NON fatale: + il chiamante riconcilia i fill via resting_fills (trade history).""" + if not order_id: + return {"state": "error", "error": "no order_id"} + try: + return self.client.cancel_order(order_id) + except Exception as exc: + return {"state": "error", "error": str(exc)} + + def resting_fills(self, instrument: str, + order_id: str) -> tuple[float, float | None, float]: + """Fill (anche parziali) di un ordine resting, riconciliati dal trade + history per order_id (fonte autorevole, come per i market). Ritorna + (amount_fillato, prezzo_medio, fee_usd).""" + if not order_id: + return 0.0, None, 0.0 + spec = contract_spec(instrument) + try: + trades = [t for t in self.client.get_trade_history( + limit=100, instrument_name=instrument) + if str(t.get("order_id")) == str(order_id)] + except Exception: + return 0.0, None, 0.0 + amt = sum(float(t.get("amount", 0) or 0) for t in trades) + if amt <= 0: + return 0.0, None, 0.0 + avg = sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0) + for t in trades) / amt + fee_coin = sum(float(t.get("fee", 0) or 0) for t in trades) + fee_usd = fee_coin if spec.get("linear") else (fee_coin * avg if avg else 0.0) + return amt, avg or None, fee_usd + def close(self, instrument: str, label: str | None = None) -> Fill: """Chiude a mercato la posizione e riverifica che il conto sia flat, leggendo la fee di chiusura dal trade history.""" diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index ae01f00..53b40d8 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import time from datetime import datetime, timezone from pathlib import Path @@ -54,6 +55,7 @@ class StrategyWorker: self.real_entry_fee_usd = 0.0 self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata self.real_order_id = "" + self.real_tp_order_id = "" # LIMIT reduce-only resting al TP (persistito per il resume) self.real_trades = 0 self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum @@ -116,6 +118,7 @@ class StrategyWorker: self.real_entry_fee_usd = state.get("real_entry_fee_usd", 0.0) self.real_entry_notional = state.get("real_entry_notional", 0.0) self.real_order_id = state.get("real_order_id", "") + self.real_tp_order_id = state.get("real_tp_order_id", "") self.real_trades = state.get("real_trades", 0) self.real_first_notified = state.get("real_first_notified", False) @@ -148,6 +151,7 @@ class StrategyWorker: "real_entry_fee_usd": self.real_entry_fee_usd, "real_entry_notional": self.real_entry_notional, "real_order_id": self.real_order_id, + "real_tp_order_id": self.real_tp_order_id, "real_trades": self.real_trades, "real_first_notified": self.real_first_notified, "last_update": datetime.now(timezone.utc).isoformat(), @@ -234,22 +238,87 @@ class StrategyWorker: if not self.real_first_notified: # conferma una-tantum: l'esecuzione reale e' viva self._notify("REAL_EXEC_LIVE", data) self.real_first_notified = True + self._place_real_tp() else: self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes}) self._notify("REAL_OPEN_FAIL", {**data, "note": fill.notes}) + def _place_real_tp(self): + """LIMIT reduce-only appoggiato al TP della strategia (fix divergenza + sim/reale 2026-06-04: il market-on-poll usciva post-rimbalzo, +235 bps + sopra il livello TP). Copre la SOLA quota del worker. Se il piazzamento + fallisce si resta sul fallback market-on-poll di _real_close.""" + self.real_tp_order_id = "" + if not (self.tp and self.real_amount > 0): + return + rest = self.executor.place_tp_limit(self.exec_instrument, self.real_side, + self.real_amount, self.tp, + label=self.worker_id) + data = { + "instrument": self.exec_instrument, + "order_id": rest.order_id, + "tp": round(self.tp, 2), + "amount": self.real_amount, + "state": rest.order_state, + } + if rest.verified and rest.order_id: + self.real_tp_order_id = rest.order_id + self._log("REAL_TP_RESTING", data) + else: + self._log("REAL_TP_FAIL", {**data, "note": rest.notes}) + def _real_close(self, sim_exit: float, reason: str, sim_pnl: float): - """Chiusura REALE (reduce-only della quota worker) + confronto col sim.""" + """Chiusura REALE (reduce-only della quota worker) + confronto col sim. + + Prima riconcilia l'eventuale LIMIT resting al TP: lo cancella (innocuo + se gia' fillato — cosi' nessun fill puo' arrivare DOPO la lettura) e + legge i fill reali dal trade history per order_id; solo la quota residua + viene chiusa a mercato (fallback, o exit non-TP: stop-loss/time_limit). + L'uscita take-profit reale avviene cosi' AL livello come nel backtest, + non al poll post-rimbalzo.""" if not self.real_in_position: return - fill = self.executor.close_amount(self.exec_instrument, self.real_side, - self.real_amount, label=self.worker_id) - exit_price = fill.fill_price or sim_exit + from src.live.execution import contract_spec + step = contract_spec(self.exec_instrument)["step"] + + # 1) ordine TP resting: cancella, poi riconcilia i fill (order_id su history) + tp_amt, tp_px, tp_fee = 0.0, None, 0.0 + tp_order_id = self.real_tp_order_id + if tp_order_id: + cres = self.executor.cancel_order(tp_order_id) + cancelled = cres.get("state") == "cancelled" + for _ in range(self.executor.verify_polls): + tp_amt, tp_px, tp_fee = self.executor.resting_fills( + self.exec_instrument, tp_order_id) + if tp_amt > 0 or cancelled: + break # cancel pulito = al piu' fill parziali gia' visti + time.sleep(self.executor.verify_sleep) + tp_amt = min(tp_amt, self.real_amount) + if tp_amt > 0 and not tp_px: + tp_px = self.tp or sim_exit # fallback: il limit filla al suo livello + + # 2) quota residua → market reduce-only (mai close_position: strumento condiviso) + remainder = self.real_amount - tp_amt + fill = None + if remainder >= step / 2: + fill = self.executor.close_amount(self.exec_instrument, self.real_side, + remainder, label=self.worker_id) + market_amt = fill.amount if (fill and fill.verified) else 0.0 + + # 3) prezzo d'uscita combinato (media pesata TP-fill + market) e fee totali + parts = [(a, p) for a, p in ((tp_amt, tp_px), + (market_amt, fill.fill_price if fill else None)) + if a > 0 and p] + exit_price = (sum(a * p for a, p in parts) / sum(a for a, _ in parts) + if parts else sim_exit) + exit_fee = tp_fee + (fill.fee_usd if fill else 0.0) + verified = (tp_amt + market_amt) >= self.real_amount - step / 2 + rdir = 1 if self.real_side == "buy" else -1 price_change = (exit_price - self.real_entry_price) / self.real_entry_price \ if self.real_entry_price else 0.0 real_gross = rdir * price_change * self.real_entry_notional - real_fees = self.real_entry_fee_usd + fill.fee_usd + real_fees = self.real_entry_fee_usd + exit_fee real_pnl = real_gross - real_fees self.real_capital += real_pnl self.real_trades += 1 @@ -258,16 +327,19 @@ class StrategyWorker: if exit_price and sim_exit else None) self._log("REAL_CLOSE", { "reason": reason, - "order_id": fill.order_id, + "order_id": fill.order_id if fill else tp_order_id, + "tp_order_id": tp_order_id or None, + "tp_filled_amount": tp_amt, + "market_amount": market_amt, "sim_exit": round(sim_exit, 2), - "real_fill": fill.fill_price, + "real_fill": round(exit_price, 2) if parts else None, "slippage_bps": round(slip_bps, 2) if slip_bps is not None else None, "entry_fee_usd": round(self.real_entry_fee_usd, 5), - "exit_fee_usd": round(fill.fee_usd, 5), + "exit_fee_usd": round(exit_fee, 5), "real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4), "real_capital": round(self.real_capital, 4), - "verified": fill.verified, + "verified": verified, }) self.real_in_position = False @@ -277,6 +349,7 @@ class StrategyWorker: self.real_entry_fee_usd = 0.0 self.real_entry_notional = 0.0 self.real_order_id = "" + self.real_tp_order_id = "" def _close_position(self, current_price: float, reason: str): if not self.in_position: