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:
+6
-5
@@ -5,11 +5,12 @@
|
|||||||
|
|
||||||
## Esecuzione reale — i pezzi grossi mancanti
|
## Esecuzione reale — i pezzi grossi mancanti
|
||||||
|
|
||||||
- [ ] **Executor a 2 gambe per i PAIRS** (`PairsExecutionClient`). È la fetta reale-non-eseguita
|
- [x] ~~Executor a 2 gambe per i PAIRS~~ — FATTO (2026-06-08): `PairsExecutionClient`
|
||||||
più grande (29% del portafoglio). Serve: piazzamento il più possibile atomico delle 2 gambe,
|
(open/close 2 gambe, leg-risk unwind, ledger shadow resume-safe). Test 92/92 + smoke
|
||||||
fallback di **unwind** se una sola filla (leg-risk), dimensionamento congiunto col doppio
|
testnet end-to-end OK. **Capability PRONTA ma SPENTA** (`pairs_enabled: false`).
|
||||||
arrotondamento ai lotti minimi (BTC/ETH/LTC/ADA/SOL hanno step diversi), riconciliazione e
|
Diario `docs/diary/2026-06-08-pairs-executor.md`.
|
||||||
chiusura a 2 gambe. Progetto a sé, il più impegnativo rimasto.
|
- [ ] **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
|
- [ ] **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
|
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
|
"market reduce-only a scadenza orizzonte" nell'executor; (b) valutare TP/SL su SH01 — MA la
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -33,6 +33,13 @@ overrides:
|
|||||||
instruments:
|
instruments:
|
||||||
BTC: BTC_USDC-PERPETUAL
|
BTC: BTC_USDC-PERPETUAL
|
||||||
ETH: ETH_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%
|
# Disaster-bracket on-book (2026-06-07): STOP_MARKET reduce-only a ~-30%
|
||||||
# dall'ingresso, piazzato a ogni REAL_OPEN e cancellato alla chiusura.
|
# dall'ingresso, piazzato a ogni REAL_OPEN e cancellato alla chiusura.
|
||||||
# Assicurazione per gli outage (runner fermo = exit non valutati); in
|
# Assicurazione per gli outage (runner fermo = exit non valutati); in
|
||||||
|
|||||||
@@ -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
@@ -29,8 +29,12 @@ from src.live.cerbero_client import CerberoClient
|
|||||||
_CONTRACT: dict[str, dict[str, Any]] = {
|
_CONTRACT: dict[str, dict[str, Any]] = {
|
||||||
"BTC-PERPETUAL": {"linear": False, "min": 10.0, "step": 10.0, "tick": 0.5},
|
"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},
|
"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"},
|
"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"},
|
"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})
|
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:
|
def settlement_currency(instrument: str) -> str:
|
||||||
"""Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions
|
"""Inverse → base-coin (BTC/ETH); lineari USDC → USDC. Usato per get_positions
|
||||||
e per denominare la fee."""
|
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,
|
return Fill(instrument, side, 0.0, 0.0, fill_price, fee_coin, fee_usd,
|
||||||
order_id, resp.get("state"), verified, raw=resp,
|
order_id, resp.get("state"), verified, raw=resp,
|
||||||
notes="" if verified else f"posizione non flat dopo close (pos={pos})")
|
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
@@ -38,6 +38,8 @@ class PairsWorker:
|
|||||||
fee_rt: float = 0.001, # per gamba RT; la coppia paga 2x
|
fee_rt: float = 0.001, # per gamba RT; la coppia paga 2x
|
||||||
name: str = "PR01_pairs_reversion",
|
name: str = "PR01_pairs_reversion",
|
||||||
data_dir: Path = Path("data/paper_trades"),
|
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_a = asset_a
|
||||||
self.asset_b = asset_b
|
self.asset_b = asset_b
|
||||||
@@ -74,6 +76,27 @@ class PairsWorker:
|
|||||||
self.last_bar_ts = 0
|
self.last_bar_ts = 0
|
||||||
self.started_at = datetime.now(timezone.utc).isoformat()
|
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._load_state()
|
||||||
self._save_state()
|
self._save_state()
|
||||||
|
|
||||||
@@ -98,8 +121,24 @@ class PairsWorker:
|
|||||||
self.total_wins = s.get("total_wins", 0)
|
self.total_wins = s.get("total_wins", 0)
|
||||||
self.last_bar_ts = s.get("last_bar_ts", 0)
|
self.last_bar_ts = s.get("last_bar_ts", 0)
|
||||||
self.started_at = s.get("started_at", self.started_at)
|
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),
|
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):
|
def _save_state(self):
|
||||||
state = {
|
state = {
|
||||||
@@ -109,6 +148,13 @@ class PairsWorker:
|
|||||||
"bars_held": self.bars_held, "total_trades": self.total_trades,
|
"bars_held": self.bars_held, "total_trades": self.total_trades,
|
||||||
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
|
"total_wins": self.total_wins, "last_bar_ts": self.last_bar_ts,
|
||||||
"started_at": self.started_at, "last_update": datetime.now(timezone.utc).isoformat(),
|
"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:
|
with open(self.status_path, "w") as f:
|
||||||
json.dump(state, f, indent=2)
|
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),
|
"entry_a": round(ca, 4), "entry_b": round(cb, 4), "z": round(z, 3),
|
||||||
"capital": round(self.capital, 2)}
|
"capital": round(self.capital, 2)}
|
||||||
self._log("OPEN", data); self._notify("OPENED", data)
|
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):
|
def _close(self, ca: float, cb: float, z: float, reason: str):
|
||||||
if not self.in_position:
|
if not self.in_position:
|
||||||
@@ -166,6 +276,8 @@ class PairsWorker:
|
|||||||
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
||||||
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
||||||
self._log("CLOSE", data); self._notify("CLOSED", data)
|
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.in_position = False
|
||||||
self.direction = 0
|
self.direction = 0
|
||||||
self.entry_a = self.entry_b = self.entry_z = 0.0
|
self.entry_a = self.entry_b = self.entry_z = 0.0
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ NOTIFY_EVENTS = {
|
|||||||
# testnet: sim entra su un prezzo fantasma, il reale sul book)
|
# testnet: sim entra su un prezzo fantasma, il reale sul book)
|
||||||
"REAL_DSL_CANCEL_FAIL", # cancel del disaster-SL fallita dopo retry: possibile
|
"REAL_DSL_CANCEL_FAIL", # cancel del disaster-SL fallita dopo retry: possibile
|
||||||
# stop ORFANO sul book -> verificare a mano
|
# stop ORFANO sul book -> verificare a mano
|
||||||
|
"REAL_CLOSE_FAILED", # chiusura reale a 2 gambe (pairs) non verificata su una gamba
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+21
-4
@@ -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,
|
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
||||||
data_dir: Path = DATA_DIR, position_size: float = 0.15,
|
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.
|
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata.
|
||||||
|
|
||||||
executor/exec_instrument: se valorizzati (solo per i fade single-leg abilitati),
|
executor/exec_instrument: per i fade single-leg, StrategyWorker affianca al fill sim
|
||||||
lo StrategyWorker affianca al fill simulato un ordine REALE su Deribit (shadow)."""
|
un ordine REALE (shadow). pairs_executor/exec_instruments: idem per i PairsWorker
|
||||||
|
(esecuzione reale a 2 gambe)."""
|
||||||
if spec.kind == "pairs":
|
if spec.kind == "pairs":
|
||||||
return PairsWorker(
|
return PairsWorker(
|
||||||
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
|
asset_a=spec.a, asset_b=spec.b, tf=spec.tf, params=spec.params,
|
||||||
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
capital=alloc_capital, position_size=position_size, leverage=leverage,
|
||||||
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
|
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
|
||||||
|
executor=pairs_executor, exec_instruments=exec_instruments,
|
||||||
)
|
)
|
||||||
if spec.kind == "basket":
|
if spec.kind == "basket":
|
||||||
pr = spec.params
|
pr = spec.params
|
||||||
@@ -245,7 +248,9 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
exec_enabled = bool(_exec_cfg.get("enabled"))
|
exec_enabled = bool(_exec_cfg.get("enabled"))
|
||||||
exec_sleeves = set(_exec_cfg.get("sleeves", []))
|
exec_sleeves = set(_exec_cfg.get("sleeves", []))
|
||||||
exec_instr = _exec_cfg.get("instruments", {}) or {}
|
exec_instr = _exec_cfg.get("instruments", {}) or {}
|
||||||
|
pairs_exec_enabled = bool(_exec_cfg.get("pairs_enabled")) # esecuzione reale 2 gambe
|
||||||
executor = None
|
executor = None
|
||||||
|
pairs_executor = None
|
||||||
if exec_enabled:
|
if exec_enabled:
|
||||||
from src.live.execution import ExecutionClient
|
from src.live.execution import ExecutionClient
|
||||||
executor = ExecutionClient(client=client)
|
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
|
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)} "
|
print(f"[runner] ESECUZIONE REALE attiva (shadow) — sleeve={sorted(exec_sleeves)} "
|
||||||
f"strumenti={exec_instr} disaster_sl={executor.disaster_sl_pct}")
|
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):
|
def _exec_for(s):
|
||||||
"""(executor, exec_instrument) per uno sleeve, solo se fade single-leg abilitato."""
|
"""(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 None, None
|
||||||
return executor, exec_instr.get(s.asset)
|
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)
|
dr = sleeve_returns_df(live_ids)
|
||||||
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
|
weights = W.weight_vector(p.weighting, live_ids, dr, weights=p.weights,
|
||||||
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
|
caps=p.caps, clusters=clusters, lookback=p.vol_lookback)
|
||||||
@@ -267,9 +282,11 @@ def run(config_path: str = "portfolios.yml"):
|
|||||||
workers = {}
|
workers = {}
|
||||||
for s in live_specs:
|
for s in live_specs:
|
||||||
ex, inst = _exec_for(s)
|
ex, inst = _exec_for(s)
|
||||||
|
pex, pinst = _pairs_exec_for(s)
|
||||||
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
|
workers[s.sid] = build_worker_for(s, alloc[s.sid], p.leverage,
|
||||||
position_size=pos_for_spec(s.sid, position_size, ps_family),
|
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:
|
if ps_family:
|
||||||
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
|
print(f"[runner] position_size globale={position_size} override famiglia={ps_family}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user