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
+11 -3
View File
@@ -9,12 +9,18 @@ sys.path.insert(0, str(PROJECT_ROOT))
from src.portfolio.base import Portfolio, SleeveSpec # noqa: E402
# Universo live tradabile (8 asset con feed Cerbero v2 + parquet). ROT02/TSM01 ci ruotano sopra.
UNIVERSE8 = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
HONEST = [
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
SleeveSpec(kind="single", name="TR01", sid="TR01_basket", cluster="trend"),
SleeveSpec(kind="single", name="ROT02", sid="ROT02_rot", cluster="rotation"),
SleeveSpec(kind="basket", name="TR01", sid="TR01_basket", cluster="trend",
params={"universe": ["BNB", "BTC", "DOGE", "SOL", "XRP"], "tf": "4h"}),
SleeveSpec(kind="rotation", name="ROT02", sid="ROT02_rot", cluster="rotation",
params={"universe": UNIVERSE8, "tf": "1d", "top_k": 3, "gross": 0.45}),
]
PAIRS = [
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHBTC", a="ETH", b="BTC", cluster="ETH-rev"),
@@ -23,7 +29,9 @@ PAIRS = [
SleeveSpec(kind="pairs", name="PR01", sid="PR_BTCLTC", a="BTC", b="LTC", cluster="BTC-rev"),
SleeveSpec(kind="pairs", name="PR01", sid="PR_ETHSOL", a="ETH", b="SOL", cluster="ETH-rev"),
]
TSM = [SleeveSpec(kind="single", name="TSM01", sid="TSM01", cluster="trend")]
TSM = [SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01", cluster="trend",
params={"universe": UNIVERSE8, "tf": "1d",
"horizons": [63, 126, 252], "thr": 1.0, "gross": 0.30})]
SHAPE = [SleeveSpec(kind="ml", name="SH01", sid=f"SH_{a}", asset=a, cluster="shape")
for a in ("BTC", "ETH")]
+95 -31
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:
df = res[s.asset]
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])
w.train(df, hold=inner.hold_bars)
w.tick(df)
ledger.update_equity({sid: _worker_equity(wk) for sid, wk in workers.items()})
+39
View File
@@ -0,0 +1,39 @@
"""T5: integrazione worker honest/TSM01 nel PortfolioRunner."""
from src.portfolio.runner import build_worker_for, _STRAT_MODULE, _MULTI_KINDS
from src.portfolio.base import SleeveSpec
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.strategy_worker import StrategyWorker
from scripts.portfolios._defs import PORTFOLIOS
def test_build_basket_worker(tmp_path):
spec = SleeveSpec(kind="basket", name="TR01", sid="TR01_basket",
params={"universe": ["BNB", "BTC"], "tf": "4h"})
w = build_worker_for(spec, alloc_capital=120.0, leverage=2.0, data_dir=tmp_path)
assert isinstance(w, BasketTrendWorker) and w.capital == 120.0
def test_build_rotation_and_tsmom(tmp_path):
rot = SleeveSpec(kind="rotation", name="ROT02", sid="ROT02_rot",
params={"universe": ["BTC", "ETH"], "tf": "1d", "top_k": 1, "gross": 0.45})
tsm = SleeveSpec(kind="tsmom", name="TSM01", sid="TSM01",
params={"universe": ["BTC", "ETH"], "tf": "1d", "gross": 0.30})
wr = build_worker_for(rot, 100.0, 2.0, data_dir=tmp_path)
wt = build_worker_for(tsm, 100.0, 2.0, data_dir=tmp_path)
assert isinstance(wr, RotationWorker) and wr.capital == 100.0
assert isinstance(wt, TsmomWorker) and wt.capital == 100.0
def test_dip01_builds_as_strategy_worker(tmp_path):
spec = SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC")
w = build_worker_for(spec, 80.0, 2.0, data_dir=tmp_path)
assert isinstance(w, StrategyWorker) and w.capital == 80.0
def test_port06_has_no_unsupported_sleeves():
p = PORTFOLIOS["PORT06"]
unsupported = [s.sid for s in p.sleeves
if not (s.kind in ("pairs",) + _MULTI_KINDS or s.name in _STRAT_MODULE)]
assert unsupported == []