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