2 Commits

Author SHA1 Message Date
Adriano Dal Pastro 59901e236b release: v1.1.11 2026-06-08 10:19:56 +00:00
Adriano Dal Pastro c655604131 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>
2026-06-08 10:19:56 +00:00
10 changed files with 471 additions and 13 deletions
+1 -1
View File
@@ -1 +1 @@
1.1.10
1.1.11
+6 -5
View File
@@ -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
+53
View File
@@ -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.
+7
View File
@@ -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
+81
View File
@@ -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()
+88 -2
View File
@@ -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})")
+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
+1
View File
@@ -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
}
+21 -4
View File
@@ -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}")
+100
View File
@@ -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