"""CrossSectionalWorker — paper/live worker per XS01 (reversione cross-sectional, 8 asset). Mirror ESATTO di scripts.strategies.XS01_cross_sectional.xsec_sim: ogni HOLD barre classifica gli asset per rendimento su LB barre, pesi w = -(ret - media)/gross (market- neutral gross 1), entra al close, esce dopo HOLD barre, riallinea (1 barra di stacco fra uscita e nuovo ingresso, come l'engine). PnL su book log-return netto fee 0.10% RT. Stato persistente (resume). Solo SIM (esecuzione reale a 8 gambe non implementata). PHASE-TRANCHING (2026-06-11, gate xs01_tranche_gate.py): param `tranches`=K divide il book in K sub-book sfasati di hold/K barre, capitale comune (PnL/K per tranche). La fase del roll non-sovrapposto e' arbitraria e da sola muove Sharpe FULL daily 1.52-2.33 e DD 13.8-33.1% (timing-luck): l'ensemble di fase la elimina SENZA parametri fittati (plateau K=2 e K=3 entrambi promossi; PORT06 OOS Sh 10.07->10.15, DD 1.48->1.38). Solo path live, come disp_min: il backtest canonico resta single-phase. K=1 = comportamento storico. """ 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 CrossSectionalWorker: def __init__(self, universe, tf="1h", params=None, capital=1000.0, position_size=0.15, leverage=3.0, fee_rt=0.0005, name="XS01", data_dir=Path("data/portfolio_paper")): self.universe = list(universe) p = params or {} self.lb = int(p.get("lb", 48)) self.hold = int(p.get("hold", 12)) # dispersion-gate (2026-06-10): entra solo se la std cross-section del # momentum lb supera disp_min — senza dispersione da far rientrare i # trade sono fee. None = off (parita' col backtest canonico non filtrato). self.disp_min = p.get("disp_min") self.tf = tf self.initial_capital = capital self.position_size = position_size self.leverage = leverage self.fee_rt = fee_rt self.worker_id = f"{name}__{tf}" self.work_dir = Path(data_dir) / self.worker_id self.work_dir.mkdir(parents=True, exist_ok=True) self.status_path = self.work_dir / "status.json" self.trades_path = self.work_dir / "trades.jsonl" self.k = max(1, int(p.get("tranches", 1))) self._step = max(1, round(self.hold / self.k)) # sfasamento iniziale fra tranche self.capital = capital self.books = [self._flat_book(j * self._step) for j in range(self.k)] self.total_trades = 0 self.total_wins = 0 self.last_bar_ts = 0 self._load() def _flat_book(self, wait: int = 0): return {"weights": {a: 0.0 for a in self.universe}, "entry_px": {a: 0.0 for a in self.universe}, "bars_held": 0, "in_position": False, "wait": int(wait)} @property def in_position(self) -> bool: return any(b["in_position"] for b in self.books) # ---------- persistenza ---------- def _load(self): if not self.status_path.exists(): self._log("INIT", {"capital": self.capital, "universe": self.universe, "lb": self.lb, "hold": self.hold, "tranches": self.k}) return s = json.loads(self.status_path.read_text()) self.capital = s.get("capital", self.initial_capital) 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) if "books" in s: for j, bs in enumerate(s["books"][: self.k]): b = self.books[j] b["weights"] = {**{a: 0.0 for a in self.universe}, **bs.get("weights", {})} b["entry_px"] = {**{a: 0.0 for a in self.universe}, **bs.get("entry_px", {})} b["bars_held"] = int(bs.get("bars_held", 0)) b["in_position"] = bool(bs.get("in_position", False)) b["wait"] = int(bs.get("wait", 0)) elif s.get("in_position") or s.get("weights"): # migrazione dallo schema legacy single-book: il vecchio book diventa la # tranche 0; le altre partono flat col loro sfasamento (gia' in __init__) b = self.books[0] b["weights"] = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})} b["entry_px"] = {**{a: 0.0 for a in self.universe}, **s.get("entry_px", {})} b["bars_held"] = int(s.get("bars_held", 0)) b["in_position"] = bool(s.get("in_position", False)) b["wait"] = 0 def _save(self): self.status_path.write_text(json.dumps({ "capital": round(float(self.capital), 2), "in_position": bool(self.in_position), "tranches": int(self.k), "books": [{"weights": {a: round(float(v), 5) for a, v in b["weights"].items()}, "entry_px": {a: float(v) for a, v in b["entry_px"].items()}, "bars_held": int(b["bars_held"]), "in_position": bool(b["in_position"]), "wait": int(b["wait"])} for b in self.books], "total_trades": int(self.total_trades), "total_wins": int(self.total_wins), "last_bar_ts": int(self.last_bar_ts), "last_update": datetime.now(timezone.utc).isoformat(), }, indent=2)) def _log(self, event, data=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, default=str) + "\n") print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)[:160]}") def _notify(self, event, data=None): notify_event(event, {"worker": self.worker_id, **(data or {})}) # ---------- pannello allineato ---------- def _panel(self, data: dict): frames = [] for a in self.universe: df = data.get(a) if df is None or df.empty: return None frames.append(df[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp")) M = pd.concat(frames, axis=1, join="inner").sort_index() # scarta la barra IN FORMAZIONE (close non settled) — come gli altri worker from src.live.bars import last_bar_is_forming ts = M.index.to_numpy() if len(ts) and last_bar_is_forming(ts): M = M.iloc[:-1] return M # ---------- weights (identici all'engine) ---------- def _weights(self, logC_row, logC_lb_row): dm = logC_row - logC_lb_row dm = dm - dm.mean() w = -dm gw = np.sum(np.abs(w)) return w / gw if gw > 1e-9 else None def _close_book(self, b, closes_now, tranche: int): """Realizza il PnL del book della tranche al prezzo attuale (log-return netto fee). Capitale comune: il notional della tranche e' 1/K del book virtuale.""" book = 0.0 for k, a in enumerate(self.universe): book += b["weights"][a] * np.log(closes_now[k] / b["entry_px"][a]) # cast a tipi Python: i numpy (float64/int64/bool_) rompono json.dumps in _save net = float(book - 2 * self.fee_rt) pnl = float(self.capital * self.position_size * self.leverage * net / self.k) self.capital = max(self.capital + pnl, 10.0) self.total_trades += 1 self.total_wins += 1 if net > 0 else 0 acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0 self._log("CLOSE", {"tranche": tranche, "book_ret": round(book * 100, 3), "net": round(net * 100, 3), "pnl": round(pnl, 2), "capital": round(self.capital, 2), "trades": self.total_trades, "acc": round(acc, 1)}) b["in_position"] = False b["weights"] = {a: 0.0 for a in self.universe} def _open_book(self, M, i, b, tranche: int): cols = list(M.columns) logC = np.log(M.values) if self.disp_min is not None: disp = float(np.nanstd(logC[i] - logC[i - self.lb])) if disp < float(self.disp_min): return # regime senza dispersione: skip entry w = self._weights(logC[i], logC[i - self.lb]) if w is None: return closes = M.iloc[i].values b["weights"] = {a: float(w[cols.index(a)]) for a in self.universe} b["entry_px"] = {a: float(closes[cols.index(a)]) for a in self.universe} b["bars_held"] = 0 b["in_position"] = True self._log("OPEN", {"tranche": tranche, "long": [a for a in self.universe if b["weights"][a] > 0.05], "short": [a for a in self.universe if b["weights"][a] < -0.05], "capital": round(self.capital, 2)}) # ---------- tick ---------- def tick(self, data: dict): M = self._panel(data) if M is None or len(M) < self.lb + 1: # serve close[i] e close[i-lb] -> lb+1 barre return i = len(M) - 1 cur_ts = int(M.index[i]) new_bar = cur_ts > self.last_bar_ts for j, b in enumerate(self.books): if b["in_position"]: if new_bar: b["bars_held"] += 1 # esce dopo HOLD barre; NON rientra nello stesso tick -> entry-to-entry = hold+1 if b["bars_held"] >= self.hold: self._close_book(b, M.iloc[i].values, j) elif b["wait"] > 0: if new_bar: b["wait"] -= 1 # sfasamento iniziale della tranche else: self._open_book(M, i, b, j) # entra al bar corrente (i = lb alla prima volta) # solo avanti: se il panel si accorcia per un feed in ritardo (inner join), # non si regredisce — una barra gia' contata non va ricontata self.last_bar_ts = max(self.last_bar_ts, cur_ts) self._save() @property def status_summary(self) -> str: acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0 nb = sum(1 for b in self.books if b["in_position"]) st = f"BOOK {nb}/{self.k}" if nb else "FLAT" return f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {st}"