feat: trading bot MVP — ICT Order Block + Liquidity Sweep strategy

Full-stack trading bot with:
- FastAPI backend with ICT strategy (Order Block + Liquidity Sweep detection)
- Backtester engine with rolling window, spread simulation, and performance metrics
- Hybrid market data service (yfinance + TwelveData with rate limiting + SQLite cache)
- Simulated exchange for paper trading
- React/TypeScript frontend with TradingView lightweight-charts v5
- Live dashboard with candlestick chart, OHLC legend, trade markers
- Backtest page with configurable parameters, equity curve, and trade table
- WebSocket support for real-time updates
- Bot runner with asyncio loop for automated trading

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:25:51 +01:00
commit 4df8d53b1a
58 changed files with 7484 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.venv/
venv/
env/
# Secrets — NE JAMAIS COMMITER
backend/.env
*.db
*.sqlite
# Frontend
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Editors
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/

138
CLAUDE.md Normal file
View File

@@ -0,0 +1,138 @@
# Trader Bot — CLAUDE.md
Bot de trading ICT (Order Block + Liquidity Sweep) avec interface web.
## Architecture
```
trader-bot/
├── backend/ # Python / FastAPI
│ ├── app/
│ │ ├── main.py # Entry point FastAPI
│ │ ├── core/
│ │ │ ├── config.py # Paramètres via .env
│ │ │ ├── database.py # SQLAlchemy async + SQLite
│ │ │ ├── exchange/
│ │ │ │ ├── base.py # AbstractExchange interface
│ │ │ │ └── oanda.py # Implémentation OANDA
│ │ │ ├── strategy/
│ │ │ │ ├── base.py # AbstractStrategy + dataclasses
│ │ │ │ └── order_block_sweep.py # Stratégie ICT principale
│ │ │ ├── backtester.py # Moteur backtest
│ │ │ └── bot.py # BotRunner asyncio
│ │ ├── models/ # SQLAlchemy (Candle, Trade, BacktestResult)
│ │ ├── services/
│ │ │ ├── market_data.py # Fetch + cache candles
│ │ │ └── trade_manager.py # Position sizing + exécution
│ │ └── api/
│ │ ├── routes/ # GET/POST endpoints
│ │ └── websocket.py # WS broadcast manager
│ ├── requirements.txt
│ └── .env # Clés OANDA (NE PAS COMMITER)
└── frontend/ # React / TypeScript / Vite
└── src/
├── components/Chart/ # CandlestickChart (lightweight-charts)
├── components/Dashboard/ # BotStatus, PnLSummary, TradeList
├── components/Backtest/ # BacktestForm, BacktestResults
├── pages/ # Dashboard.tsx, Backtest.tsx
├── hooks/useWebSocket.ts # WS auto-reconnect
└── lib/api.ts # Axios client typé
```
## Stack technique
- **Backend** : Python 3.11+, FastAPI, SQLAlchemy async, oandapyV20, pandas, pandas-ta
- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, lightweight-charts (TradingView)
- **Base de données** : SQLite (fichier `backend/trader_bot.db`)
- **Exchange** : OANDA v20 REST API (environment practice/live via `.env`)
## Commandes de développement
### Backend
```bash
cd backend
pip install -r requirements.txt
# Copier et configurer les variables d'environnement
cp .env.example .env
# Remplir OANDA_API_KEY et OANDA_ACCOUNT_ID dans .env
uvicorn app.main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
## Configuration OANDA
Variables dans `backend/.env` :
- `OANDA_API_KEY` : clé API (Settings → API Access sur fxpractice.oanda.com)
- `OANDA_ACCOUNT_ID` : ID du compte (format `001-004-XXXXXXX-001`)
- `OANDA_ENVIRONMENT` : `practice` (paper trading) ou `live`
Pour retrouver l'Account ID après démarrage du backend :
```
GET http://localhost:8000/api/bot/status
```
Ou via curl vers l'API OANDA :
```
curl -H "Authorization: Bearer <API_KEY>" https://api-fxpractice.oanda.com/v3/accounts
```
## Instruments supportés
| Instrument | Description |
|---|---|
| EUR_USD | Euro / Dollar |
| GBP_USD | Livre / Dollar |
| USD_JPY | Dollar / Yen |
| SPX500_USD | S&P 500 (CFD) |
| NAS100_USD | Nasdaq 100 (CFD) |
| XAU_USD | Or / Dollar |
## Stratégie — Order Block + Liquidity Sweep (ICT/SMC)
### Logique de détection (order_block_sweep.py)
1. **Swing Detection** : swing high/low sur `swing_strength` bougies de chaque côté
2. **Equal Highs/Lows** : deux swings proches (< `liquidity_tolerance_pips`) = pool de liquidité
3. **Liquidity Sweep** : wick qui dépasse le niveau ET close de l'autre côté
4. **Order Block** : dernière bougie bearish avant impulse haussière (bullish OB) / inverse
5. **Signal** : sweep confirmé + prix revient dans la zone OB
6. **SL** : au-delà de l'OB · **TP** : R:R fixe (`rr_ratio`)
### Paramètres configurables
| Param | Défaut | Description |
|---|---|---|
| `swing_strength` | 5 | N bougies de chaque côté pour valider un swing |
| `liquidity_tolerance_pips` | 2.0 | Tolérance Equal H/L en pips |
| `rr_ratio` | 2.0 | Ratio Risk/Reward cible |
| `min_impulse_candles` | 3 | Bougies min dans l'impulsion |
| `min_impulse_factor` | 1.5 | Taille impulsion vs corps moyen |
## API Endpoints
| Method | Route | Description |
|---|---|---|
| GET | `/api/candles` | Candles OANDA (params: instrument, granularity, count) |
| GET | `/api/trades` | Historique des trades (params: source, status) |
| POST | `/api/backtest` | Lance un backtest, retourne métriques + trades |
| GET | `/api/backtest/history` | 20 derniers backtests |
| GET | `/api/bot/status` | État du bot |
| POST | `/api/bot/start` | Démarrer le bot |
| POST | `/api/bot/stop` | Arrêter le bot |
| WS | `/ws/live` | Stream events temps réel |
## Roadmap / TODO
- [ ] Remplir `OANDA_ACCOUNT_ID` dans `backend/.env`
- [ ] Tester la connexion OANDA (`GET /api/candles?instrument=EUR_USD`)
- [ ] Valider la détection des Order Blocks sur données réelles
- [ ] Afficher les zones OB et niveaux de liquidité sur le chart (overlay)
- [ ] Ajouter le support multi-timeframe (HTF pour context + LTF pour entrée)
- [ ] Implémenter le trailing stop-loss
- [ ] Ajouter des notifications (email/telegram) sur les trades
- [ ] Ajouter tests unitaires pour la stratégie

18
backend/.env.example Normal file
View 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
View File

View File

View File

View 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
]

View 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é"}

View 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 [],
}

View 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
],
}

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

View File

View File

@@ -0,0 +1,269 @@
"""
Moteur de backtesting vectorisé.
Simule l'exécution de la stratégie sur des données historiques :
- Rolling window : analyse les N dernières bougies à chaque pas
- Gestion du spread OANDA
- Suivi des positions ouvertes (SL / TP hit)
- Calcul des métriques de performance
"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import numpy as np
import pandas as pd
from app.core.strategy.base import AbstractStrategy, TradeSignal
@dataclass
class BacktestTrade:
direction: str
entry_price: float
stop_loss: float
take_profit: float
entry_time: datetime
exit_price: Optional[float] = None
exit_time: Optional[datetime] = None
pnl_pips: Optional[float] = None
pnl_pct: Optional[float] = None
status: str = "open" # "win" | "loss" | "open"
signal_type: str = ""
@dataclass
class BacktestMetrics:
initial_balance: float
final_balance: float
total_pnl: float
total_pnl_pct: float
total_trades: int
winning_trades: int
losing_trades: int
win_rate: float
avg_win_pips: float
avg_loss_pips: float
expectancy: float
max_drawdown: float
max_drawdown_pct: float
sharpe_ratio: float
profit_factor: float
equity_curve: list[dict] = field(default_factory=list)
trades: list[BacktestTrade] = field(default_factory=list)
class Backtester:
def __init__(
self,
strategy: AbstractStrategy,
initial_balance: float = 10_000.0,
risk_percent: float = 1.0,
spread_pips: float = 1.5,
pip_value: float = 0.0001,
) -> None:
self.strategy = strategy
self.initial_balance = initial_balance
self.risk_percent = risk_percent # % du capital risqué par trade
self.spread_pips = spread_pips
self.pip_value = pip_value
def run(
self,
df: pd.DataFrame,
window: int = 100,
) -> BacktestMetrics:
"""
Exécute le backtest sur le DataFrame complet.
window : nombre de bougies analysées à chaque pas (rolling)
"""
df = df.copy().reset_index(drop=True)
trades: list[BacktestTrade] = []
balance = self.initial_balance
equity_curve: list[dict] = [{"time": str(df.iloc[0]["time"]), "balance": balance}]
open_trade: Optional[BacktestTrade] = None
last_signal_time: Optional[datetime] = None
used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded)
for i in range(window, len(df)):
candle = df.iloc[i]
# 1. Gérer la position ouverte (SL/TP hit ?)
if open_trade:
closed, exit_price = self._check_exit(open_trade, candle)
if closed:
pnl_pips = self._calc_pnl_pips(open_trade, exit_price)
pnl_money = self._pips_to_money(pnl_pips, balance)
balance += pnl_money
open_trade.exit_price = exit_price
open_trade.exit_time = candle["time"]
open_trade.pnl_pips = pnl_pips
open_trade.pnl_pct = pnl_money / self.initial_balance * 100
open_trade.status = "win" if pnl_pips > 0 else "loss"
trades.append(open_trade)
open_trade = None
equity_curve.append({"time": str(candle["time"]), "balance": balance})
# 2. Si pas de position ouverte, chercher un signal
if not open_trade:
slice_df = df.iloc[i - window:i + 1]
result = self.strategy.analyze(slice_df)
if result.signals:
# Filtrer les signaux déjà exploités (éviter doublons)
new_signals = [
s for s in result.signals
if last_signal_time is None or s.time > last_signal_time
]
if not new_signals:
continue
signal = new_signals[-1] # Signal le plus récent
# Appliquer le spread
entry, sl, tp = self._apply_spread(signal)
# Éviter de réouvrir le même trade (même direction + même prix)
sig_key = (signal.direction, round(entry / self.pip_value))
if sig_key in used_signals:
continue
used_signals.add(sig_key)
last_signal_time = signal.time
open_trade = BacktestTrade(
direction=signal.direction,
entry_price=entry,
stop_loss=sl,
take_profit=tp,
entry_time=candle["time"],
signal_type=signal.signal_type,
)
# Fermer la position ouverte en fin de période
if open_trade:
last_price = df.iloc[-1]["close"]
pnl_pips = self._calc_pnl_pips(open_trade, last_price)
pnl_money = self._pips_to_money(pnl_pips, balance)
balance += pnl_money
open_trade.exit_price = last_price
open_trade.exit_time = df.iloc[-1]["time"]
open_trade.pnl_pips = pnl_pips
open_trade.pnl_pct = pnl_money / self.initial_balance * 100
open_trade.status = "win" if pnl_pips > 0 else "loss"
trades.append(open_trade)
equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance})
return self._compute_metrics(trades, balance, equity_curve)
# ─── Helpers ──────────────────────────────────────────────────────────────
def _check_exit(
self, trade: BacktestTrade, candle: pd.Series
) -> tuple[bool, float]:
"""Vérifie si le SL ou TP est touché sur la bougie. Retourne (closed, exit_price)."""
if trade.direction == "buy":
if candle["low"] <= trade.stop_loss:
return True, trade.stop_loss
if candle["high"] >= trade.take_profit:
return True, trade.take_profit
else:
if candle["high"] >= trade.stop_loss:
return True, trade.stop_loss
if candle["low"] <= trade.take_profit:
return True, trade.take_profit
return False, 0.0
def _apply_spread(self, signal: TradeSignal) -> tuple[float, float, float]:
"""Applique le spread à l'entrée."""
half_spread = (self.spread_pips * self.pip_value) / 2
if signal.direction == "buy":
entry = signal.entry_price + half_spread
sl = signal.stop_loss
tp = signal.take_profit
else:
entry = signal.entry_price - half_spread
sl = signal.stop_loss
tp = signal.take_profit
return entry, sl, tp
def _calc_pnl_pips(self, trade: BacktestTrade, exit_price: float) -> float:
if trade.direction == "buy":
return (exit_price - trade.entry_price) / self.pip_value
return (trade.entry_price - exit_price) / self.pip_value
def _pips_to_money(self, pips: float, balance: float) -> float:
"""Convertit les pips en unités monétaires selon le risque défini."""
risk_amount = balance * (self.risk_percent / 100)
# Nombre de pips de risque sur ce trade
return pips * (risk_amount / 10) # Approximation simple
def _compute_metrics(
self,
trades: list[BacktestTrade],
final_balance: float,
equity_curve: list[dict],
) -> BacktestMetrics:
if not trades:
return BacktestMetrics(
initial_balance=self.initial_balance,
final_balance=final_balance,
total_pnl=0, total_pnl_pct=0,
total_trades=0, winning_trades=0, losing_trades=0,
win_rate=0, avg_win_pips=0, avg_loss_pips=0,
expectancy=0, max_drawdown=0, max_drawdown_pct=0,
sharpe_ratio=0, profit_factor=0,
equity_curve=equity_curve, trades=trades,
)
winners = [t for t in trades if t.status == "win"]
losers = [t for t in trades if t.status == "loss"]
total_pnl = final_balance - self.initial_balance
win_rate = len(winners) / len(trades) if trades else 0
avg_win = np.mean([t.pnl_pips for t in winners]) if winners else 0
avg_loss = abs(np.mean([t.pnl_pips for t in losers])) if losers else 0
expectancy = (win_rate * avg_win) - ((1 - win_rate) * avg_loss)
# Max Drawdown
balances = [eq["balance"] for eq in equity_curve]
peak = balances[0]
max_dd = 0.0
for b in balances:
if b > peak:
peak = b
dd = (peak - b) / peak
if dd > max_dd:
max_dd = dd
# Profit Factor
gross_profit = sum(t.pnl_pips for t in winners) if winners else 0
gross_loss = abs(sum(t.pnl_pips for t in losers)) if losers else 1
pf = gross_profit / gross_loss if gross_loss > 0 else 0
# Sharpe Ratio (simplifié — daily returns)
pnl_series = [t.pnl_pips or 0 for t in trades]
if len(pnl_series) > 1:
mean_ret = np.mean(pnl_series)
std_ret = np.std(pnl_series)
sharpe = (mean_ret / std_ret * np.sqrt(252)) if std_ret > 0 else 0
else:
sharpe = 0
return BacktestMetrics(
initial_balance=self.initial_balance,
final_balance=final_balance,
total_pnl=total_pnl,
total_pnl_pct=total_pnl / self.initial_balance * 100,
total_trades=len(trades),
winning_trades=len(winners),
losing_trades=len(losers),
win_rate=win_rate * 100,
avg_win_pips=float(avg_win),
avg_loss_pips=float(avg_loss),
expectancy=float(expectancy),
max_drawdown=max_dd * self.initial_balance,
max_drawdown_pct=max_dd * 100,
sharpe_ratio=float(sharpe),
profit_factor=float(pf),
equity_curve=equity_curve,
trades=trades,
)

155
backend/app/core/bot.py Normal file
View File

@@ -0,0 +1,155 @@
"""
BotRunner — boucle de trading live asynchrone.
Cycle :
1. Fetch les N dernières bougies
2. Analyser la stratégie
3. Si signal + pas de position ouverte → exécuter
4. Broadcast l'état via WebSocket
5. Attendre le prochain tick
"""
import asyncio
import logging
from datetime import datetime
from typing import Callable, Optional
from app.core.config import settings
from app.core.exchange.base import AbstractExchange
from app.core.strategy.base import AbstractStrategy, AnalysisResult
from app.services.trade_manager import TradeManager
logger = logging.getLogger(__name__)
# Interval en secondes selon la granularité
GRANULARITY_SECONDS = {
"M1": 60, "M5": 300, "M15": 900, "M30": 1800,
"H1": 3600, "H2": 7200, "H4": 14400, "H6": 21600,
"H8": 28800, "H12": 43200, "D": 86400,
}
class BotRunner:
def __init__(
self,
exchange: AbstractExchange,
strategy: AbstractStrategy,
trade_manager: TradeManager,
instrument: str = "",
granularity: str = "",
candles_window: int = 150,
) -> None:
self._exchange = exchange
self._strategy = strategy
self._trade_manager = trade_manager
self.instrument = instrument or settings.bot_default_instrument
self.granularity = granularity or settings.bot_default_granularity
self.candles_window = candles_window
self._running = False
self._task: Optional[asyncio.Task] = None
self._last_analysis: Optional[AnalysisResult] = None
self._started_at: Optional[datetime] = None
# Callback pour broadcaster les événements (WebSocket)
self._on_event: Optional[Callable] = None
@property
def is_running(self) -> bool:
return self._running
def set_event_callback(self, callback: Callable) -> None:
self._on_event = callback
async def start(self) -> None:
if self._running:
logger.warning("Bot déjà en cours d'exécution")
return
self._running = True
self._started_at = datetime.utcnow()
logger.info("Bot démarré — %s %s", self.instrument, self.granularity)
self._task = asyncio.create_task(self._loop())
async def stop(self) -> None:
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("Bot arrêté")
async def _loop(self) -> None:
interval = GRANULARITY_SECONDS.get(self.granularity, 3600)
# Décaler d'un peu pour s'assurer que la bougie est fermée
wait_seconds = max(interval // 10, 5)
while self._running:
try:
await self._tick()
except Exception as e:
logger.error("Erreur dans la boucle du bot: %s", e, exc_info=True)
await asyncio.sleep(wait_seconds)
async def _tick(self) -> None:
# 1. Fetch candles
df = await self._exchange.get_candles(
self.instrument, self.granularity, self.candles_window
)
if df.empty:
return
# 2. Analyser
result = self._strategy.analyze(df)
self._last_analysis = result
# 3. Vérifier les positions ouvertes
open_trades = await self._exchange.get_open_trades()
instrument_trades = [t for t in open_trades if t.instrument == self.instrument]
event = {
"type": "tick",
"instrument": self.instrument,
"granularity": self.granularity,
"last_close": float(df.iloc[-1]["close"]),
"last_time": str(df.iloc[-1]["time"]),
"order_blocks": len(result.order_blocks),
"liquidity_levels": len(result.liquidity_levels),
"signals": len(result.signals),
"open_positions": len(instrument_trades),
}
# 4. Exécuter le signal si aucune position ouverte
if result.signals and not instrument_trades:
signal = result.signals[-1]
try:
order = await self._trade_manager.execute_signal(signal)
if order:
logger.info(
"Trade exécuté : %s %s @ %.5f",
signal.direction, self.instrument, order.entry_price,
)
event["new_trade"] = {
"direction": order.direction,
"entry_price": order.entry_price,
"stop_loss": order.stop_loss,
"take_profit": order.take_profit,
}
except Exception as e:
logger.error("Erreur exécution trade: %s", e)
# 5. Broadcast
if self._on_event:
await self._on_event(event)
def get_status(self) -> dict:
return {
"running": self._running,
"instrument": self.instrument,
"granularity": self.granularity,
"started_at": str(self._started_at) if self._started_at else None,
"strategy": self._strategy.__class__.__name__,
"strategy_params": self._strategy.get_params(),
}

View File

@@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# TwelveData (fallback pour l'intraday historique)
# Clé gratuite sur https://twelvedata.com/
twelvedata_api_key: str = ""
# Bot defaults
bot_default_instrument: str = "EUR_USD"
bot_default_granularity: str = "H1"
bot_risk_percent: float = 1.0
bot_rr_ratio: float = 2.0
bot_initial_balance: float = 10_000.0
# Database
database_url: str = "sqlite+aiosqlite:///./trader_bot.db"
settings = Settings()

View File

@@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

View File

View File

@@ -0,0 +1,88 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
import pandas as pd
@dataclass
class OrderResult:
trade_id: str
instrument: str
direction: str # "buy" | "sell"
units: float
entry_price: float
stop_loss: float
take_profit: float
opened_at: datetime
@dataclass
class OpenTrade:
trade_id: str
instrument: str
direction: str
units: float
entry_price: float
stop_loss: float
take_profit: float
unrealized_pnl: float
opened_at: datetime
@dataclass
class AccountInfo:
balance: float
nav: float # Net Asset Value
unrealized_pnl: float
currency: str
class AbstractExchange(ABC):
"""Interface commune pour tous les exchanges/brokers."""
@abstractmethod
async def get_candles(
self,
instrument: str,
granularity: str,
count: int = 200,
from_time: Optional[datetime] = None,
to_time: Optional[datetime] = None,
) -> pd.DataFrame:
"""
Retourne un DataFrame avec colonnes : time, open, high, low, close, volume.
"""
...
@abstractmethod
async def place_order(
self,
instrument: str,
units: float, # positif = buy, négatif = sell
stop_loss: float,
take_profit: float,
) -> OrderResult:
"""Place un ordre au marché avec SL et TP."""
...
@abstractmethod
async def close_trade(self, trade_id: str) -> float:
"""Ferme un trade par son ID. Retourne le PnL réalisé."""
...
@abstractmethod
async def get_open_trades(self) -> list[OpenTrade]:
"""Retourne la liste des positions ouvertes."""
...
@abstractmethod
async def get_account_info(self) -> AccountInfo:
"""Retourne les infos du compte (solde, PnL, etc.)."""
...
@abstractmethod
async def get_price(self, instrument: str) -> float:
"""Retourne le prix mid actuel."""
...

View File

@@ -0,0 +1,171 @@
from datetime import datetime, timezone
from typing import Optional
import pandas as pd
from oandapyV20 import API
from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing
from app.core.config import settings
from app.core.exchange.base import (
AbstractExchange,
AccountInfo,
OpenTrade,
OrderResult,
)
# Granularités OANDA valides
GRANULARITIES = {
"M1", "M5", "M15", "M30",
"H1", "H2", "H4", "H6", "H8", "H12",
"D", "W", "M",
}
# Nombre max de candles par requête OANDA
MAX_CANDLES_PER_REQUEST = 5000
def _parse_time(ts: str) -> datetime:
"""Parse RFC3339 timestamp OANDA → datetime UTC."""
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
class OandaExchange(AbstractExchange):
def __init__(self) -> None:
env = "practice" if settings.oanda_environment == "practice" else "live"
self._client = API(
access_token=settings.oanda_api_key,
environment=env,
)
self._account_id = settings.oanda_account_id
async def get_candles(
self,
instrument: str,
granularity: str = "H1",
count: int = 200,
from_time: Optional[datetime] = None,
to_time: Optional[datetime] = None,
) -> pd.DataFrame:
if granularity not in GRANULARITIES:
raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}")
params: dict = {"granularity": granularity, "price": "M"}
if from_time and to_time:
params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ")
params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ")
else:
params["count"] = min(count, MAX_CANDLES_PER_REQUEST)
r = instruments.InstrumentsCandles(instrument, params=params)
self._client.request(r)
candles = r.response.get("candles", [])
rows = []
for c in candles:
if c.get("complete", True):
mid = c["mid"]
rows.append({
"time": _parse_time(c["time"]),
"open": float(mid["o"]),
"high": float(mid["h"]),
"low": float(mid["l"]),
"close": float(mid["c"]),
"volume": int(c.get("volume", 0)),
})
df = pd.DataFrame(rows)
if not df.empty:
df.sort_values("time", inplace=True)
df.reset_index(drop=True, inplace=True)
return df
async def place_order(
self,
instrument: str,
units: float,
stop_loss: float,
take_profit: float,
) -> OrderResult:
data = {
"order": {
"type": "MARKET",
"instrument": instrument,
"units": str(int(units)),
"stopLossOnFill": {"price": f"{stop_loss:.5f}"},
"takeProfitOnFill": {"price": f"{take_profit:.5f}"},
"timeInForce": "FOK",
}
}
r = orders.OrderCreate(self._account_id, data=data)
self._client.request(r)
resp = r.response
fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {})
trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown")
entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0))
direction = "buy" if units > 0 else "sell"
return OrderResult(
trade_id=trade_id,
instrument=instrument,
direction=direction,
units=abs(units),
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
opened_at=datetime.now(timezone.utc),
)
async def close_trade(self, trade_id: str) -> float:
r = trades.TradeClose(self._account_id, tradeID=trade_id)
self._client.request(r)
pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0))
return pnl
async def get_open_trades(self) -> list[OpenTrade]:
r = trades.OpenTrades(self._account_id)
self._client.request(r)
result = []
for t in r.response.get("trades", []):
units = float(t["currentUnits"])
direction = "buy" if units > 0 else "sell"
sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0
tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0
result.append(OpenTrade(
trade_id=t["id"],
instrument=t["instrument"],
direction=direction,
units=abs(units),
entry_price=float(t["price"]),
stop_loss=sl,
take_profit=tp,
unrealized_pnl=float(t.get("unrealizedPL", 0)),
opened_at=_parse_time(t["openTime"]),
))
return result
async def get_account_info(self) -> AccountInfo:
r = accounts.AccountSummary(self._account_id)
self._client.request(r)
acc = r.response["account"]
return AccountInfo(
balance=float(acc["balance"]),
nav=float(acc["NAV"]),
unrealized_pnl=float(acc.get("unrealizedPL", 0)),
currency=acc.get("currency", "USD"),
)
async def get_price(self, instrument: str) -> float:
r = pricing.PricingInfo(
self._account_id,
params={"instruments": instrument},
)
self._client.request(r)
prices = r.response.get("prices", [])
if not prices:
raise ValueError(f"Aucun prix pour {instrument}")
bid = float(prices[0]["bids"][0]["price"])
ask = float(prices[0]["asks"][0]["price"])
return (bid + ask) / 2

View File

@@ -0,0 +1,204 @@
"""
SimulatedExchange — paper trading 100% local, sans broker.
Les ordres sont simulés en mémoire. Les prix viennent du MarketDataService
(yfinance + TwelveData + cache DB).
"""
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
import pandas as pd
from app.core.exchange.base import (
AbstractExchange,
AccountInfo,
OpenTrade,
OrderResult,
)
logger = logging.getLogger(__name__)
class SimulatedExchange(AbstractExchange):
"""
Exchange simulé pour le paper trading.
Les positions sont gardées en mémoire (réinitialisées au redémarrage).
Les trades fermés sont persistés en DB via le BotRunner.
"""
def __init__(
self,
market_data_service, # MarketDataService — évite l'import circulaire
initial_balance: float = 10_000.0,
) -> None:
self._market_data = market_data_service
self._balance = initial_balance
self._initial_balance = initial_balance
self._open_trades: dict[str, OpenTrade] = {}
# ── AbstractExchange interface ────────────────────────────────────────────
async def get_candles(
self,
instrument: str,
granularity: str,
count: int = 200,
from_time=None,
to_time=None,
) -> pd.DataFrame:
return await self._market_data.get_candles(
instrument, granularity, count,
start=from_time, end=to_time,
)
async def place_order(
self,
instrument: str,
units: float,
stop_loss: float,
take_profit: float,
) -> OrderResult:
price = await self._market_data.get_latest_price(instrument)
if price is None:
raise ValueError(f"Prix introuvable pour {instrument}")
direction = "buy" if units > 0 else "sell"
trade_id = f"SIM-{uuid.uuid4().hex[:8].upper()}"
now = datetime.now(timezone.utc).replace(tzinfo=None)
trade = OpenTrade(
trade_id=trade_id,
instrument=instrument,
direction=direction,
units=abs(units),
entry_price=price,
stop_loss=stop_loss,
take_profit=take_profit,
unrealized_pnl=0.0,
opened_at=now,
)
self._open_trades[trade_id] = trade
logger.info(
"[SIM] Ordre ouvert %s %s %.2f @ %.5f | SL=%.5f TP=%.5f",
direction.upper(), instrument, abs(units), price, stop_loss, take_profit,
)
return OrderResult(
trade_id=trade_id,
instrument=instrument,
direction=direction,
units=abs(units),
entry_price=price,
stop_loss=stop_loss,
take_profit=take_profit,
opened_at=now,
)
async def close_trade(self, trade_id: str) -> float:
trade = self._open_trades.pop(trade_id, None)
if trade is None:
raise ValueError(f"Trade {trade_id} introuvable")
price = await self._market_data.get_latest_price(trade.instrument)
if price is None:
price = trade.entry_price
pnl = self._calc_pnl(trade, price)
self._balance += pnl
logger.info(
"[SIM] Trade fermé %s %s @ %.5f | PnL=%.2f | Balance=%.2f",
trade_id, trade.instrument, price, pnl, self._balance,
)
return pnl
async def get_open_trades(self) -> list[OpenTrade]:
# Mettre à jour le PnL flottant
updated: list[OpenTrade] = []
for trade in self._open_trades.values():
price = await self._market_data.get_latest_price(trade.instrument)
if price is not None:
pnl = self._calc_pnl(trade, price)
updated.append(OpenTrade(
trade_id=trade.trade_id,
instrument=trade.instrument,
direction=trade.direction,
units=trade.units,
entry_price=trade.entry_price,
stop_loss=trade.stop_loss,
take_profit=trade.take_profit,
unrealized_pnl=pnl,
opened_at=trade.opened_at,
))
else:
updated.append(trade)
return updated
async def get_account_info(self) -> AccountInfo:
open_trades = await self.get_open_trades()
unrealized = sum(t.unrealized_pnl for t in open_trades)
return AccountInfo(
balance=self._balance,
nav=self._balance + unrealized,
unrealized_pnl=unrealized,
currency="USD",
)
async def get_price(self, instrument: str) -> float:
price = await self._market_data.get_latest_price(instrument)
if price is None:
raise ValueError(f"Prix introuvable pour {instrument}")
return price
# ── Simulation du tick (appelée par BotRunner) ────────────────────────────
async def check_sl_tp(self, instrument: str) -> list[tuple[str, float]]:
"""
Vérifie si SL ou TP sont touchés pour les positions ouvertes.
Retourne la liste des (trade_id, pnl) des positions fermées.
"""
price = await self._market_data.get_latest_price(instrument)
if price is None:
return []
closed: list[tuple[str, float]] = []
for trade_id, trade in list(self._open_trades.items()):
if trade.instrument != instrument:
continue
hit = self._is_sl_tp_hit(trade, price)
if hit:
exit_price = trade.stop_loss if hit == "sl" else trade.take_profit
pnl = self._calc_pnl(trade, exit_price)
self._balance += pnl
del self._open_trades[trade_id]
logger.info(
"[SIM] %s touché — %s %s | PnL=%.2f",
hit.upper(), trade_id, trade.instrument, pnl,
)
closed.append((trade_id, pnl))
return closed
# ── Helpers ───────────────────────────────────────────────────────────────
def _calc_pnl(self, trade: OpenTrade, exit_price: float) -> float:
if trade.direction == "buy":
return (exit_price - trade.entry_price) * trade.units
return (trade.entry_price - exit_price) * trade.units
def _is_sl_tp_hit(self, trade: OpenTrade, current_price: float) -> Optional[str]:
if trade.direction == "buy":
if current_price <= trade.stop_loss:
return "sl"
if current_price >= trade.take_profit:
return "tp"
else:
if current_price >= trade.stop_loss:
return "sl"
if current_price <= trade.take_profit:
return "tp"
return None

View File

View File

@@ -0,0 +1,66 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
import pandas as pd
@dataclass
class OrderBlockZone:
"""Zone Order Block à surveiller."""
id: str
direction: str # "bullish" | "bearish"
top: float # Haut de la zone
bottom: float # Bas de la zone
origin_time: pd.Timestamp
mitigated: bool = False # True si le prix a traversé la zone
@dataclass
class LiquidityLevel:
"""Niveau de liquidité (Equal H/L)."""
id: str
direction: str # "high" | "low"
price: float
origin_time: pd.Timestamp
swept: bool = False # True si le prix a dépassé ce niveau
@dataclass
class TradeSignal:
"""Signal de trading généré par la stratégie."""
direction: str # "buy" | "sell"
entry_price: float
stop_loss: float
take_profit: float
signal_type: str # description du setup
time: pd.Timestamp
order_block: Optional[OrderBlockZone] = None
liquidity_level: Optional[LiquidityLevel] = None
@dataclass
class AnalysisResult:
"""Résultat complet de l'analyse de la stratégie."""
order_blocks: list[OrderBlockZone] = field(default_factory=list)
liquidity_levels: list[LiquidityLevel] = field(default_factory=list)
signals: list[TradeSignal] = field(default_factory=list)
class AbstractStrategy(ABC):
"""Interface commune pour toutes les stratégies."""
@abstractmethod
def analyze(self, df: pd.DataFrame) -> AnalysisResult:
"""
Analyse un DataFrame de candles et retourne les zones,
niveaux de liquidité et signaux d'entrée.
df doit avoir les colonnes : time, open, high, low, close, volume
"""
...
@abstractmethod
def get_params(self) -> dict:
"""Retourne les paramètres configurables de la stratégie."""
...

View File

@@ -0,0 +1,381 @@
"""
Stratégie ICT — Order Block + Liquidity Sweep
Logique :
1. Détecter les swing highs/lows (N bougies de chaque côté)
2. Identifier les niveaux de liquidité : Equal Highs (EQH) / Equal Lows (EQL)
- Deux swing H/L proches dans une tolérance de X pips = pool de liquidité
3. Détecter un Liquidity Sweep :
- Le wick d'une bougie dépasse un EQH/EQL, mais la bougie close de l'autre côté
→ Confirmation que la liquidité a été absorbée (stop hunt)
4. Identifier l'Order Block dans la direction du renversement :
- Bullish OB : dernière bougie bearish (close < open) avant le mouvement impulsif haussier
- Bearish OB : dernière bougie bullish (close > open) avant le mouvement impulsif bearish
5. Signal d'entrée : le prix revient dans la zone OB après le sweep
6. SL : en dessous du bas de l'OB (buy) ou au-dessus du haut (sell)
7. TP : niveau de liquidité opposé ou R:R fixe
"""
from dataclasses import dataclass
from typing import Optional
import pandas as pd
from app.core.strategy.base import (
AbstractStrategy,
AnalysisResult,
LiquidityLevel,
OrderBlockZone,
TradeSignal,
)
@dataclass
class OrderBlockSweepParams:
swing_strength: int = 5 # N bougies de chaque côté pour valider un swing
liquidity_tolerance_pips: float = 2.0 # Tolérance en pips pour Equal H/L
pip_value: float = 0.0001 # Valeur d'un pip (0.0001 pour Forex, 0.01 pour JPY)
min_impulse_candles: int = 3 # Nombre min de bougies dans l'impulsion
min_impulse_factor: float = 1.5 # Taille impulsion vs bougie moyenne
rr_ratio: float = 2.0 # Ratio Risk/Reward pour le TP
class OrderBlockSweepStrategy(AbstractStrategy):
def __init__(self, params: Optional[OrderBlockSweepParams] = None) -> None:
self.params = params or OrderBlockSweepParams()
def get_params(self) -> dict:
return {
"swing_strength": self.params.swing_strength,
"liquidity_tolerance_pips": self.params.liquidity_tolerance_pips,
"pip_value": self.params.pip_value,
"min_impulse_candles": self.params.min_impulse_candles,
"min_impulse_factor": self.params.min_impulse_factor,
"rr_ratio": self.params.rr_ratio,
}
def analyze(self, df: pd.DataFrame) -> AnalysisResult:
if len(df) < self.params.swing_strength * 2 + 5:
return AnalysisResult()
df = df.copy().reset_index(drop=True)
swings = self._detect_swings(df)
liquidity_levels = self._detect_liquidity(df, swings)
order_blocks = self._detect_order_blocks(df)
signals = self._detect_signals(df, liquidity_levels, order_blocks)
return AnalysisResult(
order_blocks=order_blocks,
liquidity_levels=liquidity_levels,
signals=signals,
)
# ─── Swing Detection ──────────────────────────────────────────────────────
def _detect_swings(self, df: pd.DataFrame) -> pd.DataFrame:
"""Retourne un DataFrame avec colonnes swing_high et swing_low (bool)."""
n = self.params.swing_strength
highs = df["high"].values
lows = df["low"].values
swing_high = [False] * len(df)
swing_low = [False] * len(df)
for i in range(n, len(df) - n):
# Swing High : plus haut que les N bougies avant et après
if all(highs[i] > highs[i - j] for j in range(1, n + 1)) and \
all(highs[i] > highs[i + j] for j in range(1, n + 1)):
swing_high[i] = True
# Swing Low : plus bas que les N bougies avant et après
if all(lows[i] < lows[i - j] for j in range(1, n + 1)) and \
all(lows[i] < lows[i + j] for j in range(1, n + 1)):
swing_low[i] = True
df = df.copy()
df["swing_high"] = swing_high
df["swing_low"] = swing_low
return df
# ─── Liquidity Detection ──────────────────────────────────────────────────
def _detect_liquidity(
self, df: pd.DataFrame, swings: pd.DataFrame
) -> list[LiquidityLevel]:
"""
Identifie les Equal Highs (EQH) et Equal Lows (EQL).
Deux swing H/L sont "égaux" si leur différence est < tolerance_pips.
"""
tol = self.params.liquidity_tolerance_pips * self.params.pip_value
levels: list[LiquidityLevel] = []
swing_highs = swings[swings["swing_high"]].copy()
swing_lows = swings[swings["swing_low"]].copy()
# Equal Highs
sh_prices = swing_highs["high"].values
sh_times = swing_highs["time"].values
for i in range(len(sh_prices)):
for j in range(i + 1, len(sh_prices)):
if abs(sh_prices[i] - sh_prices[j]) <= tol:
level_price = (sh_prices[i] + sh_prices[j]) / 2
# Vérifier si déjà sweepé dans les données actuelles
swept = self._is_level_swept(df, level_price, "high", sh_times[j])
levels.append(LiquidityLevel(
id=f"EQH_{i}_{j}",
direction="high",
price=level_price,
origin_time=pd.Timestamp(sh_times[j]),
swept=swept,
))
# Equal Lows
sl_prices = swing_lows["low"].values
sl_times = swing_lows["time"].values
for i in range(len(sl_prices)):
for j in range(i + 1, len(sl_prices)):
if abs(sl_prices[i] - sl_prices[j]) <= tol:
level_price = (sl_prices[i] + sl_prices[j]) / 2
swept = self._is_level_swept(df, level_price, "low", sl_times[j])
levels.append(LiquidityLevel(
id=f"EQL_{i}_{j}",
direction="low",
price=level_price,
origin_time=pd.Timestamp(sl_times[j]),
swept=swept,
))
return levels
def _is_level_swept(
self,
df: pd.DataFrame,
price: float,
direction: str,
after_time,
) -> bool:
"""Vérifie si un niveau a été sweepé après sa formation."""
mask = df["time"] > pd.Timestamp(after_time)
future = df[mask]
if future.empty:
return False
if direction == "high":
return bool((future["high"] > price).any())
return bool((future["low"] < price).any())
# ─── Order Block Detection ────────────────────────────────────────────────
def _detect_order_blocks(self, df: pd.DataFrame) -> list[OrderBlockZone]:
"""
Détecte les Order Blocks :
- Bullish OB : dernière bougie bearish avant une impulsion haussière significative
- Bearish OB : dernière bougie bullish avant une impulsion bearish significative
"""
blocks: list[OrderBlockZone] = []
min_imp = self.params.min_impulse_candles
factor = self.params.min_impulse_factor
avg_body = (df["close"] - df["open"]).abs().mean()
for i in range(1, len(df) - min_imp):
# Chercher une impulsion haussière après la bougie i
impulse_up = self._is_impulse(df, i, "up", min_imp, factor, avg_body)
if impulse_up:
# Chercher la dernière bougie bearish avant i (inclus)
for k in range(i, max(0, i - 10) - 1, -1):
if df.loc[k, "close"] < df.loc[k, "open"]: # bearish
mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp)
blocks.append(OrderBlockZone(
id=f"BullishOB_{k}",
direction="bullish",
top=df.loc[k, "open"], # top = open de la bougie bearish
bottom=df.loc[k, "low"],
origin_time=df.loc[k, "time"],
mitigated=mitigated,
))
break
# Chercher une impulsion bearish après la bougie i
impulse_down = self._is_impulse(df, i, "down", min_imp, factor, avg_body)
if impulse_down:
for k in range(i, max(0, i - 10) - 1, -1):
if df.loc[k, "close"] > df.loc[k, "open"]: # bullish
mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp)
blocks.append(OrderBlockZone(
id=f"BearishOB_{k}",
direction="bearish",
top=df.loc[k, "high"],
bottom=df.loc[k, "open"], # bottom = open de la bougie bullish
origin_time=df.loc[k, "time"],
mitigated=mitigated,
))
break
# Dédupliquer (garder le plus récent par zone)
return self._deduplicate_obs(blocks)
def _is_impulse(
self,
df: pd.DataFrame,
start: int,
direction: str,
min_candles: int,
factor: float,
avg_body: float,
) -> bool:
"""Vérifie si une impulsion directionnelle commence à l'index start."""
end = min(start + min_candles, len(df))
segment = df.iloc[start:end]
if len(segment) < min_candles:
return False
total_move = abs(segment.iloc[-1]["close"] - segment.iloc[0]["open"])
if total_move < avg_body * factor:
return False
if direction == "up":
return segment.iloc[-1]["close"] > segment.iloc[0]["open"]
return segment.iloc[-1]["close"] < segment.iloc[0]["open"]
def _is_ob_mitigated(
self,
df: pd.DataFrame,
ob_idx: int,
direction: str,
after_idx: int,
) -> bool:
"""Vérifie si un OB a été mitié (prix est revenu dans la zone)."""
top = df.loc[ob_idx, "open"] if direction == "bullish" else df.loc[ob_idx, "high"]
bottom = df.loc[ob_idx, "low"] if direction == "bullish" else df.loc[ob_idx, "open"]
future = df.iloc[after_idx:]
if direction == "bullish":
return bool(((future["low"] <= top) & (future["low"] >= bottom)).any())
return bool(((future["high"] >= bottom) & (future["high"] <= top)).any())
def _deduplicate_obs(self, blocks: list[OrderBlockZone]) -> list[OrderBlockZone]:
"""Supprime les OB en double (même direction et zone proche)."""
seen_ids: set[str] = set()
unique: list[OrderBlockZone] = []
for b in blocks:
if b.id not in seen_ids:
seen_ids.add(b.id)
unique.append(b)
return unique
# ─── Signal Detection ─────────────────────────────────────────────────────
def _detect_signals(
self,
df: pd.DataFrame,
liquidity_levels: list[LiquidityLevel],
order_blocks: list[OrderBlockZone],
) -> list[TradeSignal]:
"""
Génère des signaux quand :
1. Un niveau de liquidité est sweepé (wick dépasse + close de l'autre côté)
2. Le prix revient dans un OB de direction opposée au sweep
"""
signals: list[TradeSignal] = []
# Travailler sur les 50 dernières bougies pour les signaux récents
lookback = df.tail(50).copy()
if len(lookback) < 2:
return signals
for level in liquidity_levels:
if level.swept:
continue
for i in range(1, len(lookback)):
candle = lookback.iloc[i]
# Sweep d'un Equal High → signal SELL potentiel
if level.direction == "high":
sweep = (
candle["high"] > level.price and # wick dépasse
candle["close"] < level.price # close en dessous
)
if sweep:
signal = self._find_entry_signal(
df=lookback,
sweep_idx=i,
direction="sell",
swept_level=level,
order_blocks=order_blocks,
)
if signal:
signals.append(signal)
break # Un seul signal par niveau
# Sweep d'un Equal Low → signal BUY potentiel
elif level.direction == "low":
sweep = (
candle["low"] < level.price and # wick dépasse
candle["close"] > level.price # close au-dessus
)
if sweep:
signal = self._find_entry_signal(
df=lookback,
sweep_idx=i,
direction="buy",
swept_level=level,
order_blocks=order_blocks,
)
if signal:
signals.append(signal)
break # Un seul signal par niveau
return signals
def _find_entry_signal(
self,
df: pd.DataFrame,
sweep_idx: int,
direction: str,
swept_level: LiquidityLevel,
order_blocks: list[OrderBlockZone],
) -> Optional[TradeSignal]:
"""
Cherche un OB valide dans la direction du signal après le sweep.
"""
sweep_time = df.iloc[sweep_idx]["time"]
sweep_price = df.iloc[sweep_idx]["close"]
# Chercher un OB non mitié dans la bonne direction
ob_direction = "bullish" if direction == "buy" else "bearish"
candidate_obs = [
ob for ob in order_blocks
if ob.direction == ob_direction
and not ob.mitigated
and ob.origin_time <= sweep_time
]
if not candidate_obs:
return None
# Prendre l'OB le plus récent
ob = max(candidate_obs, key=lambda x: x.origin_time)
# Vérifier que le prix actuel est proche de l'OB ou dans la zone
if direction == "buy":
# Le prix doit être au-dessus ou proche du bas de l'OB
if sweep_price < ob.bottom * 0.998: # trop loin en dessous
return None
entry = ob.top
sl = ob.bottom - 2 * self.params.pip_value
tp = entry + (entry - sl) * self.params.rr_ratio
else:
if sweep_price > ob.top * 1.002:
return None
entry = ob.bottom
sl = ob.top + 2 * self.params.pip_value
tp = entry - (sl - entry) * self.params.rr_ratio
return TradeSignal(
direction=direction,
entry_price=entry,
stop_loss=sl,
take_profit=tp,
signal_type=f"LiquiditySweep_{swept_level.direction.upper()}+OrderBlock",
time=sweep_time,
order_block=ob,
liquidity_level=swept_level,
)

90
backend/app/main.py Normal file
View 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),
},
}

View File

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

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

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

View File

View 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",
}

View 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

View 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

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

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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trader Bot — ICT Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3377
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
frontend/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "trader-bot-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lightweight-charts": "^5.1.0",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

53
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { BrowserRouter, NavLink, Route, Routes } from 'react-router-dom'
import { BarChart2, Bot, FlaskConical } from 'lucide-react'
import Dashboard from './pages/Dashboard'
import Backtest from './pages/Backtest'
export default function App() {
return (
<BrowserRouter>
<div className="flex h-screen overflow-hidden bg-[#0f1117]">
{/* Sidebar */}
<aside className="w-16 flex flex-col items-center gap-4 py-6 bg-[#1a1d27] border-r border-[#2a2d3e]">
<div className="text-[#6366f1] mb-4">
<Bot size={28} />
</div>
<NavLink
to="/"
title="Dashboard"
className={({ isActive }) =>
`p-3 rounded-xl transition-colors ${
isActive
? 'bg-[#6366f1] text-white'
: 'text-[#64748b] hover:text-white hover:bg-[#2a2d3e]'
}`
}
>
<BarChart2 size={20} />
</NavLink>
<NavLink
to="/backtest"
title="Backtest"
className={({ isActive }) =>
`p-3 rounded-xl transition-colors ${
isActive
? 'bg-[#6366f1] text-white'
: 'text-[#64748b] hover:text-white hover:bg-[#2a2d3e]'
}`
}
>
<FlaskConical size={20} />
</NavLink>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/backtest" element={<Backtest />} />
</Routes>
</main>
</div>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { FlaskConical, Loader2 } from 'lucide-react'
import type { BacktestResult } from '../../lib/api'
import { runBacktest } from '../../lib/api'
interface Props {
onResult: (result: BacktestResult) => void
}
const INSTRUMENTS = [
'EUR_USD', 'GBP_USD', 'USD_JPY', 'USD_CHF', 'AUD_USD', 'USD_CAD',
'GBP_JPY', 'EUR_JPY', 'SPX500_USD', 'NAS100_USD', 'XAU_USD',
]
const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D']
export default function BacktestForm({ onResult }: Props) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState({
instrument: 'EUR_USD',
granularity: 'H1',
candle_count: 500,
initial_balance: 10000,
risk_percent: 1.0,
rr_ratio: 2.0,
swing_strength: 5,
liquidity_tolerance_pips: 2.0,
spread_pips: 1.5,
})
const handleChange = (key: string, value: string | number) => {
setForm((prev) => ({ ...prev, [key]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const result = await runBacktest(form)
onResult(result)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
setError(msg)
} finally {
setLoading(false)
}
}
const Field = ({
label, name, type = 'number', options
}: {
label: string; name: string; type?: string; options?: string[]
}) => (
<div>
<label className="block text-xs text-[#64748b] mb-1">{label}</label>
{options ? (
<select
value={form[name as keyof typeof form]}
onChange={(e) => handleChange(name, e.target.value)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-[#6366f1]"
>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<input
type={type}
step="any"
value={form[name as keyof typeof form]}
onChange={(e) => handleChange(name, parseFloat(e.target.value) || 0)}
className="w-full bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-[#6366f1]"
/>
)}
</div>
)
return (
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
<h2 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider mb-4">
Paramètres du backtest
</h2>
<div className="grid grid-cols-2 gap-3">
<Field label="Instrument" name="instrument" options={INSTRUMENTS} />
<Field label="Timeframe" name="granularity" options={GRANULARITIES} />
<Field label="Nombre de bougies" name="candle_count" />
<Field label="Capital initial ($)" name="initial_balance" />
<Field label="Risque par trade (%)" name="risk_percent" />
<Field label="Ratio R:R" name="rr_ratio" />
<Field label="Swing strength" name="swing_strength" />
<Field label="Tolérance liquidité (pips)" name="liquidity_tolerance_pips" />
<Field label="Spread (pips)" name="spread_pips" />
</div>
{error && (
<div className="text-sm text-[#ef5350] bg-[#ef535020] border border-[#ef535033] rounded-lg px-3 py-2">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 py-2.5 bg-[#6366f1] hover:bg-[#4f46e5] text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-60"
>
{loading ? (
<><Loader2 size={16} className="animate-spin" /> Backtesting...</>
) : (
<><FlaskConical size={16} /> Lancer le backtest</>
)}
</button>
</form>
)
}

View File

@@ -0,0 +1,163 @@
import { createChart, LineSeries, ColorType, type Time } from 'lightweight-charts'
import { useEffect, useRef } from 'react'
import type { BacktestResult } from '../../lib/api'
interface Props {
result: BacktestResult
}
function MetricCard({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg p-3">
<div className="text-xs text-[#64748b] mb-1">{label}</div>
<div className={`text-lg font-bold ${color ?? 'text-white'}`}>{value}</div>
</div>
)
}
export default function BacktestResults({ result }: Props) {
const { metrics, equity_curve, trades } = result
const chartRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!chartRef.current || equity_curve.length === 0) return
const chart = createChart(chartRef.current, {
width: chartRef.current.clientWidth,
height: 220,
layout: {
background: { type: ColorType.Solid, color: '#0f1117' },
textColor: '#64748b',
},
grid: { vertLines: { color: '#1a1d27' }, horzLines: { color: '#1a1d27' } },
rightPriceScale: { borderColor: '#2a2d3e' },
timeScale: { borderColor: '#2a2d3e', timeVisible: true },
})
const lineSeries = chart.addSeries(LineSeries, {
color: '#6366f1',
lineWidth: 2,
})
lineSeries.setData(
equity_curve.map((p) => ({
time: (new Date(p.time).getTime() / 1000) as Time,
value: p.balance,
}))
)
chart.timeScale().fitContent()
const observer = new ResizeObserver(() => {
if (chartRef.current) chart.applyOptions({ width: chartRef.current.clientWidth })
})
observer.observe(chartRef.current)
return () => {
observer.disconnect()
chart.remove()
}
}, [equity_curve])
const pnlPositive = metrics.total_pnl >= 0
return (
<div className="space-y-4">
{/* Métriques clés */}
<div className="grid grid-cols-3 gap-3">
<MetricCard
label="PnL Total"
value={`${pnlPositive ? '+' : ''}${metrics.total_pnl.toFixed(2)} (${metrics.total_pnl_pct.toFixed(1)}%)`}
color={pnlPositive ? 'text-[#26a69a]' : 'text-[#ef5350]'}
/>
<MetricCard
label="Win Rate"
value={`${metrics.win_rate.toFixed(1)}%`}
color={metrics.win_rate >= 50 ? 'text-[#26a69a]' : 'text-[#ef5350]'}
/>
<MetricCard label="Trades" value={`${metrics.winning_trades}W / ${metrics.losing_trades}L`} />
<MetricCard label="Drawdown max" value={`-${metrics.max_drawdown_pct.toFixed(1)}%`} color="text-[#ef5350]" />
<MetricCard label="Sharpe Ratio" value={metrics.sharpe_ratio.toFixed(3)} />
<MetricCard label="Profit Factor" value={metrics.profit_factor.toFixed(2)} />
<MetricCard label="Expectancy (pips)" value={metrics.expectancy.toFixed(1)} />
<MetricCard label="Avg Win" value={`+${metrics.avg_win_pips.toFixed(1)} pips`} color="text-[#26a69a]" />
<MetricCard label="Avg Loss" value={`-${metrics.avg_loss_pips.toFixed(1)} pips`} color="text-[#ef5350]" />
</div>
{/* Equity Curve */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<h3 className="text-xs font-semibold text-[#64748b] uppercase tracking-wider mb-3">
Courbe d'équité
</h3>
<div ref={chartRef} />
</div>
{/* Tableau des trades */}
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a2d3e] flex items-center justify-between">
<h3 className="text-xs font-semibold text-[#64748b] uppercase tracking-wider">
Trades simulés
</h3>
<span className="text-xs text-[#64748b]">{trades.length} trades</span>
</div>
<div className="overflow-x-auto max-h-64">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-[#1a1d27]">
<tr className="text-[#64748b] border-b border-[#2a2d3e]">
<th className="text-left px-3 py-2">Direction</th>
<th className="text-left px-3 py-2">Signal</th>
<th className="text-right px-3 py-2">Entrée</th>
<th className="text-right px-3 py-2">Sortie</th>
<th className="text-right px-3 py-2">PnL (pips)</th>
<th className="text-left px-3 py-2">Résultat</th>
<th className="text-left px-3 py-2">Ouverture</th>
<th className="text-left px-3 py-2">Fermeture</th>
</tr>
</thead>
<tbody>
{trades.map((t, i) => (
<tr key={i} className="border-b border-[#2a2d3e20] hover:bg-[#2a2d3e20]">
<td className="px-3 py-2">
<span className={`px-1.5 py-0.5 rounded font-semibold ${
t.direction === 'buy'
? 'bg-[#26a69a20] text-[#26a69a]'
: 'bg-[#ef535020] text-[#ef5350]'
}`}>
{t.direction.toUpperCase()}
</span>
</td>
<td className="px-3 py-2 text-[#94a3b8] text-[10px] max-w-[140px] truncate" title={t.signal_type}>
{t.signal_type.replace('LiquiditySweep_', 'Sweep ').replace('+OrderBlock', ' + OB')}
</td>
<td className="px-3 py-2 text-right font-mono">{t.entry_price.toFixed(5)}</td>
<td className="px-3 py-2 text-right font-mono text-[#64748b]">
{t.exit_price?.toFixed(5) ?? ''}
</td>
<td className={`px-3 py-2 text-right font-mono ${
(t.pnl_pips ?? 0) > 0 ? 'text-[#26a69a]' : 'text-[#ef5350]'
}`}>
{t.pnl_pips !== null
? `${t.pnl_pips > 0 ? '+' : ''}${t.pnl_pips.toFixed(1)}`
: ''}
</td>
<td className="px-3 py-2">
<span className={t.status === 'win' ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
{t.status === 'win' ? ' Win' : ' Loss'}
</span>
</td>
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
{new Date(t.entry_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
<td className="px-3 py-2 text-[#64748b] whitespace-nowrap">
{t.exit_time
? new Date(t.exit_time).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
: ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,229 @@
import {
createChart,
CandlestickSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type SeriesMarker,
type Time,
ColorType,
} from 'lightweight-charts'
import { useEffect, useRef, useState } from 'react'
import type { Candle, BacktestTrade, Trade } from '../../lib/api'
interface OrderBlock {
direction: 'bullish' | 'bearish'
top: number
bottom: number
origin_time: string
mitigated: boolean
}
interface LiquidityLevel {
direction: 'high' | 'low'
price: number
origin_time: string
swept: boolean
}
interface Props {
candles: Candle[]
orderBlocks?: OrderBlock[]
liquidityLevels?: LiquidityLevel[]
trades?: (BacktestTrade | Trade)[]
height?: number
}
interface OhlcLegend {
time: string
open: number
high: number
low: number
close: number
}
function toTimestamp(t: string): Time {
return (new Date(t).getTime() / 1000) as Time
}
function pricePrecision(price: number): { precision: number; minMove: number } {
if (price >= 100) return { precision: 2, minMove: 0.01 }
if (price >= 10) return { precision: 3, minMove: 0.001 }
return { precision: 5, minMove: 0.00001 }
}
function fmt(v: number): string {
if (v >= 100) return v.toFixed(2)
if (v >= 10) return v.toFixed(3)
return v.toFixed(5)
}
export default function CandlestickChart({
candles,
orderBlocks = [],
trades = [],
height = 500,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const [legend, setLegend] = useState<OhlcLegend | null>(null)
useEffect(() => {
if (!containerRef.current) return
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height,
layout: {
background: { type: ColorType.Solid, color: '#0f1117' },
textColor: '#64748b',
},
grid: {
vertLines: { color: '#1a1d27' },
horzLines: { color: '#1a1d27' },
},
crosshair: {
vertLine: { color: '#6366f1', labelBackgroundColor: '#6366f1' },
horzLine: { color: '#6366f1', labelBackgroundColor: '#6366f1' },
},
rightPriceScale: { borderColor: '#2a2d3e' },
timeScale: {
borderColor: '#2a2d3e',
timeVisible: true,
secondsVisible: false,
},
})
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
wickUpColor: '#26a69a',
wickDownColor: '#ef5350',
})
chartRef.current = chart
candleSeriesRef.current = candleSeries
chart.subscribeCrosshairMove((param) => {
if (!param.seriesData.size) {
setLegend(null)
return
}
const d = param.seriesData.get(candleSeries) as CandlestickData | undefined
if (!d) return
const ts = typeof d.time === 'number' ? d.time * 1000 : 0
setLegend({
time: new Date(ts).toUTCString().slice(5, 22),
open: d.open,
high: d.high,
low: d.low,
close: d.close,
})
})
const resizeObserver = new ResizeObserver(() => {
if (containerRef.current) {
chart.applyOptions({ width: containerRef.current.clientWidth })
}
})
resizeObserver.observe(containerRef.current)
return () => {
resizeObserver.disconnect()
chart.remove()
}
}, [height])
// Mise à jour des données
useEffect(() => {
if (!candleSeriesRef.current) return
if (candles.length === 0) {
candleSeriesRef.current.setData([])
return
}
const { precision, minMove } = pricePrecision(candles[0].close)
candleSeriesRef.current.applyOptions({
priceFormat: { type: 'price', precision, minMove },
})
const data: CandlestickData[] = candles.map((c) => ({
time: toTimestamp(c.time),
open: c.open,
high: c.high,
low: c.low,
close: c.close,
}))
candleSeriesRef.current.setData(data)
// Markers pour les trades (v5 API)
const markers: SeriesMarker<Time>[] = []
for (const t of trades) {
const entryTime = toTimestamp('entry_time' in t ? t.entry_time : t.opened_at)
markers.push({
time: entryTime,
position: t.direction === 'buy' ? 'belowBar' : 'aboveBar',
color: t.direction === 'buy' ? '#26a69a' : '#ef5350',
shape: t.direction === 'buy' ? 'arrowUp' : 'arrowDown',
text: t.direction === 'buy' ? 'BUY' : 'SELL',
size: 1,
})
const exitTime = 'exit_time' in t ? t.exit_time : t.closed_at
if (exitTime) {
const won = 'pnl_pips' in t ? (t.pnl_pips ?? 0) > 0 : (t.pnl ?? 0) > 0
markers.push({
time: toTimestamp(exitTime),
position: t.direction === 'buy' ? 'aboveBar' : 'belowBar',
color: won ? '#26a69a' : '#ef5350',
shape: 'circle',
text: won ? '✓' : '✗',
size: 0.8,
})
}
}
if (markers.length > 0) {
markers.sort((a, b) => (a.time as number) - (b.time as number))
createSeriesMarkers(candleSeriesRef.current, markers)
}
chartRef.current?.timeScale().fitContent()
}, [candles, trades])
return (
<div className="relative w-full rounded-xl overflow-hidden border border-[#2a2d3e]">
{/* Légende OHLC */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-3 text-xs font-mono">
{legend ? (
<>
<span className="text-[#64748b]">{legend.time}</span>
<span className="text-slate-300">O <span className="text-white">{fmt(legend.open)}</span></span>
<span className="text-slate-300">H <span className="text-[#26a69a]">{fmt(legend.high)}</span></span>
<span className="text-slate-300">L <span className="text-[#ef5350]">{fmt(legend.low)}</span></span>
<span className="text-slate-300">C <span className="text-white">{fmt(legend.close)}</span></span>
</>
) : (
<div className="flex gap-2">
{orderBlocks.filter((o) => o.direction === 'bullish').length > 0 && (
<span className="px-2 py-0.5 rounded bg-[#26a69a33] text-[#26a69a] border border-[#26a69a55]">
{orderBlocks.filter((o) => o.direction === 'bullish').length} Bullish OB
</span>
)}
{orderBlocks.filter((o) => o.direction === 'bearish').length > 0 && (
<span className="px-2 py-0.5 rounded bg-[#ef535033] text-[#ef5350] border border-[#ef535055]">
{orderBlocks.filter((o) => o.direction === 'bearish').length} Bearish OB
</span>
)}
</div>
)}
</div>
<div ref={containerRef} style={{ height }} />
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { Play, Square, Wifi, WifiOff } from 'lucide-react'
import type { BotStatus } from '../../lib/api'
import { startBot, stopBot } from '../../lib/api'
interface Props {
status: BotStatus | null
wsConnected: boolean
onStatusChange: () => void
}
export default function BotStatusCard({ status, wsConnected, onStatusChange }: Props) {
const [loading, setLoading] = useState(false)
const handleToggle = async () => {
setLoading(true)
try {
if (status?.running) {
await stopBot()
} else {
await startBot()
}
onStatusChange()
} finally {
setLoading(false)
}
}
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider">
Bot Status
</h2>
<div className="flex items-center gap-1.5 text-xs">
{wsConnected ? (
<><Wifi size={12} className="text-[#26a69a]" /><span className="text-[#26a69a]">Live</span></>
) : (
<><WifiOff size={12} className="text-[#64748b]" /><span className="text-[#64748b]">Offline</span></>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${status?.running ? 'bg-[#26a69a] animate-pulse' : 'bg-[#64748b]'}`} />
<span className="text-lg font-bold">
{status?.running ? 'Running' : 'Stopped'}
</span>
</div>
{status?.running && (
<div className="mt-2 text-xs text-[#64748b] space-y-0.5">
<div>{status.instrument} · {status.granularity}</div>
{status.started_at && (
<div>Depuis {new Date(status.started_at).toLocaleTimeString('fr-FR')}</div>
)}
</div>
)}
<button
onClick={handleToggle}
disabled={loading}
className={`mt-4 w-full flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-medium transition-colors ${
status?.running
? 'bg-[#ef535020] text-[#ef5350] hover:bg-[#ef535040] border border-[#ef535033]'
: 'bg-[#6366f1] text-white hover:bg-[#4f46e5]'
} disabled:opacity-50`}
>
{status?.running ? (
<><Square size={14} /> Arrêter</>
) : (
<><Play size={14} /> Démarrer</>
)}
</button>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import type { Trade } from '../../lib/api'
interface Props {
trades: Trade[]
}
export default function PnLSummary({ trades }: Props) {
const closed = trades.filter((t) => t.status === 'closed' && t.pnl !== null)
const totalPnl = closed.reduce((acc, t) => acc + (t.pnl ?? 0), 0)
const winners = closed.filter((t) => (t.pnl ?? 0) > 0)
const winRate = closed.length > 0 ? (winners.length / closed.length) * 100 : 0
const stats = [
{
label: 'PnL Total',
value: `${totalPnl >= 0 ? '+' : ''}${totalPnl.toFixed(2)}`,
color: totalPnl >= 0 ? 'text-[#26a69a]' : 'text-[#ef5350]',
},
{
label: 'Win Rate',
value: `${winRate.toFixed(1)}%`,
color: winRate >= 50 ? 'text-[#26a69a]' : 'text-[#ef5350]',
},
{
label: 'Trades',
value: closed.length.toString(),
color: 'text-white',
},
{
label: 'Ouverts',
value: trades.filter((t) => t.status === 'open').length.toString(),
color: 'text-[#6366f1]',
},
]
return (
<div className="grid grid-cols-2 gap-3">
{stats.map((s) => (
<div key={s.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
<div className="text-xs text-[#64748b] mb-1">{s.label}</div>
<div className={`text-xl font-bold ${s.color}`}>{s.value}</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,92 @@
import type { Trade } from '../../lib/api'
interface Props {
trades: Trade[]
}
export default function TradeList({ trades }: Props) {
if (trades.length === 0) {
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 text-center text-[#64748b] text-sm">
Aucun trade pour le moment
</div>
)
}
return (
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a2d3e]">
<h3 className="text-sm font-semibold text-[#64748b] uppercase tracking-wider">
Trades récents
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-[#64748b] border-b border-[#2a2d3e]">
<th className="text-left px-4 py-2">Instrument</th>
<th className="text-left px-4 py-2">Direction</th>
<th className="text-right px-4 py-2">Entrée</th>
<th className="text-right px-4 py-2">Sortie</th>
<th className="text-right px-4 py-2">PnL</th>
<th className="text-left px-4 py-2">Statut</th>
<th className="text-left px-4 py-2">Date</th>
</tr>
</thead>
<tbody>
{trades.map((t) => (
<tr
key={t.id}
className="border-b border-[#2a2d3e] hover:bg-[#2a2d3e20] transition-colors"
>
<td className="px-4 py-2.5 font-mono text-xs">{t.instrument}</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs font-semibold ${
t.direction === 'buy'
? 'bg-[#26a69a20] text-[#26a69a]'
: 'bg-[#ef535020] text-[#ef5350]'
}`}
>
{t.direction.toUpperCase()}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{t.entry_price.toFixed(5)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs text-[#64748b]">
{t.exit_price ? t.exit_price.toFixed(5) : '—'}
</td>
<td className="px-4 py-2.5 text-right font-mono text-xs">
{t.pnl !== null ? (
<span className={t.pnl >= 0 ? 'text-[#26a69a]' : 'text-[#ef5350]'}>
{t.pnl >= 0 ? '+' : ''}{t.pnl.toFixed(2)}
</span>
) : (
<span className="text-[#64748b]"></span>
)}
</td>
<td className="px-4 py-2.5">
<span
className={`px-1.5 py-0.5 rounded text-xs ${
t.status === 'open'
? 'bg-[#6366f120] text-[#6366f1]'
: t.status === 'closed'
? 'bg-[#2a2d3e] text-[#64748b]'
: 'bg-[#ef535020] text-[#ef5350]'
}`}
>
{t.status}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-[#64748b]">
{new Date(t.opened_at).toLocaleDateString('fr-FR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react'
export interface WsEvent {
type: string
[key: string]: unknown
}
export function useWebSocket(url: string) {
const [lastEvent, setLastEvent] = useState<WsEvent | null>(null)
const [connected, setConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null)
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
const connect = () => {
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setConnected(true)
pingRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('ping')
}, 30_000)
}
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as WsEvent
setLastEvent(data)
} catch {
// ignore
}
}
ws.onclose = () => {
setConnected(false)
if (pingRef.current) clearInterval(pingRef.current)
// Reconnect après 3s
setTimeout(connect, 3_000)
}
ws.onerror = () => {
ws.close()
}
}
connect()
return () => {
if (pingRef.current) clearInterval(pingRef.current)
wsRef.current?.close()
}
}, [url])
return { lastEvent, connected }
}

33
frontend/src/index.css Normal file
View File

@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: 'Inter', system-ui, sans-serif;
}
body {
background-color: #0f1117;
color: #e2e8f0;
margin: 0;
}
* {
box-sizing: border-box;
}
/* Scrollbar dark */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1d27;
}
::-webkit-scrollbar-thumb {
background: #2a2d3e;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #6366f1;
}

130
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,130 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 30_000,
})
export interface Candle {
time: string
open: number
high: number
low: number
close: number
volume: number
}
export interface Trade {
id: number
source: string
instrument: string
direction: 'buy' | 'sell'
units: number
entry_price: number
stop_loss: number
take_profit: number
exit_price: number | null
pnl: number | null
status: string
signal_type: string | null
opened_at: string
closed_at: string | null
}
export interface BacktestMetrics {
initial_balance: number
final_balance: number
total_pnl: number
total_pnl_pct: number
total_trades: number
winning_trades: number
losing_trades: number
win_rate: number
avg_win_pips: number
avg_loss_pips: number
expectancy: number
max_drawdown: number
max_drawdown_pct: number
sharpe_ratio: number
profit_factor: number
}
export interface BacktestResult {
backtest_id: number
instrument: string
granularity: string
period: { start: string; end: string }
metrics: BacktestMetrics
equity_curve: { time: string; balance: number }[]
trades: BacktestTrade[]
}
export interface BacktestTrade {
direction: string
entry_price: number
exit_price: number | null
stop_loss: number
take_profit: number
entry_time: string
exit_time: string | null
pnl_pips: number | null
status: string
signal_type: string
}
export interface BotStatus {
running: boolean
instrument: string
granularity: string
started_at: string | null
strategy: string
strategy_params: Record<string, unknown>
}
// ─── API calls ───────────────────────────────────────────────────────────────
export const fetchCandles = (
instrument: string,
granularity: string,
count = 200,
) =>
api
.get<{ candles: Candle[] }>('/candles', {
params: { instrument, granularity, count },
})
.then((r) => r.data.candles)
export const fetchTrades = (params?: {
source?: string
status?: string
limit?: number
}) =>
api
.get<{ trades: Trade[] }>('/trades', { params })
.then((r) => r.data.trades)
export const fetchBotStatus = () =>
api.get<BotStatus>('/bot/status').then((r) => r.data)
export const startBot = (instrument?: string, granularity?: string) =>
api
.post<BotStatus>('/bot/start', { instrument, granularity })
.then((r) => r.data)
export const stopBot = () =>
api.post('/bot/stop').then((r) => r.data)
export const runBacktest = (params: {
instrument: string
granularity: string
candle_count: number
initial_balance: number
risk_percent: number
rr_ratio: number
swing_strength: number
liquidity_tolerance_pips: number
spread_pips: number
}) =>
api.post<BacktestResult>('/backtest', params).then((r) => r.data)
export default api

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,51 @@
import { useState } from 'react'
import BacktestForm from '../components/Backtest/BacktestForm'
import BacktestResults from '../components/Backtest/BacktestResults'
import CandlestickChart from '../components/Chart/CandlestickChart'
import type { BacktestResult, Candle } from '../lib/api'
import { fetchCandles } from '../lib/api'
export default function Backtest() {
const [result, setResult] = useState<BacktestResult | null>(null)
const [candles, setCandles] = useState<Candle[]>([])
const handleResult = async (r: BacktestResult) => {
setResult(r)
// Charger les candles pour la période du backtest
const c = await fetchCandles(r.instrument, r.granularity, 500)
setCandles(c)
}
return (
<div className="p-4 flex gap-4 h-full">
{/* Left: form */}
<div className="w-72 flex-shrink-0">
<BacktestForm onResult={handleResult} />
</div>
{/* Right: results */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-auto">
<h1 className="text-base font-bold text-white">Backtesting Order Block + Liquidity Sweep</h1>
{result ? (
<>
{/* Chart avec les trades superposés */}
<CandlestickChart
candles={candles}
trades={result.trades}
height={360}
/>
<BacktestResults result={result} />
</>
) : (
<div className="flex-1 flex items-center justify-center text-[#64748b] text-sm">
<div className="text-center">
<div className="text-4xl mb-3"></div>
<div>Configure les paramètres et lance un backtest</div>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useState } from 'react'
import CandlestickChart from '../components/Chart/CandlestickChart'
import BotStatusCard from '../components/Dashboard/BotStatus'
import PnLSummary from '../components/Dashboard/PnLSummary'
import TradeList from '../components/Dashboard/TradeList'
import { fetchBotStatus, fetchCandles, fetchTrades } from '../lib/api'
import type { BotStatus, Candle, Trade } from '../lib/api'
import { useWebSocket } from '../hooks/useWebSocket'
const WS_URL = `ws://${window.location.host}/ws/live`
const INSTRUMENTS = ['EUR_USD', 'GBP_USD', 'USD_JPY', 'SPX500_USD', 'NAS100_USD']
const GRANULARITIES = ['M15', 'M30', 'H1', 'H4', 'D']
export default function Dashboard() {
const [candles, setCandles] = useState<Candle[]>([])
const [trades, setTrades] = useState<Trade[]>([])
const [botStatus, setBotStatus] = useState<BotStatus | null>(null)
const [instrument, setInstrument] = useState('EUR_USD')
const [granularity, setGranularity] = useState('H1')
const { lastEvent, connected } = useWebSocket(WS_URL)
const loadData = useCallback(async () => {
const [c, t, s] = await Promise.allSettled([
fetchCandles(instrument, granularity, 200),
fetchTrades({ limit: 50 }),
fetchBotStatus(),
])
if (c.status === 'fulfilled') setCandles(c.value)
else setCandles([])
if (t.status === 'fulfilled') setTrades(t.value)
if (s.status === 'fulfilled') setBotStatus(s.value)
}, [instrument, granularity])
useEffect(() => {
loadData()
}, [loadData])
// Rafraîchir sur les ticks WebSocket
useEffect(() => {
if (lastEvent?.type === 'tick') {
fetchCandles(instrument, granularity, 5).then((newCandles) => {
if (newCandles.length > 0) {
setCandles((prev) => {
const merged = [...prev]
for (const nc of newCandles) {
const idx = merged.findIndex((c) => c.time === nc.time)
if (idx >= 0) merged[idx] = nc
else merged.push(nc)
}
return merged.slice(-500)
})
}
})
if (lastEvent.new_trade) {
fetchTrades({ limit: 50 }).then(setTrades)
}
}
}, [lastEvent, instrument, granularity])
return (
<div className="flex h-full">
{/* Main chart area */}
<div className="flex-1 flex flex-col p-4 gap-4 min-w-0">
{/* Toolbar */}
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-white">Dashboard Live</h1>
<div className="flex items-center gap-2 ml-auto">
<select
value={instrument}
onChange={(e) => setInstrument(e.target.value)}
className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#6366f1]"
>
{INSTRUMENTS.map((i) => <option key={i}>{i}</option>)}
</select>
<select
value={granularity}
onChange={(e) => setGranularity(e.target.value)}
className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-[#6366f1]"
>
{GRANULARITIES.map((g) => <option key={g}>{g}</option>)}
</select>
<button
onClick={loadData}
className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-3 py-1.5 text-sm text-[#64748b] hover:text-white hover:border-[#6366f1] transition-colors"
>
Refresh
</button>
</div>
</div>
{/* Chart */}
<CandlestickChart key={`${instrument}-${granularity}`} candles={candles} trades={trades} height={460} />
{/* Trade list */}
<TradeList trades={trades} />
</div>
{/* Right sidebar */}
<div className="w-64 flex-shrink-0 p-4 flex flex-col gap-4 border-l border-[#2a2d3e]">
<BotStatusCard
status={botStatus}
wsConnected={connected}
onStatusChange={loadData}
/>
<PnLSummary trades={trades} />
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: {
background: '#0f1117',
surface: '#1a1d27',
border: '#2a2d3e',
'text-primary': '#e2e8f0',
'text-muted': '#64748b',
accent: '#6366f1',
'bull': '#26a69a',
'bear': '#ef5350',
'bull-light': '#26a69a33',
'bear-light': '#ef535033',
},
},
},
plugins: [],
}
export default config

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})

0
test.py Normal file
View File