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:
18
backend/.env.example
Normal file
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# TwelveData API (données intraday historiques au-delà des limites yfinance)
|
||||
# Clé gratuite sur https://twelvedata.com/ — 800 req/jour
|
||||
TWELVEDATA_API_KEY=your_twelvedata_key_here
|
||||
|
||||
# OANDA API (optionnel — pour le trading réel via broker)
|
||||
OANDA_API_KEY=your_api_key_here
|
||||
OANDA_ACCOUNT_ID=your_account_id_here
|
||||
OANDA_ENVIRONMENT=practice
|
||||
|
||||
# Bot Configuration
|
||||
BOT_DEFAULT_INSTRUMENT=EUR_USD
|
||||
BOT_DEFAULT_GRANULARITY=H1
|
||||
BOT_RISK_PERCENT=1.0
|
||||
BOT_RR_RATIO=2.0
|
||||
BOT_INITIAL_BALANCE=10000.0
|
||||
|
||||
# Database
|
||||
DATABASE_URL=sqlite+aiosqlite:///./trader_bot.db
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
147
backend/app/api/routes/backtest.py
Normal file
147
backend/app/api/routes/backtest.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.backtester import Backtester
|
||||
from app.core.database import get_db
|
||||
from app.core.strategy.order_block_sweep import OrderBlockSweepParams, OrderBlockSweepStrategy
|
||||
from app.models.backtest_result import BacktestResult
|
||||
from app.services.market_data import MarketDataService
|
||||
|
||||
router = APIRouter(prefix="/backtest", tags=["backtest"])
|
||||
|
||||
|
||||
class BacktestRequest(BaseModel):
|
||||
instrument: str = Field(default="EUR_USD")
|
||||
granularity: str = Field(default="H1")
|
||||
candle_count: int = Field(default=500, ge=100, le=5000)
|
||||
initial_balance: float = Field(default=10_000.0, gt=0)
|
||||
risk_percent: float = Field(default=1.0, gt=0, le=10)
|
||||
rr_ratio: float = Field(default=2.0, ge=1.0, le=10.0)
|
||||
swing_strength: int = Field(default=5, ge=2, le=20)
|
||||
liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5)
|
||||
spread_pips: float = Field(default=1.5, ge=0)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def run_backtest(
|
||||
req: BacktestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = MarketDataService(db)
|
||||
|
||||
# Fetch données historiques (yfinance + TwelveData + cache DB)
|
||||
try:
|
||||
df = await service.get_candles(req.instrument, req.granularity, req.candle_count)
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"Erreur fetch données: {e}")
|
||||
|
||||
if len(df) < 150:
|
||||
raise HTTPException(400, "Pas assez de données (min 150 bougies)")
|
||||
|
||||
# Configurer la stratégie
|
||||
params = OrderBlockSweepParams(
|
||||
swing_strength=req.swing_strength,
|
||||
liquidity_tolerance_pips=req.liquidity_tolerance_pips,
|
||||
rr_ratio=req.rr_ratio,
|
||||
)
|
||||
strategy = OrderBlockSweepStrategy(params)
|
||||
|
||||
# Lancer le backtest
|
||||
backtester = Backtester(
|
||||
strategy=strategy,
|
||||
initial_balance=req.initial_balance,
|
||||
risk_percent=req.risk_percent,
|
||||
spread_pips=req.spread_pips,
|
||||
)
|
||||
metrics = backtester.run(df)
|
||||
|
||||
# Sauvegarder en BDD
|
||||
result = BacktestResult(
|
||||
instrument=req.instrument,
|
||||
granularity=req.granularity,
|
||||
start_date=df.iloc[0]["time"],
|
||||
end_date=df.iloc[-1]["time"],
|
||||
initial_balance=metrics.initial_balance,
|
||||
final_balance=metrics.final_balance,
|
||||
total_pnl=metrics.total_pnl,
|
||||
total_trades=metrics.total_trades,
|
||||
winning_trades=metrics.winning_trades,
|
||||
losing_trades=metrics.losing_trades,
|
||||
win_rate=metrics.win_rate,
|
||||
max_drawdown=metrics.max_drawdown,
|
||||
sharpe_ratio=metrics.sharpe_ratio,
|
||||
expectancy=metrics.expectancy,
|
||||
equity_curve=metrics.equity_curve,
|
||||
strategy_params=strategy.get_params(),
|
||||
)
|
||||
db.add(result)
|
||||
await db.commit()
|
||||
await db.refresh(result)
|
||||
|
||||
# Formater les trades pour la réponse
|
||||
trades_out = [
|
||||
{
|
||||
"direction": t.direction,
|
||||
"entry_price": t.entry_price,
|
||||
"exit_price": t.exit_price,
|
||||
"stop_loss": t.stop_loss,
|
||||
"take_profit": t.take_profit,
|
||||
"entry_time": str(t.entry_time),
|
||||
"exit_time": str(t.exit_time) if t.exit_time else None,
|
||||
"pnl_pips": t.pnl_pips,
|
||||
"status": t.status,
|
||||
"signal_type": t.signal_type,
|
||||
}
|
||||
for t in metrics.trades
|
||||
]
|
||||
|
||||
return {
|
||||
"backtest_id": result.id,
|
||||
"instrument": req.instrument,
|
||||
"granularity": req.granularity,
|
||||
"period": {"start": str(df.iloc[0]["time"]), "end": str(df.iloc[-1]["time"])},
|
||||
"metrics": {
|
||||
"initial_balance": metrics.initial_balance,
|
||||
"final_balance": round(metrics.final_balance, 2),
|
||||
"total_pnl": round(metrics.total_pnl, 2),
|
||||
"total_pnl_pct": round(metrics.total_pnl_pct, 2),
|
||||
"total_trades": metrics.total_trades,
|
||||
"winning_trades": metrics.winning_trades,
|
||||
"losing_trades": metrics.losing_trades,
|
||||
"win_rate": round(metrics.win_rate, 1),
|
||||
"avg_win_pips": round(metrics.avg_win_pips, 1),
|
||||
"avg_loss_pips": round(metrics.avg_loss_pips, 1),
|
||||
"expectancy": round(metrics.expectancy, 2),
|
||||
"max_drawdown": round(metrics.max_drawdown, 2),
|
||||
"max_drawdown_pct": round(metrics.max_drawdown_pct, 2),
|
||||
"sharpe_ratio": round(metrics.sharpe_ratio, 3),
|
||||
"profit_factor": round(metrics.profit_factor, 2),
|
||||
},
|
||||
"equity_curve": metrics.equity_curve,
|
||||
"trades": trades_out,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_backtest_history(db: AsyncSession = Depends(get_db)):
|
||||
from sqlalchemy import select
|
||||
stmt = select(BacktestResult).order_by(BacktestResult.created_at.desc()).limit(20)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"instrument": r.instrument,
|
||||
"granularity": r.granularity,
|
||||
"total_pnl": r.total_pnl,
|
||||
"win_rate": r.win_rate,
|
||||
"total_trades": r.total_trades,
|
||||
"sharpe_ratio": r.sharpe_ratio,
|
||||
"created_at": str(r.created_at),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
46
backend/app/api/routes/bot.py
Normal file
46
backend/app/api/routes/bot.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/bot", tags=["bot"])
|
||||
|
||||
# Instance globale du bot (initialisée dans main.py)
|
||||
_bot_runner = None
|
||||
|
||||
|
||||
def set_bot_runner(runner) -> None:
|
||||
global _bot_runner
|
||||
_bot_runner = runner
|
||||
|
||||
|
||||
class BotStartRequest(BaseModel):
|
||||
instrument: str = ""
|
||||
granularity: str = ""
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def bot_status():
|
||||
if _bot_runner is None:
|
||||
return {"running": False, "message": "Bot non initialisé"}
|
||||
return _bot_runner.get_status()
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_bot(req: BotStartRequest):
|
||||
if _bot_runner is None:
|
||||
raise HTTPException(503, "Bot non initialisé")
|
||||
if req.instrument:
|
||||
_bot_runner.instrument = req.instrument
|
||||
if req.granularity:
|
||||
_bot_runner.granularity = req.granularity
|
||||
await _bot_runner.start()
|
||||
return {"message": "Bot démarré", "status": _bot_runner.get_status()}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_bot():
|
||||
if _bot_runner is None:
|
||||
raise HTTPException(503, "Bot non initialisé")
|
||||
await _bot_runner.stop()
|
||||
return {"message": "Bot arrêté"}
|
||||
30
backend/app/api/routes/candles.py
Normal file
30
backend/app/api/routes/candles.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.market_data import MarketDataService
|
||||
|
||||
router = APIRouter(prefix="/candles", tags=["candles"])
|
||||
|
||||
VALID_GRANULARITIES = {"M1", "M5", "M15", "M30", "H1", "H4", "D"}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_candles(
|
||||
instrument: str = Query(default="EUR_USD", description="Ex: EUR_USD, GBP_USD, SPX500_USD"),
|
||||
granularity: str = Query(default="H1", description="M1, M5, M15, M30, H1, H4, D"),
|
||||
count: int = Query(default=200, ge=10, le=5000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if granularity not in VALID_GRANULARITIES:
|
||||
raise HTTPException(400, f"Granularité invalide. Valides: {VALID_GRANULARITIES}")
|
||||
|
||||
service = MarketDataService(db)
|
||||
df = await service.get_candles(instrument, granularity, count)
|
||||
|
||||
return {
|
||||
"instrument": instrument,
|
||||
"granularity": granularity,
|
||||
"count": len(df),
|
||||
"candles": df.to_dict(orient="records") if not df.empty else [],
|
||||
}
|
||||
53
backend/app/api/routes/trades.py
Normal file
53
backend/app/api/routes/trades.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.trade import Trade
|
||||
|
||||
router = APIRouter(prefix="/trades", tags=["trades"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_trades(
|
||||
source: Optional[str] = Query(default=None, description="live | backtest"),
|
||||
status: Optional[str] = Query(default=None, description="open | closed"),
|
||||
instrument: Optional[str] = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Trade).order_by(Trade.opened_at.desc()).limit(limit)
|
||||
if source:
|
||||
stmt = stmt.where(Trade.source == source)
|
||||
if status:
|
||||
stmt = stmt.where(Trade.status == status)
|
||||
if instrument:
|
||||
stmt = stmt.where(Trade.instrument == instrument)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
trades = result.scalars().all()
|
||||
|
||||
return {
|
||||
"total": len(trades),
|
||||
"trades": [
|
||||
{
|
||||
"id": t.id,
|
||||
"source": t.source,
|
||||
"instrument": t.instrument,
|
||||
"direction": t.direction,
|
||||
"units": t.units,
|
||||
"entry_price": t.entry_price,
|
||||
"stop_loss": t.stop_loss,
|
||||
"take_profit": t.take_profit,
|
||||
"exit_price": t.exit_price,
|
||||
"pnl": t.pnl,
|
||||
"status": t.status,
|
||||
"signal_type": t.signal_type,
|
||||
"opened_at": str(t.opened_at),
|
||||
"closed_at": str(t.closed_at) if t.closed_at else None,
|
||||
}
|
||||
for t in trades
|
||||
],
|
||||
}
|
||||
57
backend/app/api/websocket.py
Normal file
57
backend/app/api/websocket.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
WebSocket endpoint pour le streaming de données live.
|
||||
|
||||
Broadcast les événements du bot (ticks, nouveaux trades, etc.)
|
||||
vers tous les clients connectés.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Set
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
self._active: Set[WebSocket] = set()
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self._active.add(ws)
|
||||
logger.info("WS client connecté — %d total", len(self._active))
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
self._active.discard(ws)
|
||||
logger.info("WS client déconnecté — %d restants", len(self._active))
|
||||
|
||||
async def broadcast(self, data: dict) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
message = json.dumps(data, default=str)
|
||||
dead: Set[WebSocket] = set()
|
||||
for ws in list(self._active):
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(ws)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
async def websocket_endpoint(ws: WebSocket) -> None:
|
||||
await manager.connect(ws)
|
||||
try:
|
||||
while True:
|
||||
# Garder la connexion ouverte, le client peut envoyer des pings
|
||||
data = await ws.receive_text()
|
||||
if data == "ping":
|
||||
await ws.send_text(json.dumps({"type": "pong"}))
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(ws)
|
||||
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,
|
||||
)
|
||||
90
backend/app/main.py
Normal file
90
backend/app/main.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import backtest, bot, candles, trades
|
||||
from app.api.routes.bot import set_bot_runner
|
||||
from app.api.websocket import manager, websocket_endpoint
|
||||
from app.core.bot import BotRunner
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal, init_db
|
||||
from app.core.exchange.simulated import SimulatedExchange
|
||||
from app.core.strategy.order_block_sweep import OrderBlockSweepStrategy
|
||||
from app.services.market_data import MarketDataService
|
||||
from app.services.trade_manager import TradeManager
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="Trader Bot API",
|
||||
description="Bot de trading ICT — Order Block + Liquidity Sweep",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(candles.router, prefix="/api")
|
||||
app.include_router(trades.router, prefix="/api")
|
||||
app.include_router(backtest.router, prefix="/api")
|
||||
app.include_router(bot.router, prefix="/api")
|
||||
|
||||
|
||||
@app.websocket("/ws/live")
|
||||
async def ws_live(websocket: WebSocket):
|
||||
await websocket_endpoint(websocket)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await init_db()
|
||||
logger.info("Base de données initialisée")
|
||||
|
||||
# Initialiser le bot avec l'exchange simulé (paper trading local)
|
||||
async with AsyncSessionLocal() as db:
|
||||
market_data = MarketDataService(db)
|
||||
exchange = SimulatedExchange(
|
||||
market_data_service=market_data,
|
||||
initial_balance=settings.bot_initial_balance,
|
||||
)
|
||||
strategy = OrderBlockSweepStrategy()
|
||||
trade_mgr = TradeManager(exchange)
|
||||
runner = BotRunner(
|
||||
exchange=exchange,
|
||||
strategy=strategy,
|
||||
trade_manager=trade_mgr,
|
||||
)
|
||||
|
||||
async def broadcast_event(event: dict):
|
||||
await manager.broadcast(event)
|
||||
|
||||
runner.set_event_callback(broadcast_event)
|
||||
set_bot_runner(runner)
|
||||
|
||||
td_status = "✓ configurée" if settings.twelvedata_api_key else "✗ manquante (historique limité)"
|
||||
logger.info(
|
||||
"Bot initialisé — SimulatedExchange | balance=%.0f$ | TwelveData=%s",
|
||||
settings.bot_initial_balance,
|
||||
td_status,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"name": "Trader Bot API",
|
||||
"version": "0.1.0",
|
||||
"docs": "/docs",
|
||||
"data_sources": {
|
||||
"primary": "yfinance",
|
||||
"fallback_historical": "TwelveData",
|
||||
"twelvedata_configured": bool(settings.twelvedata_api_key),
|
||||
},
|
||||
}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
32
backend/app/models/backtest_result.py
Normal file
32
backend/app/models/backtest_result.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Float, Integer, String, DateTime, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class BacktestResult(Base):
|
||||
__tablename__ = "backtest_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
instrument: Mapped[str] = mapped_column(String(20))
|
||||
granularity: Mapped[str] = mapped_column(String(10))
|
||||
start_date: Mapped[datetime] = mapped_column(DateTime)
|
||||
end_date: Mapped[datetime] = mapped_column(DateTime)
|
||||
initial_balance: Mapped[float] = mapped_column(Float, default=10000.0)
|
||||
final_balance: Mapped[float] = mapped_column(Float)
|
||||
total_pnl: Mapped[float] = mapped_column(Float)
|
||||
total_trades: Mapped[int] = mapped_column(Integer)
|
||||
winning_trades: Mapped[int] = mapped_column(Integer)
|
||||
losing_trades: Mapped[int] = mapped_column(Integer)
|
||||
win_rate: Mapped[float] = mapped_column(Float)
|
||||
max_drawdown: Mapped[float] = mapped_column(Float)
|
||||
sharpe_ratio: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
expectancy: Mapped[float] = mapped_column(Float)
|
||||
# Courbe d'équité [{time, balance}] stockée en JSON
|
||||
equity_curve: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
# Paramètres de la stratégie utilisés
|
||||
strategy_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
25
backend/app/models/candle.py
Normal file
25
backend/app/models/candle.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Float, Integer, String, DateTime, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Candle(Base):
|
||||
__tablename__ = "candles"
|
||||
__table_args__ = (
|
||||
# Garantit INSERT OR IGNORE sur (instrument, granularity, time)
|
||||
UniqueConstraint("instrument", "granularity", "time", name="uq_candle"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
instrument: Mapped[str] = mapped_column(String(20), index=True)
|
||||
granularity: Mapped[str] = mapped_column(String(10))
|
||||
time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
open: Mapped[float] = mapped_column(Float)
|
||||
high: Mapped[float] = mapped_column(Float)
|
||||
low: Mapped[float] = mapped_column(Float)
|
||||
close: Mapped[float] = mapped_column(Float)
|
||||
volume: Mapped[int] = mapped_column(Integer, default=0)
|
||||
complete: Mapped[bool] = mapped_column(default=True)
|
||||
34
backend/app/models/trade.py
Normal file
34
backend/app/models/trade.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Float, Integer, String, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class Trade(Base):
|
||||
__tablename__ = "trades"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# "live" | "backtest"
|
||||
source: Mapped[str] = mapped_column(String(10), default="live")
|
||||
# identifiant OANDA du trade live (si applicable)
|
||||
oanda_trade_id: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
instrument: Mapped[str] = mapped_column(String(20), index=True)
|
||||
# "buy" | "sell"
|
||||
direction: Mapped[str] = mapped_column(String(4))
|
||||
units: Mapped[float] = mapped_column(Float)
|
||||
entry_price: Mapped[float] = mapped_column(Float)
|
||||
stop_loss: Mapped[float] = mapped_column(Float)
|
||||
take_profit: Mapped[float] = mapped_column(Float)
|
||||
exit_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
pnl: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||
# "open" | "closed" | "cancelled"
|
||||
status: Mapped[str] = mapped_column(String(10), default="open")
|
||||
# Signal ayant déclenché le trade
|
||||
signal_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
# ID du backtest parent (si applicable)
|
||||
backtest_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/data_providers/__init__.py
Normal file
0
backend/app/services/data_providers/__init__.py
Normal file
82
backend/app/services/data_providers/constants.py
Normal file
82
backend/app/services/data_providers/constants.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Constantes de mapping entre les noms canoniques du projet
|
||||
et les symboles/intervalles propres à chaque source de données.
|
||||
"""
|
||||
|
||||
# ── Limites yfinance (jours de données disponibles par granularité) ──────────
|
||||
YF_MAX_DAYS: dict[str, int] = {
|
||||
"M1": 7,
|
||||
"M5": 60,
|
||||
"M15": 60,
|
||||
"M30": 60,
|
||||
"H1": 730,
|
||||
"H4": 730,
|
||||
"D": 9999,
|
||||
}
|
||||
|
||||
# ── Durée d'une bougie en minutes ─────────────────────────────────────────────
|
||||
GRANULARITY_MINUTES: dict[str, int] = {
|
||||
"M1": 1,
|
||||
"M5": 5,
|
||||
"M15": 15,
|
||||
"M30": 30,
|
||||
"H1": 60,
|
||||
"H4": 240,
|
||||
"D": 1440,
|
||||
}
|
||||
|
||||
# ── Mapping vers les intervalles yfinance ─────────────────────────────────────
|
||||
GRANULARITY_TO_YF: dict[str, str] = {
|
||||
"M1": "1m",
|
||||
"M5": "5m",
|
||||
"M15": "15m",
|
||||
"M30": "30m",
|
||||
"H1": "1h",
|
||||
"H4": "4h",
|
||||
"D": "1d",
|
||||
}
|
||||
|
||||
# ── Mapping vers les intervalles TwelveData ───────────────────────────────────
|
||||
GRANULARITY_TO_TD: dict[str, str] = {
|
||||
"M1": "1min",
|
||||
"M5": "5min",
|
||||
"M15": "15min",
|
||||
"M30": "30min",
|
||||
"H1": "1h",
|
||||
"H4": "4h",
|
||||
"D": "1day",
|
||||
}
|
||||
|
||||
# ── Mapping instrument → symbole yfinance ─────────────────────────────────────
|
||||
INSTRUMENT_TO_YF: dict[str, str] = {
|
||||
"EUR_USD": "EURUSD=X",
|
||||
"GBP_USD": "GBPUSD=X",
|
||||
"USD_JPY": "USDJPY=X",
|
||||
"USD_CHF": "USDCHF=X",
|
||||
"AUD_USD": "AUDUSD=X",
|
||||
"USD_CAD": "USDCAD=X",
|
||||
"GBP_JPY": "GBPJPY=X",
|
||||
"EUR_JPY": "EURJPY=X",
|
||||
"EUR_GBP": "EURGBP=X",
|
||||
"SPX500_USD": "^GSPC",
|
||||
"NAS100_USD": "^NDX",
|
||||
"XAU_USD": "GC=F",
|
||||
"US30_USD": "YM=F",
|
||||
}
|
||||
|
||||
# ── Mapping instrument → symbole TwelveData ───────────────────────────────────
|
||||
INSTRUMENT_TO_TD: dict[str, str] = {
|
||||
"EUR_USD": "EUR/USD",
|
||||
"GBP_USD": "GBP/USD",
|
||||
"USD_JPY": "USD/JPY",
|
||||
"USD_CHF": "USD/CHF",
|
||||
"AUD_USD": "AUD/USD",
|
||||
"USD_CAD": "USD/CAD",
|
||||
"GBP_JPY": "GBP/JPY",
|
||||
"EUR_JPY": "EUR/JPY",
|
||||
"EUR_GBP": "EUR/GBP",
|
||||
"SPX500_USD": "SPY",
|
||||
"NAS100_USD": "QQQ",
|
||||
"XAU_USD": "XAU/USD",
|
||||
"US30_USD": "DJI",
|
||||
}
|
||||
159
backend/app/services/data_providers/twelvedata_provider.py
Normal file
159
backend/app/services/data_providers/twelvedata_provider.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Provider TwelveData — données OHLCV historiques illimitées.
|
||||
|
||||
Plan gratuit : 800 requêtes/jour, 8 req/min.
|
||||
Docs : https://twelvedata.com/docs
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import pandas as pd
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.data_providers.constants import GRANULARITY_TO_TD, INSTRUMENT_TO_TD
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TWELVEDATA_BASE_URL = "https://api.twelvedata.com"
|
||||
# Nombre max de points par requête TwelveData (plan gratuit)
|
||||
MAX_OUTPUTSIZE = 5000
|
||||
# Limite du plan gratuit : 8 req/min
|
||||
_RATE_LIMIT = 8
|
||||
_RATE_WINDOW = 61 # secondes (légèrement au-dessus de 60 pour la marge)
|
||||
_rate_lock = asyncio.Lock()
|
||||
_request_times: list[float] = []
|
||||
|
||||
|
||||
async def _rate_limited_get(client: httpx.AsyncClient, url: str, params: dict) -> httpx.Response:
|
||||
"""Wrapper qui respecte la limite de 8 req/min de TwelveData."""
|
||||
global _request_times
|
||||
async with _rate_lock:
|
||||
now = time.monotonic()
|
||||
# Purger les timestamps hors fenêtre
|
||||
_request_times = [t for t in _request_times if now - t < _RATE_WINDOW]
|
||||
if len(_request_times) >= _RATE_LIMIT:
|
||||
wait = _RATE_WINDOW - (now - _request_times[0])
|
||||
if wait > 0:
|
||||
logger.info("TwelveData rate limit : attente %.1f s", wait)
|
||||
await asyncio.sleep(wait)
|
||||
_request_times = [t for t in _request_times if time.monotonic() - t < _RATE_WINDOW]
|
||||
_request_times.append(time.monotonic())
|
||||
return await client.get(url, params=params)
|
||||
|
||||
|
||||
class TwelveDataProvider:
|
||||
"""Fetche des candles depuis l'API TwelveData."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api_key = settings.twelvedata_api_key
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._api_key)
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
start: datetime,
|
||||
end: Optional[datetime] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""Fetche les candles pour la période [start, end]."""
|
||||
if not self.is_configured():
|
||||
logger.warning("TwelveData : TWELVEDATA_API_KEY non configurée")
|
||||
return pd.DataFrame()
|
||||
|
||||
td_symbol = INSTRUMENT_TO_TD.get(instrument)
|
||||
td_interval = GRANULARITY_TO_TD.get(granularity)
|
||||
|
||||
if not td_symbol or not td_interval:
|
||||
logger.warning("TwelveData : instrument/granularité non supporté — %s %s", instrument, granularity)
|
||||
return pd.DataFrame()
|
||||
|
||||
if end is None:
|
||||
end = datetime.utcnow()
|
||||
|
||||
logger.info(
|
||||
"TwelveData fetch : %s (%s) %s → %s",
|
||||
instrument, granularity, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
# TwelveData supporte max 5000 points par requête
|
||||
# Si la période est longue, on fait plusieurs requêtes
|
||||
all_frames: list[pd.DataFrame] = []
|
||||
current_end = end
|
||||
|
||||
while current_end > start:
|
||||
df_chunk = await self._fetch_chunk(td_symbol, td_interval, start, current_end)
|
||||
if df_chunk.empty:
|
||||
break
|
||||
all_frames.append(df_chunk)
|
||||
oldest = df_chunk["time"].min()
|
||||
if oldest <= start:
|
||||
break
|
||||
# Reculer pour la prochaine requête
|
||||
current_end = oldest - timedelta(seconds=1)
|
||||
|
||||
if not all_frames:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.concat(all_frames, ignore_index=True)
|
||||
df = df.drop_duplicates(subset=["time"])
|
||||
df = df.sort_values("time").reset_index(drop=True)
|
||||
df = df[(df["time"] >= start) & (df["time"] <= end)]
|
||||
|
||||
logger.info("TwelveData : %d bougies récupérées pour %s %s", len(df), instrument, granularity)
|
||||
return df
|
||||
|
||||
async def _fetch_chunk(
|
||||
self,
|
||||
td_symbol: str,
|
||||
td_interval: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> pd.DataFrame:
|
||||
params = {
|
||||
"symbol": td_symbol,
|
||||
"interval": td_interval,
|
||||
"start_date": start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": end.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"outputsize": MAX_OUTPUTSIZE,
|
||||
"format": "JSON",
|
||||
"apikey": self._api_key,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await _rate_limited_get(client, f"{TWELVEDATA_BASE_URL}/time_series", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.error("TwelveData erreur HTTP : %s", e)
|
||||
return pd.DataFrame()
|
||||
|
||||
if data.get("status") == "error":
|
||||
logger.error("TwelveData API erreur : %s", data.get("message"))
|
||||
return pd.DataFrame()
|
||||
|
||||
values = data.get("values", [])
|
||||
if not values:
|
||||
return pd.DataFrame()
|
||||
|
||||
rows = []
|
||||
for v in values:
|
||||
rows.append({
|
||||
"time": pd.to_datetime(v["datetime"]),
|
||||
"open": float(v["open"]),
|
||||
"high": float(v["high"]),
|
||||
"low": float(v["low"]),
|
||||
"close": float(v["close"]),
|
||||
"volume": int(v.get("volume", 0)),
|
||||
})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df = df.sort_values("time").reset_index(drop=True)
|
||||
return df
|
||||
134
backend/app/services/data_providers/yfinance_provider.py
Normal file
134
backend/app/services/data_providers/yfinance_provider.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Provider yfinance — données OHLCV gratuites.
|
||||
|
||||
Limites :
|
||||
- M1 : 7 derniers jours
|
||||
- M5/M15/M30 : 60 derniers jours
|
||||
- H1/H4 : 730 derniers jours
|
||||
- D : illimité
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.services.data_providers.constants import (
|
||||
GRANULARITY_TO_YF,
|
||||
INSTRUMENT_TO_YF,
|
||||
YF_MAX_DAYS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Normalise un DataFrame yfinance vers le format interne."""
|
||||
df = df.copy()
|
||||
df.index = pd.to_datetime(df.index, utc=True)
|
||||
df.index = df.index.tz_localize(None) if df.index.tz is not None else df.index
|
||||
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
# yfinance peut retourner des colonnes multi-index
|
||||
if isinstance(df.columns, pd.MultiIndex):
|
||||
df.columns = df.columns.get_level_values(0)
|
||||
|
||||
df = df.rename(columns={"adj close": "close"})[["open", "high", "low", "close", "volume"]]
|
||||
df = df.dropna(subset=["open", "high", "low", "close"])
|
||||
df.index.name = "time"
|
||||
df = df.reset_index()
|
||||
df["time"] = pd.to_datetime(df["time"]).dt.tz_localize(None)
|
||||
return df
|
||||
|
||||
|
||||
def _fetch_sync(
|
||||
yf_symbol: str,
|
||||
yf_interval: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> pd.DataFrame:
|
||||
"""Exécution synchrone de yfinance (sera appelée dans un thread)."""
|
||||
import yfinance as yf
|
||||
|
||||
ticker = yf.Ticker(yf_symbol)
|
||||
df = ticker.history(
|
||||
interval=yf_interval,
|
||||
start=start.strftime("%Y-%m-%d"),
|
||||
end=(end + timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
auto_adjust=True,
|
||||
prepost=False,
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
class YFinanceProvider:
|
||||
"""Fetche des candles depuis Yahoo Finance."""
|
||||
|
||||
def yf_cutoff(self, granularity: str) -> Optional[datetime]:
|
||||
"""Retourne la date la plus ancienne que yfinance peut fournir."""
|
||||
max_days = YF_MAX_DAYS.get(granularity)
|
||||
if max_days is None:
|
||||
return None
|
||||
return datetime.utcnow() - timedelta(days=max_days - 1)
|
||||
|
||||
def can_provide(self, granularity: str, start: datetime) -> bool:
|
||||
"""Vérifie si yfinance peut fournir des données pour cette période."""
|
||||
cutoff = self.yf_cutoff(granularity)
|
||||
if cutoff is None:
|
||||
return False
|
||||
return start >= cutoff
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
start: datetime,
|
||||
end: Optional[datetime] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Fetche les candles pour la période [start, end].
|
||||
Tronque start à la limite yfinance si nécessaire.
|
||||
"""
|
||||
yf_symbol = INSTRUMENT_TO_YF.get(instrument)
|
||||
yf_interval = GRANULARITY_TO_YF.get(granularity)
|
||||
|
||||
if not yf_symbol or not yf_interval:
|
||||
logger.warning("yfinance : instrument ou granularité non supporté — %s %s", instrument, granularity)
|
||||
return pd.DataFrame()
|
||||
|
||||
# Tronquer start à la limite yfinance
|
||||
cutoff = self.yf_cutoff(granularity)
|
||||
if cutoff and start < cutoff:
|
||||
logger.debug("yfinance : start tronqué de %s à %s", start, cutoff)
|
||||
start = cutoff
|
||||
|
||||
if end is None:
|
||||
end = datetime.utcnow()
|
||||
|
||||
if start >= end:
|
||||
return pd.DataFrame()
|
||||
|
||||
logger.info(
|
||||
"yfinance fetch : %s (%s) %s → %s",
|
||||
instrument, granularity, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
raw = await loop.run_in_executor(
|
||||
None, _fetch_sync, yf_symbol, yf_interval, start, end
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("yfinance erreur : %s", e)
|
||||
return pd.DataFrame()
|
||||
|
||||
if raw.empty:
|
||||
logger.warning("yfinance : aucune donnée pour %s %s", instrument, granularity)
|
||||
return pd.DataFrame()
|
||||
|
||||
df = _normalize(raw)
|
||||
df = df[(df["time"] >= start) & (df["time"] <= end)]
|
||||
logger.info("yfinance : %d bougies récupérées pour %s %s", len(df), instrument, granularity)
|
||||
return df
|
||||
264
backend/app/services/market_data.py
Normal file
264
backend/app/services/market_data.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
MarketDataService — source de données hybride avec cache DB.
|
||||
|
||||
Stratégie de fetch pour une période [start, end] demandée :
|
||||
|
||||
1. DB d'abord → on récupère ce qu'on a déjà, on ne refetch jamais ce qui existe
|
||||
2. Gaps récents → yfinance (dans ses limites temporelles)
|
||||
3. Gaps historiques → TwelveData (pour tout ce que yfinance ne peut pas couvrir)
|
||||
4. Tout est stocké → les prochaines requêtes seront servies depuis la DB
|
||||
|
||||
Exemple (M1, 10 derniers jours demandés) :
|
||||
- DB : déjà ce qu'on a en cache
|
||||
- yfinance : J-7 → maintenant (limite M1 = 7 jours)
|
||||
- TwelveData : J-10 → J-7 (historique au-delà de yfinance)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import and_, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.candle import Candle
|
||||
from app.services.data_providers.constants import GRANULARITY_MINUTES
|
||||
from app.services.data_providers.twelvedata_provider import TwelveDataProvider
|
||||
from app.services.data_providers.yfinance_provider import YFinanceProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Facteur pour compenser weekends + jours fériés dans le calcul de la fenêtre
|
||||
TRADING_DAYS_FACTOR = 1.5
|
||||
|
||||
|
||||
class MarketDataService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self._db = db
|
||||
self._yf = YFinanceProvider()
|
||||
self._td = TwelveDataProvider()
|
||||
|
||||
# ── API publique ──────────────────────────────────────────────────────────
|
||||
|
||||
async def get_candles(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
count: int = 200,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Retourne jusqu'à `count` bougies pour instrument/granularity.
|
||||
Si start/end fournis, ils définissent la plage exacte.
|
||||
|
||||
Processus :
|
||||
1. Calcul de la fenêtre temporelle nécessaire
|
||||
2. Détection et comblement des gaps (yfinance + TwelveData)
|
||||
3. Lecture depuis DB et retour
|
||||
"""
|
||||
if end is None:
|
||||
end = datetime.utcnow()
|
||||
if start is None:
|
||||
minutes = GRANULARITY_MINUTES.get(granularity, 60)
|
||||
start = end - timedelta(minutes=int(minutes * count * TRADING_DAYS_FACTOR))
|
||||
|
||||
await self._fill_gaps(instrument, granularity, start, end)
|
||||
return await self._db_fetch(instrument, granularity, start, end, limit=count)
|
||||
|
||||
async def get_latest_price(self, instrument: str) -> Optional[float]:
|
||||
"""Retourne le dernier close connu (DB ou yfinance M1)."""
|
||||
stmt = (
|
||||
select(Candle.close)
|
||||
.where(Candle.instrument == instrument)
|
||||
.order_by(Candle.time.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self._db.execute(stmt)
|
||||
price = result.scalar_one_or_none()
|
||||
if price:
|
||||
return float(price)
|
||||
|
||||
df = await self.get_candles(instrument, "M1", count=2)
|
||||
return float(df.iloc[-1]["close"]) if not df.empty else None
|
||||
|
||||
# ── Logique de détection et comblement des gaps ───────────────────────────
|
||||
|
||||
async def _fill_gaps(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> None:
|
||||
gaps = await self._find_gaps(instrument, granularity, start, end)
|
||||
for gap_start, gap_end in gaps:
|
||||
await self._fetch_and_store_gap(instrument, granularity, gap_start, gap_end)
|
||||
|
||||
async def _find_gaps(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> list[tuple[datetime, datetime]]:
|
||||
"""
|
||||
Retourne la liste des (gap_start, gap_end) manquants en DB.
|
||||
|
||||
Logique :
|
||||
- Si rien en DB pour la plage → un seul gap = (start, end)
|
||||
- Sinon → combler avant le plus ancien et/ou après le plus récent
|
||||
"""
|
||||
stmt = (
|
||||
select(Candle.time)
|
||||
.where(
|
||||
and_(
|
||||
Candle.instrument == instrument,
|
||||
Candle.granularity == granularity,
|
||||
Candle.time >= start,
|
||||
Candle.time <= end,
|
||||
)
|
||||
)
|
||||
.order_by(Candle.time)
|
||||
)
|
||||
result = await self._db.execute(stmt)
|
||||
times = [r[0] for r in result.fetchall()]
|
||||
|
||||
if not times:
|
||||
return [(start, end)]
|
||||
|
||||
gaps: list[tuple[datetime, datetime]] = []
|
||||
interval = timedelta(minutes=GRANULARITY_MINUTES.get(granularity, 60))
|
||||
oldest, newest = times[0], times[-1]
|
||||
|
||||
# Gap avant : demande de données antérieures à ce qu'on a
|
||||
if start < oldest - interval:
|
||||
gaps.append((start, oldest))
|
||||
|
||||
# Gap après : demande de données plus récentes que ce qu'on a
|
||||
freshness_threshold = interval * 2
|
||||
if end > newest + freshness_threshold:
|
||||
gaps.append((newest, end))
|
||||
|
||||
return gaps
|
||||
|
||||
async def _fetch_and_store_gap(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
gap_start: datetime,
|
||||
gap_end: datetime,
|
||||
) -> None:
|
||||
"""
|
||||
Fetche un gap :
|
||||
1. yfinance pour la partie récente (dans ses limites)
|
||||
2. TwelveData en fallback si yfinance échoue, ou pour la partie historique
|
||||
"""
|
||||
yf_cutoff = self._yf.yf_cutoff(granularity)
|
||||
yf_covered = False
|
||||
|
||||
# ── yfinance : partie récente du gap ─────────────────────────────────
|
||||
if yf_cutoff is not None:
|
||||
yf_start = max(gap_start, yf_cutoff)
|
||||
if yf_start < gap_end:
|
||||
df_yf = await self._yf.fetch(instrument, granularity, yf_start, gap_end)
|
||||
if not df_yf.empty:
|
||||
await self._store(df_yf, instrument, granularity)
|
||||
yf_covered = True
|
||||
|
||||
# ── TwelveData : historique + fallback si yfinance indisponible ───────
|
||||
if self._td.is_configured():
|
||||
# Partie historique (avant la limite yfinance)
|
||||
td_end = yf_cutoff if (yf_cutoff and gap_start < yf_cutoff) else None
|
||||
if td_end and gap_start < td_end:
|
||||
df_td = await self._td.fetch(instrument, granularity, gap_start, td_end)
|
||||
if not df_td.empty:
|
||||
await self._store(df_td, instrument, granularity)
|
||||
|
||||
# Fallback pour la partie récente si yfinance n'a rien retourné
|
||||
if not yf_covered:
|
||||
yf_start = max(gap_start, yf_cutoff) if yf_cutoff else gap_start
|
||||
if yf_start < gap_end:
|
||||
logger.info(
|
||||
"yfinance indisponible — fallback TwelveData pour %s %s [%s → %s]",
|
||||
instrument, granularity,
|
||||
yf_start.strftime("%Y-%m-%d"), gap_end.strftime("%Y-%m-%d"),
|
||||
)
|
||||
df_td2 = await self._td.fetch(instrument, granularity, yf_start, gap_end)
|
||||
if not df_td2.empty:
|
||||
await self._store(df_td2, instrument, granularity)
|
||||
elif not yf_covered:
|
||||
logger.warning(
|
||||
"Gap [%s → %s] pour %s %s — "
|
||||
"TWELVEDATA_API_KEY manquante et yfinance indisponible.",
|
||||
gap_start.strftime("%Y-%m-%d"),
|
||||
gap_end.strftime("%Y-%m-%d"),
|
||||
instrument,
|
||||
granularity,
|
||||
)
|
||||
|
||||
# ── DB helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _db_fetch(
|
||||
self,
|
||||
instrument: str,
|
||||
granularity: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
limit: int = 5000,
|
||||
) -> pd.DataFrame:
|
||||
stmt = (
|
||||
select(Candle)
|
||||
.where(
|
||||
and_(
|
||||
Candle.instrument == instrument,
|
||||
Candle.granularity == granularity,
|
||||
Candle.time >= start,
|
||||
Candle.time <= end,
|
||||
)
|
||||
)
|
||||
.order_by(Candle.time.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self._db.execute(stmt)
|
||||
rows = result.scalars().all()
|
||||
|
||||
if not rows:
|
||||
return pd.DataFrame(columns=["time", "open", "high", "low", "close", "volume"])
|
||||
|
||||
df = pd.DataFrame(
|
||||
[{"time": r.time, "open": r.open, "high": r.high,
|
||||
"low": r.low, "close": r.close, "volume": r.volume}
|
||||
for r in rows]
|
||||
)
|
||||
return df.sort_values("time").reset_index(drop=True)
|
||||
|
||||
async def _store(self, df: pd.DataFrame, instrument: str, granularity: str) -> None:
|
||||
"""
|
||||
Insère les bougies en DB avec INSERT OR IGNORE.
|
||||
Les bougies déjà présentes (même instrument+granularity+time) ne sont jamais modifiées.
|
||||
"""
|
||||
if df.empty:
|
||||
return
|
||||
|
||||
for _, row in df.iterrows():
|
||||
await self._db.execute(
|
||||
text(
|
||||
"INSERT OR IGNORE INTO candles "
|
||||
"(instrument, granularity, time, open, high, low, close, volume, complete) "
|
||||
"VALUES (:instrument, :granularity, :time, :open, :high, :low, :close, :volume, 1)"
|
||||
),
|
||||
{
|
||||
"instrument": instrument,
|
||||
"granularity": granularity,
|
||||
"time": pd.Timestamp(row["time"]).to_pydatetime().replace(tzinfo=None),
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": int(row.get("volume", 0)),
|
||||
},
|
||||
)
|
||||
|
||||
await self._db.commit()
|
||||
67
backend/app/services/trade_manager.py
Normal file
67
backend/app/services/trade_manager.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Gestion des positions : sizing, validation, suivi.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exchange.base import AbstractExchange, AccountInfo, OrderResult
|
||||
from app.core.strategy.base import TradeSignal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TradeManager:
|
||||
def __init__(self, exchange: AbstractExchange) -> None:
|
||||
self._exchange = exchange
|
||||
self._risk_percent = settings.bot_risk_percent
|
||||
self._pip_value = 0.0001
|
||||
|
||||
async def execute_signal(self, signal: TradeSignal) -> Optional[OrderResult]:
|
||||
"""
|
||||
Calcule le sizing et place l'ordre sur l'exchange.
|
||||
Retourne None si le signal est invalide ou si le sizing est trop petit.
|
||||
"""
|
||||
account = await self._exchange.get_account_info()
|
||||
|
||||
risk_pips = abs(signal.entry_price - signal.stop_loss) / self._pip_value
|
||||
if risk_pips < 1:
|
||||
logger.warning("Signal ignoré : risque en pips trop faible (%s)", risk_pips)
|
||||
return None
|
||||
|
||||
units = self._calculate_units(account, risk_pips, signal)
|
||||
if abs(units) < 1000:
|
||||
logger.warning("Signal ignoré : taille de position trop petite (%s)", units)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Exécution signal %s %s — entry=%.5f SL=%.5f TP=%.5f units=%d",
|
||||
signal.direction, signal.signal_type,
|
||||
signal.entry_price, signal.stop_loss, signal.take_profit, units,
|
||||
)
|
||||
return await self._exchange.place_order(
|
||||
instrument=signal.signal_type.split("+")[0] if "+" in signal.signal_type else "EUR_USD",
|
||||
units=units,
|
||||
stop_loss=signal.stop_loss,
|
||||
take_profit=signal.take_profit,
|
||||
)
|
||||
|
||||
def _calculate_units(
|
||||
self,
|
||||
account: AccountInfo,
|
||||
risk_pips: float,
|
||||
signal: TradeSignal,
|
||||
) -> float:
|
||||
"""
|
||||
Calcule la taille de position basée sur le risque défini.
|
||||
Formula : units = (balance * risk%) / (risk_pips * pip_value_per_unit)
|
||||
Pour EUR/USD standard : pip_value_per_unit = $0.0001
|
||||
"""
|
||||
risk_amount = account.balance * (self._risk_percent / 100)
|
||||
# Pour simplification : 1 pip = 0.0001, valeur pip sur mini-lot = $1
|
||||
pip_value_per_unit = self._pip_value
|
||||
raw_units = risk_amount / (risk_pips * pip_value_per_unit)
|
||||
# Arrondir à la centaine inférieure
|
||||
units = (int(raw_units) // 100) * 100
|
||||
return float(units) if signal.direction == "buy" else float(-units)
|
||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
pandas==2.2.3
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
aiosqlite==0.20.0
|
||||
pydantic-settings==2.6.1
|
||||
python-dotenv==1.0.1
|
||||
httpx==0.27.2
|
||||
websockets==13.1
|
||||
python-multipart==0.0.18
|
||||
yfinance==0.2.48
|
||||
Reference in New Issue
Block a user