From c655604131c15201d4bbeefa4f58e41080c13128 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Mon, 8 Jun 2026 10:19:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(live):=20executor=20a=202=20gambe=20per=20?= =?UTF-8?q?i=20pairs=20(PairsExecutionClient,=20shadow)=20=E2=80=94=20pron?= =?UTF-8?q?to,=20spento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PairsExecutionClient (compone ExecutionClient): open_pair (2 market long A/short B, verifica per-gamba, LEG-RISK unwind se una sola filla), close_pair (2 reduce-only via close_amount, MAI close_position), register_contract (fetch spec USDC). Spec LTC/ADA/SOL aggiunti. PairsWorker: ledger reale shadow a 2 gambe resume-safe (_real_open_pair/ _real_close_pair, PnL per gamba dir A=+d/B=-d, doppio arrotondamento riportato). Runner: pairs_executor gated su execution.pairs_enabled (false di default). Validazione: test 92/92 (open/close, leg-risk unwind, resume) + smoke testnet end-to-end (open 2 gambe verificate, close reduce-only, PnL reale -0.039 vs sim -0.036, conto flat). Smoke ora aborta se ci sono posizioni di produzione + usa solo close_amount. NB incidente testnet documentato (diario): pulizia manuale con close_position ha flattato le quote shadow dei fade sul conto condiviso -> auto-riconciliazione al prossimo close. Lezione: mai close_position su strumenti condivisi. pairs_enabled resta FALSE: accendere con finestra a conto flat + osservazione. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/TODO.md | 11 +-- docs/diary/2026-06-08-pairs-executor.md | 53 +++++++++++ portfolios.yml | 7 ++ scripts/analysis/live_pairs_smoke.py | 81 +++++++++++++++++ src/live/execution.py | 90 ++++++++++++++++++- src/live/pairs_worker.py | 114 +++++++++++++++++++++++- src/live/telegram_notifier.py | 1 + src/portfolio/runner.py | 25 +++++- tests/portfolio/test_pairs_execution.py | 100 +++++++++++++++++++++ 9 files changed, 470 insertions(+), 12 deletions(-) create mode 100644 docs/diary/2026-06-08-pairs-executor.md create mode 100644 scripts/analysis/live_pairs_smoke.py create mode 100644 tests/portfolio/test_pairs_execution.py diff --git a/docs/TODO.md b/docs/TODO.md index 0c61078..316da5d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -5,11 +5,12 @@ ## Esecuzione reale — i pezzi grossi mancanti -- [ ] **Executor a 2 gambe per i PAIRS** (`PairsExecutionClient`). È la fetta reale-non-eseguita - più grande (29% del portafoglio). Serve: piazzamento il più possibile atomico delle 2 gambe, - fallback di **unwind** se una sola filla (leg-risk), dimensionamento congiunto col doppio - arrotondamento ai lotti minimi (BTC/ETH/LTC/ADA/SOL hanno step diversi), riconciliazione e - chiusura a 2 gambe. Progetto a sé, il più impegnativo rimasto. +- [x] ~~Executor a 2 gambe per i PAIRS~~ — FATTO (2026-06-08): `PairsExecutionClient` + (open/close 2 gambe, leg-risk unwind, ledger shadow resume-safe). Test 92/92 + smoke + testnet end-to-end OK. **Capability PRONTA ma SPENTA** (`pairs_enabled: false`). + Diario `docs/diary/2026-06-08-pairs-executor.md`. +- [ ] **Accendere `pairs_enabled: true`** quando c'è una finestra a conto flat + osservazione. + Poi monitorare REAL_OPEN_PAIR/REAL_CLOSE_PAIR e lo sbilanciamento notional fra gambe. - [ ] **SH01 in reale**: oggi escluso perché non ha TP/SL (esce a orizzonte H=12) → manca il livello a cui agganciare il limit resting + disaster-bracket. Due strade: (a) nuovo percorso "market reduce-only a scadenza orizzonte" nell'executor; (b) valutare TP/SL su SH01 — MA la diff --git a/docs/diary/2026-06-08-pairs-executor.md b/docs/diary/2026-06-08-pairs-executor.md new file mode 100644 index 0000000..bbf93e4 --- /dev/null +++ b/docs/diary/2026-06-08-pairs-executor.md @@ -0,0 +1,53 @@ +# 2026-06-08 — Executor a 2 gambe per i pairs (PairsExecutionClient) + +Implementato il pezzo di esecuzione reale più impegnativo rimasto: l'esecuzione +shadow a 2 gambe per la famiglia PAIRS (29% del portafoglio, finora solo simulata). + +## Cosa + +- **`execution.PairsExecutionClient`**: compone l'`ExecutionClient` single-leg. + - `open_pair(inst_a, inst_b, direction, notional)`: piazza le 2 gambe (long A/short B + o viceversa) a market, verifica per-gamba sul trade; **LEG-RISK**: se UNA sola gamba + filla → UNWIND (richiude la fillata reduce-only) per non restare direzionali → + verified=False. + - `close_pair(...)`: chiude entrambe reduce-only (solo `close_amount` della quota, + MAI `close_position`), riconcilia fee/prezzi. + - `register_contract`: fetch dinamico spec USDC da Deribit per strumenti non hardcodati. + - Strumenti = lineari USDC (payoff lineare = matematica del backtest a 2 gambe). + Aggiunti spec LTC/ADA/SOL_USDC (step 0.1/0.2/0.1). +- **`PairsWorker` shadow**: ledger reale parallelo (`real_capital`, `real_dir`, + `real_amount_a/b`, `real_entry_a/b`, `real_notional_a/b`, fee), persistito e resume-safe; + `_real_open_pair`/`_real_close_pair` agganciati a `_open`/`_close`; PnL reale per gamba + (dir A=+d, dir B=−d). Doppio arrotondamento → piccolo sbilanciamento notional, riportato. +- **runner**: `pairs_executor` (PairsExecutionClient su stesso ExecutionClient dei fade), + `_pairs_exec_for`, gate su `execution.pairs_enabled`. Config `portfolios.yml`: + instruments estesi (LTC/ADA/SOL) + `pairs_enabled: false` (capability pronta, SPENTA). + +## Validazione + +- Test (executor finto): 92/92 — open/close a 2 gambe, **leg-risk unwind**, ledger reale + persiste e resume. +- **Smoke testnet end-to-end** (`live_pairs_smoke.py`, €0): open 2 gambe verificate + (long ETH 0.011 @1666 / short BTC 0.0003 @63263, fee $0.019), close 2 gambe reduce-only, + riconciliazione PnL reale −0.039 vs sim −0.036 (coerente), **conto flat dopo**. + +## Incidente operativo (testnet, €0) — da ricordare + +Durante la prima esecuzione, lo smoke è crashato (bug del test: prezzi sim=0 → divisione +per zero nel ramo sim) lasciando una posizione aperta. Per pulirla ho usato a mano +`ExecutionClient.close()` (= `close_position`) che **flatta l'intero strumento** → ho +chiuso anche le 3 posizioni reali SHADOW dei fade del runner (BTC/ETH_USDC condivisi). +Impatto: testnet, shadow (non guida le decisioni), €0; 3 worker fade con +`real_in_position=True` su conto flat → si auto-riconciliano al prossimo close sim +(close_amount su flat → verified=False, reset; "shadow pulito dalla prossima apertura"). +I TP resting erano già stati cancellati da close_position (cancel → order_not_found). + +**Lezione (CLAUDE.md la documentava già)**: MAI `close_position` su strumenti condivisi. +Fix permanente: lo smoke ora (a) usa solo `close_amount` della quota, (b) ABORTA se ci +sono posizioni di produzione aperte sugli strumenti, (c) usa prezzi sim reali. + +## Stato: PRONTO ma SPENTO (`pairs_enabled: false`) + +L'executor è validato (test + smoke). NON attivato in produzione: accenderlo richiede +una finestra a conto flat e un periodo di osservazione, dato il conto condiviso coi fade. +Quando si accende: i 5 pairs eseguiranno reale a 2 gambe accanto al sim. diff --git a/portfolios.yml b/portfolios.yml index 08449cf..b2f2850 100644 --- a/portfolios.yml +++ b/portfolios.yml @@ -33,6 +33,13 @@ overrides: instruments: BTC: BTC_USDC-PERPETUAL ETH: ETH_USDC-PERPETUAL + LTC: LTC_USDC-PERPETUAL + ADA: ADA_USDC-PERPETUAL + SOL: SOL_USDC-PERPETUAL + # Esecuzione REALE a 2 gambe per i pairs (PairsExecutionClient, 2026-06-08). + # Long A / short B sui lineari USDC, con unwind se una gamba sola filla (leg-risk). + # false finche' lo smoke testnet non e' verificato in produzione. + pairs_enabled: false # Disaster-bracket on-book (2026-06-07): STOP_MARKET reduce-only a ~-30% # dall'ingresso, piazzato a ogni REAL_OPEN e cancellato alla chiusura. # Assicurazione per gli outage (runner fermo = exit non valutati); in diff --git a/scripts/analysis/live_pairs_smoke.py b/scripts/analysis/live_pairs_smoke.py new file mode 100644 index 0000000..fe1ecb0 --- /dev/null +++ b/scripts/analysis/live_pairs_smoke.py @@ -0,0 +1,81 @@ +"""Smoke ESECUZIONE REALE a 2 gambe (PairsExecutionClient) su Deribit testnet. + +Apre e chiude una posizione pair micro (ETH/BTC lineari USDC) come farebbe il +PairsWorker, esercitando: open_pair (2 market simultanei long A/short B), verifica +per-gamba, close_pair (2 reduce-only), riconciliazione PnL/fee a 2 gambe. +Verifica finale: conto flat su ENTRAMBI gli strumenti. + +Costo testnet = €0. Non tocca lo stato di produzione (data_dir temporanea). + + uv run python scripts/analysis/live_pairs_smoke.py +""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from src.live.cerbero_client import CerberoClient +from src.live.execution import ExecutionClient, PairsExecutionClient +from src.live.pairs_worker import PairsWorker + + +def main() -> None: + client = CerberoClient() + acct = client.get_account_summary(currency="USDC") + print(f"Account testnet equity={acct.get('equity')} USDC testnet={acct.get('testnet')}") + if not acct.get("testnet"): + raise SystemExit("ABORT: non testnet") + + pex = PairsExecutionClient(leg=ExecutionClient(client=client)) + inst = {"ETH": "ETH_USDC-PERPETUAL", "BTC": "BTC_USDC-PERPETUAL"} + pex.ensure_specs(*inst.values()) + print(f"strumenti: {inst}") + + # GUARDIA: questo conto e' condiviso col runner di produzione (fade reali su + # BTC/ETH_USDC). Se ci sono posizioni aperte, ABORT: lo smoke userebbe lo stesso + # strumento e la verifica "conto flat" sarebbe falsata (e un eventuale + # close_position flatterebbe le quote del runner). Mai eseguire close_position qui. + for i in inst.values(): + sz = pex.leg._position_size(i) + if sz != 0: + raise SystemExit(f"ABORT: posizione di produzione aperta su {i} (size={sz}). " + f"Eseguire lo smoke solo a conto flat.") + + with tempfile.TemporaryDirectory() as tmp: + # capitale piccolo -> notional micro per gamba (capital*pos*lev) + w = PairsWorker("ETH", "BTC", "1h", capital=60.0, position_size=0.15, leverage=2.0, + data_dir=Path(tmp), executor=pex, exec_instruments=inst) + print(f"execution_enabled={w.execution_enabled} notional/gamba atteso=${60*0.15*2:.0f}") + + # prezzi sim plausibili (il ramo sim del worker li usa per il suo ledger; + # il reale usa i fill veri). Niente zeri -> niente divisione per zero. + pa = pex.leg._mark_price(inst["ETH"]) or 1668.0 + pb = pex.leg._mark_price(inst["BTC"]) or 63000.0 + print("\n[A] OPEN long ratio (long ETH / short BTC)") + w._open(1, pa, pb, -2.5) + print(f" real_in_position={w.real_in_position} dir={w.real_dir}") + print(f" gamba A {w.real_side_a} {w.real_amount_a} @ {w.real_entry_a} (notional ${w.real_notional_a:.2f})") + print(f" gamba B {w.real_side_b} {w.real_amount_b} @ {w.real_entry_b} (notional ${w.real_notional_b:.2f})") + print(f" entry_fee ${w.real_entry_fee:.5f}") + assert w.real_in_position, "open pair non verificato" + assert w.real_side_a == "buy" and w.real_side_b == "sell" + + print("\n[B] CLOSE (richiude entrambe le gambe reduce-only)") + cap0 = w.real_capital + pa2 = pex.leg._mark_price(inst["ETH"]) or pa + pb2 = pex.leg._mark_price(inst["BTC"]) or pb + w._close(pa2, pb2, 0.3, "mean_revert") + print(f" real_capital {cap0:.4f} -> {w.real_capital:.4f} (Δ {w.real_capital-cap0:+.4f}) trades={w.real_trades}") + assert not w.real_in_position, "close pair non azzera la posizione" + + # verifica conto flat su entrambi gli strumenti + pa = pex.leg._position_size(inst["ETH"]) + pb = pex.leg._position_size(inst["BTC"]) + print(f"\n posizione netta ETH={pa} BTC={pb}") + print("✓ catena pairs OK — open 2 gambe verificate, close 2 gambe, fee reali, conto flat" + if pa == 0 and pb == 0 else + f"⚠ residuo sul book (ETH={pa} BTC={pb}) — verificare manualmente") + + +if __name__ == "__main__": + main() diff --git a/src/live/execution.py b/src/live/execution.py index b931af1..9609922 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -29,8 +29,12 @@ from src.live.cerbero_client import CerberoClient _CONTRACT: dict[str, dict[str, Any]] = { "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"}, + "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"}, + # lineari USDC per le gambe dei pairs (PairsExecutionClient, 2026-06-08) + "LTC_USDC-PERPETUAL": {"linear": True, "min": 0.1, "step": 0.1, "tick": 0.01, "settle": "USDC"}, + "ADA_USDC-PERPETUAL": {"linear": True, "min": 0.2, "step": 0.2, "tick": 1e-05, "settle": "USDC"}, + "SOL_USDC-PERPETUAL": {"linear": True, "min": 0.1, "step": 0.1, "tick": 0.001, "settle": "USDC"}, } @@ -38,6 +42,24 @@ def contract_spec(instrument: str) -> dict[str, Any]: return _CONTRACT.get(instrument, {"linear": False, "min": 1.0, "step": 1.0}) +def register_contract(instrument: str, client) -> dict[str, Any]: + """Recupera lo spec di uno strumento USDC da Deribit (get_instruments) e lo + registra in _CONTRACT. Fallback per strumenti pair non hardcodati; ritorna lo + spec (o il default se non trovato). Idempotente.""" + if instrument in _CONTRACT: + return _CONTRACT[instrument] + try: + for i in client.get_instruments(currency="USDC", kind="future", limit=300): + if i.get("symbol") == instrument: + step = float(i["native"].get("min_trade_amount")) + _CONTRACT[instrument] = {"linear": True, "min": step, "step": step, + "tick": float(i.get("tick_size") or 0), "settle": "USDC"} + return _CONTRACT[instrument] + except Exception: + pass + return contract_spec(instrument) + + def settlement_currency(instrument: str) -> str: """Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions e per denominare la fee.""" @@ -345,3 +367,67 @@ class ExecutionClient: return Fill(instrument, side, 0.0, 0.0, fill_price, fee_coin, fee_usd, order_id, resp.get("state"), verified, raw=resp, notes="" if verified else f"posizione non flat dopo close (pos={pos})") + + +@dataclass +class PairFill: + """Esito verificato di un'apertura/chiusura a 2 GAMBE.""" + verified: bool # entrambe le gambe eseguite e verificate + leg_a: Fill + leg_b: Fill + unwound: bool = False # true se una gamba e' fallita e l'altra e' stata richiusa + notes: str = "" + + +@dataclass +class PairsExecutionClient: + """Esecuzione REALE a 2 gambe (shadow) per i pairs market-neutral su Deribit. + + Compone un ExecutionClient single-leg: apre/chiude le due gambe (long A / short B + o viceversa) come market reduce_only-aware, con gestione del LEG-RISK: + - open_pair: piazza entrambe; se UNA sola filla -> UNWIND (richiude la fillata + reduce-only) per non restare con esposizione direzionale netta -> verified=False. + - close_pair: chiude entrambe reduce-only (market); ritorna fee e prezzi reali. + Strumenti = lineari USDC (payoff lineare == matematica del backtest a 2 gambe; fee + in USDC). amount per gamba arrotondato allo step del rispettivo strumento (doppio + arrotondamento: piccolo sbilanciamento di notional inevitabile, riportato). + """ + leg: "ExecutionClient" = field(default_factory=ExecutionClient) + + def __post_init__(self): + # registra gli spec USDC degli strumenti pair non hardcodati (LTC/ADA/SOL ci sono; + # questo copre eventuali coppie future) + self.client = self.leg.client + + def ensure_specs(self, *instruments: str): + for inst in instruments: + register_contract(inst, self.client) + + def open_pair(self, inst_a: str, inst_b: str, direction: int, + notional_usd: float, label: str | None = None) -> PairFill: + """direction +1 = long A / short B; -1 = short A / long B. notional uguale per gamba.""" + self.ensure_specs(inst_a, inst_b) + side_a = "buy" if direction == 1 else "sell" + side_b = "sell" if direction == 1 else "buy" + fa = self.leg.open(inst_a, side_a, notional_usd, label=label) + fb = self.leg.open(inst_b, side_b, notional_usd, label=label) + if fa.verified and fb.verified: + return PairFill(True, fa, fb) + # LEG-RISK: una sola gamba (o nessuna) verificata -> unwind la fillata + 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) + unwound = True + return PairFill(False, fa, fb, unwound=unwound, + notes=f"leg-fail (a={fa.verified} b={fb.verified}), unwound={unwound}") + + def close_pair(self, inst_a: str, inst_b: str, side_a: str, side_b: str, + amount_a: float, amount_b: float, label: str | None = None) -> PairFill: + """Chiude entrambe le gambe a mercato (reduce-only del lato opposto all'entrata). + Ritorna PairFill con i Fill di chiusura (fee/prezzi reali). verified = entrambe chiuse.""" + ca = self.leg.close_amount(inst_a, side_a, amount_a, label=label) + cb = self.leg.close_amount(inst_b, side_b, amount_b, label=label) + return PairFill(ca.verified and cb.verified, ca, cb, + notes="" if (ca.verified and cb.verified) + else f"close parziale (a={ca.verified} b={cb.verified})") diff --git a/src/live/pairs_worker.py b/src/live/pairs_worker.py index 0c5170f..ea68409 100644 --- a/src/live/pairs_worker.py +++ b/src/live/pairs_worker.py @@ -38,6 +38,8 @@ class PairsWorker: fee_rt: float = 0.001, # per gamba RT; la coppia paga 2x name: str = "PR01_pairs_reversion", data_dir: Path = Path("data/paper_trades"), + executor=None, # PairsExecutionClient: esecuzione REALE shadow a 2 gambe + exec_instruments: dict | None = None, # {asset: instrument USDC} ): self.asset_a = asset_a self.asset_b = asset_b @@ -74,6 +76,27 @@ class PairsWorker: self.last_bar_ts = 0 self.started_at = datetime.now(timezone.utc).isoformat() + # --- esecuzione REALE shadow a 2 gambe (sim resta la verita' che guida) --- + self.executor = executor + self.exec_instruments = exec_instruments or {} + self.inst_a = self.exec_instruments.get(asset_a) + self.inst_b = self.exec_instruments.get(asset_b) + self.execution_enabled = bool(executor and self.inst_a and self.inst_b) + self.real_capital = capital + self.real_in_position = False + self.real_dir = 0 + self.real_side_a = "" # lato della gamba A all'apertura ("buy"/"sell") + self.real_side_b = "" + self.real_amount_a = 0.0 # amount eseguito per gamba (base-coin) + self.real_amount_b = 0.0 + self.real_entry_a = 0.0 # prezzo di fill per gamba + self.real_entry_b = 0.0 + self.real_notional_a = 0.0 # USD effettivi per gamba + self.real_notional_b = 0.0 + self.real_entry_fee = 0.0 + self.real_trades = 0 + self.real_first_notified = False + self._load_state() self._save_state() @@ -98,8 +121,24 @@ class PairsWorker: self.total_wins = s.get("total_wins", 0) self.last_bar_ts = s.get("last_bar_ts", 0) self.started_at = s.get("started_at", self.started_at) + self.real_capital = s.get("real_capital", self.initial_capital) + self.real_in_position = s.get("real_in_position", False) + self.real_dir = s.get("real_dir", 0) + self.real_side_a = s.get("real_side_a", "") + self.real_side_b = s.get("real_side_b", "") + self.real_amount_a = s.get("real_amount_a", 0.0) + self.real_amount_b = s.get("real_amount_b", 0.0) + self.real_entry_a = s.get("real_entry_a", 0.0) + self.real_entry_b = s.get("real_entry_b", 0.0) + self.real_notional_a = s.get("real_notional_a", 0.0) + self.real_notional_b = s.get("real_notional_b", 0.0) + self.real_entry_fee = s.get("real_entry_fee", 0.0) + self.real_trades = s.get("real_trades", 0) + self.real_first_notified = s.get("real_first_notified", False) 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_in_position": self.real_in_position}) def _save_state(self): state = { @@ -109,6 +148,13 @@ class PairsWorker: "bars_held": self.bars_held, "total_trades": self.total_trades, "total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts, "started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(), + "real_capital": round(self.real_capital, 4), "real_in_position": self.real_in_position, + "real_dir": self.real_dir, "real_side_a": self.real_side_a, "real_side_b": self.real_side_b, + "real_amount_a": self.real_amount_a, "real_amount_b": self.real_amount_b, + "real_entry_a": self.real_entry_a, "real_entry_b": self.real_entry_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_first_notified": self.real_first_notified, } with open(self.status_path, "w") as f: json.dump(state, f, indent=2) @@ -145,6 +191,70 @@ class PairsWorker: "entry_a": round(ca, 4), "entry_b": round(cb, 4), "z": round(z, 3), "capital": round(self.capital, 2)} self._log("OPEN", data); self._notify("OPENED", data) + if self.execution_enabled: + self._real_open_pair(d, ca, cb) + + def _real_open_pair(self, d: int, sim_a: float, sim_b: float): + """Apertura REALE shadow a 2 gambe (long A/short B se d=1). Notional uguale per + gamba = capital*pos*lev. Logga slippage e fee reali; gestisce il leg-fail.""" + notional = self.capital * self.position_size * self.leverage + pf = self.executor.open_pair(self.inst_a, self.inst_b, d, notional, label=self.worker_id) + data = {"dir": d, "inst_a": self.inst_a, "inst_b": self.inst_b, + "notional_leg": round(notional, 2), + "fill_a": pf.leg_a.fill_price, "fill_b": pf.leg_b.fill_price, + "fee_usd": round(pf.leg_a.fee_usd + pf.leg_b.fee_usd, 5), + "verified": pf.verified} + if pf.verified: + 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 + 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 + self.real_notional_b = pf.leg_b.amount * self.real_entry_b + self.real_entry_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd + self._log("REAL_OPEN_PAIR", data) + if not self.real_first_notified: + self._notify("REAL_EXEC_LIVE", data); self.real_first_notified = True + else: + self._log("REAL_OPEN_FAIL", {**data, "note": pf.notes}) + self._notify("REAL_OPEN_FAIL", {**data, "note": pf.notes}) + self._save_state() # persisti subito il ledger reale (resume-safe sui crash) + + def _real_close_pair(self, sim_a: float, sim_b: float, reason: str, sim_pnl: float): + """Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia + PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo.""" + if not self.real_in_position: + return + 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, + label=self.worker_id) + 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 + da, db = self.real_dir, -self.real_dir + gross = (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) + exit_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd + real_pnl = gross - 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, + "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}) + if not pf.verified: + self._notify("REAL_CLOSE_FAILED", {"worker": self.worker_id, "note": pf.notes}) + self.real_in_position = False + self.real_dir = 0 + self.real_side_a = self.real_side_b = "" + self.real_amount_a = self.real_amount_b = 0.0 + self.real_entry_a = self.real_entry_b = 0.0 + self.real_notional_a = self.real_notional_b = 0.0 + self.real_entry_fee = 0.0 + self._save_state() def _close(self, ca: float, cb: float, z: float, reason: str): if not self.in_position: @@ -166,6 +276,8 @@ class PairsWorker: "capital": round(self.capital, 2), "bars_held": self.bars_held, "win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)} self._log("CLOSE", data); self._notify("CLOSED", data) + if self.execution_enabled: + self._real_close_pair(ca, cb, reason, pnl) self.in_position = False self.direction = 0 self.entry_a = self.entry_b = self.entry_z = 0.0 diff --git a/src/live/telegram_notifier.py b/src/live/telegram_notifier.py index a63ea78..57cabbf 100644 --- a/src/live/telegram_notifier.py +++ b/src/live/telegram_notifier.py @@ -27,6 +27,7 @@ NOTIFY_EVENTS = { # testnet: sim entra su un prezzo fantasma, il reale sul book) "REAL_DSL_CANCEL_FAIL", # cancel del disaster-SL fallita dopo retry: possibile # stop ORFANO sul book -> verificare a mano + "REAL_CLOSE_FAILED", # chiusura reale a 2 gambe (pairs) non verificata su una gamba } diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 135d4f4..be6eca7 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -49,16 +49,19 @@ def pos_for_spec(sid: str, global_ps: float, family_overrides: dict[str, float]) def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, data_dir: Path = DATA_DIR, position_size: float = 0.15, - executor=None, exec_instrument: str | None = None): + executor=None, exec_instrument: str | None = None, + pairs_executor=None, exec_instruments: dict | None = None): """Costruisce il worker esecutore per uno sleeve con capitale = quota allocata. - executor/exec_instrument: se valorizzati (solo per i fade single-leg abilitati), - lo StrategyWorker affianca al fill simulato un ordine REALE su Deribit (shadow).""" + executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim + un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker + (esecuzione reale a 2 gambe).""" if spec.kind == "pairs": return PairsWorker( asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params, capital=alloc_capital, position_size=position_size, leverage=leverage, fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir, + executor=pairs_executor, exec_instruments=exec_instruments, ) if spec.kind == "basket": pr = spec.params @@ -245,7 +248,9 @@ def run(config_path: str = "portfolios.yml"): exec_enabled = bool(_exec_cfg.get("enabled")) exec_sleeves = set(_exec_cfg.get("sleeves", [])) exec_instr = _exec_cfg.get("instruments", {}) or {} + pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe executor = None + pairs_executor = None if exec_enabled: from src.live.execution import ExecutionClient executor = ExecutionClient(client=client) @@ -253,6 +258,10 @@ def run(config_path: str = "portfolios.yml"): executor.disaster_sl_pct = float(_exec_cfg.get("disaster_sl_pct", 0.30) or 0) or None print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} " f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct}") + if pairs_exec_enabled: + from src.live.execution import PairsExecutionClient + pairs_executor = PairsExecutionClient(leg=executor) + print(f"[runner] ESECUZIONE REALE PAIRS (2 gambe) attiva — strumenti={exec_instr}") def _exec_for(s): """(executor, exec_instrument) per uno sleeve, solo se fade single-leg abilitato.""" @@ -260,6 +269,12 @@ def run(config_path: str = "portfolios.yml"): return None, None return executor, exec_instr.get(s.asset) + def _pairs_exec_for(s): + """(pairs_executor, {asset: instrument}) per uno sleeve pairs, se abilitato.""" + if not pairs_exec_enabled or s.kind != "pairs": + return None, None + return pairs_executor, exec_instr + dr = sleeve_returns_df(live_ids) weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights, caps=p.caps, clusters=clusters, lookback=p.vol_lookback) @@ -267,9 +282,11 @@ def run(config_path: str = "portfolios.yml"): workers = {} for s in live_specs: ex, inst = _exec_for(s) + pex, pinst = _pairs_exec_for(s) workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage, position_size=pos_for_spec(s.sid, position_size, ps_family), - executor=ex, exec_instrument=inst) + executor=ex, exec_instrument=inst, + pairs_executor=pex, exec_instruments=pinst) if ps_family: print(f"[runner] position_size globale={position_size} override famiglia={ps_family}") diff --git a/tests/portfolio/test_pairs_execution.py b/tests/portfolio/test_pairs_execution.py new file mode 100644 index 0000000..bc5173e --- /dev/null +++ b/tests/portfolio/test_pairs_execution.py @@ -0,0 +1,100 @@ +"""PairsWorker esecuzione reale a 2 gambe (shadow): apertura/chiusura, leg-risk unwind, +ledger reale parallelo. Executor finto, nessuna rete.""" +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest + +from src.live.execution import Fill, PairFill +from src.live.pairs_worker import PairsWorker + + +class FakePairsExec: + """Simula PairsExecutionClient: fill deterministici, con possibilità di leg-fail.""" + def __init__(self, fail_leg=None): + self.fail_leg = fail_leg # None | 'a' | 'b' : quale gamba NON filla all'open + self.opened = [] + self.closed = [] + self.unwound = [] + + 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) + + def open_pair(self, inst_a, inst_b, direction, notional, label=None): + side_a = "buy" if direction == 1 else "sell" + side_b = "sell" if direction == 1 else "buy" + ok_a = self.fail_leg != 'a' + ok_b = self.fail_leg != 'b' + fa = self._fill(inst_a, side_a, 100.0, ok=ok_a) + fb = self._fill(inst_b, side_b, 50.0, ok=ok_b) + self.opened.append((inst_a, inst_b, direction)) + if ok_a and ok_b: + return PairFill(True, fa, fb) + if ok_a and fa.amount > 0: + self.unwound.append(inst_a) + if ok_b and fb.amount > 0: + self.unwound.append(inst_b) + return PairFill(False, fa, fb, unwound=bool(self.unwound), notes="leg-fail") + + def close_pair(self, inst_a, inst_b, side_a, side_b, amt_a, amt_b, label=None): + opp_a = "sell" if side_a == "buy" else "buy" + opp_b = "sell" if side_b == "buy" else "buy" + self.closed.append((inst_a, inst_b)) + # prezzi di uscita: A salito a 102, B fermo a 50 -> long-A/short-B guadagna + return PairFill(True, self._fill(inst_a, opp_a, 102.0, amt_a), + self._fill(inst_b, opp_b, 50.0, amt_b)) + + +INST = {"ETH": "ETH_USDC-PERPETUAL", "BTC": "BTC_USDC-PERPETUAL"} + + +def _worker(tmp_path, fake, monkeypatch, notified=None): + if notified is not None: + monkeypatch.setattr("src.live.pairs_worker.notify_event", + lambda ev, data=None: notified.append(ev)) + return PairsWorker("ETH", "BTC", "1h", capital=200.0, position_size=0.2, leverage=2.0, + data_dir=tmp_path, executor=fake, exec_instruments=INST) + + +def test_execution_enabled_wired(tmp_path): + w = _worker(tmp_path, FakePairsExec(), pytest.MonkeyPatch()) + assert w.execution_enabled and w.inst_a == "ETH_USDC-PERPETUAL" and w.inst_b == "BTC_USDC-PERPETUAL" + + +def test_real_open_and_close_pair(tmp_path, monkeypatch): + notified = [] + fake = FakePairsExec() + w = _worker(tmp_path, fake, monkeypatch, notified) + w._open(1, 100.0, 50.0, -2.5) # long ratio + assert w.real_in_position and w.real_dir == 1 + assert w.real_side_a == "buy" and w.real_side_b == "sell" + assert fake.opened and "REAL_EXEC_LIVE" in notified + cap0 = w.real_capital + w._close(102.0, 50.0, 0.3, "mean_revert") + assert not w.real_in_position and fake.closed + assert w.real_capital > cap0 # A +2% / B 0 -> long-A/short-B in utile + assert w.real_trades == 1 + + +def test_leg_fail_unwinds_and_no_position(tmp_path, monkeypatch): + notified = [] + fake = FakePairsExec(fail_leg='b') # gamba B non filla + w = _worker(tmp_path, fake, monkeypatch, notified) + w._open(1, 100.0, 50.0, -2.5) + assert not w.real_in_position # niente posizione reale + assert "ETH_USDC-PERPETUAL" in fake.unwound # la gamba A fillata e' stata richiusa + assert "REAL_OPEN_FAIL" in notified + + +def test_real_ledger_persists_and_resumes(tmp_path, monkeypatch): + fake = FakePairsExec() + w = _worker(tmp_path, fake, monkeypatch, []) + w._open(-1, 100.0, 50.0, 2.5) # short ratio + assert w.real_in_position and w.real_dir == -1 + w2 = PairsWorker("ETH", "BTC", "1h", capital=200.0, position_size=0.2, leverage=2.0, + data_dir=tmp_path, executor=fake, exec_instruments=INST) + assert w2.real_in_position and w2.real_dir == -1 + assert w2.real_side_a == "sell" and w2.real_amount_a > 0