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
# 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,
)
strategy = OrderBlockSweepStrategy(params)
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,
)

View File

@@ -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<string, string> = {
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<string[]>(['order_block_sweep'])
const [filters, setFilters] = useState<string[]>([])
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
}) => (
<div>
<label className="block text-xs text-[#64748b] mb-1">{label}</label>
@@ -68,18 +95,41 @@ export default function BacktestForm({ onResult }: Props) {
<input
type={type}
step="any"
value={form[name as keyof typeof form]}
onChange={(e) => 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]"
/>
)}
</div>
)
const Checkbox = ({
label, checked, onChange, description,
}: {
label: string; checked: boolean; onChange: () => void; description?: string
}) => (
<label className="flex items-start gap-2 cursor-pointer group">
<input
type="checkbox"
checked={checked}
onChange={onChange}
className="mt-0.5 accent-[#6366f1] w-3.5 h-3.5"
/>
<div>
<span className="text-sm text-white group-hover:text-[#6366f1] transition-colors">{label}</span>
{description && <p className="text-[10px] text-[#64748b] mt-0.5">{description}</p>}
</div>
</label>
)
return (
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<h2 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider mb-4">
Paramètres du backtest
Parametres du backtest
</h2>
<div className="grid grid-cols-2 gap-3">
@@ -90,10 +140,55 @@ export default function BacktestForm({ onResult }: Props) {
<Field label="Risque par trade (%)" name="risk_percent" />
<Field label="Ratio R:R" name="rr_ratio" />
<Field label="Swing strength" name="swing_strength" />
<Field label="Tolérance liquidité (pips)" name="liquidity_tolerance_pips" />
<Field label="Tolerance liquidite (pips)" name="liquidity_tolerance_pips" />
<Field label="Spread (pips)" name="spread_pips" />
</div>
{/* Limite données info */}
{(form.granularity === 'M1' || form.granularity === 'M5') && (
<div className="text-[10px] text-[#64748b] bg-[#0f1117] rounded-lg px-3 py-2 border border-[#2a2d3e]">
{form.granularity === 'M1' ? 'M1 : donnees limitees a 7 jours' : 'M5 : donnees limitees a 60 jours'}
</div>
)}
{/* Stratégies */}
<div className="border-t border-[#2a2d3e] pt-3">
<h3 className="text-[10px] text-[#64748b] uppercase tracking-wider mb-2">Strategies</h3>
<Checkbox
label="Order Block + Liquidity Sweep"
description="ICT : Equal H/L, sweep et entree sur OB"
checked={strategies.includes('order_block_sweep')}
onChange={() => toggleStrategy('order_block_sweep')}
/>
</div>
{/* Filtres */}
<div className="border-t border-[#2a2d3e] pt-3">
<h3 className="text-[10px] text-[#64748b] uppercase tracking-wider mb-2">Filtres</h3>
<Checkbox
label="Filtre tendance HTF"
description={`Trades uniquement dans le sens de la tendance ${HTF_MAP[form.granularity] ?? ''} (EMA cross)`}
checked={filters.includes('htf_trend_filter')}
onChange={() => toggleFilter('htf_trend_filter')}
/>
{filters.includes('htf_trend_filter') && (
<div className="grid grid-cols-2 gap-3 mt-2 ml-5">
<Field
label="EMA rapide"
name="htf_ema_fast"
value={htfEmaFast}
onChange={setHtfEmaFast}
/>
<Field
label="EMA lente"
name="htf_ema_slow"
value={htfEmaSlow}
onChange={setHtfEmaSlow}
/>
</div>
)}
</div>
{error && (
<div className="text-sm text-[#ef5350] bg-[#ef535020] border border-[#ef535033] rounded-lg px-3 py-2">
{error}
@@ -102,7 +197,7 @@ export default function BacktestForm({ onResult }: Props) {
<button
type="submit"
disabled={loading}
disabled={loading || strategies.length === 0}
className="w-full flex items-center justify-center gap-2 py-2.5 bg-[#6366f1] hover:bg-[#4f46e5] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-60"
>
{loading ? (

View File

@@ -1,5 +1,6 @@
import { createChart, LineSeries, ColorType, type Time } from 'lightweight-charts'
import { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import type { BacktestResult } from '../../lib/api'
interface Props {
@@ -18,6 +19,7 @@ function MetricCard({ label, value, color }: { label: string; value: string; col
export default function BacktestResults({ result }: Props) {
const { metrics, equity_curve, trades } = result
const chartRef = useRef<HTMLDivElement>(null)
const [expandedRow, setExpandedRow] = useState<number | null>(null)
useEffect(() => {
if (!chartRef.current || equity_curve.length === 0) return
@@ -115,7 +117,11 @@ export default function BacktestResults({ result }: Props) {
</thead>
<tbody>
{trades.map((t, i) => (
<tr key={i} className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20]">
<React.Fragment key={i}>
<tr
className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20] cursor-pointer"
onClick={() => setExpandedRow(expandedRow === i ? null : i)}
>
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded font-semibold ${
t.direction === 'buy'
@@ -126,7 +132,14 @@ export default function BacktestResults({ result }: Props) {
</span>
</td>
<td className="px-3 py-2 text-[#94a3b8] text-[10px] max-w-[140px] truncate" title={t.signal_type}>
<span className="flex items-center gap-1">
{t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')}
{t.reason && (
expandedRow === i
? <ChevronUp size={12} className="text-[#6366f1] shrink-0" />
: <ChevronDown size={12} className="text-[#64748b] shrink-0" />
)}
</span>
</td>
<td className="px-3 py-2 text-right font-mono">{t.entry_price.toFixed(5)}</td>
<td className="px-3 py-2 text-right font-mono text-[#64748b]">
@@ -153,6 +166,46 @@ export default function BacktestResults({ result }: Props) {
: ''}
</td>
</tr>
{expandedRow === i && t.reason && (
<tr className="bg-[#0f1117]">
<td colSpan={8} className="px-4 py-3">
<div className="text-[11px] space-y-1.5">
<div className="text-white font-medium">{t.reason.summary}</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[#94a3b8]">
{t.reason.swept_level_price != null && (
<span>
Liquidite : <span className={t.reason.swept_level_direction === 'high' ? 'text-[#ef5350]' : 'text-[#26a69a]'}>
{t.reason.swept_level_direction === 'high' ? 'Equal High' : 'Equal Low'} @ {t.reason.swept_level_price.toFixed(5)}
</span>
</span>
)}
{t.reason.ob_direction && (
<span>
Order Block : <span className={t.reason.ob_direction === 'bullish' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
{t.reason.ob_direction === 'bullish' ? 'Bullish' : 'Bearish'} [{t.reason.ob_bottom?.toFixed(5)} - {t.reason.ob_top?.toFixed(5)}]
</span>
</span>
)}
{t.reason.ob_origin_time && (
<span>
OB forme le : {new Date(t.reason.ob_origin_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
{t.reason.filters_applied.length > 0 && (
<div className="flex gap-2 mt-1">
{t.reason.filters_applied.map((f, fi) => (
<span key={fi} className="px-2 py-0.5 rounded bg-[#6366f120] text-[#6366f1] border border-[#6366f133] text-[10px]">
{f}
</span>
))}
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>

View File

@@ -1,6 +1,8 @@
import {
createChart,
CandlestickSeries,
AreaSeries,
LineSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
@@ -8,29 +10,15 @@ import {
type SeriesMarker,
type Time,
ColorType,
LineStyle,
} from 'lightweight-charts'
import { useEffect, useRef, useState } from 'react'
import type { Candle, BacktestTrade, Trade } from '../../lib/api'
interface OrderBlock {
direction: 'bullish' | 'bearish'
top: number
bottom: number
origin_time: string
mitigated: boolean
}
interface LiquidityLevel {
direction: 'high' | 'low'
price: number
origin_time: string
swept: boolean
}
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Candle, BacktestTrade, Trade, OrderBlockData, LiquidityLevelData } from '../../lib/api'
interface Props {
candles: Candle[]
orderBlocks?: OrderBlock[]
liquidityLevels?: LiquidityLevel[]
orderBlocks?: OrderBlockData[]
liquidityLevels?: LiquidityLevelData[]
trades?: (BacktestTrade | Trade)[]
height?: number
}
@@ -62,13 +50,53 @@ function fmt(v: number): string {
export default function CandlestickChart({
candles,
orderBlocks = [],
liquidityLevels = [],
trades = [],
height = 500,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const overlaySeriesRef = useRef<(ISeriesApi<'Area'> | ISeriesApi<'Line'>)[]>([])
const [legend, setLegend] = useState<OhlcLegend | null>(null)
const [hoverPrice, setHoverPrice] = useState<number | null>(null)
// Annotation la plus proche du curseur (OB ou LL)
const hoverAnnotation = useMemo(() => {
if (hoverPrice === null || (!orderBlocks.length && !liquidityLevels.length)) return null
const pip = hoverPrice >= 100 ? 0.01 : hoverPrice >= 10 ? 0.001 : 0.0001
const threshold = pip * 15
let best: { label: string; detail: string; color: string } | null = null
let minDist = threshold
for (const ob of orderBlocks) {
const mid = (ob.top + ob.bottom) / 2
const dist = Math.abs(hoverPrice - mid)
if (dist < minDist) {
minDist = dist
best = {
label: ob.direction === 'bullish' ? 'Bullish OB' : 'Bearish OB',
detail: `${ob.bottom.toFixed(5)} ${ob.top.toFixed(5)}`,
color: ob.direction === 'bullish' ? '#26a69a' : '#ef5350',
}
}
}
for (const ll of liquidityLevels) {
const dist = Math.abs(hoverPrice - ll.price)
if (dist < minDist) {
minDist = dist
best = {
label: ll.direction === 'high' ? 'EQH' : 'EQL',
detail: ll.price.toFixed(5),
color: ll.direction === 'high' ? '#ef5350' : '#26a69a',
}
}
}
return best
}, [hoverPrice, orderBlocks, liquidityLevels])
useEffect(() => {
if (!containerRef.current) return
@@ -111,6 +139,7 @@ export default function CandlestickChart({
chart.subscribeCrosshairMove((param) => {
if (!param.seriesData.size) {
setLegend(null)
setHoverPrice(null)
return
}
const d = param.seriesData.get(candleSeries) as CandlestickData | undefined
@@ -123,6 +152,9 @@ export default function CandlestickChart({
low: d.low,
close: d.close,
})
if (param.point) {
setHoverPrice(candleSeries.coordinateToPrice(param.point.y))
}
})
const resizeObserver = new ResizeObserver(() => {
@@ -195,6 +227,100 @@ export default function CandlestickChart({
chartRef.current?.timeScale().fitContent()
}, [candles, trades])
// Overlay: Order Blocks (zones bornées) + Liquidity Levels (lignes bornées dans le temps)
useEffect(() => {
const chart = chartRef.current
const candleSeries = candleSeriesRef.current
if (!chart || !candleSeries) return
// Cleanup toujours en premier, même quand candles est vide
for (const s of overlaySeriesRef.current) {
try { chart.removeSeries(s) } catch { /* already removed */ }
}
overlaySeriesRef.current = []
if (candles.length === 0) return
const lastTime = toTimestamp(candles[candles.length - 1].time)
// Maps ob_id / ll_id → temps de fin (depuis les trades)
const obEndTimes = new Map<string, Time>()
const llEndTimes = new Map<string, Time>()
for (const trade of trades) {
if ('order_block' in trade) {
const bt = trade as BacktestTrade
if (bt.order_block?.id && bt.exit_time) {
obEndTimes.set(bt.order_block.id, toTimestamp(bt.exit_time))
}
if (bt.liquidity_level?.id && bt.entry_time) {
llEndTimes.set(bt.liquidity_level.id, toTimestamp(bt.entry_time))
}
}
}
// Order Blocks — uniquement ceux utilisés dans un trade, de origin_time à exit_time
const sortedObs = [...orderBlocks]
.filter((ob) => obEndTimes.has(ob.id))
.sort((a, b) => (a.mitigated === b.mitigated ? 0 : a.mitigated ? 1 : -1))
for (const ob of sortedObs) {
const alpha = 0.14
const isBullish = ob.direction === 'bullish'
const color = isBullish ? `rgba(38, 166, 154, ${alpha})` : `rgba(239, 83, 80, ${alpha})`
const lineColor = isBullish ? 'rgba(38, 166, 154, 0.4)' : 'rgba(239, 83, 80, 0.4)'
try {
const startTime = toTimestamp(ob.origin_time)
const endTime = obEndTimes.get(ob.id) ?? lastTime
if ((startTime as number) >= (endTime as number)) continue
const areaSeries = chart.addSeries(AreaSeries, {
topColor: color,
bottomColor: 'transparent',
lineColor,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
crosshairMarkerVisible: false,
priceScaleId: 'right',
baseValue: { type: 'price' as const, price: ob.bottom },
lastValueVisible: false,
priceLineVisible: false,
})
areaSeries.setData([
{ time: startTime, value: ob.top },
{ time: endTime, value: ob.top },
])
overlaySeriesRef.current.push(areaSeries)
} catch { /* skip si problème de temps */ }
}
// Liquidity Levels — uniquement ceux sweepés pour déclencher un trade, de origin_time à entry_time
for (const level of liquidityLevels.filter((l) => llEndTimes.has(l.id))) {
try {
const startTime = toTimestamp(level.origin_time)
const endTime = llEndTimes.get(level.id)!
if ((startTime as number) >= (endTime as number)) continue
const isHigh = level.direction === 'high'
const color = isHigh ? 'rgba(239, 83, 80, 0.7)' : 'rgba(38, 166, 154, 0.7)'
const lineSeries = chart.addSeries(LineSeries, {
color,
lineWidth: 1,
lineStyle: LineStyle.Dashed,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
})
lineSeries.setData([
{ time: startTime, value: level.price },
{ time: endTime, value: level.price },
])
overlaySeriesRef.current.push(lineSeries)
} catch { /* skip */ }
}
}, [candles, orderBlocks, liquidityLevels, trades])
return (
<div className="relative w-full rounded-xl overflow-hidden border border-[#2a2d3e]">
{/* Légende OHLC */}
@@ -206,17 +332,34 @@ export default function CandlestickChart({
<span className="text-slate-300">H <span className="text-[#26a69a]">{fmt(legend.high)}</span></span>
<span className="text-slate-300">L <span className="text-[#ef5350]">{fmt(legend.low)}</span></span>
<span className="text-slate-300">C <span className="text-white">{fmt(legend.close)}</span></span>
{hoverAnnotation && (
<span
className="ml-1 px-2 py-0.5 rounded border text-xs font-mono"
style={{
color: hoverAnnotation.color,
borderColor: hoverAnnotation.color + '55',
backgroundColor: hoverAnnotation.color + '18',
}}
>
{hoverAnnotation.label}: {hoverAnnotation.detail}
</span>
)}
</>
) : (
<div className="flex gap-2">
{orderBlocks.filter((o) => o.direction === 'bullish').length > 0 && (
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bullish').length > 0 && (
<span className="px-2 py-0.5 rounded bg-[#26a69a33] text-[#26a69a] border border-[#26a69a55]">
{orderBlocks.filter((o) => o.direction === 'bullish').length} Bullish OB
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bullish').length} Bullish OB
</span>
)}
{orderBlocks.filter((o) => o.direction === 'bearish').length > 0 && (
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bearish').length > 0 && (
<span className="px-2 py-0.5 rounded bg-[#ef535033] text-[#ef5350] border border-[#ef535055]">
{orderBlocks.filter((o) => o.direction === 'bearish').length} Bearish OB
{trades.filter((t) => 'order_block' in t && (t as BacktestTrade).order_block?.direction === 'bearish').length} Bearish OB
</span>
)}
{trades.filter((t) => 'liquidity_level' in t && (t as BacktestTrade).liquidity_level).length > 0 && (
<span className="px-2 py-0.5 rounded bg-[#6366f133] text-[#6366f1] border border-[#6366f155]">
{trades.filter((t) => 'liquidity_level' in t && (t as BacktestTrade).liquidity_level).length} Sweeps
</span>
)}
</div>

View File

@@ -49,6 +49,34 @@ export interface BacktestMetrics {
profit_factor: number
}
export interface TradeReason {
summary: string
swept_level_price: number | null
swept_level_direction: string | null
ob_direction: string | null
ob_top: number | null
ob_bottom: number | null
ob_origin_time: string | null
filters_applied: string[]
}
export interface OrderBlockData {
id: string
direction: 'bullish' | 'bearish'
top: number
bottom: number
origin_time: string
mitigated: boolean
}
export interface LiquidityLevelData {
id: string
direction: 'high' | 'low'
price: number
origin_time: string
swept: boolean
}
export interface BacktestResult {
backtest_id: number
instrument: string
@@ -57,6 +85,10 @@ export interface BacktestResult {
metrics: BacktestMetrics
equity_curve: { time: string; balance: number }[]
trades: BacktestTrade[]
candles: Candle[]
order_blocks: OrderBlockData[]
liquidity_levels: LiquidityLevelData[]
htf_trend?: { htf_granularity: string; trend: string; ema_fast: number; ema_slow: number } | null
}
export interface BacktestTrade {
@@ -70,6 +102,9 @@ export interface BacktestTrade {
pnl_pips: number | null
status: string
signal_type: string
reason: TradeReason | null
order_block: OrderBlockData | null
liquidity_level: LiquidityLevelData | null
}
export interface BotStatus {
@@ -124,6 +159,10 @@ export const runBacktest = (params: {
swing_strength: number
liquidity_tolerance_pips: number
spread_pips: number
strategies: string[]
filters: string[]
htf_ema_fast?: number
htf_ema_slow?: number
}) =>
api.post<BacktestResult>('/backtest', params).then((r) => r.data)

View File

@@ -2,18 +2,15 @@ import { useState } from 'react'
import BacktestForm from '../components/Backtest/BacktestForm'
import BacktestResults from '../components/Backtest/BacktestResults'
import CandlestickChart from '../components/Chart/CandlestickChart'
import type { BacktestResult, Candle } from '../lib/api'
import { fetchCandles } from '../lib/api'
import type { BacktestResult } from '../lib/api'
export default function Backtest() {
const [result, setResult] = useState<BacktestResult | null>(null)
const [candles, setCandles] = useState<Candle[]>([])
const [backtestKey, setBacktestKey] = useState(0)
const handleResult = async (r: BacktestResult) => {
const handleResult = (r: BacktestResult) => {
setResult(r)
// Charger les candles pour la période du backtest
const c = await fetchCandles(r.instrument, r.granularity, 500)
setCandles(c)
setBacktestKey(k => k + 1)
}
return (
@@ -25,14 +22,30 @@ export default function Backtest() {
{/* Right: results */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-auto">
<h1 className="text-base font-bold text-white">Backtesting Order Block + Liquidity Sweep</h1>
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-white">
Backtesting{result ? `${result.instrument} ${result.granularity}` : ''}
</h1>
{result?.htf_trend && (
<span className={`text-xs px-2 py-0.5 rounded border ${
result.htf_trend.trend === 'bullish'
? 'bg-[#26a69a20] text-[#26a69a] border-[#26a69a55]'
: 'bg-[#ef535020] text-[#ef5350] border-[#ef535055]'
}`}>
HTF {result.htf_trend.htf_granularity}: {result.htf_trend.trend}
</span>
)}
</div>
{result ? (
<>
{/* Chart avec les trades superposés */}
{/* Chart avec trades, Order Blocks et niveaux de liquidité */}
<CandlestickChart
candles={candles}
key={backtestKey}
candles={result.candles}
trades={result.trades}
orderBlocks={result.order_blocks}
liquidityLevels={result.liquidity_levels}
height={360}
/>
<BacktestResults result={result} />