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:
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user