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>
148 lines
5.2 KiB
Python
148 lines
5.2 KiB
Python
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
|
|
]
|