""" SimulatedExchange — paper trading 100% local, sans broker. Les ordres sont simulés en mémoire. Les prix viennent du MarketDataService (yfinance + TwelveData + cache DB). """ import logging import uuid from datetime import datetime, timezone from typing import Optional import pandas as pd from app.core.exchange.base import ( AbstractExchange, AccountInfo, OpenTrade, OrderResult, ) logger = logging.getLogger(__name__) class SimulatedExchange(AbstractExchange): """ Exchange simulé pour le paper trading. Les positions sont gardées en mémoire (réinitialisées au redémarrage). Les trades fermés sont persistés en DB via le BotRunner. """ def __init__( self, market_data_service, # MarketDataService — évite l'import circulaire initial_balance: float = 10_000.0, ) -> None: self._market_data = market_data_service self._balance = initial_balance self._initial_balance = initial_balance self._open_trades: dict[str, OpenTrade] = {} # ── AbstractExchange interface ──────────────────────────────────────────── async def get_candles( self, instrument: str, granularity: str, count: int = 200, from_time=None, to_time=None, ) -> pd.DataFrame: return await self._market_data.get_candles( instrument, granularity, count, start=from_time, end=to_time, ) async def place_order( self, instrument: str, units: float, stop_loss: float, take_profit: float, ) -> OrderResult: price = await self._market_data.get_latest_price(instrument) if price is None: raise ValueError(f"Prix introuvable pour {instrument}") direction = "buy" if units > 0 else "sell" trade_id = f"SIM-{uuid.uuid4().hex[:8].upper()}" now = datetime.now(timezone.utc).replace(tzinfo=None) trade = OpenTrade( trade_id=trade_id, instrument=instrument, direction=direction, units=abs(units), entry_price=price, stop_loss=stop_loss, take_profit=take_profit, unrealized_pnl=0.0, opened_at=now, ) self._open_trades[trade_id] = trade logger.info( "[SIM] Ordre ouvert %s %s %.2f @ %.5f | SL=%.5f TP=%.5f", direction.upper(), instrument, abs(units), price, stop_loss, take_profit, ) return OrderResult( trade_id=trade_id, instrument=instrument, direction=direction, units=abs(units), entry_price=price, stop_loss=stop_loss, take_profit=take_profit, opened_at=now, ) async def close_trade(self, trade_id: str) -> float: trade = self._open_trades.pop(trade_id, None) if trade is None: raise ValueError(f"Trade {trade_id} introuvable") price = await self._market_data.get_latest_price(trade.instrument) if price is None: price = trade.entry_price pnl = self._calc_pnl(trade, price) self._balance += pnl logger.info( "[SIM] Trade fermé %s %s @ %.5f | PnL=%.2f | Balance=%.2f", trade_id, trade.instrument, price, pnl, self._balance, ) return pnl async def get_open_trades(self) -> list[OpenTrade]: # Mettre à jour le PnL flottant updated: list[OpenTrade] = [] for trade in self._open_trades.values(): price = await self._market_data.get_latest_price(trade.instrument) if price is not None: pnl = self._calc_pnl(trade, price) updated.append(OpenTrade( trade_id=trade.trade_id, instrument=trade.instrument, direction=trade.direction, units=trade.units, entry_price=trade.entry_price, stop_loss=trade.stop_loss, take_profit=trade.take_profit, unrealized_pnl=pnl, opened_at=trade.opened_at, )) else: updated.append(trade) return updated async def get_account_info(self) -> AccountInfo: open_trades = await self.get_open_trades() unrealized = sum(t.unrealized_pnl for t in open_trades) return AccountInfo( balance=self._balance, nav=self._balance + unrealized, unrealized_pnl=unrealized, currency="USD", ) async def get_price(self, instrument: str) -> float: price = await self._market_data.get_latest_price(instrument) if price is None: raise ValueError(f"Prix introuvable pour {instrument}") return price # ── Simulation du tick (appelée par BotRunner) ──────────────────────────── async def check_sl_tp(self, instrument: str) -> list[tuple[str, float]]: """ Vérifie si SL ou TP sont touchés pour les positions ouvertes. Retourne la liste des (trade_id, pnl) des positions fermées. """ price = await self._market_data.get_latest_price(instrument) if price is None: return [] closed: list[tuple[str, float]] = [] for trade_id, trade in list(self._open_trades.items()): if trade.instrument != instrument: continue hit = self._is_sl_tp_hit(trade, price) if hit: exit_price = trade.stop_loss if hit == "sl" else trade.take_profit pnl = self._calc_pnl(trade, exit_price) self._balance += pnl del self._open_trades[trade_id] logger.info( "[SIM] %s touché — %s %s | PnL=%.2f", hit.upper(), trade_id, trade.instrument, pnl, ) closed.append((trade_id, pnl)) return closed # ── Helpers ─────────────────────────────────────────────────────────────── def _calc_pnl(self, trade: OpenTrade, exit_price: float) -> float: if trade.direction == "buy": return (exit_price - trade.entry_price) * trade.units return (trade.entry_price - exit_price) * trade.units def _is_sl_tp_hit(self, trade: OpenTrade, current_price: float) -> Optional[str]: if trade.direction == "buy": if current_price <= trade.stop_loss: return "sl" if current_price >= trade.take_profit: return "tp" else: if current_price >= trade.stop_loss: return "sl" if current_price <= trade.take_profit: return "tp" return None