Files
PythagorasGoal/src/live/tsmom_worker.py
T
Adriano Dal Pastro 62e272255b feat(live): disaster-bracket on-book sui fade reali + alert FEED_OUTAGE + osservabilita' multi-asset
Punti 5-6 dell'improvement-sweep 2026-06-06 (protezione capitale + osservabilita'):

Punto 5 — disaster bracket:
- ExecutionClient.place_disaster_sl: STOP_MARKET reduce-only a ~-30% dall'ingresso
  (trigger mark price), piazzato a ogni REAL_OPEN (MR01/MR02/MR07/DIP01) e
  cancellato in _real_close. Assicurazione outage: il poll-loop in except lascia
  le posizioni reali senza valutazione exit (ETH gap max storico 33%/1h). In
  operativita' normale non scatta mai -> 0 costo Sharpe. real_dsl_order_id
  persistito (resume-safe). Config overrides.execution.disaster_sl_pct (0.30).
- NB: set_stop_loss di cerbero-mcp e' un private/edit Deribit (solo ordini APERTI)
  -> non usabile su market fillati; il bracket e' un trigger order autonomo via
  place_order(type=stop_market). Cancel di un trigger order risponde 'untriggered'
  (= successo, verificato testnet: re-cancel -> order_not_found).
- Runner: alert Telegram FEED_OUTAGE dopo 5 poll falliti consecutivi (elenco
  posizioni reali aperte) + notifica RIPRESO con durata.

Punto 6 — osservabilita':
- in_position nei _save() di TR01/ROT02/TSM01; hourly_report: sezione MULTI-ASSET
  (book | ultimo flip | freschezza status) — prima i 3 worker erano invisibili
  (collect() filtra su event/in_position che non emettevano); esclusi dalla
  tabella IN CORSO (assume entry/bars single-leg).
- live_shadow_smoke esteso: scenari C/D SHORT (TP-resting BUY mai esercitato
  prima) + disaster bracket in tutti gli scenari.

Verifiche: 72/72 test; smoke testnet 4 scenari verdi (DSL piazzato/cancellato due
lati, zero ordini orfani sul book, conto flat); multi_asset_section renderizza sui
dati live. Diario docs/diary/2026-06-07-sweep-fixes.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:36:11 +00:00

98 lines
4.3 KiB
Python

"""TsmomWorker (TSM01): consenso TSMOM multi-orizzonte risk-gated, ribilancio giornaliero.
Replica live di tsmom_research.tsmom_sim (horizons 63/126/252, thr 1.0, gross 0.30, SMA100 gate)."""
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.rotation_worker import _panel, _warn_panel_short, FEE_RT
class TsmomWorker:
def __init__(self, universe, horizons=(63, 126, 252), thr=1.0, gross=0.30,
regime_n=100, tf="1d", capital=1000.0, fee_rt=FEE_RT,
name="TSM01", data_dir=Path("data/portfolio_paper")):
self.universe = list(universe)
self.horizons = tuple(horizons)
self.thr = thr
self.gross = gross
self.regime_n = regime_n
self.tf = tf
self.initial_capital = capital
self.capital = capital
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.weights = {a: 0.0 for a in self.universe}
self.last_bar_ts = 0
self.in_position = False
self._panel_warned = False # dedup WARN panel corto (per episodio, non persistito)
self._load()
def _load(self):
if self.status_path.exists():
s = json.loads(self.status_path.read_text())
self.capital = s.get("capital", self.capital)
self.weights = {**{a: 0.0 for a in self.universe}, **s.get("weights", {})}
self.last_bar_ts = s.get("last_bar_ts", 0)
self.in_position = any(v > 0 for v in self.weights.values())
def _save(self):
self.status_path.write_text(json.dumps({
"capital": round(self.capital, 2), "weights": self.weights,
"in_position": self.in_position, # per hourly_report (osservabilita')
"last_bar_ts": self.last_bar_ts,
"ts": datetime.now(timezone.utc).isoformat()}, indent=2))
def tick(self, data: dict):
need = max(max(self.horizons) + 1, self.regime_n + 1)
panel, cols = _panel(data, self.universe)
if panel is None or len(panel) < need or "BTC" not in cols:
self._panel_warned = _warn_panel_short(
self.worker_id, panel, cols, need, self.last_bar_ts, self._panel_warned)
return
self._panel_warned = False
P = panel[cols].values
bar_ts = int(panel["timestamp"].iloc[-1])
if self.last_bar_ts and bar_ts > self.last_bar_ts:
day_ret = P[-1] / P[-2] - 1.0
port_r = sum(self.weights.get(cols[k], 0.0) * day_ret[k] for k in range(len(cols)))
self.capital = max(self.capital * (1.0 + float(port_r)), 10.0)
btc = P[:, cols.index("BTC")]
bma = pd.Series(btc).rolling(self.regime_n).mean().values
risk_on = btc[-1] > bma[-1] if not np.isnan(bma[-1]) else False
score = np.zeros(len(cols))
for h in self.horizons:
score += np.sign(P[-1] / P[-1 - h] - 1.0)
score /= len(self.horizons)
chosen = [k for k in range(len(cols)) if score[k] >= self.thr] if risk_on else []
nw = {a: 0.0 for a in self.universe}
for k in chosen:
nw[cols[k]] = self.gross / len(chosen)
turnover = sum(abs(nw[a] - self.weights.get(a, 0.0)) for a in self.universe)
self.capital -= self.capital * turnover * (self.fee_rt / 2)
if turnover > 0:
self._log(nw, float(self.capital))
self.weights = nw
self.last_bar_ts = bar_ts
self.in_position = any(v > 0 for v in nw.values())
self._save()
def _log(self, weights, cap):
with open(self.trades_path, "a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(),
"weights": {a: round(w, 4) for a, w in weights.items() if w > 0},
"capital": round(cap, 2)}) + "\n")
@property
def status_summary(self):
held = {a: round(w, 3) for a, w in self.weights.items() if w > 0}
return f"{self.worker_id}: cap={self.capital:.0f} held={held}"