feat: trading bot MVP — ICT Order Block + Liquidity Sweep strategy
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>
This commit is contained in:
155
backend/app/core/bot.py
Normal file
155
backend/app/core/bot.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
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(),
|
||||
}
|
||||
Reference in New Issue
Block a user