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>
205 lines
7.0 KiB
Python
205 lines
7.0 KiB
Python
"""
|
|
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
|