82c05f6f81
7 finder paralleli sul diff della giornata (8adf388..HEAD), fix dei confermati: CRITICI (money-path): - close_amount: GUARD stato-stantio sul fallback netting — il residuo non-reduce-only e' consentito solo fino al gap (conto reale - libri degli altri worker - orfani) nella direzione della chiusura (src/live/books.py = fonte unica, usata anche dal reconciler). Senza guard, un close su stato stantio (DSL scattato in outage, flatten manuale) APRIVA una posizione nuda a taglia piena bookata come 'chiusura verificata'. Fail-safe se il gap non e' calcolabile. Check polvere PRIMA di _quantize_step (che clampa al lotto minimo: un residuo 1e-17 diventava un ordine nudo da un lotto). - _real_close: market_amt = filled_amount anche a merged verified=False (i contratti chiusi dal reduce-only non si buttano se il leg netting fallisce); REAL_CLOSE_PARTIAL non piu' gateato su verified (era soppresso proprio nel caso parziale reale). - pairs: verita' per-FRAZIONE di gamba (gross proporzionale al fillato, orfano = solo il residuo — prima falsava reconciler e real_capital della parte gia' chiusa); REAL_OPEN_PAIR booka filled_amount; docstring applied corretta. - open_pair unwind: chiude il FILLATO, non il richiesto (senza il cap silenzioso del reduce-only avrebbe mangiato quota altrui). - place_tp_limit: quantize CONSERVATIVO (sell=floor/buy=ceil) — il rounding al tick piu' vicino poteva mettere il resting oltre il TP sim -> tocco genuino classificato phantom sistematicamente. ROBUSTEZZA/OSSERVABILITA': - runner: WORKER_ERROR_STREAK a 5 e poi ogni 50 poll + recovery RIPRESO + flag real_in_position nell'alert (prima: one-shot a n==5, poi silenzio). - _tp_phantom: TTL 120s sul verdetto (era ~50 HTTP/h per worker a barra fantasma); merge notes con entrambi gli order_id (audit-trail). - reset_flatten: _quantize_step Decimal (round float produceva amount che Deribit rifiuta); hourly_report: book XS01 con gambe SHORT visibili (abs). - validate_xsec_worker: POS/LEV importati (non hardcoded); xs01_tranche: regression-lock ESEGUITO vs xsec_sim; reconcile su src/live/books; drift_monitor: rolling vettoriale + exit code 1 su warn. Test: +4 guard/dust, fixture filled_amount -> 114 passed. Deferiti (TODO): resting esposti al netting, lifecycle orphan_legs, finestra trade-history TP_PHANTOM, validazione feed a monte, dedup minori. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
427 lines
22 KiB
Python
427 lines
22 KiB
Python
"""PairsWorker — paper trading a 2 GAMBE per la famiglia PR01 (spread reversion).
|
|
|
|
Market-neutral: long asset A / short asset B (o viceversa) sullo z-score del log-ratio.
|
|
Distinto dallo StrategyWorker single-leg: gestisce due strumenti, due prezzi di
|
|
ingresso, e conta le fee su ENTRAMBE le gambe (2*fee_rt*lev = 0.20% RT/coppia con
|
|
fee_rt=0.001). Semantica identica al backtest scripts/analysis/pairs_research.pairs_sim:
|
|
|
|
r[i] = log(closeA[i]/closeB[i]); z[i] = (r[i]-SMA_n(r)[i]) / STD_n(r)[i] (causale)
|
|
ENTRY a close[i]: z<=-z_in -> LONG ratio (long A / short B); z>=+z_in -> SHORT ratio
|
|
EXIT: |z| <= z_exit (rientro) oppure time-limit max_bars
|
|
filtro candele sporche: salta l'ingresso se |dr[i]| > jump_max
|
|
PnL = (retA - retB) * direction * lev - 2*fee_rt*lev (notional uguale per gamba)
|
|
|
|
Stato persistente (resume al restart) e log come StrategyWorker.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from src.live.telegram_notifier import notify_event
|
|
|
|
|
|
class PairsWorker:
|
|
def __init__(
|
|
self,
|
|
asset_a: str,
|
|
asset_b: str,
|
|
tf: str,
|
|
params: dict | None = None,
|
|
capital: float = 1000.0,
|
|
position_size: float = 0.15,
|
|
leverage: float = 3.0,
|
|
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}
|
|
real_truth: bool = False,
|
|
):
|
|
self.asset_a = asset_a
|
|
self.asset_b = asset_b
|
|
self.tf = tf
|
|
self.name = name
|
|
p = params or {}
|
|
self.n = int(p.get("n", 50))
|
|
self.z_in = float(p.get("z_in", 2.0))
|
|
self.z_exit = float(p.get("z_exit", 0.75))
|
|
self.max_bars = int(p.get("max_bars", 72))
|
|
self.jump_max = float(p.get("jump_max", 0.08))
|
|
# flat-skip (timeframe sub-orari, es. 15m): non entrare/uscire su candele flat
|
|
# (O=H=L=C, prezzo stale/liquidita' zero -> fill non eseguibile). LIVE-REALIZABLE:
|
|
# l'uscita arma exit_ready e si esegue alla prima barra PULITA. Parita' col backtest
|
|
# pairs_research.pairs_sim_flat(flat_skip=True). Default off = comportamento 1h storico.
|
|
self.flat_skip = bool(p.get("flat_skip", False))
|
|
|
|
self.initial_capital = capital
|
|
self.position_size = position_size
|
|
self.leverage = leverage
|
|
self.fee_rt = fee_rt
|
|
|
|
self.worker_id = f"{name}__{asset_a}_{asset_b}__{tf}"
|
|
self.work_dir = data_dir / self.worker_id
|
|
self.work_dir.mkdir(parents=True, exist_ok=True)
|
|
self.trades_path = self.work_dir / "trades.jsonl"
|
|
self.status_path = self.work_dir / "status.json"
|
|
|
|
self.capital = capital
|
|
self.in_position = False
|
|
self.direction = 0 # +1 long ratio (long A/short B), -1 short ratio
|
|
self.entry_a = 0.0
|
|
self.entry_b = 0.0
|
|
self.entry_z = 0.0
|
|
self.entry_time = ""
|
|
self.bars_held = 0
|
|
self.exit_ready = False # flat-skip: condizione di uscita armata, attende barra pulita
|
|
self.total_trades = 0
|
|
self.total_wins = 0
|
|
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)
|
|
# REAL-TRUTH (2026-06-10): come StrategyWorker — `capital` aggiornato dal
|
|
# PnL dei fill reali (2 gambe, fee reali); sim solo diagnostica nel log.
|
|
self.real_truth = bool(real_truth and self.execution_enabled)
|
|
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.orphan_legs: list[dict] = [] # gambe respinte dal netting (persistite)
|
|
|
|
self._load_state()
|
|
self._save_state()
|
|
|
|
# ---------------- persistenza ----------------
|
|
def _load_state(self):
|
|
if not self.status_path.exists():
|
|
self._log("INIT", {"capital": self.capital, "pair": f"{self.asset_a}/{self.asset_b}",
|
|
"tf": self.tf, "params": {"n": self.n, "z_in": self.z_in,
|
|
"z_exit": self.z_exit, "max_bars": self.max_bars}})
|
|
return
|
|
with open(self.status_path) as f:
|
|
s = json.load(f)
|
|
self.capital = s.get("capital", self.initial_capital)
|
|
self.in_position = s.get("in_position", False)
|
|
self.direction = s.get("direction", 0)
|
|
self.entry_a = s.get("entry_a", 0.0)
|
|
self.entry_b = s.get("entry_b", 0.0)
|
|
self.entry_z = s.get("entry_z", 0.0)
|
|
self.entry_time = s.get("entry_time", "")
|
|
self.bars_held = s.get("bars_held", 0)
|
|
self.exit_ready = s.get("exit_ready", False)
|
|
self.total_trades = s.get("total_trades", 0)
|
|
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.orphan_legs = s.get("orphan_legs", [])
|
|
self._log("RESUME", {"capital": round(self.capital, 2),
|
|
"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 = {
|
|
"capital": round(self.capital, 2), "in_position": self.in_position,
|
|
"direction": self.direction, "entry_a": self.entry_a, "entry_b": self.entry_b,
|
|
"entry_z": round(self.entry_z, 4), "entry_time": self.entry_time,
|
|
"bars_held": self.bars_held, "exit_ready": self.exit_ready,
|
|
"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,
|
|
"orphan_legs": self.orphan_legs,
|
|
}
|
|
with open(self.status_path, "w") as f:
|
|
json.dump(state, f, indent=2)
|
|
|
|
def _log(self, event: str, data: dict | None = None):
|
|
entry = {"ts": datetime.now(timezone.utc).isoformat(), "worker": self.worker_id,
|
|
"event": event, **(data or {})}
|
|
with open(self.trades_path, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
|
|
|
|
def _notify(self, event: str, data: dict | None = None):
|
|
notify_event(event, {"worker": self.worker_id, **(data or {})})
|
|
|
|
# ---------------- segnale ----------------
|
|
def _zscore(self, ca: np.ndarray, cb: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
r = np.log(ca / cb)
|
|
ma = pd.Series(r).rolling(self.n).mean().values
|
|
sd = pd.Series(r).rolling(self.n).std().values
|
|
z = (r - ma) / np.where(sd == 0, np.nan, sd)
|
|
dr = np.abs(np.diff(r, prepend=r[0]))
|
|
return z, dr
|
|
|
|
# ---------------- trading ----------------
|
|
def _open(self, d: int, ca: float, cb: float, z: float):
|
|
self.in_position = True
|
|
self.direction = d
|
|
self.entry_a, self.entry_b, self.entry_z = ca, cb, z
|
|
self.entry_time = datetime.now(timezone.utc).isoformat()
|
|
self.bars_held = 0
|
|
self.exit_ready = False
|
|
data = {"direction": "long_ratio" if d == 1 else "short_ratio",
|
|
"long_leg": self.asset_a if d == 1 else self.asset_b,
|
|
"short_leg": self.asset_b if d == 1 else self.asset_a,
|
|
"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
|
|
# amount FILLATO, non richiesto (coerente con strategy_worker, 2026-06-11)
|
|
self.real_amount_a = pf.leg_a.filled_amount or pf.leg_a.amount
|
|
self.real_amount_b = pf.leg_b.filled_amount or 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) -> tuple[float | None, bool]:
|
|
"""Chiusura REALE shadow: richiude entrambe le gambe (netting-aware),
|
|
riconcilia PnL reale per-gamba e fee, aggiorna il ledger reale parallelo.
|
|
|
|
Ritorna (real_pnl, applied): applied=True SOLO se ENTRAMBE le gambe hanno
|
|
chiuso per intero con fill verificato — con una gamba orfana il "PnL dello
|
|
spread" non esiste e real-truth ricade sul sim DICHIARATO."""
|
|
if not self.real_in_position:
|
|
return None, False
|
|
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)
|
|
# VERITA' PER-GAMBA (audit 2026-06-11): una gamba puo' essere RESPINTA dal
|
|
# netting di conto (reduce-only nel verso sbagliato quando un altro worker e'
|
|
# nella direzione opposta sullo stesso strumento). Prima il PnL veniva
|
|
# calcolato col prezzo SIM per la gamba mai eseguita e sommato al ledger
|
|
# reale (3 PnL fantasma il 2026-06-11, gamba ETH orfana sul conto).
|
|
# Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba
|
|
# respinta diventa un ORFANO registrato (persistito) + alert Telegram.
|
|
from src.live.execution import contract_spec
|
|
for leg in (pf.leg_a, pf.leg_b):
|
|
if "netting" in (getattr(leg, "notes", "") or ""):
|
|
# reduce-only cappato/respinto, residuo in market puro (v1.1.25)
|
|
self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
|
|
self._notify("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
|
|
# verita' per-FRAZIONE di gamba (code-review 2026-06-11): una gamba puo'
|
|
# chiudere PARZIALMENTE (reduce-only cappato + netting negato/fallito) —
|
|
# si booka il gross della sola frazione FILLATA e l'orfano registra il
|
|
# solo RESIDUO (prima: gross binario tutto-o-niente e orfano a amount
|
|
# pieno, che falsava reconciler e real_capital della parte gia' chiusa).
|
|
filled_a = min(getattr(pf.leg_a, "filled_amount", 0.0), self.real_amount_a)
|
|
filled_b = min(getattr(pf.leg_b, "filled_amount", 0.0), self.real_amount_b)
|
|
step_a = contract_spec(self.inst_a).get("step", 0.001)
|
|
step_b = contract_spec(self.inst_b).get("step", 0.001)
|
|
ok_a = filled_a >= self.real_amount_a - step_a / 2
|
|
ok_b = filled_b >= self.real_amount_b - step_b / 2
|
|
frac_a = filled_a / self.real_amount_a if self.real_amount_a else 0.0
|
|
frac_b = filled_b / self.real_amount_b if self.real_amount_b else 0.0
|
|
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_a = da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a
|
|
gross_b = 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_a * frac_a + gross_b * frac_b
|
|
- 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,
|
|
"leg_a_ok": ok_a, "leg_b_ok": ok_b,
|
|
"filled_a": filled_a, "filled_b": filled_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})
|
|
for ok, inst, side, amt, filled, step in (
|
|
(ok_a, self.inst_a, self.real_side_a, self.real_amount_a, filled_a, step_a),
|
|
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b, filled_b, step_b)):
|
|
residue = amt - filled
|
|
if not ok and residue >= step / 2:
|
|
orphan = {"instrument": inst, "entry_side": side,
|
|
"amount": round(residue, 8),
|
|
"ts": datetime.now(timezone.utc).isoformat(), "reason": reason}
|
|
self.orphan_legs.append(orphan)
|
|
self._notify("PAIR_LEG_ORPHAN", {
|
|
"worker": self.worker_id, **orphan,
|
|
"note": ("gamba NON chiusa per il residuo indicato (netting "
|
|
"negato/fallito): posizione orfana sul conto — "
|
|
"risolvere e RIMUOVERE l'orfano dallo status")})
|
|
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()
|
|
# applied (real-truth) SOLO se entrambe le gambe hanno chiuso verificate:
|
|
# con una gamba orfana il "PnL reale dello spread" non esiste -> meglio il
|
|
# fallback sim DICHIARATO che un numero mezzo-reale
|
|
return real_pnl, ok_a and ok_b
|
|
|
|
def _close(self, ca: float, cb: float, z: float, reason: str):
|
|
if not self.in_position:
|
|
return
|
|
ret_a = (ca - self.entry_a) / self.entry_a
|
|
ret_b = (cb - self.entry_b) / self.entry_b
|
|
gross = (ret_a - ret_b) * self.direction * self.leverage
|
|
fee = 2 * self.fee_rt * self.leverage # 2 gambe
|
|
net = gross - fee
|
|
sim_pnl = self.capital * self.position_size * net
|
|
|
|
# REAL-TRUTH: chiusura reale PRIMA dell'update ledger (come StrategyWorker)
|
|
real_pnl, real_applied = (None, False)
|
|
if self.execution_enabled:
|
|
real_pnl, real_applied = self._real_close_pair(ca, cb, reason, sim_pnl)
|
|
use_real = self.real_truth and real_applied
|
|
pnl = real_pnl if use_real else sim_pnl
|
|
|
|
self.capital = max(self.capital + pnl, 0.0)
|
|
is_win = pnl > 0
|
|
self.total_trades += 1
|
|
self.total_wins += is_win
|
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
|
data = {"reason": reason, "exit_a": round(ca, 4), "exit_b": round(cb, 4),
|
|
"z": round(z, 3), "gross_ret": round(gross * 100, 3), "fee": round(fee * 100, 3),
|
|
"net_return": round(net * 100, 3), "pnl": round(pnl, 2),
|
|
"capital": round(self.capital, 2), "bars_held": self.bars_held,
|
|
"win": bool(is_win), "total_trades": self.total_trades, "accuracy": round(acc, 1)}
|
|
if self.real_truth:
|
|
data["pnl_source"] = "real" if use_real else "sim_fallback"
|
|
data["sim_pnl"] = round(sim_pnl, 2)
|
|
if real_pnl is not None:
|
|
data["real_pnl"] = round(real_pnl, 4)
|
|
self._log("CLOSE", data); self._notify("CLOSED", data)
|
|
self.in_position = False
|
|
self.direction = 0
|
|
self.entry_a = self.entry_b = self.entry_z = 0.0
|
|
self.bars_held = 0
|
|
|
|
def tick(self, df_a: pd.DataFrame, df_b: pd.DataFrame):
|
|
"""Chiamato ad ogni poll con gli OHLCV aggiornati delle due gambe."""
|
|
if df_a is None or df_b is None or df_a.empty or df_b.empty:
|
|
return
|
|
# merge OHLC quando disponibile (serve a rilevare le candele flat per il flat-skip);
|
|
# se le colonne OHLC mancano, flat resta False -> comportamento close-only invariato.
|
|
ohlc = ["open", "high", "low", "close"]
|
|
keep_a = ["timestamp"] + [c for c in ohlc if c in df_a.columns]
|
|
keep_b = ["timestamp"] + [c for c in ohlc if c in df_b.columns]
|
|
m = df_a[keep_a].merge(df_b[keep_b], on="timestamp", how="inner",
|
|
suffixes=("_a", "_b")).sort_values("timestamp").reset_index(drop=True)
|
|
# Scarta la barra IN FORMAZIONE: entry ED exit valutati SOLO sul close di
|
|
# barra COMPLETA, come il backtest (pairs_research: close settled) —
|
|
# lezione EXIT-16. Detection condivisa: src.live.bars.
|
|
from src.live.bars import last_bar_is_forming
|
|
if last_bar_is_forming(m["timestamp"].values):
|
|
m = m.iloc[:-1]
|
|
if len(m) < self.n + 2:
|
|
return
|
|
ca, cb = m["close_a"].values, m["close_b"].values
|
|
z, dr = self._zscore(ca, cb)
|
|
i = len(m) - 1
|
|
cur_ts = int(m["timestamp"].iloc[i])
|
|
zi = z[i]
|
|
if np.isnan(zi):
|
|
self._save_state(); return
|
|
|
|
# flat della barra corrente (entrambe le gambe): O=H=L=C in una delle due
|
|
flat_i = False
|
|
if self.flat_skip and {"open_a", "high_a", "low_a"}.issubset(m.columns) \
|
|
and {"open_b", "high_b", "low_b"}.issubset(m.columns):
|
|
fa = (m["open_a"].iloc[i] == m["high_a"].iloc[i] == m["low_a"].iloc[i] == ca[i])
|
|
fb = (m["open_b"].iloc[i] == m["high_b"].iloc[i] == m["low_b"].iloc[i] == cb[i])
|
|
flat_i = bool(fa or fb)
|
|
|
|
if self.in_position:
|
|
if cur_ts > self.last_bar_ts:
|
|
self.bars_held += 1
|
|
self.last_bar_ts = cur_ts
|
|
# arma l'uscita: |z|<=z_exit (rientro) o time-limit; poi esegui alla 1a barra pulita
|
|
if not self.exit_ready and (abs(zi) <= self.z_exit or self.bars_held >= self.max_bars):
|
|
self.exit_ready = True
|
|
if self.exit_ready and not flat_i:
|
|
reason = "mean_revert" if abs(zi) <= self.z_exit else "time_limit"
|
|
self._close(float(ca[i]), float(cb[i]), float(zi), reason)
|
|
self._save_state()
|
|
return
|
|
|
|
# cerca ingresso (no look-ahead: z[i] usa solo dati <= i); mai su barra stale
|
|
if dr[i] <= self.jump_max and not flat_i:
|
|
if zi <= -self.z_in:
|
|
self._open(1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
|
elif zi >= self.z_in:
|
|
self._open(-1, float(ca[i]), float(cb[i]), float(zi)); self.last_bar_ts = cur_ts
|
|
self._save_state()
|
|
|
|
@property
|
|
def status_summary(self) -> str:
|
|
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
|
pos = ("LONG " + self.asset_a if self.direction == 1
|
|
else "SHORT " + self.asset_a if self.direction == -1 else "FLAT")
|
|
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {pos}")
|