c655604131
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>
101 lines
4.3 KiB
Python
101 lines
4.3 KiB
Python
"""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
|