""" BotRunner — boucle de trading live asynchrone. Cycle : 1. Fetch les N dernières bougies 2. Analyser la stratégie 3. Si signal + pas de position ouverte → exécuter 4. Broadcast l'état via WebSocket 5. Attendre le prochain tick """ import asyncio import logging from datetime import datetime from typing import Callable, Optional from app.core.config import settings from app.core.exchange.base import AbstractExchange from app.core.strategy.base import AbstractStrategy, AnalysisResult from app.services.trade_manager import TradeManager logger = logging.getLogger(__name__) # Interval en secondes selon la granularité GRANULARITY_SECONDS = { "M1": 60, "M5": 300, "M15": 900, "M30": 1800, "H1": 3600, "H2": 7200, "H4": 14400, "H6": 21600, "H8": 28800, "H12": 43200, "D": 86400, } class BotRunner: def __init__( self, exchange: AbstractExchange, strategy: AbstractStrategy, trade_manager: TradeManager, instrument: str = "", granularity: str = "", candles_window: int = 150, ) -> None: self._exchange = exchange self._strategy = strategy self._trade_manager = trade_manager self.instrument = instrument or settings.bot_default_instrument self.granularity = granularity or settings.bot_default_granularity self.candles_window = candles_window self._running = False self._task: Optional[asyncio.Task] = None self._last_analysis: Optional[AnalysisResult] = None self._started_at: Optional[datetime] = None # Callback pour broadcaster les événements (WebSocket) self._on_event: Optional[Callable] = None @property def is_running(self) -> bool: return self._running def set_event_callback(self, callback: Callable) -> None: self._on_event = callback async def start(self) -> None: if self._running: logger.warning("Bot déjà en cours d'exécution") return self._running = True self._started_at = datetime.utcnow() logger.info("Bot démarré — %s %s", self.instrument, self.granularity) self._task = asyncio.create_task(self._loop()) async def stop(self) -> None: self._running = False if self._task: self._task.cancel() try: await self._task except asyncio.CancelledError: pass logger.info("Bot arrêté") async def _loop(self) -> None: interval = GRANULARITY_SECONDS.get(self.granularity, 3600) # Décaler d'un peu pour s'assurer que la bougie est fermée wait_seconds = max(interval // 10, 5) while self._running: try: await self._tick() except Exception as e: logger.error("Erreur dans la boucle du bot: %s", e, exc_info=True) await asyncio.sleep(wait_seconds) async def _tick(self) -> None: # 1. Fetch candles df = await self._exchange.get_candles( self.instrument, self.granularity, self.candles_window ) if df.empty: return # 2. Analyser result = self._strategy.analyze(df) self._last_analysis = result # 3. Vérifier les positions ouvertes open_trades = await self._exchange.get_open_trades() instrument_trades = [t for t in open_trades if t.instrument == self.instrument] event = { "type": "tick", "instrument": self.instrument, "granularity": self.granularity, "last_close": float(df.iloc[-1]["close"]), "last_time": str(df.iloc[-1]["time"]), "order_blocks": len(result.order_blocks), "liquidity_levels": len(result.liquidity_levels), "signals": len(result.signals), "open_positions": len(instrument_trades), } # 4. Exécuter le signal si aucune position ouverte if result.signals and not instrument_trades: signal = result.signals[-1] try: order = await self._trade_manager.execute_signal(signal) if order: logger.info( "Trade exécuté : %s %s @ %.5f", signal.direction, self.instrument, order.entry_price, ) event["new_trade"] = { "direction": order.direction, "entry_price": order.entry_price, "stop_loss": order.stop_loss, "take_profit": order.take_profit, } except Exception as e: logger.error("Erreur exécution trade: %s", e) # 5. Broadcast if self._on_event: await self._on_event(event) def get_status(self) -> dict: return { "running": self._running, "instrument": self.instrument, "granularity": self.granularity, "started_at": str(self._started_at) if self._started_at else None, "strategy": self._strategy.__class__.__name__, "strategy_params": self._strategy.get_params(), }