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:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
138
CLAUDE.md
Normal 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
18
backend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# TwelveData API (données intraday historiques au-delà des limites yfinance)
|
||||||
|
# Clé gratuite sur https://twelvedata.com/ — 800 req/jour
|
||||||
|
TWELVEDATA_API_KEY=your_twelvedata_key_here
|
||||||
|
|
||||||
|
# OANDA API (optionnel — pour le trading réel via broker)
|
||||||
|
OANDA_API_KEY=your_api_key_here
|
||||||
|
OANDA_ACCOUNT_ID=your_account_id_here
|
||||||
|
OANDA_ENVIRONMENT=practice
|
||||||
|
|
||||||
|
# Bot Configuration
|
||||||
|
BOT_DEFAULT_INSTRUMENT=EUR_USD
|
||||||
|
BOT_DEFAULT_GRANULARITY=H1
|
||||||
|
BOT_RISK_PERCENT=1.0
|
||||||
|
BOT_RR_RATIO=2.0
|
||||||
|
BOT_INITIAL_BALANCE=10000.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///./trader_bot.db
|
||||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
0
backend/app/api/routes/__init__.py
Normal file
147
backend/app/api/routes/backtest.py
Normal file
147
backend/app/api/routes/backtest.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.backtester import Backtester
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.strategy.order_block_sweep import OrderBlockSweepParams, OrderBlockSweepStrategy
|
||||||
|
from app.models.backtest_result import BacktestResult
|
||||||
|
from app.services.market_data import MarketDataService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/backtest", tags=["backtest"])
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestRequest(BaseModel):
|
||||||
|
instrument: str = Field(default="EUR_USD")
|
||||||
|
granularity: str = Field(default="H1")
|
||||||
|
candle_count: int = Field(default=500, ge=100, le=5000)
|
||||||
|
initial_balance: float = Field(default=10_000.0, gt=0)
|
||||||
|
risk_percent: float = Field(default=1.0, gt=0, le=10)
|
||||||
|
rr_ratio: float = Field(default=2.0, ge=1.0, le=10.0)
|
||||||
|
swing_strength: int = Field(default=5, ge=2, le=20)
|
||||||
|
liquidity_tolerance_pips: float = Field(default=2.0, ge=0.5)
|
||||||
|
spread_pips: float = Field(default=1.5, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def run_backtest(
|
||||||
|
req: BacktestRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = MarketDataService(db)
|
||||||
|
|
||||||
|
# Fetch données historiques (yfinance + TwelveData + cache DB)
|
||||||
|
try:
|
||||||
|
df = await service.get_candles(req.instrument, req.granularity, req.candle_count)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Erreur fetch données: {e}")
|
||||||
|
|
||||||
|
if len(df) < 150:
|
||||||
|
raise HTTPException(400, "Pas assez de données (min 150 bougies)")
|
||||||
|
|
||||||
|
# Configurer la stratégie
|
||||||
|
params = OrderBlockSweepParams(
|
||||||
|
swing_strength=req.swing_strength,
|
||||||
|
liquidity_tolerance_pips=req.liquidity_tolerance_pips,
|
||||||
|
rr_ratio=req.rr_ratio,
|
||||||
|
)
|
||||||
|
strategy = OrderBlockSweepStrategy(params)
|
||||||
|
|
||||||
|
# Lancer le backtest
|
||||||
|
backtester = Backtester(
|
||||||
|
strategy=strategy,
|
||||||
|
initial_balance=req.initial_balance,
|
||||||
|
risk_percent=req.risk_percent,
|
||||||
|
spread_pips=req.spread_pips,
|
||||||
|
)
|
||||||
|
metrics = backtester.run(df)
|
||||||
|
|
||||||
|
# Sauvegarder en BDD
|
||||||
|
result = BacktestResult(
|
||||||
|
instrument=req.instrument,
|
||||||
|
granularity=req.granularity,
|
||||||
|
start_date=df.iloc[0]["time"],
|
||||||
|
end_date=df.iloc[-1]["time"],
|
||||||
|
initial_balance=metrics.initial_balance,
|
||||||
|
final_balance=metrics.final_balance,
|
||||||
|
total_pnl=metrics.total_pnl,
|
||||||
|
total_trades=metrics.total_trades,
|
||||||
|
winning_trades=metrics.winning_trades,
|
||||||
|
losing_trades=metrics.losing_trades,
|
||||||
|
win_rate=metrics.win_rate,
|
||||||
|
max_drawdown=metrics.max_drawdown,
|
||||||
|
sharpe_ratio=metrics.sharpe_ratio,
|
||||||
|
expectancy=metrics.expectancy,
|
||||||
|
equity_curve=metrics.equity_curve,
|
||||||
|
strategy_params=strategy.get_params(),
|
||||||
|
)
|
||||||
|
db.add(result)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(result)
|
||||||
|
|
||||||
|
# Formater les trades pour la réponse
|
||||||
|
trades_out = [
|
||||||
|
{
|
||||||
|
"direction": t.direction,
|
||||||
|
"entry_price": t.entry_price,
|
||||||
|
"exit_price": t.exit_price,
|
||||||
|
"stop_loss": t.stop_loss,
|
||||||
|
"take_profit": t.take_profit,
|
||||||
|
"entry_time": str(t.entry_time),
|
||||||
|
"exit_time": str(t.exit_time) if t.exit_time else None,
|
||||||
|
"pnl_pips": t.pnl_pips,
|
||||||
|
"status": t.status,
|
||||||
|
"signal_type": t.signal_type,
|
||||||
|
}
|
||||||
|
for t in metrics.trades
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backtest_id": result.id,
|
||||||
|
"instrument": req.instrument,
|
||||||
|
"granularity": req.granularity,
|
||||||
|
"period": {"start": str(df.iloc[0]["time"]), "end": str(df.iloc[-1]["time"])},
|
||||||
|
"metrics": {
|
||||||
|
"initial_balance": metrics.initial_balance,
|
||||||
|
"final_balance": round(metrics.final_balance, 2),
|
||||||
|
"total_pnl": round(metrics.total_pnl, 2),
|
||||||
|
"total_pnl_pct": round(metrics.total_pnl_pct, 2),
|
||||||
|
"total_trades": metrics.total_trades,
|
||||||
|
"winning_trades": metrics.winning_trades,
|
||||||
|
"losing_trades": metrics.losing_trades,
|
||||||
|
"win_rate": round(metrics.win_rate, 1),
|
||||||
|
"avg_win_pips": round(metrics.avg_win_pips, 1),
|
||||||
|
"avg_loss_pips": round(metrics.avg_loss_pips, 1),
|
||||||
|
"expectancy": round(metrics.expectancy, 2),
|
||||||
|
"max_drawdown": round(metrics.max_drawdown, 2),
|
||||||
|
"max_drawdown_pct": round(metrics.max_drawdown_pct, 2),
|
||||||
|
"sharpe_ratio": round(metrics.sharpe_ratio, 3),
|
||||||
|
"profit_factor": round(metrics.profit_factor, 2),
|
||||||
|
},
|
||||||
|
"equity_curve": metrics.equity_curve,
|
||||||
|
"trades": trades_out,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_backtest_history(db: AsyncSession = Depends(get_db)):
|
||||||
|
from sqlalchemy import select
|
||||||
|
stmt = select(BacktestResult).order_by(BacktestResult.created_at.desc()).limit(20)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"instrument": r.instrument,
|
||||||
|
"granularity": r.granularity,
|
||||||
|
"total_pnl": r.total_pnl,
|
||||||
|
"win_rate": r.win_rate,
|
||||||
|
"total_trades": r.total_trades,
|
||||||
|
"sharpe_ratio": r.sharpe_ratio,
|
||||||
|
"created_at": str(r.created_at),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
46
backend/app/api/routes/bot.py
Normal file
46
backend/app/api/routes/bot.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/bot", tags=["bot"])
|
||||||
|
|
||||||
|
# Instance globale du bot (initialisée dans main.py)
|
||||||
|
_bot_runner = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_bot_runner(runner) -> None:
|
||||||
|
global _bot_runner
|
||||||
|
_bot_runner = runner
|
||||||
|
|
||||||
|
|
||||||
|
class BotStartRequest(BaseModel):
|
||||||
|
instrument: str = ""
|
||||||
|
granularity: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def bot_status():
|
||||||
|
if _bot_runner is None:
|
||||||
|
return {"running": False, "message": "Bot non initialisé"}
|
||||||
|
return _bot_runner.get_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def start_bot(req: BotStartRequest):
|
||||||
|
if _bot_runner is None:
|
||||||
|
raise HTTPException(503, "Bot non initialisé")
|
||||||
|
if req.instrument:
|
||||||
|
_bot_runner.instrument = req.instrument
|
||||||
|
if req.granularity:
|
||||||
|
_bot_runner.granularity = req.granularity
|
||||||
|
await _bot_runner.start()
|
||||||
|
return {"message": "Bot démarré", "status": _bot_runner.get_status()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop")
|
||||||
|
async def stop_bot():
|
||||||
|
if _bot_runner is None:
|
||||||
|
raise HTTPException(503, "Bot non initialisé")
|
||||||
|
await _bot_runner.stop()
|
||||||
|
return {"message": "Bot arrêté"}
|
||||||
30
backend/app/api/routes/candles.py
Normal file
30
backend/app/api/routes/candles.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.services.market_data import MarketDataService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/candles", tags=["candles"])
|
||||||
|
|
||||||
|
VALID_GRANULARITIES = {"M1", "M5", "M15", "M30", "H1", "H4", "D"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_candles(
|
||||||
|
instrument: str = Query(default="EUR_USD", description="Ex: EUR_USD, GBP_USD, SPX500_USD"),
|
||||||
|
granularity: str = Query(default="H1", description="M1, M5, M15, M30, H1, H4, D"),
|
||||||
|
count: int = Query(default=200, ge=10, le=5000),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if granularity not in VALID_GRANULARITIES:
|
||||||
|
raise HTTPException(400, f"Granularité invalide. Valides: {VALID_GRANULARITIES}")
|
||||||
|
|
||||||
|
service = MarketDataService(db)
|
||||||
|
df = await service.get_candles(instrument, granularity, count)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"instrument": instrument,
|
||||||
|
"granularity": granularity,
|
||||||
|
"count": len(df),
|
||||||
|
"candles": df.to_dict(orient="records") if not df.empty else [],
|
||||||
|
}
|
||||||
53
backend/app/api/routes/trades.py
Normal file
53
backend/app/api/routes/trades.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.models.trade import Trade
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trades", tags=["trades"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_trades(
|
||||||
|
source: Optional[str] = Query(default=None, description="live | backtest"),
|
||||||
|
status: Optional[str] = Query(default=None, description="open | closed"),
|
||||||
|
instrument: Optional[str] = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
stmt = select(Trade).order_by(Trade.opened_at.desc()).limit(limit)
|
||||||
|
if source:
|
||||||
|
stmt = stmt.where(Trade.source == source)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(Trade.status == status)
|
||||||
|
if instrument:
|
||||||
|
stmt = stmt.where(Trade.instrument == instrument)
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
trades = result.scalars().all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(trades),
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"source": t.source,
|
||||||
|
"instrument": t.instrument,
|
||||||
|
"direction": t.direction,
|
||||||
|
"units": t.units,
|
||||||
|
"entry_price": t.entry_price,
|
||||||
|
"stop_loss": t.stop_loss,
|
||||||
|
"take_profit": t.take_profit,
|
||||||
|
"exit_price": t.exit_price,
|
||||||
|
"pnl": t.pnl,
|
||||||
|
"status": t.status,
|
||||||
|
"signal_type": t.signal_type,
|
||||||
|
"opened_at": str(t.opened_at),
|
||||||
|
"closed_at": str(t.closed_at) if t.closed_at else None,
|
||||||
|
}
|
||||||
|
for t in trades
|
||||||
|
],
|
||||||
|
}
|
||||||
57
backend/app/api/websocket.py
Normal file
57
backend/app/api/websocket.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
WebSocket endpoint pour le streaming de données live.
|
||||||
|
|
||||||
|
Broadcast les événements du bot (ticks, nouveaux trades, etc.)
|
||||||
|
vers tous les clients connectés.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Set
|
||||||
|
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._active: Set[WebSocket] = set()
|
||||||
|
|
||||||
|
async def connect(self, ws: WebSocket) -> None:
|
||||||
|
await ws.accept()
|
||||||
|
self._active.add(ws)
|
||||||
|
logger.info("WS client connecté — %d total", len(self._active))
|
||||||
|
|
||||||
|
def disconnect(self, ws: WebSocket) -> None:
|
||||||
|
self._active.discard(ws)
|
||||||
|
logger.info("WS client déconnecté — %d restants", len(self._active))
|
||||||
|
|
||||||
|
async def broadcast(self, data: dict) -> None:
|
||||||
|
if not self._active:
|
||||||
|
return
|
||||||
|
message = json.dumps(data, default=str)
|
||||||
|
dead: Set[WebSocket] = set()
|
||||||
|
for ws in list(self._active):
|
||||||
|
try:
|
||||||
|
await ws.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
dead.add(ws)
|
||||||
|
for ws in dead:
|
||||||
|
self.disconnect(ws)
|
||||||
|
|
||||||
|
|
||||||
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
|
async def websocket_endpoint(ws: WebSocket) -> None:
|
||||||
|
await manager.connect(ws)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Garder la connexion ouverte, le client peut envoyer des pings
|
||||||
|
data = await ws.receive_text()
|
||||||
|
if data == "ping":
|
||||||
|
await ws.send_text(json.dumps({"type": "pong"}))
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
manager.disconnect(ws)
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
269
backend/app/core/backtester.py
Normal file
269
backend/app/core/backtester.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
Moteur de backtesting vectorisé.
|
||||||
|
|
||||||
|
Simule l'exécution de la stratégie sur des données historiques :
|
||||||
|
- Rolling window : analyse les N dernières bougies à chaque pas
|
||||||
|
- Gestion du spread OANDA
|
||||||
|
- Suivi des positions ouvertes (SL / TP hit)
|
||||||
|
- Calcul des métriques de performance
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.core.strategy.base import AbstractStrategy, TradeSignal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BacktestTrade:
|
||||||
|
direction: str
|
||||||
|
entry_price: float
|
||||||
|
stop_loss: float
|
||||||
|
take_profit: float
|
||||||
|
entry_time: datetime
|
||||||
|
exit_price: Optional[float] = None
|
||||||
|
exit_time: Optional[datetime] = None
|
||||||
|
pnl_pips: Optional[float] = None
|
||||||
|
pnl_pct: Optional[float] = None
|
||||||
|
status: str = "open" # "win" | "loss" | "open"
|
||||||
|
signal_type: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BacktestMetrics:
|
||||||
|
initial_balance: float
|
||||||
|
final_balance: float
|
||||||
|
total_pnl: float
|
||||||
|
total_pnl_pct: float
|
||||||
|
total_trades: int
|
||||||
|
winning_trades: int
|
||||||
|
losing_trades: int
|
||||||
|
win_rate: float
|
||||||
|
avg_win_pips: float
|
||||||
|
avg_loss_pips: float
|
||||||
|
expectancy: float
|
||||||
|
max_drawdown: float
|
||||||
|
max_drawdown_pct: float
|
||||||
|
sharpe_ratio: float
|
||||||
|
profit_factor: float
|
||||||
|
equity_curve: list[dict] = field(default_factory=list)
|
||||||
|
trades: list[BacktestTrade] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class Backtester:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
strategy: AbstractStrategy,
|
||||||
|
initial_balance: float = 10_000.0,
|
||||||
|
risk_percent: float = 1.0,
|
||||||
|
spread_pips: float = 1.5,
|
||||||
|
pip_value: float = 0.0001,
|
||||||
|
) -> None:
|
||||||
|
self.strategy = strategy
|
||||||
|
self.initial_balance = initial_balance
|
||||||
|
self.risk_percent = risk_percent # % du capital risqué par trade
|
||||||
|
self.spread_pips = spread_pips
|
||||||
|
self.pip_value = pip_value
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
window: int = 100,
|
||||||
|
) -> BacktestMetrics:
|
||||||
|
"""
|
||||||
|
Exécute le backtest sur le DataFrame complet.
|
||||||
|
|
||||||
|
window : nombre de bougies analysées à chaque pas (rolling)
|
||||||
|
"""
|
||||||
|
df = df.copy().reset_index(drop=True)
|
||||||
|
trades: list[BacktestTrade] = []
|
||||||
|
balance = self.initial_balance
|
||||||
|
equity_curve: list[dict] = [{"time": str(df.iloc[0]["time"]), "balance": balance}]
|
||||||
|
open_trade: Optional[BacktestTrade] = None
|
||||||
|
last_signal_time: Optional[datetime] = None
|
||||||
|
used_signals: set[tuple[str, int]] = set() # (direction, entry_price_rounded)
|
||||||
|
|
||||||
|
for i in range(window, len(df)):
|
||||||
|
candle = df.iloc[i]
|
||||||
|
|
||||||
|
# 1. Gérer la position ouverte (SL/TP hit ?)
|
||||||
|
if open_trade:
|
||||||
|
closed, exit_price = self._check_exit(open_trade, candle)
|
||||||
|
if closed:
|
||||||
|
pnl_pips = self._calc_pnl_pips(open_trade, exit_price)
|
||||||
|
pnl_money = self._pips_to_money(pnl_pips, balance)
|
||||||
|
balance += pnl_money
|
||||||
|
open_trade.exit_price = exit_price
|
||||||
|
open_trade.exit_time = candle["time"]
|
||||||
|
open_trade.pnl_pips = pnl_pips
|
||||||
|
open_trade.pnl_pct = pnl_money / self.initial_balance * 100
|
||||||
|
open_trade.status = "win" if pnl_pips > 0 else "loss"
|
||||||
|
trades.append(open_trade)
|
||||||
|
open_trade = None
|
||||||
|
equity_curve.append({"time": str(candle["time"]), "balance": balance})
|
||||||
|
|
||||||
|
# 2. Si pas de position ouverte, chercher un signal
|
||||||
|
if not open_trade:
|
||||||
|
slice_df = df.iloc[i - window:i + 1]
|
||||||
|
result = self.strategy.analyze(slice_df)
|
||||||
|
|
||||||
|
if result.signals:
|
||||||
|
# Filtrer les signaux déjà exploités (éviter doublons)
|
||||||
|
new_signals = [
|
||||||
|
s for s in result.signals
|
||||||
|
if last_signal_time is None or s.time > last_signal_time
|
||||||
|
]
|
||||||
|
if not new_signals:
|
||||||
|
continue
|
||||||
|
signal = new_signals[-1] # Signal le plus récent
|
||||||
|
# Appliquer le spread
|
||||||
|
entry, sl, tp = self._apply_spread(signal)
|
||||||
|
# Éviter de réouvrir le même trade (même direction + même prix)
|
||||||
|
sig_key = (signal.direction, round(entry / self.pip_value))
|
||||||
|
if sig_key in used_signals:
|
||||||
|
continue
|
||||||
|
used_signals.add(sig_key)
|
||||||
|
last_signal_time = signal.time
|
||||||
|
open_trade = BacktestTrade(
|
||||||
|
direction=signal.direction,
|
||||||
|
entry_price=entry,
|
||||||
|
stop_loss=sl,
|
||||||
|
take_profit=tp,
|
||||||
|
entry_time=candle["time"],
|
||||||
|
signal_type=signal.signal_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fermer la position ouverte en fin de période
|
||||||
|
if open_trade:
|
||||||
|
last_price = df.iloc[-1]["close"]
|
||||||
|
pnl_pips = self._calc_pnl_pips(open_trade, last_price)
|
||||||
|
pnl_money = self._pips_to_money(pnl_pips, balance)
|
||||||
|
balance += pnl_money
|
||||||
|
open_trade.exit_price = last_price
|
||||||
|
open_trade.exit_time = df.iloc[-1]["time"]
|
||||||
|
open_trade.pnl_pips = pnl_pips
|
||||||
|
open_trade.pnl_pct = pnl_money / self.initial_balance * 100
|
||||||
|
open_trade.status = "win" if pnl_pips > 0 else "loss"
|
||||||
|
trades.append(open_trade)
|
||||||
|
|
||||||
|
equity_curve.append({"time": str(df.iloc[-1]["time"]), "balance": balance})
|
||||||
|
return self._compute_metrics(trades, balance, equity_curve)
|
||||||
|
|
||||||
|
# ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _check_exit(
|
||||||
|
self, trade: BacktestTrade, candle: pd.Series
|
||||||
|
) -> tuple[bool, float]:
|
||||||
|
"""Vérifie si le SL ou TP est touché sur la bougie. Retourne (closed, exit_price)."""
|
||||||
|
if trade.direction == "buy":
|
||||||
|
if candle["low"] <= trade.stop_loss:
|
||||||
|
return True, trade.stop_loss
|
||||||
|
if candle["high"] >= trade.take_profit:
|
||||||
|
return True, trade.take_profit
|
||||||
|
else:
|
||||||
|
if candle["high"] >= trade.stop_loss:
|
||||||
|
return True, trade.stop_loss
|
||||||
|
if candle["low"] <= trade.take_profit:
|
||||||
|
return True, trade.take_profit
|
||||||
|
return False, 0.0
|
||||||
|
|
||||||
|
def _apply_spread(self, signal: TradeSignal) -> tuple[float, float, float]:
|
||||||
|
"""Applique le spread à l'entrée."""
|
||||||
|
half_spread = (self.spread_pips * self.pip_value) / 2
|
||||||
|
if signal.direction == "buy":
|
||||||
|
entry = signal.entry_price + half_spread
|
||||||
|
sl = signal.stop_loss
|
||||||
|
tp = signal.take_profit
|
||||||
|
else:
|
||||||
|
entry = signal.entry_price - half_spread
|
||||||
|
sl = signal.stop_loss
|
||||||
|
tp = signal.take_profit
|
||||||
|
return entry, sl, tp
|
||||||
|
|
||||||
|
def _calc_pnl_pips(self, trade: BacktestTrade, exit_price: float) -> float:
|
||||||
|
if trade.direction == "buy":
|
||||||
|
return (exit_price - trade.entry_price) / self.pip_value
|
||||||
|
return (trade.entry_price - exit_price) / self.pip_value
|
||||||
|
|
||||||
|
def _pips_to_money(self, pips: float, balance: float) -> float:
|
||||||
|
"""Convertit les pips en unités monétaires selon le risque défini."""
|
||||||
|
risk_amount = balance * (self.risk_percent / 100)
|
||||||
|
# Nombre de pips de risque sur ce trade
|
||||||
|
return pips * (risk_amount / 10) # Approximation simple
|
||||||
|
|
||||||
|
def _compute_metrics(
|
||||||
|
self,
|
||||||
|
trades: list[BacktestTrade],
|
||||||
|
final_balance: float,
|
||||||
|
equity_curve: list[dict],
|
||||||
|
) -> BacktestMetrics:
|
||||||
|
if not trades:
|
||||||
|
return BacktestMetrics(
|
||||||
|
initial_balance=self.initial_balance,
|
||||||
|
final_balance=final_balance,
|
||||||
|
total_pnl=0, total_pnl_pct=0,
|
||||||
|
total_trades=0, winning_trades=0, losing_trades=0,
|
||||||
|
win_rate=0, avg_win_pips=0, avg_loss_pips=0,
|
||||||
|
expectancy=0, max_drawdown=0, max_drawdown_pct=0,
|
||||||
|
sharpe_ratio=0, profit_factor=0,
|
||||||
|
equity_curve=equity_curve, trades=trades,
|
||||||
|
)
|
||||||
|
|
||||||
|
winners = [t for t in trades if t.status == "win"]
|
||||||
|
losers = [t for t in trades if t.status == "loss"]
|
||||||
|
|
||||||
|
total_pnl = final_balance - self.initial_balance
|
||||||
|
win_rate = len(winners) / len(trades) if trades else 0
|
||||||
|
|
||||||
|
avg_win = np.mean([t.pnl_pips for t in winners]) if winners else 0
|
||||||
|
avg_loss = abs(np.mean([t.pnl_pips for t in losers])) if losers else 0
|
||||||
|
expectancy = (win_rate * avg_win) - ((1 - win_rate) * avg_loss)
|
||||||
|
|
||||||
|
# Max Drawdown
|
||||||
|
balances = [eq["balance"] for eq in equity_curve]
|
||||||
|
peak = balances[0]
|
||||||
|
max_dd = 0.0
|
||||||
|
for b in balances:
|
||||||
|
if b > peak:
|
||||||
|
peak = b
|
||||||
|
dd = (peak - b) / peak
|
||||||
|
if dd > max_dd:
|
||||||
|
max_dd = dd
|
||||||
|
|
||||||
|
# Profit Factor
|
||||||
|
gross_profit = sum(t.pnl_pips for t in winners) if winners else 0
|
||||||
|
gross_loss = abs(sum(t.pnl_pips for t in losers)) if losers else 1
|
||||||
|
pf = gross_profit / gross_loss if gross_loss > 0 else 0
|
||||||
|
|
||||||
|
# Sharpe Ratio (simplifié — daily returns)
|
||||||
|
pnl_series = [t.pnl_pips or 0 for t in trades]
|
||||||
|
if len(pnl_series) > 1:
|
||||||
|
mean_ret = np.mean(pnl_series)
|
||||||
|
std_ret = np.std(pnl_series)
|
||||||
|
sharpe = (mean_ret / std_ret * np.sqrt(252)) if std_ret > 0 else 0
|
||||||
|
else:
|
||||||
|
sharpe = 0
|
||||||
|
|
||||||
|
return BacktestMetrics(
|
||||||
|
initial_balance=self.initial_balance,
|
||||||
|
final_balance=final_balance,
|
||||||
|
total_pnl=total_pnl,
|
||||||
|
total_pnl_pct=total_pnl / self.initial_balance * 100,
|
||||||
|
total_trades=len(trades),
|
||||||
|
winning_trades=len(winners),
|
||||||
|
losing_trades=len(losers),
|
||||||
|
win_rate=win_rate * 100,
|
||||||
|
avg_win_pips=float(avg_win),
|
||||||
|
avg_loss_pips=float(avg_loss),
|
||||||
|
expectancy=float(expectancy),
|
||||||
|
max_drawdown=max_dd * self.initial_balance,
|
||||||
|
max_drawdown_pct=max_dd * 100,
|
||||||
|
sharpe_ratio=float(sharpe),
|
||||||
|
profit_factor=float(pf),
|
||||||
|
equity_curve=equity_curve,
|
||||||
|
trades=trades,
|
||||||
|
)
|
||||||
155
backend/app/core/bot.py
Normal file
155
backend/app/core/bot.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
BotRunner — boucle de trading live asynchrone.
|
||||||
|
|
||||||
|
Cycle :
|
||||||
|
1. Fetch les N dernières bougies
|
||||||
|
2. Analyser la stratégie
|
||||||
|
3. Si signal + pas de position ouverte → exécuter
|
||||||
|
4. Broadcast l'état via WebSocket
|
||||||
|
5. Attendre le prochain tick
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exchange.base import AbstractExchange
|
||||||
|
from app.core.strategy.base import AbstractStrategy, AnalysisResult
|
||||||
|
from app.services.trade_manager import TradeManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Interval en secondes selon la granularité
|
||||||
|
GRANULARITY_SECONDS = {
|
||||||
|
"M1": 60, "M5": 300, "M15": 900, "M30": 1800,
|
||||||
|
"H1": 3600, "H2": 7200, "H4": 14400, "H6": 21600,
|
||||||
|
"H8": 28800, "H12": 43200, "D": 86400,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BotRunner:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
exchange: AbstractExchange,
|
||||||
|
strategy: AbstractStrategy,
|
||||||
|
trade_manager: TradeManager,
|
||||||
|
instrument: str = "",
|
||||||
|
granularity: str = "",
|
||||||
|
candles_window: int = 150,
|
||||||
|
) -> None:
|
||||||
|
self._exchange = exchange
|
||||||
|
self._strategy = strategy
|
||||||
|
self._trade_manager = trade_manager
|
||||||
|
self.instrument = instrument or settings.bot_default_instrument
|
||||||
|
self.granularity = granularity or settings.bot_default_granularity
|
||||||
|
self.candles_window = candles_window
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._task: Optional[asyncio.Task] = None
|
||||||
|
self._last_analysis: Optional[AnalysisResult] = None
|
||||||
|
self._started_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Callback pour broadcaster les événements (WebSocket)
|
||||||
|
self._on_event: Optional[Callable] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
def set_event_callback(self, callback: Callable) -> None:
|
||||||
|
self._on_event = callback
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Bot déjà en cours d'exécution")
|
||||||
|
return
|
||||||
|
self._running = True
|
||||||
|
self._started_at = datetime.utcnow()
|
||||||
|
logger.info("Bot démarré — %s %s", self.instrument, self.granularity)
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
if self._task:
|
||||||
|
self._task.cancel()
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Bot arrêté")
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
interval = GRANULARITY_SECONDS.get(self.granularity, 3600)
|
||||||
|
# Décaler d'un peu pour s'assurer que la bougie est fermée
|
||||||
|
wait_seconds = max(interval // 10, 5)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._tick()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erreur dans la boucle du bot: %s", e, exc_info=True)
|
||||||
|
|
||||||
|
await asyncio.sleep(wait_seconds)
|
||||||
|
|
||||||
|
async def _tick(self) -> None:
|
||||||
|
# 1. Fetch candles
|
||||||
|
df = await self._exchange.get_candles(
|
||||||
|
self.instrument, self.granularity, self.candles_window
|
||||||
|
)
|
||||||
|
if df.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Analyser
|
||||||
|
result = self._strategy.analyze(df)
|
||||||
|
self._last_analysis = result
|
||||||
|
|
||||||
|
# 3. Vérifier les positions ouvertes
|
||||||
|
open_trades = await self._exchange.get_open_trades()
|
||||||
|
instrument_trades = [t for t in open_trades if t.instrument == self.instrument]
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"type": "tick",
|
||||||
|
"instrument": self.instrument,
|
||||||
|
"granularity": self.granularity,
|
||||||
|
"last_close": float(df.iloc[-1]["close"]),
|
||||||
|
"last_time": str(df.iloc[-1]["time"]),
|
||||||
|
"order_blocks": len(result.order_blocks),
|
||||||
|
"liquidity_levels": len(result.liquidity_levels),
|
||||||
|
"signals": len(result.signals),
|
||||||
|
"open_positions": len(instrument_trades),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Exécuter le signal si aucune position ouverte
|
||||||
|
if result.signals and not instrument_trades:
|
||||||
|
signal = result.signals[-1]
|
||||||
|
try:
|
||||||
|
order = await self._trade_manager.execute_signal(signal)
|
||||||
|
if order:
|
||||||
|
logger.info(
|
||||||
|
"Trade exécuté : %s %s @ %.5f",
|
||||||
|
signal.direction, self.instrument, order.entry_price,
|
||||||
|
)
|
||||||
|
event["new_trade"] = {
|
||||||
|
"direction": order.direction,
|
||||||
|
"entry_price": order.entry_price,
|
||||||
|
"stop_loss": order.stop_loss,
|
||||||
|
"take_profit": order.take_profit,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Erreur exécution trade: %s", e)
|
||||||
|
|
||||||
|
# 5. Broadcast
|
||||||
|
if self._on_event:
|
||||||
|
await self._on_event(event)
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"running": self._running,
|
||||||
|
"instrument": self.instrument,
|
||||||
|
"granularity": self.granularity,
|
||||||
|
"started_at": str(self._started_at) if self._started_at else None,
|
||||||
|
"strategy": self._strategy.__class__.__name__,
|
||||||
|
"strategy_params": self._strategy.get_params(),
|
||||||
|
}
|
||||||
22
backend/app/core/config.py
Normal file
22
backend/app/core/config.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
|
# TwelveData (fallback pour l'intraday historique)
|
||||||
|
# Clé gratuite sur https://twelvedata.com/
|
||||||
|
twelvedata_api_key: str = ""
|
||||||
|
|
||||||
|
# Bot defaults
|
||||||
|
bot_default_instrument: str = "EUR_USD"
|
||||||
|
bot_default_granularity: str = "H1"
|
||||||
|
bot_risk_percent: float = 1.0
|
||||||
|
bot_rr_ratio: float = 2.0
|
||||||
|
bot_initial_balance: float = 10_000.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./trader_bot.db"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
yield session
|
||||||
0
backend/app/core/exchange/__init__.py
Normal file
0
backend/app/core/exchange/__init__.py
Normal file
88
backend/app/core/exchange/base.py
Normal file
88
backend/app/core/exchange/base.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderResult:
|
||||||
|
trade_id: str
|
||||||
|
instrument: str
|
||||||
|
direction: str # "buy" | "sell"
|
||||||
|
units: float
|
||||||
|
entry_price: float
|
||||||
|
stop_loss: float
|
||||||
|
take_profit: float
|
||||||
|
opened_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OpenTrade:
|
||||||
|
trade_id: str
|
||||||
|
instrument: str
|
||||||
|
direction: str
|
||||||
|
units: float
|
||||||
|
entry_price: float
|
||||||
|
stop_loss: float
|
||||||
|
take_profit: float
|
||||||
|
unrealized_pnl: float
|
||||||
|
opened_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AccountInfo:
|
||||||
|
balance: float
|
||||||
|
nav: float # Net Asset Value
|
||||||
|
unrealized_pnl: float
|
||||||
|
currency: str
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractExchange(ABC):
|
||||||
|
"""Interface commune pour tous les exchanges/brokers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_candles(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
count: int = 200,
|
||||||
|
from_time: Optional[datetime] = None,
|
||||||
|
to_time: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Retourne un DataFrame avec colonnes : time, open, high, low, close, volume.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def place_order(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
units: float, # positif = buy, négatif = sell
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
) -> OrderResult:
|
||||||
|
"""Place un ordre au marché avec SL et TP."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def close_trade(self, trade_id: str) -> float:
|
||||||
|
"""Ferme un trade par son ID. Retourne le PnL réalisé."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_open_trades(self) -> list[OpenTrade]:
|
||||||
|
"""Retourne la liste des positions ouvertes."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_account_info(self) -> AccountInfo:
|
||||||
|
"""Retourne les infos du compte (solde, PnL, etc.)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_price(self, instrument: str) -> float:
|
||||||
|
"""Retourne le prix mid actuel."""
|
||||||
|
...
|
||||||
171
backend/app/core/exchange/oanda.py
Normal file
171
backend/app/core/exchange/oanda.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from oandapyV20 import API
|
||||||
|
from oandapyV20.endpoints import accounts, instruments, orders, trades, pricing
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exchange.base import (
|
||||||
|
AbstractExchange,
|
||||||
|
AccountInfo,
|
||||||
|
OpenTrade,
|
||||||
|
OrderResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Granularités OANDA valides
|
||||||
|
GRANULARITIES = {
|
||||||
|
"M1", "M5", "M15", "M30",
|
||||||
|
"H1", "H2", "H4", "H6", "H8", "H12",
|
||||||
|
"D", "W", "M",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nombre max de candles par requête OANDA
|
||||||
|
MAX_CANDLES_PER_REQUEST = 5000
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(ts: str) -> datetime:
|
||||||
|
"""Parse RFC3339 timestamp OANDA → datetime UTC."""
|
||||||
|
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
class OandaExchange(AbstractExchange):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
env = "practice" if settings.oanda_environment == "practice" else "live"
|
||||||
|
self._client = API(
|
||||||
|
access_token=settings.oanda_api_key,
|
||||||
|
environment=env,
|
||||||
|
)
|
||||||
|
self._account_id = settings.oanda_account_id
|
||||||
|
|
||||||
|
async def get_candles(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str = "H1",
|
||||||
|
count: int = 200,
|
||||||
|
from_time: Optional[datetime] = None,
|
||||||
|
to_time: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
if granularity not in GRANULARITIES:
|
||||||
|
raise ValueError(f"Granularité invalide: {granularity}. Valides: {GRANULARITIES}")
|
||||||
|
|
||||||
|
params: dict = {"granularity": granularity, "price": "M"}
|
||||||
|
|
||||||
|
if from_time and to_time:
|
||||||
|
params["from"] = from_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
params["to"] = to_time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
else:
|
||||||
|
params["count"] = min(count, MAX_CANDLES_PER_REQUEST)
|
||||||
|
|
||||||
|
r = instruments.InstrumentsCandles(instrument, params=params)
|
||||||
|
self._client.request(r)
|
||||||
|
|
||||||
|
candles = r.response.get("candles", [])
|
||||||
|
rows = []
|
||||||
|
for c in candles:
|
||||||
|
if c.get("complete", True):
|
||||||
|
mid = c["mid"]
|
||||||
|
rows.append({
|
||||||
|
"time": _parse_time(c["time"]),
|
||||||
|
"open": float(mid["o"]),
|
||||||
|
"high": float(mid["h"]),
|
||||||
|
"low": float(mid["l"]),
|
||||||
|
"close": float(mid["c"]),
|
||||||
|
"volume": int(c.get("volume", 0)),
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
if not df.empty:
|
||||||
|
df.sort_values("time", inplace=True)
|
||||||
|
df.reset_index(drop=True, inplace=True)
|
||||||
|
return df
|
||||||
|
|
||||||
|
async def place_order(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
units: float,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
) -> OrderResult:
|
||||||
|
data = {
|
||||||
|
"order": {
|
||||||
|
"type": "MARKET",
|
||||||
|
"instrument": instrument,
|
||||||
|
"units": str(int(units)),
|
||||||
|
"stopLossOnFill": {"price": f"{stop_loss:.5f}"},
|
||||||
|
"takeProfitOnFill": {"price": f"{take_profit:.5f}"},
|
||||||
|
"timeInForce": "FOK",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r = orders.OrderCreate(self._account_id, data=data)
|
||||||
|
self._client.request(r)
|
||||||
|
|
||||||
|
resp = r.response
|
||||||
|
fill = resp.get("orderFillTransaction") or resp.get("relatedTransactionIDs", {})
|
||||||
|
trade_id = resp.get("orderFillTransaction", {}).get("tradeOpened", {}).get("tradeID", "unknown")
|
||||||
|
entry_price = float(resp.get("orderFillTransaction", {}).get("price", 0))
|
||||||
|
direction = "buy" if units > 0 else "sell"
|
||||||
|
|
||||||
|
return OrderResult(
|
||||||
|
trade_id=trade_id,
|
||||||
|
instrument=instrument,
|
||||||
|
direction=direction,
|
||||||
|
units=abs(units),
|
||||||
|
entry_price=entry_price,
|
||||||
|
stop_loss=stop_loss,
|
||||||
|
take_profit=take_profit,
|
||||||
|
opened_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close_trade(self, trade_id: str) -> float:
|
||||||
|
r = trades.TradeClose(self._account_id, tradeID=trade_id)
|
||||||
|
self._client.request(r)
|
||||||
|
pnl = float(r.response.get("orderFillTransaction", {}).get("pl", 0))
|
||||||
|
return pnl
|
||||||
|
|
||||||
|
async def get_open_trades(self) -> list[OpenTrade]:
|
||||||
|
r = trades.OpenTrades(self._account_id)
|
||||||
|
self._client.request(r)
|
||||||
|
result = []
|
||||||
|
for t in r.response.get("trades", []):
|
||||||
|
units = float(t["currentUnits"])
|
||||||
|
direction = "buy" if units > 0 else "sell"
|
||||||
|
sl = float(t.get("stopLoss", {}).get("price", 0)) if t.get("stopLoss") else 0.0
|
||||||
|
tp = float(t.get("takeProfit", {}).get("price", 0)) if t.get("takeProfit") else 0.0
|
||||||
|
result.append(OpenTrade(
|
||||||
|
trade_id=t["id"],
|
||||||
|
instrument=t["instrument"],
|
||||||
|
direction=direction,
|
||||||
|
units=abs(units),
|
||||||
|
entry_price=float(t["price"]),
|
||||||
|
stop_loss=sl,
|
||||||
|
take_profit=tp,
|
||||||
|
unrealized_pnl=float(t.get("unrealizedPL", 0)),
|
||||||
|
opened_at=_parse_time(t["openTime"]),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_account_info(self) -> AccountInfo:
|
||||||
|
r = accounts.AccountSummary(self._account_id)
|
||||||
|
self._client.request(r)
|
||||||
|
acc = r.response["account"]
|
||||||
|
return AccountInfo(
|
||||||
|
balance=float(acc["balance"]),
|
||||||
|
nav=float(acc["NAV"]),
|
||||||
|
unrealized_pnl=float(acc.get("unrealizedPL", 0)),
|
||||||
|
currency=acc.get("currency", "USD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_price(self, instrument: str) -> float:
|
||||||
|
r = pricing.PricingInfo(
|
||||||
|
self._account_id,
|
||||||
|
params={"instruments": instrument},
|
||||||
|
)
|
||||||
|
self._client.request(r)
|
||||||
|
prices = r.response.get("prices", [])
|
||||||
|
if not prices:
|
||||||
|
raise ValueError(f"Aucun prix pour {instrument}")
|
||||||
|
bid = float(prices[0]["bids"][0]["price"])
|
||||||
|
ask = float(prices[0]["asks"][0]["price"])
|
||||||
|
return (bid + ask) / 2
|
||||||
204
backend/app/core/exchange/simulated.py
Normal file
204
backend/app/core/exchange/simulated.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""
|
||||||
|
SimulatedExchange — paper trading 100% local, sans broker.
|
||||||
|
|
||||||
|
Les ordres sont simulés en mémoire. Les prix viennent du MarketDataService
|
||||||
|
(yfinance + TwelveData + cache DB).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.core.exchange.base import (
|
||||||
|
AbstractExchange,
|
||||||
|
AccountInfo,
|
||||||
|
OpenTrade,
|
||||||
|
OrderResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedExchange(AbstractExchange):
|
||||||
|
"""
|
||||||
|
Exchange simulé pour le paper trading.
|
||||||
|
Les positions sont gardées en mémoire (réinitialisées au redémarrage).
|
||||||
|
Les trades fermés sont persistés en DB via le BotRunner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
market_data_service, # MarketDataService — évite l'import circulaire
|
||||||
|
initial_balance: float = 10_000.0,
|
||||||
|
) -> None:
|
||||||
|
self._market_data = market_data_service
|
||||||
|
self._balance = initial_balance
|
||||||
|
self._initial_balance = initial_balance
|
||||||
|
self._open_trades: dict[str, OpenTrade] = {}
|
||||||
|
|
||||||
|
# ── AbstractExchange interface ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_candles(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
count: int = 200,
|
||||||
|
from_time=None,
|
||||||
|
to_time=None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
return await self._market_data.get_candles(
|
||||||
|
instrument, granularity, count,
|
||||||
|
start=from_time, end=to_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def place_order(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
units: float,
|
||||||
|
stop_loss: float,
|
||||||
|
take_profit: float,
|
||||||
|
) -> OrderResult:
|
||||||
|
price = await self._market_data.get_latest_price(instrument)
|
||||||
|
if price is None:
|
||||||
|
raise ValueError(f"Prix introuvable pour {instrument}")
|
||||||
|
|
||||||
|
direction = "buy" if units > 0 else "sell"
|
||||||
|
trade_id = f"SIM-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
trade = OpenTrade(
|
||||||
|
trade_id=trade_id,
|
||||||
|
instrument=instrument,
|
||||||
|
direction=direction,
|
||||||
|
units=abs(units),
|
||||||
|
entry_price=price,
|
||||||
|
stop_loss=stop_loss,
|
||||||
|
take_profit=take_profit,
|
||||||
|
unrealized_pnl=0.0,
|
||||||
|
opened_at=now,
|
||||||
|
)
|
||||||
|
self._open_trades[trade_id] = trade
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[SIM] Ordre ouvert %s %s %.2f @ %.5f | SL=%.5f TP=%.5f",
|
||||||
|
direction.upper(), instrument, abs(units), price, stop_loss, take_profit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return OrderResult(
|
||||||
|
trade_id=trade_id,
|
||||||
|
instrument=instrument,
|
||||||
|
direction=direction,
|
||||||
|
units=abs(units),
|
||||||
|
entry_price=price,
|
||||||
|
stop_loss=stop_loss,
|
||||||
|
take_profit=take_profit,
|
||||||
|
opened_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close_trade(self, trade_id: str) -> float:
|
||||||
|
trade = self._open_trades.pop(trade_id, None)
|
||||||
|
if trade is None:
|
||||||
|
raise ValueError(f"Trade {trade_id} introuvable")
|
||||||
|
|
||||||
|
price = await self._market_data.get_latest_price(trade.instrument)
|
||||||
|
if price is None:
|
||||||
|
price = trade.entry_price
|
||||||
|
|
||||||
|
pnl = self._calc_pnl(trade, price)
|
||||||
|
self._balance += pnl
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[SIM] Trade fermé %s %s @ %.5f | PnL=%.2f | Balance=%.2f",
|
||||||
|
trade_id, trade.instrument, price, pnl, self._balance,
|
||||||
|
)
|
||||||
|
return pnl
|
||||||
|
|
||||||
|
async def get_open_trades(self) -> list[OpenTrade]:
|
||||||
|
# Mettre à jour le PnL flottant
|
||||||
|
updated: list[OpenTrade] = []
|
||||||
|
for trade in self._open_trades.values():
|
||||||
|
price = await self._market_data.get_latest_price(trade.instrument)
|
||||||
|
if price is not None:
|
||||||
|
pnl = self._calc_pnl(trade, price)
|
||||||
|
updated.append(OpenTrade(
|
||||||
|
trade_id=trade.trade_id,
|
||||||
|
instrument=trade.instrument,
|
||||||
|
direction=trade.direction,
|
||||||
|
units=trade.units,
|
||||||
|
entry_price=trade.entry_price,
|
||||||
|
stop_loss=trade.stop_loss,
|
||||||
|
take_profit=trade.take_profit,
|
||||||
|
unrealized_pnl=pnl,
|
||||||
|
opened_at=trade.opened_at,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
updated.append(trade)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def get_account_info(self) -> AccountInfo:
|
||||||
|
open_trades = await self.get_open_trades()
|
||||||
|
unrealized = sum(t.unrealized_pnl for t in open_trades)
|
||||||
|
return AccountInfo(
|
||||||
|
balance=self._balance,
|
||||||
|
nav=self._balance + unrealized,
|
||||||
|
unrealized_pnl=unrealized,
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_price(self, instrument: str) -> float:
|
||||||
|
price = await self._market_data.get_latest_price(instrument)
|
||||||
|
if price is None:
|
||||||
|
raise ValueError(f"Prix introuvable pour {instrument}")
|
||||||
|
return price
|
||||||
|
|
||||||
|
# ── Simulation du tick (appelée par BotRunner) ────────────────────────────
|
||||||
|
|
||||||
|
async def check_sl_tp(self, instrument: str) -> list[tuple[str, float]]:
|
||||||
|
"""
|
||||||
|
Vérifie si SL ou TP sont touchés pour les positions ouvertes.
|
||||||
|
Retourne la liste des (trade_id, pnl) des positions fermées.
|
||||||
|
"""
|
||||||
|
price = await self._market_data.get_latest_price(instrument)
|
||||||
|
if price is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
closed: list[tuple[str, float]] = []
|
||||||
|
for trade_id, trade in list(self._open_trades.items()):
|
||||||
|
if trade.instrument != instrument:
|
||||||
|
continue
|
||||||
|
hit = self._is_sl_tp_hit(trade, price)
|
||||||
|
if hit:
|
||||||
|
exit_price = trade.stop_loss if hit == "sl" else trade.take_profit
|
||||||
|
pnl = self._calc_pnl(trade, exit_price)
|
||||||
|
self._balance += pnl
|
||||||
|
del self._open_trades[trade_id]
|
||||||
|
logger.info(
|
||||||
|
"[SIM] %s touché — %s %s | PnL=%.2f",
|
||||||
|
hit.upper(), trade_id, trade.instrument, pnl,
|
||||||
|
)
|
||||||
|
closed.append((trade_id, pnl))
|
||||||
|
|
||||||
|
return closed
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _calc_pnl(self, trade: OpenTrade, exit_price: float) -> float:
|
||||||
|
if trade.direction == "buy":
|
||||||
|
return (exit_price - trade.entry_price) * trade.units
|
||||||
|
return (trade.entry_price - exit_price) * trade.units
|
||||||
|
|
||||||
|
def _is_sl_tp_hit(self, trade: OpenTrade, current_price: float) -> Optional[str]:
|
||||||
|
if trade.direction == "buy":
|
||||||
|
if current_price <= trade.stop_loss:
|
||||||
|
return "sl"
|
||||||
|
if current_price >= trade.take_profit:
|
||||||
|
return "tp"
|
||||||
|
else:
|
||||||
|
if current_price >= trade.stop_loss:
|
||||||
|
return "sl"
|
||||||
|
if current_price <= trade.take_profit:
|
||||||
|
return "tp"
|
||||||
|
return None
|
||||||
0
backend/app/core/strategy/__init__.py
Normal file
0
backend/app/core/strategy/__init__.py
Normal file
66
backend/app/core/strategy/base.py
Normal file
66
backend/app/core/strategy/base.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderBlockZone:
|
||||||
|
"""Zone Order Block à surveiller."""
|
||||||
|
id: str
|
||||||
|
direction: str # "bullish" | "bearish"
|
||||||
|
top: float # Haut de la zone
|
||||||
|
bottom: float # Bas de la zone
|
||||||
|
origin_time: pd.Timestamp
|
||||||
|
mitigated: bool = False # True si le prix a traversé la zone
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LiquidityLevel:
|
||||||
|
"""Niveau de liquidité (Equal H/L)."""
|
||||||
|
id: str
|
||||||
|
direction: str # "high" | "low"
|
||||||
|
price: float
|
||||||
|
origin_time: pd.Timestamp
|
||||||
|
swept: bool = False # True si le prix a dépassé ce niveau
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeSignal:
|
||||||
|
"""Signal de trading généré par la stratégie."""
|
||||||
|
direction: str # "buy" | "sell"
|
||||||
|
entry_price: float
|
||||||
|
stop_loss: float
|
||||||
|
take_profit: float
|
||||||
|
signal_type: str # description du setup
|
||||||
|
time: pd.Timestamp
|
||||||
|
order_block: Optional[OrderBlockZone] = None
|
||||||
|
liquidity_level: Optional[LiquidityLevel] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalysisResult:
|
||||||
|
"""Résultat complet de l'analyse de la stratégie."""
|
||||||
|
order_blocks: list[OrderBlockZone] = field(default_factory=list)
|
||||||
|
liquidity_levels: list[LiquidityLevel] = field(default_factory=list)
|
||||||
|
signals: list[TradeSignal] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractStrategy(ABC):
|
||||||
|
"""Interface commune pour toutes les stratégies."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def analyze(self, df: pd.DataFrame) -> AnalysisResult:
|
||||||
|
"""
|
||||||
|
Analyse un DataFrame de candles et retourne les zones,
|
||||||
|
niveaux de liquidité et signaux d'entrée.
|
||||||
|
|
||||||
|
df doit avoir les colonnes : time, open, high, low, close, volume
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_params(self) -> dict:
|
||||||
|
"""Retourne les paramètres configurables de la stratégie."""
|
||||||
|
...
|
||||||
381
backend/app/core/strategy/order_block_sweep.py
Normal file
381
backend/app/core/strategy/order_block_sweep.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
"""
|
||||||
|
Stratégie ICT — Order Block + Liquidity Sweep
|
||||||
|
|
||||||
|
Logique :
|
||||||
|
1. Détecter les swing highs/lows (N bougies de chaque côté)
|
||||||
|
2. Identifier les niveaux de liquidité : Equal Highs (EQH) / Equal Lows (EQL)
|
||||||
|
- Deux swing H/L proches dans une tolérance de X pips = pool de liquidité
|
||||||
|
3. Détecter un Liquidity Sweep :
|
||||||
|
- Le wick d'une bougie dépasse un EQH/EQL, mais la bougie close de l'autre côté
|
||||||
|
→ Confirmation que la liquidité a été absorbée (stop hunt)
|
||||||
|
4. Identifier l'Order Block dans la direction du renversement :
|
||||||
|
- Bullish OB : dernière bougie bearish (close < open) avant le mouvement impulsif haussier
|
||||||
|
- Bearish OB : dernière bougie bullish (close > open) avant le mouvement impulsif bearish
|
||||||
|
5. Signal d'entrée : le prix revient dans la zone OB après le sweep
|
||||||
|
6. SL : en dessous du bas de l'OB (buy) ou au-dessus du haut (sell)
|
||||||
|
7. TP : niveau de liquidité opposé ou R:R fixe
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.core.strategy.base import (
|
||||||
|
AbstractStrategy,
|
||||||
|
AnalysisResult,
|
||||||
|
LiquidityLevel,
|
||||||
|
OrderBlockZone,
|
||||||
|
TradeSignal,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrderBlockSweepParams:
|
||||||
|
swing_strength: int = 5 # N bougies de chaque côté pour valider un swing
|
||||||
|
liquidity_tolerance_pips: float = 2.0 # Tolérance en pips pour Equal H/L
|
||||||
|
pip_value: float = 0.0001 # Valeur d'un pip (0.0001 pour Forex, 0.01 pour JPY)
|
||||||
|
min_impulse_candles: int = 3 # Nombre min de bougies dans l'impulsion
|
||||||
|
min_impulse_factor: float = 1.5 # Taille impulsion vs bougie moyenne
|
||||||
|
rr_ratio: float = 2.0 # Ratio Risk/Reward pour le TP
|
||||||
|
|
||||||
|
|
||||||
|
class OrderBlockSweepStrategy(AbstractStrategy):
|
||||||
|
def __init__(self, params: Optional[OrderBlockSweepParams] = None) -> None:
|
||||||
|
self.params = params or OrderBlockSweepParams()
|
||||||
|
|
||||||
|
def get_params(self) -> dict:
|
||||||
|
return {
|
||||||
|
"swing_strength": self.params.swing_strength,
|
||||||
|
"liquidity_tolerance_pips": self.params.liquidity_tolerance_pips,
|
||||||
|
"pip_value": self.params.pip_value,
|
||||||
|
"min_impulse_candles": self.params.min_impulse_candles,
|
||||||
|
"min_impulse_factor": self.params.min_impulse_factor,
|
||||||
|
"rr_ratio": self.params.rr_ratio,
|
||||||
|
}
|
||||||
|
|
||||||
|
def analyze(self, df: pd.DataFrame) -> AnalysisResult:
|
||||||
|
if len(df) < self.params.swing_strength * 2 + 5:
|
||||||
|
return AnalysisResult()
|
||||||
|
|
||||||
|
df = df.copy().reset_index(drop=True)
|
||||||
|
swings = self._detect_swings(df)
|
||||||
|
liquidity_levels = self._detect_liquidity(df, swings)
|
||||||
|
order_blocks = self._detect_order_blocks(df)
|
||||||
|
signals = self._detect_signals(df, liquidity_levels, order_blocks)
|
||||||
|
|
||||||
|
return AnalysisResult(
|
||||||
|
order_blocks=order_blocks,
|
||||||
|
liquidity_levels=liquidity_levels,
|
||||||
|
signals=signals,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Swing Detection ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_swings(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Retourne un DataFrame avec colonnes swing_high et swing_low (bool)."""
|
||||||
|
n = self.params.swing_strength
|
||||||
|
highs = df["high"].values
|
||||||
|
lows = df["low"].values
|
||||||
|
swing_high = [False] * len(df)
|
||||||
|
swing_low = [False] * len(df)
|
||||||
|
|
||||||
|
for i in range(n, len(df) - n):
|
||||||
|
# Swing High : plus haut que les N bougies avant et après
|
||||||
|
if all(highs[i] > highs[i - j] for j in range(1, n + 1)) and \
|
||||||
|
all(highs[i] > highs[i + j] for j in range(1, n + 1)):
|
||||||
|
swing_high[i] = True
|
||||||
|
# Swing Low : plus bas que les N bougies avant et après
|
||||||
|
if all(lows[i] < lows[i - j] for j in range(1, n + 1)) and \
|
||||||
|
all(lows[i] < lows[i + j] for j in range(1, n + 1)):
|
||||||
|
swing_low[i] = True
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
df["swing_high"] = swing_high
|
||||||
|
df["swing_low"] = swing_low
|
||||||
|
return df
|
||||||
|
|
||||||
|
# ─── Liquidity Detection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_liquidity(
|
||||||
|
self, df: pd.DataFrame, swings: pd.DataFrame
|
||||||
|
) -> list[LiquidityLevel]:
|
||||||
|
"""
|
||||||
|
Identifie les Equal Highs (EQH) et Equal Lows (EQL).
|
||||||
|
Deux swing H/L sont "égaux" si leur différence est < tolerance_pips.
|
||||||
|
"""
|
||||||
|
tol = self.params.liquidity_tolerance_pips * self.params.pip_value
|
||||||
|
levels: list[LiquidityLevel] = []
|
||||||
|
|
||||||
|
swing_highs = swings[swings["swing_high"]].copy()
|
||||||
|
swing_lows = swings[swings["swing_low"]].copy()
|
||||||
|
|
||||||
|
# Equal Highs
|
||||||
|
sh_prices = swing_highs["high"].values
|
||||||
|
sh_times = swing_highs["time"].values
|
||||||
|
for i in range(len(sh_prices)):
|
||||||
|
for j in range(i + 1, len(sh_prices)):
|
||||||
|
if abs(sh_prices[i] - sh_prices[j]) <= tol:
|
||||||
|
level_price = (sh_prices[i] + sh_prices[j]) / 2
|
||||||
|
# Vérifier si déjà sweepé dans les données actuelles
|
||||||
|
swept = self._is_level_swept(df, level_price, "high", sh_times[j])
|
||||||
|
levels.append(LiquidityLevel(
|
||||||
|
id=f"EQH_{i}_{j}",
|
||||||
|
direction="high",
|
||||||
|
price=level_price,
|
||||||
|
origin_time=pd.Timestamp(sh_times[j]),
|
||||||
|
swept=swept,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Equal Lows
|
||||||
|
sl_prices = swing_lows["low"].values
|
||||||
|
sl_times = swing_lows["time"].values
|
||||||
|
for i in range(len(sl_prices)):
|
||||||
|
for j in range(i + 1, len(sl_prices)):
|
||||||
|
if abs(sl_prices[i] - sl_prices[j]) <= tol:
|
||||||
|
level_price = (sl_prices[i] + sl_prices[j]) / 2
|
||||||
|
swept = self._is_level_swept(df, level_price, "low", sl_times[j])
|
||||||
|
levels.append(LiquidityLevel(
|
||||||
|
id=f"EQL_{i}_{j}",
|
||||||
|
direction="low",
|
||||||
|
price=level_price,
|
||||||
|
origin_time=pd.Timestamp(sl_times[j]),
|
||||||
|
swept=swept,
|
||||||
|
))
|
||||||
|
|
||||||
|
return levels
|
||||||
|
|
||||||
|
def _is_level_swept(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
price: float,
|
||||||
|
direction: str,
|
||||||
|
after_time,
|
||||||
|
) -> bool:
|
||||||
|
"""Vérifie si un niveau a été sweepé après sa formation."""
|
||||||
|
mask = df["time"] > pd.Timestamp(after_time)
|
||||||
|
future = df[mask]
|
||||||
|
if future.empty:
|
||||||
|
return False
|
||||||
|
if direction == "high":
|
||||||
|
return bool((future["high"] > price).any())
|
||||||
|
return bool((future["low"] < price).any())
|
||||||
|
|
||||||
|
# ─── Order Block Detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_order_blocks(self, df: pd.DataFrame) -> list[OrderBlockZone]:
|
||||||
|
"""
|
||||||
|
Détecte les Order Blocks :
|
||||||
|
- Bullish OB : dernière bougie bearish avant une impulsion haussière significative
|
||||||
|
- Bearish OB : dernière bougie bullish avant une impulsion bearish significative
|
||||||
|
"""
|
||||||
|
blocks: list[OrderBlockZone] = []
|
||||||
|
min_imp = self.params.min_impulse_candles
|
||||||
|
factor = self.params.min_impulse_factor
|
||||||
|
avg_body = (df["close"] - df["open"]).abs().mean()
|
||||||
|
|
||||||
|
for i in range(1, len(df) - min_imp):
|
||||||
|
# Chercher une impulsion haussière après la bougie i
|
||||||
|
impulse_up = self._is_impulse(df, i, "up", min_imp, factor, avg_body)
|
||||||
|
if impulse_up:
|
||||||
|
# Chercher la dernière bougie bearish avant i (inclus)
|
||||||
|
for k in range(i, max(0, i - 10) - 1, -1):
|
||||||
|
if df.loc[k, "close"] < df.loc[k, "open"]: # bearish
|
||||||
|
mitigated = self._is_ob_mitigated(df, k, "bullish", i + min_imp)
|
||||||
|
blocks.append(OrderBlockZone(
|
||||||
|
id=f"BullishOB_{k}",
|
||||||
|
direction="bullish",
|
||||||
|
top=df.loc[k, "open"], # top = open de la bougie bearish
|
||||||
|
bottom=df.loc[k, "low"],
|
||||||
|
origin_time=df.loc[k, "time"],
|
||||||
|
mitigated=mitigated,
|
||||||
|
))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Chercher une impulsion bearish après la bougie i
|
||||||
|
impulse_down = self._is_impulse(df, i, "down", min_imp, factor, avg_body)
|
||||||
|
if impulse_down:
|
||||||
|
for k in range(i, max(0, i - 10) - 1, -1):
|
||||||
|
if df.loc[k, "close"] > df.loc[k, "open"]: # bullish
|
||||||
|
mitigated = self._is_ob_mitigated(df, k, "bearish", i + min_imp)
|
||||||
|
blocks.append(OrderBlockZone(
|
||||||
|
id=f"BearishOB_{k}",
|
||||||
|
direction="bearish",
|
||||||
|
top=df.loc[k, "high"],
|
||||||
|
bottom=df.loc[k, "open"], # bottom = open de la bougie bullish
|
||||||
|
origin_time=df.loc[k, "time"],
|
||||||
|
mitigated=mitigated,
|
||||||
|
))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Dédupliquer (garder le plus récent par zone)
|
||||||
|
return self._deduplicate_obs(blocks)
|
||||||
|
|
||||||
|
def _is_impulse(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
start: int,
|
||||||
|
direction: str,
|
||||||
|
min_candles: int,
|
||||||
|
factor: float,
|
||||||
|
avg_body: float,
|
||||||
|
) -> bool:
|
||||||
|
"""Vérifie si une impulsion directionnelle commence à l'index start."""
|
||||||
|
end = min(start + min_candles, len(df))
|
||||||
|
segment = df.iloc[start:end]
|
||||||
|
if len(segment) < min_candles:
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_move = abs(segment.iloc[-1]["close"] - segment.iloc[0]["open"])
|
||||||
|
if total_move < avg_body * factor:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if direction == "up":
|
||||||
|
return segment.iloc[-1]["close"] > segment.iloc[0]["open"]
|
||||||
|
return segment.iloc[-1]["close"] < segment.iloc[0]["open"]
|
||||||
|
|
||||||
|
def _is_ob_mitigated(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
ob_idx: int,
|
||||||
|
direction: str,
|
||||||
|
after_idx: int,
|
||||||
|
) -> bool:
|
||||||
|
"""Vérifie si un OB a été mitié (prix est revenu dans la zone)."""
|
||||||
|
top = df.loc[ob_idx, "open"] if direction == "bullish" else df.loc[ob_idx, "high"]
|
||||||
|
bottom = df.loc[ob_idx, "low"] if direction == "bullish" else df.loc[ob_idx, "open"]
|
||||||
|
future = df.iloc[after_idx:]
|
||||||
|
if direction == "bullish":
|
||||||
|
return bool(((future["low"] <= top) & (future["low"] >= bottom)).any())
|
||||||
|
return bool(((future["high"] >= bottom) & (future["high"] <= top)).any())
|
||||||
|
|
||||||
|
def _deduplicate_obs(self, blocks: list[OrderBlockZone]) -> list[OrderBlockZone]:
|
||||||
|
"""Supprime les OB en double (même direction et zone proche)."""
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
unique: list[OrderBlockZone] = []
|
||||||
|
for b in blocks:
|
||||||
|
if b.id not in seen_ids:
|
||||||
|
seen_ids.add(b.id)
|
||||||
|
unique.append(b)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
# ─── Signal Detection ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_signals(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
liquidity_levels: list[LiquidityLevel],
|
||||||
|
order_blocks: list[OrderBlockZone],
|
||||||
|
) -> list[TradeSignal]:
|
||||||
|
"""
|
||||||
|
Génère des signaux quand :
|
||||||
|
1. Un niveau de liquidité est sweepé (wick dépasse + close de l'autre côté)
|
||||||
|
2. Le prix revient dans un OB de direction opposée au sweep
|
||||||
|
"""
|
||||||
|
signals: list[TradeSignal] = []
|
||||||
|
|
||||||
|
# Travailler sur les 50 dernières bougies pour les signaux récents
|
||||||
|
lookback = df.tail(50).copy()
|
||||||
|
|
||||||
|
if len(lookback) < 2:
|
||||||
|
return signals
|
||||||
|
|
||||||
|
for level in liquidity_levels:
|
||||||
|
if level.swept:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i in range(1, len(lookback)):
|
||||||
|
candle = lookback.iloc[i]
|
||||||
|
|
||||||
|
# Sweep d'un Equal High → signal SELL potentiel
|
||||||
|
if level.direction == "high":
|
||||||
|
sweep = (
|
||||||
|
candle["high"] > level.price and # wick dépasse
|
||||||
|
candle["close"] < level.price # close en dessous
|
||||||
|
)
|
||||||
|
if sweep:
|
||||||
|
signal = self._find_entry_signal(
|
||||||
|
df=lookback,
|
||||||
|
sweep_idx=i,
|
||||||
|
direction="sell",
|
||||||
|
swept_level=level,
|
||||||
|
order_blocks=order_blocks,
|
||||||
|
)
|
||||||
|
if signal:
|
||||||
|
signals.append(signal)
|
||||||
|
break # Un seul signal par niveau
|
||||||
|
|
||||||
|
# Sweep d'un Equal Low → signal BUY potentiel
|
||||||
|
elif level.direction == "low":
|
||||||
|
sweep = (
|
||||||
|
candle["low"] < level.price and # wick dépasse
|
||||||
|
candle["close"] > level.price # close au-dessus
|
||||||
|
)
|
||||||
|
if sweep:
|
||||||
|
signal = self._find_entry_signal(
|
||||||
|
df=lookback,
|
||||||
|
sweep_idx=i,
|
||||||
|
direction="buy",
|
||||||
|
swept_level=level,
|
||||||
|
order_blocks=order_blocks,
|
||||||
|
)
|
||||||
|
if signal:
|
||||||
|
signals.append(signal)
|
||||||
|
break # Un seul signal par niveau
|
||||||
|
|
||||||
|
return signals
|
||||||
|
|
||||||
|
def _find_entry_signal(
|
||||||
|
self,
|
||||||
|
df: pd.DataFrame,
|
||||||
|
sweep_idx: int,
|
||||||
|
direction: str,
|
||||||
|
swept_level: LiquidityLevel,
|
||||||
|
order_blocks: list[OrderBlockZone],
|
||||||
|
) -> Optional[TradeSignal]:
|
||||||
|
"""
|
||||||
|
Cherche un OB valide dans la direction du signal après le sweep.
|
||||||
|
"""
|
||||||
|
sweep_time = df.iloc[sweep_idx]["time"]
|
||||||
|
sweep_price = df.iloc[sweep_idx]["close"]
|
||||||
|
|
||||||
|
# Chercher un OB non mitié dans la bonne direction
|
||||||
|
ob_direction = "bullish" if direction == "buy" else "bearish"
|
||||||
|
candidate_obs = [
|
||||||
|
ob for ob in order_blocks
|
||||||
|
if ob.direction == ob_direction
|
||||||
|
and not ob.mitigated
|
||||||
|
and ob.origin_time <= sweep_time
|
||||||
|
]
|
||||||
|
|
||||||
|
if not candidate_obs:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Prendre l'OB le plus récent
|
||||||
|
ob = max(candidate_obs, key=lambda x: x.origin_time)
|
||||||
|
|
||||||
|
# Vérifier que le prix actuel est proche de l'OB ou dans la zone
|
||||||
|
if direction == "buy":
|
||||||
|
# Le prix doit être au-dessus ou proche du bas de l'OB
|
||||||
|
if sweep_price < ob.bottom * 0.998: # trop loin en dessous
|
||||||
|
return None
|
||||||
|
entry = ob.top
|
||||||
|
sl = ob.bottom - 2 * self.params.pip_value
|
||||||
|
tp = entry + (entry - sl) * self.params.rr_ratio
|
||||||
|
else:
|
||||||
|
if sweep_price > ob.top * 1.002:
|
||||||
|
return None
|
||||||
|
entry = ob.bottom
|
||||||
|
sl = ob.top + 2 * self.params.pip_value
|
||||||
|
tp = entry - (sl - entry) * self.params.rr_ratio
|
||||||
|
|
||||||
|
return TradeSignal(
|
||||||
|
direction=direction,
|
||||||
|
entry_price=entry,
|
||||||
|
stop_loss=sl,
|
||||||
|
take_profit=tp,
|
||||||
|
signal_type=f"LiquiditySweep_{swept_level.direction.upper()}+OrderBlock",
|
||||||
|
time=sweep_time,
|
||||||
|
order_block=ob,
|
||||||
|
liquidity_level=swept_level,
|
||||||
|
)
|
||||||
90
backend/app/main.py
Normal file
90
backend/app/main.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.api.routes import backtest, bot, candles, trades
|
||||||
|
from app.api.routes.bot import set_bot_runner
|
||||||
|
from app.api.websocket import manager, websocket_endpoint
|
||||||
|
from app.core.bot import BotRunner
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import AsyncSessionLocal, init_db
|
||||||
|
from app.core.exchange.simulated import SimulatedExchange
|
||||||
|
from app.core.strategy.order_block_sweep import OrderBlockSweepStrategy
|
||||||
|
from app.services.market_data import MarketDataService
|
||||||
|
from app.services.trade_manager import TradeManager
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Trader Bot API",
|
||||||
|
description="Bot de trading ICT — Order Block + Liquidity Sweep",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:5173", "http://localhost:3000"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(candles.router, prefix="/api")
|
||||||
|
app.include_router(trades.router, prefix="/api")
|
||||||
|
app.include_router(backtest.router, prefix="/api")
|
||||||
|
app.include_router(bot.router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/live")
|
||||||
|
async def ws_live(websocket: WebSocket):
|
||||||
|
await websocket_endpoint(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
await init_db()
|
||||||
|
logger.info("Base de données initialisée")
|
||||||
|
|
||||||
|
# Initialiser le bot avec l'exchange simulé (paper trading local)
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
market_data = MarketDataService(db)
|
||||||
|
exchange = SimulatedExchange(
|
||||||
|
market_data_service=market_data,
|
||||||
|
initial_balance=settings.bot_initial_balance,
|
||||||
|
)
|
||||||
|
strategy = OrderBlockSweepStrategy()
|
||||||
|
trade_mgr = TradeManager(exchange)
|
||||||
|
runner = BotRunner(
|
||||||
|
exchange=exchange,
|
||||||
|
strategy=strategy,
|
||||||
|
trade_manager=trade_mgr,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def broadcast_event(event: dict):
|
||||||
|
await manager.broadcast(event)
|
||||||
|
|
||||||
|
runner.set_event_callback(broadcast_event)
|
||||||
|
set_bot_runner(runner)
|
||||||
|
|
||||||
|
td_status = "✓ configurée" if settings.twelvedata_api_key else "✗ manquante (historique limité)"
|
||||||
|
logger.info(
|
||||||
|
"Bot initialisé — SimulatedExchange | balance=%.0f$ | TwelveData=%s",
|
||||||
|
settings.bot_initial_balance,
|
||||||
|
td_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"name": "Trader Bot API",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
"data_sources": {
|
||||||
|
"primary": "yfinance",
|
||||||
|
"fallback_historical": "TwelveData",
|
||||||
|
"twelvedata_configured": bool(settings.twelvedata_api_key),
|
||||||
|
},
|
||||||
|
}
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
32
backend/app/models/backtest_result.py
Normal file
32
backend/app/models/backtest_result.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Float, Integer, String, DateTime, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestResult(Base):
|
||||||
|
__tablename__ = "backtest_results"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
instrument: Mapped[str] = mapped_column(String(20))
|
||||||
|
granularity: Mapped[str] = mapped_column(String(10))
|
||||||
|
start_date: Mapped[datetime] = mapped_column(DateTime)
|
||||||
|
end_date: Mapped[datetime] = mapped_column(DateTime)
|
||||||
|
initial_balance: Mapped[float] = mapped_column(Float, default=10000.0)
|
||||||
|
final_balance: Mapped[float] = mapped_column(Float)
|
||||||
|
total_pnl: Mapped[float] = mapped_column(Float)
|
||||||
|
total_trades: Mapped[int] = mapped_column(Integer)
|
||||||
|
winning_trades: Mapped[int] = mapped_column(Integer)
|
||||||
|
losing_trades: Mapped[int] = mapped_column(Integer)
|
||||||
|
win_rate: Mapped[float] = mapped_column(Float)
|
||||||
|
max_drawdown: Mapped[float] = mapped_column(Float)
|
||||||
|
sharpe_ratio: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
expectancy: Mapped[float] = mapped_column(Float)
|
||||||
|
# Courbe d'équité [{time, balance}] stockée en JSON
|
||||||
|
equity_curve: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
# Paramètres de la stratégie utilisés
|
||||||
|
strategy_params: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
25
backend/app/models/candle.py
Normal file
25
backend/app/models/candle.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Float, Integer, String, DateTime, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Candle(Base):
|
||||||
|
__tablename__ = "candles"
|
||||||
|
__table_args__ = (
|
||||||
|
# Garantit INSERT OR IGNORE sur (instrument, granularity, time)
|
||||||
|
UniqueConstraint("instrument", "granularity", "time", name="uq_candle"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
instrument: Mapped[str] = mapped_column(String(20), index=True)
|
||||||
|
granularity: Mapped[str] = mapped_column(String(10))
|
||||||
|
time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||||
|
open: Mapped[float] = mapped_column(Float)
|
||||||
|
high: Mapped[float] = mapped_column(Float)
|
||||||
|
low: Mapped[float] = mapped_column(Float)
|
||||||
|
close: Mapped[float] = mapped_column(Float)
|
||||||
|
volume: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
complete: Mapped[bool] = mapped_column(default=True)
|
||||||
34
backend/app/models/trade.py
Normal file
34
backend/app/models/trade.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Float, Integer, String, DateTime
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Trade(Base):
|
||||||
|
__tablename__ = "trades"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
# "live" | "backtest"
|
||||||
|
source: Mapped[str] = mapped_column(String(10), default="live")
|
||||||
|
# identifiant OANDA du trade live (si applicable)
|
||||||
|
oanda_trade_id: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
instrument: Mapped[str] = mapped_column(String(20), index=True)
|
||||||
|
# "buy" | "sell"
|
||||||
|
direction: Mapped[str] = mapped_column(String(4))
|
||||||
|
units: Mapped[float] = mapped_column(Float)
|
||||||
|
entry_price: Mapped[float] = mapped_column(Float)
|
||||||
|
stop_loss: Mapped[float] = mapped_column(Float)
|
||||||
|
take_profit: Mapped[float] = mapped_column(Float)
|
||||||
|
exit_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
pnl: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
# "open" | "closed" | "cancelled"
|
||||||
|
status: Mapped[str] = mapped_column(String(10), default="open")
|
||||||
|
# Signal ayant déclenché le trade
|
||||||
|
signal_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
opened_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
# ID du backtest parent (si applicable)
|
||||||
|
backtest_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/data_providers/__init__.py
Normal file
0
backend/app/services/data_providers/__init__.py
Normal file
82
backend/app/services/data_providers/constants.py
Normal file
82
backend/app/services/data_providers/constants.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Constantes de mapping entre les noms canoniques du projet
|
||||||
|
et les symboles/intervalles propres à chaque source de données.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Limites yfinance (jours de données disponibles par granularité) ──────────
|
||||||
|
YF_MAX_DAYS: dict[str, int] = {
|
||||||
|
"M1": 7,
|
||||||
|
"M5": 60,
|
||||||
|
"M15": 60,
|
||||||
|
"M30": 60,
|
||||||
|
"H1": 730,
|
||||||
|
"H4": 730,
|
||||||
|
"D": 9999,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Durée d'une bougie en minutes ─────────────────────────────────────────────
|
||||||
|
GRANULARITY_MINUTES: dict[str, int] = {
|
||||||
|
"M1": 1,
|
||||||
|
"M5": 5,
|
||||||
|
"M15": 15,
|
||||||
|
"M30": 30,
|
||||||
|
"H1": 60,
|
||||||
|
"H4": 240,
|
||||||
|
"D": 1440,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mapping vers les intervalles yfinance ─────────────────────────────────────
|
||||||
|
GRANULARITY_TO_YF: dict[str, str] = {
|
||||||
|
"M1": "1m",
|
||||||
|
"M5": "5m",
|
||||||
|
"M15": "15m",
|
||||||
|
"M30": "30m",
|
||||||
|
"H1": "1h",
|
||||||
|
"H4": "4h",
|
||||||
|
"D": "1d",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mapping vers les intervalles TwelveData ───────────────────────────────────
|
||||||
|
GRANULARITY_TO_TD: dict[str, str] = {
|
||||||
|
"M1": "1min",
|
||||||
|
"M5": "5min",
|
||||||
|
"M15": "15min",
|
||||||
|
"M30": "30min",
|
||||||
|
"H1": "1h",
|
||||||
|
"H4": "4h",
|
||||||
|
"D": "1day",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mapping instrument → symbole yfinance ─────────────────────────────────────
|
||||||
|
INSTRUMENT_TO_YF: dict[str, str] = {
|
||||||
|
"EUR_USD": "EURUSD=X",
|
||||||
|
"GBP_USD": "GBPUSD=X",
|
||||||
|
"USD_JPY": "USDJPY=X",
|
||||||
|
"USD_CHF": "USDCHF=X",
|
||||||
|
"AUD_USD": "AUDUSD=X",
|
||||||
|
"USD_CAD": "USDCAD=X",
|
||||||
|
"GBP_JPY": "GBPJPY=X",
|
||||||
|
"EUR_JPY": "EURJPY=X",
|
||||||
|
"EUR_GBP": "EURGBP=X",
|
||||||
|
"SPX500_USD": "^GSPC",
|
||||||
|
"NAS100_USD": "^NDX",
|
||||||
|
"XAU_USD": "GC=F",
|
||||||
|
"US30_USD": "YM=F",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Mapping instrument → symbole TwelveData ───────────────────────────────────
|
||||||
|
INSTRUMENT_TO_TD: dict[str, str] = {
|
||||||
|
"EUR_USD": "EUR/USD",
|
||||||
|
"GBP_USD": "GBP/USD",
|
||||||
|
"USD_JPY": "USD/JPY",
|
||||||
|
"USD_CHF": "USD/CHF",
|
||||||
|
"AUD_USD": "AUD/USD",
|
||||||
|
"USD_CAD": "USD/CAD",
|
||||||
|
"GBP_JPY": "GBP/JPY",
|
||||||
|
"EUR_JPY": "EUR/JPY",
|
||||||
|
"EUR_GBP": "EUR/GBP",
|
||||||
|
"SPX500_USD": "SPY",
|
||||||
|
"NAS100_USD": "QQQ",
|
||||||
|
"XAU_USD": "XAU/USD",
|
||||||
|
"US30_USD": "DJI",
|
||||||
|
}
|
||||||
159
backend/app/services/data_providers/twelvedata_provider.py
Normal file
159
backend/app/services/data_providers/twelvedata_provider.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Provider TwelveData — données OHLCV historiques illimitées.
|
||||||
|
|
||||||
|
Plan gratuit : 800 requêtes/jour, 8 req/min.
|
||||||
|
Docs : https://twelvedata.com/docs
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.services.data_providers.constants import GRANULARITY_TO_TD, INSTRUMENT_TO_TD
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TWELVEDATA_BASE_URL = "https://api.twelvedata.com"
|
||||||
|
# Nombre max de points par requête TwelveData (plan gratuit)
|
||||||
|
MAX_OUTPUTSIZE = 5000
|
||||||
|
# Limite du plan gratuit : 8 req/min
|
||||||
|
_RATE_LIMIT = 8
|
||||||
|
_RATE_WINDOW = 61 # secondes (légèrement au-dessus de 60 pour la marge)
|
||||||
|
_rate_lock = asyncio.Lock()
|
||||||
|
_request_times: list[float] = []
|
||||||
|
|
||||||
|
|
||||||
|
async def _rate_limited_get(client: httpx.AsyncClient, url: str, params: dict) -> httpx.Response:
|
||||||
|
"""Wrapper qui respecte la limite de 8 req/min de TwelveData."""
|
||||||
|
global _request_times
|
||||||
|
async with _rate_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
# Purger les timestamps hors fenêtre
|
||||||
|
_request_times = [t for t in _request_times if now - t < _RATE_WINDOW]
|
||||||
|
if len(_request_times) >= _RATE_LIMIT:
|
||||||
|
wait = _RATE_WINDOW - (now - _request_times[0])
|
||||||
|
if wait > 0:
|
||||||
|
logger.info("TwelveData rate limit : attente %.1f s", wait)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
_request_times = [t for t in _request_times if time.monotonic() - t < _RATE_WINDOW]
|
||||||
|
_request_times.append(time.monotonic())
|
||||||
|
return await client.get(url, params=params)
|
||||||
|
|
||||||
|
|
||||||
|
class TwelveDataProvider:
|
||||||
|
"""Fetche des candles depuis l'API TwelveData."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._api_key = settings.twelvedata_api_key
|
||||||
|
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return bool(self._api_key)
|
||||||
|
|
||||||
|
async def fetch(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
start: datetime,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Fetche les candles pour la période [start, end]."""
|
||||||
|
if not self.is_configured():
|
||||||
|
logger.warning("TwelveData : TWELVEDATA_API_KEY non configurée")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
td_symbol = INSTRUMENT_TO_TD.get(instrument)
|
||||||
|
td_interval = GRANULARITY_TO_TD.get(granularity)
|
||||||
|
|
||||||
|
if not td_symbol or not td_interval:
|
||||||
|
logger.warning("TwelveData : instrument/granularité non supporté — %s %s", instrument, granularity)
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
if end is None:
|
||||||
|
end = datetime.utcnow()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TwelveData fetch : %s (%s) %s → %s",
|
||||||
|
instrument, granularity, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# TwelveData supporte max 5000 points par requête
|
||||||
|
# Si la période est longue, on fait plusieurs requêtes
|
||||||
|
all_frames: list[pd.DataFrame] = []
|
||||||
|
current_end = end
|
||||||
|
|
||||||
|
while current_end > start:
|
||||||
|
df_chunk = await self._fetch_chunk(td_symbol, td_interval, start, current_end)
|
||||||
|
if df_chunk.empty:
|
||||||
|
break
|
||||||
|
all_frames.append(df_chunk)
|
||||||
|
oldest = df_chunk["time"].min()
|
||||||
|
if oldest <= start:
|
||||||
|
break
|
||||||
|
# Reculer pour la prochaine requête
|
||||||
|
current_end = oldest - timedelta(seconds=1)
|
||||||
|
|
||||||
|
if not all_frames:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
df = pd.concat(all_frames, ignore_index=True)
|
||||||
|
df = df.drop_duplicates(subset=["time"])
|
||||||
|
df = df.sort_values("time").reset_index(drop=True)
|
||||||
|
df = df[(df["time"] >= start) & (df["time"] <= end)]
|
||||||
|
|
||||||
|
logger.info("TwelveData : %d bougies récupérées pour %s %s", len(df), instrument, granularity)
|
||||||
|
return df
|
||||||
|
|
||||||
|
async def _fetch_chunk(
|
||||||
|
self,
|
||||||
|
td_symbol: str,
|
||||||
|
td_interval: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
params = {
|
||||||
|
"symbol": td_symbol,
|
||||||
|
"interval": td_interval,
|
||||||
|
"start_date": start.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"end_date": end.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"outputsize": MAX_OUTPUTSIZE,
|
||||||
|
"format": "JSON",
|
||||||
|
"apikey": self._api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
resp = await _rate_limited_get(client, f"{TWELVEDATA_BASE_URL}/time_series", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("TwelveData erreur HTTP : %s", e)
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
if data.get("status") == "error":
|
||||||
|
logger.error("TwelveData API erreur : %s", data.get("message"))
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
values = data.get("values", [])
|
||||||
|
if not values:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for v in values:
|
||||||
|
rows.append({
|
||||||
|
"time": pd.to_datetime(v["datetime"]),
|
||||||
|
"open": float(v["open"]),
|
||||||
|
"high": float(v["high"]),
|
||||||
|
"low": float(v["low"]),
|
||||||
|
"close": float(v["close"]),
|
||||||
|
"volume": int(v.get("volume", 0)),
|
||||||
|
})
|
||||||
|
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df = df.sort_values("time").reset_index(drop=True)
|
||||||
|
return df
|
||||||
134
backend/app/services/data_providers/yfinance_provider.py
Normal file
134
backend/app/services/data_providers/yfinance_provider.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""
|
||||||
|
Provider yfinance — données OHLCV gratuites.
|
||||||
|
|
||||||
|
Limites :
|
||||||
|
- M1 : 7 derniers jours
|
||||||
|
- M5/M15/M30 : 60 derniers jours
|
||||||
|
- H1/H4 : 730 derniers jours
|
||||||
|
- D : illimité
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from app.services.data_providers.constants import (
|
||||||
|
GRANULARITY_TO_YF,
|
||||||
|
INSTRUMENT_TO_YF,
|
||||||
|
YF_MAX_DAYS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
"""Normalise un DataFrame yfinance vers le format interne."""
|
||||||
|
df = df.copy()
|
||||||
|
df.index = pd.to_datetime(df.index, utc=True)
|
||||||
|
df.index = df.index.tz_localize(None) if df.index.tz is not None else df.index
|
||||||
|
|
||||||
|
df.columns = [c.lower() for c in df.columns]
|
||||||
|
# yfinance peut retourner des colonnes multi-index
|
||||||
|
if isinstance(df.columns, pd.MultiIndex):
|
||||||
|
df.columns = df.columns.get_level_values(0)
|
||||||
|
|
||||||
|
df = df.rename(columns={"adj close": "close"})[["open", "high", "low", "close", "volume"]]
|
||||||
|
df = df.dropna(subset=["open", "high", "low", "close"])
|
||||||
|
df.index.name = "time"
|
||||||
|
df = df.reset_index()
|
||||||
|
df["time"] = pd.to_datetime(df["time"]).dt.tz_localize(None)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_sync(
|
||||||
|
yf_symbol: str,
|
||||||
|
yf_interval: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""Exécution synchrone de yfinance (sera appelée dans un thread)."""
|
||||||
|
import yfinance as yf
|
||||||
|
|
||||||
|
ticker = yf.Ticker(yf_symbol)
|
||||||
|
df = ticker.history(
|
||||||
|
interval=yf_interval,
|
||||||
|
start=start.strftime("%Y-%m-%d"),
|
||||||
|
end=(end + timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||||
|
auto_adjust=True,
|
||||||
|
prepost=False,
|
||||||
|
)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class YFinanceProvider:
|
||||||
|
"""Fetche des candles depuis Yahoo Finance."""
|
||||||
|
|
||||||
|
def yf_cutoff(self, granularity: str) -> Optional[datetime]:
|
||||||
|
"""Retourne la date la plus ancienne que yfinance peut fournir."""
|
||||||
|
max_days = YF_MAX_DAYS.get(granularity)
|
||||||
|
if max_days is None:
|
||||||
|
return None
|
||||||
|
return datetime.utcnow() - timedelta(days=max_days - 1)
|
||||||
|
|
||||||
|
def can_provide(self, granularity: str, start: datetime) -> bool:
|
||||||
|
"""Vérifie si yfinance peut fournir des données pour cette période."""
|
||||||
|
cutoff = self.yf_cutoff(granularity)
|
||||||
|
if cutoff is None:
|
||||||
|
return False
|
||||||
|
return start >= cutoff
|
||||||
|
|
||||||
|
async def fetch(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
start: datetime,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Fetche les candles pour la période [start, end].
|
||||||
|
Tronque start à la limite yfinance si nécessaire.
|
||||||
|
"""
|
||||||
|
yf_symbol = INSTRUMENT_TO_YF.get(instrument)
|
||||||
|
yf_interval = GRANULARITY_TO_YF.get(granularity)
|
||||||
|
|
||||||
|
if not yf_symbol or not yf_interval:
|
||||||
|
logger.warning("yfinance : instrument ou granularité non supporté — %s %s", instrument, granularity)
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# Tronquer start à la limite yfinance
|
||||||
|
cutoff = self.yf_cutoff(granularity)
|
||||||
|
if cutoff and start < cutoff:
|
||||||
|
logger.debug("yfinance : start tronqué de %s à %s", start, cutoff)
|
||||||
|
start = cutoff
|
||||||
|
|
||||||
|
if end is None:
|
||||||
|
end = datetime.utcnow()
|
||||||
|
|
||||||
|
if start >= end:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"yfinance fetch : %s (%s) %s → %s",
|
||||||
|
instrument, granularity, start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
raw = await loop.run_in_executor(
|
||||||
|
None, _fetch_sync, yf_symbol, yf_interval, start, end
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("yfinance erreur : %s", e)
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
if raw.empty:
|
||||||
|
logger.warning("yfinance : aucune donnée pour %s %s", instrument, granularity)
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
df = _normalize(raw)
|
||||||
|
df = df[(df["time"] >= start) & (df["time"] <= end)]
|
||||||
|
logger.info("yfinance : %d bougies récupérées pour %s %s", len(df), instrument, granularity)
|
||||||
|
return df
|
||||||
264
backend/app/services/market_data.py
Normal file
264
backend/app/services/market_data.py
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
MarketDataService — source de données hybride avec cache DB.
|
||||||
|
|
||||||
|
Stratégie de fetch pour une période [start, end] demandée :
|
||||||
|
|
||||||
|
1. DB d'abord → on récupère ce qu'on a déjà, on ne refetch jamais ce qui existe
|
||||||
|
2. Gaps récents → yfinance (dans ses limites temporelles)
|
||||||
|
3. Gaps historiques → TwelveData (pour tout ce que yfinance ne peut pas couvrir)
|
||||||
|
4. Tout est stocké → les prochaines requêtes seront servies depuis la DB
|
||||||
|
|
||||||
|
Exemple (M1, 10 derniers jours demandés) :
|
||||||
|
- DB : déjà ce qu'on a en cache
|
||||||
|
- yfinance : J-7 → maintenant (limite M1 = 7 jours)
|
||||||
|
- TwelveData : J-10 → J-7 (historique au-delà de yfinance)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from sqlalchemy import and_, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.candle import Candle
|
||||||
|
from app.services.data_providers.constants import GRANULARITY_MINUTES
|
||||||
|
from app.services.data_providers.twelvedata_provider import TwelveDataProvider
|
||||||
|
from app.services.data_providers.yfinance_provider import YFinanceProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Facteur pour compenser weekends + jours fériés dans le calcul de la fenêtre
|
||||||
|
TRADING_DAYS_FACTOR = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataService:
|
||||||
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
self._db = db
|
||||||
|
self._yf = YFinanceProvider()
|
||||||
|
self._td = TwelveDataProvider()
|
||||||
|
|
||||||
|
# ── API publique ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_candles(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
count: int = 200,
|
||||||
|
start: Optional[datetime] = None,
|
||||||
|
end: Optional[datetime] = None,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Retourne jusqu'à `count` bougies pour instrument/granularity.
|
||||||
|
Si start/end fournis, ils définissent la plage exacte.
|
||||||
|
|
||||||
|
Processus :
|
||||||
|
1. Calcul de la fenêtre temporelle nécessaire
|
||||||
|
2. Détection et comblement des gaps (yfinance + TwelveData)
|
||||||
|
3. Lecture depuis DB et retour
|
||||||
|
"""
|
||||||
|
if end is None:
|
||||||
|
end = datetime.utcnow()
|
||||||
|
if start is None:
|
||||||
|
minutes = GRANULARITY_MINUTES.get(granularity, 60)
|
||||||
|
start = end - timedelta(minutes=int(minutes * count * TRADING_DAYS_FACTOR))
|
||||||
|
|
||||||
|
await self._fill_gaps(instrument, granularity, start, end)
|
||||||
|
return await self._db_fetch(instrument, granularity, start, end, limit=count)
|
||||||
|
|
||||||
|
async def get_latest_price(self, instrument: str) -> Optional[float]:
|
||||||
|
"""Retourne le dernier close connu (DB ou yfinance M1)."""
|
||||||
|
stmt = (
|
||||||
|
select(Candle.close)
|
||||||
|
.where(Candle.instrument == instrument)
|
||||||
|
.order_by(Candle.time.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await self._db.execute(stmt)
|
||||||
|
price = result.scalar_one_or_none()
|
||||||
|
if price:
|
||||||
|
return float(price)
|
||||||
|
|
||||||
|
df = await self.get_candles(instrument, "M1", count=2)
|
||||||
|
return float(df.iloc[-1]["close"]) if not df.empty else None
|
||||||
|
|
||||||
|
# ── Logique de détection et comblement des gaps ───────────────────────────
|
||||||
|
|
||||||
|
async def _fill_gaps(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> None:
|
||||||
|
gaps = await self._find_gaps(instrument, granularity, start, end)
|
||||||
|
for gap_start, gap_end in gaps:
|
||||||
|
await self._fetch_and_store_gap(instrument, granularity, gap_start, gap_end)
|
||||||
|
|
||||||
|
async def _find_gaps(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> list[tuple[datetime, datetime]]:
|
||||||
|
"""
|
||||||
|
Retourne la liste des (gap_start, gap_end) manquants en DB.
|
||||||
|
|
||||||
|
Logique :
|
||||||
|
- Si rien en DB pour la plage → un seul gap = (start, end)
|
||||||
|
- Sinon → combler avant le plus ancien et/ou après le plus récent
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(Candle.time)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Candle.instrument == instrument,
|
||||||
|
Candle.granularity == granularity,
|
||||||
|
Candle.time >= start,
|
||||||
|
Candle.time <= end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Candle.time)
|
||||||
|
)
|
||||||
|
result = await self._db.execute(stmt)
|
||||||
|
times = [r[0] for r in result.fetchall()]
|
||||||
|
|
||||||
|
if not times:
|
||||||
|
return [(start, end)]
|
||||||
|
|
||||||
|
gaps: list[tuple[datetime, datetime]] = []
|
||||||
|
interval = timedelta(minutes=GRANULARITY_MINUTES.get(granularity, 60))
|
||||||
|
oldest, newest = times[0], times[-1]
|
||||||
|
|
||||||
|
# Gap avant : demande de données antérieures à ce qu'on a
|
||||||
|
if start < oldest - interval:
|
||||||
|
gaps.append((start, oldest))
|
||||||
|
|
||||||
|
# Gap après : demande de données plus récentes que ce qu'on a
|
||||||
|
freshness_threshold = interval * 2
|
||||||
|
if end > newest + freshness_threshold:
|
||||||
|
gaps.append((newest, end))
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
|
||||||
|
async def _fetch_and_store_gap(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
gap_start: datetime,
|
||||||
|
gap_end: datetime,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Fetche un gap :
|
||||||
|
1. yfinance pour la partie récente (dans ses limites)
|
||||||
|
2. TwelveData en fallback si yfinance échoue, ou pour la partie historique
|
||||||
|
"""
|
||||||
|
yf_cutoff = self._yf.yf_cutoff(granularity)
|
||||||
|
yf_covered = False
|
||||||
|
|
||||||
|
# ── yfinance : partie récente du gap ─────────────────────────────────
|
||||||
|
if yf_cutoff is not None:
|
||||||
|
yf_start = max(gap_start, yf_cutoff)
|
||||||
|
if yf_start < gap_end:
|
||||||
|
df_yf = await self._yf.fetch(instrument, granularity, yf_start, gap_end)
|
||||||
|
if not df_yf.empty:
|
||||||
|
await self._store(df_yf, instrument, granularity)
|
||||||
|
yf_covered = True
|
||||||
|
|
||||||
|
# ── TwelveData : historique + fallback si yfinance indisponible ───────
|
||||||
|
if self._td.is_configured():
|
||||||
|
# Partie historique (avant la limite yfinance)
|
||||||
|
td_end = yf_cutoff if (yf_cutoff and gap_start < yf_cutoff) else None
|
||||||
|
if td_end and gap_start < td_end:
|
||||||
|
df_td = await self._td.fetch(instrument, granularity, gap_start, td_end)
|
||||||
|
if not df_td.empty:
|
||||||
|
await self._store(df_td, instrument, granularity)
|
||||||
|
|
||||||
|
# Fallback pour la partie récente si yfinance n'a rien retourné
|
||||||
|
if not yf_covered:
|
||||||
|
yf_start = max(gap_start, yf_cutoff) if yf_cutoff else gap_start
|
||||||
|
if yf_start < gap_end:
|
||||||
|
logger.info(
|
||||||
|
"yfinance indisponible — fallback TwelveData pour %s %s [%s → %s]",
|
||||||
|
instrument, granularity,
|
||||||
|
yf_start.strftime("%Y-%m-%d"), gap_end.strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
df_td2 = await self._td.fetch(instrument, granularity, yf_start, gap_end)
|
||||||
|
if not df_td2.empty:
|
||||||
|
await self._store(df_td2, instrument, granularity)
|
||||||
|
elif not yf_covered:
|
||||||
|
logger.warning(
|
||||||
|
"Gap [%s → %s] pour %s %s — "
|
||||||
|
"TWELVEDATA_API_KEY manquante et yfinance indisponible.",
|
||||||
|
gap_start.strftime("%Y-%m-%d"),
|
||||||
|
gap_end.strftime("%Y-%m-%d"),
|
||||||
|
instrument,
|
||||||
|
granularity,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── DB helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _db_fetch(
|
||||||
|
self,
|
||||||
|
instrument: str,
|
||||||
|
granularity: str,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
limit: int = 5000,
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
stmt = (
|
||||||
|
select(Candle)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Candle.instrument == instrument,
|
||||||
|
Candle.granularity == granularity,
|
||||||
|
Candle.time >= start,
|
||||||
|
Candle.time <= end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Candle.time.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self._db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return pd.DataFrame(columns=["time", "open", "high", "low", "close", "volume"])
|
||||||
|
|
||||||
|
df = pd.DataFrame(
|
||||||
|
[{"time": r.time, "open": r.open, "high": r.high,
|
||||||
|
"low": r.low, "close": r.close, "volume": r.volume}
|
||||||
|
for r in rows]
|
||||||
|
)
|
||||||
|
return df.sort_values("time").reset_index(drop=True)
|
||||||
|
|
||||||
|
async def _store(self, df: pd.DataFrame, instrument: str, granularity: str) -> None:
|
||||||
|
"""
|
||||||
|
Insère les bougies en DB avec INSERT OR IGNORE.
|
||||||
|
Les bougies déjà présentes (même instrument+granularity+time) ne sont jamais modifiées.
|
||||||
|
"""
|
||||||
|
if df.empty:
|
||||||
|
return
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
await self._db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT OR IGNORE INTO candles "
|
||||||
|
"(instrument, granularity, time, open, high, low, close, volume, complete) "
|
||||||
|
"VALUES (:instrument, :granularity, :time, :open, :high, :low, :close, :volume, 1)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"instrument": instrument,
|
||||||
|
"granularity": granularity,
|
||||||
|
"time": pd.Timestamp(row["time"]).to_pydatetime().replace(tzinfo=None),
|
||||||
|
"open": float(row["open"]),
|
||||||
|
"high": float(row["high"]),
|
||||||
|
"low": float(row["low"]),
|
||||||
|
"close": float(row["close"]),
|
||||||
|
"volume": int(row.get("volume", 0)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._db.commit()
|
||||||
67
backend/app/services/trade_manager.py
Normal file
67
backend/app/services/trade_manager.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Gestion des positions : sizing, validation, suivi.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.exchange.base import AbstractExchange, AccountInfo, OrderResult
|
||||||
|
from app.core.strategy.base import TradeSignal
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TradeManager:
|
||||||
|
def __init__(self, exchange: AbstractExchange) -> None:
|
||||||
|
self._exchange = exchange
|
||||||
|
self._risk_percent = settings.bot_risk_percent
|
||||||
|
self._pip_value = 0.0001
|
||||||
|
|
||||||
|
async def execute_signal(self, signal: TradeSignal) -> Optional[OrderResult]:
|
||||||
|
"""
|
||||||
|
Calcule le sizing et place l'ordre sur l'exchange.
|
||||||
|
Retourne None si le signal est invalide ou si le sizing est trop petit.
|
||||||
|
"""
|
||||||
|
account = await self._exchange.get_account_info()
|
||||||
|
|
||||||
|
risk_pips = abs(signal.entry_price - signal.stop_loss) / self._pip_value
|
||||||
|
if risk_pips < 1:
|
||||||
|
logger.warning("Signal ignoré : risque en pips trop faible (%s)", risk_pips)
|
||||||
|
return None
|
||||||
|
|
||||||
|
units = self._calculate_units(account, risk_pips, signal)
|
||||||
|
if abs(units) < 1000:
|
||||||
|
logger.warning("Signal ignoré : taille de position trop petite (%s)", units)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Exécution signal %s %s — entry=%.5f SL=%.5f TP=%.5f units=%d",
|
||||||
|
signal.direction, signal.signal_type,
|
||||||
|
signal.entry_price, signal.stop_loss, signal.take_profit, units,
|
||||||
|
)
|
||||||
|
return await self._exchange.place_order(
|
||||||
|
instrument=signal.signal_type.split("+")[0] if "+" in signal.signal_type else "EUR_USD",
|
||||||
|
units=units,
|
||||||
|
stop_loss=signal.stop_loss,
|
||||||
|
take_profit=signal.take_profit,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calculate_units(
|
||||||
|
self,
|
||||||
|
account: AccountInfo,
|
||||||
|
risk_pips: float,
|
||||||
|
signal: TradeSignal,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Calcule la taille de position basée sur le risque défini.
|
||||||
|
Formula : units = (balance * risk%) / (risk_pips * pip_value_per_unit)
|
||||||
|
Pour EUR/USD standard : pip_value_per_unit = $0.0001
|
||||||
|
"""
|
||||||
|
risk_amount = account.balance * (self._risk_percent / 100)
|
||||||
|
# Pour simplification : 1 pip = 0.0001, valeur pip sur mini-lot = $1
|
||||||
|
pip_value_per_unit = self._pip_value
|
||||||
|
raw_units = risk_amount / (risk_pips * pip_value_per_unit)
|
||||||
|
# Arrondir à la centaine inférieure
|
||||||
|
units = (int(raw_units) // 100) * 100
|
||||||
|
return float(units) if signal.direction == "buy" else float(-units)
|
||||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
pandas==2.2.3
|
||||||
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
aiosqlite==0.20.0
|
||||||
|
pydantic-settings==2.6.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
httpx==0.27.2
|
||||||
|
websockets==13.1
|
||||||
|
python-multipart==0.0.18
|
||||||
|
yfinance==0.2.48
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal 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
3377
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
frontend/package.json
Normal file
33
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
53
frontend/src/App.tsx
Normal file
53
frontend/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
frontend/src/components/Backtest/BacktestForm.tsx
Normal file
116
frontend/src/components/Backtest/BacktestForm.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
frontend/src/components/Backtest/BacktestResults.tsx
Normal file
163
frontend/src/components/Backtest/BacktestResults.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
229
frontend/src/components/Chart/CandlestickChart.tsx
Normal file
229
frontend/src/components/Chart/CandlestickChart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/Dashboard/BotStatus.tsx
Normal file
77
frontend/src/components/Dashboard/BotStatus.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
frontend/src/components/Dashboard/PnLSummary.tsx
Normal file
46
frontend/src/components/Dashboard/PnLSummary.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
frontend/src/components/Dashboard/TradeList.tsx
Normal file
92
frontend/src/components/Dashboard/TradeList.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/hooks/useWebSocket.ts
Normal file
56
frontend/src/hooks/useWebSocket.ts
Normal 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
33
frontend/src/index.css
Normal 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
130
frontend/src/lib/api.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
51
frontend/src/pages/Backtest.tsx
Normal file
51
frontend/src/pages/Backtest.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
frontend/src/pages/Dashboard.tsx
Normal file
111
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/tailwind.config.ts
Normal file
28
frontend/tailwind.config.ts
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
25
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user