fix: aligner les candles du chart avec la période du backtest
Bug: les markers BUY/SELL du chart utilisaient les timestamps des trades du backtest mais les candles étaient fetchées séparément (500 candles récentes), causant un désalignement visuel. - Backend: /backtest retourne désormais les candles exactes du DataFrame analysé - Frontend: Backtest.tsx utilise result.candles directement (suppression du fetchCandles séparé) - Ajout: sérialisation reason/OB/LL sur les trades, overlays OB/LL bornés dans le temps, trade reasons expandables, filtre HTF, badge tendance HTF Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -8,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.backtester import Backtester
|
||||
from app.core.database import get_db
|
||||
from app.core.strategy.order_block_sweep import OrderBlockSweepParams, OrderBlockSweepStrategy
|
||||
from app.core.strategy.htf_trend_filter import HTFTrendFilter, HTFTrendFilterParams, HTF_MAP
|
||||
from app.models.backtest_result import BacktestResult
|
||||
from app.services.market_data import MarketDataService
|
||||
|
||||
@@ -24,6 +24,11 @@ class BacktestRequest(BaseModel):
|
||||
swing_strength: int = Field(default=5, ge=2, le=20)
|
||||
liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5)
|
||||
spread_pips: float = Field(default=1.5, ge=0)
|
||||
# Sélection des stratégies et filtres
|
||||
strategies: list[str] = Field(default=["order_block_sweep"])
|
||||
filters: list[str] = Field(default=[])
|
||||
htf_ema_fast: int = Field(default=20, ge=5, le=100)
|
||||
htf_ema_slow: int = Field(default=50, ge=10, le=200)
|
||||
|
||||
|
||||
@router.post("")
|
||||
@@ -42,13 +47,45 @@ async def run_backtest(
|
||||
if len(df) < 150:
|
||||
raise HTTPException(400, "Pas assez de données (min 150 bougies)")
|
||||
|
||||
# Configurer la stratégie
|
||||
params = OrderBlockSweepParams(
|
||||
swing_strength=req.swing_strength,
|
||||
liquidity_tolerance_pips=req.liquidity_tolerance_pips,
|
||||
rr_ratio=req.rr_ratio,
|
||||
)
|
||||
strategy = OrderBlockSweepStrategy(params)
|
||||
# Construire la stratégie selon la sélection
|
||||
strategies = []
|
||||
if "order_block_sweep" in req.strategies:
|
||||
params = OrderBlockSweepParams(
|
||||
swing_strength=req.swing_strength,
|
||||
liquidity_tolerance_pips=req.liquidity_tolerance_pips,
|
||||
rr_ratio=req.rr_ratio,
|
||||
)
|
||||
strategies.append(OrderBlockSweepStrategy(params))
|
||||
|
||||
if not strategies:
|
||||
raise HTTPException(400, "Au moins une stratégie doit être sélectionnée")
|
||||
|
||||
# Pour l'instant une seule stratégie supportée
|
||||
strategy = strategies[0]
|
||||
|
||||
# Construire les filtres
|
||||
active_filters = []
|
||||
htf_trend_info: Optional[dict] = None
|
||||
|
||||
if "htf_trend_filter" in req.filters:
|
||||
htf_gran = HTF_MAP.get(req.granularity, req.granularity)
|
||||
try:
|
||||
htf_df = await service.get_candles(req.instrument, htf_gran, 200)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Erreur fetch données HTF ({htf_gran}): {e}")
|
||||
|
||||
htf_filter = HTFTrendFilter(HTFTrendFilterParams(
|
||||
ema_fast=req.htf_ema_fast,
|
||||
ema_slow=req.htf_ema_slow,
|
||||
))
|
||||
trend = htf_filter.determine_trend(htf_df)
|
||||
active_filters.append(htf_filter)
|
||||
htf_trend_info = {
|
||||
"htf_granularity": htf_gran,
|
||||
"trend": trend,
|
||||
"ema_fast": req.htf_ema_fast,
|
||||
"ema_slow": req.htf_ema_slow,
|
||||
}
|
||||
|
||||
# Lancer le backtest
|
||||
backtester = Backtester(
|
||||
@@ -56,10 +93,15 @@ async def run_backtest(
|
||||
initial_balance=req.initial_balance,
|
||||
risk_percent=req.risk_percent,
|
||||
spread_pips=req.spread_pips,
|
||||
filters=active_filters,
|
||||
)
|
||||
metrics = backtester.run(df)
|
||||
|
||||
# Sauvegarder en BDD
|
||||
strategy_params = strategy.get_params()
|
||||
if htf_trend_info:
|
||||
strategy_params["htf_trend_filter"] = htf_trend_info
|
||||
|
||||
result = BacktestResult(
|
||||
instrument=req.instrument,
|
||||
granularity=req.granularity,
|
||||
@@ -76,7 +118,7 @@ async def run_backtest(
|
||||
sharpe_ratio=metrics.sharpe_ratio,
|
||||
expectancy=metrics.expectancy,
|
||||
equity_curve=metrics.equity_curve,
|
||||
strategy_params=strategy.get_params(),
|
||||
strategy_params=strategy_params,
|
||||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
@@ -95,10 +137,18 @@ async def run_backtest(
|
||||
"pnl_pips": t.pnl_pips,
|
||||
"status": t.status,
|
||||
"signal_type": t.signal_type,
|
||||
"reason": t.reason,
|
||||
"order_block": t.order_block,
|
||||
"liquidity_level": t.liquidity_level,
|
||||
}
|
||||
for t in metrics.trades
|
||||
]
|
||||
|
||||
# Candles utilisées pour le backtest (pour le chart frontend)
|
||||
candles_out = df[["time", "open", "high", "low", "close", "volume"]].copy()
|
||||
candles_out["time"] = candles_out["time"].astype(str)
|
||||
candles_list = candles_out.to_dict(orient="records")
|
||||
|
||||
return {
|
||||
"backtest_id": result.id,
|
||||
"instrument": req.instrument,
|
||||
@@ -123,6 +173,31 @@ async def run_backtest(
|
||||
},
|
||||
"equity_curve": metrics.equity_curve,
|
||||
"trades": trades_out,
|
||||
"candles": candles_list,
|
||||
"order_blocks": metrics.all_order_blocks,
|
||||
"liquidity_levels": metrics.all_liquidity_levels,
|
||||
"htf_trend": htf_trend_info,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/strategies")
|
||||
async def list_strategies():
|
||||
"""Retourne les stratégies et filtres disponibles."""
|
||||
return {
|
||||
"strategies": [
|
||||
{
|
||||
"name": "order_block_sweep",
|
||||
"label": "Order Block + Liquidity Sweep",
|
||||
"description": "Stratégie ICT : détection Equal H/L, sweep et entrée sur OB",
|
||||
},
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"name": "htf_trend_filter",
|
||||
"label": "Filtre tendance HTF",
|
||||
"description": "Ne prendre que les trades dans le sens de la tendance du timeframe supérieur (EMA crossover)",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ class BacktestTrade:
|
||||
pnl_pct: Optional[float] = None
|
||||
status: str = "open" # "win" | "loss" | "open"
|
||||
signal_type: str = ""
|
||||
reason: Optional[dict] = None
|
||||
order_block: Optional[dict] = None
|
||||
liquidity_level: Optional[dict] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -52,6 +55,8 @@ class BacktestMetrics:
|
||||
profit_factor: float
|
||||
equity_curve: list[dict] = field(default_factory=list)
|
||||
trades: list[BacktestTrade] = field(default_factory=list)
|
||||
all_order_blocks: list[dict] = field(default_factory=list)
|
||||
all_liquidity_levels: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
class Backtester:
|
||||
@@ -62,12 +67,14 @@ class Backtester:
|
||||
risk_percent: float = 1.0,
|
||||
spread_pips: float = 1.5,
|
||||
pip_value: float = 0.0001,
|
||||
filters: Optional[list] = None,
|
||||
) -> None:
|
||||
self.strategy = strategy
|
||||
self.initial_balance = initial_balance
|
||||
self.risk_percent = risk_percent # % du capital risqué par trade
|
||||
self.spread_pips = spread_pips
|
||||
self.pip_value = pip_value
|
||||
self._filters = filters or []
|
||||
|
||||
def run(
|
||||
self,
|
||||
@@ -87,6 +94,10 @@ class Backtester:
|
||||
last_signal_time: Optional[datetime] = None
|
||||
used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded)
|
||||
|
||||
# Accumuler toutes les zones détectées (dédupliquées par id)
|
||||
all_obs: dict[str, dict] = {}
|
||||
all_lls: dict[str, dict] = {}
|
||||
|
||||
for i in range(window, len(df)):
|
||||
candle = df.iloc[i]
|
||||
|
||||
@@ -111,10 +122,24 @@ class Backtester:
|
||||
slice_df = df.iloc[i - window:i + 1]
|
||||
result = self.strategy.analyze(slice_df)
|
||||
|
||||
if result.signals:
|
||||
# Accumuler les zones détectées
|
||||
for ob in result.order_blocks:
|
||||
if ob.id not in all_obs:
|
||||
all_obs[ob.id] = self._serialize_ob(ob)
|
||||
for ll in result.liquidity_levels:
|
||||
if ll.id not in all_lls:
|
||||
all_lls[ll.id] = self._serialize_ll(ll)
|
||||
|
||||
# Appliquer les filtres sur les signaux
|
||||
signals = result.signals
|
||||
for f in self._filters:
|
||||
if hasattr(f, "filter_signals"):
|
||||
signals = f.filter_signals(signals)
|
||||
|
||||
if signals:
|
||||
# Filtrer les signaux déjà exploités (éviter doublons)
|
||||
new_signals = [
|
||||
s for s in result.signals
|
||||
s for s in signals
|
||||
if last_signal_time is None or s.time > last_signal_time
|
||||
]
|
||||
if not new_signals:
|
||||
@@ -135,6 +160,9 @@ class Backtester:
|
||||
take_profit=tp,
|
||||
entry_time=candle["time"],
|
||||
signal_type=signal.signal_type,
|
||||
reason=self._serialize_reason(signal.reason) if signal.reason else None,
|
||||
order_block=self._serialize_ob(signal.order_block) if signal.order_block else None,
|
||||
liquidity_level=self._serialize_ll(signal.liquidity_level) if signal.liquidity_level else None,
|
||||
)
|
||||
|
||||
# Fermer la position ouverte en fin de période
|
||||
@@ -151,7 +179,46 @@ class Backtester:
|
||||
trades.append(open_trade)
|
||||
|
||||
equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance})
|
||||
return self._compute_metrics(trades, balance, equity_curve)
|
||||
metrics = self._compute_metrics(trades, balance, equity_curve)
|
||||
metrics.all_order_blocks = list(all_obs.values())
|
||||
metrics.all_liquidity_levels = list(all_lls.values())
|
||||
return metrics
|
||||
|
||||
# ─── Serialization ─────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _serialize_reason(reason) -> dict:
|
||||
return {
|
||||
"summary": reason.summary,
|
||||
"swept_level_price": reason.swept_level_price,
|
||||
"swept_level_direction": reason.swept_level_direction,
|
||||
"ob_direction": reason.ob_direction,
|
||||
"ob_top": reason.ob_top,
|
||||
"ob_bottom": reason.ob_bottom,
|
||||
"ob_origin_time": reason.ob_origin_time,
|
||||
"filters_applied": reason.filters_applied,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_ob(ob) -> dict:
|
||||
return {
|
||||
"id": ob.id,
|
||||
"direction": ob.direction,
|
||||
"top": ob.top,
|
||||
"bottom": ob.bottom,
|
||||
"origin_time": str(ob.origin_time),
|
||||
"mitigated": ob.mitigated,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _serialize_ll(ll) -> dict:
|
||||
return {
|
||||
"id": ll.id,
|
||||
"direction": ll.direction,
|
||||
"price": ll.price,
|
||||
"origin_time": str(ll.origin_time),
|
||||
"swept": ll.swept,
|
||||
}
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -26,6 +26,19 @@ class LiquidityLevel:
|
||||
swept: bool = False # True si le prix a dépassé ce niveau
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeReason:
|
||||
"""Explication structurée de pourquoi un signal a été généré."""
|
||||
summary: str # "EQH sweep at 1.08542 → Bullish OB [1.082 - 1.0835]"
|
||||
swept_level_price: Optional[float] = None
|
||||
swept_level_direction: Optional[str] = None # "high" | "low"
|
||||
ob_direction: Optional[str] = None # "bullish" | "bearish"
|
||||
ob_top: Optional[float] = None
|
||||
ob_bottom: Optional[float] = None
|
||||
ob_origin_time: Optional[str] = None
|
||||
filters_applied: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeSignal:
|
||||
"""Signal de trading généré par la stratégie."""
|
||||
@@ -37,6 +50,7 @@ class TradeSignal:
|
||||
time: pd.Timestamp
|
||||
order_block: Optional[OrderBlockZone] = None
|
||||
liquidity_level: Optional[LiquidityLevel] = None
|
||||
reason: Optional[TradeReason] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
90
backend/app/core/strategy/htf_trend_filter.py
Normal file
90
backend/app/core/strategy/htf_trend_filter.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Filtre tendance Higher Timeframe (HTF).
|
||||
|
||||
Filtre les signaux de trading en fonction de la tendance sur le timeframe supérieur :
|
||||
- Si la tendance HTF est haussière → seuls les signaux BUY sont autorisés
|
||||
- Si la tendance HTF est baissière → seuls les signaux SELL sont autorisés
|
||||
|
||||
La tendance est déterminée par un croisement EMA (fast vs slow) sur les données HTF.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.core.strategy.base import TradeSignal
|
||||
|
||||
|
||||
# Mapping : timeframe courante → timeframe supérieure
|
||||
HTF_MAP: dict[str, str] = {
|
||||
"M1": "M5",
|
||||
"M5": "M15",
|
||||
"M15": "H1",
|
||||
"M30": "H1",
|
||||
"H1": "H4",
|
||||
"H4": "D",
|
||||
"D": "D",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class HTFTrendFilterParams:
|
||||
ema_fast: int = 20
|
||||
ema_slow: int = 50
|
||||
|
||||
|
||||
class HTFTrendFilter:
|
||||
"""Filtre les signaux selon la tendance du timeframe supérieur (EMA crossover)."""
|
||||
|
||||
def __init__(self, params: Optional[HTFTrendFilterParams] = None) -> None:
|
||||
self.params = params or HTFTrendFilterParams()
|
||||
self._trend: Optional[str] = None # "bullish" | "bearish" | "neutral"
|
||||
|
||||
@staticmethod
|
||||
def get_htf_granularity(current_granularity: str) -> str:
|
||||
return HTF_MAP.get(current_granularity, current_granularity)
|
||||
|
||||
def determine_trend(self, htf_df: pd.DataFrame) -> str:
|
||||
"""Détermine la tendance via EMA crossover. Retourne 'bullish', 'bearish' ou 'neutral'."""
|
||||
if len(htf_df) < self.params.ema_slow + 5:
|
||||
self._trend = "neutral"
|
||||
return self._trend
|
||||
|
||||
close = htf_df["close"]
|
||||
ema_fast = close.ewm(span=self.params.ema_fast, adjust=False).mean()
|
||||
ema_slow = close.ewm(span=self.params.ema_slow, adjust=False).mean()
|
||||
|
||||
if ema_fast.iloc[-1] > ema_slow.iloc[-1]:
|
||||
self._trend = "bullish"
|
||||
else:
|
||||
self._trend = "bearish"
|
||||
|
||||
return self._trend
|
||||
|
||||
def filter_signals(self, signals: list[TradeSignal]) -> list[TradeSignal]:
|
||||
"""Supprime les signaux qui vont contre la tendance HTF."""
|
||||
if self._trend is None or self._trend == "neutral":
|
||||
return signals
|
||||
|
||||
filtered = []
|
||||
for signal in signals:
|
||||
allowed = (
|
||||
(self._trend == "bullish" and signal.direction == "buy")
|
||||
or (self._trend == "bearish" and signal.direction == "sell")
|
||||
)
|
||||
if allowed:
|
||||
if signal.reason:
|
||||
signal.reason.filters_applied.append(
|
||||
f"HTF {self._trend} → {signal.direction.upper()} autorise"
|
||||
)
|
||||
filtered.append(signal)
|
||||
|
||||
return filtered
|
||||
|
||||
def get_params(self) -> dict:
|
||||
return {
|
||||
"ema_fast": self.params.ema_fast,
|
||||
"ema_slow": self.params.ema_slow,
|
||||
"detected_trend": self._trend,
|
||||
}
|
||||
@@ -26,6 +26,7 @@ from app.core.strategy.base import (
|
||||
AnalysisResult,
|
||||
LiquidityLevel,
|
||||
OrderBlockZone,
|
||||
TradeReason,
|
||||
TradeSignal,
|
||||
)
|
||||
|
||||
@@ -120,7 +121,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
# Vérifier si déjà sweepé dans les données actuelles
|
||||
swept = self._is_level_swept(df, level_price, "high", sh_times[j])
|
||||
levels.append(LiquidityLevel(
|
||||
id=f"EQH_{i}_{j}",
|
||||
id=f"EQH_{sh_times[i]}_{sh_times[j]}",
|
||||
direction="high",
|
||||
price=level_price,
|
||||
origin_time=pd.Timestamp(sh_times[j]),
|
||||
@@ -136,7 +137,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
level_price = (sl_prices[i] + sl_prices[j]) / 2
|
||||
swept = self._is_level_swept(df, level_price, "low", sl_times[j])
|
||||
levels.append(LiquidityLevel(
|
||||
id=f"EQL_{i}_{j}",
|
||||
id=f"EQL_{sl_times[i]}_{sl_times[j]}",
|
||||
direction="low",
|
||||
price=level_price,
|
||||
origin_time=pd.Timestamp(sl_times[j]),
|
||||
@@ -183,7 +184,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
if df.loc[k, "close"] < df.loc[k, "open"]: # bearish
|
||||
mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp)
|
||||
blocks.append(OrderBlockZone(
|
||||
id=f"BullishOB_{k}",
|
||||
id=f"BullishOB_{df.loc[k, 'time']}",
|
||||
direction="bullish",
|
||||
top=df.loc[k, "open"], # top = open de la bougie bearish
|
||||
bottom=df.loc[k, "low"],
|
||||
@@ -199,7 +200,7 @@ class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
if df.loc[k, "close"] > df.loc[k, "open"]: # bullish
|
||||
mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp)
|
||||
blocks.append(OrderBlockZone(
|
||||
id=f"BearishOB_{k}",
|
||||
id=f"BearishOB_{df.loc[k, 'time']}",
|
||||
direction="bearish",
|
||||
top=df.loc[k, "high"],
|
||||
bottom=df.loc[k, "open"], # bottom = open de la bougie bullish
|
||||
@@ -369,6 +370,18 @@ class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
sl = ob.top + 2 * self.params.pip_value
|
||||
tp = entry - (sl - entry) * self.params.rr_ratio
|
||||
|
||||
level_label = "EQH" if swept_level.direction == "high" else "EQL"
|
||||
ob_label = "Bullish" if ob.direction == "bullish" else "Bearish"
|
||||
reason = TradeReason(
|
||||
summary=f"{level_label} sweep at {swept_level.price:.5f} → {ob_label} OB [{ob.bottom:.5f} - {ob.top:.5f}]",
|
||||
swept_level_price=swept_level.price,
|
||||
swept_level_direction=swept_level.direction,
|
||||
ob_direction=ob.direction,
|
||||
ob_top=ob.top,
|
||||
ob_bottom=ob.bottom,
|
||||
ob_origin_time=str(ob.origin_time),
|
||||
)
|
||||
|
||||
return TradeSignal(
|
||||
direction=direction,
|
||||
entry_price=entry,
|
||||
@@ -378,4 +391,5 @@ class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
time=sweep_time,
|
||||
order_block=ob,
|
||||
liquidity_level=swept_level,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,44 +117,95 @@ export default function BacktestResults({ result }: Props) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.map((t, i) => (
|
||||
<tr key={i} className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20]">
|
||||
<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]'
|
||||
<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'
|
||||
? '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()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#94a3b8] text-[10px] max-w-[140px] truncate" title={t.signal_type}>
|
||||
{t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')}
|
||||
</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.pnl_pips !== null
|
||||
? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
|
||||
{t.status === 'win' ? '✓ Win' : '✗ Loss'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{t.exit_time
|
||||
? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{t.pnl_pips !== null
|
||||
? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
|
||||
{t.status === 'win' ? '✓ Win' : '✗ Loss'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
|
||||
{t.exit_time
|
||||
? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user