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

@@ -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