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/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
147
backend/app/api/routes/backtest.py
Normal file
147
backend/app/api/routes/backtest.py
Normal 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
|
||||
]
|
||||
46
backend/app/api/routes/bot.py
Normal file
46
backend/app/api/routes/bot.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/bot", tags=["bot"])
|
||||
|
||||
# Instance globale du bot (initialisée dans main.py)
|
||||
_bot_runner = None
|
||||
|
||||
|
||||
def set_bot_runner(runner) -> None:
|
||||
global _bot_runner
|
||||
_bot_runner = runner
|
||||
|
||||
|
||||
class BotStartRequest(BaseModel):
|
||||
instrument: str = ""
|
||||
granularity: str = ""
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def bot_status():
|
||||
if _bot_runner is None:
|
||||
return {"running": False, "message": "Bot non initialisé"}
|
||||
return _bot_runner.get_status()
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_bot(req: BotStartRequest):
|
||||
if _bot_runner is None:
|
||||
raise HTTPException(503, "Bot non initialisé")
|
||||
if req.instrument:
|
||||
_bot_runner.instrument = req.instrument
|
||||
if req.granularity:
|
||||
_bot_runner.granularity = req.granularity
|
||||
await _bot_runner.start()
|
||||
return {"message": "Bot démarré", "status": _bot_runner.get_status()}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_bot():
|
||||
if _bot_runner is None:
|
||||
raise HTTPException(503, "Bot non initialisé")
|
||||
await _bot_runner.stop()
|
||||
return {"message": "Bot arrêté"}
|
||||
30
backend/app/api/routes/candles.py
Normal file
30
backend/app/api/routes/candles.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.market_data import MarketDataService
|
||||
|
||||
router = APIRouter(prefix="/candles", tags=["candles"])
|
||||
|
||||
VALID_GRANULARITIES = {"M1", "M5", "M15", "M30", "H1", "H4", "D"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_candles(
|
||||
instrument: str = Query(default="EUR_USD", description="Ex: EUR_USD, GBP_USD, SPX500_USD"),
|
||||
granularity: str = Query(default="H1", description="M1, M5, M15, M30, H1, H4, D"),
|
||||
count: int = Query(default=200, ge=10, le=5000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if granularity not in VALID_GRANULARITIES:
|
||||
raise HTTPException(400, f"Granularité invalide. Valides: {VALID_GRANULARITIES}")
|
||||
|
||||
service = MarketDataService(db)
|
||||
df = await service.get_candles(instrument, granularity, count)
|
||||
|
||||
return {
|
||||
"instrument": instrument,
|
||||
"granularity": granularity,
|
||||
"count": len(df),
|
||||
"candles": df.to_dict(orient="records") if not df.empty else [],
|
||||
}
|
||||
53
backend/app/api/routes/trades.py
Normal file
53
backend/app/api/routes/trades.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.trade import Trade
|
||||
|
||||
router = APIRouter(prefix="/trades", tags=["trades"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_trades(
|
||||
source: Optional[str] = Query(default=None, description="live | backtest"),
|
||||
status: Optional[str] = Query(default=None, description="open | closed"),
|
||||
instrument: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Trade).order_by(Trade.opened_at.desc()).limit(limit)
|
||||
if source:
|
||||
stmt = stmt.where(Trade.source == source)
|
||||
if status:
|
||||
stmt = stmt.where(Trade.status == status)
|
||||
if instrument:
|
||||
stmt = stmt.where(Trade.instrument == instrument)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
trades = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": len(trades),
|
||||
"trades": [
|
||||
{
|
||||
"id": t.id,
|
||||
"source": t.source,
|
||||
"instrument": t.instrument,
|
||||
"direction": t.direction,
|
||||
"units": t.units,
|
||||
"entry_price": t.entry_price,
|
||||
"stop_loss": t.stop_loss,
|
||||
"take_profit": t.take_profit,
|
||||
"exit_price": t.exit_price,
|
||||
"pnl": t.pnl,
|
||||
"status": t.status,
|
||||
"signal_type": t.signal_type,
|
||||
"opened_at": str(t.opened_at),
|
||||
"closed_at": str(t.closed_at) if t.closed_at else None,
|
||||
}
|
||||
for t in trades
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user