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)",
},
],
}

View File

@@ -31,6 +31,9 @@ class BacktestTrade:
pnl_pct: Optional[float] = None
status: str = "open" # "win" | "loss" | "open"
signal_type: str = ""
reason: Optional[dict] = None
order_block: Optional[dict] = None
liquidity_level: Optional[dict] = None
@dataclass
@@ -52,6 +55,8 @@ class BacktestMetrics:
profit_factor: float
equity_curve: list[dict] = field(default_factory=list)
trades: list[BacktestTrade] = field(default_factory=list)
all_order_blocks: list[dict] = field(default_factory=list)
all_liquidity_levels: list[dict] = field(default_factory=list)
class Backtester:
@@ -62,12 +67,14 @@ class Backtester:
risk_percent: float = 1.0,
spread_pips: float = 1.5,
pip_value: float = 0.0001,
filters: Optional[list] = None,
) -> None:
self.strategy = strategy
self.initial_balance = initial_balance
self.risk_percent = risk_percent # % du capital risqué par trade
self.spread_pips = spread_pips
self.pip_value = pip_value
self._filters = filters or []
def run(
self,
@@ -87,6 +94,10 @@ class Backtester:
last_signal_time: Optional[datetime] = None
used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded)
# Accumuler toutes les zones détectées (dédupliquées par id)
all_obs: dict[str, dict] = {}
all_lls: dict[str, dict] = {}
for i in range(window, len(df)):
candle = df.iloc[i]
@@ -111,10 +122,24 @@ class Backtester:
slice_df = df.iloc[i - window:i + 1]
result = self.strategy.analyze(slice_df)
if result.signals:
# Accumuler les zones détectées
for ob in result.order_blocks:
if ob.id not in all_obs:
all_obs[ob.id] = self._serialize_ob(ob)
for ll in result.liquidity_levels:
if ll.id not in all_lls:
all_lls[ll.id] = self._serialize_ll(ll)
# Appliquer les filtres sur les signaux
signals = result.signals
for f in self._filters:
if hasattr(f, "filter_signals"):
signals = f.filter_signals(signals)
if signals:
# Filtrer les signaux déjà exploités (éviter doublons)
new_signals = [
s for s in result.signals
s for s in signals
if last_signal_time is None or s.time > last_signal_time
]
if not new_signals:
@@ -135,6 +160,9 @@ class Backtester:
take_profit=tp,
entry_time=candle["time"],
signal_type=signal.signal_type,
reason=self._serialize_reason(signal.reason) if signal.reason else None,
order_block=self._serialize_ob(signal.order_block) if signal.order_block else None,
liquidity_level=self._serialize_ll(signal.liquidity_level) if signal.liquidity_level else None,
)
# Fermer la position ouverte en fin de période
@@ -151,7 +179,46 @@ class Backtester:
trades.append(open_trade)
equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance})
return self._compute_metrics(trades, balance, equity_curve)
metrics = self._compute_metrics(trades, balance, equity_curve)
metrics.all_order_blocks = list(all_obs.values())
metrics.all_liquidity_levels = list(all_lls.values())
return metrics
# ─── Serialization ─────────────────────────────────────────────────────────
@staticmethod
def _serialize_reason(reason) -> dict:
return {
"summary": reason.summary,
"swept_level_price": reason.swept_level_price,
"swept_level_direction": reason.swept_level_direction,
"ob_direction": reason.ob_direction,
"ob_top": reason.ob_top,
"ob_bottom": reason.ob_bottom,
"ob_origin_time": reason.ob_origin_time,
"filters_applied": reason.filters_applied,
}
@staticmethod
def _serialize_ob(ob) -> dict:
return {
"id": ob.id,
"direction": ob.direction,
"top": ob.top,
"bottom": ob.bottom,
"origin_time": str(ob.origin_time),
"mitigated": ob.mitigated,
}
@staticmethod
def _serialize_ll(ll) -> dict:
return {
"id": ll.id,
"direction": ll.direction,
"price": ll.price,
"origin_time": str(ll.origin_time),
"swept": ll.swept,
}
# ─── Helpers ──────────────────────────────────────────────────────────────

View File

@@ -26,6 +26,19 @@ class LiquidityLevel:
swept: bool = False # True si le prix a dépassé ce niveau
@dataclass
class TradeReason:
"""Explication structurée de pourquoi un signal a été généré."""
summary: str # "EQH sweep at 1.08542 → Bullish OB [1.082 - 1.0835]"
swept_level_price: Optional[float] = None
swept_level_direction: Optional[str] = None # "high" | "low"
ob_direction: Optional[str] = None # "bullish" | "bearish"
ob_top: Optional[float] = None
ob_bottom: Optional[float] = None
ob_origin_time: Optional[str] = None
filters_applied: list[str] = field(default_factory=list)
@dataclass
class TradeSignal:
"""Signal de trading généré par la stratégie."""
@@ -37,6 +50,7 @@ class TradeSignal:
time: pd.Timestamp
order_block: Optional[OrderBlockZone] = None
liquidity_level: Optional[LiquidityLevel] = None
reason: Optional[TradeReason] = None
@dataclass

View File

@@ -0,0 +1,90 @@
"""
Filtre tendance Higher Timeframe (HTF).
Filtre les signaux de trading en fonction de la tendance sur le timeframe supérieur :
- Si la tendance HTF est haussière → seuls les signaux BUY sont autorisés
- Si la tendance HTF est baissière → seuls les signaux SELL sont autorisés
La tendance est déterminée par un croisement EMA (fast vs slow) sur les données HTF.
"""
from dataclasses import dataclass
from typing import Optional
import pandas as pd
from app.core.strategy.base import TradeSignal
# Mapping : timeframe courante → timeframe supérieure
HTF_MAP: dict[str, str] = {
"M1": "M5",
"M5": "M15",
"M15": "H1",
"M30": "H1",
"H1": "H4",
"H4": "D",
"D": "D",
}
@dataclass
class HTFTrendFilterParams:
ema_fast: int = 20
ema_slow: int = 50
class HTFTrendFilter:
"""Filtre les signaux selon la tendance du timeframe supérieur (EMA crossover)."""
def __init__(self, params: Optional[HTFTrendFilterParams] = None) -> None:
self.params = params or HTFTrendFilterParams()
self._trend: Optional[str] = None # "bullish" | "bearish" | "neutral"
@staticmethod
def get_htf_granularity(current_granularity: str) -> str:
return HTF_MAP.get(current_granularity, current_granularity)
def determine_trend(self, htf_df: pd.DataFrame) -> str:
"""Détermine la tendance via EMA crossover. Retourne 'bullish', 'bearish' ou 'neutral'."""
if len(htf_df) < self.params.ema_slow + 5:
self._trend = "neutral"
return self._trend
close = htf_df["close"]
ema_fast = close.ewm(span=self.params.ema_fast, adjust=False).mean()
ema_slow = close.ewm(span=self.params.ema_slow, adjust=False).mean()
if ema_fast.iloc[-1] > ema_slow.iloc[-1]:
self._trend = "bullish"
else:
self._trend = "bearish"
return self._trend
def filter_signals(self, signals: list[TradeSignal]) -> list[TradeSignal]:
"""Supprime les signaux qui vont contre la tendance HTF."""
if self._trend is None or self._trend == "neutral":
return signals
filtered = []
for signal in signals:
allowed = (
(self._trend == "bullish" and signal.direction == "buy")
or (self._trend == "bearish" and signal.direction == "sell")
)
if allowed:
if signal.reason:
signal.reason.filters_applied.append(
f"HTF {self._trend}{signal.direction.upper()} autorise"
)
filtered.append(signal)
return filtered
def get_params(self) -> dict:
return {
"ema_fast": self.params.ema_fast,
"ema_slow": self.params.ema_slow,
"detected_trend": self._trend,
}

View File

@@ -26,6 +26,7 @@ from app.core.strategy.base import (
AnalysisResult,
LiquidityLevel,
OrderBlockZone,
TradeReason,
TradeSignal,
)
@@ -120,7 +121,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
# Vérifier si déjà sweepé dans les données actuelles
swept = self._is_level_swept(df, level_price, "high", sh_times[j])
levels.append(LiquidityLevel(
id=f"EQH_{i}_{j}",
id=f"EQH_{sh_times[i]}_{sh_times[j]}",
direction="high",
price=level_price,
origin_time=pd.Timestamp(sh_times[j]),
@@ -136,7 +137,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
level_price = (sl_prices[i] + sl_prices[j]) / 2
swept = self._is_level_swept(df, level_price, "low", sl_times[j])
levels.append(LiquidityLevel(
id=f"EQL_{i}_{j}",
id=f"EQL_{sl_times[i]}_{sl_times[j]}",
direction="low",
price=level_price,
origin_time=pd.Timestamp(sl_times[j]),
@@ -183,7 +184,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
if df.loc[k, "close"] < df.loc[k, "open"]: # bearish
mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp)
blocks.append(OrderBlockZone(
id=f"BullishOB_{k}",
id=f"BullishOB_{df.loc[k, 'time']}",
direction="bullish",
top=df.loc[k, "open"], # top = open de la bougie bearish
bottom=df.loc[k, "low"],
@@ -199,7 +200,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
if df.loc[k, "close"] > df.loc[k, "open"]: # bullish
mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp)
blocks.append(OrderBlockZone(
id=f"BearishOB_{k}",
id=f"BearishOB_{df.loc[k, 'time']}",
direction="bearish",
top=df.loc[k, "high"],
bottom=df.loc[k, "open"], # bottom = open de la bougie bullish
@@ -369,6 +370,18 @@ class OrderBlockSweepStrategy(AbstractStrategy):
sl = ob.top + 2 * self.params.pip_value
tp = entry - (sl - entry) * self.params.rr_ratio
level_label = "EQH" if swept_level.direction == "high" else "EQL"
ob_label = "Bullish" if ob.direction == "bullish" else "Bearish"
reason = TradeReason(
summary=f"{level_label} sweep at {swept_level.price:.5f}{ob_label} OB [{ob.bottom:.5f} - {ob.top:.5f}]",
swept_level_price=swept_level.price,
swept_level_direction=swept_level.direction,
ob_direction=ob.direction,
ob_top=ob.top,
ob_bottom=ob.bottom,
ob_origin_time=str(ob.origin_time),
)
return TradeSignal(
direction=direction,
entry_price=entry,
@@ -378,4 +391,5 @@ class OrderBlockSweepStrategy(AbstractStrategy):
time=sweep_time,
order_block=ob,
liquidity_level=swept_level,
reason=reason,
)