feat(portfolio): integra worker honest/TSM01 nel runner (PORT06 live completo)

build_worker_for gestisce basket/rotation/tsmom + DIP01 via StrategyWorker; run()
fetcha 1h e resampla a 4h/1d, lookback dimensionato sui daily (TSM01 252g); tick
multi-asset per kind. _defs marca TR01/ROT02/TSM01 col kind+universo. Niente piu'
sleeve saltati in PORT06.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 17:45:39 +02:00
parent 1e60835612
commit a7ada9f36c
3 changed files with 147 additions and 36 deletions
+97 -33
View File
@@ -1,24 +1,42 @@
"""PortfolioRunner: faccia live del portafoglio (capitale pool, sizing, ribilancio, ledger).
Riusa i worker esistenti come esecutori e il data layer Cerbero v2."""
Riusa i worker esistenti come esecutori e il data layer Cerbero v2.
Worker per tipo di sleeve:
single (fade/dip) -> StrategyWorker | ml (shape) -> MLWorkerWrapper(StrategyWorker)
pairs -> PairsWorker (2 gambe) | basket (TR01) -> BasketTrendWorker
rotation (ROT02) -> RotationWorker | tsmom (TSM01) -> TsmomWorker
Feed: il runner fetcha candele 1h da Cerbero v2 e le RESAMPLA a 4h/1d (come get_df nel
backtest) per i worker a cadenza piu' lenta. Il lookback per asset e' dimensionato sul
worker piu' esigente (TSM01 usa 252 giorni)."""
from __future__ import annotations
from pathlib import Path
import pandas as pd
from src.portfolio.base import SleeveSpec, Portfolio
from src.portfolio.ledger import PortfolioLedger
from src.live.strategy_worker import StrategyWorker
from src.live.pairs_worker import PairsWorker
from src.live.basket_trend_worker import BasketTrendWorker
from src.live.rotation_worker import RotationWorker
from src.live.tsmom_worker import TsmomWorker
from src.live.multi_runner import MLWorkerWrapper
from src.live.strategy_loader import load_strategy
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/
# Codice-breve sleeve -> nome modulo Strategy in scripts/strategies/ (worker single/ml)
_STRAT_MODULE = {
"MR01": "MR01_bollinger_fade", "MR02": "MR02_donchian_fade",
"MR07": "MR07_return_reversal", "SH01": "SH01_shape_ml",
# DIP01/TR01/ROT02 sono honest a sé: vedi nota nel design (worker dedicati in fase 2)
"DIP01": "DIP01_dip_buy",
}
_MULTI_KINDS = ("basket", "rotation", "tsmom")
DATA_DIR = Path("data/portfolio_paper")
# giorni di storia da fetchare per timeframe (TSM01 1d usa 252 barre -> ~440 giorni col buffer)
_LOOKBACK_DAYS = {"1h": 90, "4h": 220, "1d": 440}
def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
data_dir: Path = DATA_DIR, position_size: float = 0.15):
@@ -29,10 +47,28 @@ def build_worker_for(spec: SleeveSpec, alloc_capital: float, leverage: float,
capital=alloc_capital, position_size=position_size, leverage=leverage,
fee_rt=0.001, name="PR01_pairs_reversion", data_dir=data_dir,
)
if spec.kind == "basket":
pr = spec.params
return BasketTrendWorker(
universe=pr["universe"], tf=pr.get("tf", "4h"), capital=alloc_capital,
position_size=position_size, leverage=leverage, data_dir=data_dir,
)
if spec.kind == "rotation":
pr = spec.params
return RotationWorker(
universe=pr["universe"], top_k=pr.get("top_k", 3), gross=pr.get("gross", 0.45),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
)
if spec.kind == "tsmom":
pr = spec.params
return TsmomWorker(
universe=pr["universe"], horizons=tuple(pr.get("horizons", (63, 126, 252))),
thr=pr.get("thr", 1.0), gross=pr.get("gross", 0.30),
tf=pr.get("tf", "1d"), capital=alloc_capital, data_dir=data_dir,
)
module = _STRAT_MODULE.get(spec.name)
if module is None:
raise ValueError(f"sleeve live non ancora supportato: {spec.name} "
f"(honest DIP01/TR01/ROT02 richiedono worker dedicati, fase 2)")
raise ValueError(f"sleeve live non supportato: {spec.name} (kind={spec.kind})")
strategy = load_strategy(module)
worker = StrategyWorker(
strategy=strategy, asset=spec.asset, tf=spec.tf, capital=alloc_capital,
@@ -63,13 +99,36 @@ def rebalance_allocations(ledger: PortfolioLedger, workers: dict, weights: dict[
ledger.save()
def _resample(df: pd.DataFrame, tf: str) -> pd.DataFrame:
"""Resampla candele 1h -> 4h/1d mantenendo timestamp ms reale (come get_df del backtest)."""
if tf == "1h":
return df
rule = {"4h": "4h", "1d": "1D"}[tf]
d = df.copy()
d["dt"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
d = d.set_index("dt")
agg = d.resample(rule).agg({"open": "first", "high": "max", "low": "min",
"close": "last", "volume": "sum"}).dropna()
epoch = pd.Timestamp("1970-01-01", tz="UTC")
agg["timestamp"] = ((agg.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return agg.reset_index(drop=True)
def _spec_assets_tf(spec: SleeveSpec):
"""(lista asset, tf) coinvolti da uno sleeve."""
if spec.kind == "pairs":
return [spec.a, spec.b], spec.tf
if spec.kind in _MULTI_KINDS:
return list(spec.params["universe"]), spec.params.get("tf", "1d" if spec.kind != "basket" else "4h")
return [spec.asset], spec.tf
def run(config_path: str = "portfolios.yml"):
"""Loop live a portafoglio. Data layer Cerbero v2; ribilancio a fine giornata UTC.
Gli sleeve senza worker live (honest DIP01/TR01/ROT02) vengono SALTATI con warning
(restano solo in backtest); i pesi sono rinormalizzati sugli sleeve eseguibili."""
"""Loop live a portafoglio (tutti i tipi di sleeve). Data layer Cerbero v2 con resample;
ribilancio a cambio giornata UTC."""
import time
from datetime import datetime, timezone, timedelta
import pandas as pd
import yaml
from src.portfolio.base import load_active_portfolio
from src.portfolio.sleeves import sleeve_returns_df
from src.portfolio import weighting as W
@@ -77,13 +136,11 @@ def run(config_path: str = "portfolios.yml"):
from src.live.multi_runner import INSTRUMENT_MAP
p: Portfolio = load_active_portfolio(config_path)
import yaml as _yaml
_ov = (_yaml.safe_load(__import__("pathlib").Path(config_path).read_text()) or {}).get("overrides", {})
_ov = (yaml.safe_load(Path(config_path).read_text()) or {}).get("overrides", {})
poll = int(_ov.get("poll_seconds", 60))
def _supported(s):
return s.kind == "pairs" or s.name in _STRAT_MODULE
return s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE
live_specs = [s for s in p.sleeves if _supported(s)]
skipped = [s.sid for s in p.sleeves if not _supported(s)]
if skipped:
@@ -100,40 +157,47 @@ def run(config_path: str = "portfolios.yml"):
alloc = ledger.allocate(weights)
workers = {s.sid: build_worker_for(s, alloc[s.sid], p.leverage) for s in live_specs}
# lookback (giorni) richiesto per ogni asset = max sui worker che lo usano
asset_days: dict[str, int] = {}
for s in live_specs:
assets, tf = _spec_assets_tf(s)
for a in assets:
asset_days[a] = max(asset_days.get(a, 0), _LOOKBACK_DAYS.get(tf, 90))
inst_map = dict(INSTRUMENT_MAP)
last_day = ""
while True:
try:
keys = set()
for s in live_specs:
if s.kind == "pairs":
keys.add((s.a, s.tf)); keys.add((s.b, s.tf))
else:
keys.add((s.asset, s.tf))
cache = {}
end = datetime.now(timezone.utc); start = end - timedelta(days=60)
for asset, tf in keys:
# fetch 1h per asset al lookback massimo richiesto
raw1h: dict[str, pd.DataFrame] = {}
end = datetime.now(timezone.utc)
for asset, days in asset_days.items():
inst = inst_map.get(asset, f"{asset}-PERPETUAL")
start = end - timedelta(days=days)
candles = client.get_historical_v2(inst, start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"), tf)
end.strftime("%Y-%m-%d"), "1h")
if candles:
df = pd.DataFrame(candles)
df["timestamp"] = df["timestamp"].astype("int64")
cache[(asset, tf)] = df.sort_values("timestamp").reset_index(drop=True)
raw1h[asset] = df.sort_values("timestamp").reset_index(drop=True)
# tick di ogni worker col suo timeframe (resample dal 1h)
for s in live_specs:
w = workers[s.sid]
assets, tf = _spec_assets_tf(s)
if any(a not in raw1h for a in assets):
continue
res = {a: _resample(raw1h[a], tf) for a in assets}
if s.kind == "pairs":
ka, kb = (s.a, s.tf), (s.b, s.tf)
if ka in cache and kb in cache:
w.tick(cache[ka], cache[kb])
w.tick(res[s.a], res[s.b])
elif s.kind in _MULTI_KINDS:
w.tick(res)
else:
key = (s.asset, s.tf)
if key in cache:
inner = getattr(w, "worker", w)
if hasattr(w, "needs_training") and w.needs_training():
w.train(cache[key], hold=inner.hold_bars)
w.tick(cache[key])
df = res[s.asset]
inner = getattr(w, "worker", w)
if hasattr(w, "needs_training") and w.needs_training():
w.train(df, hold=inner.hold_bars)
w.tick(df)
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})