14522262e6
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""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): "<COIN>-PERPETUAL"
|
|
# - altcoin = perpetui LINEARI USDC (margine USDC): "<COIN>_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()
|