feat(xsec): XS01 reversione cross-sectional (8 asset) -> PORT06 PAPER
Famiglia NUOVA trovata in sessione (dopo aver scartato trend/breakout/seasonal/ opzioni/funding come rumore): ogni 12h long i perdenti relativi / short i vincenti su 8 asset, market-neutral. Scorrelata (~0) da pairs e fade -> diversificatore. - engine canonico scripts/strategies/XS01_cross_sectional.py (no look-ahead, plateau OOS Sharpe 2-3.9, 5/5 anni+, edge concentrato 2025, cost-sensitive ~0.35% RT). - src/live/xsec_worker.py CrossSectionalWorker: validate_xsec_worker == backtest ESATTO (4993/1427 trade). Mirror della cadenza engine (entry-to-entry = hold+1). - gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.17pp, risk-contrib 2.2%). xsec_port06_gate.py. - wiring: _defs XSEC in PORT06 (19 sleeve, family XSEC), build_everything, runner kind=xsec, asset_days da supported (fix fetch alt anche per paper sleeves), paper. - 8 gambe -> niente exec reale -> gira PAPER. Regression-lock 18->19, FULL 7.20->7.34, OOS 9.66->10.07. 93 test verdi. Diario 2026-06-09-xs01-cross-sectional.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""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).
|
||||
"""
|
||||
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))
|
||||
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.capital = capital
|
||||
self.in_position = False
|
||||
self.weights = {a: 0.0 for a in self.universe}
|
||||
self.entry_px = {a: 0.0 for a in self.universe}
|
||||
self.bars_held = 0
|
||||
self.cooldown = 0 # 1 barra di stacco dopo l'uscita (come l'engine)
|
||||
self.total_trades = 0
|
||||
self.total_wins = 0
|
||||
self.last_bar_ts = 0
|
||||
self._load()
|
||||
|
||||
# ---------- 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})
|
||||
return
|
||||
s = json.loads(self.status_path.read_text())
|
||||
self.capital = s.get("capital", self.initial_capital)
|
||||
self.in_position = s.get("in_position", False)
|
||||
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
|
||||
self.entry_px = {**{a: 0.0 for a in self.universe}, **s.get("entry_px", {})}
|
||||
self.bars_held = s.get("bars_held", 0)
|
||||
self.cooldown = s.get("cooldown", 0)
|
||||
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)
|
||||
|
||||
def _save(self):
|
||||
self.status_path.write_text(json.dumps({
|
||||
"capital": round(self.capital, 2), "in_position": self.in_position,
|
||||
"weights": {a: round(v, 5) for a, v in self.weights.items()},
|
||||
"entry_px": self.entry_px, "bars_held": self.bars_held, "cooldown": self.cooldown,
|
||||
"total_trades": self.total_trades, "total_wins": self.total_wins,
|
||||
"last_bar_ts": 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, closes_now):
|
||||
"""Realizza il PnL del book corrente al prezzo attuale (log-return netto fee)."""
|
||||
book = 0.0
|
||||
for k, a in enumerate(self.universe):
|
||||
book += self.weights[a] * np.log(closes_now[k] / self.entry_px[a])
|
||||
net = book - 2 * self.fee_rt
|
||||
pnl = self.capital * self.position_size * self.leverage * net
|
||||
self.capital = max(self.capital + pnl, 10.0)
|
||||
self.total_trades += 1
|
||||
self.total_wins += net > 0
|
||||
acc = self.total_wins / self.total_trades * 100 if self.total_trades else 0
|
||||
self._log("CLOSE", {"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)})
|
||||
self.in_position = False
|
||||
self.weights = {a: 0.0 for a in self.universe}
|
||||
|
||||
def _open_book(self, M, i):
|
||||
cols = list(M.columns)
|
||||
logC = np.log(M.values)
|
||||
w = self._weights(logC[i], logC[i - self.lb])
|
||||
if w is None:
|
||||
return
|
||||
closes = M.iloc[i].values
|
||||
self.weights = {a: float(w[cols.index(a)]) for a in self.universe}
|
||||
self.entry_px = {a: float(closes[cols.index(a)]) for a in self.universe}
|
||||
self.bars_held = 0
|
||||
self.in_position = True
|
||||
self._log("OPEN", {"long": [a for a in self.universe if self.weights[a] > 0.05],
|
||||
"short": [a for a in self.universe if self.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
|
||||
|
||||
if self.in_position:
|
||||
if new_bar:
|
||||
self.bars_held += 1
|
||||
self.last_bar_ts = cur_ts
|
||||
# esce dopo HOLD barre; NON rientra nello stesso tick -> entry-to-entry = hold+1
|
||||
if self.bars_held >= self.hold:
|
||||
self._close_book(M.iloc[i].values)
|
||||
else:
|
||||
self._open_book(M, i) # entra al bar corrente (i = lb alla prima volta)
|
||||
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
|
||||
st = "BOOK" if self.in_position else ("COOL" if self.cooldown else "FLAT")
|
||||
return f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t {acc:.0f}% | {st}"
|
||||
Reference in New Issue
Block a user