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:
@@ -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)",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
90
backend/app/core/strategy/htf_trend_filter.py
Normal file
90
backend/app/core/strategy/htf_trend_filter.py
Normal 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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user