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:
Adriano Dal Pastro
2026-06-09 21:38:05 +00:00
parent d3dab57532
commit a85289d7c7
12 changed files with 508 additions and 13 deletions
@@ -0,0 +1,49 @@
# 2026-06-09 — XS01: reversione cross-sectional (famiglia nuova, trovata + deployata PAPER)
## Origine
Dopo aver scartato (alla cieca, coi giochi) trend/breakout/seasonal/opzioni/funding come
rumore o EV, ho cercato io un meccanismo *diverso* dalla mean-reversion pairwise. Trovato:
**XS01 — reversione CROSS-SECTIONAL** su 8 asset (BTC/ETH/LTC/ADA/SOL/BNB/XRP/DOGE).
## Meccanismo
Ogni HOLD=12 ore: classifica gli 8 asset per rendimento su LB=48 ore, pesi
w = (ret media_cross-section), normalizzati a gross 1 → **long i perdenti relativi /
short i vincenti**, market-neutral. Roll non sovrapposto (entry-to-entry = hold+1 barre).
Fee 0.10% RT/book. Cattura il FATTORE reversione trasversale, distinto dai pairs (pairwise).
## Verifica (engine canonico `scripts/strategies/XS01_cross_sectional.py`)
- **No look-ahead** verificato (segnale invariato perturbando il futuro).
- **Robusto**: plateau OOS Sharpe **23.9** su lb 1272 × hold 624.
- **Scorrelato**: corr **0.006 / 0.035** da PR01 ETH/BTC, 0.028 dai fade → diversificatore.
- Per-anno (entry): 2022 +34, 2023 +6, 2024 +21, **2025 +225**, 2026 +85 (5/5 anni+).
- **Caveat**: edge concentrato sul 2025; cost-sensitive (muore ~0.35% RT/book); 8 gambe;
storia dal 2022 (no 2018-2020).
## Worker validato (== backtest esatto)
`src/live/xsec_worker.py` `CrossSectionalWorker`: book market-neutral che rolla ogni HOLD
barre, stessa formula pesi e cadenza dell'engine. `validate_xsec_worker.py`: replay
bar-per-bar == backtest **ESATTO** (worker 4993/1427 trade/49.8% == backtest 4993/1427/49.8%).
Bug risolto: il primo prototipo rollava 1 barra troppo tardi (cooldown extra) → rimosso,
guard a lb+1, entry-to-entry = hold+1.
## Gate PORT06 — PROMOSSO (con asterisco)
| | corr | FULL Sh | FULL DD | OOS Sh | OOS DD |
|---|---|---|---|---|---|
| ATTUALE (19→ senza XS01) | — | 7.20 | 3.68 | 9.66 | 1.31 |
| **+XS01** | 0.006 | **7.34** | **3.46** | **10.07** | 1.48 |
Migliora 3 metriche su 4 (OOS Sharpe **+0.41**, il salto più grande dal 15m; FULL DD giù).
Unico neo: OOS DD +0.17pp. Risk-contrib XS01 solo **2.2%** (diversificatore a bassa vol).
## Deploy (v?, 2026-06-09) — PAPER
8 gambe → niente esecuzione reale (come TR01/ROT02/TSM01) → XS01 gira **PAPER**
(`paper_sleeves`), fuori dal pool, raccoglie statistica forward. Wiring: `_defs.XSEC` in
PORT06 (19 sleeve, family XSEC via prefix "XS"), `build_everything` (equity da xsec_sim),
`runner` kind="xsec" → CrossSectionalWorker, `asset_days` ora include i paper (fix: gli alt
BNB/DOGE/XRP ora vengono fetchati anche per TR01/ROT02/TSM01). Regression-lock aggiornati
(18→19 sleeve, FULL 7.20→7.34, OOS 9.66→10.07, DD 3.68→3.46). 93 test verdi.
**Direzione futura:** se la statistica forward conferma, costruire l'esecuzione reale a
N gambe (oggi inesistente) per portarlo nel pool. Per ora: candidato validato che gira
PAPER e si osserva. Artefatti: `scripts/strategies/XS01_cross_sectional.py`,
`src/live/xsec_worker.py`, `scripts/analysis/{validate_xsec_worker,xsec_port06_gate}.py`.
+1 -1
View File
@@ -17,7 +17,7 @@ overrides:
# fisso, SOLO per statistica in vista di future implementazioni reali. NB: il portafoglio # fisso, SOLO per statistica in vista di future implementazioni reali. NB: il portafoglio
# live diverge ora dal PORT06 canonico (17 sleeve) -> DD reale ~5.35% vs 3.96% validato: # live diverge ora dal PORT06 canonico (17 sleeve) -> DD reale ~5.35% vs 3.96% validato:
# il prezzo di vedere il risultato reale puro (scelta utente). # il prezzo di vedere il risultato reale puro (scelta utente).
paper_sleeves: [TR01, ROT02, TSM01] paper_sleeves: [TR01, ROT02, TSM01, XS01]
# Frazione di capitale-sleeve per posizione (canonico backtest = 0.15). # Frazione di capitale-sleeve per posizione (canonico backtest = 0.15).
# 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego # 0.5 con leva 2x = 100% della fetta impegnata quando in posizione (max impiego
# dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato). # dei 2K senza debito di margine). NB: il DD scala ~lineare (~×3.3 vs validato).
+5
View File
@@ -59,6 +59,11 @@ def build_everything():
t = tsmom_sim() t = tsmom_sim()
tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])} tsm = {"TSM01": daily_from(t["eq_ts"], t["eq_v"])}
shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")} shape = {f"SH_{a}": _norm(shape_daily_equity(a, IDX)) for a in ("BTC", "ETH")}
# XS01 — reversione cross-sectional (8 asset, market-neutral). Engine canonico
# scripts.strategies.XS01_cross_sectional (worker validato == backtest).
from scripts.strategies.XS01_cross_sectional import xsec_sim
x = xsec_sim()
tsm["XS01"] = daily_from(x["eq_ts"], x["eq_v"])
return S, pairs, tsm, shape return S, pairs, tsm, shape
+54
View File
@@ -0,0 +1,54 @@
"""Valida il CrossSectionalWorker: replay bar-per-bar == backtest XS01.xsec_sim?
Come validate_worker_pairs: alimenta il worker con finestre trailing crescenti del
pannello 8-asset e confronta capitale finale e n.trade col backtest di riferimento
scripts.strategies.XS01_cross_sectional.xsec_sim. Se combaciano, la semantica live e' fedele.
"""
from __future__ import annotations
import shutil
import sys
import tempfile
from pathlib import Path
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.live.xsec_worker import CrossSectionalWorker
from scripts.strategies.XS01_cross_sectional import aligned_panel, xsec_sim, UNIVERSE, LB, HOLD
def main():
print("=" * 88)
print(" VALIDAZIONE CrossSectionalWorker — replay live vs backtest xsec_sim (fee 0.10% RT/book)")
print("=" * 88)
M = aligned_panel(UNIVERSE)
dfs = {a: pd.DataFrame({"timestamp": M.index.values, "close": M[a].values}) for a in UNIVERSE}
n = len(M)
tmp = Path(tempfile.mkdtemp(prefix="xsec_val_"))
try:
w = CrossSectionalWorker(UNIVERSE, tf="1h", params={"lb": LB, "hold": HOLD},
fee_rt=0.0005, data_dir=tmp)
w._save = lambda: None; w._log = lambda *a, **k: None; w._notify = lambda *a, **k: None
window = LB + 6
for k in range(LB + 1, n + 1): # prima finestra = lb+1 barre -> ingresso al bar lb
lo = max(0, k - window)
w.tick({a: dfs[a].iloc[lo:k] for a in UNIVERSE})
bt = xsec_sim(UNIVERSE)
bt_cap = 1000.0 * (1 + bt["ret"] / 100)
cap_ok = abs(w.capital - bt_cap) / bt_cap < 0.02 if bt_cap else False
trd_ok = abs(w.total_trades - bt["trades"]) <= max(2, bt["trades"] * 0.02)
ww = w.total_wins / w.total_trades * 100 if w.total_trades else 0
print(f"\n {'':<6}{'cap':>14}{'trades':>8}{'win%':>7}")
print(f" WORKER{w.capital:>14.0f}{w.total_trades:>8d}{ww:>7.1f}")
print(f" BCKTST{bt_cap:>14.0f}{bt['trades']:>8d}{bt['win']:>7.1f}")
print(f"\n ESITO: {'OK (replay == backtest)' if (cap_ok and trd_ok) else 'DIFF -> INDAGARE'}")
print(" (diff minime attese da bar finale aperta / troncamento)")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()
+97
View File
@@ -0,0 +1,97 @@
"""GATE PORT06 — XS01 (reversione cross-sectional 8 asset), candidato trovato in sessione.
XS01: ogni HOLD ore, long i perdenti relativi / short i vincenti su 8 asset (lb LB),
market-neutral gross 1, fee 0.10% RT/book. Decorrelato (~0) dai pairs. Domanda: aggiunto
a PORT06 migliora Sharpe/DD? (criterio del progetto: OOS Sharpe non peggiora E DD scende.)
uv run python scripts/analysis/xsec_port06_gate.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
from scripts.analysis.combine_portfolio import port_returns, metrics, SPLIT, OOS_DATE
from scripts.analysis.report_families import daily_from
from scripts.portfolios._defs import PORTFOLIOS
from src.portfolio.sleeves import all_sleeve_equities
from src.portfolio import weighting as W
ASSETS = ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"]
LB, HOLD, FEE = 48, 12, 0.0005
def xsec_equity(pos=0.15, lev=3.0):
dfs = {a: load_data(a, "1h")[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp")
for a in ASSETS}
M = pd.concat(dfs.values(), axis=1, join="inner").sort_index()
C = M[ASSETS].values
ts = pd.to_datetime(M.index, unit="ms", utc=True)
n = len(C); logC = np.log(C)
cap = 1000.0; eq_ts, eq_v, rets = [], [], []
last = -1; i = LB
while i < n - HOLD:
if i <= last:
i += 1; continue
dm = (logC[i] - logC[i - LB]); dm = dm - dm.mean()
w = -dm; gw = np.sum(np.abs(w))
if gw < 1e-9:
i += 1; continue
w = w / gw
net = np.sum(w * (logC[i + HOLD] - logC[i])) - FEE * np.sum(np.abs(w)) * 2
cap = max(cap + cap * pos * lev * net, 10.0)
rets.append(net); eq_ts.append(ts[i + HOLD]); eq_v.append(cap)
last = i + HOLD; i += 1
return daily_from(eq_ts, eq_v), np.array(rets)
def port_metrics(members, ids, clusters, caps):
dr = pd.DataFrame({i: members[i].pct_change().fillna(0.0) for i in ids})
w = W.weight_vector("cap", ids, dr, caps=caps, clusters=clusters)
drp = port_returns({i: members[i] for i in ids}, w)
return metrics(drp), metrics(drp, lo=SPLIT), w
def main():
p = PORTFOLIOS["PORT06"]
eq_base = dict(all_sleeve_equities())
print("=" * 92)
print(" GATE PORT06 — XS01 reversione cross-sectional (8 asset) | OOS da", OOS_DATE)
print("=" * 92)
for pos, lbl in [(0.15, "XS01 pos0.15"), (0.075, "XS01 pos0.075 (mezza)")]:
e, r = xsec_equity(pos=pos)
# correlazione con i pairs e i fade
cors = {}
for ref in ("PR_ETHBTC", "MR02_ETH"):
j = pd.concat([e.pct_change(), eq_base[ref].pct_change()], axis=1).dropna()
cors[ref] = round(j.iloc[:, 0].corr(j.iloc[:, 1]), 3)
ids0 = list(p.sleeve_ids); cl0 = p.clusters; caps = p.caps
f0, o0, _ = port_metrics(eq_base, ids0, cl0, caps)
mem = dict(eq_base); mem["XS01"] = e
ids1 = ids0 + ["XS01"]; cl1 = dict(cl0); cl1["XS01"] = "xsec"
f1, o1, w1 = port_metrics(mem, ids1, cl1, caps)
# risk contribution di XS01
drm = pd.DataFrame({i: mem[i].pct_change().fillna(0.0) for i in ids1})
cov = drm.cov(); wv = np.array([w1[i] for i in ids1])
pv = float(wv @ cov.values @ wv)
rc = {i: float(w1[i] * (cov.values[k] @ wv) / pv * 100) for k, i in enumerate(ids1)}
print(f"\n[{lbl}] corr XS01 vs {cors} | peso XS01 {w1['XS01']*100:.1f}% | "
f"risk-contrib XS01 {rc['XS01']:.1f}%")
print(f" {'config':<16}{'FULL Sh':>8}{'FULL DD%':>9}{'OOS Sh':>8}{'OOS DD%':>8}")
print(f" {'ATTUALE':<16}{f0['sharpe']:>8.2f}{f0['dd']:>9.2f}{o0['sharpe']:>8.2f}{o0['dd']:>8.2f}")
print(f" {'+XS01':<16}{f1['sharpe']:>8.2f}{f1['dd']:>9.2f}{o1['sharpe']:>8.2f}{o1['dd']:>8.2f}")
ok = (o1["sharpe"] >= o0["sharpe"] - 0.02 and o1["dd"] <= o0["dd"] + 1e-9
and f1["sharpe"] >= f0["sharpe"] - 0.02 and f1["dd"] <= f0["dd"] + 1e-9)
print(f" => {'PROMOSSO' if ok else 'non passa il criterio stretto (vedi numeri)'}")
if __name__ == "__main__":
main()
+8 -1
View File
@@ -101,6 +101,13 @@ TSM = [SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01", cluster="trend",
SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape", SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape",
params={"last_block_only": True}) params={"last_block_only": True})
for a in ("BTC", "ETH")] for a in ("BTC", "ETH")]
# XS01 — reversione CROSS-SECTIONAL (8 asset, market-neutral). Famiglia nuova, scorrelata
# (~0) da pairs e fade. Gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46.
# 8 gambe -> niente esecuzione reale: gira PAPER (come TR01/ROT02/TSM01). Worker validato
# (validate_xsec_worker: replay == backtest esatto). Diario 2026-06-09.
XSEC = [SleeveSpec(kind="xsec", name="XS01", sid="XS01", cluster="xsec",
params={"universe": ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"],
"tf": "1h", "lb": 48, "hold": 12})]
PORTFOLIOS = { PORTFOLIOS = {
"PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"), "PORT01": Portfolio("PORT01", "Honest", HONEST, weighting="equal"),
@@ -115,6 +122,6 @@ PORTFOLIOS = {
# che NESSUNO stop taglia la coda ETH senza rompere l'edge -> si dimezza l'esposizione # che NESSUNO stop taglia la coda ETH senza rompere l'edge -> si dimezza l'esposizione
# (costo backtest ~0: FULL 6.47->6.43, OOS 8.82->8.58, FULL DD 4.10->3.96). Vedi # (costo backtest ~0: FULL 6.47->6.43, OOS 8.82->8.58, FULL DD 4.10->3.96). Vedi
# docs/diary/2026-06-05-sh01-sl-research.md. # docs/diary/2026-06-05-sh01-sl-research.md.
"PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE, "PORT06": Portfolio("PORT06", "Master + shape", FADE + HONEST + PAIRS + TSM + SHAPE + XSEC,
weighting="cap", caps={"PAIRS": 0.33, "SHAPE": 0.0588}, leverage=2.0), weighting="cap", caps={"PAIRS": 0.33, "SHAPE": 0.0588}, leverage=2.0),
} }
+105
View File
@@ -0,0 +1,105 @@
"""XS01 — Cross-Sectional Reversion (market-neutral su 8 cripto). FAMIGLIA NUOVA.
Distinta dai pairs PR01 (pairwise) e dai fade (single-asset): ogni HOLD ore classifica
gli 8 asset per rendimento su LB ore e va LONG i perdenti relativi / SHORT i vincenti
(peso ∝ -(ret - media_cross-section)), market-neutral gross 1. Cattura il FATTORE
reversione cross-sezionale. Scorrelato (~0) da pairs e fade -> diversificatore.
Engine ONESTO (no look-ahead, verificato): pesi a barra i da close[<=i]; ingresso a
close[i], uscita a close[i+HOLD]; roll NON sovrapposto (riallinea ogni HOLD barre).
Fee = 0.10% RT/book (turnover gross 1 -> 2*fee_rt). PnL su capitale composto (pos, lev).
Validazione (sessione 2026-06-09, lb48 hold12, fee 0.10% RT, OOS ultimo 30%):
FULL Sharpe ~3.3 / OOS ~3.4, plateau lb 12-72 x hold 6-24 (OOS 2-3.9), 4/5 anni+.
Decorrelato (-0.006 da PR01 ETH/BTC). Cost-sensitive: muore ~0.35% RT/book.
Gate PORT06: +XS01 -> OOS Sharpe 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.06pp a mezza size).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
from src.data.downloader import load_data
UNIVERSE = ["BTC", "ETH", "LTC", "ADA", "SOL", "BNB", "XRP", "DOGE"]
FEE_RT, LEV, POS, OOS_FRAC = 0.0005, 3.0, 0.15, 0.30
LB, HOLD = 48, 12
def aligned_panel(assets=UNIVERSE, tf="1h"):
dfs = {a: load_data(a, tf)[["timestamp", "close"]].rename(columns={"close": a}).set_index("timestamp")
for a in assets}
M = pd.concat(dfs.values(), axis=1, join="inner").sort_index()
return M[assets]
def xsec_sim(assets=UNIVERSE, tf="1h", lb=LB, hold=HOLD, fee_rt=FEE_RT, lev=LEV,
pos=POS, split_frac=0.0):
M = aligned_panel(assets, tf)
C = M[assets].values
ts = pd.to_datetime(M.index, unit="ms", utc=True)
n = len(C); logC = np.log(C)
split = int(n * split_frac)
cap = peak = 1000.0; dd = 0.0
trades = wins = 0; rets = []; yearly = {}; yearly_n = {}
eq_ts, eq_v = [], []
last = -1; i = max(lb, split)
fee = 2 * fee_rt # gross 1 -> turnover 2 (entra+esce)
while i < n - hold:
if i <= last:
i += 1; continue
dm = (logC[i] - logC[i - lb]); dm = dm - dm.mean()
w = -dm; gw = np.sum(np.abs(w))
if gw < 1e-9:
i += 1; continue
w = w / gw # market-neutral, gross 1
book = float(np.sum(w * (logC[i + hold] - logC[i])))
net = book - fee
cap = max(cap + cap * pos * lev * net, 10.0)
peak = max(peak, cap); dd = max(dd, (peak - cap) / peak)
trades += 1; wins += net > 0; rets.append(net * pos); last = i + hold
eq_ts.append(ts[i + hold]); eq_v.append(cap)
yearly[ts[i].year] = yearly.get(ts[i].year, 0.0) + net * 100
yearly_n[ts[i].year] = yearly_n.get(ts[i].year, 0) + 1
i += 1
yrs_span = (ts[-1] - ts[max(split, 0)]).days / 365.25 or 1
sharpe = 0.0
if len(rets) > 1 and np.std(rets) > 0:
sharpe = float(np.mean(rets) / np.std(rets) * np.sqrt(trades / yrs_span))
ret_tot = (cap / 1000 - 1) * 100
cagr = ((cap / 1000) ** (1 / yrs_span) - 1) * 100 if cap > 0 else -100
return dict(trades=trades, win=wins / trades * 100 if trades else 0, ret=ret_tot,
cagr=cagr, dd=dd * 100, sharpe=sharpe, yearly=yearly, yearly_n=yearly_n,
eq_ts=eq_ts, eq_v=eq_v)
def check_no_lookahead():
M = aligned_panel(); logC = np.log(M.values); i = 1000
a = (logC[i] - logC[i - LB])
Cp = logC.copy(); Cp[i + 1:] += 0.5
b = (Cp[i] - Cp[i - LB])
print(f" no-look-ahead: segnale invariato col futuro perturbato -> "
f"{'OK' if np.allclose(a, b) else 'VIOLAZIONE'}")
def run():
print("=" * 84)
print(" XS01 — Cross-Sectional Reversion (8 asset, market-neutral) | netto fee 0.10% RT/book")
print("=" * 84)
check_no_lookahead()
f = xsec_sim()
o = xsec_sim(split_frac=1 - OOS_FRAC)
yrs = f["yearly"]; pos_y = sum(1 for v in yrs.values() if v > 0)
print(f" trade {f['trades']} | win {f['win']:.1f}% | CAGR {f['cagr']:.0f}% | DD {f['dd']:.0f}% | "
f"Sharpe FULL {f['sharpe']:.2f} / OOS {o['sharpe']:.2f} | anni+ {pos_y}/{len(yrs)}")
print(" per anno:", " ".join(f"{y}:{v:+.0f}%" for y, v in sorted(yrs.items())))
if __name__ == "__main__":
run()
+168
View File
@@ -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}"
+11 -2
View File
@@ -22,6 +22,7 @@ from src.live.pairs_worker import PairsWorker
from src.live.basket_trend_worker import BasketTrendWorker from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker from src.live.tsmom_worker import TsmomWorker
from src.live.xsec_worker import CrossSectionalWorker
from src.live.strategy_loader import load_strategy from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml) # Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
@@ -30,7 +31,7 @@ _STRAT_MODULE = {
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml", "MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
"DIP01": "DIP01_dip_buy", "DIP01": "DIP01_dip_buy",
} }
_MULTI_KINDS = ("basket", "rotation", "tsmom") _MULTI_KINDS = ("basket", "rotation", "tsmom", "xsec")
DATA_DIR = Path("data/portfolio_paper") DATA_DIR = Path("data/portfolio_paper")
# giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer) # giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer)
@@ -88,6 +89,14 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30), thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir, tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
) )
if spec.kind == "xsec":
pr = spec.params
return CrossSectionalWorker(
universe=pr["universe"], tf=pr.get("tf", "1h"),
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12)},
capital=alloc_capital, position_size=position_size, leverage=leverage,
data_dir=data_dir,
)
module = _STRAT_MODULE.get(spec.name) module = _STRAT_MODULE.get(spec.name)
if module is None: if module is None:
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})") raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
@@ -338,7 +347,7 @@ def run(config_path: str = "portfolios.yml"):
# 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] = {}
for s in live_specs: for s in supported: # live + PAPER (anche XS01/TR01/ROT02/TSM01)
assets, tf = _spec_assets_tf(s) assets, tf = _spec_assets_tf(s)
days = _LOOKBACK_DAYS.get(tf, 90) days = _LOOKBACK_DAYS.get(tf, 90)
if s.kind == "ml": # SH01 ha bisogno di molta storia 1h if s.kind == "ml": # SH01 ha bisogno di molta storia 1h
+1 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import numpy as np import numpy as np
import pandas as pd import pandas as pd
_PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE")] _PREFIX = [("PR_", "PAIRS"), ("SH_", "SHAPE"), ("TSM", "TSM"), ("MR", "FADE"), ("XS", "XSEC")]
def family_of(sleeve_id: str) -> str: def family_of(sleeve_id: str) -> str:
+6 -5
View File
@@ -9,8 +9,9 @@ def test_port06_cap_backtest_numbers_locked():
# copertura storica -> metriche migliorate (Sharpe 6.07->6.47, OOS 8.19->8.82, # copertura storica -> metriche migliorate (Sharpe 6.07->6.47, OOS 8.19->8.82,
# DD 4.9%->4.1%). Nuovo baseline atteso, non una regressione. # DD 4.9%->4.1%). Nuovo baseline atteso, non una regressione.
# Aggiornato 2026-06-09: aggiunto lo sleeve BLEND PR_ETHBTC_15M (ETH/BTC pairs 15m # Aggiornato 2026-06-09: aggiunto lo sleeve BLEND PR_ETHBTC_15M (ETH/BTC pairs 15m
# flat-skip, mezza size) -> miglioria attesa: FULL 6.47->7.20, OOS 8.82->9.66, # flat-skip, mezza size) -> FULL 6.47->7.20, OOS 8.82->9.66, DD 4.1%->3.7%.
# DD 4.1%->3.7%. Vedi docs/diary/2026-06-09-pairs15m-live-path.md. # Aggiornato 2026-06-09 (2): + XS01 (reversione cross-sectional 8 asset, PAPER) ->
assert r.full["sharpe"] == pytest.approx(7.20, abs=0.15) # FULL 7.20->7.34, OOS 9.66->10.07, FULL DD 3.68->3.46 (OOS DD +0.17pp).
assert r.oos["sharpe"] == pytest.approx(9.66, abs=0.25) assert r.full["sharpe"] == pytest.approx(7.34, abs=0.15)
assert r.full["dd"] == pytest.approx(3.68, abs=0.5) assert r.oos["sharpe"] == pytest.approx(10.07, abs=0.25)
assert r.full["dd"] == pytest.approx(3.46, abs=0.5)
+3 -3
View File
@@ -8,9 +8,9 @@ def test_six_portfolios_defined():
def test_port06_is_master_shape_cap(): def test_port06_is_master_shape_cap():
p = PORTFOLIOS["PORT06"] p = PORTFOLIOS["PORT06"]
sids = set(p.sleeve_ids) sids = set(p.sleeve_ids)
assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC", "PR_ETHBTC_15M"} <= sids assert {"SH_BTC", "SH_ETH", "TSM01", "PR_ETHBTC", "PR_ETHBTC_15M", "XS01"} <= sids
# 18 dal 2026-06-09: aggiunto lo sleeve BLEND PR_ETHBTC_15M (ETH/BTC pairs 15m flat-skip) # 19 dal 2026-06-09: + XS01 (reversione cross-sectional 8 asset, sleeve PAPER, family XSEC)
assert len(sids) == 18 assert len(sids) == 19
# SHAPE cappata a 0.0588 (2026-06-05): SH01 senza SL by-design, esposizione dimezzata # SHAPE cappata a 0.0588 (2026-06-05): SH01 senza SL by-design, esposizione dimezzata
# (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute) # (ricerca sh01_exit_lab: 11 famiglie di stop, 0 sopravvissute)
assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588} assert p.weighting == "cap" and p.caps == {"PAIRS": 0.33, "SHAPE": 0.0588}