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:
@@ -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
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user