"""Multi-Strategy Paper Trader — orchestratore per N strategie in parallelo.""" from __future__ import annotations import time import yaml from datetime import datetime, timedelta, timezone from pathlib import Path import pandas as pd from src.live.cerbero_client import CerberoClient from src.live.strategy_loader import load_strategy from src.live.strategy_worker import StrategyWorker from src.live.pairs_worker import PairsWorker from src.live.signal_engine import SignalEngine from src.live.telegram_notifier import send_telegram PROJECT_ROOT = Path(__file__).resolve().parents[2] DATA_DIR = PROJECT_ROOT / "data" / "paper_trades" RESOLUTION_MAP = {"15m": "15", "1h": "60", "5m": "5"} # Convenzione Deribit (verificata via Cerbero, 2026-05-29): # - BTC/ETH = perpetui INVERSE (margine coin): "-PERPETUAL" # - altcoin = perpetui LINEARI USDC (margine USDC): "_USDC-PERPETUAL", storia dal 2022 # Trappola: "LTC-PERPETUAL"/"ADA-PERPETUAL" = 0 candele; "SOL-PERPETUAL" = contratto vecchio # con dati sbagliati. Per gli alt usare SEMPRE la forma _USDC-PERPETUAL. INSTRUMENT_MAP = { "BTC": "BTC-PERPETUAL", "ETH": "ETH-PERPETUAL", "SOL": "SOL_USDC-PERPETUAL", "LTC": "LTC_USDC-PERPETUAL", "ADA": "ADA_USDC-PERPETUAL", "XRP": "XRP_USDC-PERPETUAL", "BNB": "BNB_USDC-PERPETUAL", "DOGE": "DOGE_USDC-PERPETUAL", } class MLWorkerWrapper: """Wrapper speciale per ML01 che usa SignalEngine con training.""" def __init__(self, worker: StrategyWorker, config: dict): self.worker = worker self.engine = SignalEngine( bb_w=config.get("params", {}).get("bb_window", 14), sq_thr=config.get("params", {}).get("sq_threshold", 0.8), ml_thr=config.get("params", {}).get("ml_threshold", 0.70), ) self.trained = False self.last_train: datetime | None = None self.retrain_hours = config.get("retrain_hours", 24) def needs_training(self) -> bool: if not self.trained: return True if self.last_train is None: return True elapsed = (datetime.now(timezone.utc) - self.last_train).total_seconds() return elapsed > self.retrain_hours * 3600 def train(self, df: pd.DataFrame, hold: int = 3): result = self.engine.train(df, lookahead=hold) if "error" not in result: self.trained = True self.last_train = datetime.now(timezone.utc) print(f" [{self.worker.worker_id}] TRAIN OK: {result}") else: print(f" [{self.worker.worker_id}] TRAIN FAIL: {result}") def tick(self, df: pd.DataFrame): if not self.trained: return worker = self.worker c = df["close"].values current_price = float(c[-1]) current_ts = int(df["timestamp"].iloc[-1]) if worker.in_position: if current_ts > worker.last_bar_ts: worker.bars_held += 1 worker.last_bar_ts = current_ts if worker.bars_held >= worker.hold_bars: worker._close_position(current_price, "hold_limit") else: pnl_pct = (current_price - worker.entry_price) / worker.entry_price * worker.direction if pnl_pct <= -0.02: worker._close_position(current_price, "stop_loss") worker._save_state() return signal = self.engine.check_signal(df) if signal: from src.strategies.base import Signal direction = 1 if signal["direction"] == "buy" else -1 sig = Signal(idx=len(df)-1, direction=direction, entry_price=current_price) worker._open_position(sig, current_price) worker.last_bar_ts = current_ts worker._save_state() def load_config(path: Path) -> dict: with open(path) as f: return yaml.safe_load(f) def build_workers(config: dict) -> tuple[list[StrategyWorker], list[MLWorkerWrapper]]: """Crea worker da config YAML.""" defaults = config.get("defaults", {}) regular_workers: list[StrategyWorker] = [] ml_workers: list[MLWorkerWrapper] = [] for entry in config.get("strategies", []): if not entry.get("enabled", True): continue name = entry["name"] asset = entry["asset"] tf = entry["tf"] capital = entry.get("capital", defaults.get("capital", 1000)) pos_size = entry.get("position_size", defaults.get("position_size", 0.15)) leverage = entry.get("leverage", defaults.get("leverage", 3)) hold = entry.get("hold_bars", defaults.get("hold_bars", 3)) params = entry.get("params", {}) strategy = load_strategy(name) worker = StrategyWorker( strategy=strategy, asset=asset, tf=tf, capital=capital, position_size=pos_size, leverage=leverage, hold_bars=hold, params=params, data_dir=DATA_DIR, ) if name == "ML01_squeeze_gbm": ml_wrapper = MLWorkerWrapper(worker, {**defaults, **entry}) ml_workers.append(ml_wrapper) else: regular_workers.append(worker) return regular_workers, ml_workers def build_pairs_workers(config: dict) -> list[PairsWorker]: """Crea i PairsWorker (2 gambe) dalla sezione `pairs:` dello YAML.""" defaults = config.get("defaults", {}) workers: list[PairsWorker] = [] for entry in config.get("pairs", []): if not entry.get("enabled", True): continue workers.append(PairsWorker( asset_a=entry["a"], asset_b=entry["b"], tf=entry.get("tf", "1h"), params=entry.get("params", {}), capital=entry.get("capital", defaults.get("capital", 1000)), position_size=entry.get("position_size", defaults.get("position_size", 0.15)), leverage=entry.get("leverage", defaults.get("leverage", 3)), fee_rt=entry.get("fee_rt", 0.001), name=entry.get("name", "PR01_pairs_reversion"), data_dir=DATA_DIR, )) return workers def run(): config_path = PROJECT_ROOT / "strategies.yml" if not config_path.exists(): print(f"ERRORE: {config_path} non trovato") return config = load_config(config_path) defaults = config.get("defaults", {}) poll_seconds = defaults.get("poll_seconds", 60) lookback_days = 60 train_lookback_days = 365 regular_workers, ml_workers = build_workers(config) pairs_workers = build_pairs_workers(config) all_worker_count = len(regular_workers) + len(ml_workers) + len(pairs_workers) if all_worker_count == 0: print("Nessuna strategia abilitata in strategies.yml") return client = CerberoClient() print("=" * 70) print(f" MULTI-STRATEGY PAPER TRADER") print(f" Strategie attive: {all_worker_count}") print(f" Poll: ogni {poll_seconds}s") print(f" Data dir: {DATA_DIR}") print("=" * 70) for w in regular_workers: print(f" • {w.status_summary}") for mw in ml_workers: print(f" • {mw.worker.status_summary} [ML]") for pw in pairs_workers: print(f" • {pw.status_summary} [PAIRS]") send_telegram(f"🚀 Multi-Strategy avviato: {all_worker_count} strategie") # Raccogli asset/tf unici per fetch raggruppato def _get_data_keys() -> set[tuple[str, str]]: keys = set() for w in regular_workers: keys.add((w.asset, w.tf)) for mw in ml_workers: keys.add((mw.worker.asset, mw.worker.tf)) for pw in pairs_workers: # entrambe le gambe del pair keys.add((pw.asset_a, pw.tf)) keys.add((pw.asset_b, pw.tf)) return keys # Training iniziale ML for mw in ml_workers: asset = mw.worker.asset instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL") resolution = RESOLUTION_MAP.get(mw.worker.tf, "15") end = datetime.now(timezone.utc) start = end - timedelta(days=train_lookback_days) candles = client.get_historical(instrument, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), resolution) if candles: df_train = pd.DataFrame(candles) df_train["timestamp"] = df_train["timestamp"].astype("int64") df_train = df_train.sort_values("timestamp").reset_index(drop=True) mw.train(df_train, hold=mw.worker.hold_bars) while True: try: data_keys = _get_data_keys() candle_cache: dict[tuple[str, str], pd.DataFrame] = {} for asset, tf in data_keys: instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL") resolution = RESOLUTION_MAP.get(tf, "15") end = datetime.now(timezone.utc) start = end - timedelta(days=lookback_days) candles = client.get_historical( instrument, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), resolution, ) if candles: df = pd.DataFrame(candles) df["timestamp"] = df["timestamp"].astype("int64") df = df.sort_values("timestamp").reset_index(drop=True) candle_cache[(asset, tf)] = df # Fetch 1h live per strategie multi-timeframe (es. MT01): # il trend va preso da Cerbero, non dal parquet statico (che resta indietro). htf_cache: dict[str, pd.DataFrame] = {} mtf_assets = {w.asset for w in regular_workers if w.strategy.name.startswith("MT01")} for asset in mtf_assets: instrument = INSTRUMENT_MAP.get(asset, f"{asset}-PERPETUAL") end = datetime.now(timezone.utc) start = end - timedelta(days=lookback_days) try: candles_1h = client.get_historical( instrument, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), "60", ) if candles_1h: df1h = pd.DataFrame(candles_1h) df1h["timestamp"] = df1h["timestamp"].astype("int64") htf_cache[asset] = df1h.sort_values("timestamp").reset_index(drop=True) except Exception as e: print(f" [1h fetch {asset}] ERRORE: {e}") # Tick regular workers for w in regular_workers: key = (w.asset, w.tf) if key in candle_cache: try: w.tick(candle_cache[key], df_1h=htf_cache.get(w.asset)) except Exception as e: print(f" [{w.worker_id}] ERRORE: {e}") # Tick ML workers for mw in ml_workers: key = (mw.worker.asset, mw.worker.tf) if key not in candle_cache: continue if mw.needs_training(): mw.train(candle_cache[key], hold=mw.worker.hold_bars) try: mw.tick(candle_cache[key]) except Exception as e: print(f" [{mw.worker.worker_id}] ERRORE: {e}") # Tick pairs workers (2 gambe) for pw in pairs_workers: ka, kb = (pw.asset_a, pw.tf), (pw.asset_b, pw.tf) if ka in candle_cache and kb in candle_cache: try: pw.tick(candle_cache[ka], candle_cache[kb]) except Exception as e: print(f" [{pw.worker_id}] ERRORE: {e}") # Status periodico now = datetime.now(timezone.utc) if now.minute == 0 and now.second < poll_seconds: lines = [f"📊 Status {now.strftime('%H:%M')} UTC"] for w in regular_workers: lines.append(f" {w.status_summary}") for mw in ml_workers: lines.append(f" {mw.worker.status_summary} [ML]") for pw in pairs_workers: lines.append(f" {pw.status_summary} [PAIRS]") send_telegram("\n".join(lines)) except KeyboardInterrupt: print("\nShutdown...") for w in regular_workers: if w.in_position: df = candle_cache.get((w.asset, w.tf)) if df is not None and not df.empty: w._close_position(float(df["close"].iloc[-1]), "shutdown") w._save_state() for mw in ml_workers: if mw.worker.in_position: df = candle_cache.get((mw.worker.asset, mw.worker.tf)) if df is not None and not df.empty: mw.worker._close_position(float(df["close"].iloc[-1]), "shutdown") mw.worker._save_state() for pw in pairs_workers: # salva stato; non forzo la chiusura a 2 gambe pw._save_state() send_telegram("🛑 Multi-Strategy arrestato") break except Exception as e: print(f" ERRORE GLOBALE: {e}") import traceback traceback.print_exc() time.sleep(poll_seconds) if __name__ == "__main__": run()