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 typing import Optional
from fastapi import APIRouter, Depends, HTTPException 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.backtester import Backtester
from app.core.database import get_db from app.core.database import get_db
from app.core.strategy.order_block_sweep import OrderBlockSweepParams, OrderBlockSweepStrategy 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.models.backtest_result import BacktestResult
from app.services.market_data import MarketDataService from app.services.market_data import MarketDataService
@@ -24,6 +24,11 @@ class BacktestRequest(BaseModel):
swing_strength: int = Field(default=5, ge=2, le=20) swing_strength: int = Field(default=5, ge=2, le=20)
liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5) liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5)
spread_pips: float = Field(default=1.5, ge=0) 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("") @router.post("")
@@ -42,13 +47,45 @@ async def run_backtest(
if len(df) < 150: if len(df) < 150:
raise HTTPException(400, "Pas assez de données (min 150 bougies)") raise HTTPException(400, "Pas assez de données (min 150 bougies)")
# Configurer la stratégie # Construire la stratégie selon la sélection
params = OrderBlockSweepParams( strategies = []
swing_strength=req.swing_strength, if "order_block_sweep" in req.strategies:
liquidity_tolerance_pips=req.liquidity_tolerance_pips, params = OrderBlockSweepParams(
rr_ratio=req.rr_ratio, swing_strength=req.swing_strength,
) liquidity_tolerance_pips=req.liquidity_tolerance_pips,
strategy = OrderBlockSweepStrategy(params) 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 # Lancer le backtest
backtester = Backtester( backtester = Backtester(
@@ -56,10 +93,15 @@ async def run_backtest(
initial_balance=req.initial_balance, initial_balance=req.initial_balance,
risk_percent=req.risk_percent, risk_percent=req.risk_percent,
spread_pips=req.spread_pips, spread_pips=req.spread_pips,
filters=active_filters,
) )
metrics = backtester.run(df) metrics = backtester.run(df)
# Sauvegarder en BDD # Sauvegarder en BDD
strategy_params = strategy.get_params()
if htf_trend_info:
strategy_params["htf_trend_filter"] = htf_trend_info
result = BacktestResult( result = BacktestResult(
instrument=req.instrument, instrument=req.instrument,
granularity=req.granularity, granularity=req.granularity,
@@ -76,7 +118,7 @@ async def run_backtest(
sharpe_ratio=metrics.sharpe_ratio, sharpe_ratio=metrics.sharpe_ratio,
expectancy=metrics.expectancy, expectancy=metrics.expectancy,
equity_curve=metrics.equity_curve, equity_curve=metrics.equity_curve,
strategy_params=strategy.get_params(), strategy_params=strategy_params,
) )
db.add(result) db.add(result)
await db.commit() await db.commit()
@@ -95,10 +137,18 @@ async def run_backtest(
"pnl_pips": t.pnl_pips, "pnl_pips": t.pnl_pips,
"status": t.status, "status": t.status,
"signal_type": t.signal_type, "signal_type": t.signal_type,
"reason": t.reason,
"order_block": t.order_block,
"liquidity_level": t.liquidity_level,
} }
for t in metrics.trades 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 { return {
"backtest_id": result.id, "backtest_id": result.id,
"instrument": req.instrument, "instrument": req.instrument,
@@ -123,6 +173,31 @@ async def run_backtest(
}, },
"equity_curve": metrics.equity_curve, "equity_curve": metrics.equity_curve,
"trades": trades_out, "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 pnl_pct: Optional[float] = None
status: str = "open" # "win" | "loss" | "open" status: str = "open" # "win" | "loss" | "open"
signal_type: str = "" signal_type: str = ""
reason: Optional[dict] = None
order_block: Optional[dict] = None
liquidity_level: Optional[dict] = None
@dataclass @dataclass
@@ -52,6 +55,8 @@ class BacktestMetrics:
profit_factor: float profit_factor: float
equity_curve: list[dict] = field(default_factory=list) equity_curve: list[dict] = field(default_factory=list)
trades: list[BacktestTrade] = 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: class Backtester:
@@ -62,12 +67,14 @@ class Backtester:
risk_percent: float = 1.0, risk_percent: float = 1.0,
spread_pips: float = 1.5, spread_pips: float = 1.5,
pip_value: float = 0.0001, pip_value: float = 0.0001,
filters: Optional[list] = None,
) -> None: ) -> None:
self.strategy = strategy self.strategy = strategy
self.initial_balance = initial_balance self.initial_balance = initial_balance
self.risk_percent = risk_percent # % du capital risqué par trade self.risk_percent = risk_percent # % du capital risqué par trade
self.spread_pips = spread_pips self.spread_pips = spread_pips
self.pip_value = pip_value self.pip_value = pip_value
self._filters = filters or []
def run( def run(
self, self,
@@ -87,6 +94,10 @@ class Backtester:
last_signal_time: Optional[datetime] = None last_signal_time: Optional[datetime] = None
used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded) 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)): for i in range(window, len(df)):
candle = df.iloc[i] candle = df.iloc[i]
@@ -111,10 +122,24 @@ class Backtester:
slice_df = df.iloc[i - window:i + 1] slice_df = df.iloc[i - window:i + 1]
result = self.strategy.analyze(slice_df) 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) # Filtrer les signaux déjà exploités (éviter doublons)
new_signals = [ 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 last_signal_time is None or s.time > last_signal_time
] ]
if not new_signals: if not new_signals:
@@ -135,6 +160,9 @@ class Backtester:
take_profit=tp, take_profit=tp,
entry_time=candle["time"], entry_time=candle["time"],
signal_type=signal.signal_type, 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 # Fermer la position ouverte en fin de période
@@ -151,7 +179,46 @@ class Backtester:
trades.append(open_trade) trades.append(open_trade)
equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance}) 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 ────────────────────────────────────────────────────────────── # ─── Helpers ──────────────────────────────────────────────────────────────

View File

@@ -26,6 +26,19 @@ class LiquidityLevel:
swept: bool = False # True si le prix a dépassé ce niveau 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 @dataclass
class TradeSignal: class TradeSignal:
"""Signal de trading généré par la stratégie.""" """Signal de trading généré par la stratégie."""
@@ -37,6 +50,7 @@ class TradeSignal:
time: pd.Timestamp time: pd.Timestamp
order_block: Optional[OrderBlockZone] = None order_block: Optional[OrderBlockZone] = None
liquidity_level: Optional[LiquidityLevel] = None liquidity_level: Optional[LiquidityLevel] = None
reason: Optional[TradeReason] = None
@dataclass @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, AnalysisResult,
LiquidityLevel, LiquidityLevel,
OrderBlockZone, OrderBlockZone,
TradeReason,
TradeSignal, TradeSignal,
) )
@@ -120,7 +121,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
# Vérifier si déjà sweepé dans les données actuelles # Vérifier si déjà sweepé dans les données actuelles
swept = self._is_level_swept(df, level_price, "high", sh_times[j]) swept = self._is_level_swept(df, level_price, "high", sh_times[j])
levels.append(LiquidityLevel( levels.append(LiquidityLevel(
id=f"EQH_{i}_{j}", id=f"EQH_{sh_times[i]}_{sh_times[j]}",
direction="high", direction="high",
price=level_price, price=level_price,
origin_time=pd.Timestamp(sh_times[j]), origin_time=pd.Timestamp(sh_times[j]),
@@ -136,7 +137,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
level_price = (sl_prices[i] + sl_prices[j]) / 2 level_price = (sl_prices[i] + sl_prices[j]) / 2
swept = self._is_level_swept(df, level_price, "low", sl_times[j]) swept = self._is_level_swept(df, level_price, "low", sl_times[j])
levels.append(LiquidityLevel( levels.append(LiquidityLevel(
id=f"EQL_{i}_{j}", id=f"EQL_{sl_times[i]}_{sl_times[j]}",
direction="low", direction="low",
price=level_price, price=level_price,
origin_time=pd.Timestamp(sl_times[j]), origin_time=pd.Timestamp(sl_times[j]),
@@ -183,7 +184,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
if df.loc[k, "close"] < df.loc[k, "open"]: # bearish if df.loc[k, "close"] < df.loc[k, "open"]: # bearish
mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp) mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp)
blocks.append(OrderBlockZone( blocks.append(OrderBlockZone(
id=f"BullishOB_{k}", id=f"BullishOB_{df.loc[k, 'time']}",
direction="bullish", direction="bullish",
top=df.loc[k, "open"], # top = open de la bougie bearish top=df.loc[k, "open"], # top = open de la bougie bearish
bottom=df.loc[k, "low"], bottom=df.loc[k, "low"],
@@ -199,7 +200,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
if df.loc[k, "close"] > df.loc[k, "open"]: # bullish if df.loc[k, "close"] > df.loc[k, "open"]: # bullish
mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp) mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp)
blocks.append(OrderBlockZone( blocks.append(OrderBlockZone(
id=f"BearishOB_{k}", id=f"BearishOB_{df.loc[k, 'time']}",
direction="bearish", direction="bearish",
top=df.loc[k, "high"], top=df.loc[k, "high"],
bottom=df.loc[k, "open"], # bottom = open de la bougie bullish 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 sl = ob.top + 2 * self.params.pip_value
tp = entry - (sl - entry) * self.params.rr_ratio 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( return TradeSignal(
direction=direction, direction=direction,
entry_price=entry, entry_price=entry,
@@ -378,4 +391,5 @@ class OrderBlockSweepStrategy(AbstractStrategy):
time=sweep_time, time=sweep_time,
order_block=ob, order_block=ob,
liquidity_level=swept_level, liquidity_level=swept_level,
reason=reason,
) )

View File

@@ -12,7 +12,11 @@ const INSTRUMENTS = [
'GBP_JPY', 'EUR_JPY', 'SPX500_USD', 'NAS100_USD', 'XAU_USD', '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) { export default function BacktestForm({ onResult }: Props) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -30,16 +34,38 @@ export default function BacktestForm({ onResult }: Props) {
spread_pips: 1.5, 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) => { const handleChange = (key: string, value: string | number) => {
setForm((prev) => ({ ...prev, [key]: value })) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError(null) setError(null)
try { 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) onResult(result)
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -50,9 +76,10 @@ export default function BacktestForm({ onResult }: Props) {
} }
const Field = ({ const Field = ({
label, name, type = 'number', options label, name, type = 'number', options, value, onChange,
}: { }: {
label: string; name: string; type?: string; options?: string[] label: string; name: string; type?: string; options?: string[]
value?: number; onChange?: (v: number) => void
}) => ( }) => (
<div> <div>
<label className="block text-xs text-[#64748b] mb-1">{label}</label> <label className="block text-xs text-[#64748b] mb-1">{label}</label>
@@ -68,18 +95,41 @@ export default function BacktestForm({ onResult }: Props) {
<input <input
type={type} type={type}
step="any" step="any"
value={form[name as keyof typeof form]} value={value ?? form[name as keyof typeof form]}
onChange={(e) => handleChange(name, parseFloat(e.target.value) || 0)} 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]" 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> </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 ( return (
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4"> <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"> <h2 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider mb-4">
Paramètres du backtest Parametres du backtest
</h2> </h2>
<div className="grid grid-cols-2 gap-3"> <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="Risque par trade (%)" name="risk_percent" />
<Field label="Ratio R:R" name="rr_ratio" /> <Field label="Ratio R:R" name="rr_ratio" />
<Field label="Swing strength" name="swing_strength" /> <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" /> <Field label="Spread (pips)" name="spread_pips" />
</div> </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 && ( {error && (
<div className="text-sm text-[#ef5350] bg-[#ef535020] border border-[#ef535033] rounded-lg px-3 py-2"> <div className="text-sm text-[#ef5350] bg-[#ef535020] border border-[#ef535033] rounded-lg px-3 py-2">
{error} {error}
@@ -102,7 +197,7 @@ export default function BacktestForm({ onResult }: Props) {
<button <button
type="submit" 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" 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 ? ( {loading ? (

View File

@@ -1,5 +1,6 @@
import { createChart, LineSeries, ColorType, type Time } from 'lightweight-charts' 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' import type { BacktestResult } from '../../lib/api'
interface Props { interface Props {
@@ -18,6 +19,7 @@ function MetricCard({ label, value, color }: { label: string; value: string; col
export default function BacktestResults({ result }: Props) { export default function BacktestResults({ result }: Props) {
const { metrics, equity_curve, trades } = result const { metrics, equity_curve, trades } = result
const chartRef = useRef<HTMLDivElement>(null) const chartRef = useRef<HTMLDivElement>(null)
const [expandedRow, setExpandedRow] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
if (!chartRef.current || equity_curve.length === 0) return if (!chartRef.current || equity_curve.length === 0) return
@@ -115,44 +117,95 @@ export default function BacktestResults({ result }: Props) {
</thead> </thead>
<tbody> <tbody>
{trades.map((t, i) => ( {trades.map((t, i) => (
<tr key={i} className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20]"> <React.Fragment key={i}>
<td className="px-3 py-2"> <tr
<span className={`px-1.5 py-0.5 rounded font-semibold ${ className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20] cursor-pointer"
t.direction === 'buy' onClick={() => setExpandedRow(expandedRow === i ? null : i)}
? 'bg-[#26a69a20] text-[#26a69a]' >
: 'bg-[#ef535020] text-[#ef5350]' <td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded font-semibold ${
t.direction === 'buy'
? 'bg-[#26a69a20] text-[#26a69a]'
: 'bg-[#ef535020] text-[#ef5350]'
}`}>
{t.direction.toUpperCase()}
</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]">
{t.exit_price?.toFixed(5) ?? ''}
</td>
<td className={`px-3 py-2 text-right font-mono ${
(t.pnl_pips ?? 0) > 0 ? 'text-[#26a69a]' : 'text-[#ef5350]'
}`}> }`}>
{t.direction.toUpperCase()} {t.pnl_pips !== null
</span> ? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}`
</td> : ''}
<td className="px-3 py-2 text-[#94a3b8] text-[10px] max-w-[140px] truncate" title={t.signal_type}> </td>
{t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')} <td className="px-3 py-2">
</td> <span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
<td className="px-3 py-2 text-right font-mono">{t.entry_price.toFixed(5)}</td> {t.status === 'win' ? ' Win' : ' Loss'}
<td className="px-3 py-2 text-right font-mono text-[#64748b]"> </span>
{t.exit_price?.toFixed(5) ?? ''} </td>
</td> <td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
<td className={`px-3 py-2 text-right font-mono ${ {new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
(t.pnl_pips ?? 0) > 0 ? 'text-[#26a69a]' : 'text-[#ef5350]' </td>
}`}> <td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
{t.pnl_pips !== null {t.exit_time
? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}` ? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
: ''} : ''}
</td> </td>
<td className="px-3 py-2"> </tr>
<span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}> {expandedRow === i && t.reason && (
{t.status === 'win' ? ' Win' : ' Loss'} <tr className="bg-[#0f1117]">
</span> <td colSpan={8} className="px-4 py-3">
</td> <div className="text-[11px] space-y-1.5">
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap"> <div className="text-white font-medium">{t.reason.summary}</div>
{new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} <div className="flex flex-wrap gap-x-6 gap-y-1 text-[#94a3b8]">
</td> {t.reason.swept_level_price != null && (
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap"> <span>
{t.exit_time Liquidite : <span className={t.reason.swept_level_direction === 'high' ? 'text-[#ef5350]' : 'text-[#26a69a]'}>
? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) {t.reason.swept_level_direction === 'high' ? 'Equal High' : 'Equal Low'} @ {t.reason.swept_level_price.toFixed(5)}
: ''} </span>
</td> </span>
</tr> )}
{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> </tbody>
</table> </table>

View File

@@ -1,6 +1,8 @@
import { import {
createChart, createChart,
CandlestickSeries, CandlestickSeries,
AreaSeries,
LineSeries,
createSeriesMarkers, createSeriesMarkers,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
@@ -8,29 +10,15 @@ import {
type SeriesMarker, type SeriesMarker,
type Time, type Time,
ColorType, ColorType,
LineStyle,
} from 'lightweight-charts' } from 'lightweight-charts'
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { Candle, BacktestTrade, Trade } from '../../lib/api' import type { Candle, BacktestTrade, Trade, OrderBlockData, LiquidityLevelData } 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
}
interface Props { interface Props {
candles: Candle[] candles: Candle[]
orderBlocks?: OrderBlock[] orderBlocks?: OrderBlockData[]
liquidityLevels?: LiquidityLevel[] liquidityLevels?: LiquidityLevelData[]
trades?: (BacktestTrade | Trade)[] trades?: (BacktestTrade | Trade)[]
height?: number height?: number
} }
@@ -62,13 +50,53 @@ function fmt(v: number): string {
export default function CandlestickChart({ export default function CandlestickChart({
candles, candles,
orderBlocks = [], orderBlocks = [],
liquidityLevels = [],
trades = [], trades = [],
height = 500, height = 500,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null) const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null) const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const overlaySeriesRef = useRef<(ISeriesApi<'Area'> | ISeriesApi<'Line'>)[]>([])
const [legend, setLegend] = useState<OhlcLegend | null>(null) 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(() => { useEffect(() => {
if (!containerRef.current) return if (!containerRef.current) return
@@ -111,6 +139,7 @@ export default function CandlestickChart({
chart.subscribeCrosshairMove((param) => { chart.subscribeCrosshairMove((param) => {
if (!param.seriesData.size) { if (!param.seriesData.size) {
setLegend(null) setLegend(null)
setHoverPrice(null)
return return
} }
const d = param.seriesData.get(candleSeries) as CandlestickData | undefined const d = param.seriesData.get(candleSeries) as CandlestickData | undefined
@@ -123,6 +152,9 @@ export default function CandlestickChart({
low: d.low, low: d.low,
close: d.close, close: d.close,
}) })
if (param.point) {
setHoverPrice(candleSeries.coordinateToPrice(param.point.y))
}
}) })
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
@@ -195,6 +227,100 @@ export default function CandlestickChart({
chartRef.current?.timeScale().fitContent() chartRef.current?.timeScale().fitContent()
}, [candles, trades]) }, [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 ( return (
<div className="relative w-full rounded-xl overflow-hidden border border-[#2a2d3e]"> <div className="relative w-full rounded-xl overflow-hidden border border-[#2a2d3e]">
{/* Légende OHLC */} {/* 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">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">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> <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"> <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]"> <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> </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]"> <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> </span>
)} )}
</div> </div>

View File

@@ -49,6 +49,34 @@ export interface BacktestMetrics {
profit_factor: number 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 { export interface BacktestResult {
backtest_id: number backtest_id: number
instrument: string instrument: string
@@ -57,6 +85,10 @@ export interface BacktestResult {
metrics: BacktestMetrics metrics: BacktestMetrics
equity_curve: { time: string; balance: number }[] equity_curve: { time: string; balance: number }[]
trades: BacktestTrade[] 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 { export interface BacktestTrade {
@@ -70,6 +102,9 @@ export interface BacktestTrade {
pnl_pips: number | null pnl_pips: number | null
status: string status: string
signal_type: string signal_type: string
reason: TradeReason | null
order_block: OrderBlockData | null
liquidity_level: LiquidityLevelData | null
} }
export interface BotStatus { export interface BotStatus {
@@ -124,6 +159,10 @@ export const runBacktest = (params: {
swing_strength: number swing_strength: number
liquidity_tolerance_pips: number liquidity_tolerance_pips: number
spread_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) 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 BacktestForm from '../components/Backtest/BacktestForm'
import BacktestResults from '../components/Backtest/BacktestResults' import BacktestResults from '../components/Backtest/BacktestResults'
import CandlestickChart from '../components/Chart/CandlestickChart' import CandlestickChart from '../components/Chart/CandlestickChart'
import type { BacktestResult, Candle } from '../lib/api' import type { BacktestResult } from '../lib/api'
import { fetchCandles } from '../lib/api'
export default function Backtest() { export default function Backtest() {
const [result, setResult] = useState<BacktestResult | null>(null) 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) setResult(r)
// Charger les candles pour la période du backtest setBacktestKey(k => k + 1)
const c = await fetchCandles(r.instrument, r.granularity, 500)
setCandles(c)
} }
return ( return (
@@ -25,14 +22,30 @@ export default function Backtest() {
{/* Right: results */} {/* Right: results */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-auto"> <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 ? ( {result ? (
<> <>
{/* Chart avec les trades superposés */} {/* Chart avec trades, Order Blocks et niveaux de liquidité */}
<CandlestickChart <CandlestickChart
candles={candles} key={backtestKey}
candles={result.candles}
trades={result.trades} trades={result.trades}
orderBlocks={result.order_blocks}
liquidityLevels={result.liquidity_levels}
height={360} height={360}
/> />
<BacktestResults result={result} /> <BacktestResults result={result} />