feat: multi-strategy paper trader — N strategie in parallelo su testnet

- src/live/multi_runner.py: orchestratore con fetch raggruppato per asset/tf
- src/live/strategy_worker.py: worker indipendente con stato persistente JSONL
- src/live/strategy_loader.py: import dinamico classi Strategy
- strategies.yml: config dichiarativa con defaults e override per strategia
- Docker: container unico, strategies.yml montato come volume read-only
- Supporta hot-add: aggiungi riga YAML + restart, storico intatto
- Ogni strategia: €1000 USDC virtuale, equity tracking, Telegram notify

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:12:18 +02:00
parent 0e47956f7a
commit b79c87e4af
9 changed files with 819 additions and 3 deletions
+271
View File
@@ -0,0 +1,271 @@
"""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.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"}
INSTRUMENT_MAP = {
"BTC": "BTC-PERPETUAL",
"ETH": "ETH-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 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)
all_worker_count = len(regular_workers) + len(ml_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]")
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))
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
# Tick regular workers
for w in regular_workers:
key = (w.asset, w.tf)
if key in candle_cache:
try:
w.tick(candle_cache[key])
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}")
# 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]")
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()
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()
+51
View File
@@ -0,0 +1,51 @@
"""Import dinamico delle classi Strategy da scripts/strategies/."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
from src.strategies.base import Strategy
PROJECT_ROOT = Path(__file__).resolve().parents[2]
STRATEGIES_DIR = PROJECT_ROOT / "scripts" / "strategies"
_REGISTRY: dict[str, type[Strategy]] = {}
MODULE_MAP = {
"SQ01_squeeze_base": ("SQ01_squeeze_base", "SqueezeBase"),
"SQ02_antifake_vol": ("SQ02_squeeze_antifake_vol", "SqueezeAntifakeVol"),
"SQ03_filtered": ("SQ03_squeeze_all_filters", "SqueezeFiltered"),
"SQ04_ultimate": ("SQ04_squeeze_ultimate", "SqueezeUltimate"),
"ML01_squeeze_gbm": ("ML01_squeeze_gbm", "SqueezeGBM"),
}
def load_strategy(name: str) -> Strategy:
"""Carica e istanzia una Strategy per nome."""
if name in _REGISTRY:
return _REGISTRY[name]()
if name not in MODULE_MAP:
raise ValueError(f"Strategia sconosciuta: {name}. Disponibili: {list(MODULE_MAP)}")
module_file, class_name = MODULE_MAP[name]
module_path = STRATEGIES_DIR / f"{module_file}.py"
if not module_path.exists():
raise FileNotFoundError(f"File strategia non trovato: {module_path}")
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
spec = importlib.util.spec_from_file_location(f"strategies.{module_file}", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
cls = getattr(module, class_name)
_REGISTRY[name] = cls
return cls()
def list_available() -> list[str]:
return list(MODULE_MAP.keys())
+226
View File
@@ -0,0 +1,226 @@
"""Worker per singola strategia — paper trading con stato persistente."""
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.strategies.base import Strategy, Signal
from src.live.telegram_notifier import notify_event
FEE_RT = 0.002
class StrategyWorker:
"""Gestisce paper trading per una singola strategia/asset/tf."""
def __init__(
self,
strategy: Strategy,
asset: str,
tf: str,
capital: float = 1000.0,
position_size: float = 0.15,
leverage: float = 3.0,
hold_bars: int = 3,
params: dict | None = None,
data_dir: Path = Path("data/paper_trades"),
):
self.strategy = strategy
self.asset = asset
self.tf = tf
self.initial_capital = capital
self.position_size = position_size
self.leverage = leverage
self.hold_bars = hold_bars
self.params = params or {}
self.worker_id = f"{strategy.name}__{asset}__{tf}"
self.work_dir = data_dir / self.worker_id
self.work_dir.mkdir(parents=True, exist_ok=True)
self.trades_path = self.work_dir / "trades.jsonl"
self.status_path = self.work_dir / "status.json"
self.capital = capital
self.in_position = False
self.direction: int = 0
self.entry_price: float = 0
self.entry_time: str = ""
self.bars_held: int = 0
self.total_trades: int = 0
self.total_wins: int = 0
self.started_at = datetime.now(timezone.utc).isoformat()
self.last_bar_ts: int = 0
self._load_state()
self._save_state()
def _load_state(self):
"""Riprende stato da status.json se esiste."""
if not self.status_path.exists():
self._log("INIT", {"capital": self.capital, "strategy": self.strategy.name,
"asset": self.asset, "tf": self.tf})
return
with open(self.status_path) as f:
state = json.load(f)
self.capital = state.get("capital", self.initial_capital)
self.in_position = state.get("in_position", False)
self.direction = state.get("direction", 0)
self.entry_price = state.get("entry_price", 0)
self.entry_time = state.get("entry_time", "")
self.bars_held = state.get("bars_held", 0)
self.total_trades = state.get("total_trades", 0)
self.total_wins = state.get("total_wins", 0)
self.started_at = state.get("started_at", self.started_at)
self.last_bar_ts = state.get("last_bar_ts", 0)
self._log("RESUME", {"capital": round(self.capital, 2),
"total_trades": self.total_trades,
"in_position": self.in_position})
def _save_state(self):
state = {
"capital": round(self.capital, 2),
"in_position": self.in_position,
"direction": self.direction,
"entry_price": self.entry_price,
"entry_time": self.entry_time,
"bars_held": self.bars_held,
"total_trades": self.total_trades,
"total_wins": self.total_wins,
"started_at": self.started_at,
"last_bar_ts": self.last_bar_ts,
"last_update": datetime.now(timezone.utc).isoformat(),
}
with open(self.status_path, "w") as f:
json.dump(state, f, indent=2)
def _log(self, event: str, data: dict | None = None):
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"worker": self.worker_id,
"event": event,
**(data or {}),
}
with open(self.trades_path, "a") as f:
f.write(json.dumps(entry) + "\n")
print(f" [{self.worker_id}] {event}: {json.dumps(data or {}, default=str)}")
def _notify(self, event: str, data: dict | None = None):
enriched = {"worker": self.worker_id, **(data or {})}
notify_event(event, enriched)
def _open_position(self, signal: Signal, current_price: float):
notional = self.capital * self.position_size * self.leverage
size = notional / current_price if current_price > 0 else 0
self.in_position = True
self.direction = signal.direction
self.entry_price = current_price
self.entry_time = datetime.now(timezone.utc).isoformat()
self.bars_held = 0
trade_data = {
"direction": "long" if signal.direction == 1 else "short",
"price": round(current_price, 2),
"size": round(size, 6),
"notional": round(notional, 2),
"capital": round(self.capital, 2),
}
self._log("OPEN", trade_data)
self._notify("OPENED", trade_data)
def _close_position(self, current_price: float, reason: str):
if not self.in_position:
return
price_change = (current_price - self.entry_price) / self.entry_price
trade_return = price_change * self.direction
net = trade_return * self.leverage - FEE_RT * self.leverage
pnl = self.capital * self.position_size * net
is_win = trade_return > 0
self.capital += pnl
self.capital = max(self.capital, 0)
self.total_trades += 1
if is_win:
self.total_wins += 1
accuracy = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
trade_data = {
"reason": reason,
"direction": "long" if self.direction == 1 else "short",
"entry": round(self.entry_price, 2),
"exit": round(current_price, 2),
"pnl": round(pnl, 2),
"net_return": round(net * 100, 3),
"capital": round(self.capital, 2),
"bars_held": self.bars_held,
"win": is_win,
"total_trades": self.total_trades,
"accuracy": round(accuracy, 1),
}
self._log("CLOSE", trade_data)
self._notify("CLOSED", trade_data)
self.in_position = False
self.direction = 0
self.entry_price = 0
self.entry_time = ""
self.bars_held = 0
def tick(self, df: pd.DataFrame):
"""Chiamato ad ogni poll con DataFrame OHLCV aggiornato."""
if df.empty or len(df) < 100:
return
c = df["close"].values
current_price = float(c[-1])
current_ts = int(df["timestamp"].iloc[-1])
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
if self.in_position:
if current_ts > self.last_bar_ts:
self.bars_held += 1
self.last_bar_ts = current_ts
if self.bars_held >= self.hold_bars:
self._close_position(current_price, "hold_limit")
else:
pnl_pct = (current_price - self.entry_price) / self.entry_price * self.direction
if pnl_pct <= -0.02:
self._close_position(current_price, "stop_loss")
self._save_state()
return
# Genera segnali
signals = self.strategy.generate_signals(
df, ts, asset=self.asset, tf=self.tf, **self.params
)
if not signals:
self._save_state()
return
last_signal = signals[-1]
last_idx = len(df) - 1
if last_signal.idx >= last_idx - 1:
self._open_position(last_signal, current_price)
self.last_bar_ts = current_ts
self._save_state()
@property
def status_summary(self) -> str:
acc = self.total_wins / self.total_trades * 100 if self.total_trades > 0 else 0
pos = "LONG" if self.direction == 1 else "SHORT" if self.direction == -1 else "FLAT"
return (f"{self.worker_id}: €{self.capital:.0f} | {self.total_trades}t "
f"{acc:.0f}% | {pos}")