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,147 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.backtester import Backtester
from app.core.database import get_db
from app.core.strategy.order_block_sweep import OrderBlockSweepParams, OrderBlockSweepStrategy
from app.models.backtest_result import BacktestResult
from app.services.market_data import MarketDataService
router = APIRouter(prefix="/backtest", tags=["backtest"])
class BacktestRequest(BaseModel):
instrument: str = Field(default="EUR_USD")
granularity: str = Field(default="H1")
candle_count: int = Field(default=500, ge=100, le=5000)
initial_balance: float = Field(default=10_000.0, gt=0)
risk_percent: float = Field(default=1.0, gt=0, le=10)
rr_ratio: float = Field(default=2.0, ge=1.0, le=10.0)
swing_strength: int = Field(default=5, ge=2, le=20)
liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5)
spread_pips: float = Field(default=1.5, ge=0)
@router.post("")
async def run_backtest(
req: BacktestRequest,
db: AsyncSession = Depends(get_db),
):
service = MarketDataService(db)
# Fetch données historiques (yfinance + TwelveData + cache DB)
try:
df = await service.get_candles(req.instrument, req.granularity, req.candle_count)
except Exception as e:
raise HTTPException(502, f"Erreur fetch données: {e}")
if len(df) < 150:
raise HTTPException(400, "Pas assez de données (min 150 bougies)")
# Configurer la stratégie
params = OrderBlockSweepParams(
swing_strength=req.swing_strength,
liquidity_tolerance_pips=req.liquidity_tolerance_pips,
rr_ratio=req.rr_ratio,
)
strategy = OrderBlockSweepStrategy(params)
# Lancer le backtest
backtester = Backtester(
strategy=strategy,
initial_balance=req.initial_balance,
risk_percent=req.risk_percent,
spread_pips=req.spread_pips,
)
metrics = backtester.run(df)
# Sauvegarder en BDD
result = BacktestResult(
instrument=req.instrument,
granularity=req.granularity,
start_date=df.iloc[0]["time"],
end_date=df.iloc[-1]["time"],
initial_balance=metrics.initial_balance,
final_balance=metrics.final_balance,
total_pnl=metrics.total_pnl,
total_trades=metrics.total_trades,
winning_trades=metrics.winning_trades,
losing_trades=metrics.losing_trades,
win_rate=metrics.win_rate,
max_drawdown=metrics.max_drawdown,
sharpe_ratio=metrics.sharpe_ratio,
expectancy=metrics.expectancy,
equity_curve=metrics.equity_curve,
strategy_params=strategy.get_params(),
)
db.add(result)
await db.commit()
await db.refresh(result)
# Formater les trades pour la réponse
trades_out = [
{
"direction": t.direction,
"entry_price": t.entry_price,
"exit_price": t.exit_price,
"stop_loss": t.stop_loss,
"take_profit": t.take_profit,
"entry_time": str(t.entry_time),
"exit_time": str(t.exit_time) if t.exit_time else None,
"pnl_pips": t.pnl_pips,
"status": t.status,
"signal_type": t.signal_type,
}
for t in metrics.trades
]
return {
"backtest_id": result.id,
"instrument": req.instrument,
"granularity": req.granularity,
"period": {"start": str(df.iloc[0]["time"]), "end": str(df.iloc[-1]["time"])},
"metrics": {
"initial_balance": metrics.initial_balance,
"final_balance": round(metrics.final_balance, 2),
"total_pnl": round(metrics.total_pnl, 2),
"total_pnl_pct": round(metrics.total_pnl_pct, 2),
"total_trades": metrics.total_trades,
"winning_trades": metrics.winning_trades,
"losing_trades": metrics.losing_trades,
"win_rate": round(metrics.win_rate, 1),
"avg_win_pips": round(metrics.avg_win_pips, 1),
"avg_loss_pips": round(metrics.avg_loss_pips, 1),
"expectancy": round(metrics.expectancy, 2),
"max_drawdown": round(metrics.max_drawdown, 2),
"max_drawdown_pct": round(metrics.max_drawdown_pct, 2),
"sharpe_ratio": round(metrics.sharpe_ratio, 3),
"profit_factor": round(metrics.profit_factor, 2),
},
"equity_curve": metrics.equity_curve,
"trades": trades_out,
}
@router.get("/history")
async def get_backtest_history(db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
stmt = select(BacktestResult).order_by(BacktestResult.created_at.desc()).limit(20)
result = await db.execute(stmt)
rows = result.scalars().all()
return [
{
"id": r.id,
"instrument": r.instrument,
"granularity": r.granularity,
"total_pnl": r.total_pnl,
"win_rate": r.win_rate,
"total_trades": r.total_trades,
"sharpe_ratio": r.sharpe_ratio,
"created_at": str(r.created_at),
}
for r in rows
]