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:
2026-02-24 23:25:51 +01:00
commit 4df8d53b1a
58 changed files with 7484 additions and 0 deletions

View File

View 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
View 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(),
}

View 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()

View 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

View File

View 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."""
...

View 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

View 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

View File

View 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."""
...

View 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,
)