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) <noreply@anthropic.com>
This commit is contained in:
+11
-1
@@ -2,9 +2,19 @@
|
|||||||
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
|
# (definito in scripts/portfolios/_defs.py) e ne fa l'override dei parametri operativi.
|
||||||
active: PORT06 # default raccomandato: master + shape
|
active: PORT06 # default raccomandato: master + shape
|
||||||
overrides:
|
overrides:
|
||||||
total_capital: 1000
|
total_capital: 2000
|
||||||
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
|
weighting: cap # equal | cap | inverse_vol | cluster_rp | manual
|
||||||
caps: {PAIRS: 0.33}
|
caps: {PAIRS: 0.33}
|
||||||
leverage: 2 # sobrio per il live reale
|
leverage: 2 # sobrio per il live reale
|
||||||
rebalance: 1D
|
rebalance: 1D
|
||||||
poll_seconds: 60
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -89,9 +89,17 @@ class CerberoClient:
|
|||||||
amount: float,
|
amount: float,
|
||||||
order_type: str = "market",
|
order_type: str = "market",
|
||||||
price: float | None = None,
|
price: float | None = None,
|
||||||
leverage: int | None = 3,
|
leverage: int | None = None,
|
||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
|
reduce_only: bool = False,
|
||||||
) -> dict:
|
) -> 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] = {
|
payload: dict[str, Any] = {
|
||||||
"instrument_name": instrument,
|
"instrument_name": instrument,
|
||||||
"side": side,
|
"side": side,
|
||||||
@@ -104,11 +112,22 @@ class CerberoClient:
|
|||||||
payload["leverage"] = leverage
|
payload["leverage"] = leverage
|
||||||
if label:
|
if label:
|
||||||
payload["label"] = label
|
payload["label"] = label
|
||||||
|
if reduce_only:
|
||||||
|
payload["reduce_only"] = True
|
||||||
return self._post("/mcp-deribit/tools/place_order", payload)
|
return self._post("/mcp-deribit/tools/place_order", payload)
|
||||||
|
|
||||||
def close_position(self, instrument: str) -> dict:
|
def close_position(self, instrument: str) -> dict:
|
||||||
return self._post("/mcp-deribit/tools/close_position", {"instrument_name": instrument})
|
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:
|
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})
|
return self._post("/mcp-deribit/tools/set_stop_loss", {"order_id": order_id, "stop_price": stop_price})
|
||||||
|
|
||||||
|
|||||||
@@ -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})")
|
||||||
+120
-1
@@ -10,6 +10,7 @@ import pandas as pd
|
|||||||
|
|
||||||
from src.strategies.base import Strategy, Signal
|
from src.strategies.base import Strategy, Signal
|
||||||
from src.live.telegram_notifier import notify_event
|
from src.live.telegram_notifier import notify_event
|
||||||
|
from src.live.execution import ExecutionClient
|
||||||
|
|
||||||
FEE_RT = 0.002
|
FEE_RT = 0.002
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ class StrategyWorker:
|
|||||||
hold_bars: int = 3,
|
hold_bars: int = 3,
|
||||||
params: dict | None = None,
|
params: dict | None = None,
|
||||||
data_dir: Path = Path("data/paper_trades"),
|
data_dir: Path = Path("data/paper_trades"),
|
||||||
|
executor: ExecutionClient | None = None,
|
||||||
|
exec_instrument: str | None = None,
|
||||||
):
|
):
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
self.asset = asset
|
self.asset = asset
|
||||||
@@ -38,6 +41,21 @@ class StrategyWorker:
|
|||||||
self.hold_bars = hold_bars
|
self.hold_bars = hold_bars
|
||||||
self.params = params or {}
|
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.worker_id = f"{strategy.name}__{asset}__{tf}"
|
||||||
self.work_dir = data_dir / self.worker_id
|
self.work_dir = data_dir / self.worker_id
|
||||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -89,9 +107,21 @@ class StrategyWorker:
|
|||||||
self.sl = state.get("sl", 0.0)
|
self.sl = state.get("sl", 0.0)
|
||||||
self.max_bars = state.get("max_bars", 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),
|
self._log("RESUME", {"capital": round(self.capital, 2),
|
||||||
"total_trades": self.total_trades,
|
"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):
|
def _save_state(self):
|
||||||
state = {
|
state = {
|
||||||
@@ -108,6 +138,15 @@ class StrategyWorker:
|
|||||||
"tp": self.tp,
|
"tp": self.tp,
|
||||||
"sl": self.sl,
|
"sl": self.sl,
|
||||||
"max_bars": self.max_bars,
|
"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(),
|
"last_update": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
with open(self.status_path, "w") as f:
|
with open(self.status_path, "w") as f:
|
||||||
@@ -155,6 +194,83 @@ class StrategyWorker:
|
|||||||
self._log("OPEN", trade_data)
|
self._log("OPEN", trade_data)
|
||||||
self._notify("OPENED", 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):
|
def _close_position(self, current_price: float, reason: str):
|
||||||
if not self.in_position:
|
if not self.in_position:
|
||||||
return
|
return
|
||||||
@@ -189,6 +305,9 @@ class StrategyWorker:
|
|||||||
self._log("CLOSE", trade_data)
|
self._log("CLOSE", trade_data)
|
||||||
self._notify("CLOSED", trade_data)
|
self._notify("CLOSED", trade_data)
|
||||||
|
|
||||||
|
if self.execution_enabled:
|
||||||
|
self._real_close(current_price, reason, pnl)
|
||||||
|
|
||||||
self.in_position = False
|
self.in_position = False
|
||||||
self.direction = 0
|
self.direction = 0
|
||||||
self.entry_price = 0
|
self.entry_price = 0
|
||||||
|
|||||||
+31
-3
@@ -41,8 +41,12 @@ _ML_LOOKBACK_DAYS = 365
|
|||||||
|
|
||||||
|
|
||||||
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,
|
||||||
"""Costruisce il worker esecutore per uno sleeve con capitale = quota allocata."""
|
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":
|
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,
|
||||||
@@ -81,6 +85,7 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
|
|||||||
return StrategyWorker(
|
return StrategyWorker(
|
||||||
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
|
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
|
||||||
position_size=position_size, leverage=leverage, params=spec.params, data_dir=data_dir,
|
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)
|
ledger = PortfolioLedger(p.code, total_capital=p.total_capital)
|
||||||
client = CerberoClient()
|
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)
|
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)
|
||||||
alloc = ledger.allocate(weights)
|
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
|
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
|
||||||
asset_days: dict[str, int] = {}
|
asset_days: dict[str, int] = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user