From adbc41102e5cf3e83eefd36956498b9e3535f90e Mon Sep 17 00:00:00 2001 From: tika Date: Sun, 1 Mar 2026 01:23:42 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20aligner=20les=20candles=20du=20chart=20a?= =?UTF-8?q?vec=20la=20p=C3=A9riode=20du=20backtest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routes/backtest.py | 93 ++++++++- backend/app/core/backtester.py | 73 ++++++- backend/app/core/strategy/base.py | 14 ++ backend/app/core/strategy/htf_trend_filter.py | 90 +++++++++ .../app/core/strategy/order_block_sweep.py | 22 +- .../src/components/Backtest/BacktestForm.tsx | 111 +++++++++- .../components/Backtest/BacktestResults.tsx | 129 ++++++++---- .../src/components/Chart/CandlestickChart.tsx | 189 +++++++++++++++--- frontend/src/lib/api.ts | 39 ++++ frontend/src/pages/Backtest.tsx | 33 ++- 10 files changed, 698 insertions(+), 95 deletions(-) create mode 100644 backend/app/core/strategy/htf_trend_filter.py diff --git a/backend/app/api/routes/backtest.py b/backend/app/api/routes/backtest.py index 02eeb3e..39cec79 100644 --- a/backend/app/api/routes/backtest.py +++ b/backend/app/api/routes/backtest.py @@ -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)", + }, + ], } diff --git a/backend/app/core/backtester.py b/backend/app/core/backtester.py index d97f9b9..b7950ef 100644 --- a/backend/app/core/backtester.py +++ b/backend/app/core/backtester.py @@ -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 ────────────────────────────────────────────────────────────── diff --git a/backend/app/core/strategy/base.py b/backend/app/core/strategy/base.py index 6a5a83d..f91e74e 100644 --- a/backend/app/core/strategy/base.py +++ b/backend/app/core/strategy/base.py @@ -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 diff --git a/backend/app/core/strategy/htf_trend_filter.py b/backend/app/core/strategy/htf_trend_filter.py new file mode 100644 index 0000000..ae86aa5 --- /dev/null +++ b/backend/app/core/strategy/htf_trend_filter.py @@ -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, + } diff --git a/backend/app/core/strategy/order_block_sweep.py b/backend/app/core/strategy/order_block_sweep.py index ce5ac66..804b0fe 100644 --- a/backend/app/core/strategy/order_block_sweep.py +++ b/backend/app/core/strategy/order_block_sweep.py @@ -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, ) diff --git a/frontend/src/components/Backtest/BacktestForm.tsx b/frontend/src/components/Backtest/BacktestForm.tsx index 762df9a..671edd5 100644 --- a/frontend/src/components/Backtest/BacktestForm.tsx +++ b/frontend/src/components/Backtest/BacktestForm.tsx @@ -12,7 +12,11 @@ const INSTRUMENTS = [ 'GBP_JPY', 'EUR_JPY', 'SPX500_USD', 'NAS100_USD', 'XAU_USD', ] -const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D'] +const GRANULARITIES = ['M1', 'M5', 'M15', 'M30', 'H1', 'H4', 'D'] + +const HTF_MAP: Record = { + M1: 'M5', M5: 'M15', M15: 'H1', M30: 'H1', H1: 'H4', H4: 'D', D: 'D', +} export default function BacktestForm({ onResult }: Props) { const [loading, setLoading] = useState(false) @@ -30,16 +34,38 @@ export default function BacktestForm({ onResult }: Props) { spread_pips: 1.5, }) + const [strategies, setStrategies] = useState(['order_block_sweep']) + const [filters, setFilters] = useState([]) + const [htfEmaFast, setHtfEmaFast] = useState(20) + const [htfEmaSlow, setHtfEmaSlow] = useState(50) + const handleChange = (key: string, value: string | number) => { setForm((prev) => ({ ...prev, [key]: value })) } + const toggleStrategy = (name: string) => { + setStrategies((prev) => + prev.includes(name) ? prev.filter((s) => s !== name) : [...prev, name] + ) + } + + const toggleFilter = (name: string) => { + setFilters((prev) => + prev.includes(name) ? prev.filter((f) => f !== name) : [...prev, name] + ) + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError(null) try { - const result = await runBacktest(form) + const result = await runBacktest({ + ...form, + strategies, + filters, + ...(filters.includes('htf_trend_filter') ? { htf_ema_fast: htfEmaFast, htf_ema_slow: htfEmaSlow } : {}), + }) onResult(result) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Erreur inconnue' @@ -50,9 +76,10 @@ export default function BacktestForm({ onResult }: Props) { } const Field = ({ - label, name, type = 'number', options + label, name, type = 'number', options, value, onChange, }: { label: string; name: string; type?: string; options?: string[] + value?: number; onChange?: (v: number) => void }) => (
@@ -68,18 +95,41 @@ export default function BacktestForm({ onResult }: Props) { handleChange(name, parseFloat(e.target.value) || 0)} + value={value ?? form[name as keyof typeof form]} + onChange={(e) => { + const v = parseFloat(e.target.value) || 0 + if (onChange) onChange(v) + else handleChange(name, v) + }} className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-[#6366f1]" /> )}
) + const Checkbox = ({ + label, checked, onChange, description, + }: { + label: string; checked: boolean; onChange: () => void; description?: string + }) => ( + + ) + return (

- Paramètres du backtest + Parametres du backtest

@@ -90,10 +140,55 @@ export default function BacktestForm({ onResult }: Props) { - +
+ {/* Limite données info */} + {(form.granularity === 'M1' || form.granularity === 'M5') && ( +
+ {form.granularity === 'M1' ? 'M1 : donnees limitees a 7 jours' : 'M5 : donnees limitees a 60 jours'} +
+ )} + + {/* Stratégies */} +
+

Strategies

+ toggleStrategy('order_block_sweep')} + /> +
+ + {/* Filtres */} +
+

Filtres

+ toggleFilter('htf_trend_filter')} + /> + {filters.includes('htf_trend_filter') && ( +
+ + +
+ )} +
+ {error && (
{error} @@ -102,7 +197,7 @@ export default function BacktestForm({ onResult }: Props) {