"""Paper trader: loop principale che monitora, segnala e opera su Deribit testnet.""" from __future__ import annotations import json import time from datetime import datetime, timedelta, timezone from pathlib import Path import pandas as pd from src.live.cerbero_client import CerberoClient from src.live.signal_engine import SignalEngine from src.live.telegram_notifier import notify_event LOG_DIR = Path(__file__).resolve().parents[2] / "data" / "paper_trades" INSTRUMENT = "ETH_USDC-PERPETUAL" TRAIN_INSTRUMENT = "ETH-PERPETUAL" CURRENCY = "USDC" RESOLUTION = "15" LEVERAGE = 3 POSITION_PCT = 0.15 HOLD_BARS = 3 POLL_SECONDS = 60 LOOKBACK_DAYS = 60 TRAIN_LOOKBACK_DAYS = 365 VIRTUAL_CAPITAL = 1000.0 # simula capitale reale, ignora balance testnet class PaperTrader: def __init__(self): self.client = CerberoClient() self.engine = SignalEngine(bb_w=14, sq_thr=0.8, ml_thr=0.70) self.virtual_capital = VIRTUAL_CAPITAL self.in_position = False self.position_entry_time: datetime | None = None self.position_direction: str | None = None self.position_entry_price: float = 0 self.position_size: float = 0 self.bars_held = 0 self.last_bar_ts: int = 0 LOG_DIR.mkdir(parents=True, exist_ok=True) self.log_path = LOG_DIR / f"trades_{datetime.now().strftime('%Y%m%d_%H%M%S')}.jsonl" self.status_path = LOG_DIR / "status.json" def log(self, event: str, data: dict | None = None): entry = { "timestamp": datetime.now(timezone.utc).isoformat(), "event": event, **(data or {}), } with open(self.log_path, "a") as f: f.write(json.dumps(entry) + "\n") print(f" [{entry['timestamp'][:19]}] {event}: {json.dumps(data or {})}") notify_event(event, data) def save_status(self): status = { "virtual_capital": round(self.virtual_capital, 2), "in_position": self.in_position, "direction": self.position_direction, "entry_price": self.position_entry_price, "position_size": self.position_size, "entry_time": self.position_entry_time.isoformat() if self.position_entry_time else None, "bars_held": self.bars_held, "last_update": datetime.now(timezone.utc).isoformat(), } with open(self.status_path, "w") as f: json.dump(status, f, indent=2) def fetch_candles(self, days: int = LOOKBACK_DAYS, instrument: str | None = None) -> pd.DataFrame: end = datetime.now(timezone.utc) start = end - timedelta(days=days) candles = self.client.get_historical( instrument or TRAIN_INSTRUMENT, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"), RESOLUTION, ) if not candles: return pd.DataFrame() df = pd.DataFrame(candles) df["timestamp"] = df["timestamp"].astype("int64") df = df.sort_values("timestamp").reset_index(drop=True) return df def train_model(self): self.log("TRAINING", {"lookback_days": TRAIN_LOOKBACK_DAYS, "instrument": TRAIN_INSTRUMENT}) df = self.fetch_candles(TRAIN_LOOKBACK_DAYS, TRAIN_INSTRUMENT) if df.empty: self.log("TRAINING_FAILED", {"reason": "no data"}) return False result = self.engine.train(df, lookahead=HOLD_BARS) self.log("TRAINING_DONE", result) return "error" not in result def open_position(self, direction: str, signal: dict): ticker = self.client.get_ticker(INSTRUMENT) price = ticker["last_price"] notional = self.virtual_capital * POSITION_PCT * LEVERAGE amount = round(notional / price, 3) amount = max(amount, 0.001) side = "buy" if direction == "buy" else "sell" self.log("OPENING", { "side": side, "amount": amount, "price": price, "virtual_capital": round(self.virtual_capital, 2), "notional": round(notional, 2), "signal": signal, }) try: result = self.client.place_order( instrument=INSTRUMENT, side=side, amount=amount, order_type="market", leverage=LEVERAGE, label="pythagoras-squeeze", ) self.in_position = True self.position_direction = side self.position_entry_price = price self.position_size = amount self.position_entry_time = datetime.now(timezone.utc) self.bars_held = 0 self.log("OPENED", {"order_result": result}) except Exception as e: self.log("OPEN_FAILED", {"error": str(e)}) def close_current_position(self, reason: str): if not self.in_position: return ticker = self.client.get_ticker(INSTRUMENT) exit_price = ticker["last_price"] if self.position_direction == "buy": trade_pnl = (exit_price - self.position_entry_price) * self.position_size else: trade_pnl = (self.position_entry_price - exit_price) * self.position_size fee = self.position_size * (self.position_entry_price + exit_price) * 0.001 net_pnl = trade_pnl - fee pnl_pct = net_pnl / self.virtual_capital * 100 self.log("CLOSING", { "reason": reason, "entry_price": self.position_entry_price, "exit_price": exit_price, "size": self.position_size, "trade_pnl": round(trade_pnl, 2), "fee": round(fee, 2), "net_pnl": round(net_pnl, 2), "pnl_pct": round(pnl_pct, 3), "bars_held": self.bars_held, "capital_before": round(self.virtual_capital, 2), }) try: result = self.client.close_position(INSTRUMENT) self.virtual_capital += net_pnl self.log("CLOSED", { "result": result, "net_pnl": round(net_pnl, 2), "pnl_pct": round(pnl_pct, 3), "virtual_capital": round(self.virtual_capital, 2), }) except Exception as e: self.log("CLOSE_FAILED", {"error": str(e)}) self.in_position = False self.position_direction = None self.position_entry_price = 0 self.position_size = 0 self.position_entry_time = None self.bars_held = 0 def check_position_exit(self, df: pd.DataFrame): if not self.in_position: return current_ts = df["timestamp"].iloc[-1] if current_ts > self.last_bar_ts: self.bars_held += 1 self.last_bar_ts = current_ts if self.bars_held >= HOLD_BARS: self.close_current_position("hold_limit") return price = df["close"].iloc[-1] if self.position_direction == "buy": pnl_pct = (price - self.position_entry_price) / self.position_entry_price else: pnl_pct = (self.position_entry_price - price) / self.position_entry_price if pnl_pct <= -0.02: self.close_current_position("stop_loss_2pct") def run_once(self) -> str: """Esegui un singolo ciclo. Ritorna lo stato.""" df = self.fetch_candles(LOOKBACK_DAYS, TRAIN_INSTRUMENT) if df.empty: return "no_data" if self.in_position: self.check_position_exit(df) self.save_status() if self.in_position: return f"in_position_{self.position_direction}_bar{self.bars_held}" return "position_closed" signal = self.engine.check_signal(df) if signal: self.log("SIGNAL", signal) self.open_position(signal["direction"], signal) self.save_status() return f"signal_{signal['direction']}" self.save_status() return "watching" def run(self, retrain_hours: int = 24): """Loop principale.""" print("=" * 60) print(f" PAPER TRADER — {INSTRUMENT} (margine {CURRENCY})") print(f" Segnali da: {TRAIN_INSTRUMENT} {RESOLUTION}m") print(f" Leva: {LEVERAGE}x, Position: {POSITION_PCT*100:.0f}%, Hold: {HOLD_BARS} barre") print(f" Poll: ogni {POLL_SECONDS}s") print(f" Log: {self.log_path}") print("=" * 60) account = self.client.get_account_summary() self.log("STARTUP", { "virtual_capital": self.virtual_capital, "testnet_equity": account["equity"], "testnet": account.get("testnet", True), }) if not self.train_model(): print("Training fallito. Uscita.") return last_train = datetime.now(timezone.utc) while True: try: now = datetime.now(timezone.utc) if (now - last_train).total_seconds() > retrain_hours * 3600: self.train_model() last_train = now status = self.run_once() if status != "watching": print(f" → {status}") except KeyboardInterrupt: self.log("SHUTDOWN", {"reason": "keyboard"}) if self.in_position: self.close_current_position("shutdown") break except Exception as e: self.log("ERROR", {"error": str(e)}) print(f" ERRORE: {e}") time.sleep(POLL_SECONDS) if __name__ == "__main__": trader = PaperTrader() trader.run()