feat(stability): sweep stabilità — fix TR01 mean(rets), XS01 phase-tranching K=3, z-stop pairs bocciato

Audit anti-overfit su tutte le 19 sleeve (diario 2026-06-11-stability-sweep.md):

- FIX BasketTrendWorker: mean(rets) sui soli asset in posizione sovrappesava N/k
  a paniere parziale (1 long = 0.45 del capitale invece di 0.09) -> replay -44%
  vs ref +42%. Ora sum(rets)/N (convenzione canonica 1/N): replay +32% vs +42%
  (residuo = convenzione dichiarata). Solo statistica PAPER.
- XS01 PHASE-TRANCHING (gate xs01_tranche_gate: plateau K=2 E K=3 promossi,
  PORT06 OOS Sh 10.07->10.15 DD 1.48->1.38, FULL pari): la fase del roll e'
  timing-luck (Sharpe daily 1.52-2.33, DD 13.8-33% sulle 12 fasi). Worker con
  param tranches (default 1), 3 sub-book sfasati hold/3 su capitale comune,
  migrazione status legacy, last_bar_ts solo-avanti; runner forward del param;
  _defs tranches=3; hourly_report aggrega i sub-book; validatore esteso e
  PASSATO (K=1 == xsec_sim esatto, K=3 == unione fasi esatto).
- Disaster-cap z sui pairs: pre-registrato e BOCCIATO su tutti i criteri (coda
  OOS peggiora 4/6 coppie, Sharpe -10..-49%, plateau solo del danno; 5a conferma
  stop-su-MR). Record pairs_zstop_research.py; pairs restano senza stop.
- Audit drift: regression-lock trendmax OK (parita' 1.00000, plateau 2.5/3.0/3.5
  confermato), correlazioni cross-famiglia ~0 invariate; PORT06 rolling al
  19-28mo pct (normale) ma FADE 120g al 2o percentile storico -> monitor in TODO
  (nessun ritocco parametri).
- TODO: forming-bar ROT02/TSM01 era gia' fixato (v1.1.10), item chiuso.

Test: pytest 99 passed; validate_honest_workers OK; validate_xsec_worker OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-11 13:29:14 +00:00
parent ba5ec7bd6b
commit 9d15506b05
12 changed files with 693 additions and 72 deletions
+5 -1
View File
@@ -86,7 +86,11 @@ class BasketTrendWorker:
self.positions[a] = target
self.last_bar_ts[a] = bar_ts
if rets:
self.capital = max(self.capital * (1 + float(np.mean(rets))), 10.0)
# equal-weight 1/N sull'UNIVERSO come il reference (_tr_basket_daily):
# gli asset flat contribuiscono 0. mean(rets) mediava sui SOLI asset in
# posizione -> sovrappeso N/k a paniere parziale (con 1 long: 0.45 del
# capitale invece di 0.09) -> replay -44% vs reference +42%.
self.capital = max(self.capital * (1 + float(np.sum(rets)) / len(self.universe)), 10.0)
self.in_position = any(v > 0 for v in self.positions.values())
self._save()
+77 -40
View File
@@ -5,6 +5,13 @@ classifica gli asset per rendimento su LB barre, pesi w = -(ret - media)/gross (
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
@@ -41,40 +48,61 @@ class CrossSectionalWorker:
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.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.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})
"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.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)
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),
"weights": {a: round(float(v), 5) for a, v in self.weights.items()},
"entry_px": {a: float(v) for a, v in self.entry_px.items()},
"bars_held": int(self.bars_held), "cooldown": int(self.cooldown),
"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(),
@@ -114,25 +142,27 @@ class CrossSectionalWorker:
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)."""
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 += self.weights[a] * np.log(closes_now[k] / self.entry_px[a])
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)
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", {"book_ret": round(book * 100, 3), "net": round(net * 100, 3),
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)})
self.in_position = False
self.weights = {a: 0.0 for a in self.universe}
b["in_position"] = False
b["weights"] = {a: 0.0 for a in self.universe}
def _open_book(self, M, i):
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:
@@ -143,13 +173,14 @@ class CrossSectionalWorker:
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)})
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):
@@ -160,20 +191,26 @@ class CrossSectionalWorker:
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
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
st = "BOOK" if self.in_position else ("COOL" if self.cooldown else "FLAT")
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}"
+2 -1
View File
@@ -97,7 +97,8 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
return CrossSectionalWorker(
universe=pr["universe"], tf=pr.get("tf", "1h"),
params={"lb": pr.get("lb", 48), "hold": pr.get("hold", 12),
"disp_min": pr.get("disp_min")},
"disp_min": pr.get("disp_min"),
"tranches": pr.get("tranches", 1)},
capital=alloc_capital, position_size=position_size, leverage=leverage,
data_dir=data_dir,
)