Full-stack trading bot with: - FastAPI backend with ICT strategy (Order Block + Liquidity Sweep detection) - Backtester engine with rolling window, spread simulation, and performance metrics - Hybrid market data service (yfinance + TwelveData with rate limiting + SQLite cache) - Simulated exchange for paper trading - React/TypeScript frontend with TradingView lightweight-charts v5 - Live dashboard with candlestick chart, OHLC legend, trade markers - Backtest page with configurable parameters, equity curve, and trade table - WebSocket support for real-time updates - Bot runner with asyncio loop for automated trading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
156 lines
5.2 KiB
Python
156 lines
5.2 KiB
Python
"""
|
|
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(),
|
|
}
|