feat(live): executor a 2 gambe per i pairs (PairsExecutionClient, shadow) — pronto, spento

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) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-08 10:19:56 +00:00
parent a2f1b960ec
commit c655604131
9 changed files with 470 additions and 12 deletions
+113 -1
View File
@@ -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