"""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}")