From cb1b6ea46a157eb6583d01b934524384e62c168c Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Wed, 3 Jun 2026 10:11:26 +0000 Subject: [PATCH] feat(live): esecuzione REALE su Deribit testnet (shadow) per i 6 fade sui lineari USDC - ExecutionClient: notional->amount (lineare USDC + inverse), open/close_amount reduce-only, verifica sul trade (order_id), fee reali lette dai trades[] - CerberoClient: place_order market + reduce_only, get_trade_history - StrategyWorker: shadow (REAL_OPEN/REAL_CLOSE accanto al sim), ledger reale parallelo persistito, confronto slippage/fee sim-vs-reale - runner+portfolios.yml: config execution (6 fade MR01/MR02/MR07 x BTC/ETH su BTC_USDC/ETH_USDC-PERPETUAL), capitale 2000 - smoke: live_exec_smoke (layer) + live_shadow_smoke (catena worker), provati su testnet Co-Authored-By: Claude Opus 4.8 (1M context) --- portfolios.yml | 12 +- scripts/analysis/live_exec_smoke.py | 80 +++++++++ scripts/analysis/live_shadow_smoke.py | 67 ++++++++ src/live/cerbero_client.py | 21 ++- src/live/execution.py | 234 ++++++++++++++++++++++++++ src/live/strategy_worker.py | 121 ++++++++++++- src/portfolio/runner.py | 34 +++- 7 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 scripts/analysis/live_exec_smoke.py create mode 100644 scripts/analysis/live_shadow_smoke.py create mode 100644 src/live/execution.py diff --git a/portfolios.yml b/portfolios.yml index c956df9..9360cd0 100644 --- a/portfolios.yml +++ b/portfolios.yml @@ -2,9 +2,19 @@ # (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi. active: PORT06 # default raccomandato: master + shape overrides: - total_capital: 1000 + total_capital: 2000 weighting: cap # equal | cap | inverse_vol | cluster_rp | manual caps: {PAIRS: 0.33} leverage: 2 # sobrio per il live reale rebalance: 1D poll_seconds: 60 + # Esecuzione REALE su Deribit testnet, in SHADOW (sim + reale in parallelo). + # Solo i 6 fade single-leg (MR01/MR02/MR07 x BTC/ETH); ordini sui LINEARI USDC + # (payoff lineare = matematica del backtest; fee/PnL in USDC). Gli altri sleeve + # (pairs/rotation/tsmom/shape/dip) restano simulati. + execution: + enabled: true + sleeves: [MR01, MR02, MR07] + instruments: + BTC: BTC_USDC-PERPETUAL + ETH: ETH_USDC-PERPETUAL diff --git a/scripts/analysis/live_exec_smoke.py b/scripts/analysis/live_exec_smoke.py new file mode 100644 index 0000000..4fb948f --- /dev/null +++ b/scripts/analysis/live_exec_smoke.py @@ -0,0 +1,80 @@ +"""Smoke end-to-end dell'esecuzione REALE su Deribit testnet. + +Dimostra il giro completo che serve al live: + account → OPEN (ordine reale minimo) → VERIFICA posizione su Deribit → + FEE reale dai trade → CLOSE → VERIFICA flat → riepilogo. + +Usa il sizing MINIMO del contratto (BTC $10, ETH $1): costo testnet = €0. +NON tocca il runner: e' solo una prova della macchina di esecuzione. + + uv run python scripts/analysis/live_exec_smoke.py # ETH + BTC + uv run python scripts/analysis/live_exec_smoke.py ETH-PERPETUAL +""" +from __future__ import annotations + +import sys + +from src.live.cerbero_client import CerberoClient +from src.live.execution import ExecutionClient, contract_spec, settlement_currency + + +def smoke_one(ex: ExecutionClient, instrument: str, notional: float = 10.0) -> bool: + spec = contract_spec(instrument) + cur = settlement_currency(instrument) + kind = "lineare USDC" if spec.get("linear") else "inverse" + print(f"\n===== {instrument} ({kind}, ~${notional:.0f} notional) =====") + + pre = ex._position_size(instrument) + print(f" posizione pre: {pre} USD (atteso 0)") + + print(f" → OPEN buy ${notional:.0f} notional ...") + f = ex.open(instrument, "buy", notional, label="smoke-exec") + print(f" order_id={f.order_id} state={f.order_state} verified={f.verified}") + print(f" fill_price={f.fill_price} amount={f.amount} ({'BTC' if spec.get('linear') else 'USD'})") + print(f" FEE reale: {f.fee_coin:.10f} {cur} (~${f.fee_usd:.4f})") + if f.notes: + print(f" note: {f.notes}") + if not f.verified: + print(" ✗ OPEN NON verificato — interrompo questo strumento") + return False + print(" ✓ posizione confermata su Deribit (riletta da get_positions)") + + print(" → CLOSE ...") + c = ex.close(instrument, label="smoke-exec") + print(f" order_id={c.order_id} state={c.order_state} verified(flat)={c.verified}") + print(f" fill_price={c.fill_price} FEE reale: {c.fee_coin:.10f} {cur} (~${c.fee_usd:.4f})") + if c.notes: + print(f" note: {c.notes}") + + post = ex._position_size(instrument) + ok = f.verified and c.verified and post == 0 + # fee RT reale osservata vs assunto 0.10% RT sul notional + fee_usd_rt = f.fee_usd + c.fee_usd + assumed_rt = notional * 0.001 + print(f" posizione post: {post} USD (atteso 0)") + print(f" FEE RT reale ~${fee_usd_rt:.4f} vs assunto 0.10% RT ~${assumed_rt:.4f}") + print(f" {'✓ OK' if ok else '✗ FALLITO'} — giro completo {instrument}") + return ok + + +def main() -> None: + instruments = sys.argv[1:] or ["ETH-PERPETUAL", "BTC-PERPETUAL"] + 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"): + print("✗ ABORT: non e' testnet — niente ordini reali su mainnet.") + sys.exit(1) + + ex = ExecutionClient(client=client) + results = {inst: smoke_one(ex, inst) for inst in instruments} + + print("\n===== RIEPILOGO =====") + for inst, ok in results.items(): + print(f" {inst:16s} {'✓ OK' if ok else '✗ FALLITO'}") + sys.exit(0 if all(results.values()) else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/live_shadow_smoke.py b/scripts/analysis/live_shadow_smoke.py new file mode 100644 index 0000000..ee42983 --- /dev/null +++ b/scripts/analysis/live_shadow_smoke.py @@ -0,0 +1,67 @@ +"""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. + +Non tocca lo stato di produzione (data_dir temporanea). Costo testnet = €0. + + uv run python scripts/analysis/live_shadow_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 +from src.live.strategy_loader import load_strategy +from src.live.strategy_worker import StrategyWorker +from src.strategies.base import Signal + + +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") + + ex = ExecutionClient(client=client) + instrument = "BTC_USDC-PERPETUAL" + price = ex._mark_price(instrument) + print(f"{instrument} mark={price}") + + with tempfile.TemporaryDirectory() as tmp: + w = StrategyWorker( + strategy=load_strategy("MR01_bollinger_fade"), + asset="BTC", tf="1h", capital=100.0, position_size=0.15, leverage=2.0, + data_dir=Path(tmp), executor=ex, exec_instrument=instrument, + ) + 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={}) + 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" + + # 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") + 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" + + # 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") + + +if __name__ == "__main__": + main() diff --git a/src/live/cerbero_client.py b/src/live/cerbero_client.py index e3749b7..2929005 100644 --- a/src/live/cerbero_client.py +++ b/src/live/cerbero_client.py @@ -89,9 +89,17 @@ class CerberoClient: amount: float, order_type: str = "market", price: float | None = None, - leverage: int | None = 3, + leverage: int | None = None, label: str | None = None, + reduce_only: bool = False, ) -> dict: + """Piazza un ordine REALE su Deribit. `amount`: per i perp inverse + (BTC/ETH-PERPETUAL) e' in USD notional (step BTC $10, ETH $1); per i lineari + USDC (BTC_USDC/ETH_USDC-PERPETUAL) e' nel base-coin (step 0.0001/0.001). + `reduce_only=True` per chiudere solo la propria quota su uno strumento + condiviso (le posizioni si nettano per conto). Ritorna il `result` grezzo + Deribit: {"order": {...}, "trades": [{price, amount, fee, ...}]} → le fee + REALI sono in trades[].""" payload: dict[str, Any] = { "instrument_name": instrument, "side": side, @@ -104,11 +112,22 @@ class CerberoClient: payload["leverage"] = leverage if label: payload["label"] = label + if reduce_only: + payload["reduce_only"] = True return self._post("/mcp-deribit/tools/place_order", payload) def close_position(self, instrument: str) -> dict: return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument}) + def get_trade_history(self, limit: int = 100, instrument_name: str | None = None) -> list[dict]: + """Trade ESEGUITI sul conto (fonte autorevole delle fee reali). Ogni voce: + {instrument, direction, price, amount, fee, timestamp, order_id}.""" + payload: dict[str, Any] = {"limit": limit} + if instrument_name: + payload["instrument_name"] = instrument_name + out = self._post("/mcp-deribit/tools/get_trade_history", payload) + return out if isinstance(out, list) else out.get("trades", []) + def set_stop_loss(self, order_id: str, stop_price: float) -> dict: return self._post("/mcp-deribit/tools/set_stop_loss", {"order_id": order_id, "stop_price": stop_price}) diff --git a/src/live/execution.py b/src/live/execution.py new file mode 100644 index 0000000..9eeac79 --- /dev/null +++ b/src/live/execution.py @@ -0,0 +1,234 @@ +"""Esecuzione REALE su Deribit (testnet) con verifica post-ordine e fee reali. + +Flusso per ogni ordine: + 1. converte il notional (USD) in `amount` Deribit, arrotondato allo step del + contratto (BTC-PERPETUAL step $10, ETH-PERPETUAL step $1) e clampato al minimo; + 2. piazza un market order REALE via Cerbero → Deribit private/buy|sell; + 3. RIVERIFICA su Deribit: rilegge get_positions (la posizione esiste con la size + giusta?) e get_trade_history (ritrova il fill per order_id) — non si fida della + sola risposta dell'ordine; + 4. estrae la FEE REALE dai trades[] del fill (per i perp inverse la fee e' nel + coin di settlement: BTC/ETH → la convertiamo anche in USD col prezzo di fill). + +NB perp inverse Deribit: `amount` e la dimensione posizione sono in USD notional; +la fee dei trade e' denominata nel base-coin (BTC/ETH). +""" +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any + +from src.live.cerbero_client import CerberoClient + +# Specifiche contratto (verificate da test.deribit.com/public/get_instrument). +# INVERSE (reversed): amount in USD, step in USD (es. BTC $10, ETH $1). +# 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"}, +} + + +def contract_spec(instrument: str) -> dict[str, Any]: + return _CONTRACT.get(instrument, {"linear": False, "min": 1.0, "step": 1.0}) + + +def settlement_currency(instrument: str) -> str: + """Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions + e per denominare la fee.""" + spec = contract_spec(instrument) + if spec.get("settle"): + return spec["settle"] + return instrument.split("-")[0].split("_")[0] + + +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. + Inverse: amount in USD (step USD). Lineari USDC: amount in base-coin (serve il + `price` per convertire). Ritorna 0.0 se sotto mezzo step (niente ordine).""" + spec = contract_spec(instrument) + step, mn = spec["step"], spec["min"] + if spec.get("linear"): + if not price: + return 0.0 + units = notional_usd / price # base-coin richiesti + if units < step / 2: + return 0.0 + return max(round(units / step) * step, mn) + if notional_usd < step / 2: + return 0.0 + return max(round(notional_usd / step) * step, mn) + + +@dataclass +class Fill: + """Esito verificato di un ordine reale.""" + instrument: str + side: str # "buy" | "sell" + requested_notional: float # USD chiesti dalla strategia + amount: float # USD effettivi (arrotondati allo step) + fill_price: float | None # prezzo medio di esecuzione (da Deribit) + fee_coin: float # fee reale nel coin di settlement (BTC/ETH) + fee_usd: float # fee reale convertita in USD (fee_coin * fill_price) + order_id: str | None + order_state: str | None # "filled" atteso per market + verified: bool # posizione/trade riscontrati su Deribit + raw: dict[str, Any] = field(default_factory=dict) + notes: str = "" + + +def _avg_fill_price(order: dict, trades: list[dict]) -> float | None: + p = order.get("average_price") + if p: + return float(p) + # fallback: media pesata per amount dai trade + tot_amt = sum(float(t.get("amount", 0) or 0) for t in trades) + if tot_amt > 0: + return sum(float(t.get("price", 0) or 0) * float(t.get("amount", 0) or 0) + for t in trades) / tot_amt + return None + + +@dataclass +class ExecutionClient: + """Wrapper d'esecuzione reale sopra CerberoClient. ogni open/close ritorna un + Fill VERIFICATO (o verified=False con la ragione in notes).""" + client: CerberoClient = field(default_factory=CerberoClient) + verify_polls: int = 4 # tentativi di riverifica + verify_sleep: float = 0.6 # attesa fra i poll (s) + + # --- helper di verifica --- + + def _position_size(self, instrument: str) -> float: + """Size assoluta (USD) della posizione aperta sull'instrument, 0 se flat.""" + cur = settlement_currency(instrument) + try: + for p in self.client.get_positions(currency=cur): + if p.get("instrument") == instrument: + return abs(float(p.get("size", 0) or 0)) + except Exception: + pass + return 0.0 + + def _trade_by_order(self, instrument: str, order_id: str | None) -> dict | None: + """Ritrova il fill nel trade history per order_id (fonte autorevole fee).""" + if not order_id: + return None + try: + for t in self.client.get_trade_history(limit=50, instrument_name=instrument): + if str(t.get("order_id")) == str(order_id): + return t + except Exception: + pass + return None + + # --- API --- + + def _mark_price(self, instrument: str) -> float | None: + try: + t = self.client.get_ticker(instrument) + return float(t.get("mark_price") or t.get("last_price") or 0) or None + except Exception: + return None + + def amount_for(self, instrument: str, notional_usd: float) -> float: + """Notional USD → amount Deribit (gestisce inverse/lineare, prezzo per i lineari).""" + spec = contract_spec(instrument) + price = self._mark_price(instrument) if spec.get("linear") else None + return notional_to_amount(instrument, notional_usd, price=price) + + 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.""" + 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) + 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 {}, + notes=f"place_order error: {resp}") + + order = resp.get("order", resp) or {} + trades = resp.get("trades", []) or [] + order_id = order.get("order_id") + state = order.get("order_state") + fill_price = _avg_fill_price(order, trades) + + # 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) + 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: + fill_price = float(th.get("price") or 0) or None + # lineari USDC: fee gia' in USDC; inverse: nel base-coin → * prezzo + 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) + 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)})") + + def open(self, instrument: str, side: str, notional_usd: float, + label: str | None = None) -> Fill: + """Apre la quota del worker (market, NON reduce_only).""" + amount = self.amount_for(instrument, notional_usd) + return self._submit(instrument, side, amount, notional_usd, + reduce_only=False, label=label) + + def close_amount(self, instrument: str, entry_side: str, amount: float, + label: str | None = None) -> Fill: + """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).""" + opp = "sell" if entry_side == "buy" else "buy" + return self._submit(instrument, opp, amount, 0.0, + reduce_only=True, label=label) + + 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.""" + side = "close" + resp = self.client.close_position(instrument) + if not isinstance(resp, dict) or resp.get("state") == "error" or "error" in resp: + return Fill(instrument, side, 0.0, 0.0, None, 0.0, 0.0, None, "error", + False, raw=resp if isinstance(resp, dict) else {}, + notes=f"close error: {resp}") + order_id = resp.get("order_id") + + # fee/prezzo di chiusura dal trade history (close_position non li ritorna) + th = self._trade_by_order(instrument, order_id) + fee_coin = float(th["fee"]) if th and th.get("fee") is not None else 0.0 + fill_price = float(th.get("price")) if th and th.get("price") else None + if contract_spec(instrument).get("linear"): + fee_usd = fee_coin + else: + fee_usd = fee_coin * fill_price if (fee_coin and fill_price) else 0.0 + + # verifica: la posizione deve essere tornata flat + pos = 1.0 + for _ in range(self.verify_polls): + pos = self._position_size(instrument) + if pos == 0: + break + time.sleep(self.verify_sleep) + verified = pos == 0 + + 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})") diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 823740f..5322fcf 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -10,6 +10,7 @@ import pandas as pd from src.strategies.base import Strategy, Signal from src.live.telegram_notifier import notify_event +from src.live.execution import ExecutionClient FEE_RT = 0.002 @@ -28,6 +29,8 @@ class StrategyWorker: hold_bars: int = 3, params: dict | None = None, data_dir: Path = Path("data/paper_trades"), + executor: ExecutionClient | None = None, + exec_instrument: str | None = None, ): self.strategy = strategy self.asset = asset @@ -38,6 +41,21 @@ class StrategyWorker: self.hold_bars = hold_bars self.params = params or {} + # --- Esecuzione REALE (shadow): se attiva, ogni open/close sim e' affiancato + # da un ordine reale su Deribit (lineare USDC), con ledger reale parallelo. --- + self.executor = executor + self.exec_instrument = exec_instrument + self.execution_enabled = bool(executor and exec_instrument) + self.real_capital = capital + self.real_in_position = False + self.real_side = "" # "buy" | "sell" dell'apertura reale + self.real_amount = 0.0 # amount Deribit (base-coin) da richiudere + self.real_entry_price = 0.0 + self.real_entry_fee_usd = 0.0 + self.real_entry_notional = 0.0 # USD effettivi esposti all'entrata + self.real_order_id = "" + self.real_trades = 0 + self.worker_id = f"{strategy.name}__{asset}__{tf}" self.work_dir = data_dir / self.worker_id self.work_dir.mkdir(parents=True, exist_ok=True) @@ -89,9 +107,21 @@ class StrategyWorker: self.sl = state.get("sl", 0.0) self.max_bars = state.get("max_bars", 0) + self.real_capital = state.get("real_capital", self.initial_capital) + self.real_in_position = state.get("real_in_position", False) + self.real_side = state.get("real_side", "") + self.real_amount = state.get("real_amount", 0.0) + self.real_entry_price = state.get("real_entry_price", 0.0) + 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_trades = state.get("real_trades", 0) + self._log("RESUME", {"capital": round(self.capital, 2), "total_trades": self.total_trades, - "in_position": self.in_position}) + "in_position": self.in_position, + "real_capital": round(self.real_capital, 2), + "real_in_position": self.real_in_position}) def _save_state(self): state = { @@ -108,6 +138,15 @@ class StrategyWorker: "tp": self.tp, "sl": self.sl, "max_bars": self.max_bars, + "real_capital": round(self.real_capital, 4), + "real_in_position": self.real_in_position, + "real_side": self.real_side, + "real_amount": self.real_amount, + "real_entry_price": self.real_entry_price, + "real_entry_fee_usd": self.real_entry_fee_usd, + "real_entry_notional": self.real_entry_notional, + "real_order_id": self.real_order_id, + "real_trades": self.real_trades, "last_update": datetime.now(timezone.utc).isoformat(), } with open(self.status_path, "w") as f: @@ -155,6 +194,83 @@ class StrategyWorker: self._log("OPEN", trade_data) self._notify("OPENED", trade_data) + if self.execution_enabled: + self._real_open(signal.direction, current_price, notional) + + def _real_open(self, direction: int, sim_price: float, notional: float): + """Apertura REALE (shadow) accanto al fill simulato. Logga il confronto + prezzo-sim vs prezzo-eseguito e la fee reale Deribit.""" + from src.live.execution import contract_spec + side = "buy" if direction == 1 else "sell" + fill = self.executor.open(self.exec_instrument, side, notional, label=self.worker_id) + + slip_bps = ((fill.fill_price / sim_price - 1) * 1e4 + if fill.fill_price and sim_price else None) + data = { + "instrument": self.exec_instrument, + "side": side, + "order_id": fill.order_id, + "amount": fill.amount, + "sim_price": round(sim_price, 2), + "real_fill": fill.fill_price, + "slippage_bps": round(slip_bps, 2) if slip_bps is not None else None, + "fee_usd": round(fill.fee_usd, 5), + "verified": fill.verified, + } + if fill.verified: + linear = contract_spec(self.exec_instrument).get("linear") + self.real_in_position = True + self.real_side = side + self.real_amount = fill.amount + self.real_entry_price = fill.fill_price or sim_price + self.real_entry_fee_usd = fill.fee_usd + self.real_entry_notional = (fill.amount * self.real_entry_price + if linear else fill.amount) + self.real_order_id = fill.order_id or "" + self._log("REAL_OPEN", data) + else: + self._log("REAL_OPEN_FAIL", {**data, "note": fill.notes}) + + def _real_close(self, sim_exit: float, reason: str, sim_pnl: float): + """Chiusura REALE (reduce-only della quota worker) + confronto col sim.""" + 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 + 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_pnl = real_gross - real_fees + self.real_capital += real_pnl + self.real_trades += 1 + + slip_bps = ((exit_price / sim_exit - 1) * 1e4 + if exit_price and sim_exit else None) + self._log("REAL_CLOSE", { + "reason": reason, + "order_id": fill.order_id, + "sim_exit": round(sim_exit, 2), + "real_fill": fill.fill_price, + "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), + "real_pnl_usd": round(real_pnl, 4), + "sim_pnl_usd": round(sim_pnl, 4), + "real_capital": round(self.real_capital, 4), + "verified": fill.verified, + }) + + self.real_in_position = False + self.real_side = "" + self.real_amount = 0.0 + self.real_entry_price = 0.0 + self.real_entry_fee_usd = 0.0 + self.real_entry_notional = 0.0 + self.real_order_id = "" + def _close_position(self, current_price: float, reason: str): if not self.in_position: return @@ -189,6 +305,9 @@ class StrategyWorker: self._log("CLOSE", trade_data) self._notify("CLOSED", trade_data) + if self.execution_enabled: + self._real_close(current_price, reason, pnl) + self.in_position = False self.direction = 0 self.entry_price = 0 diff --git a/src/portfolio/runner.py b/src/portfolio/runner.py index 6236c22..eca38e4 100644 --- a/src/portfolio/runner.py +++ b/src/portfolio/runner.py @@ -41,8 +41,12 @@ _ML_LOOKBACK_DAYS = 365 def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, - data_dir: Path = DATA_DIR, position_size: float = 0.15): - """Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.""" + data_dir: Path = DATA_DIR, position_size: float = 0.15, + executor=None, exec_instrument: str | 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).""" if spec.kind == "pairs": return PairsWorker( asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params, @@ -81,6 +85,7 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float, return StrategyWorker( strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital, position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir, + executor=executor, exec_instrument=exec_instrument, ) @@ -156,11 +161,34 @@ def run(config_path: str = "portfolios.yml"): ledger = PortfolioLedger(p.code, total_capital=p.total_capital) client = CerberoClient() + # --- Esecuzione REALE (shadow) su Deribit testnet, solo sui fade abilitati --- + # overrides.execution: {enabled, sleeves:[MR01,...], instruments:{BTC:..,ETH:..}} + _exec_cfg = _ov.get("execution", {}) or {} + exec_enabled = bool(_exec_cfg.get("enabled")) + exec_sleeves = set(_exec_cfg.get("sleeves", [])) + exec_instr = _exec_cfg.get("instruments", {}) or {} + executor = None + if exec_enabled: + from src.live.execution import ExecutionClient + executor = ExecutionClient(client=client) + print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} " + f"strumenti={exec_instr}") + + def _exec_for(s): + """(executor, exec_instrument) per uno sleeve, solo se fade single-leg abilitato.""" + if not exec_enabled or s.kind not in ("single",) or s.name not in exec_sleeves: + return None, None + return executor, exec_instr.get(s.asset) + 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) alloc = ledger.allocate(weights) - workers = {s.sid: build_worker_for(s, alloc[s.sid], p.leverage) for s in live_specs} + workers = {} + for s in live_specs: + ex, inst = _exec_for(s) + workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage, + executor=ex, exec_instrument=inst) # lookback (giorni) richiesto per ogni asset = max sui worker che lo usano asset_days: dict[str, int] = {}