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 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)",
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user