diff --git a/src/live/rotation_worker.py b/src/live/rotation_worker.py new file mode 100644 index 0000000..60e65bd --- /dev/null +++ b/src/live/rotation_worker.py @@ -0,0 +1,109 @@ +"""RotationWorker (ROT02): dual-momentum top-k risk-gated, ribilancio giornaliero. +Replica live di honest_improve2._rot_daily_equity (lookback 60, top_k 3, gross 0.45, 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 + +FEE_RT = 0.001 + + +def _panel(data: dict, universe: list): + """Allinea {asset: df} sui timestamp comuni -> (df_panel, cols presenti).""" + frames = {} + for a in universe: + df = data.get(a) + if df is not None and len(df): + frames[a] = df[["timestamp", "close"]].rename(columns={"close": a}) + if not frames: + return None, [] + panel = None + for a, f in frames.items(): + panel = f if panel is None else panel.merge(f, on="timestamp", how="inner") + panel = panel.sort_values("timestamp").reset_index(drop=True) + cols = [a for a in universe if a in frames] + return panel, cols + + +class RotationWorker: + def __init__(self, universe, lookback=60, top_k=3, gross=0.45, regime_n=100, + tf="1d", capital=1000.0, fee_rt=FEE_RT, name="ROT02_rot", + data_dir=Path("data/portfolio_paper")): + self.universe = list(universe) + self.lookback = lookback + self.top_k = top_k + 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._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, + "last_bar_ts": self.last_bar_ts, + "ts": datetime.now(timezone.utc).isoformat()}, indent=2)) + + def tick(self, data: dict): + panel, cols = _panel(data, self.universe) + if panel is None or len(panel) < max(self.lookback + 1, self.regime_n + 1) or "BTC" not in cols: + return + P = panel[cols].values + bar_ts = int(panel["timestamp"].iloc[-1]) + # 1) realizza il rendimento dei pesi correnti sull'ultima barra chiusa + 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) + # 2) ricalcola pesi target + 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 + mom = P[-1] / P[-1 - self.lookback] - 1.0 + order = np.argsort(mom)[::-1] + chosen = [k for k in order if mom[k] > 0][: self.top_k] if risk_on else [] + nw = {a: 0.0 for a in self.universe} + for k in chosen: + nw[cols[k]] = self.gross / len(chosen) + # 3) fee sul turnover + 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}" diff --git a/tests/portfolio/test_rotation_worker.py b/tests/portfolio/test_rotation_worker.py new file mode 100644 index 0000000..46509c8 --- /dev/null +++ b/tests/portfolio/test_rotation_worker.py @@ -0,0 +1,32 @@ +import numpy as np +import pandas as pd +from src.live.rotation_worker import RotationWorker + + +def _df(n=200, slope=1.0): + c = np.linspace(100, 100 + slope * n, n) + ts = (pd.date_range("2023-01-01", periods=n, freq="1D", tz="UTC").astype("int64") // 10**6) + return pd.DataFrame({"timestamp": ts, "open": c, "high": c, "low": c, "close": c, "volume": 1.0}) + + +def test_rotation_picks_top_momentum_when_risk_on(tmp_path): + w = RotationWorker(universe=["BTC", "AAA", "BBB"], top_k=2, gross=0.45, data_dir=tmp_path) + data = {"BTC": _df(slope=1.0), "AAA": _df(slope=3.0), "BBB": _df(slope=0.1)} + w.tick(data) + assert w.weights["AAA"] > 0 + assert abs(sum(w.weights.values()) - 0.45) < 1e-9 + + +def test_rotation_flat_when_risk_off(tmp_path): + # BTC in downtrend -> risk_off -> nessuna posizione + w = RotationWorker(universe=["BTC", "AAA"], top_k=1, gross=0.45, data_dir=tmp_path) + data = {"BTC": _df(slope=-1.0), "AAA": _df(slope=3.0)} + w.tick(data) + assert sum(w.weights.values()) == 0.0 + + +def test_rotation_persists_and_resumes(tmp_path): + w = RotationWorker(universe=["BTC", "AAA"], top_k=1, gross=0.45, data_dir=tmp_path) + w.tick({"BTC": _df(slope=1.0), "AAA": _df(slope=3.0)}) + w2 = RotationWorker(universe=["BTC", "AAA"], top_k=1, gross=0.45, data_dir=tmp_path) + assert w2.weights == w.weights