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:
0
backend/app/core/exchange/__init__.py
Normal file
0
backend/app/core/exchange/__init__.py
Normal file
88
backend/app/core/exchange/base.py
Normal file
88
backend/app/core/exchange/base.py
Normal 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."""
|
||||
...
|
||||
171
backend/app/core/exchange/oanda.py
Normal file
171
backend/app/core/exchange/oanda.py
Normal 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
|
||||
204
backend/app/core/exchange/simulated.py
Normal file
204
backend/app/core/exchange/simulated.py
Normal 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
|
||||
Reference in New Issue
Block a user