fix: aligner les candles du chart avec la période du backtest

Bug: les markers BUY/SELL du chart utilisaient les timestamps des trades
du backtest mais les candles étaient fetchées séparément (500 candles récentes),
causant un désalignement visuel.

- Backend: /backtest retourne désormais les candles exactes du DataFrame analysé
- Frontend: Backtest.tsx utilise result.candles directement (suppression du
  fetchCandles séparé)
- Ajout: sérialisation reason/OB/LL sur les trades, overlays OB/LL bornés
  dans le temps, trade reasons expandables, filtre HTF, badge tendance HTF

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 01:23:42 +01:00
parent 4df8d53b1a
commit adbc41102e
10 changed files with 698 additions and 95 deletions

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -8,6 +7,7 @@ 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.core.strategy.htf_trend_filter import HTFTrendFilter, HTFTrendFilterParams, HTF_MAP
from app.models.backtest_result import BacktestResult
from app.services.market_data import MarketDataService
@@ -24,6 +24,11 @@ class BacktestRequest(BaseModel):
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)
# Sélection des stratégies et filtres
strategies: list[str] = Field(default=["order_block_sweep"])
filters: list[str] = Field(default=[])
htf_ema_fast: int = Field(default=20, ge=5, le=100)
htf_ema_slow: int = Field(default=50, ge=10, le=200)
@router.post("")
@@ -42,13 +47,45 @@ async def run_backtest(
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)
# Construire la stratégie selon la sélection
strategies = []
if "order_block_sweep" in req.strategies:
params = OrderBlockSweepParams(
swing_strength=req.swing_strength,
liquidity_tolerance_pips=req.liquidity_tolerance_pips,
rr_ratio=req.rr_ratio,
)
strategies.append(OrderBlockSweepStrategy(params))
if not strategies:
raise HTTPException(400, "Au moins une stratégie doit être sélectionnée")
# Pour l'instant une seule stratégie supportée
strategy = strategies[0]
# Construire les filtres
active_filters = []
htf_trend_info: Optional[dict] = None
if "htf_trend_filter" in req.filters:
htf_gran = HTF_MAP.get(req.granularity, req.granularity)
try:
htf_df = await service.get_candles(req.instrument, htf_gran, 200)
except Exception as e:
raise HTTPException(502, f"Erreur fetch données HTF ({htf_gran}): {e}")
htf_filter = HTFTrendFilter(HTFTrendFilterParams(
ema_fast=req.htf_ema_fast,
ema_slow=req.htf_ema_slow,
))
trend = htf_filter.determine_trend(htf_df)
active_filters.append(htf_filter)
htf_trend_info = {
"htf_granularity": htf_gran,
"trend": trend,
"ema_fast": req.htf_ema_fast,
"ema_slow": req.htf_ema_slow,
}
# Lancer le backtest
backtester = Backtester(
@@ -56,10 +93,15 @@ async def run_backtest(
initial_balance=req.initial_balance,
risk_percent=req.risk_percent,
spread_pips=req.spread_pips,
filters=active_filters,
)
metrics = backtester.run(df)
# Sauvegarder en BDD
strategy_params = strategy.get_params()
if htf_trend_info:
strategy_params["htf_trend_filter"] = htf_trend_info
result = BacktestResult(
instrument=req.instrument,
granularity=req.granularity,
@@ -76,7 +118,7 @@ async def run_backtest(
sharpe_ratio=metrics.sharpe_ratio,
expectancy=metrics.expectancy,
equity_curve=metrics.equity_curve,
strategy_params=strategy.get_params(),
strategy_params=strategy_params,
)
db.add(result)
await db.commit()
@@ -95,10 +137,18 @@ async def run_backtest(
"pnl_pips": t.pnl_pips,
"status": t.status,
"signal_type": t.signal_type,
"reason": t.reason,
"order_block": t.order_block,
"liquidity_level": t.liquidity_level,
}
for t in metrics.trades
]
# Candles utilisées pour le backtest (pour le chart frontend)
candles_out = df[["time", "open", "high", "low", "close", "volume"]].copy()
candles_out["time"] = candles_out["time"].astype(str)
candles_list = candles_out.to_dict(orient="records")
return {
"backtest_id": result.id,
"instrument": req.instrument,
@@ -123,6 +173,31 @@ async def run_backtest(
},
"equity_curve": metrics.equity_curve,
"trades": trades_out,
"candles": candles_list,
"order_blocks": metrics.all_order_blocks,
"liquidity_levels": metrics.all_liquidity_levels,
"htf_trend": htf_trend_info,
}
@router.get("/strategies")
async def list_strategies():
"""Retourne les stratégies et filtres disponibles."""
return {
"strategies": [
{
"name": "order_block_sweep",
"label": "Order Block + Liquidity Sweep",
"description": "Stratégie ICT : détection Equal H/L, sweep et entrée sur OB",
},
],
"filters": [
{
"name": "htf_trend_filter",
"label": "Filtre tendance HTF",
"description": "Ne prendre que les trades dans le sens de la tendance du timeframe supérieur (EMA crossover)",
},
],
}