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:
2026-02-24 23:25:51 +01:00
commit 4df8d53b1a
58 changed files with 7484 additions and 0 deletions

View File

View File

@@ -0,0 +1,88 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import pandas as pd
@dataclass
class OrderResult:
trade_id: str
instrument: str
direction: str # "buy" | "sell"
units: float
entry_price: float
stop_loss: float
take_profit: float
opened_at: datetime
@dataclass
class OpenTrade:
trade_id: str
instrument: str
direction: str
units: float
entry_price: float
stop_loss: float
take_profit: float
unrealized_pnl: float
opened_at: datetime
@dataclass
class AccountInfo:
balance: float
nav: float # Net Asset Value
unrealized_pnl: float
currency: str
class AbstractExchange(ABC):
"""Interface commune pour tous les exchanges/brokers."""
@abstractmethod
async def get_candles(
self,
instrument: str,
granularity: str,
count: int = 200,
from_time: Optional[datetime] = None,
to_time: Optional[datetime] = None,
) -> pd.DataFrame:
"""
Retourne un DataFrame avec colonnes : time, open, high, low, close, volume.
"""
...
@abstractmethod
async def place_order(
self,
instrument: str,
units: float, # positif = buy, négatif = sell
stop_loss: float,
take_profit: float,
) -> OrderResult:
"""Place un ordre au marché avec SL et TP."""
...
@abstractmethod
async def close_trade(self, trade_id: str) -> float:
"""Ferme un trade par son ID. Retourne le PnL réalisé."""
...
@abstractmethod
async def get_open_trades(self) -> list[OpenTrade]:
"""Retourne la liste des positions ouvertes."""
...
@abstractmethod
async def get_account_info(self) -> AccountInfo:
"""Retourne les infos du compte (solde, PnL, etc.)."""
...
@abstractmethod
async def get_price(self, instrument: str) -> float:
"""Retourne le prix mid actuel."""
...

View File

@@ -0,0 +1,171 @@
from datetime import datetime, timezone
from typing import Optional
import pandas as pd
from oandapyV20 import API
from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing
from app.core.config import settings
from app.core.exchange.base import (
AbstractExchange,
AccountInfo,
OpenTrade,
OrderResult,
)
# Granularités OANDA valides
GRANULARITIES = {
"M1", "M5", "M15", "M30",
"H1", "H2", "H4", "H6", "H8", "H12",
"D", "W", "M",
}
# Nombre max de candles par requête OANDA
MAX_CANDLES_PER_REQUEST = 5000
def _parse_time(ts: str) -> datetime:
"""Parse RFC3339 timestamp OANDA → datetime UTC."""
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
class OandaExchange(AbstractExchange):
def __init__(self) -> None:
env = "practice" if settings.oanda_environment == "practice" else "live"
self._client = API(
access_token=settings.oanda_api_key,
environment=env,
)
self._account_id = settings.oanda_account_id
async def get_candles(
self,
instrument: str,
granularity: str = "H1",
count: int = 200,
from_time: Optional[datetime] = None,
to_time: Optional[datetime] = None,
) -> pd.DataFrame:
if granularity not in GRANULARITIES:
raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}")
params: dict = {"granularity": granularity, "price": "M"}
if from_time and to_time:
params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ")
params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ")
else:
params["count"] = min(count, MAX_CANDLES_PER_REQUEST)
r = instruments.InstrumentsCandles(instrument, params=params)
self._client.request(r)
candles = r.response.get("candles", [])
rows = []
for c in candles:
if c.get("complete", True):
mid = c["mid"]
rows.append({
"time": _parse_time(c["time"]),
"open": float(mid["o"]),
"high": float(mid["h"]),
"low": float(mid["l"]),
"close": float(mid["c"]),
"volume": int(c.get("volume", 0)),
})
df = pd.DataFrame(rows)
if not df.empty:
df.sort_values("time", inplace=True)
df.reset_index(drop=True, inplace=True)
return df
async def place_order(
self,
instrument: str,
units: float,
stop_loss: float,
take_profit: float,
) -> OrderResult:
data = {
"order": {
"type": "MARKET",
"instrument": instrument,
"units": str(int(units)),
"stopLossOnFill": {"price": f"{stop_loss:.5f}"},
"takeProfitOnFill": {"price": f"{take_profit:.5f}"},
"timeInForce": "FOK",
}
}
r = orders.OrderCreate(self._account_id, data=data)
self._client.request(r)
resp = r.response
fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {})
trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown")
entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0))
direction = "buy" if units > 0 else "sell"
return OrderResult(
trade_id=trade_id,
instrument=instrument,
direction=direction,
units=abs(units),
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
opened_at=datetime.now(timezone.utc),
)
async def close_trade(self, trade_id: str) -> float:
r = trades.TradeClose(self._account_id, tradeID=trade_id)
self._client.request(r)
pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0))
return pnl
async def get_open_trades(self) -> list[OpenTrade]:
r = trades.OpenTrades(self._account_id)
self._client.request(r)
result = []
for t in r.response.get("trades", []):
units = float(t["currentUnits"])
direction = "buy" if units > 0 else "sell"
sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0
tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0
result.append(OpenTrade(
trade_id=t["id"],
instrument=t["instrument"],
direction=direction,
units=abs(units),
entry_price=float(t["price"]),
stop_loss=sl,
take_profit=tp,
unrealized_pnl=float(t.get("unrealizedPL", 0)),
opened_at=_parse_time(t["openTime"]),
))
return result
async def get_account_info(self) -> AccountInfo:
r = accounts.AccountSummary(self._account_id)
self._client.request(r)
acc = r.response["account"]
return AccountInfo(
balance=float(acc["balance"]),
nav=float(acc["NAV"]),
unrealized_pnl=float(acc.get("unrealizedPL", 0)),
currency=acc.get("currency", "USD"),
)
async def get_price(self, instrument: str) -> float:
r = pricing.PricingInfo(
self._account_id,
params={"instruments": instrument},
)
self._client.request(r)
prices = r.response.get("prices", [])
if not prices:
raise ValueError(f"Aucun prix pour {instrument}")
bid = float(prices[0]["bids"][0]["price"])
ask = float(prices[0]["asks"][0]["price"])
return (bid + ask) / 2

View File

@@ -0,0 +1,204 @@
"""
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