feat: trading bot MVP — ICT Order Block + Liquidity Sweep strategy
Full-stack trading bot with: - FastAPI backend with ICT strategy (Order Block + Liquidity Sweep detection) - Backtester engine with rolling window, spread simulation, and performance metrics - Hybrid market data service (yfinance + TwelveData with rate limiting + SQLite cache) - Simulated exchange for paper trading - React/TypeScript frontend with TradingView lightweight-charts v5 - Live dashboard with candlestick chart, OHLC legend, trade markers - Backtest page with configurable parameters, equity curve, and trade table - WebSocket support for real-time updates - Bot runner with asyncio loop for automated trading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
269
backend/app/core/backtester.py
Normal file
269
backend/app/core/backtester.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Moteur de backtesting vectorisé.
|
||||
|
||||
Simule l'exécution de la stratégie sur des données historiques :
|
||||
- Rolling window : analyse les N dernières bougies à chaque pas
|
||||
- Gestion du spread OANDA
|
||||
- Suivi des positions ouvertes (SL / TP hit)
|
||||
- Calcul des métriques de performance
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.core.strategy.base import AbstractStrategy, TradeSignal
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestTrade:
|
||||
direction: str
|
||||
entry_price: float
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
entry_time: datetime
|
||||
exit_price: Optional[float] = None
|
||||
exit_time: Optional[datetime] = None
|
||||
pnl_pips: Optional[float] = None
|
||||
pnl_pct: Optional[float] = None
|
||||
status: str = "open" # "win" | "loss" | "open"
|
||||
signal_type: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestMetrics:
|
||||
initial_balance: float
|
||||
final_balance: float
|
||||
total_pnl: float
|
||||
total_pnl_pct: float
|
||||
total_trades: int
|
||||
winning_trades: int
|
||||
losing_trades: int
|
||||
win_rate: float
|
||||
avg_win_pips: float
|
||||
avg_loss_pips: float
|
||||
expectancy: float
|
||||
max_drawdown: float
|
||||
max_drawdown_pct: float
|
||||
sharpe_ratio: float
|
||||
profit_factor: float
|
||||
equity_curve: list[dict] = field(default_factory=list)
|
||||
trades: list[BacktestTrade] = field(default_factory=list)
|
||||
|
||||
|
||||
class Backtester:
|
||||
def __init__(
|
||||
self,
|
||||
strategy: AbstractStrategy,
|
||||
initial_balance: float = 10_000.0,
|
||||
risk_percent: float = 1.0,
|
||||
spread_pips: float = 1.5,
|
||||
pip_value: float = 0.0001,
|
||||
) -> 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
|
||||
|
||||
def run(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
window: int = 100,
|
||||
) -> BacktestMetrics:
|
||||
"""
|
||||
Exécute le backtest sur le DataFrame complet.
|
||||
|
||||
window : nombre de bougies analysées à chaque pas (rolling)
|
||||
"""
|
||||
df = df.copy().reset_index(drop=True)
|
||||
trades: list[BacktestTrade] = []
|
||||
balance = self.initial_balance
|
||||
equity_curve: list[dict] = [{"time": str(df.iloc[0]["time"]), "balance": balance}]
|
||||
open_trade: Optional[BacktestTrade] = None
|
||||
last_signal_time: Optional[datetime] = None
|
||||
used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded)
|
||||
|
||||
for i in range(window, len(df)):
|
||||
candle = df.iloc[i]
|
||||
|
||||
# 1. Gérer la position ouverte (SL/TP hit ?)
|
||||
if open_trade:
|
||||
closed, exit_price = self._check_exit(open_trade, candle)
|
||||
if closed:
|
||||
pnl_pips = self._calc_pnl_pips(open_trade, exit_price)
|
||||
pnl_money = self._pips_to_money(pnl_pips, balance)
|
||||
balance += pnl_money
|
||||
open_trade.exit_price = exit_price
|
||||
open_trade.exit_time = candle["time"]
|
||||
open_trade.pnl_pips = pnl_pips
|
||||
open_trade.pnl_pct = pnl_money / self.initial_balance * 100
|
||||
open_trade.status = "win" if pnl_pips > 0 else "loss"
|
||||
trades.append(open_trade)
|
||||
open_trade = None
|
||||
equity_curve.append({"time": str(candle["time"]), "balance": balance})
|
||||
|
||||
# 2. Si pas de position ouverte, chercher un signal
|
||||
if not open_trade:
|
||||
slice_df = df.iloc[i - window:i + 1]
|
||||
result = self.strategy.analyze(slice_df)
|
||||
|
||||
if result.signals:
|
||||
# Filtrer les signaux déjà exploités (éviter doublons)
|
||||
new_signals = [
|
||||
s for s in result.signals
|
||||
if last_signal_time is None or s.time > last_signal_time
|
||||
]
|
||||
if not new_signals:
|
||||
continue
|
||||
signal = new_signals[-1] # Signal le plus récent
|
||||
# Appliquer le spread
|
||||
entry, sl, tp = self._apply_spread(signal)
|
||||
# Éviter de réouvrir le même trade (même direction + même prix)
|
||||
sig_key = (signal.direction, round(entry / self.pip_value))
|
||||
if sig_key in used_signals:
|
||||
continue
|
||||
used_signals.add(sig_key)
|
||||
last_signal_time = signal.time
|
||||
open_trade = BacktestTrade(
|
||||
direction=signal.direction,
|
||||
entry_price=entry,
|
||||
stop_loss=sl,
|
||||
take_profit=tp,
|
||||
entry_time=candle["time"],
|
||||
signal_type=signal.signal_type,
|
||||
)
|
||||
|
||||
# Fermer la position ouverte en fin de période
|
||||
if open_trade:
|
||||
last_price = df.iloc[-1]["close"]
|
||||
pnl_pips = self._calc_pnl_pips(open_trade, last_price)
|
||||
pnl_money = self._pips_to_money(pnl_pips, balance)
|
||||
balance += pnl_money
|
||||
open_trade.exit_price = last_price
|
||||
open_trade.exit_time = df.iloc[-1]["time"]
|
||||
open_trade.pnl_pips = pnl_pips
|
||||
open_trade.pnl_pct = pnl_money / self.initial_balance * 100
|
||||
open_trade.status = "win" if pnl_pips > 0 else "loss"
|
||||
trades.append(open_trade)
|
||||
|
||||
equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance})
|
||||
return self._compute_metrics(trades, balance, equity_curve)
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_exit(
|
||||
self, trade: BacktestTrade, candle: pd.Series
|
||||
) -> tuple[bool, float]:
|
||||
"""Vérifie si le SL ou TP est touché sur la bougie. Retourne (closed, exit_price)."""
|
||||
if trade.direction == "buy":
|
||||
if candle["low"] <= trade.stop_loss:
|
||||
return True, trade.stop_loss
|
||||
if candle["high"] >= trade.take_profit:
|
||||
return True, trade.take_profit
|
||||
else:
|
||||
if candle["high"] >= trade.stop_loss:
|
||||
return True, trade.stop_loss
|
||||
if candle["low"] <= trade.take_profit:
|
||||
return True, trade.take_profit
|
||||
return False, 0.0
|
||||
|
||||
def _apply_spread(self, signal: TradeSignal) -> tuple[float, float, float]:
|
||||
"""Applique le spread à l'entrée."""
|
||||
half_spread = (self.spread_pips * self.pip_value) / 2
|
||||
if signal.direction == "buy":
|
||||
entry = signal.entry_price + half_spread
|
||||
sl = signal.stop_loss
|
||||
tp = signal.take_profit
|
||||
else:
|
||||
entry = signal.entry_price - half_spread
|
||||
sl = signal.stop_loss
|
||||
tp = signal.take_profit
|
||||
return entry, sl, tp
|
||||
|
||||
def _calc_pnl_pips(self, trade: BacktestTrade, exit_price: float) -> float:
|
||||
if trade.direction == "buy":
|
||||
return (exit_price - trade.entry_price) / self.pip_value
|
||||
return (trade.entry_price - exit_price) / self.pip_value
|
||||
|
||||
def _pips_to_money(self, pips: float, balance: float) -> float:
|
||||
"""Convertit les pips en unités monétaires selon le risque défini."""
|
||||
risk_amount = balance * (self.risk_percent / 100)
|
||||
# Nombre de pips de risque sur ce trade
|
||||
return pips * (risk_amount / 10) # Approximation simple
|
||||
|
||||
def _compute_metrics(
|
||||
self,
|
||||
trades: list[BacktestTrade],
|
||||
final_balance: float,
|
||||
equity_curve: list[dict],
|
||||
) -> BacktestMetrics:
|
||||
if not trades:
|
||||
return BacktestMetrics(
|
||||
initial_balance=self.initial_balance,
|
||||
final_balance=final_balance,
|
||||
total_pnl=0, total_pnl_pct=0,
|
||||
total_trades=0, winning_trades=0, losing_trades=0,
|
||||
win_rate=0, avg_win_pips=0, avg_loss_pips=0,
|
||||
expectancy=0, max_drawdown=0, max_drawdown_pct=0,
|
||||
sharpe_ratio=0, profit_factor=0,
|
||||
equity_curve=equity_curve, trades=trades,
|
||||
)
|
||||
|
||||
winners = [t for t in trades if t.status == "win"]
|
||||
losers = [t for t in trades if t.status == "loss"]
|
||||
|
||||
total_pnl = final_balance - self.initial_balance
|
||||
win_rate = len(winners) / len(trades) if trades else 0
|
||||
|
||||
avg_win = np.mean([t.pnl_pips for t in winners]) if winners else 0
|
||||
avg_loss = abs(np.mean([t.pnl_pips for t in losers])) if losers else 0
|
||||
expectancy = (win_rate * avg_win) - ((1 - win_rate) * avg_loss)
|
||||
|
||||
# Max Drawdown
|
||||
balances = [eq["balance"] for eq in equity_curve]
|
||||
peak = balances[0]
|
||||
max_dd = 0.0
|
||||
for b in balances:
|
||||
if b > peak:
|
||||
peak = b
|
||||
dd = (peak - b) / peak
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
# Profit Factor
|
||||
gross_profit = sum(t.pnl_pips for t in winners) if winners else 0
|
||||
gross_loss = abs(sum(t.pnl_pips for t in losers)) if losers else 1
|
||||
pf = gross_profit / gross_loss if gross_loss > 0 else 0
|
||||
|
||||
# Sharpe Ratio (simplifié — daily returns)
|
||||
pnl_series = [t.pnl_pips or 0 for t in trades]
|
||||
if len(pnl_series) > 1:
|
||||
mean_ret = np.mean(pnl_series)
|
||||
std_ret = np.std(pnl_series)
|
||||
sharpe = (mean_ret / std_ret * np.sqrt(252)) if std_ret > 0 else 0
|
||||
else:
|
||||
sharpe = 0
|
||||
|
||||
return BacktestMetrics(
|
||||
initial_balance=self.initial_balance,
|
||||
final_balance=final_balance,
|
||||
total_pnl=total_pnl,
|
||||
total_pnl_pct=total_pnl / self.initial_balance * 100,
|
||||
total_trades=len(trades),
|
||||
winning_trades=len(winners),
|
||||
losing_trades=len(losers),
|
||||
win_rate=win_rate * 100,
|
||||
avg_win_pips=float(avg_win),
|
||||
avg_loss_pips=float(avg_loss),
|
||||
expectancy=float(expectancy),
|
||||
max_drawdown=max_dd * self.initial_balance,
|
||||
max_drawdown_pct=max_dd * 100,
|
||||
sharpe_ratio=float(sharpe),
|
||||
profit_factor=float(pf),
|
||||
equity_curve=equity_curve,
|
||||
trades=trades,
|
||||
)
|
||||
155
backend/app/core/bot.py
Normal file
155
backend/app/core/bot.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
BotRunner — boucle de trading live asynchrone.
|
||||
|
||||
Cycle :
|
||||
1. Fetch les N dernières bougies
|
||||
2. Analyser la stratégie
|
||||
3. Si signal + pas de position ouverte → exécuter
|
||||
4. Broadcast l'état via WebSocket
|
||||
5. Attendre le prochain tick
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exchange.base import AbstractExchange
|
||||
from app.core.strategy.base import AbstractStrategy, AnalysisResult
|
||||
from app.services.trade_manager import TradeManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Interval en secondes selon la granularité
|
||||
GRANULARITY_SECONDS = {
|
||||
"M1": 60, "M5": 300, "M15": 900, "M30": 1800,
|
||||
"H1": 3600, "H2": 7200, "H4": 14400, "H6": 21600,
|
||||
"H8": 28800, "H12": 43200, "D": 86400,
|
||||
}
|
||||
|
||||
|
||||
class BotRunner:
|
||||
def __init__(
|
||||
self,
|
||||
exchange: AbstractExchange,
|
||||
strategy: AbstractStrategy,
|
||||
trade_manager: TradeManager,
|
||||
instrument: str = "",
|
||||
granularity: str = "",
|
||||
candles_window: int = 150,
|
||||
) -> None:
|
||||
self._exchange = exchange
|
||||
self._strategy = strategy
|
||||
self._trade_manager = trade_manager
|
||||
self.instrument = instrument or settings.bot_default_instrument
|
||||
self.granularity = granularity or settings.bot_default_granularity
|
||||
self.candles_window = candles_window
|
||||
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._last_analysis: Optional[AnalysisResult] = None
|
||||
self._started_at: Optional[datetime] = None
|
||||
|
||||
# Callback pour broadcaster les événements (WebSocket)
|
||||
self._on_event: Optional[Callable] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
def set_event_callback(self, callback: Callable) -> None:
|
||||
self._on_event = callback
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._running:
|
||||
logger.warning("Bot déjà en cours d'exécution")
|
||||
return
|
||||
self._running = True
|
||||
self._started_at = datetime.utcnow()
|
||||
logger.info("Bot démarré — %s %s", self.instrument, self.granularity)
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Bot arrêté")
|
||||
|
||||
async def _loop(self) -> None:
|
||||
interval = GRANULARITY_SECONDS.get(self.granularity, 3600)
|
||||
# Décaler d'un peu pour s'assurer que la bougie est fermée
|
||||
wait_seconds = max(interval // 10, 5)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self._tick()
|
||||
except Exception as e:
|
||||
logger.error("Erreur dans la boucle du bot: %s", e, exc_info=True)
|
||||
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
async def _tick(self) -> None:
|
||||
# 1. Fetch candles
|
||||
df = await self._exchange.get_candles(
|
||||
self.instrument, self.granularity, self.candles_window
|
||||
)
|
||||
if df.empty:
|
||||
return
|
||||
|
||||
# 2. Analyser
|
||||
result = self._strategy.analyze(df)
|
||||
self._last_analysis = result
|
||||
|
||||
# 3. Vérifier les positions ouvertes
|
||||
open_trades = await self._exchange.get_open_trades()
|
||||
instrument_trades = [t for t in open_trades if t.instrument == self.instrument]
|
||||
|
||||
event = {
|
||||
"type": "tick",
|
||||
"instrument": self.instrument,
|
||||
"granularity": self.granularity,
|
||||
"last_close": float(df.iloc[-1]["close"]),
|
||||
"last_time": str(df.iloc[-1]["time"]),
|
||||
"order_blocks": len(result.order_blocks),
|
||||
"liquidity_levels": len(result.liquidity_levels),
|
||||
"signals": len(result.signals),
|
||||
"open_positions": len(instrument_trades),
|
||||
}
|
||||
|
||||
# 4. Exécuter le signal si aucune position ouverte
|
||||
if result.signals and not instrument_trades:
|
||||
signal = result.signals[-1]
|
||||
try:
|
||||
order = await self._trade_manager.execute_signal(signal)
|
||||
if order:
|
||||
logger.info(
|
||||
"Trade exécuté : %s %s @ %.5f",
|
||||
signal.direction, self.instrument, order.entry_price,
|
||||
)
|
||||
event["new_trade"] = {
|
||||
"direction": order.direction,
|
||||
"entry_price": order.entry_price,
|
||||
"stop_loss": order.stop_loss,
|
||||
"take_profit": order.take_profit,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Erreur exécution trade: %s", e)
|
||||
|
||||
# 5. Broadcast
|
||||
if self._on_event:
|
||||
await self._on_event(event)
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return {
|
||||
"running": self._running,
|
||||
"instrument": self.instrument,
|
||||
"granularity": self.granularity,
|
||||
"started_at": str(self._started_at) if self._started_at else None,
|
||||
"strategy": self._strategy.__class__.__name__,
|
||||
"strategy_params": self._strategy.get_params(),
|
||||
}
|
||||
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
# TwelveData (fallback pour l'intraday historique)
|
||||
# Clé gratuite sur https://twelvedata.com/
|
||||
twelvedata_api_key: str = ""
|
||||
|
||||
# Bot defaults
|
||||
bot_default_instrument: str = "EUR_USD"
|
||||
bot_default_granularity: str = "H1"
|
||||
bot_risk_percent: float = 1.0
|
||||
bot_rr_ratio: float = 2.0
|
||||
bot_initial_balance: float = 10_000.0
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite+aiosqlite:///./trader_bot.db"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
0
backend/app/core/exchange/__init__.py
Normal file
0
backend/app/core/exchange/__init__.py
Normal file
88
backend/app/core/exchange/base.py
Normal file
88
backend/app/core/exchange/base.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderResult:
|
||||
trade_id: str
|
||||
instrument: str
|
||||
direction: str # "buy" | "sell"
|
||||
units: float
|
||||
entry_price: float
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
opened_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenTrade:
|
||||
trade_id: str
|
||||
instrument: str
|
||||
direction: str
|
||||
units: float
|
||||
entry_price: float
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
unrealized_pnl: float
|
||||
opened_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountInfo:
|
||||
balance: float
|
||||
nav: float # Net Asset Value
|
||||
unrealized_pnl: float
|
||||
currency: str
|
||||
|
||||
|
||||
class AbstractExchange(ABC):
|
||||
"""Interface commune pour tous les exchanges/brokers."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_candles(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
count: int = 200,
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Retourne un DataFrame avec colonnes : time, open, high, low, close, volume.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def place_order(
|
||||
self,
|
||||
instrument: str,
|
||||
units: float, # positif = buy, négatif = sell
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> OrderResult:
|
||||
"""Place un ordre au marché avec SL et TP."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close_trade(self, trade_id: str) -> float:
|
||||
"""Ferme un trade par son ID. Retourne le PnL réalisé."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_open_trades(self) -> list[OpenTrade]:
|
||||
"""Retourne la liste des positions ouvertes."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_account_info(self) -> AccountInfo:
|
||||
"""Retourne les infos du compte (solde, PnL, etc.)."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_price(self, instrument: str) -> float:
|
||||
"""Retourne le prix mid actuel."""
|
||||
...
|
||||
171
backend/app/core/exchange/oanda.py
Normal file
171
backend/app/core/exchange/oanda.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from oandapyV20 import API
|
||||
from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exchange.base import (
|
||||
AbstractExchange,
|
||||
AccountInfo,
|
||||
OpenTrade,
|
||||
OrderResult,
|
||||
)
|
||||
|
||||
|
||||
# Granularités OANDA valides
|
||||
GRANULARITIES = {
|
||||
"M1", "M5", "M15", "M30",
|
||||
"H1", "H2", "H4", "H6", "H8", "H12",
|
||||
"D", "W", "M",
|
||||
}
|
||||
|
||||
# Nombre max de candles par requête OANDA
|
||||
MAX_CANDLES_PER_REQUEST = 5000
|
||||
|
||||
|
||||
def _parse_time(ts: str) -> datetime:
|
||||
"""Parse RFC3339 timestamp OANDA → datetime UTC."""
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
class OandaExchange(AbstractExchange):
|
||||
def __init__(self) -> None:
|
||||
env = "practice" if settings.oanda_environment == "practice" else "live"
|
||||
self._client = API(
|
||||
access_token=settings.oanda_api_key,
|
||||
environment=env,
|
||||
)
|
||||
self._account_id = settings.oanda_account_id
|
||||
|
||||
async def get_candles(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str = "H1",
|
||||
count: int = 200,
|
||||
from_time: Optional[datetime] = None,
|
||||
to_time: Optional[datetime] = None,
|
||||
) -> pd.DataFrame:
|
||||
if granularity not in GRANULARITIES:
|
||||
raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}")
|
||||
|
||||
params: dict = {"granularity": granularity, "price": "M"}
|
||||
|
||||
if from_time and to_time:
|
||||
params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
else:
|
||||
params["count"] = min(count, MAX_CANDLES_PER_REQUEST)
|
||||
|
||||
r = instruments.InstrumentsCandles(instrument, params=params)
|
||||
self._client.request(r)
|
||||
|
||||
candles = r.response.get("candles", [])
|
||||
rows = []
|
||||
for c in candles:
|
||||
if c.get("complete", True):
|
||||
mid = c["mid"]
|
||||
rows.append({
|
||||
"time": _parse_time(c["time"]),
|
||||
"open": float(mid["o"]),
|
||||
"high": float(mid["h"]),
|
||||
"low": float(mid["l"]),
|
||||
"close": float(mid["c"]),
|
||||
"volume": int(c.get("volume", 0)),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
if not df.empty:
|
||||
df.sort_values("time", inplace=True)
|
||||
df.reset_index(drop=True, inplace=True)
|
||||
return df
|
||||
|
||||
async def place_order(
|
||||
self,
|
||||
instrument: str,
|
||||
units: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> OrderResult:
|
||||
data = {
|
||||
"order": {
|
||||
"type": "MARKET",
|
||||
"instrument": instrument,
|
||||
"units": str(int(units)),
|
||||
"stopLossOnFill": {"price": f"{stop_loss:.5f}"},
|
||||
"takeProfitOnFill": {"price": f"{take_profit:.5f}"},
|
||||
"timeInForce": "FOK",
|
||||
}
|
||||
}
|
||||
r = orders.OrderCreate(self._account_id, data=data)
|
||||
self._client.request(r)
|
||||
|
||||
resp = r.response
|
||||
fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {})
|
||||
trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown")
|
||||
entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0))
|
||||
direction = "buy" if units > 0 else "sell"
|
||||
|
||||
return OrderResult(
|
||||
trade_id=trade_id,
|
||||
instrument=instrument,
|
||||
direction=direction,
|
||||
units=abs(units),
|
||||
entry_price=entry_price,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
opened_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
async def close_trade(self, trade_id: str) -> float:
|
||||
r = trades.TradeClose(self._account_id, tradeID=trade_id)
|
||||
self._client.request(r)
|
||||
pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0))
|
||||
return pnl
|
||||
|
||||
async def get_open_trades(self) -> list[OpenTrade]:
|
||||
r = trades.OpenTrades(self._account_id)
|
||||
self._client.request(r)
|
||||
result = []
|
||||
for t in r.response.get("trades", []):
|
||||
units = float(t["currentUnits"])
|
||||
direction = "buy" if units > 0 else "sell"
|
||||
sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0
|
||||
tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0
|
||||
result.append(OpenTrade(
|
||||
trade_id=t["id"],
|
||||
instrument=t["instrument"],
|
||||
direction=direction,
|
||||
units=abs(units),
|
||||
entry_price=float(t["price"]),
|
||||
stop_loss=sl,
|
||||
take_profit=tp,
|
||||
unrealized_pnl=float(t.get("unrealizedPL", 0)),
|
||||
opened_at=_parse_time(t["openTime"]),
|
||||
))
|
||||
return result
|
||||
|
||||
async def get_account_info(self) -> AccountInfo:
|
||||
r = accounts.AccountSummary(self._account_id)
|
||||
self._client.request(r)
|
||||
acc = r.response["account"]
|
||||
return AccountInfo(
|
||||
balance=float(acc["balance"]),
|
||||
nav=float(acc["NAV"]),
|
||||
unrealized_pnl=float(acc.get("unrealizedPL", 0)),
|
||||
currency=acc.get("currency", "USD"),
|
||||
)
|
||||
|
||||
async def get_price(self, instrument: str) -> float:
|
||||
r = pricing.PricingInfo(
|
||||
self._account_id,
|
||||
params={"instruments": instrument},
|
||||
)
|
||||
self._client.request(r)
|
||||
prices = r.response.get("prices", [])
|
||||
if not prices:
|
||||
raise ValueError(f"Aucun prix pour {instrument}")
|
||||
bid = float(prices[0]["bids"][0]["price"])
|
||||
ask = float(prices[0]["asks"][0]["price"])
|
||||
return (bid + ask) / 2
|
||||
204
backend/app/core/exchange/simulated.py
Normal file
204
backend/app/core/exchange/simulated.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
SimulatedExchange — paper trading 100% local, sans broker.
|
||||
|
||||
Les ordres sont simulés en mémoire. Les prix viennent du MarketDataService
|
||||
(yfinance + TwelveData + cache DB).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.core.exchange.base import (
|
||||
AbstractExchange,
|
||||
AccountInfo,
|
||||
OpenTrade,
|
||||
OrderResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimulatedExchange(AbstractExchange):
|
||||
"""
|
||||
Exchange simulé pour le paper trading.
|
||||
Les positions sont gardées en mémoire (réinitialisées au redémarrage).
|
||||
Les trades fermés sont persistés en DB via le BotRunner.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
market_data_service, # MarketDataService — évite l'import circulaire
|
||||
initial_balance: float = 10_000.0,
|
||||
) -> None:
|
||||
self._market_data = market_data_service
|
||||
self._balance = initial_balance
|
||||
self._initial_balance = initial_balance
|
||||
self._open_trades: dict[str, OpenTrade] = {}
|
||||
|
||||
# ── AbstractExchange interface ────────────────────────────────────────────
|
||||
|
||||
async def get_candles(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
count: int = 200,
|
||||
from_time=None,
|
||||
to_time=None,
|
||||
) -> pd.DataFrame:
|
||||
return await self._market_data.get_candles(
|
||||
instrument, granularity, count,
|
||||
start=from_time, end=to_time,
|
||||
)
|
||||
|
||||
async def place_order(
|
||||
self,
|
||||
instrument: str,
|
||||
units: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
) -> OrderResult:
|
||||
price = await self._market_data.get_latest_price(instrument)
|
||||
if price is None:
|
||||
raise ValueError(f"Prix introuvable pour {instrument}")
|
||||
|
||||
direction = "buy" if units > 0 else "sell"
|
||||
trade_id = f"SIM-{uuid.uuid4().hex[:8].upper()}"
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
trade = OpenTrade(
|
||||
trade_id=trade_id,
|
||||
instrument=instrument,
|
||||
direction=direction,
|
||||
units=abs(units),
|
||||
entry_price=price,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
unrealized_pnl=0.0,
|
||||
opened_at=now,
|
||||
)
|
||||
self._open_trades[trade_id] = trade
|
||||
|
||||
logger.info(
|
||||
"[SIM] Ordre ouvert %s %s %.2f @ %.5f | SL=%.5f TP=%.5f",
|
||||
direction.upper(), instrument, abs(units), price, stop_loss, take_profit,
|
||||
)
|
||||
|
||||
return OrderResult(
|
||||
trade_id=trade_id,
|
||||
instrument=instrument,
|
||||
direction=direction,
|
||||
units=abs(units),
|
||||
entry_price=price,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
opened_at=now,
|
||||
)
|
||||
|
||||
async def close_trade(self, trade_id: str) -> float:
|
||||
trade = self._open_trades.pop(trade_id, None)
|
||||
if trade is None:
|
||||
raise ValueError(f"Trade {trade_id} introuvable")
|
||||
|
||||
price = await self._market_data.get_latest_price(trade.instrument)
|
||||
if price is None:
|
||||
price = trade.entry_price
|
||||
|
||||
pnl = self._calc_pnl(trade, price)
|
||||
self._balance += pnl
|
||||
|
||||
logger.info(
|
||||
"[SIM] Trade fermé %s %s @ %.5f | PnL=%.2f | Balance=%.2f",
|
||||
trade_id, trade.instrument, price, pnl, self._balance,
|
||||
)
|
||||
return pnl
|
||||
|
||||
async def get_open_trades(self) -> list[OpenTrade]:
|
||||
# Mettre à jour le PnL flottant
|
||||
updated: list[OpenTrade] = []
|
||||
for trade in self._open_trades.values():
|
||||
price = await self._market_data.get_latest_price(trade.instrument)
|
||||
if price is not None:
|
||||
pnl = self._calc_pnl(trade, price)
|
||||
updated.append(OpenTrade(
|
||||
trade_id=trade.trade_id,
|
||||
instrument=trade.instrument,
|
||||
direction=trade.direction,
|
||||
units=trade.units,
|
||||
entry_price=trade.entry_price,
|
||||
stop_loss=trade.stop_loss,
|
||||
take_profit=trade.take_profit,
|
||||
unrealized_pnl=pnl,
|
||||
opened_at=trade.opened_at,
|
||||
))
|
||||
else:
|
||||
updated.append(trade)
|
||||
return updated
|
||||
|
||||
async def get_account_info(self) -> AccountInfo:
|
||||
open_trades = await self.get_open_trades()
|
||||
unrealized = sum(t.unrealized_pnl for t in open_trades)
|
||||
return AccountInfo(
|
||||
balance=self._balance,
|
||||
nav=self._balance + unrealized,
|
||||
unrealized_pnl=unrealized,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
async def get_price(self, instrument: str) -> float:
|
||||
price = await self._market_data.get_latest_price(instrument)
|
||||
if price is None:
|
||||
raise ValueError(f"Prix introuvable pour {instrument}")
|
||||
return price
|
||||
|
||||
# ── Simulation du tick (appelée par BotRunner) ────────────────────────────
|
||||
|
||||
async def check_sl_tp(self, instrument: str) -> list[tuple[str, float]]:
|
||||
"""
|
||||
Vérifie si SL ou TP sont touchés pour les positions ouvertes.
|
||||
Retourne la liste des (trade_id, pnl) des positions fermées.
|
||||
"""
|
||||
price = await self._market_data.get_latest_price(instrument)
|
||||
if price is None:
|
||||
return []
|
||||
|
||||
closed: list[tuple[str, float]] = []
|
||||
for trade_id, trade in list(self._open_trades.items()):
|
||||
if trade.instrument != instrument:
|
||||
continue
|
||||
hit = self._is_sl_tp_hit(trade, price)
|
||||
if hit:
|
||||
exit_price = trade.stop_loss if hit == "sl" else trade.take_profit
|
||||
pnl = self._calc_pnl(trade, exit_price)
|
||||
self._balance += pnl
|
||||
del self._open_trades[trade_id]
|
||||
logger.info(
|
||||
"[SIM] %s touché — %s %s | PnL=%.2f",
|
||||
hit.upper(), trade_id, trade.instrument, pnl,
|
||||
)
|
||||
closed.append((trade_id, pnl))
|
||||
|
||||
return closed
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _calc_pnl(self, trade: OpenTrade, exit_price: float) -> float:
|
||||
if trade.direction == "buy":
|
||||
return (exit_price - trade.entry_price) * trade.units
|
||||
return (trade.entry_price - exit_price) * trade.units
|
||||
|
||||
def _is_sl_tp_hit(self, trade: OpenTrade, current_price: float) -> Optional[str]:
|
||||
if trade.direction == "buy":
|
||||
if current_price <= trade.stop_loss:
|
||||
return "sl"
|
||||
if current_price >= trade.take_profit:
|
||||
return "tp"
|
||||
else:
|
||||
if current_price >= trade.stop_loss:
|
||||
return "sl"
|
||||
if current_price <= trade.take_profit:
|
||||
return "tp"
|
||||
return None
|
||||
0
backend/app/core/strategy/__init__.py
Normal file
0
backend/app/core/strategy/__init__.py
Normal file
66
backend/app/core/strategy/base.py
Normal file
66
backend/app/core/strategy/base.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderBlockZone:
|
||||
"""Zone Order Block à surveiller."""
|
||||
id: str
|
||||
direction: str # "bullish" | "bearish"
|
||||
top: float # Haut de la zone
|
||||
bottom: float # Bas de la zone
|
||||
origin_time: pd.Timestamp
|
||||
mitigated: bool = False # True si le prix a traversé la zone
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiquidityLevel:
|
||||
"""Niveau de liquidité (Equal H/L)."""
|
||||
id: str
|
||||
direction: str # "high" | "low"
|
||||
price: float
|
||||
origin_time: pd.Timestamp
|
||||
swept: bool = False # True si le prix a dépassé ce niveau
|
||||
|
||||
|
||||
@dataclass
|
||||
class TradeSignal:
|
||||
"""Signal de trading généré par la stratégie."""
|
||||
direction: str # "buy" | "sell"
|
||||
entry_price: float
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
signal_type: str # description du setup
|
||||
time: pd.Timestamp
|
||||
order_block: Optional[OrderBlockZone] = None
|
||||
liquidity_level: Optional[LiquidityLevel] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""Résultat complet de l'analyse de la stratégie."""
|
||||
order_blocks: list[OrderBlockZone] = field(default_factory=list)
|
||||
liquidity_levels: list[LiquidityLevel] = field(default_factory=list)
|
||||
signals: list[TradeSignal] = field(default_factory=list)
|
||||
|
||||
|
||||
class AbstractStrategy(ABC):
|
||||
"""Interface commune pour toutes les stratégies."""
|
||||
|
||||
@abstractmethod
|
||||
def analyze(self, df: pd.DataFrame) -> AnalysisResult:
|
||||
"""
|
||||
Analyse un DataFrame de candles et retourne les zones,
|
||||
niveaux de liquidité et signaux d'entrée.
|
||||
|
||||
df doit avoir les colonnes : time, open, high, low, close, volume
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_params(self) -> dict:
|
||||
"""Retourne les paramètres configurables de la stratégie."""
|
||||
...
|
||||
381
backend/app/core/strategy/order_block_sweep.py
Normal file
381
backend/app/core/strategy/order_block_sweep.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Stratégie ICT — Order Block + Liquidity Sweep
|
||||
|
||||
Logique :
|
||||
1. Détecter les swing highs/lows (N bougies de chaque côté)
|
||||
2. Identifier les niveaux de liquidité : Equal Highs (EQH) / Equal Lows (EQL)
|
||||
- Deux swing H/L proches dans une tolérance de X pips = pool de liquidité
|
||||
3. Détecter un Liquidity Sweep :
|
||||
- Le wick d'une bougie dépasse un EQH/EQL, mais la bougie close de l'autre côté
|
||||
→ Confirmation que la liquidité a été absorbée (stop hunt)
|
||||
4. Identifier l'Order Block dans la direction du renversement :
|
||||
- Bullish OB : dernière bougie bearish (close < open) avant le mouvement impulsif haussier
|
||||
- Bearish OB : dernière bougie bullish (close > open) avant le mouvement impulsif bearish
|
||||
5. Signal d'entrée : le prix revient dans la zone OB après le sweep
|
||||
6. SL : en dessous du bas de l'OB (buy) ou au-dessus du haut (sell)
|
||||
7. TP : niveau de liquidité opposé ou R:R fixe
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.core.strategy.base import (
|
||||
AbstractStrategy,
|
||||
AnalysisResult,
|
||||
LiquidityLevel,
|
||||
OrderBlockZone,
|
||||
TradeSignal,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderBlockSweepParams:
|
||||
swing_strength: int = 5 # N bougies de chaque côté pour valider un swing
|
||||
liquidity_tolerance_pips: float = 2.0 # Tolérance en pips pour Equal H/L
|
||||
pip_value: float = 0.0001 # Valeur d'un pip (0.0001 pour Forex, 0.01 pour JPY)
|
||||
min_impulse_candles: int = 3 # Nombre min de bougies dans l'impulsion
|
||||
min_impulse_factor: float = 1.5 # Taille impulsion vs bougie moyenne
|
||||
rr_ratio: float = 2.0 # Ratio Risk/Reward pour le TP
|
||||
|
||||
|
||||
class OrderBlockSweepStrategy(AbstractStrategy):
|
||||
def __init__(self, params: Optional[OrderBlockSweepParams] = None) -> None:
|
||||
self.params = params or OrderBlockSweepParams()
|
||||
|
||||
def get_params(self) -> dict:
|
||||
return {
|
||||
"swing_strength": self.params.swing_strength,
|
||||
"liquidity_tolerance_pips": self.params.liquidity_tolerance_pips,
|
||||
"pip_value": self.params.pip_value,
|
||||
"min_impulse_candles": self.params.min_impulse_candles,
|
||||
"min_impulse_factor": self.params.min_impulse_factor,
|
||||
"rr_ratio": self.params.rr_ratio,
|
||||
}
|
||||
|
||||
def analyze(self, df: pd.DataFrame) -> AnalysisResult:
|
||||
if len(df) < self.params.swing_strength * 2 + 5:
|
||||
return AnalysisResult()
|
||||
|
||||
df = df.copy().reset_index(drop=True)
|
||||
swings = self._detect_swings(df)
|
||||
liquidity_levels = self._detect_liquidity(df, swings)
|
||||
order_blocks = self._detect_order_blocks(df)
|
||||
signals = self._detect_signals(df, liquidity_levels, order_blocks)
|
||||
|
||||
return AnalysisResult(
|
||||
order_blocks=order_blocks,
|
||||
liquidity_levels=liquidity_levels,
|
||||
signals=signals,
|
||||
)
|
||||
|
||||
# ─── Swing Detection ──────────────────────────────────────────────────────
|
||||
|
||||
def _detect_swings(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Retourne un DataFrame avec colonnes swing_high et swing_low (bool)."""
|
||||
n = self.params.swing_strength
|
||||
highs = df["high"].values
|
||||
lows = df["low"].values
|
||||
swing_high = [False] * len(df)
|
||||
swing_low = [False] * len(df)
|
||||
|
||||
for i in range(n, len(df) - n):
|
||||
# Swing High : plus haut que les N bougies avant et après
|
||||
if all(highs[i] > highs[i - j] for j in range(1, n + 1)) and \
|
||||
all(highs[i] > highs[i + j] for j in range(1, n + 1)):
|
||||
swing_high[i] = True
|
||||
# Swing Low : plus bas que les N bougies avant et après
|
||||
if all(lows[i] < lows[i - j] for j in range(1, n + 1)) and \
|
||||
all(lows[i] < lows[i + j] for j in range(1, n + 1)):
|
||||
swing_low[i] = True
|
||||
|
||||
df = df.copy()
|
||||
df["swing_high"] = swing_high
|
||||
df["swing_low"] = swing_low
|
||||
return df
|
||||
|
||||
# ─── Liquidity Detection ──────────────────────────────────────────────────
|
||||
|
||||
def _detect_liquidity(
|
||||
self, df: pd.DataFrame, swings: pd.DataFrame
|
||||
) -> list[LiquidityLevel]:
|
||||
"""
|
||||
Identifie les Equal Highs (EQH) et Equal Lows (EQL).
|
||||
Deux swing H/L sont "égaux" si leur différence est < tolerance_pips.
|
||||
"""
|
||||
tol = self.params.liquidity_tolerance_pips * self.params.pip_value
|
||||
levels: list[LiquidityLevel] = []
|
||||
|
||||
swing_highs = swings[swings["swing_high"]].copy()
|
||||
swing_lows = swings[swings["swing_low"]].copy()
|
||||
|
||||
# Equal Highs
|
||||
sh_prices = swing_highs["high"].values
|
||||
sh_times = swing_highs["time"].values
|
||||
for i in range(len(sh_prices)):
|
||||
for j in range(i + 1, len(sh_prices)):
|
||||
if abs(sh_prices[i] - sh_prices[j]) <= tol:
|
||||
level_price = (sh_prices[i] + sh_prices[j]) / 2
|
||||
# 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}",
|
||||
direction="high",
|
||||
price=level_price,
|
||||
origin_time=pd.Timestamp(sh_times[j]),
|
||||
swept=swept,
|
||||
))
|
||||
|
||||
# Equal Lows
|
||||
sl_prices = swing_lows["low"].values
|
||||
sl_times = swing_lows["time"].values
|
||||
for i in range(len(sl_prices)):
|
||||
for j in range(i + 1, len(sl_prices)):
|
||||
if abs(sl_prices[i] - sl_prices[j]) <= tol:
|
||||
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}",
|
||||
direction="low",
|
||||
price=level_price,
|
||||
origin_time=pd.Timestamp(sl_times[j]),
|
||||
swept=swept,
|
||||
))
|
||||
|
||||
return levels
|
||||
|
||||
def _is_level_swept(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
price: float,
|
||||
direction: str,
|
||||
after_time,
|
||||
) -> bool:
|
||||
"""Vérifie si un niveau a été sweepé après sa formation."""
|
||||
mask = df["time"] > pd.Timestamp(after_time)
|
||||
future = df[mask]
|
||||
if future.empty:
|
||||
return False
|
||||
if direction == "high":
|
||||
return bool((future["high"] > price).any())
|
||||
return bool((future["low"] < price).any())
|
||||
|
||||
# ─── Order Block Detection ────────────────────────────────────────────────
|
||||
|
||||
def _detect_order_blocks(self, df: pd.DataFrame) -> list[OrderBlockZone]:
|
||||
"""
|
||||
Détecte les Order Blocks :
|
||||
- Bullish OB : dernière bougie bearish avant une impulsion haussière significative
|
||||
- Bearish OB : dernière bougie bullish avant une impulsion bearish significative
|
||||
"""
|
||||
blocks: list[OrderBlockZone] = []
|
||||
min_imp = self.params.min_impulse_candles
|
||||
factor = self.params.min_impulse_factor
|
||||
avg_body = (df["close"] - df["open"]).abs().mean()
|
||||
|
||||
for i in range(1, len(df) - min_imp):
|
||||
# Chercher une impulsion haussière après la bougie i
|
||||
impulse_up = self._is_impulse(df, i, "up", min_imp, factor, avg_body)
|
||||
if impulse_up:
|
||||
# Chercher la dernière bougie bearish avant i (inclus)
|
||||
for k in range(i, max(0, i - 10) - 1, -1):
|
||||
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}",
|
||||
direction="bullish",
|
||||
top=df.loc[k, "open"], # top = open de la bougie bearish
|
||||
bottom=df.loc[k, "low"],
|
||||
origin_time=df.loc[k, "time"],
|
||||
mitigated=mitigated,
|
||||
))
|
||||
break
|
||||
|
||||
# Chercher une impulsion bearish après la bougie i
|
||||
impulse_down = self._is_impulse(df, i, "down", min_imp, factor, avg_body)
|
||||
if impulse_down:
|
||||
for k in range(i, max(0, i - 10) - 1, -1):
|
||||
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}",
|
||||
direction="bearish",
|
||||
top=df.loc[k, "high"],
|
||||
bottom=df.loc[k, "open"], # bottom = open de la bougie bullish
|
||||
origin_time=df.loc[k, "time"],
|
||||
mitigated=mitigated,
|
||||
))
|
||||
break
|
||||
|
||||
# Dédupliquer (garder le plus récent par zone)
|
||||
return self._deduplicate_obs(blocks)
|
||||
|
||||
def _is_impulse(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
start: int,
|
||||
direction: str,
|
||||
min_candles: int,
|
||||
factor: float,
|
||||
avg_body: float,
|
||||
) -> bool:
|
||||
"""Vérifie si une impulsion directionnelle commence à l'index start."""
|
||||
end = min(start + min_candles, len(df))
|
||||
segment = df.iloc[start:end]
|
||||
if len(segment) < min_candles:
|
||||
return False
|
||||
|
||||
total_move = abs(segment.iloc[-1]["close"] - segment.iloc[0]["open"])
|
||||
if total_move < avg_body * factor:
|
||||
return False
|
||||
|
||||
if direction == "up":
|
||||
return segment.iloc[-1]["close"] > segment.iloc[0]["open"]
|
||||
return segment.iloc[-1]["close"] < segment.iloc[0]["open"]
|
||||
|
||||
def _is_ob_mitigated(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
ob_idx: int,
|
||||
direction: str,
|
||||
after_idx: int,
|
||||
) -> bool:
|
||||
"""Vérifie si un OB a été mitié (prix est revenu dans la zone)."""
|
||||
top = df.loc[ob_idx, "open"] if direction == "bullish" else df.loc[ob_idx, "high"]
|
||||
bottom = df.loc[ob_idx, "low"] if direction == "bullish" else df.loc[ob_idx, "open"]
|
||||
future = df.iloc[after_idx:]
|
||||
if direction == "bullish":
|
||||
return bool(((future["low"] <= top) & (future["low"] >= bottom)).any())
|
||||
return bool(((future["high"] >= bottom) & (future["high"] <= top)).any())
|
||||
|
||||
def _deduplicate_obs(self, blocks: list[OrderBlockZone]) -> list[OrderBlockZone]:
|
||||
"""Supprime les OB en double (même direction et zone proche)."""
|
||||
seen_ids: set[str] = set()
|
||||
unique: list[OrderBlockZone] = []
|
||||
for b in blocks:
|
||||
if b.id not in seen_ids:
|
||||
seen_ids.add(b.id)
|
||||
unique.append(b)
|
||||
return unique
|
||||
|
||||
# ─── Signal Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _detect_signals(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
liquidity_levels: list[LiquidityLevel],
|
||||
order_blocks: list[OrderBlockZone],
|
||||
) -> list[TradeSignal]:
|
||||
"""
|
||||
Génère des signaux quand :
|
||||
1. Un niveau de liquidité est sweepé (wick dépasse + close de l'autre côté)
|
||||
2. Le prix revient dans un OB de direction opposée au sweep
|
||||
"""
|
||||
signals: list[TradeSignal] = []
|
||||
|
||||
# Travailler sur les 50 dernières bougies pour les signaux récents
|
||||
lookback = df.tail(50).copy()
|
||||
|
||||
if len(lookback) < 2:
|
||||
return signals
|
||||
|
||||
for level in liquidity_levels:
|
||||
if level.swept:
|
||||
continue
|
||||
|
||||
for i in range(1, len(lookback)):
|
||||
candle = lookback.iloc[i]
|
||||
|
||||
# Sweep d'un Equal High → signal SELL potentiel
|
||||
if level.direction == "high":
|
||||
sweep = (
|
||||
candle["high"] > level.price and # wick dépasse
|
||||
candle["close"] < level.price # close en dessous
|
||||
)
|
||||
if sweep:
|
||||
signal = self._find_entry_signal(
|
||||
df=lookback,
|
||||
sweep_idx=i,
|
||||
direction="sell",
|
||||
swept_level=level,
|
||||
order_blocks=order_blocks,
|
||||
)
|
||||
if signal:
|
||||
signals.append(signal)
|
||||
break # Un seul signal par niveau
|
||||
|
||||
# Sweep d'un Equal Low → signal BUY potentiel
|
||||
elif level.direction == "low":
|
||||
sweep = (
|
||||
candle["low"] < level.price and # wick dépasse
|
||||
candle["close"] > level.price # close au-dessus
|
||||
)
|
||||
if sweep:
|
||||
signal = self._find_entry_signal(
|
||||
df=lookback,
|
||||
sweep_idx=i,
|
||||
direction="buy",
|
||||
swept_level=level,
|
||||
order_blocks=order_blocks,
|
||||
)
|
||||
if signal:
|
||||
signals.append(signal)
|
||||
break # Un seul signal par niveau
|
||||
|
||||
return signals
|
||||
|
||||
def _find_entry_signal(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
sweep_idx: int,
|
||||
direction: str,
|
||||
swept_level: LiquidityLevel,
|
||||
order_blocks: list[OrderBlockZone],
|
||||
) -> Optional[TradeSignal]:
|
||||
"""
|
||||
Cherche un OB valide dans la direction du signal après le sweep.
|
||||
"""
|
||||
sweep_time = df.iloc[sweep_idx]["time"]
|
||||
sweep_price = df.iloc[sweep_idx]["close"]
|
||||
|
||||
# Chercher un OB non mitié dans la bonne direction
|
||||
ob_direction = "bullish" if direction == "buy" else "bearish"
|
||||
candidate_obs = [
|
||||
ob for ob in order_blocks
|
||||
if ob.direction == ob_direction
|
||||
and not ob.mitigated
|
||||
and ob.origin_time <= sweep_time
|
||||
]
|
||||
|
||||
if not candidate_obs:
|
||||
return None
|
||||
|
||||
# Prendre l'OB le plus récent
|
||||
ob = max(candidate_obs, key=lambda x: x.origin_time)
|
||||
|
||||
# Vérifier que le prix actuel est proche de l'OB ou dans la zone
|
||||
if direction == "buy":
|
||||
# Le prix doit être au-dessus ou proche du bas de l'OB
|
||||
if sweep_price < ob.bottom * 0.998: # trop loin en dessous
|
||||
return None
|
||||
entry = ob.top
|
||||
sl = ob.bottom - 2 * self.params.pip_value
|
||||
tp = entry + (entry - sl) * self.params.rr_ratio
|
||||
else:
|
||||
if sweep_price > ob.top * 1.002:
|
||||
return None
|
||||
entry = ob.bottom
|
||||
sl = ob.top + 2 * self.params.pip_value
|
||||
tp = entry - (sl - entry) * self.params.rr_ratio
|
||||
|
||||
return TradeSignal(
|
||||
direction=direction,
|
||||
entry_price=entry,
|
||||
stop_loss=sl,
|
||||
take_profit=tp,
|
||||
signal_type=f"LiquiditySweep_{swept_level.direction.upper()}+OrderBlock",
|
||||
time=sweep_time,
|
||||
order_block=ob,
|
||||
liquidity_level=swept_level,
|
||||
)
|
||||
Reference in New Issue
Block a user