14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
217 lines
10 KiB
Python
217 lines
10 KiB
Python
"""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}"
|