Initial commit — Trading AI Secure project complet

Architecture Docker (8 services), FastAPI, TimescaleDB, Redis, Streamlit.
Stratégies : scalping, intraday, swing. MLEngine + RegimeDetector (HMM).
BacktestEngine + WalkForwardAnalyzer + Optuna optimizer.
Routes API complètes dont /optimize async.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tika
2026-03-08 17:38:09 +00:00
commit da30ef19ed
111 changed files with 31723 additions and 0 deletions

411
src/README.md Normal file
View File

@@ -0,0 +1,411 @@
# 📁 Source Code - Trading AI Secure
## 🎯 Vue d'ensemble
Ce dossier contient tout le code source de l'application Trading AI Secure.
---
## 📂 Structure
```
src/
├── __init__.py # Package principal
├── main.py # Point d'entrée
├── core/ # Modules centraux
│ ├── __init__.py
│ ├── risk_manager.py # Risk Manager (Singleton)
│ └── strategy_engine.py # Orchestrateur stratégies
├── strategies/ # Stratégies de trading
│ ├── __init__.py
│ ├── base_strategy.py # Classe abstraite
│ ├── scalping/ # À créer
│ ├── intraday/ # À créer
│ └── swing/ # À créer
├── data/ # Connecteurs données (À créer)
├── ml/ # Machine Learning (À créer)
├── backtesting/ # Backtesting (À créer)
├── ui/ # Interface (À créer)
├── monitoring/ # Monitoring (À créer)
└── utils/ # Utilitaires
├── __init__.py
├── logger.py # Système de logging
└── config_loader.py # Chargeur configuration
```
---
## 🚀 Utilisation
### Lancer l'Application
```bash
# Backtesting
python src/main.py --mode backtest --strategy intraday --symbol EURUSD
# Paper trading
python src/main.py --mode paper --strategy all
# Optimisation
python src/main.py --mode optimize --strategy scalping
```
### Importer des Modules
```python
# Risk Manager
from src.core.risk_manager import RiskManager
risk_manager = RiskManager()
is_valid, error = risk_manager.validate_trade(...)
# Strategy Engine
from src.core.strategy_engine import StrategyEngine
engine = StrategyEngine(config, risk_manager)
await engine.load_strategy('intraday')
await engine.run()
# Logging
from src.utils.logger import setup_logger, get_logger
setup_logger(level='INFO')
logger = get_logger(__name__)
logger.info("Message")
# Configuration
from src.utils.config_loader import ConfigLoader
config = ConfigLoader.load_all()
risk_limits = config['risk_limits']
```
---
## 📚 Modules Détaillés
### Core
#### RiskManager (`core/risk_manager.py`)
**Responsabilités** :
- Validation pré-trade (10 vérifications)
- Gestion des positions
- Calcul métriques de risque (VaR, CVaR, drawdown)
- Circuit breakers
- Statistiques
**Usage** :
```python
risk_manager = RiskManager()
risk_manager.initialize(config)
# Valider trade
is_valid, error = risk_manager.validate_trade(
symbol='EURUSD',
quantity=1000,
price=1.1000,
stop_loss=1.0950,
take_profit=1.1100,
strategy='intraday'
)
# Métriques
metrics = risk_manager.get_risk_metrics()
print(f"VaR: ${metrics.portfolio_var:.2f}")
```
#### StrategyEngine (`core/strategy_engine.py`)
**Responsabilités** :
- Chargement dynamique des stratégies
- Boucle principale de trading
- Distribution données marché
- Collecte et filtrage signaux
- Exécution ordres
**Usage** :
```python
engine = StrategyEngine(config, risk_manager)
# Charger stratégies
await engine.load_strategy('scalping')
await engine.load_strategy('intraday')
# Lancer
await engine.run()
```
### Strategies
#### BaseStrategy (`strategies/base_strategy.py`)
**Classe abstraite** pour toutes les stratégies.
**Méthodes à implémenter** :
- `analyze(market_data)` : Génère signaux
- `calculate_indicators(data)` : Calcule indicateurs
**Méthodes fournies** :
- `calculate_position_size()` : Kelly Criterion
- `update_parameters()` : Paramètres adaptatifs
- `record_trade()` : Enregistrement trades
**Usage** :
```python
from src.strategies.base_strategy import BaseStrategy
class MyStrategy(BaseStrategy):
def analyze(self, market_data):
# Implémenter logique
df = self.calculate_indicators(market_data)
if condition:
return Signal(...)
return None
def calculate_indicators(self, data):
# Calculer indicateurs
data['sma_20'] = data['close'].rolling(20).mean()
return data
```
### Utils
#### Logger (`utils/logger.py`)
**Fonctionnalités** :
- Logs console colorés
- Logs fichiers avec rotation
- Niveaux configurables
**Usage** :
```python
from src.utils.logger import setup_logger, get_logger
# Setup (une fois au démarrage)
setup_logger(level='INFO', log_dir='logs')
# Utiliser
logger = get_logger(__name__)
logger.info("Info message")
logger.warning("Warning message")
logger.error("Error message")
```
#### ConfigLoader (`utils/config_loader.py`)
**Fonctionnalités** :
- Chargement YAML
- Accès centralisé
**Usage** :
```python
from src.utils.config_loader import ConfigLoader
# Charger toute la config
config = ConfigLoader.load_all()
# Ou spécifique
risk_limits = ConfigLoader.get_risk_limits()
strategy_params = ConfigLoader.get_strategy_params('intraday')
```
---
## 🧪 Tests
### Lancer les Tests
```bash
# Tous les tests
pytest tests/
# Tests spécifiques
pytest tests/unit/test_risk_manager.py
# Avec couverture
pytest --cov=src tests/
```
### Écrire des Tests
```python
# tests/unit/test_risk_manager.py
import pytest
from src.core.risk_manager import RiskManager
def test_singleton():
rm1 = RiskManager()
rm2 = RiskManager()
assert rm1 is rm2
def test_validate_trade():
rm = RiskManager()
is_valid, error = rm.validate_trade(...)
assert is_valid is True
```
---
## 📝 Conventions de Code
### Style
- **PEP 8** : Respecter PEP 8
- **Type Hints** : Obligatoires sur tous les paramètres et retours
- **Docstrings** : Google style pour toutes les classes et méthodes
- **Imports** : Organisés (stdlib, third-party, local)
### Exemple
```python
"""
Module description.
Detailed explanation of what this module does.
"""
from typing import Dict, List, Optional
import numpy as np
from src.core.risk_manager import RiskManager
class MyClass:
"""
Brief description.
Detailed description of the class.
Attributes:
attr1: Description
attr2: Description
"""
def __init__(self, param1: str, param2: int):
"""
Initialize MyClass.
Args:
param1: Description
param2: Description
"""
self.attr1 = param1
self.attr2 = param2
def my_method(self, arg1: float) -> bool:
"""
Brief description.
Detailed description of what the method does.
Args:
arg1: Description
Returns:
Description of return value
Raises:
ValueError: When something is wrong
"""
if arg1 < 0:
raise ValueError("arg1 must be positive")
return True
```
---
## 🔧 Développement
### Ajouter une Nouvelle Stratégie
1. **Créer fichier** : `src/strategies/my_strategy/my_strategy.py`
2. **Hériter de BaseStrategy** :
```python
from src.strategies.base_strategy import BaseStrategy, Signal
class MyStrategy(BaseStrategy):
def analyze(self, market_data):
# Implémenter
pass
def calculate_indicators(self, data):
# Implémenter
pass
```
3. **Ajouter configuration** : `config/strategy_params.yaml`
4. **Charger dans StrategyEngine** : Modifier `strategy_engine.py`
5. **Tester** : Créer `tests/unit/test_my_strategy.py`
### Ajouter un Nouveau Module
1. **Créer dossier** : `src/my_module/`
2. **Créer `__init__.py`** : Exports du module
3. **Créer fichiers** : Implémenter fonctionnalités
4. **Documenter** : Ajouter README dans le module
5. **Tester** : Créer tests unitaires
---
## 📊 Métriques de Code
### Couverture Actuelle
| Module | Fichiers | Lignes | Couverture | Statut |
|--------|----------|--------|------------|--------|
| core | 2 | ~1,000 | 0% | ⏳ À tester |
| strategies | 1 | ~450 | 0% | ⏳ À tester |
| utils | 2 | ~270 | 0% | ⏳ À tester |
| **TOTAL** | **5** | **~1,720** | **0%** | **⏳ À tester** |
**Objectif** : 85% de couverture
---
## 🐛 Debugging
### Activer Debug Logging
```bash
python src/main.py --log-level DEBUG --mode backtest --strategy intraday
```
### Profiling
```bash
# CPU profiling
python -m cProfile -o profile.stats src/main.py --mode backtest
python -m pstats profile.stats
# Memory profiling
python -m memory_profiler src/main.py --mode backtest
```
---
## 📚 Ressources
- [Documentation Complète](../docs/)
- [Guide de Contribution](../docs/CONTRIBUTING.md)
- [Architecture](../docs/ARCHITECTURE.md)
- [Risk Framework](../docs/RISK_FRAMEWORK.md)
---
**Code maintenu par l'équipe Trading AI Secure**
**Version** : 0.1.0-alpha
**Dernière mise à jour** : 2024-01-15

34
src/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
"""
Trading AI Secure - Application de Trading Multi-Stratégie avec IA Adaptative
Ce package contient tous les modules nécessaires pour un système de trading
algorithmique sécurisé avec IA adaptative et risk management intégré.
Modules:
core: Modules centraux (risk manager, strategy engine)
strategies: Stratégies de trading (scalping, intraday, swing)
ml: Machine learning et IA adaptative
data: Connecteurs de données et sources
backtesting: Framework de backtesting et validation
ui: Interface utilisateur (dashboard)
monitoring: Monitoring et alertes
utils: Utilitaires et helpers
Version: 0.1.0-alpha
Author: Trading AI Secure Team
License: MIT
"""
__version__ = "0.1.0-alpha"
__author__ = "Trading AI Secure Team"
__license__ = "MIT"
# Imports principaux pour faciliter l'utilisation
from src.core.risk_manager import RiskManager
from src.core.strategy_engine import StrategyEngine
__all__ = [
"RiskManager",
"StrategyEngine",
"__version__",
]

0
src/api/__init__.py Normal file
View File

93
src/api/app.py Normal file
View File

@@ -0,0 +1,93 @@
"""
Point d'entrée FastAPI - Trading AI Secure
Lance avec :
uvicorn src.api.app:app --host 0.0.0.0 --port 8100 --reload
Ou via Docker :
docker compose up trading-api
"""
import sys
from pathlib import Path
# Garantit que les imports src.* fonctionnent que l'app
# soit lancée depuis la racine du projet ou depuis Docker (/app)
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.api.routers import health, trading
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Événements de démarrage / arrêt de l'application."""
# --- Startup ---
from src.db.session import init_db
from src.utils.config_loader import ConfigLoader
from src.core.risk_manager import RiskManager
import logging
logger = logging.getLogger(__name__)
logger.info("Starting Trading AI Secure API...")
# Initialiser les tables DB
try:
init_db()
except Exception as exc:
logger.warning(f"DB init skipped (DB may not be ready): {exc}")
# Pré-charger config et Risk Manager
try:
config = ConfigLoader.load_all()
rm = RiskManager()
if not rm.config:
rm.initialize(config["risk_limits"])
logger.info("Risk Manager initialized")
except Exception as exc:
logger.warning(f"RiskManager pre-init skipped: {exc}")
yield
# --- Shutdown ---
logger.info("Trading AI Secure API shutting down")
app = FastAPI(
lifespan=lifespan,
title="Trading AI Secure",
description=(
"API de trading algorithmique avec IA adaptative.\n\n"
"Architecture : FastAPI · TimescaleDB · Redis · ML Engine · Streamlit"
),
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
)
# CORS - à restreindre en production
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Routers
app.include_router(health.router, tags=["monitoring"])
app.include_router(trading.router)
@app.get("/", include_in_schema=False)
def root():
return {
"service": "Trading AI Secure API",
"version": "0.1.0",
"docs": "/docs",
"health": "/health",
}

View File

79
src/api/routers/health.py Normal file
View File

@@ -0,0 +1,79 @@
"""
Routes de santé et monitoring.
Exposées à Prometheus via /metrics et utilisées par les health checks Docker.
"""
import time
from fastapi import APIRouter
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
from fastapi.responses import Response
router = APIRouter()
# Métriques Prometheus
REQUEST_COUNT = Counter(
'trading_api_requests_total',
'Nombre total de requêtes API',
['method', 'endpoint', 'status']
)
REQUEST_LATENCY = Histogram(
'trading_api_request_latency_seconds',
'Latence des requêtes API',
['endpoint']
)
_start_time = time.time()
@router.get("/health")
def health_check():
"""Health check endpoint - utilisé par Docker et NPM."""
return {
"status": "healthy",
"service": "trading-api",
"uptime_seconds": round(time.time() - _start_time, 2),
}
@router.get("/ready")
def readiness_check():
"""
Readiness check — vérifie DB et Redis avant d'accepter du trafic.
Retourne 503 si une dépendance est indisponible.
"""
from fastapi import HTTPException
from src.db.session import check_db_connection
import redis
import os
issues: list[str] = []
# Vérification DB
if not check_db_connection():
issues.append("database")
# Vérification Redis
try:
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis.from_url(redis_url, socket_connect_timeout=2)
r.ping()
except Exception:
issues.append("redis")
if issues:
raise HTTPException(
status_code=503,
detail={"status": "unavailable", "issues": issues},
)
return {"status": "ready"}
@router.get("/metrics")
def metrics():
"""Endpoint Prometheus metrics."""
return Response(
content=generate_latest(),
media_type=CONTENT_TYPE_LATEST
)

735
src/api/routers/trading.py Normal file
View File

@@ -0,0 +1,735 @@
"""
Routes de trading : risk, positions, signaux, backtesting, paper trading.
"""
import asyncio
import json
import os
import uuid
from datetime import datetime
from typing import Dict, List, Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
router = APIRouter(prefix="/trading", tags=["trading"])
# Jobs de backtest en cours (en mémoire — remplacer par Redis en production)
_backtest_jobs: Dict[str, dict] = {}
# Dernier état ML connu — mis à jour lors des détections de régime
_ml_state: Dict = {}
# Cache ML par symbole — évite de re-entraîner le HMM à chaque appel
# Format : {symbol: {"result": MLStatus, "timestamp": datetime}}
_ml_cache: Dict = {}
_ML_CACHE_TTL_MINUTES = 15
# État du paper trading en cours
_paper_state: Dict = {"task": None, "engine": None, "strategy": None}
def _get_redis():
"""Retourne un client Redis synchrone (None si indisponible)."""
try:
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
return redis_lib.from_url(redis_url, socket_connect_timeout=2)
except Exception:
return None
# =============================================================================
# Modèles de données
# =============================================================================
class BacktestRequest(BaseModel):
strategy: str = Field(..., description="scalping | intraday | swing")
symbol: str = Field(default="EURUSD")
period: str = Field(default="1y", description="6m | 1y | 2y")
initial_capital: float = Field(default=10000.0, gt=0)
class BacktestResponse(BaseModel):
job_id: str
status: str # pending | running | completed | failed
strategy: str
symbol: str
# Remplis quand completed
total_return: Optional[float] = None
sharpe_ratio: Optional[float] = None
max_drawdown: Optional[float] = None
win_rate: Optional[float] = None
profit_factor: Optional[float] = None
total_trades: Optional[int] = None
is_valid_for_paper: Optional[bool] = None
class PaperTradingStatus(BaseModel):
running: bool
strategy: Optional[str]
capital: float
pnl: float
pnl_pct: float
open_positions: int
class PositionResponse(BaseModel):
symbol: str
direction: str
quantity: float
entry_price: float
current_price: float
stop_loss: float
take_profit: float
unrealized_pnl: float
strategy: str
entry_time: str
class Signal(BaseModel):
symbol: str
direction: str # BUY | SELL
confidence: float
strategy: str
timestamp: str
class RiskStatus(BaseModel):
portfolio_value: float
initial_capital: float
total_return: float
current_drawdown: float
max_drawdown_allowed: float
daily_pnl: float
weekly_pnl: float
open_positions: int
total_trades: int
win_rate: float
circuit_breaker_active: bool
circuit_breaker_reason: Optional[str]
risk_utilization: float
var_95: float
class EmergencyStopResponse(BaseModel):
halted: bool
reason: str
class MLStatus(BaseModel):
available: bool
regime: Optional[int]
regime_name: str
regime_pct: Dict[str, float] # distribution des régimes sur la période
strategy_advice: Dict[str, bool] # {strategy: should_trade}
symbol: str
bars_analyzed: int
# =============================================================================
# Risk & Portfolio
# =============================================================================
@router.get("/risk/status", response_model=RiskStatus, summary="Statut Risk Manager")
def get_risk_status():
"""
Retourne l'état complet du Risk Manager :
drawdown actuel, PnL, positions ouvertes, circuit breakers.
"""
from src.core.risk_manager import RiskManager
rm = RiskManager()
stats = rm.get_statistics()
metrics = rm.get_risk_metrics()
return RiskStatus(
portfolio_value= stats["portfolio_value"],
initial_capital= stats["initial_capital"],
total_return= stats["total_return"],
current_drawdown= stats["current_drawdown"],
max_drawdown_allowed= rm.config.get("global_limits", {}).get("max_drawdown", 0.10),
daily_pnl= metrics.daily_pnl,
weekly_pnl= metrics.weekly_pnl,
open_positions= stats["num_positions"],
total_trades= stats["total_trades"],
win_rate= stats["win_rate"],
circuit_breaker_active= stats["trading_halted"],
circuit_breaker_reason= rm.halt_reason,
risk_utilization= metrics.risk_utilization,
var_95= metrics.portfolio_var,
)
@router.post("/risk/emergency-stop", response_model=EmergencyStopResponse,
summary="Arrêt d'urgence")
def emergency_stop(reason: str = "Manuel via API"):
"""
Déclenche l'arrêt d'urgence du trading.
Toutes les nouvelles validations de trade seront refusées.
"""
from src.core.risk_manager import RiskManager
rm = RiskManager()
rm.halt_trading(reason)
return EmergencyStopResponse(halted=True, reason=reason)
@router.post("/risk/resume", summary="Reprendre le trading")
def resume_trading():
"""Reprend le trading après un arrêt (manuel uniquement)."""
from src.core.risk_manager import RiskManager
rm = RiskManager()
rm.resume_trading()
return {"status": "trading_resumed"}
# =============================================================================
# Positions
# =============================================================================
@router.get("/positions", response_model=List[PositionResponse], summary="Positions ouvertes")
def get_positions():
"""Retourne toutes les positions ouvertes dans le Risk Manager."""
from src.core.risk_manager import RiskManager
rm = RiskManager()
return [
PositionResponse(
symbol= pos.symbol,
direction= "LONG" if pos.quantity > 0 else "SHORT",
quantity= abs(pos.quantity),
entry_price= pos.entry_price,
current_price= pos.current_price,
stop_loss= pos.stop_loss,
take_profit= pos.take_profit,
unrealized_pnl= pos.unrealized_pnl,
strategy= pos.strategy,
entry_time= pos.entry_time.isoformat(),
)
for pos in rm.positions.values()
]
# =============================================================================
# Signaux
# =============================================================================
@router.get("/signals", response_model=List[Signal], summary="Signaux actifs")
def get_active_signals():
"""
Retourne les signaux de trading actifs générés par le StrategyEngine.
Publiés dans Redis par la boucle StrategyEngine (clé trading:signals, TTL 5 min).
"""
r = _get_redis()
if r is None:
return []
try:
raw = r.get("trading:signals")
if raw:
return [Signal(**item) for item in json.loads(raw)]
except Exception:
pass
return []
# =============================================================================
# ML / Regime Detection
# =============================================================================
@router.get("/ml/status", response_model=MLStatus, summary="Statut ML et régime de marché")
def get_ml_status(symbol: str = "EURUSD"):
"""
Détecte le régime de marché actuel via le MLEngine (RegimeDetector HMM).
Retourne le régime courant, sa distribution et les recommandations par stratégie.
Calcul effectué sur les 30 derniers jours de données horaires.
Le résultat est mis en cache 15 minutes par symbole pour éviter le re-training.
"""
import asyncio
from datetime import timedelta
from src.ml.ml_engine import MLEngine
from src.data.data_service import DataService
from src.utils.config_loader import ConfigLoader
# Vérifier le cache — retourner directement si valide
cached = _ml_cache.get(symbol)
if cached:
age_minutes = (datetime.now() - cached["timestamp"]).total_seconds() / 60
if age_minutes < _ML_CACHE_TTL_MINUTES:
return cached["result"]
try:
config = ConfigLoader.load_all()
data_service = DataService(config)
now = datetime.now()
start = now - timedelta(days=30)
# Récupérer données synchrones via asyncio.run
df = asyncio.run(
data_service.get_historical_data(
symbol=symbol,
timeframe="1h",
start_date=start,
end_date=now,
)
)
if df is None or df.empty or len(df) < 50:
return MLStatus(
available=False,
regime=None,
regime_name="Données insuffisantes",
regime_pct={},
strategy_advice={},
symbol=symbol,
bars_analyzed=0,
)
df.columns = [c.lower() for c in df.columns]
ml = MLEngine(config=config.get("ml", {}))
ml.initialize(df)
regime_info = ml.get_regime_info()
regime_stats = ml.regime_detector.get_regime_statistics(df)
strategy_advice = {
s: ml.should_trade(s)
for s in ("scalping", "intraday", "swing")
}
# Mettre à jour l'état global
_ml_state.update({
"regime": regime_info.get("regime"),
"regime_name": regime_info.get("regime_name", "Unknown"),
"symbol": symbol,
})
result = MLStatus(
available=True,
regime=regime_info.get("regime"),
regime_name=regime_info.get("regime_name", "Unknown"),
regime_pct=regime_stats.get("regime_percentages", {}),
strategy_advice=strategy_advice,
symbol=symbol,
bars_analyzed=len(df),
)
# Mettre en cache le résultat
_ml_cache[symbol] = {"result": result, "timestamp": datetime.now()}
return result
except Exception as exc:
return MLStatus(
available=False,
regime=None,
regime_name=f"Erreur : {exc}",
regime_pct={},
strategy_advice={},
symbol=symbol,
bars_analyzed=0,
)
# =============================================================================
# Backtesting
# =============================================================================
async def _run_backtest_task(job_id: str, request: BacktestRequest):
"""Tâche asynchrone de backtesting."""
_backtest_jobs[job_id]["status"] = "running"
try:
from src.backtesting.backtest_engine import BacktestEngine
from src.utils.config_loader import ConfigLoader
config = ConfigLoader.load_all()
# Le BacktestEngine actuel est synchrone — on l'exécute dans un thread
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
None,
lambda: _sync_backtest(request, config),
)
# BacktestEngine.run() retourne {'metrics': {...}, 'trades': [...], ...}
metrics = results.get("metrics", {}) if results else {}
_backtest_jobs[job_id].update({
"status": "completed",
"total_return": metrics.get("total_return", 0),
"sharpe_ratio": metrics.get("sharpe_ratio", 0),
"max_drawdown": metrics.get("max_drawdown", 0),
"win_rate": metrics.get("win_rate", 0),
"profit_factor": metrics.get("profit_factor", 0),
"total_trades": metrics.get("total_trades", 0),
"is_valid_for_paper": (
metrics.get("sharpe_ratio", 0) >= 1.5
and metrics.get("max_drawdown", 1) <= 0.10
and metrics.get("win_rate", 0) >= 0.55
),
})
except Exception as exc:
_backtest_jobs[job_id]["status"] = "failed"
_backtest_jobs[job_id]["error"] = str(exc)
def _sync_backtest(request: BacktestRequest, config: dict) -> dict:
"""Wrapper synchrone autour du BacktestEngine."""
import asyncio
from src.backtesting.backtest_engine import BacktestEngine
from src.core.strategy_engine import StrategyEngine
from src.core.risk_manager import RiskManager
rm = RiskManager()
if not rm.config:
rm.initialize(config["risk_limits"])
se = StrategyEngine(
config=config.get("strategy_params", {}),
risk_manager=rm,
)
engine = BacktestEngine(strategy_engine=se, config=config)
async def _run():
# Charger la stratégie AVANT de lancer le backtest
await se.load_strategy(request.strategy)
return await engine.run(
symbols=[request.symbol],
period=request.period,
initial_capital=request.initial_capital,
)
return asyncio.run(_run())
@router.post("/backtest", response_model=BacktestResponse, summary="Lancer un backtest")
async def run_backtest(request: BacktestRequest, background_tasks: BackgroundTasks):
"""
Lance un backtest en arrière-plan et retourne un `job_id`.
Interroger `/trading/backtest/{job_id}` pour le résultat.
"""
if request.strategy not in ("scalping", "intraday", "swing"):
raise HTTPException(400, detail="strategy doit être : scalping | intraday | swing")
job_id = str(uuid.uuid4())
_backtest_jobs[job_id] = {
"status": "pending",
"strategy": request.strategy,
"symbol": request.symbol,
}
background_tasks.add_task(_run_backtest_task, job_id, request)
return BacktestResponse(
job_id= job_id,
status= "pending",
strategy= request.strategy,
symbol= request.symbol,
)
@router.get("/backtest/{job_id}", response_model=BacktestResponse, summary="Résultat backtest")
def get_backtest_result(job_id: str):
"""Retourne l'état d'un job de backtesting."""
job = _backtest_jobs.get(job_id)
if job is None:
raise HTTPException(404, detail=f"Job {job_id} introuvable")
return BacktestResponse(job_id=job_id, **job)
# =============================================================================
# Historique des trades (lecture DB)
# =============================================================================
@router.get("/trades", summary="Historique des trades")
def get_trades(limit: int = 200, strategy: Optional[str] = None):
"""
Retourne les trades enregistrés en base (modèle Trade).
Filtre optionnel par stratégie.
"""
from src.db.session import get_db
from src.db.models import Trade
try:
db = next(get_db())
query = db.query(Trade).order_by(Trade.exit_time.desc())
if strategy:
query = query.filter(Trade.strategy == strategy)
trades = query.limit(limit).all()
return [
{
"id": t.id,
"symbol": t.symbol,
"strategy": t.strategy,
"direction": t.direction,
"entry_price": float(t.entry_price),
"exit_price": float(t.exit_price) if t.exit_price else None,
"quantity": float(t.quantity),
"pnl": float(t.pnl) if t.pnl is not None else None,
"pnl_pct": float(t.pnl_pct) if t.pnl_pct is not None else None,
"entry_time": t.entry_time.isoformat() if t.entry_time else None,
"exit_time": t.exit_time.isoformat() if t.exit_time else None,
"status": t.status,
}
for t in trades
]
except Exception as exc:
return []
# =============================================================================
# Paper Trading
# =============================================================================
@router.get("/paper/status", response_model=PaperTradingStatus, summary="Statut paper trading")
def get_paper_status():
"""Statut du paper trading : capital, PnL, positions."""
from src.core.risk_manager import RiskManager
rm = RiskManager()
stats = rm.get_statistics()
initial = stats["initial_capital"]
value = stats["portfolio_value"]
task_running = (
_paper_state["task"] is not None
and not _paper_state["task"].done()
)
return PaperTradingStatus(
running= task_running,
strategy= _paper_state.get("strategy"),
capital= value,
pnl= value - initial,
pnl_pct= stats["total_return"],
open_positions= stats["num_positions"],
)
@router.post("/paper/start", summary="Démarrer le paper trading")
async def start_paper_trading(strategy: str, initial_capital: float = 10000.0):
"""
Démarre le paper trading pour une stratégie (asyncio.create_task).
La boucle tourne en arrière-plan, publie les signaux dans Redis.
"""
from src.backtesting.paper_trading import PaperTradingEngine
from src.core.strategy_engine import StrategyEngine
from src.core.risk_manager import RiskManager
from src.utils.config_loader import ConfigLoader
if strategy not in ("scalping", "intraday", "swing", "all"):
raise HTTPException(400, detail="strategy doit être : scalping | intraday | swing | all")
# Arrêter toute session en cours avant d'en démarrer une nouvelle
existing_task = _paper_state.get("task")
if existing_task and not existing_task.done():
existing_engine = _paper_state.get("engine")
if existing_engine:
await existing_engine.stop()
else:
existing_task.cancel()
config = ConfigLoader.load_all()
rm = RiskManager()
if not rm.config:
rm.initialize(config["risk_limits"])
se = StrategyEngine(config=config.get("strategy_params", {}), risk_manager=rm)
strategies_to_load = ["scalping", "intraday", "swing"] if strategy == "all" else [strategy]
for s in strategies_to_load:
await se.load_strategy(s)
paper_engine = PaperTradingEngine(strategy_engine=se, initial_capital=initial_capital)
task = asyncio.create_task(paper_engine.run())
_paper_state.update({"task": task, "engine": paper_engine, "strategy": strategy})
return {
"status": "started",
"strategy": strategy,
"capital": initial_capital,
"note": "Paper trading démarré. Consultez /trading/paper/status pour le suivi.",
}
@router.post("/paper/stop", summary="Arrêter le paper trading")
async def stop_paper_trading():
"""Arrête le paper trading en cours et annule la tâche asyncio."""
engine = _paper_state.get("engine")
task = _paper_state.get("task")
if engine:
await engine.stop()
elif task and not task.done():
task.cancel()
_paper_state.update({"task": None, "engine": None, "strategy": None})
from src.core.risk_manager import RiskManager
rm = RiskManager()
stats = rm.get_statistics()
return {
"status": "stopped",
"final_pnl": stats["portfolio_value"] - stats["initial_capital"],
"total_trades": stats["total_trades"],
}
# =============================================================================
# Optimisation Optuna des paramètres de stratégie
# =============================================================================
# Jobs d'optimisation en cours (en mémoire)
_optimize_jobs: Dict[str, dict] = {}
class OptimizeRequest(BaseModel):
strategy: str = Field("scalping", description="Stratégie à optimiser (scalping|intraday|swing)")
symbol: str = Field("EURUSD", description="Symbole à utiliser")
period: str = Field("6m", description="Période de données (6m, 1y…)")
n_trials: int = Field(50, ge=10, le=500, description="Nombre de trials Optuna")
initial_capital: float = Field(10000.0, gt=0, description="Capital initial pour la simulation")
class OptimizeResponse(BaseModel):
job_id: str
status: str # pending | running | completed | failed
strategy: str
symbol: str
best_sharpe: Optional[float] = None
best_params: Optional[Dict] = None
wf_avg_sharpe: Optional[float] = None
wf_stability: Optional[float] = None
n_trials_done: Optional[int] = None
error: Optional[str] = None
async def _run_optimize_task(job_id: str, request: OptimizeRequest):
"""Tâche asynchrone d'optimisation Optuna."""
_optimize_jobs[job_id]["status"] = "running"
try:
from src.utils.config_loader import ConfigLoader
from src.data.data_service import DataService
from datetime import timedelta
config = ConfigLoader.load_all()
ds = DataService(config)
# Récupérer les données
end_date = datetime.now()
period_map = {'y': 365, 'm': 30, 'd': 1}
unit = request.period[-1]
value = int(request.period[:-1])
start_date = end_date - timedelta(days=value * period_map.get(unit, 1))
df = await ds.get_historical_data(request.symbol, '1h', start_date, end_date)
if df is None or df.empty:
_optimize_jobs[job_id].update({
"status": "failed",
"error": f"Pas de données pour {request.symbol}",
})
return
# Charger la classe de stratégie
if request.strategy == 'scalping':
from src.strategies.scalping.scalping_strategy import ScalpingStrategy
strategy_class = ScalpingStrategy
elif request.strategy == 'intraday':
from src.strategies.intraday.intraday_strategy import IntradayStrategy
strategy_class = IntradayStrategy
elif request.strategy == 'swing':
from src.strategies.swing.swing_strategy import SwingStrategy
strategy_class = SwingStrategy
else:
_optimize_jobs[job_id].update({
"status": "failed",
"error": f"Stratégie inconnue : {request.strategy}",
})
return
# Lancer l'optimisation dans un thread (bloquant)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: _sync_optimize(strategy_class, df, request),
)
wf = result.get('walk_forward_results', {})
_optimize_jobs[job_id].update({
"status": "completed",
"best_sharpe": result.get('best_value'),
"best_params": result.get('best_params'),
"wf_avg_sharpe": wf.get('avg_sharpe'),
"wf_stability": wf.get('stability'),
"n_trials_done": result.get('n_trials_done'),
})
# Appliquer les paramètres à la stratégie si paper trading actif
if _paper_state.get("strategy") == request.strategy and result.get('best_params'):
engine = _paper_state.get("engine")
if engine and hasattr(engine, 'strategy_engine'):
strat = engine.strategy_engine.strategies.get(request.strategy)
if strat:
strat.update_params(result['best_params'])
import logging
logging.getLogger(__name__).info(
f"Paramètres Optuna appliqués au paper trading {request.strategy}"
)
except Exception as exc:
_optimize_jobs[job_id]["status"] = "failed"
_optimize_jobs[job_id]["error"] = str(exc)
def _sync_optimize(strategy_class, df, request: OptimizeRequest) -> dict:
"""Wrapper synchrone pour ParameterOptimizer (exécuté dans un thread)."""
from src.ml.parameter_optimizer import ParameterOptimizer, OPTUNA_AVAILABLE
if not OPTUNA_AVAILABLE:
raise RuntimeError("Optuna non disponible dans ce container")
optimizer = ParameterOptimizer(
strategy_class = strategy_class,
data = df,
initial_capital = request.initial_capital,
)
return optimizer.optimize(n_trials=request.n_trials)
@router.post("/optimize", response_model=OptimizeResponse, summary="Lancer l'optimisation Optuna")
async def start_optimize(request: OptimizeRequest, background_tasks: BackgroundTasks):
"""
Lance une optimisation Optuna des paramètres d'une stratégie en arrière-plan.
Retourne un `job_id` à interroger via `GET /trading/optimize/{job_id}`.
"""
if request.strategy not in ("scalping", "intraday", "swing"):
raise HTTPException(400, detail="strategy doit être : scalping | intraday | swing")
job_id = str(uuid.uuid4())
_optimize_jobs[job_id] = {
"status": "pending",
"strategy": request.strategy,
"symbol": request.symbol,
}
background_tasks.add_task(_run_optimize_task, job_id, request)
return OptimizeResponse(
job_id = job_id,
status = "pending",
strategy = request.strategy,
symbol = request.symbol,
)
@router.get("/optimize/{job_id}", response_model=OptimizeResponse, summary="Résultat optimisation")
def get_optimize_result(job_id: str):
"""Retourne l'état d'un job d'optimisation Optuna."""
job = _optimize_jobs.get(job_id)
if job is None:
raise HTTPException(404, detail=f"Job {job_id} introuvable")
return OptimizeResponse(job_id=job_id, **job)

View File

@@ -0,0 +1,21 @@
"""
Module Backtesting - Framework de Backtesting et Validation.
Ce module fournit tous les outils pour backtester et valider les stratégies:
- BacktestEngine: Moteur de backtesting principal
- PaperTradingEngine: Paper trading temps réel
- MetricsCalculator: Calcul des métriques de performance
- WalkForwardAnalyzer: Walk-forward analysis
- MonteCarloSimulator: Simulation Monte Carlo
Tous les outils sont conçus pour éviter l'overfitting et garantir
des résultats réalistes.
"""
from src.backtesting.backtest_engine import BacktestEngine
from src.backtesting.metrics_calculator import MetricsCalculator
__all__ = [
'BacktestEngine',
'MetricsCalculator',
]

View File

@@ -0,0 +1,466 @@
"""
Backtest Engine - Moteur de Backtesting Principal.
Ce module simule l'exécution d'une stratégie sur données historiques
avec réalisme maximal:
- Slippage
- Commissions
- Spread
- Latence
- Gestion des ordres
- Risk management intégré
"""
from typing import Dict, List, Optional
from datetime import datetime, timedelta
import pandas as pd
import numpy as np
import logging
from src.core.strategy_engine import StrategyEngine
from src.core.risk_manager import RiskManager, Position
from src.backtesting.metrics_calculator import MetricsCalculator
from src.data.data_service import DataService
logger = logging.getLogger(__name__)
class BacktestEngine:
"""
Moteur de backtesting réaliste.
Simule l'exécution d'une stratégie sur données historiques avec:
- Coûts de transaction (commissions, slippage, spread)
- Risk management complet
- Gestion réaliste des ordres
- Métriques de performance détaillées
Usage:
engine = BacktestEngine(strategy_engine, config)
results = await engine.run(symbols, period, initial_capital)
"""
def __init__(
self,
strategy_engine: StrategyEngine,
config: Dict
):
"""
Initialise le backtest engine.
Args:
strategy_engine: Engine de stratégies
config: Configuration du backtesting
"""
self.strategy_engine = strategy_engine
self.config = config.get('backtesting_config', {})
# Coûts de transaction
transaction_costs = self.config.get('transaction_costs', {})
self.commission_pct = transaction_costs.get('commission_pct', 0.0001) # 0.01%
self.slippage_pct = transaction_costs.get('slippage_pct', 0.0005) # 0.05%
self.spread_pct = transaction_costs.get('spread_pct', 0.0002) # 0.02%
# État du backtest
self.equity_curve = []
self.trades = []
self.current_bar = 0
# Calculateur de métriques
self.metrics_calculator = MetricsCalculator()
logger.info("Backtest Engine initialized")
async def run(
self,
symbols: List[str],
period: str,
initial_capital: float = 10000.0
) -> Dict:
"""
Lance le backtesting.
Args:
symbols: Liste de symboles à trader
period: Période (ex: '1y', '6m', '2y')
initial_capital: Capital initial
Returns:
Dictionnaire avec résultats complets
"""
logger.info("=" * 60)
logger.info("STARTING BACKTEST")
logger.info("=" * 60)
logger.info(f"Symbols: {symbols}")
logger.info(f"Period: {period}")
logger.info(f"Initial Capital: ${initial_capital:,.2f}")
# Initialiser
self._initialize(initial_capital)
# Récupérer données historiques
logger.info("Fetching historical data...")
data_dict = await self._fetch_historical_data(symbols, period)
if not data_dict:
logger.error("No data available for backtesting")
return None
# Simuler trading
logger.info("Running backtest simulation...")
await self._simulate_trading(data_dict)
# Calculer métriques
logger.info("Calculating metrics...")
metrics = self._calculate_metrics(initial_capital)
# Générer rapport
report = self.metrics_calculator.generate_report(metrics)
logger.info("\n" + report)
# Résultats complets
results = {
'metrics': metrics,
'equity_curve': pd.Series(self.equity_curve),
'trades': self.trades,
'report': report,
'is_valid': self.metrics_calculator.is_strategy_valid(metrics),
}
logger.info("=" * 60)
logger.info("BACKTEST COMPLETED")
logger.info("=" * 60)
return results
def _initialize(self, initial_capital: float):
"""
Initialise l'état du backtest.
Args:
initial_capital: Capital initial
"""
# Reset état
self.equity_curve = [initial_capital]
self.trades = []
self.current_bar = 0
# Initialiser Risk Manager
risk_manager = self.strategy_engine.risk_manager
risk_manager.portfolio_value = initial_capital
risk_manager.initial_capital = initial_capital
risk_manager.peak_value = initial_capital
risk_manager.positions = {}
risk_manager.pnl_history = []
risk_manager.daily_trades = []
async def _fetch_historical_data(
self,
symbols: List[str],
period: str
) -> Dict[str, pd.DataFrame]:
"""
Récupère données historiques pour tous les symboles.
Args:
symbols: Liste de symboles
period: Période
Returns:
Dictionnaire {symbol: DataFrame}
"""
# Parser période
end_date = datetime.now()
if period.endswith('y'):
years = int(period[:-1])
start_date = end_date - timedelta(days=years * 365)
elif period.endswith('m'):
months = int(period[:-1])
start_date = end_date - timedelta(days=months * 30)
elif period.endswith('d'):
days = int(period[:-1])
start_date = end_date - timedelta(days=days)
else:
# Défaut: 1 an
start_date = end_date - timedelta(days=365)
# Récupérer données via DataService (Yahoo Finance → Alpha Vantage failover)
from src.data.data_service import DataService
from src.utils.config_loader import ConfigLoader
config = ConfigLoader.load_all()
data_service = DataService(config)
data_dict = {}
for symbol in symbols:
logger.info(f"Fetching {symbol}...")
try:
df = await data_service.get_historical_data(
symbol=symbol,
timeframe="1h",
start_date=start_date,
end_date=end_date,
)
if df is not None and not df.empty:
df.columns = [c.lower() for c in df.columns]
data_dict[symbol] = df
logger.info(f"{symbol}: {len(df)} bars (source réelle)")
else:
logger.warning(f"⚠️ Pas de données pour {symbol} — fallback synthétique")
data_dict[symbol] = self._generate_synthetic_data(symbol, start_date, end_date)
except Exception as exc:
logger.error(f"DataService échec pour {symbol}: {exc} — fallback synthétique")
data_dict[symbol] = self._generate_synthetic_data(symbol, start_date, end_date)
return data_dict
def _generate_synthetic_data(
self,
symbol: str,
start_date: datetime,
end_date: datetime,
) -> pd.DataFrame:
"""
Génère des données OHLCV synthétiques (random walk) comme fallback.
Utilisé uniquement quand le DataService est indisponible.
"""
logger.warning(f"Données synthétiques utilisées pour {symbol}")
dates = pd.date_range(start=start_date, end=end_date, freq="1h")
base_prices = {"EURUSD": 1.10, "GBPUSD": 1.27, "USDJPY": 148.0}
base = base_prices.get(symbol, 1.0)
np.random.seed(hash(symbol) % (2**32))
returns = np.random.normal(0.00005, 0.008, len(dates))
prices = base * np.exp(np.cumsum(returns))
df = pd.DataFrame(index=dates)
df["close"] = prices
df["open"] = df["close"].shift(1).fillna(float(prices[0]))
df["high"] = df[["open", "close"]].max(axis=1) * (1 + np.abs(np.random.normal(0, 0.0005, len(df))))
df["low"] = df[["open", "close"]].min(axis=1) * (1 - np.abs(np.random.normal(0, 0.0005, len(df))))
df["volume"] = np.random.randint(500, 5000, len(df)).astype(float)
return df
async def _simulate_trading(self, data_dict: Dict[str, pd.DataFrame]):
"""
Simule le trading sur données historiques.
Args:
data_dict: Données par symbole
"""
# Prendre le premier symbole pour simplifier
# TODO: Gérer multi-symboles
symbol = list(data_dict.keys())[0]
df = data_dict[symbol]
logger.info(f"Simulating trading on {symbol}...")
risk_manager = self.strategy_engine.risk_manager
# Itérer sur chaque barre
for i in range(50, len(df)): # Commencer à 50 pour avoir assez de données
self.current_bar = i
# Données jusqu'à cette barre (pas de look-ahead bias)
historical_data = df.iloc[:i+1].copy()
# Analyser avec stratégies
for strategy_name, strategy in self.strategy_engine.strategies.items():
try:
# Générer signal
signal = strategy.analyze(historical_data)
if signal is None:
continue
# Calculer taille position
position_size = strategy.calculate_position_size(
signal=signal,
portfolio_value=risk_manager.portfolio_value,
current_volatility=0.02
)
signal.quantity = position_size
# Valider avec Risk Manager
is_valid, error = risk_manager.validate_trade(
symbol=symbol,
quantity=position_size,
price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.take_profit,
strategy=strategy_name
)
if is_valid:
# Exécuter trade
self._execute_trade(signal, symbol, df.iloc[i])
except Exception as e:
logger.error(f"Error analyzing with {strategy_name}: {e}")
# Mettre à jour positions existantes
self._update_positions(symbol, df.iloc[i])
# Enregistrer equity
self.equity_curve.append(risk_manager.portfolio_value)
logger.info(f"Simulation completed: {len(self.trades)} trades executed")
def _execute_trade(self, signal, symbol: str, current_bar: pd.Series):
"""
Exécute un trade.
Args:
signal: Signal de trading
symbol: Symbole
current_bar: Barre actuelle
"""
risk_manager = self.strategy_engine.risk_manager
# Prix d'exécution avec slippage et spread
if signal.direction == 'LONG':
execution_price = signal.entry_price * (1 + self.slippage_pct + self.spread_pct)
else:
execution_price = signal.entry_price * (1 - self.slippage_pct - self.spread_pct)
# Calculer commission
trade_value = execution_price * signal.quantity
commission = trade_value * self.commission_pct
# Créer position
position = Position(
symbol=symbol,
quantity=signal.quantity if signal.direction == 'LONG' else -signal.quantity,
entry_price=execution_price,
current_price=execution_price,
stop_loss=signal.stop_loss,
take_profit=signal.take_profit,
strategy=signal.strategy,
entry_time=current_bar.name if hasattr(current_bar, 'name') else datetime.now(),
unrealized_pnl=0.0,
risk_amount=abs(execution_price - signal.stop_loss) * signal.quantity
)
# Déduire commission
risk_manager.portfolio_value -= commission
# Ajouter position
risk_manager.add_position(position)
logger.debug(f"Trade executed: {signal.direction} {symbol} @ {execution_price:.5f}")
def _update_positions(self, symbol: str, current_bar: pd.Series):
"""
Met à jour les positions existantes.
Args:
symbol: Symbole
current_bar: Barre actuelle
"""
risk_manager = self.strategy_engine.risk_manager
if symbol not in risk_manager.positions:
return
position = risk_manager.positions[symbol]
current_price = current_bar['close']
# Mettre à jour prix
position.current_price = current_price
position.unrealized_pnl = (current_price - position.entry_price) * position.quantity
# Vérifier stop-loss
if position.quantity > 0: # LONG
if current_price <= position.stop_loss:
self._close_position(symbol, position.stop_loss, 'stop_loss', current_bar)
elif current_price >= position.take_profit:
self._close_position(symbol, position.take_profit, 'take_profit', current_bar)
else: # SHORT
if current_price >= position.stop_loss:
self._close_position(symbol, position.stop_loss, 'stop_loss', current_bar)
elif current_price <= position.take_profit:
self._close_position(symbol, position.take_profit, 'take_profit', current_bar)
def _close_position(
self,
symbol: str,
exit_price: float,
reason: str,
current_bar: pd.Series
):
"""
Ferme une position.
Args:
symbol: Symbole
exit_price: Prix de sortie
reason: Raison de fermeture
current_bar: Barre actuelle
"""
risk_manager = self.strategy_engine.risk_manager
position = risk_manager.positions[symbol]
# Appliquer slippage et spread
if position.quantity > 0: # LONG
final_exit_price = exit_price * (1 - self.slippage_pct - self.spread_pct)
else: # SHORT
final_exit_price = exit_price * (1 + self.slippage_pct + self.spread_pct)
# Calculer P&L
pnl = (final_exit_price - position.entry_price) * position.quantity
# Commission
trade_value = abs(final_exit_price * position.quantity)
commission = trade_value * self.commission_pct
pnl -= commission
# Enregistrer trade
trade = {
'symbol': symbol,
'strategy': position.strategy,
'direction': 'LONG' if position.quantity > 0 else 'SHORT',
'entry_price': position.entry_price,
'exit_price': final_exit_price,
'quantity': abs(position.quantity),
'entry_time': position.entry_time,
'exit_time': current_bar.name if hasattr(current_bar, 'name') else datetime.now(),
'pnl': pnl,
'pnl_pct': pnl / (position.entry_price * abs(position.quantity)),
'reason': reason,
'commission': commission,
'risk': position.risk_amount,
}
self.trades.append(trade)
# Fermer position dans Risk Manager
risk_manager.close_position(symbol, final_exit_price, reason)
logger.debug(f"Position closed: {symbol} | P&L: ${pnl:.2f} | Reason: {reason}")
def _calculate_metrics(self, initial_capital: float) -> Dict:
"""
Calcule toutes les métriques de performance.
Args:
initial_capital: Capital initial
Returns:
Dictionnaire avec métriques
"""
# Créer série equity
equity_series = pd.Series(self.equity_curve)
# Calculer métriques
metrics = self.metrics_calculator.calculate_all(
equity_curve=equity_series,
trades=self.trades,
initial_capital=initial_capital
)
return metrics

View File

@@ -0,0 +1,481 @@
"""
Metrics Calculator - Calcul des Métriques de Performance.
Ce module calcule toutes les métriques de performance pour évaluer
une stratégie de trading:
- Return metrics (total, annualized, CAGR)
- Risk metrics (Sharpe, Sortino, Calmar)
- Drawdown metrics (max, average, duration)
- Trade metrics (win rate, profit factor, expectancy)
- Statistical metrics (skewness, kurtosis)
"""
from typing import List, Dict
import pandas as pd
import numpy as np
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class MetricsCalculator:
"""
Calculateur de métriques de performance.
Calcule toutes les métriques nécessaires pour évaluer une stratégie:
- Performance (returns, Sharpe, Sortino)
- Risk (drawdown, VaR, CVaR)
- Trading (win rate, profit factor)
- Statistical (skewness, kurtosis)
Usage:
calculator = MetricsCalculator()
metrics = calculator.calculate_all(equity_curve, trades)
"""
def __init__(self, risk_free_rate: float = 0.02):
"""
Initialise le calculateur.
Args:
risk_free_rate: Taux sans risque annualisé (défaut: 2%)
"""
self.risk_free_rate = risk_free_rate
def calculate_all(
self,
equity_curve: pd.Series,
trades: List[Dict],
initial_capital: float = 10000.0
) -> Dict:
"""
Calcule toutes les métriques.
Args:
equity_curve: Série temporelle de l'equity
trades: Liste des trades exécutés
initial_capital: Capital initial
Returns:
Dictionnaire avec toutes les métriques
"""
metrics = {}
# Return metrics
metrics.update(self.calculate_return_metrics(equity_curve, initial_capital))
# Risk metrics
metrics.update(self.calculate_risk_metrics(equity_curve))
# Drawdown metrics
metrics.update(self.calculate_drawdown_metrics(equity_curve))
# Trade metrics
if trades:
metrics.update(self.calculate_trade_metrics(trades))
# Statistical metrics
metrics.update(self.calculate_statistical_metrics(equity_curve))
return metrics
def calculate_return_metrics(
self,
equity_curve: pd.Series,
initial_capital: float
) -> Dict:
"""
Calcule les métriques de rendement.
Args:
equity_curve: Courbe d'equity
initial_capital: Capital initial
Returns:
Dictionnaire avec métriques de rendement
"""
final_value = equity_curve.iloc[-1]
# Total return
total_return = (final_value - initial_capital) / initial_capital
# Calcul du nombre de jours
if isinstance(equity_curve.index, pd.DatetimeIndex):
days = (equity_curve.index[-1] - equity_curve.index[0]).days
else:
days = len(equity_curve)
years = days / 365.25
# Annualized return
if years > 0:
annualized_return = (1 + total_return) ** (1 / years) - 1
else:
annualized_return = 0.0
# CAGR (Compound Annual Growth Rate)
cagr = annualized_return
# Daily returns
daily_returns = equity_curve.pct_change().dropna()
# Average daily return
avg_daily_return = daily_returns.mean()
# Average monthly return (approximation)
avg_monthly_return = avg_daily_return * 21 # ~21 trading days/month
return {
'total_return': total_return,
'annualized_return': annualized_return,
'cagr': cagr,
'avg_daily_return': avg_daily_return,
'avg_monthly_return': avg_monthly_return,
'total_days': days,
'total_years': years,
}
def calculate_risk_metrics(self, equity_curve: pd.Series) -> Dict:
"""
Calcule les métriques de risque.
Args:
equity_curve: Courbe d'equity
Returns:
Dictionnaire avec métriques de risque
"""
# Daily returns
daily_returns = equity_curve.pct_change().dropna()
if len(daily_returns) == 0:
return {
'sharpe_ratio': 0.0,
'sortino_ratio': 0.0,
'calmar_ratio': 0.0,
'volatility': 0.0,
'downside_deviation': 0.0,
}
# Volatility (annualized)
volatility = daily_returns.std() * np.sqrt(252)
# Sharpe Ratio
excess_returns = daily_returns - (self.risk_free_rate / 252)
if volatility > 0:
sharpe_ratio = (excess_returns.mean() * 252) / volatility
else:
sharpe_ratio = 0.0
# Sortino Ratio (only downside volatility)
downside_returns = daily_returns[daily_returns < 0]
downside_deviation = downside_returns.std() * np.sqrt(252)
if downside_deviation > 0:
sortino_ratio = (excess_returns.mean() * 252) / downside_deviation
else:
sortino_ratio = 0.0
# Calmar Ratio (return / max drawdown)
max_dd = self.calculate_max_drawdown(equity_curve)
annualized_return = (equity_curve.iloc[-1] / equity_curve.iloc[0]) ** (252 / len(equity_curve)) - 1
if max_dd > 0:
calmar_ratio = annualized_return / max_dd
else:
calmar_ratio = 0.0
return {
'sharpe_ratio': sharpe_ratio,
'sortino_ratio': sortino_ratio,
'calmar_ratio': calmar_ratio,
'volatility': volatility,
'downside_deviation': downside_deviation,
}
def calculate_drawdown_metrics(self, equity_curve: pd.Series) -> Dict:
"""
Calcule les métriques de drawdown.
Args:
equity_curve: Courbe d'equity
Returns:
Dictionnaire avec métriques de drawdown
"""
# Calculer drawdown
running_max = equity_curve.expanding().max()
drawdown = (equity_curve - running_max) / running_max
# Max drawdown
max_drawdown = abs(drawdown.min())
# Average drawdown
avg_drawdown = abs(drawdown[drawdown < 0].mean()) if (drawdown < 0).any() else 0.0
# Max drawdown duration
is_drawdown = drawdown < 0
drawdown_periods = is_drawdown.astype(int).groupby(
(is_drawdown != is_drawdown.shift()).cumsum()
).sum()
max_drawdown_duration = drawdown_periods.max() if len(drawdown_periods) > 0 else 0
# Current drawdown
current_drawdown = abs(drawdown.iloc[-1])
# Recovery factor (total return / max drawdown)
total_return = (equity_curve.iloc[-1] - equity_curve.iloc[0]) / equity_curve.iloc[0]
recovery_factor = total_return / max_drawdown if max_drawdown > 0 else 0.0
return {
'max_drawdown': max_drawdown,
'avg_drawdown': avg_drawdown,
'max_drawdown_duration': int(max_drawdown_duration),
'current_drawdown': current_drawdown,
'recovery_factor': recovery_factor,
}
def calculate_max_drawdown(self, equity_curve: pd.Series) -> float:
"""
Calcule le drawdown maximum.
Args:
equity_curve: Courbe d'equity
Returns:
Max drawdown (positif)
"""
running_max = equity_curve.expanding().max()
drawdown = (equity_curve - running_max) / running_max
return abs(drawdown.min())
def calculate_trade_metrics(self, trades: List[Dict]) -> Dict:
"""
Calcule les métriques de trading.
Args:
trades: Liste des trades
Returns:
Dictionnaire avec métriques de trading
"""
if not trades:
return {
'total_trades': 0,
'winning_trades': 0,
'losing_trades': 0,
'win_rate': 0.0,
'profit_factor': 0.0,
'avg_win': 0.0,
'avg_loss': 0.0,
'largest_win': 0.0,
'largest_loss': 0.0,
'avg_trade': 0.0,
'expectancy': 0.0,
}
# Extraire P&L
pnls = [trade.get('pnl', 0) for trade in trades]
# Séparer wins et losses
wins = [pnl for pnl in pnls if pnl > 0]
losses = [pnl for pnl in pnls if pnl < 0]
# Counts
total_trades = len(trades)
winning_trades = len(wins)
losing_trades = len(losses)
# Win rate
win_rate = winning_trades / total_trades if total_trades > 0 else 0.0
# Averages
avg_win = np.mean(wins) if wins else 0.0
avg_loss = np.mean(losses) if losses else 0.0
avg_trade = np.mean(pnls) if pnls else 0.0
# Largest
largest_win = max(wins) if wins else 0.0
largest_loss = min(losses) if losses else 0.0
# Profit factor
gross_profit = sum(wins) if wins else 0.0
gross_loss = abs(sum(losses)) if losses else 0.0
profit_factor = gross_profit / gross_loss if gross_loss > 0 else 0.0
# Expectancy
expectancy = (win_rate * avg_win) + ((1 - win_rate) * avg_loss)
# Average holding time
holding_times = []
for trade in trades:
if 'entry_time' in trade and 'exit_time' in trade:
duration = (trade['exit_time'] - trade['entry_time']).total_seconds() / 3600
holding_times.append(duration)
avg_holding_time = np.mean(holding_times) if holding_times else 0.0
return {
'total_trades': total_trades,
'winning_trades': winning_trades,
'losing_trades': losing_trades,
'win_rate': win_rate,
'profit_factor': profit_factor,
'avg_win': avg_win,
'avg_loss': avg_loss,
'largest_win': largest_win,
'largest_loss': largest_loss,
'avg_trade': avg_trade,
'expectancy': expectancy,
'avg_holding_time_hours': avg_holding_time,
'gross_profit': gross_profit,
'gross_loss': gross_loss,
}
def calculate_statistical_metrics(self, equity_curve: pd.Series) -> Dict:
"""
Calcule les métriques statistiques.
Args:
equity_curve: Courbe d'equity
Returns:
Dictionnaire avec métriques statistiques
"""
# Daily returns
daily_returns = equity_curve.pct_change().dropna()
if len(daily_returns) == 0:
return {
'skewness': 0.0,
'kurtosis': 0.0,
'var_95': 0.0,
'cvar_95': 0.0,
}
# Skewness (asymétrie)
skewness = daily_returns.skew()
# Kurtosis (aplatissement)
kurtosis = daily_returns.kurtosis()
# VaR (Value at Risk) 95%
var_95 = abs(daily_returns.quantile(0.05))
# CVaR (Conditional VaR) 95%
cvar_95 = abs(daily_returns[daily_returns <= daily_returns.quantile(0.05)].mean())
return {
'skewness': skewness,
'kurtosis': kurtosis,
'var_95': var_95,
'cvar_95': cvar_95,
}
def is_strategy_valid(self, metrics: Dict) -> bool:
"""
Vérifie si une stratégie satisfait les critères minimaux.
Args:
metrics: Métriques calculées
Returns:
True si stratégie valide
"""
# Critères minimaux (conservateurs)
criteria = {
'sharpe_ratio': 1.5,
'max_drawdown': 0.10, # 10%
'win_rate': 0.55,
'profit_factor': 1.3,
'total_trades': 30, # Minimum de trades
}
# Vérifier chaque critère
valid = (
metrics.get('sharpe_ratio', 0) >= criteria['sharpe_ratio'] and
metrics.get('max_drawdown', 1) <= criteria['max_drawdown'] and
metrics.get('win_rate', 0) >= criteria['win_rate'] and
metrics.get('profit_factor', 0) >= criteria['profit_factor'] and
metrics.get('total_trades', 0) >= criteria['total_trades']
)
return valid
def generate_report(self, metrics: Dict) -> str:
"""
Génère un rapport texte des métriques.
Args:
metrics: Métriques calculées
Returns:
Rapport formaté
"""
report = []
report.append("=" * 60)
report.append("BACKTEST PERFORMANCE REPORT")
report.append("=" * 60)
# Return Metrics
report.append("\n📈 RETURN METRICS")
report.append("-" * 60)
report.append(f"Total Return: {metrics.get('total_return', 0):>10.2%}")
report.append(f"Annualized Return: {metrics.get('annualized_return', 0):>10.2%}")
report.append(f"CAGR: {metrics.get('cagr', 0):>10.2%}")
report.append(f"Avg Daily Return: {metrics.get('avg_daily_return', 0):>10.4%}")
report.append(f"Avg Monthly Return: {metrics.get('avg_monthly_return', 0):>10.2%}")
# Risk Metrics
report.append("\n⚠️ RISK METRICS")
report.append("-" * 60)
report.append(f"Sharpe Ratio: {metrics.get('sharpe_ratio', 0):>10.2f}")
report.append(f"Sortino Ratio: {metrics.get('sortino_ratio', 0):>10.2f}")
report.append(f"Calmar Ratio: {metrics.get('calmar_ratio', 0):>10.2f}")
report.append(f"Volatility: {metrics.get('volatility', 0):>10.2%}")
report.append(f"Downside Deviation: {metrics.get('downside_deviation', 0):>10.2%}")
# Drawdown Metrics
report.append("\n📉 DRAWDOWN METRICS")
report.append("-" * 60)
report.append(f"Max Drawdown: {metrics.get('max_drawdown', 0):>10.2%}")
report.append(f"Avg Drawdown: {metrics.get('avg_drawdown', 0):>10.2%}")
report.append(f"Max DD Duration: {metrics.get('max_drawdown_duration', 0):>10} days")
report.append(f"Current Drawdown: {metrics.get('current_drawdown', 0):>10.2%}")
report.append(f"Recovery Factor: {metrics.get('recovery_factor', 0):>10.2f}")
# Trade Metrics
report.append("\n💼 TRADE METRICS")
report.append("-" * 60)
report.append(f"Total Trades: {metrics.get('total_trades', 0):>10}")
report.append(f"Winning Trades: {metrics.get('winning_trades', 0):>10}")
report.append(f"Losing Trades: {metrics.get('losing_trades', 0):>10}")
report.append(f"Win Rate: {metrics.get('win_rate', 0):>10.2%}")
report.append(f"Profit Factor: {metrics.get('profit_factor', 0):>10.2f}")
report.append(f"Avg Win: {metrics.get('avg_win', 0):>10.2f}")
report.append(f"Avg Loss: {metrics.get('avg_loss', 0):>10.2f}")
report.append(f"Largest Win: {metrics.get('largest_win', 0):>10.2f}")
report.append(f"Largest Loss: {metrics.get('largest_loss', 0):>10.2f}")
report.append(f"Expectancy: {metrics.get('expectancy', 0):>10.2f}")
# Statistical Metrics
report.append("\n📊 STATISTICAL METRICS")
report.append("-" * 60)
report.append(f"Skewness: {metrics.get('skewness', 0):>10.2f}")
report.append(f"Kurtosis: {metrics.get('kurtosis', 0):>10.2f}")
report.append(f"VaR (95%): {metrics.get('var_95', 0):>10.4f}")
report.append(f"CVaR (95%): {metrics.get('cvar_95', 0):>10.4f}")
# Validation
report.append("\n✅ VALIDATION")
report.append("-" * 60)
is_valid = self.is_strategy_valid(metrics)
status = "✅ VALID" if is_valid else "❌ NOT VALID"
report.append(f"Strategy Status: {status}")
report.append("=" * 60)
return "\n".join(report)

View File

@@ -0,0 +1,256 @@
"""
Paper Trading Engine - Trading Simulé en Temps Réel.
Ce module permet de tester les stratégies en conditions réelles
sans risquer de capital:
- Données temps réel
- Exécution simulée
- Métriques en temps réel
- Validation avant production
"""
from typing import Dict, Optional
from datetime import datetime
import asyncio
import pandas as pd
import logging
from src.core.strategy_engine import StrategyEngine
from src.backtesting.metrics_calculator import MetricsCalculator
logger = logging.getLogger(__name__)
class PaperTradingEngine:
"""
Moteur de paper trading temps réel.
Simule le trading en conditions réelles sans risquer de capital.
Essentiel pour valider une stratégie avant production.
Protocole strict:
- Minimum 30 jours de paper trading
- Performance stable requise
- Pas de bugs critiques
- Métriques validées
Usage:
engine = PaperTradingEngine(strategy_engine, initial_capital)
await engine.run()
"""
def __init__(
self,
strategy_engine: StrategyEngine,
initial_capital: float = 10000.0
):
"""
Initialise le paper trading engine.
Args:
strategy_engine: Engine de stratégies
initial_capital: Capital initial simulé
"""
self.strategy_engine = strategy_engine
self.initial_capital = initial_capital
# État
self.running = False
self.start_time = None
self.equity_curve = [initial_capital]
self.trades = []
# Métriques
self.metrics_calculator = MetricsCalculator()
logger.info(f"Paper Trading Engine initialized with ${initial_capital:,.2f}")
async def run(self):
"""
Lance le paper trading en temps réel.
Boucle principale qui:
1. Récupère données temps réel
2. Analyse avec stratégies
3. Exécute trades simulés
4. Met à jour métriques
5. Log performance
"""
self.running = True
self.start_time = datetime.now()
logger.info("=" * 60)
logger.info("PAPER TRADING STARTED")
logger.info("=" * 60)
logger.info(f"Start Time: {self.start_time}")
logger.info(f"Initial Capital: ${self.initial_capital:,.2f}")
logger.info("Press Ctrl+C to stop")
logger.info("=" * 60)
try:
while self.running:
iteration_start = datetime.now()
# 1. Récupérer données temps réel via StrategyEngine
market_data = await self.strategy_engine._fetch_market_data()
# 2. Mettre en cache la volatilité dans Redis
self.strategy_engine._cache_volatility(market_data)
# 3. Mettre à jour le ML Engine
await self.strategy_engine._update_ml_engine(market_data)
# 4. Analyser avec stratégies + filtre ML
signals = await self.strategy_engine._analyze_strategies(market_data)
valid_signals = self.strategy_engine._filter_signals(signals)
self.strategy_engine._publish_signals_to_redis(valid_signals)
# 5. Exécuter signaux (simulé — pas de broker réel)
await self.strategy_engine._execute_signals(valid_signals)
# 6. Mettre à jour positions ouvertes
await self.strategy_engine._update_positions(market_data)
# 7. Vérifier circuit breakers
self.strategy_engine.risk_manager.check_circuit_breakers()
# 8. Mettre à jour equity curve paper trading
current_value = self.strategy_engine.risk_manager.portfolio_value
self.equity_curve.append(current_value)
# 9. Log statistiques
self._log_statistics()
# 10. Sleep jusqu'à prochaine itération (60 secondes)
elapsed = (datetime.now() - iteration_start).total_seconds()
sleep_time = max(0, 60 - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except KeyboardInterrupt:
logger.info("\nPaper trading interrupted by user")
except Exception as e:
logger.exception(f"Error in paper trading: {e}")
finally:
await self.stop()
async def stop(self):
"""Arrête le paper trading et génère rapport final."""
self.running = False
logger.info("\n" + "=" * 60)
logger.info("PAPER TRADING STOPPED")
logger.info("=" * 60)
# Générer rapport final
summary = self.get_summary()
logger.info(f"Duration: {summary['duration_days']} days")
logger.info(f"Total Return: {summary['total_return']:.2%}")
logger.info(f"Sharpe Ratio: {summary['sharpe_ratio']:.2f}")
logger.info(f"Max Drawdown: {summary['max_drawdown']:.2%}")
logger.info(f"Total Trades: {summary['total_trades']}")
logger.info(f"Win Rate: {summary['win_rate']:.2%}")
# Vérifier si prêt pour production
if self._is_ready_for_production(summary):
logger.info("\n✅ READY FOR PRODUCTION")
else:
logger.warning("\n⚠️ NOT READY FOR PRODUCTION - Continue paper trading")
logger.info("=" * 60)
def _log_statistics(self):
"""Log les statistiques actuelles."""
risk_manager = self.strategy_engine.risk_manager
# Calculer durée
duration = (datetime.now() - self.start_time).total_seconds() / 86400 # jours
# Calculer return
current_value = risk_manager.portfolio_value
total_return = (current_value - self.initial_capital) / self.initial_capital
logger.info(
f"Day {duration:.1f} | "
f"Equity: ${current_value:,.2f} | "
f"Return: {total_return:>6.2%} | "
f"Positions: {len(risk_manager.positions)} | "
f"Trades: {len(self.trades)}"
)
def get_summary(self) -> Dict:
"""
Retourne un résumé de la session de paper trading.
Returns:
Dictionnaire avec statistiques
"""
# Durée
duration = (datetime.now() - self.start_time).total_seconds() / 86400
# Equity curve
equity_series = pd.Series(self.equity_curve)
# Calculer métriques
if len(self.equity_curve) > 1:
metrics = self.metrics_calculator.calculate_all(
equity_curve=equity_series,
trades=self.trades,
initial_capital=self.initial_capital
)
else:
metrics = {}
summary = {
'start_time': self.start_time,
'end_time': datetime.now(),
'duration_days': duration,
'initial_capital': self.initial_capital,
'final_capital': self.equity_curve[-1] if self.equity_curve else self.initial_capital,
'total_return': metrics.get('total_return', 0),
'sharpe_ratio': metrics.get('sharpe_ratio', 0),
'max_drawdown': metrics.get('max_drawdown', 0),
'total_trades': len(self.trades),
'win_rate': metrics.get('win_rate', 0),
'profit_factor': metrics.get('profit_factor', 0),
}
return summary
def _is_ready_for_production(self, summary: Dict) -> bool:
"""
Vérifie si la stratégie est prête pour production.
Critères stricts:
- Minimum 30 jours de paper trading
- Sharpe ratio >= 1.5
- Max drawdown <= 10%
- Win rate >= 55%
- Minimum 50 trades
- Performance stable
Args:
summary: Résumé de la session
Returns:
True si prêt pour production
"""
criteria = {
'min_days': 30,
'min_sharpe': 1.5,
'max_drawdown': 0.10,
'min_win_rate': 0.55,
'min_trades': 50,
}
ready = (
summary['duration_days'] >= criteria['min_days'] and
summary['sharpe_ratio'] >= criteria['min_sharpe'] and
summary['max_drawdown'] <= criteria['max_drawdown'] and
summary['win_rate'] >= criteria['min_win_rate'] and
summary['total_trades'] >= criteria['min_trades']
)
return ready

19
src/core/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""
Module Core - Composants centraux de Trading AI Secure.
Ce module contient les composants fondamentaux du système:
- RiskManager: Gestion centralisée du risque (Singleton)
- StrategyEngine: Orchestration des stratégies de trading
- SafetyLayer: Circuit breakers et protections
- ConfigManager: Gestion de la configuration
Tous les autres modules dépendent de ces composants core.
"""
from src.core.risk_manager import RiskManager
from src.core.strategy_engine import StrategyEngine
__all__ = [
'RiskManager',
'StrategyEngine',
]

234
src/core/notifications.py Normal file
View File

@@ -0,0 +1,234 @@
"""
Notifications - Trading AI Secure.
Gère les alertes multi-canaux :
- Telegram (priorité haute, temps réel)
- Email (priorité moyenne, rapports)
Usage :
from src.core.notifications import notify
notify("Max drawdown atteint !", level="critical")
notify("Trade exécuté : EURUSD +0.5%", level="info")
"""
import asyncio
import logging
import os
import smtplib
from email.mime.text import MIMEText
from typing import Literal, Optional
import httpx
logger = logging.getLogger(__name__)
NotificationLevel = Literal["info", "success", "warning", "critical"]
_EMOJIS: dict[str, str] = {
"info": "",
"success": "",
"warning": "⚠️",
"critical": "🚨",
}
# =============================================================================
# Telegram
# =============================================================================
class TelegramNotifier:
"""
Envoie des messages via un bot Telegram.
Configuration (env vars) :
TELEGRAM_BOT_TOKEN : Token du bot (obtenu via @BotFather)
TELEGRAM_CHAT_ID : Chat ID du destinataire (user ou groupe)
"""
def __init__(self):
self.bot_token: str = os.environ.get("TELEGRAM_BOT_TOKEN", "")
self.chat_id: str = os.environ.get("TELEGRAM_CHAT_ID", "")
self.enabled: bool = bool(self.bot_token and self.chat_id)
if not self.enabled:
logger.debug("Telegram notifier disabled (TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID missing)")
async def send(self, message: str, level: NotificationLevel = "info") -> bool:
"""
Envoie un message Telegram (async).
Args:
message : Corps du message
level : Niveau (info | success | warning | critical)
Returns:
True si succès
"""
if not self.enabled:
return False
emoji = _EMOJIS.get(level, "")
full_msg = f"{emoji} *Trading AI Secure*\n\n{message}"
url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
payload = {
"chat_id": self.chat_id,
"text": full_msg,
"parse_mode": "Markdown",
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
return True
except Exception as exc:
logger.error(f"Telegram send failed: {exc}")
return False
def send_sync(self, message: str, level: NotificationLevel = "info") -> bool:
"""Wrapper synchrone (crée une boucle asyncio si nécessaire)."""
if not self.enabled:
return False
try:
loop = asyncio.get_running_loop()
# On est déjà dans une boucle — programmer comme tâche non bloquante
loop.create_task(self.send(message, level))
return True
except RuntimeError:
# Pas de boucle en cours — en créer une
return asyncio.run(self.send(message, level))
# =============================================================================
# Email
# =============================================================================
class EmailNotifier:
"""
Envoie des emails via SMTP.
Configuration (env vars) :
EMAIL_FROM : Adresse expéditeur
EMAIL_TO : Adresse destinataire
EMAIL_PASSWORD : Mot de passe SMTP
SMTP_SERVER : Serveur SMTP (défaut : smtp.gmail.com)
SMTP_PORT : Port SMTP (défaut : 587)
"""
def __init__(self):
self.from_email: str = os.environ.get("EMAIL_FROM", "")
self.to_email: str = os.environ.get("EMAIL_TO", "")
self.password: str = os.environ.get("EMAIL_PASSWORD", "")
self.smtp_server: str = os.environ.get("SMTP_SERVER", "smtp.gmail.com")
self.smtp_port: int = int(os.environ.get("SMTP_PORT", "587"))
self.enabled: bool = bool(self.from_email and self.to_email and self.password)
def send(self, subject: str, body: str) -> bool:
"""Envoie un email synchrone."""
if not self.enabled:
return False
msg = MIMEText(body)
msg["Subject"] = f"[Trading AI] {subject}"
msg["From"] = self.from_email
msg["To"] = self.to_email
try:
with smtplib.SMTP(self.smtp_server, self.smtp_port) as smtp:
smtp.starttls()
smtp.login(self.from_email, self.password)
smtp.send_message(msg)
return True
except Exception as exc:
logger.error(f"Email send failed: {exc}")
return False
# =============================================================================
# NotificationService (façade)
# =============================================================================
class NotificationService:
"""
Façade unique pour toutes les notifications.
Chaque niveau est routé selon la config :
- critical → Telegram + Email
- warning → Telegram
- info → log uniquement (ou Telegram si activé)
"""
def __init__(self):
self.telegram = TelegramNotifier()
self.email = EmailNotifier()
def notify(
self,
message: str,
level: NotificationLevel = "info",
channels: Optional[list[str]] = None,
) -> None:
"""
Envoie une notification sur les canaux appropriés.
Args:
message : Corps du message
level : Niveau de criticité
channels : Force des canaux spécifiques (["telegram", "email"])
Si None, routage automatique selon le niveau.
"""
logger.log(
logging.CRITICAL if level == "critical" else
logging.WARNING if level == "warning" else
logging.INFO,
f"[NOTIFICATION/{level.upper()}] {message}",
)
if channels is None:
channels = self._default_channels(level)
if "telegram" in channels:
self.telegram.send_sync(message, level)
if "email" in channels and level in ("critical", "warning"):
subject = f"{level.upper()}: {message[:80]}"
self.email.send(subject, message)
@staticmethod
def _default_channels(level: NotificationLevel) -> list[str]:
if level == "critical":
return ["telegram", "email"]
if level == "warning":
return ["telegram"]
return [] # info/success : log seulement (éviter le spam)
# =============================================================================
# Singleton global
# =============================================================================
_service: Optional[NotificationService] = None
def get_notification_service() -> NotificationService:
"""Retourne l'instance singleton du NotificationService."""
global _service
if _service is None:
_service = NotificationService()
return _service
def notify(
message: str,
level: NotificationLevel = "info",
channels: Optional[list[str]] = None,
) -> None:
"""
Fonction raccourci pour envoyer une notification.
Usage :
notify("Max drawdown atteint !", level="critical")
"""
get_notification_service().notify(message, level, channels)

603
src/core/risk_manager.py Normal file
View File

@@ -0,0 +1,603 @@
"""
Risk Manager - Gestion Centralisée du Risque (Singleton).
Ce module implémente le Risk Manager, composant central responsable de:
- Validation pré-trade de tous les ordres
- Monitoring des positions en temps réel
- Calcul des métriques de risque (VaR, CVaR, drawdown)
- Déclenchement des circuit breakers
- Gestion des limites de risque
Le Risk Manager utilise le pattern Singleton pour garantir une instance unique
et un état global cohérent à travers toute l'application.
"""
import threading
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from datetime import datetime, timedelta
import numpy as np
import logging
logger = logging.getLogger(__name__)
# Import différé pour éviter les imports circulaires
def _get_notifier():
from src.core.notifications import get_notification_service
return get_notification_service()
@dataclass
class Position:
"""Représente une position ouverte."""
symbol: str
quantity: float
entry_price: float
current_price: float
stop_loss: float
take_profit: float
strategy: str
entry_time: datetime
unrealized_pnl: float
risk_amount: float
deal_id: Optional[str] = None
@dataclass
class RiskMetrics:
"""Métriques de risque en temps réel."""
total_risk: float
current_drawdown: float
daily_pnl: float
weekly_pnl: float
portfolio_var: float
portfolio_cvar: float
largest_position: float
num_positions: int
risk_utilization: float # % du risque max utilisé
class RiskManager:
"""
Risk Manager Central (Singleton).
Garantit:
- Une seule instance dans toute l'application
- État global cohérent
- Thread-safe pour accès concurrent
Responsabilités:
- Validation de tous les trades avant exécution
- Monitoring continu des positions
- Calcul des métriques de risque
- Déclenchement des circuit breakers
- Application des limites de risque
Usage:
risk_manager = RiskManager()
is_valid, error = risk_manager.validate_trade(...)
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""Implémentation du pattern Singleton thread-safe."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Initialise le Risk Manager (une seule fois)."""
if not hasattr(self, 'initialized'):
self.initialized = True
# Configuration
self.config = {}
# État du portfolio
self.positions: Dict[str, Position] = {}
self.portfolio_value: float = 100000.0 # Capital initial
self.peak_value: float = 100000.0
self.initial_capital: float = 100000.0
# Historique
self.daily_trades: List[Dict] = []
self.pnl_history: List[float] = []
self.drawdown_history: List[float] = []
self.equity_curve: List[float] = [100000.0]
# Circuit breakers
self.trading_halted: bool = False
self.halt_reason: Optional[str] = None
# Statistiques
self.total_trades: int = 0
self.winning_trades: int = 0
self.losing_trades: int = 0
logger.info("Risk Manager initialized (Singleton)")
def initialize(self, config: Dict):
"""
Configure le Risk Manager avec les paramètres.
Args:
config: Configuration des limites de risque
"""
self.config = config
self.initial_capital = config.get('initial_capital', 100000.0)
self.portfolio_value = self.initial_capital
self.peak_value = self.initial_capital
self.equity_curve = [self.initial_capital]
logger.info(f"Risk Manager configured with capital: ${self.initial_capital:,.2f}")
logger.info(f"Max portfolio risk: {config['global_limits']['max_portfolio_risk']:.1%}")
logger.info(f"Max drawdown: {config['global_limits']['max_drawdown']:.1%}")
def validate_trade(
self,
symbol: str,
quantity: float,
price: float,
stop_loss: float,
take_profit: float,
strategy: str
) -> Tuple[bool, Optional[str]]:
"""
Valide un trade avant exécution.
Effectue toutes les vérifications de risque:
1. Trading halted?
2. Stop-loss obligatoire
3. Risque par trade
4. Risque total portfolio
5. Taille position
6. Corrélation
7. Nombre de trades quotidiens
8. Risk/Reward ratio
9. Drawdown actuel
Args:
symbol: Symbole à trader
quantity: Quantité
price: Prix d'entrée
stop_loss: Niveau stop-loss
take_profit: Niveau take-profit
strategy: Nom de la stratégie
Returns:
(is_valid, error_message)
- is_valid: True si trade valide
- error_message: Message d'erreur si invalide
"""
# 1. Vérifier si trading halted
if self.trading_halted:
return False, f"Trading halted: {self.halt_reason}"
# 2. Vérifier stop-loss obligatoire
if stop_loss is None or stop_loss == 0:
return False, "Stop-loss is mandatory"
# 3. Calculer risque du trade
risk_amount = abs(price - stop_loss) * quantity
risk_pct = risk_amount / self.portfolio_value
# 4. Vérifier limites par trade
strategy_config = self.config.get('strategy_limits', {}).get(strategy, {})
max_risk_per_trade = strategy_config.get('risk_per_trade', 0.02)
if risk_pct > max_risk_per_trade:
return False, f"Risk per trade ({risk_pct:.2%}) exceeds limit ({max_risk_per_trade:.2%})"
# 5. Vérifier risque total portfolio
total_risk = self._calculate_total_risk() + risk_amount
max_portfolio_risk = self.config['global_limits']['max_portfolio_risk'] * self.portfolio_value
if total_risk > max_portfolio_risk:
return False, f"Total portfolio risk ({total_risk:.2f}) exceeds limit ({max_portfolio_risk:.2f})"
# 6. Vérifier taille position
position_value = price * quantity
position_pct = position_value / self.portfolio_value
max_position_size = self.config['global_limits']['max_position_size']
if position_pct > max_position_size:
return False, f"Position size ({position_pct:.2%}) exceeds limit ({max_position_size:.2%})"
# 7. Vérifier corrélation
if not self._check_correlation(symbol, strategy):
return False, "Correlation with existing positions too high"
# 8. Vérifier nombre de trades quotidiens
strategy_trades_today = len([
t for t in self.daily_trades
if t['strategy'] == strategy and t['time'].date() == datetime.now().date()
])
max_trades = strategy_config.get('max_trades_per_day', 100)
if strategy_trades_today >= max_trades:
return False, f"Max daily trades for {strategy} reached ({max_trades})"
# 9. Vérifier Risk/Reward ratio
risk = abs(price - stop_loss)
reward = abs(take_profit - price)
rr_ratio = reward / risk if risk > 0 else 0
if rr_ratio < 1.5:
return False, f"Risk/Reward ratio ({rr_ratio:.2f}) below minimum (1.5)"
# 10. Vérifier drawdown actuel
current_dd = self._calculate_current_drawdown()
max_dd = self.config['global_limits']['max_drawdown']
if current_dd >= max_dd:
return False, f"Max drawdown reached ({current_dd:.2%})"
# Toutes validations passées
logger.debug(f"Trade validated: {symbol} {quantity} @ {price}")
return True, None
def add_position(self, position: Position):
"""
Ajoute une position au portfolio.
Args:
position: Position à ajouter
"""
self.positions[position.symbol] = position
# Enregistrer trade
self.daily_trades.append({
'symbol': position.symbol,
'strategy': position.strategy,
'time': position.entry_time,
'risk': position.risk_amount,
'quantity': position.quantity,
'price': position.entry_price
})
self.total_trades += 1
logger.info(f"Position added: {position.symbol} ({position.strategy})")
def update_position(self, symbol: str, current_price: float):
"""
Met à jour le prix d'une position.
Args:
symbol: Symbole de la position
current_price: Prix actuel
"""
if symbol not in self.positions:
return
position = self.positions[symbol]
position.current_price = current_price
position.unrealized_pnl = (current_price - position.entry_price) * position.quantity
# Vérifier conditions de sortie
self._check_exit_conditions(position)
def close_position(self, symbol: str, exit_price: float, reason: str = 'manual') -> float:
"""
Ferme une position et retourne P&L.
Args:
symbol: Symbole de la position
exit_price: Prix de sortie
reason: Raison de la fermeture
Returns:
P&L de la position
"""
if symbol not in self.positions:
logger.warning(f"Attempted to close non-existent position: {symbol}")
return 0.0
position = self.positions[symbol]
pnl = (exit_price - position.entry_price) * position.quantity
# Mettre à jour portfolio
self.portfolio_value += pnl
self.pnl_history.append(pnl)
self.equity_curve.append(self.portfolio_value)
# Mettre à jour peak
if self.portfolio_value > self.peak_value:
self.peak_value = self.portfolio_value
# Statistiques
if pnl > 0:
self.winning_trades += 1
else:
self.losing_trades += 1
# Supprimer position
del self.positions[symbol]
logger.info(f"Position closed: {symbol} | P&L: ${pnl:.2f} | Reason: {reason}")
return pnl
def get_risk_metrics(self) -> RiskMetrics:
"""
Calcule et retourne les métriques de risque en temps réel.
Returns:
RiskMetrics avec toutes les métriques
"""
total_risk = self._calculate_total_risk()
max_portfolio_risk = self.config['global_limits']['max_portfolio_risk'] * self.portfolio_value
return RiskMetrics(
total_risk=total_risk,
current_drawdown=self._calculate_current_drawdown(),
daily_pnl=self._calculate_daily_pnl(),
weekly_pnl=self._calculate_weekly_pnl(),
portfolio_var=self._calculate_var(),
portfolio_cvar=self._calculate_cvar(),
largest_position=self._get_largest_position(),
num_positions=len(self.positions),
risk_utilization=total_risk / max_portfolio_risk if max_portfolio_risk > 0 else 0
)
def check_circuit_breakers(self):
"""
Vérifie toutes les conditions de circuit breakers.
Déclenche arrêt automatique si:
- Drawdown excessif
- Perte journalière excessive
- Volatilité extrême
- Autres conditions critiques
"""
# 1. Drawdown excessif
current_dd = self._calculate_current_drawdown()
max_dd = self.config['global_limits']['max_drawdown']
if current_dd >= max_dd:
self.halt_trading(f"Max drawdown reached: {current_dd:.2%}")
return
# 2. Perte journalière excessive
daily_pnl_pct = self._calculate_daily_pnl() / self.portfolio_value
max_daily_loss = self.config['global_limits']['max_daily_loss']
if daily_pnl_pct <= -max_daily_loss:
self.halt_trading(f"Max daily loss reached: {daily_pnl_pct:.2%}")
return
# 3. Volatilité extrême
if self._detect_volatility_spike():
self.halt_trading("Extreme volatility detected")
return
def halt_trading(self, reason: str):
"""
Arrête le trading immédiatement.
Args:
reason: Raison de l'arrêt
"""
self.trading_halted = True
self.halt_reason = reason
logger.critical(f"🚨 TRADING HALTED: {reason}")
self._send_emergency_alert(reason)
def resume_trading(self):
"""Reprend le trading (manuel uniquement)."""
self.trading_halted = False
self.halt_reason = None
logger.info("✅ Trading resumed")
# ========================================================================
# MÉTHODES PRIVÉES - CALCULS
# ========================================================================
def _calculate_total_risk(self) -> float:
"""Calcule le risque total du portfolio."""
return sum(pos.risk_amount for pos in self.positions.values())
def _calculate_current_drawdown(self) -> float:
"""Calcule le drawdown actuel."""
if self.peak_value == 0:
return 0.0
return (self.peak_value - self.portfolio_value) / self.peak_value
def _calculate_daily_pnl(self) -> float:
"""Calcule le P&L du jour."""
today = datetime.now().date()
# P&L réalisé aujourd'hui
daily_realized = sum(
pnl for pnl, trade in zip(self.pnl_history, self.daily_trades)
if trade['time'].date() == today
) if self.pnl_history else 0.0
# P&L non réalisé
unrealized = sum(pos.unrealized_pnl for pos in self.positions.values())
return daily_realized + unrealized
def _calculate_weekly_pnl(self) -> float:
"""Calcule le P&L réalisé + non-réalisé de la semaine en cours."""
now = datetime.now()
# Lundi de la semaine courante à minuit
week_start = (now - timedelta(days=now.weekday())).replace(
hour=0, minute=0, second=0, microsecond=0
)
# P&L réalisé cette semaine
weekly_realized = sum(
pnl
for pnl, trade in zip(self.pnl_history, self.daily_trades)
if trade["time"] >= week_start
) if self.pnl_history else 0.0
# P&L non réalisé
unrealized = sum(pos.unrealized_pnl for pos in self.positions.values())
return weekly_realized + unrealized
def _calculate_var(self, confidence: float = 0.95) -> float:
"""
Calcule Value at Risk (VaR).
Args:
confidence: Niveau de confiance (0.95 = 95%)
Returns:
VaR en valeur absolue
"""
if len(self.pnl_history) < 30:
return 0.0
returns = np.array(self.pnl_history[-30:]) / self.portfolio_value
var = np.percentile(returns, (1 - confidence) * 100)
return abs(var * self.portfolio_value)
def _calculate_cvar(self, confidence: float = 0.95) -> float:
"""
Calcule Conditional Value at Risk (CVaR / Expected Shortfall).
Args:
confidence: Niveau de confiance
Returns:
CVaR en valeur absolue
"""
if len(self.pnl_history) < 30:
return 0.0
returns = np.array(self.pnl_history[-30:]) / self.portfolio_value
var_threshold = np.percentile(returns, (1 - confidence) * 100)
# Moyenne des pertes au-delà du VaR
tail_losses = returns[returns <= var_threshold]
cvar = np.mean(tail_losses) if len(tail_losses) > 0 else 0
return abs(cvar * self.portfolio_value)
def _get_largest_position(self) -> float:
"""Retourne la taille de la plus grande position (en %)."""
if not self.positions:
return 0.0
largest = max(
abs(pos.quantity * pos.current_price) for pos in self.positions.values()
)
return largest / self.portfolio_value
def _check_correlation(self, symbol: str, strategy: str) -> bool:
"""
Vérifie la corrélation avec les positions existantes.
Args:
symbol: Symbole à vérifier
strategy: Stratégie
Returns:
True si corrélation acceptable
"""
if len(self.positions) == 0:
return True
# Simplification: vérifier si même stratégie
# En production: calculer corrélation réelle des returns
same_strategy_positions = [
pos for pos in self.positions.values()
if pos.strategy == strategy
]
max_correlation = self.config['global_limits']['max_correlation']
# Si trop de positions de même stratégie, corrélation trop haute
if len(same_strategy_positions) >= 3:
return False
return True
def _check_exit_conditions(self, position: Position):
"""
Vérifie les conditions de sortie (stop-loss / take-profit).
Args:
position: Position à vérifier
"""
# Stop-loss hit
if position.current_price <= position.stop_loss:
self.close_position(position.symbol, position.stop_loss, reason='stop_loss')
logger.warning(f"⚠️ Stop-loss hit for {position.symbol}")
# Take-profit hit
elif position.current_price >= position.take_profit:
self.close_position(position.symbol, position.take_profit, reason='take_profit')
logger.info(f"✅ Take-profit hit for {position.symbol}")
def _detect_volatility_spike(self) -> bool:
"""
Détecte un spike de volatilité anormal.
Returns:
True si spike détecté
"""
if len(self.pnl_history) < 20:
return False
recent_vol = np.std(self.pnl_history[-5:])
baseline_vol = np.std(self.pnl_history[-20:-5])
# Spike si volatilité > 3x baseline
return recent_vol > 3 * baseline_vol if baseline_vol > 0 else False
def _send_emergency_alert(self, reason: str):
"""
Envoie une alerte d'urgence via tous les canaux configurés.
Args:
reason: Raison de l'alerte
"""
metrics = self.get_statistics()
message = (
f"*TRADING HALTED*\n\n"
f"Raison : {reason}\n\n"
f"Portfolio : ${metrics['portfolio_value']:,.2f}\n"
f"Drawdown : {metrics['current_drawdown']:.2%}\n"
f"Trades : {metrics['total_trades']}\n"
f"Positions : {metrics['num_positions']}"
)
try:
_get_notifier().notify(message, level="critical")
except Exception as exc:
logger.error(f"Failed to send emergency alert: {exc}")
def get_statistics(self) -> Dict:
"""
Retourne les statistiques complètes du Risk Manager.
Returns:
Dictionnaire avec toutes les statistiques
"""
win_rate = self.winning_trades / self.total_trades if self.total_trades > 0 else 0
return {
'portfolio_value': self.portfolio_value,
'initial_capital': self.initial_capital,
'total_return': (self.portfolio_value - self.initial_capital) / self.initial_capital,
'peak_value': self.peak_value,
'current_drawdown': self._calculate_current_drawdown(),
'total_trades': self.total_trades,
'winning_trades': self.winning_trades,
'losing_trades': self.losing_trades,
'win_rate': win_rate,
'num_positions': len(self.positions),
'total_risk': self._calculate_total_risk(),
'trading_halted': self.trading_halted,
}

522
src/core/strategy_engine.py Normal file
View File

@@ -0,0 +1,522 @@
"""
Strategy Engine - Orchestrateur des Stratégies de Trading.
Ce module gère l'exécution et la coordination de toutes les stratégies:
- Chargement dynamique des stratégies
- Distribution des données marché
- Collecte et filtrage des signaux
- Coordination avec le Risk Manager
- Gestion du cycle de vie des stratégies
"""
import asyncio
from typing import Dict, List, Optional
from datetime import datetime
import logging
from src.core.risk_manager import RiskManager, Position
from src.strategies.base_strategy import BaseStrategy, Signal
logger = logging.getLogger(__name__)
class StrategyEngine:
"""
Moteur central de gestion des stratégies.
Responsabilités:
- Charger et initialiser les stratégies
- Distribuer les données marché à toutes les stratégies
- Collecter les signaux de trading
- Filtrer les signaux avec le Risk Manager
- Coordonner l'exécution des ordres
- Monitorer la performance des stratégies
Usage:
engine = StrategyEngine(config, risk_manager)
await engine.load_strategy('intraday')
await engine.run()
"""
def __init__(self, config: Dict, risk_manager: RiskManager):
"""
Initialise le Strategy Engine.
Args:
config: Configuration des stratégies
risk_manager: Instance du Risk Manager
"""
self.config = config
self.risk_manager = risk_manager
# Stratégies actives
self.strategies: Dict[str, BaseStrategy] = {}
# Signaux en attente
self.pending_signals: List[Signal] = []
# ML Engine (initialisé paresseusement lors du premier run)
self.ml_engine = None
# État
self.running = False
self.interval = 60 # Secondes entre chaque itération
logger.info("Strategy Engine initialized")
async def load_strategy(self, strategy_name: str):
"""
Charge une stratégie dynamiquement.
Args:
strategy_name: Nom de la stratégie ('scalping', 'intraday', 'swing')
"""
logger.info(f"Loading strategy: {strategy_name}")
try:
# Import dynamique de la stratégie
if strategy_name == 'scalping':
from src.strategies.scalping.scalping_strategy import ScalpingStrategy
strategy_class = ScalpingStrategy
elif strategy_name == 'intraday':
from src.strategies.intraday.intraday_strategy import IntradayStrategy
strategy_class = IntradayStrategy
elif strategy_name == 'swing':
from src.strategies.swing.swing_strategy import SwingStrategy
strategy_class = SwingStrategy
else:
raise ValueError(f"Unknown strategy: {strategy_name}")
# Récupérer configuration de la stratégie
strategy_config = self.config.get(f'{strategy_name}_strategy', {})
# Créer instance
strategy = strategy_class(strategy_config)
# Ajouter aux stratégies actives
self.strategies[strategy_name] = strategy
logger.info(f"✅ Strategy loaded: {strategy_name}")
except Exception as e:
logger.error(f"Failed to load strategy {strategy_name}: {e}")
raise
async def run(self):
"""
Boucle principale du Strategy Engine.
Cycle:
1. Récupérer données marché
2. Analyser avec chaque stratégie
3. Collecter signaux
4. Filtrer avec Risk Manager
5. Exécuter signaux valides
6. Mettre à jour positions
7. Vérifier circuit breakers
8. Sleep jusqu'à prochaine itération
"""
self.running = True
logger.info("Strategy Engine started")
try:
while self.running:
iteration_start = datetime.now()
# 1. Récupérer données marché
market_data = await self._fetch_market_data()
# 2. Mettre en cache la volatilité dans Redis
self._cache_volatility(market_data)
# 3. Mettre à jour le ML Engine avec les nouvelles données
await self._update_ml_engine(market_data)
# 4. Analyser avec chaque stratégie (+ filtre ML par régime)
signals = await self._analyze_strategies(market_data)
# 5. Filtrer avec Risk Manager
valid_signals = self._filter_signals(signals)
# 6. Publier les signaux dans Redis (pour GET /signals)
self._publish_signals_to_redis(valid_signals)
# 7. Exécuter signaux valides
await self._execute_signals(valid_signals)
# 8. Mettre à jour positions
await self._update_positions(market_data)
# 9. Vérifier circuit breakers
self.risk_manager.check_circuit_breakers()
# 10. Log statistiques
self._log_statistics()
# 11. Sleep jusqu'à prochaine itération
elapsed = (datetime.now() - iteration_start).total_seconds()
sleep_time = max(0, self.interval - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except Exception as e:
logger.exception(f"Error in Strategy Engine main loop: {e}")
raise
finally:
logger.info("Strategy Engine stopped")
async def stop(self):
"""Arrête le Strategy Engine."""
logger.info("Stopping Strategy Engine...")
self.running = False
# Fermer toutes les positions
await self._close_all_positions()
async def _fetch_market_data(self) -> Dict:
"""
Récupère les données marché pour tous les symboles actifs
via le DataService (Yahoo Finance → Alpha Vantage failover).
Returns:
Dictionnaire {symbol: DataFrame}
"""
from datetime import timedelta
from src.data.data_service import DataService
from src.utils.config_loader import ConfigLoader
if not hasattr(self, "_data_service"):
config = ConfigLoader.load_all()
self._data_service = DataService(config)
market_data: Dict = {}
now = datetime.now()
start = now - timedelta(days=5) # 5 jours pour indicateurs TA
symbols = self.config.get("symbols", ["EURUSD"])
for symbol in symbols:
try:
df = await self._data_service.get_historical_data(
symbol=symbol,
timeframe="1h",
start_date=start,
end_date=now,
)
if df is not None and not df.empty:
market_data[symbol] = df
logger.debug(f"Market data fetched: {symbol} ({len(df)} rows)")
else:
logger.warning(f"No data returned for {symbol}")
except Exception as exc:
logger.error(f"Failed to fetch market data for {symbol}: {exc}")
return market_data
async def _update_ml_engine(self, market_data: Dict):
"""
Initialise (paresseusement) et met à jour le ML Engine avec les données fraîches.
Le ML Engine est initialisé au premier appel avec les données disponibles,
puis mis à jour à chaque itération pour que la détection de régime soit courante.
"""
if not market_data:
return
# Première itération : entraîner le RegimeDetector
if self.ml_engine is None:
try:
from src.ml.ml_engine import MLEngine
self.ml_engine = MLEngine(config=self.config.get("ml", {}))
# Utiliser les données du premier symbole disponible
first_df = next(iter(market_data.values()))
if len(first_df) >= 50:
self.ml_engine.initialize(first_df)
logger.info("ML Engine initialisé avec données marché")
except Exception as exc:
logger.warning(f"ML Engine init échoué (non bloquant): {exc}")
self.ml_engine = None
return
# Itérations suivantes : mettre à jour le régime
try:
first_df = next(iter(market_data.values()))
self.ml_engine.update_with_new_data(first_df)
except Exception as exc:
logger.debug(f"ML Engine update skipped: {exc}")
async def _analyze_strategies(self, market_data: Dict) -> List[Signal]:
"""
Analyse le marché avec toutes les stratégies actives.
Args:
market_data: Données marché
Returns:
Liste de signaux générés
"""
signals = []
for strategy_name, strategy in self.strategies.items():
try:
# Vérifier si la stratégie est appropriée pour le régime ML actuel
if self.ml_engine is not None:
if not self.ml_engine.should_trade(strategy_name):
regime_info = self.ml_engine.get_regime_info()
logger.info(
f"⏭️ {strategy_name} suspendu — régime "
f"{regime_info.get('regime_name', '?')}"
)
continue
# Adapter les paramètres de la stratégie selon le régime
base_params = self.config.get(f"{strategy_name}_strategy", {})
adapted_params = self.ml_engine.adapt_parameters(
current_data=next(iter(market_data.values())),
strategy_name=strategy_name,
base_params=base_params,
)
strategy.update_params(adapted_params)
# Analyser avec la stratégie
signal = strategy.analyze(market_data)
if signal:
# Annoter le signal avec le régime ML
if self.ml_engine is not None:
regime = self.ml_engine.get_regime_info()
signal.metadata = signal.metadata or {}
signal.metadata["regime"] = regime.get("regime_name")
logger.info(f"Signal: {strategy_name}{signal.symbol} {signal.direction}")
signals.append(signal)
except Exception as exc:
logger.error(f"Erreur analyse {strategy_name}: {exc}")
return signals
def _filter_signals(self, signals: List[Signal]) -> List[Signal]:
"""
Filtre les signaux avec le Risk Manager.
Args:
signals: Signaux à filtrer
Returns:
Signaux valides uniquement
"""
valid_signals = []
for signal in signals:
# Calculer taille position
position_size = self._calculate_position_size(signal)
# Valider avec Risk Manager
is_valid, error = self.risk_manager.validate_trade(
symbol=signal.symbol,
quantity=position_size,
price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.take_profit,
strategy=signal.strategy
)
if is_valid:
signal.quantity = position_size
valid_signals.append(signal)
logger.info(f"✅ Signal validated: {signal.symbol}")
else:
logger.warning(f"❌ Signal rejected: {signal.symbol} - {error}")
return valid_signals
def _calculate_position_size(self, signal: Signal) -> float:
"""
Calcule la taille de position optimale pour un signal.
Args:
signal: Signal de trading
Returns:
Taille de position
"""
# Récupérer stratégie
strategy = self.strategies.get(signal.strategy)
if strategy:
# Calculer la volatilité réelle si des données sont disponibles
current_volatility = self._estimate_volatility(signal.symbol)
return strategy.calculate_position_size(
signal=signal,
portfolio_value=self.risk_manager.portfolio_value,
current_volatility=current_volatility,
)
# Fallback: taille fixe
return 1000.0
async def _execute_signals(self, signals: List[Signal]):
"""
Exécute les signaux validés.
Args:
signals: Signaux à exécuter
"""
for signal in signals:
try:
await self._execute_signal(signal)
except Exception as e:
logger.error(f"Failed to execute signal {signal.symbol}: {e}")
def _estimate_volatility(self, symbol: str) -> float:
"""
Estime la volatilité annualisée depuis le cache Redis (clé trading:volatility:{symbol}).
Returns:
Volatilité annualisée (par défaut 0.02 = 2% si données absentes)
"""
try:
import os
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis_lib.from_url(redis_url, socket_connect_timeout=2)
val = r.get(f"trading:volatility:{symbol}")
if val:
return float(val)
except Exception:
pass
return 0.02 # Valeur par défaut conservatrice
def _cache_volatility(self, market_data: Dict):
"""
Calcule la volatilité annualisée depuis les données fraîches et la met en cache Redis.
Clé : trading:volatility:{symbol}, TTL : 1h.
"""
try:
import os
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis_lib.from_url(redis_url, socket_connect_timeout=2)
for symbol, df in market_data.items():
col = "close" if "close" in df.columns else ("Close" if "Close" in df.columns else None)
if col and len(df) > 20:
vol = float(df[col].pct_change().dropna().std() * (252 ** 0.5))
r.set(f"trading:volatility:{symbol}", str(vol), ex=3600)
logger.debug(f"Volatilité cachée : {symbol} = {vol:.4f}")
except Exception as exc:
logger.debug(f"Cache volatilité Redis échoué (non bloquant) : {exc}")
def _publish_signals_to_redis(self, signals: List[Signal]):
"""
Publie les signaux actifs dans Redis (clé trading:signals, TTL 5 min).
Permet à l'API GET /signals de les retourner en temps réel.
"""
try:
import json
import os
import redis as redis_lib
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
r = redis_lib.from_url(redis_url, socket_connect_timeout=2)
payload = [
{
"symbol": s.symbol,
"direction": s.direction,
"confidence": getattr(s, "confidence", 0.0) or 0.0,
"strategy": s.strategy,
"timestamp": datetime.now().isoformat(),
}
for s in signals
]
r.set("trading:signals", json.dumps(payload), ex=300)
logger.debug(f"{len(signals)} signal(s) publiés dans Redis")
except Exception as exc:
logger.debug(f"Publication signaux Redis échouée (non bloquant) : {exc}")
async def _execute_signal(self, signal: Signal):
"""
Exécute un signal individuel.
En paper / simulation : ajoute directement la position au Risk Manager.
En live (Phase 5) : passer par le connecteur IG Markets.
"""
logger.info(f"Executing signal: {signal.symbol} {signal.direction} @ {signal.entry_price}")
# Phase 5 : remplacer par appel IG Markets API
# ig_connector.place_order(signal)
position = Position(
symbol=signal.symbol,
quantity=signal.quantity,
entry_price=signal.entry_price,
current_price=signal.entry_price,
stop_loss=signal.stop_loss,
take_profit=signal.take_profit,
strategy=signal.strategy,
entry_time=datetime.now(),
unrealized_pnl=0.0,
risk_amount=abs(signal.entry_price - signal.stop_loss) * signal.quantity
)
# Ajouter au Risk Manager
self.risk_manager.add_position(position)
async def _update_positions(self, market_data: Dict):
"""
Met à jour toutes les positions avec les prix actuels issus de market_data.
"""
for symbol, position in list(self.risk_manager.positions.items()):
df = market_data.get(symbol)
if df is not None and not df.empty and "close" in df.columns:
current_price = float(df["close"].iloc[-1])
else:
# Pas de données fraîches : conserver le dernier prix connu
current_price = position.current_price
self.risk_manager.update_position(symbol, current_price)
async def _close_all_positions(self):
"""Ferme toutes les positions ouvertes."""
logger.info("Closing all positions...")
for symbol in list(self.risk_manager.positions.keys()):
position = self.risk_manager.positions[symbol]
self.risk_manager.close_position(
symbol=symbol,
exit_price=position.current_price,
reason='engine_stop'
)
def _log_statistics(self):
"""Log les statistiques du Strategy Engine."""
stats = self.risk_manager.get_statistics()
metrics = self.risk_manager.get_risk_metrics()
logger.info(
f"Portfolio: ${stats['portfolio_value']:,.2f} | "
f"Return: {stats['total_return']:.2%} | "
f"DD: {stats['current_drawdown']:.2%} | "
f"Positions: {stats['num_positions']} | "
f"Risk: {metrics.risk_utilization:.1%}"
)
def get_performance_summary(self) -> Dict:
"""
Retourne un résumé de performance de toutes les stratégies.
Returns:
Dictionnaire avec performance par stratégie
"""
summary = {}
for strategy_name, strategy in self.strategies.items():
summary[strategy_name] = {
'win_rate': strategy.win_rate,
'sharpe_ratio': strategy.sharpe_ratio,
'total_trades': len(strategy.closed_trades),
'avg_win': strategy.avg_win,
'avg_loss': strategy.avg_loss,
}
return summary

20
src/data/__init__.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Module Data - Connecteurs de Données et Sources.
Ce module gère l'accès aux données de marché depuis différentes sources:
- DataService: Service unifié d'accès aux données
- YahooFinanceConnector: Données Yahoo Finance (gratuit)
- AlphaVantageConnector: Données Alpha Vantage (gratuit, API key)
- DataValidator: Validation et nettoyage des données
- CacheManager: Gestion du cache Redis
Toutes les sources implémentent l'interface BaseDataSource.
"""
from src.data.data_service import DataService
from src.data.base_data_source import BaseDataSource
__all__ = [
'DataService',
'BaseDataSource',
]

View File

@@ -0,0 +1,432 @@
"""
Alpha Vantage Connector - Source de Données Alpha Vantage.
Connecteur pour Alpha Vantage API (gratuit avec API key).
Avantages:
- Données temps réel
- Données intraday complètes
- Indicateurs techniques intégrés
- Données fondamentales
Limitations:
- 500 requêtes par jour (gratuit)
- 5 requêtes par minute
- Nécessite API key
"""
from typing import Optional
from datetime import datetime, timedelta
import pandas as pd
import time
import logging
try:
from alpha_vantage.timeseries import TimeSeries
from alpha_vantage.foreignexchange import ForeignExchange
ALPHA_VANTAGE_AVAILABLE = True
except ImportError:
ALPHA_VANTAGE_AVAILABLE = False
logging.warning("alpha_vantage not installed. Install with: pip install alpha-vantage")
from src.data.base_data_source import BaseDataSource
logger = logging.getLogger(__name__)
class AlphaVantageConnector(BaseDataSource):
"""
Connecteur Alpha Vantage.
Fournit accès aux données via Alpha Vantage API.
Nécessite une clé API gratuite.
Usage:
connector = AlphaVantageConnector(api_key='YOUR_KEY')
data = connector.fetch_historical('EURUSD', '1h', start, end)
"""
# Mapping timeframes
TIMEFRAME_MAP = {
'1m': '1min',
'5m': '5min',
'15m': '15min',
'30m': '30min',
'1h': '60min',
'1d': 'daily',
'1wk': 'weekly',
'1mo': 'monthly',
}
def __init__(self, api_key: str):
"""
Initialise le connecteur Alpha Vantage.
Args:
api_key: Clé API Alpha Vantage
"""
super().__init__(name='AlphaVantage', priority=2)
if not ALPHA_VANTAGE_AVAILABLE:
logger.error("alpha_vantage not available!")
self.api_key = None
self.ts = None
self.fx = None
return
self.api_key = api_key
# Initialiser clients
self.ts = TimeSeries(key=api_key, output_format='pandas')
self.fx = ForeignExchange(key=api_key, output_format='pandas')
# Rate limiting
self.last_request_time = None
self.min_request_interval = 12 # 5 requêtes/minute = 12 secondes entre requêtes
self.daily_request_count = 0
self.daily_request_limit = 500
self.last_reset_date = datetime.now().date()
def fetch_historical(
self,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime
) -> Optional[pd.DataFrame]:
"""
Récupère données historiques depuis Alpha Vantage.
Args:
symbol: Symbole (ex: 'EURUSD', 'AAPL')
timeframe: Timeframe
start_date: Date de début
end_date: Date de fin
Returns:
DataFrame avec OHLCV ou None si erreur
"""
if not ALPHA_VANTAGE_AVAILABLE or not self.api_key:
logger.error("Alpha Vantage not available")
return None
# Vérifier rate limit
if not self._check_rate_limit():
logger.warning("Alpha Vantage rate limit reached")
return None
try:
# Attendre si nécessaire (rate limiting)
self._wait_for_rate_limit()
# Convertir timeframe
av_interval = self.TIMEFRAME_MAP.get(timeframe, '60min')
# Déterminer si c'est du forex
is_forex = self._is_forex_pair(symbol)
if is_forex:
df = self._fetch_forex_data(symbol, av_interval)
else:
df = self._fetch_stock_data(symbol, av_interval)
if df is None or df.empty:
logger.warning(f"No data returned for {symbol}")
return None
# Filtrer par dates
df = df[(df.index >= start_date) & (df.index <= end_date)]
# Normaliser
df = self._normalize_dataframe(df)
# Valider
if not self._validate_dataframe(df):
logger.error(f"Invalid data for {symbol}")
return None
self._increment_request_count()
self._increment_daily_count()
logger.info(f"Fetched {len(df)} bars for {symbol}")
return df
except Exception as e:
logger.error(f"Error fetching data from Alpha Vantage: {e}")
return None
def fetch_realtime(self, symbol: str) -> Optional[dict]:
"""
Récupère données temps réel.
Args:
symbol: Symbole
Returns:
Dictionnaire avec prix actuels
"""
if not ALPHA_VANTAGE_AVAILABLE or not self.api_key:
return None
if not self._check_rate_limit():
return None
try:
self._wait_for_rate_limit()
is_forex = self._is_forex_pair(symbol)
if is_forex:
# Forex realtime
from_currency, to_currency = self._split_forex_pair(symbol)
data, _ = self.fx.get_currency_exchange_rate(
from_currency=from_currency,
to_currency=to_currency
)
if data is None:
return None
result = {
'symbol': symbol,
'timestamp': datetime.now(),
'bid': float(data['5. Exchange Rate']),
'ask': float(data['5. Exchange Rate']),
'last': float(data['5. Exchange Rate']),
}
else:
# Stock realtime (quote)
data, _ = self.ts.get_quote_endpoint(symbol=symbol)
if data is None:
return None
result = {
'symbol': symbol,
'timestamp': datetime.now(),
'bid': float(data['price']),
'ask': float(data['price']),
'last': float(data['price']),
'open': float(data['open']),
'high': float(data['high']),
'low': float(data['low']),
'volume': int(data['volume']),
}
self._increment_request_count()
self._increment_daily_count()
return result
except Exception as e:
logger.error(f"Error fetching realtime data: {e}")
return None
def is_available(self) -> bool:
"""
Vérifie si Alpha Vantage est disponible.
Returns:
True si disponible
"""
if not ALPHA_VANTAGE_AVAILABLE or not self.api_key:
return False
return self._check_rate_limit()
def _fetch_forex_data(self, symbol: str, interval: str) -> Optional[pd.DataFrame]:
"""
Récupère données forex.
Args:
symbol: Paire forex (ex: 'EURUSD')
interval: Intervalle
Returns:
DataFrame ou None
"""
from_currency, to_currency = self._split_forex_pair(symbol)
if interval in ['1min', '5min', '15min', '30min', '60min']:
# Intraday
df, _ = self.fx.get_currency_exchange_intraday(
from_symbol=from_currency,
to_symbol=to_currency,
interval=interval,
outputsize='full'
)
elif interval == 'daily':
df, _ = self.fx.get_currency_exchange_daily(
from_symbol=from_currency,
to_symbol=to_currency,
outputsize='full'
)
elif interval == 'weekly':
df, _ = self.fx.get_currency_exchange_weekly(
from_symbol=from_currency,
to_symbol=to_currency
)
elif interval == 'monthly':
df, _ = self.fx.get_currency_exchange_monthly(
from_symbol=from_currency,
to_symbol=to_currency
)
else:
return None
return df
def _fetch_stock_data(self, symbol: str, interval: str) -> Optional[pd.DataFrame]:
"""
Récupère données actions.
Args:
symbol: Symbole action
interval: Intervalle
Returns:
DataFrame ou None
"""
if interval in ['1min', '5min', '15min', '30min', '60min']:
# Intraday
df, _ = self.ts.get_intraday(
symbol=symbol,
interval=interval,
outputsize='full'
)
elif interval == 'daily':
df, _ = self.ts.get_daily(
symbol=symbol,
outputsize='full'
)
elif interval == 'weekly':
df, _ = self.ts.get_weekly(symbol=symbol)
elif interval == 'monthly':
df, _ = self.ts.get_monthly(symbol=symbol)
else:
return None
return df
def _is_forex_pair(self, symbol: str) -> bool:
"""
Détermine si le symbole est une paire forex.
Args:
symbol: Symbole
Returns:
True si forex
"""
forex_pairs = [
'EURUSD', 'GBPUSD', 'USDJPY', 'AUDUSD', 'USDCAD',
'USDCHF', 'NZDUSD', 'EURGBP', 'EURJPY', 'GBPJPY'
]
return symbol in forex_pairs
def _split_forex_pair(self, symbol: str) -> tuple:
"""
Sépare une paire forex en deux devises.
Args:
symbol: Paire (ex: 'EURUSD')
Returns:
Tuple (from_currency, to_currency)
"""
if len(symbol) == 6:
return symbol[:3], symbol[3:]
return symbol, 'USD'
def _normalize_dataframe(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Normalise le DataFrame Alpha Vantage.
Args:
df: DataFrame brut
Returns:
DataFrame normalisé
"""
# Renommer colonnes
column_map = {
'1. open': 'open',
'2. high': 'high',
'3. low': 'low',
'4. close': 'close',
'5. volume': 'volume',
}
df = df.rename(columns=column_map)
# S'assurer que l'index est datetime
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index)
# Trier par date
df = df.sort_index()
# Convertir en float
for col in ['open', 'high', 'low', 'close']:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')
if 'volume' in df.columns:
df['volume'] = pd.to_numeric(df['volume'], errors='coerce').fillna(0)
# Supprimer NaN
df = df.dropna()
return df
def _check_rate_limit(self) -> bool:
"""
Vérifie si on peut faire une requête.
Returns:
True si OK
"""
# Reset compteur quotidien si nouveau jour
today = datetime.now().date()
if today != self.last_reset_date:
self.daily_request_count = 0
self.last_reset_date = today
# Vérifier limite quotidienne
if self.daily_request_count >= self.daily_request_limit:
logger.warning(f"Daily limit reached: {self.daily_request_count}/{self.daily_request_limit}")
return False
return True
def _wait_for_rate_limit(self):
"""Attend si nécessaire pour respecter le rate limit."""
if self.last_request_time is not None:
elapsed = (datetime.now() - self.last_request_time).total_seconds()
if elapsed < self.min_request_interval:
wait_time = self.min_request_interval - elapsed
logger.debug(f"Rate limiting: waiting {wait_time:.1f}s")
time.sleep(wait_time)
self.last_request_time = datetime.now()
def _increment_daily_count(self):
"""Incrémente le compteur quotidien."""
self.daily_request_count += 1
logger.debug(f"Daily requests: {self.daily_request_count}/{self.daily_request_limit}")
def get_statistics(self) -> dict:
"""
Retourne les statistiques.
Returns:
Dictionnaire avec statistiques
"""
stats = super().get_statistics()
stats.update({
'daily_requests': self.daily_request_count,
'daily_limit': self.daily_request_limit,
'requests_remaining': self.daily_request_limit - self.daily_request_count,
})
return stats

View File

@@ -0,0 +1,145 @@
"""
Base Data Source - Interface Abstraite pour Sources de Données.
Toutes les sources de données doivent implémenter cette interface
pour garantir une API uniforme.
"""
from abc import ABC, abstractmethod
from typing import Optional, List
from datetime import datetime
import pandas as pd
import logging
logger = logging.getLogger(__name__)
class BaseDataSource(ABC):
"""
Interface abstraite pour toutes les sources de données.
Toutes les sources doivent implémenter:
- fetch_historical(): Récupère données historiques
- fetch_realtime(): Récupère données temps réel
- is_available(): Vérifie disponibilité
Attributs:
name: Nom de la source
priority: Priorité (0 = plus haute)
rate_limit: Limite de requêtes
"""
def __init__(self, name: str, priority: int = 10):
"""
Initialise la source de données.
Args:
name: Nom de la source
priority: Priorité (0 = plus haute)
"""
self.name = name
self.priority = priority
self.request_count = 0
self.last_request_time = None
logger.info(f"Data source initialized: {name} (priority: {priority})")
@abstractmethod
def fetch_historical(
self,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime
) -> Optional[pd.DataFrame]:
"""
Récupère données historiques.
Args:
symbol: Symbole à récupérer (ex: 'EURUSD')
timeframe: Timeframe ('1m', '5m', '15m', '1h', '1d', etc.)
start_date: Date de début
end_date: Date de fin
Returns:
DataFrame avec colonnes [open, high, low, close, volume]
ou None si erreur
"""
pass
@abstractmethod
def fetch_realtime(self, symbol: str) -> Optional[dict]:
"""
Récupère données temps réel.
Args:
symbol: Symbole à récupérer
Returns:
Dictionnaire avec prix actuels ou None si erreur
"""
pass
@abstractmethod
def is_available(self) -> bool:
"""
Vérifie si la source est disponible.
Returns:
True si disponible, False sinon
"""
pass
def get_supported_timeframes(self) -> List[str]:
"""
Retourne les timeframes supportés.
Returns:
Liste des timeframes supportés
"""
return ['1m', '5m', '15m', '30m', '1h', '4h', '1d', '1wk', '1mo']
def get_statistics(self) -> dict:
"""
Retourne les statistiques de la source.
Returns:
Dictionnaire avec statistiques
"""
return {
'name': self.name,
'priority': self.priority,
'request_count': self.request_count,
'last_request': self.last_request_time,
}
def _increment_request_count(self):
"""Incrémente le compteur de requêtes."""
self.request_count += 1
self.last_request_time = datetime.now()
def _validate_dataframe(self, df: pd.DataFrame) -> bool:
"""
Valide un DataFrame de données OHLCV.
Args:
df: DataFrame à valider
Returns:
True si valide, False sinon
"""
if df is None or df.empty:
return False
# Vérifier colonnes requises
required_columns = ['open', 'high', 'low', 'close', 'volume']
if not all(col in df.columns for col in required_columns):
logger.warning(f"Missing required columns in {self.name}")
return False
# Vérifier cohérence prix (high >= low)
if not (df['high'] >= df['low']).all():
logger.warning(f"Invalid price data in {self.name}")
return False
return True

286
src/data/data_service.py Normal file
View File

@@ -0,0 +1,286 @@
"""
Data Service - Service Unifié d'Accès aux Données.
Ce service gère l'accès aux données depuis multiples sources avec:
- Failover automatique entre sources
- Cache intelligent
- Validation des données
- Rate limiting
- Retry logic
"""
from typing import Optional, List, Dict
from datetime import datetime
import pandas as pd
import logging
from src.data.base_data_source import BaseDataSource
from src.data.yahoo_finance_connector import YahooFinanceConnector
from src.data.alpha_vantage_connector import AlphaVantageConnector
from src.data.data_validator import DataValidator
logger = logging.getLogger(__name__)
class DataService:
"""
Service unifié d'accès aux données de marché.
Fonctionnalités:
- Multi-source avec failover automatique
- Cache pour réduire appels API
- Validation automatique des données
- Rate limiting respecté
- Retry logic
Usage:
service = DataService(config)
data = await service.get_historical_data('EURUSD', '1h', start, end)
"""
def __init__(self, config: Dict):
"""
Initialise le Data Service.
Args:
config: Configuration des sources de données
"""
self.config = config
self.sources: List[BaseDataSource] = []
self.validator = DataValidator()
# Initialiser sources
self._initialize_sources()
# Trier par priorité
self.sources.sort(key=lambda x: x.priority)
logger.info(f"Data Service initialized with {len(self.sources)} sources")
def _initialize_sources(self):
"""Initialise toutes les sources de données configurées."""
data_sources_config = self.config.get('data_sources', {})
# Yahoo Finance
if data_sources_config.get('yahoo_finance', {}).get('enabled', True):
try:
yahoo = YahooFinanceConnector()
if yahoo.is_available():
self.sources.append(yahoo)
logger.info("✅ Yahoo Finance source added")
else:
logger.warning("⚠️ Yahoo Finance not available")
except Exception as e:
logger.error(f"Failed to initialize Yahoo Finance: {e}")
# Alpha Vantage
av_config = data_sources_config.get('alpha_vantage', {})
if av_config.get('enabled', False):
api_key = av_config.get('api_key')
if api_key and api_key != 'YOUR_API_KEY_HERE':
try:
alpha = AlphaVantageConnector(api_key=api_key)
if alpha.is_available():
self.sources.append(alpha)
logger.info("✅ Alpha Vantage source added")
else:
logger.warning("⚠️ Alpha Vantage not available")
except Exception as e:
logger.error(f"Failed to initialize Alpha Vantage: {e}")
else:
logger.warning("Alpha Vantage API key not configured")
async def get_historical_data(
self,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime,
max_retries: int = 3
) -> Optional[pd.DataFrame]:
"""
Récupère données historiques avec failover.
Essaie chaque source par ordre de priorité jusqu'à succès.
Args:
symbol: Symbole à récupérer
timeframe: Timeframe
start_date: Date de début
end_date: Date de fin
max_retries: Nombre maximum de tentatives par source
Returns:
DataFrame avec OHLCV ou None si toutes les sources échouent
"""
if not self.sources:
logger.error("No data sources available")
return None
logger.info(f"Fetching {symbol} {timeframe} from {start_date} to {end_date}")
# Essayer chaque source
for source in self.sources:
logger.debug(f"Trying source: {source.name}")
for attempt in range(max_retries):
try:
# Récupérer données
df = source.fetch_historical(
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date
)
if df is None or df.empty:
logger.warning(f"No data from {source.name} (attempt {attempt + 1}/{max_retries})")
continue
# Valider données
is_valid, errors = self.validator.validate(df)
if not is_valid:
logger.warning(f"Invalid data from {source.name}: {errors}")
continue
# Nettoyer données
df = self.validator.clean(df)
logger.info(f"✅ Data fetched from {source.name}: {len(df)} bars")
return df
except Exception as e:
logger.error(f"Error with {source.name} (attempt {attempt + 1}/{max_retries}): {e}")
continue
# Toutes les sources ont échoué
logger.error(f"Failed to fetch data for {symbol} from all sources")
return None
async def get_realtime_data(
self,
symbol: str,
max_retries: int = 3
) -> Optional[Dict]:
"""
Récupère données temps réel avec failover.
Args:
symbol: Symbole
max_retries: Nombre de tentatives par source
Returns:
Dictionnaire avec prix actuels ou None
"""
if not self.sources:
logger.error("No data sources available")
return None
# Essayer chaque source
for source in self.sources:
for attempt in range(max_retries):
try:
data = source.fetch_realtime(symbol)
if data is not None:
logger.debug(f"Realtime data from {source.name}: {data['last']}")
return data
except Exception as e:
logger.error(f"Error with {source.name}: {e}")
continue
logger.error(f"Failed to fetch realtime data for {symbol}")
return None
async def get_multiple_symbols(
self,
symbols: List[str],
timeframe: str,
start_date: datetime,
end_date: datetime
) -> Dict[str, pd.DataFrame]:
"""
Récupère données pour plusieurs symboles.
Args:
symbols: Liste de symboles
timeframe: Timeframe
start_date: Date de début
end_date: Date de fin
Returns:
Dictionnaire {symbol: DataFrame}
"""
results = {}
for symbol in symbols:
logger.info(f"Fetching {symbol}...")
df = await self.get_historical_data(
symbol=symbol,
timeframe=timeframe,
start_date=start_date,
end_date=end_date
)
if df is not None:
results[symbol] = df
else:
logger.warning(f"Failed to fetch {symbol}")
logger.info(f"Fetched {len(results)}/{len(symbols)} symbols")
return results
def get_available_sources(self) -> List[str]:
"""
Retourne la liste des sources disponibles.
Returns:
Liste de noms de sources
"""
return [source.name for source in self.sources if source.is_available()]
def get_source_statistics(self) -> Dict:
"""
Retourne les statistiques de toutes les sources.
Returns:
Dictionnaire avec statistiques par source
"""
stats = {}
for source in self.sources:
stats[source.name] = source.get_statistics()
return stats
def test_all_sources(self) -> Dict[str, bool]:
"""
Teste toutes les sources.
Returns:
Dictionnaire {source_name: is_available}
"""
results = {}
for source in self.sources:
logger.info(f"Testing {source.name}...")
try:
is_available = source.is_available()
results[source.name] = is_available
if is_available:
logger.info(f"{source.name} is available")
else:
logger.warning(f"⚠️ {source.name} is not available")
except Exception as e:
logger.error(f"{source.name} test failed: {e}")
results[source.name] = False
return results

333
src/data/data_validator.py Normal file
View File

@@ -0,0 +1,333 @@
"""
Data Validator - Validation et Nettoyage des Données.
Ce module valide et nettoie les données de marché pour garantir
leur qualité avant utilisation dans les stratégies.
Validations:
- Colonnes requises présentes
- Pas de valeurs manquantes excessives
- Cohérence des prix (high >= low, etc.)
- Pas d'outliers extrêmes
- Ordre chronologique
- Pas de doublons
"""
from typing import Tuple, List
import pandas as pd
import numpy as np
import logging
logger = logging.getLogger(__name__)
class DataValidator:
"""
Validateur et nettoyeur de données de marché.
Effectue des vérifications de qualité et nettoie les données
pour garantir leur fiabilité.
Usage:
validator = DataValidator()
is_valid, errors = validator.validate(df)
if is_valid:
df_clean = validator.clean(df)
"""
def __init__(self, config: dict = None):
"""
Initialise le validateur.
Args:
config: Configuration optionnelle
"""
self.config = config or {}
# Seuils de validation
self.max_missing_pct = self.config.get('max_missing_pct', 0.05) # 5%
self.outlier_std_threshold = self.config.get('outlier_std_threshold', 5) # 5 sigma
logger.debug("Data Validator initialized")
def validate(self, df: pd.DataFrame) -> Tuple[bool, List[str]]:
"""
Valide un DataFrame de données OHLCV.
Args:
df: DataFrame à valider
Returns:
Tuple (is_valid, list_of_errors)
"""
errors = []
# 1. Vérifier que le DataFrame n'est pas vide
if df is None or df.empty:
errors.append("DataFrame is empty")
return False, errors
# 2. Vérifier colonnes requises
required_columns = ['open', 'high', 'low', 'close', 'volume']
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
errors.append(f"Missing columns: {missing_columns}")
return False, errors
# 3. Vérifier valeurs manquantes
missing_pct = df[required_columns].isnull().sum() / len(df)
excessive_missing = missing_pct[missing_pct > self.max_missing_pct]
if not excessive_missing.empty:
errors.append(f"Excessive missing values: {excessive_missing.to_dict()}")
# 4. Vérifier cohérence des prix
price_errors = self._check_price_consistency(df)
errors.extend(price_errors)
# 5. Vérifier outliers (avertissement seulement — clean() les supprime)
outlier_errors = self._check_outliers(df)
if outlier_errors:
logger.warning(f"Outliers detected (will be cleaned): {outlier_errors}")
# 6. Vérifier ordre chronologique
if not self._check_chronological_order(df):
errors.append("Data not in chronological order")
# 7. Vérifier doublons
duplicates = df.index.duplicated().sum()
if duplicates > 0:
errors.append(f"Found {duplicates} duplicate timestamps")
# Déterminer si valide
is_valid = len(errors) == 0
if not is_valid:
logger.warning(f"Validation failed: {errors}")
else:
logger.debug("Validation passed")
return is_valid, errors
def clean(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Nettoie un DataFrame de données.
Args:
df: DataFrame à nettoyer
Returns:
DataFrame nettoyé
"""
df_clean = df.copy()
# 1. Supprimer doublons
df_clean = df_clean[~df_clean.index.duplicated(keep='first')]
# 2. Trier par date
df_clean = df_clean.sort_index()
# 3. Interpoler valeurs manquantes (si peu nombreuses)
missing_pct = df_clean.isnull().sum() / len(df_clean)
for col in ['open', 'high', 'low', 'close']:
if missing_pct[col] < self.max_missing_pct:
df_clean[col] = df_clean[col].interpolate(method='linear')
# Volume: forward fill puis 0
if 'volume' in df_clean.columns:
df_clean['volume'] = df_clean['volume'].fillna(method='ffill').fillna(0)
# 4. Supprimer lignes encore avec NaN
df_clean = df_clean.dropna(subset=['open', 'high', 'low', 'close'])
# 5. Corriger incohérences de prix
df_clean = self._fix_price_inconsistencies(df_clean)
# 6. Supprimer outliers extrêmes
df_clean = self._remove_extreme_outliers(df_clean)
logger.debug(f"Cleaned data: {len(df)}{len(df_clean)} rows")
return df_clean
def _check_price_consistency(self, df: pd.DataFrame) -> List[str]:
"""
Vérifie la cohérence des prix OHLC.
Args:
df: DataFrame
Returns:
Liste d'erreurs
"""
errors = []
# High doit être >= Low
invalid_high_low = (df['high'] < df['low']).sum()
if invalid_high_low > 0:
errors.append(f"{invalid_high_low} bars with high < low")
# High doit être >= Open et Close
invalid_high_open = (df['high'] < df['open']).sum()
invalid_high_close = (df['high'] < df['close']).sum()
if invalid_high_open > 0:
errors.append(f"{invalid_high_open} bars with high < open")
if invalid_high_close > 0:
errors.append(f"{invalid_high_close} bars with high < close")
# Low doit être <= Open et Close
invalid_low_open = (df['low'] > df['open']).sum()
invalid_low_close = (df['low'] > df['close']).sum()
if invalid_low_open > 0:
errors.append(f"{invalid_low_open} bars with low > open")
if invalid_low_close > 0:
errors.append(f"{invalid_low_close} bars with low > close")
return errors
def _check_outliers(self, df: pd.DataFrame) -> List[str]:
"""
Vérifie la présence d'outliers extrêmes.
Args:
df: DataFrame
Returns:
Liste d'erreurs
"""
errors = []
# Calculer returns
returns = df['close'].pct_change()
# Statistiques
mean_return = returns.mean()
std_return = returns.std()
# Outliers = au-delà de N sigma
outliers = abs(returns - mean_return) > (self.outlier_std_threshold * std_return)
num_outliers = outliers.sum()
if num_outliers > 0:
outlier_pct = num_outliers / len(df) * 100
errors.append(f"{num_outliers} outliers detected ({outlier_pct:.2f}%)")
return errors
def _check_chronological_order(self, df: pd.DataFrame) -> bool:
"""
Vérifie que les données sont en ordre chronologique.
Args:
df: DataFrame
Returns:
True si en ordre
"""
if not isinstance(df.index, pd.DatetimeIndex):
return False
return df.index.is_monotonic_increasing
def _fix_price_inconsistencies(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Corrige les incohérences de prix.
Args:
df: DataFrame
Returns:
DataFrame corrigé
"""
df_fixed = df.copy()
# Si high < low, échanger
swap_mask = df_fixed['high'] < df_fixed['low']
df_fixed.loc[swap_mask, ['high', 'low']] = df_fixed.loc[swap_mask, ['low', 'high']].values
# Ajuster high si nécessaire
df_fixed['high'] = df_fixed[['high', 'open', 'close']].max(axis=1)
# Ajuster low si nécessaire
df_fixed['low'] = df_fixed[['low', 'open', 'close']].min(axis=1)
return df_fixed
def _remove_extreme_outliers(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Supprime les outliers extrêmes.
Args:
df: DataFrame
Returns:
DataFrame sans outliers extrêmes
"""
# Calculer returns
returns = df['close'].pct_change()
# Statistiques
mean_return = returns.mean()
std_return = returns.std()
# Masque pour outliers extrêmes
outlier_mask = abs(returns - mean_return) > (self.outlier_std_threshold * std_return)
# Supprimer outliers
df_clean = df[~outlier_mask].copy()
num_removed = outlier_mask.sum()
if num_removed > 0:
logger.warning(f"Removed {num_removed} extreme outliers")
return df_clean
def get_data_quality_report(self, df: pd.DataFrame) -> dict:
"""
Génère un rapport de qualité des données.
Args:
df: DataFrame
Returns:
Dictionnaire avec métriques de qualité
"""
report = {
'total_rows': len(df),
'date_range': {
'start': df.index.min(),
'end': df.index.max(),
'days': (df.index.max() - df.index.min()).days
},
'missing_values': df.isnull().sum().to_dict(),
'missing_pct': (df.isnull().sum() / len(df) * 100).to_dict(),
'duplicates': df.index.duplicated().sum(),
'chronological': self._check_chronological_order(df),
}
# Statistiques de prix
report['price_stats'] = {
'mean_close': df['close'].mean(),
'std_close': df['close'].std(),
'min_close': df['close'].min(),
'max_close': df['close'].max(),
}
# Statistiques de volume
if 'volume' in df.columns:
report['volume_stats'] = {
'mean': df['volume'].mean(),
'median': df['volume'].median(),
'zero_volume_bars': (df['volume'] == 0).sum(),
}
# Validation
is_valid, errors = self.validate(df)
report['is_valid'] = is_valid
report['errors'] = errors
return report

View File

@@ -0,0 +1,265 @@
"""
Yahoo Finance Connector - Source de Données Yahoo Finance.
Connecteur pour Yahoo Finance (gratuit, illimité).
Avantages:
- Gratuit et illimité
- Données historiques complètes
- Données intraday (limité à 7 jours)
- Large couverture d'instruments
Limitations:
- Données intraday limitées à 7 jours
- Pas de données temps réel strictes
- Peut être instable parfois
"""
from typing import Optional
from datetime import datetime, timedelta
import pandas as pd
import logging
try:
import yfinance as yf
YFINANCE_AVAILABLE = True
except ImportError:
YFINANCE_AVAILABLE = False
logging.warning("yfinance not installed. Install with: pip install yfinance")
from src.data.base_data_source import BaseDataSource
logger = logging.getLogger(__name__)
class YahooFinanceConnector(BaseDataSource):
"""
Connecteur Yahoo Finance.
Fournit accès gratuit aux données de marché via yfinance.
Usage:
connector = YahooFinanceConnector()
data = connector.fetch_historical('EURUSD=X', '1h', start, end)
"""
# Mapping symboles standard → Yahoo Finance
SYMBOL_MAP = {
'EURUSD': 'EURUSD=X',
'GBPUSD': 'GBPUSD=X',
'USDJPY': 'USDJPY=X',
'AUDUSD': 'AUDUSD=X',
'USDCAD': 'USDCAD=X',
'USDCHF': 'USDCHF=X',
'NZDUSD': 'NZDUSD=X',
'EURGBP': 'EURGBP=X',
'EURJPY': 'EURJPY=X',
'GBPJPY': 'GBPJPY=X',
# Indices
'US500': '^GSPC', # S&P 500
'US30': '^DJI', # Dow Jones
'US100': '^IXIC', # Nasdaq
'GER40': '^GDAXI', # DAX
'UK100': '^FTSE', # FTSE 100
'FRA40': '^FCHI', # CAC 40
# Crypto
'BTCUSD': 'BTC-USD',
'ETHUSD': 'ETH-USD',
}
# Mapping timeframes
TIMEFRAME_MAP = {
'1m': '1m',
'2m': '2m',
'5m': '5m',
'15m': '15m',
'30m': '30m',
'1h': '1h',
'90m': '90m',
'1d': '1d',
'5d': '5d',
'1wk': '1wk',
'1mo': '1mo',
'3mo': '3mo',
}
def __init__(self):
"""Initialise le connecteur Yahoo Finance."""
super().__init__(name='YahooFinance', priority=1)
if not YFINANCE_AVAILABLE:
logger.error("yfinance not available!")
def fetch_historical(
self,
symbol: str,
timeframe: str,
start_date: datetime,
end_date: datetime
) -> Optional[pd.DataFrame]:
"""
Récupère données historiques depuis Yahoo Finance.
Args:
symbol: Symbole (ex: 'EURUSD')
timeframe: Timeframe (ex: '1h', '1d')
start_date: Date de début
end_date: Date de fin
Returns:
DataFrame avec OHLCV ou None si erreur
"""
if not YFINANCE_AVAILABLE:
logger.error("yfinance not installed")
return None
try:
# Convertir symbole
yf_symbol = self._convert_symbol(symbol)
# Convertir timeframe
yf_interval = self.TIMEFRAME_MAP.get(timeframe, '1h')
# Vérifier limitation intraday
if yf_interval in ['1m', '2m', '5m', '15m', '30m', '90m']:
# Yahoo limite intraday à 7 jours
max_days = 7
if (end_date - start_date).days > max_days:
logger.warning(f"Intraday data limited to {max_days} days, adjusting start_date")
start_date = end_date - timedelta(days=max_days)
logger.debug(f"Fetching {yf_symbol} {yf_interval} from {start_date} to {end_date}")
# Télécharger données
ticker = yf.Ticker(yf_symbol)
df = ticker.history(
start=start_date,
end=end_date,
interval=yf_interval,
auto_adjust=True # Ajuster pour splits/dividendes
)
if df.empty:
logger.warning(f"No data returned for {symbol}")
return None
# Normaliser colonnes
df = self._normalize_dataframe(df)
# Valider
if not self._validate_dataframe(df):
logger.error(f"Invalid data for {symbol}")
return None
self._increment_request_count()
logger.info(f"Fetched {len(df)} bars for {symbol}")
return df
except Exception as e:
logger.error(f"Error fetching data from Yahoo Finance: {e}")
return None
def fetch_realtime(self, symbol: str) -> Optional[dict]:
"""
Récupère données temps réel (dernière barre).
Args:
symbol: Symbole
Returns:
Dictionnaire avec prix actuels
"""
if not YFINANCE_AVAILABLE:
return None
try:
yf_symbol = self._convert_symbol(symbol)
ticker = yf.Ticker(yf_symbol)
info = ticker.info
# Récupérer dernière barre 1 minute
df = ticker.history(period='1d', interval='1m')
if df.empty:
return None
last_bar = df.iloc[-1]
data = {
'symbol': symbol,
'timestamp': datetime.now(),
'bid': last_bar['Close'], # Yahoo n'a pas bid/ask séparés
'ask': last_bar['Close'],
'last': last_bar['Close'],
'open': last_bar['Open'],
'high': last_bar['High'],
'low': last_bar['Low'],
'volume': last_bar['Volume'],
}
self._increment_request_count()
return data
except Exception as e:
logger.error(f"Error fetching realtime data: {e}")
return None
def is_available(self) -> bool:
"""
Vérifie si Yahoo Finance est disponible.
Returns:
True si disponible (vérification import uniquement, pas d'appel réseau)
"""
return YFINANCE_AVAILABLE
def _convert_symbol(self, symbol: str) -> str:
"""
Convertit symbole standard en symbole Yahoo Finance.
Args:
symbol: Symbole standard
Returns:
Symbole Yahoo Finance
"""
return self.SYMBOL_MAP.get(symbol, symbol)
def _normalize_dataframe(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Normalise le DataFrame Yahoo Finance.
Args:
df: DataFrame brut
Returns:
DataFrame normalisé
"""
# Renommer colonnes en minuscules
df.columns = df.columns.str.lower()
# S'assurer que l'index est datetime
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index)
# Supprimer colonnes inutiles
columns_to_keep = ['open', 'high', 'low', 'close', 'volume']
df = df[[col for col in columns_to_keep if col in df.columns]]
# Supprimer NaN
df = df.dropna()
return df
def get_supported_symbols(self) -> list:
"""
Retourne la liste des symboles supportés.
Returns:
Liste de symboles
"""
return list(self.SYMBOL_MAP.keys())

0
src/db/__init__.py Normal file
View File

203
src/db/models.py Normal file
View File

@@ -0,0 +1,203 @@
"""
Modèles SQLAlchemy - Trading AI Secure.
Tables :
- Trade : trades exécutés (ouverts + fermés)
- OHLCVData : données de marché OHLCV (hypertable TimescaleDB)
- BacktestResult : résultats de backtesting
- MLModelMeta : métadonnées des modèles ML (date entraînement, métriques)
- OptimizationRun : historique des runs Optuna
TimescaleDB hypertable pour OHLCVData :
SELECT create_hypertable('ohlcv', 'timestamp', if_not_exists => TRUE);
(Exécuter une seule fois après création de la table)
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Boolean, Column, DateTime, Float, Index,
Integer, JSON, String, Text, UniqueConstraint,
)
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
# =============================================================================
# TRADING
# =============================================================================
class Trade(Base):
"""Trade exécuté (paper ou live)."""
__tablename__ = "trades"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), nullable=False, index=True)
direction = Column(String(5), nullable=False) # LONG | SHORT
quantity = Column(Float, nullable=False)
entry_price = Column(Float, nullable=False)
exit_price = Column(Float, nullable=True)
stop_loss = Column(Float, nullable=False)
take_profit = Column(Float, nullable=False)
strategy = Column(String(50), nullable=False, index=True)
mode = Column(String(10), nullable=False, default="paper") # paper | live
entry_time = Column(DateTime, nullable=False, default=datetime.utcnow)
exit_time = Column(DateTime, nullable=True)
pnl = Column(Float, nullable=True)
pnl_pct = Column(Float, nullable=True)
risk_amount = Column(Float, nullable=True)
status = Column(String(10), nullable=False, default="open") # open | closed
close_reason = Column(String(30), nullable=True) # stop_loss | take_profit | manual | paper_end
# Référence broker (IG Markets deal ID)
deal_id = Column(String(50), nullable=True, unique=True)
# Contexte ML
ml_confidence = Column(Float, nullable=True)
market_regime = Column(String(20), nullable=True) # bull | bear | sideways | volatile
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index("ix_trades_entry_time", "entry_time"),
)
def __repr__(self) -> str:
return f"<Trade {self.direction} {self.symbol} @ {self.entry_price} [{self.status}]>"
# =============================================================================
# DONNÉES DE MARCHÉ
# =============================================================================
class OHLCVData(Base):
"""
Données OHLCV (Open/High/Low/Close/Volume).
Optimisé pour TimescaleDB : créer l'hypertable manuellement après migration :
SELECT create_hypertable('ohlcv', 'timestamp', if_not_exists => TRUE);
"""
__tablename__ = "ohlcv"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(20), nullable=False)
timeframe = Column(String(5), nullable=False) # 1m, 5m, 15m, 1h, 1d
timestamp = Column(DateTime, nullable=False)
open = Column(Float, nullable=False)
high = Column(Float, nullable=False)
low = Column(Float, nullable=False)
close = Column(Float, nullable=False)
volume = Column(Float, nullable=True)
source = Column(String(30), nullable=True) # yahoo_finance | alpha_vantage | ig_markets
__table_args__ = (
UniqueConstraint("symbol", "timeframe", "timestamp", name="uq_ohlcv"),
Index("ix_ohlcv_symbol_tf_ts", "symbol", "timeframe", "timestamp"),
)
def __repr__(self) -> str:
return f"<OHLCV {self.symbol} {self.timeframe} {self.timestamp} C={self.close}>"
# =============================================================================
# BACKTESTING
# =============================================================================
class BacktestResult(Base):
"""Résultat d'un run de backtesting."""
__tablename__ = "backtest_results"
id = Column(Integer, primary_key=True, autoincrement=True)
strategy = Column(String(50), nullable=False)
symbol = Column(String(20), nullable=False)
period = Column(String(10), nullable=False) # 6m | 1y | 2y
initial_capital = Column(Float, nullable=False)
final_capital = Column(Float, nullable=False)
# Métriques de performance
total_return = Column(Float, nullable=False)
sharpe_ratio = Column(Float, nullable=False)
max_drawdown = Column(Float, nullable=False)
win_rate = Column(Float, nullable=False)
profit_factor = Column(Float, nullable=False)
total_trades = Column(Integer, nullable=False)
calmar_ratio = Column(Float, nullable=True)
sortino_ratio = Column(Float, nullable=True)
# Validation
is_valid = Column(Boolean, default=False) # Sharpe > 1.5, DD < 10%, etc.
# Paramètres utilisés
params = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self) -> str:
return (
f"<BacktestResult {self.strategy}/{self.symbol} "
f"Sharpe={self.sharpe_ratio:.2f} DD={self.max_drawdown:.1%}>"
)
# =============================================================================
# MACHINE LEARNING
# =============================================================================
class MLModelMeta(Base):
"""Métadonnées d'un modèle ML entraîné."""
__tablename__ = "ml_models"
id = Column(Integer, primary_key=True, autoincrement=True)
model_name = Column(String(50), nullable=False) # xgboost | lightgbm | catboost | hmm_regime
strategy = Column(String(50), nullable=True)
version = Column(Integer, nullable=False, default=1)
# Métriques d'entraînement
train_sharpe = Column(Float, nullable=True)
val_sharpe = Column(Float, nullable=True)
train_accuracy = Column(Float, nullable=True)
val_accuracy = Column(Float, nullable=True)
# Paramètres
hyperparams = Column(JSON, nullable=True)
feature_names = Column(JSON, nullable=True) # liste des features utilisées
# Chemin fichier modèle sérialisé
file_path = Column(String(255), nullable=True)
trained_at = Column(DateTime, default=datetime.utcnow)
is_active = Column(Boolean, default=False)
__table_args__ = (
Index("ix_ml_models_name_version", "model_name", "version"),
)
def __repr__(self) -> str:
return f"<MLModelMeta {self.model_name} v{self.version} active={self.is_active}>"
class OptimizationRun(Base):
"""Historique des runs d'optimisation Optuna."""
__tablename__ = "optimization_runs"
id = Column(Integer, primary_key=True, autoincrement=True)
strategy = Column(String(50), nullable=False)
n_trials = Column(Integer, nullable=False)
best_sharpe = Column(Float, nullable=True)
best_params = Column(JSON, nullable=True)
drift_detected = Column(Boolean, default=False)
duration_secs = Column(Float, nullable=True)
started_at = Column(DateTime, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
def __repr__(self) -> str:
return f"<OptimizationRun {self.strategy} trials={self.n_trials} sharpe={self.best_sharpe}>"

145
src/db/session.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Session SQLAlchemy - Trading AI Secure.
Fournit :
- `engine` : connexion à la base de données
- `SessionLocal` : factory de sessions
- `get_db()` : dependency FastAPI (contextmanager)
- `init_db()` : création des tables au démarrage
"""
import os
import logging
from contextlib import contextmanager
from typing import Generator
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import sessionmaker, Session
from src.db.models import Base
logger = logging.getLogger(__name__)
# URL de connexion depuis env var (Docker) ou valeur par défaut (dev local)
DATABASE_URL: str = os.environ.get(
"DATABASE_URL",
"postgresql://trading:trading@localhost:5432/trading_db"
)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True, # Détecte connexions mortes avant utilisation
pool_size=10,
max_overflow=20,
echo=False, # Passer à True pour déboguer les requêtes SQL
)
SessionLocal = sessionmaker(
bind=engine,
autocommit=False,
autoflush=False,
)
# =============================================================================
# Dependency FastAPI
# =============================================================================
def get_db() -> Generator[Session, None, None]:
"""
Dependency FastAPI pour obtenir une session DB.
Usage dans un router :
@router.get("/trades")
def list_trades(db: Session = Depends(get_db)):
return db.query(Trade).all()
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
@contextmanager
def db_session() -> Generator[Session, None, None]:
"""
Context manager pour utilisation hors FastAPI.
Usage :
with db_session() as db:
db.add(trade)
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
# =============================================================================
# Initialisation
# =============================================================================
def init_db():
"""
Crée toutes les tables si elles n'existent pas.
À appeler au démarrage de l'application.
Pour la production, préférer Alembic pour les migrations.
"""
logger.info("Initializing database tables...")
Base.metadata.create_all(bind=engine)
logger.info("Database tables ready")
_create_timescale_hypertable()
def _create_timescale_hypertable():
"""
Crée la hypertable TimescaleDB pour ohlcv si l'extension est disponible.
Silencieuse si TimescaleDB n'est pas installé (PostgreSQL standard).
"""
try:
with engine.connect() as conn:
# Vérifier si TimescaleDB est disponible
result = conn.execute(
text("SELECT extname FROM pg_extension WHERE extname = 'timescaledb'")
)
if result.fetchone():
conn.execute(
text(
"SELECT create_hypertable('ohlcv', 'timestamp', "
"if_not_exists => TRUE, migrate_data => TRUE)"
)
)
conn.commit()
logger.info("TimescaleDB hypertable 'ohlcv' ready")
else:
logger.info("TimescaleDB not detected — using standard PostgreSQL")
except Exception as e:
logger.debug(f"Hypertable setup skipped: {e}")
def check_db_connection() -> bool:
"""
Vérifie la connexion à la base de données.
Returns:
True si la connexion fonctionne
"""
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"DB connection failed: {e}")
return False

401
src/main.py Normal file
View File

@@ -0,0 +1,401 @@
"""
Point d'entrée principal de l'application Trading AI Secure.
Ce script permet de lancer l'application en différents modes:
- backtest: Backtesting sur données historiques
- paper: Paper trading en temps réel
- live: Trading réel (après validation)
- optimize: Optimisation des paramètres
Usage:
python src/main.py --mode backtest --strategy intraday --symbol EURUSD
python src/main.py --mode paper --strategy all
python src/main.py --mode optimize --strategy scalping
"""
import argparse
import asyncio
import sys
from pathlib import Path
from typing import Optional
# Ajouter le répertoire parent au PYTHONPATH
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.core.strategy_engine import StrategyEngine
from src.core.risk_manager import RiskManager
from src.utils.logger import setup_logger, get_logger
from src.utils.config_loader import ConfigLoader
logger = get_logger(__name__)
class TradingApplication:
"""
Application principale de trading.
Gère le cycle de vie complet de l'application:
- Initialisation des composants
- Chargement de la configuration
- Lancement du mode sélectionné
- Gestion des erreurs et shutdown gracieux
"""
def __init__(self, args: argparse.Namespace):
"""
Initialise l'application.
Args:
args: Arguments de ligne de commande
"""
self.args = args
self.config = None
self.strategy_engine = None
self.risk_manager = None
async def initialize(self):
"""Initialise tous les composants."""
logger.info("=" * 60)
logger.info("Trading AI Secure - Initialisation")
logger.info("=" * 60)
# Charger configuration
logger.info("Chargement de la configuration...")
self.config = ConfigLoader.load_all()
# Initialiser Risk Manager (Singleton)
logger.info("Initialisation du Risk Manager...")
self.risk_manager = RiskManager()
self.risk_manager.initialize(self.config['risk_limits'])
# Initialiser Strategy Engine
logger.info("Initialisation du Strategy Engine...")
self.strategy_engine = StrategyEngine(
config=self.config['strategy_params'],
risk_manager=self.risk_manager
)
# Charger stratégies selon arguments
await self._load_strategies()
logger.info("Initialisation terminée avec succès!")
async def _load_strategies(self):
"""Charge les stratégies selon les arguments."""
strategy_name = self.args.strategy
if strategy_name == 'all':
strategies = ['scalping', 'intraday', 'swing']
else:
strategies = [strategy_name]
for strategy in strategies:
logger.info(f"Chargement de la stratégie: {strategy}")
await self.strategy_engine.load_strategy(strategy)
async def run_backtest(self):
"""Lance le backtesting."""
logger.info("=" * 60)
logger.info("MODE: BACKTESTING")
logger.info("=" * 60)
from src.backtesting.backtest_engine import BacktestEngine
# Créer engine de backtesting
backtest_engine = BacktestEngine(
strategy_engine=self.strategy_engine,
config=self.config['backtesting']
)
# Paramètres backtesting
symbols = self.args.symbol.split(',') if self.args.symbol else ['EURUSD']
period = self.args.period or '1y'
logger.info(f"Symboles: {symbols}")
logger.info(f"Période: {period}")
logger.info(f"Capital initial: ${self.args.initial_capital:,.2f}")
# Lancer backtesting
results = await backtest_engine.run(
symbols=symbols,
period=period,
initial_capital=self.args.initial_capital
)
# Afficher résultats
self._display_backtest_results(results)
async def run_paper_trading(self):
"""Lance le paper trading."""
logger.info("=" * 60)
logger.info("MODE: PAPER TRADING")
logger.info("=" * 60)
from src.backtesting.paper_trading import PaperTradingEngine
# Créer engine de paper trading
paper_engine = PaperTradingEngine(
strategy_engine=self.strategy_engine,
initial_capital=self.args.initial_capital
)
logger.info("Démarrage du paper trading...")
logger.info("Appuyez sur Ctrl+C pour arrêter")
try:
await paper_engine.run()
except KeyboardInterrupt:
logger.info("\nArrêt du paper trading...")
await paper_engine.stop()
# Afficher résumé
summary = paper_engine.get_summary()
self._display_paper_trading_summary(summary)
async def run_live_trading(self):
"""Lance le trading réel."""
logger.warning("=" * 60)
logger.warning("MODE: LIVE TRADING")
logger.warning("⚠️ TRADING AVEC ARGENT RÉEL!")
logger.warning("=" * 60)
# Vérifications de sécurité
if not self._verify_live_trading_requirements():
logger.error("Exigences pour live trading non satisfaites!")
return
# Confirmation utilisateur
confirmation = input("\nÊtes-vous sûr de vouloir trader en LIVE? (tapez 'YES' pour confirmer): ")
if confirmation != 'YES':
logger.info("Live trading annulé.")
return
logger.info("Démarrage du live trading...")
try:
await self.strategy_engine.run_live()
except KeyboardInterrupt:
logger.info("\nArrêt du live trading...")
await self.strategy_engine.stop()
async def run_optimization(self):
"""Lance l'optimisation des paramètres."""
logger.info("=" * 60)
logger.info("MODE: OPTIMISATION")
logger.info("=" * 60)
from src.ml.model_optimizer import ParameterOptimizer
optimizer = ParameterOptimizer(
strategy_name=self.args.strategy,
config=self.config
)
logger.info(f"Optimisation de la stratégie: {self.args.strategy}")
logger.info("Cela peut prendre plusieurs heures...")
# Lancer optimisation
best_params = await optimizer.optimize(
n_trials=self.args.n_trials or 100
)
logger.info("Optimisation terminée!")
logger.info(f"Meilleurs paramètres: {best_params}")
def _verify_live_trading_requirements(self) -> bool:
"""
Vérifie que toutes les exigences pour le live trading sont satisfaites.
Returns:
True si toutes les exigences sont satisfaites
"""
requirements = {
'paper_trading_days': 30,
'min_sharpe_ratio': 1.5,
'max_drawdown': 0.10,
'min_win_rate': 0.55,
'min_trades': 50
}
# TODO: Implémenter vérifications réelles
logger.warning("⚠️ Vérifications live trading non encore implémentées!")
return False
def _display_backtest_results(self, results: dict):
"""Affiche les résultats du backtesting."""
logger.info("\n" + "=" * 60)
logger.info("RÉSULTATS DU BACKTESTING")
logger.info("=" * 60)
logger.info(f"Return Total: {results['total_return']:>10.2%}")
logger.info(f"Sharpe Ratio: {results['sharpe_ratio']:>10.2f}")
logger.info(f"Max Drawdown: {results['max_drawdown']:>10.2%}")
logger.info(f"Win Rate: {results['win_rate']:>10.2%}")
logger.info(f"Profit Factor: {results['profit_factor']:>10.2f}")
logger.info(f"Total Trades: {results['total_trades']:>10}")
logger.info("=" * 60)
# Vérifier si stratégie est valide pour production
if self._is_strategy_valid(results):
logger.info("✅ Stratégie VALIDE pour paper trading!")
else:
logger.warning("❌ Stratégie NON VALIDE - Optimisation nécessaire")
def _display_paper_trading_summary(self, summary: dict):
"""Affiche le résumé du paper trading."""
logger.info("\n" + "=" * 60)
logger.info("RÉSUMÉ PAPER TRADING")
logger.info("=" * 60)
logger.info(f"Durée: {summary['duration_days']} jours")
logger.info(f"Return Total: {summary['total_return']:>10.2%}")
logger.info(f"Sharpe Ratio: {summary['sharpe_ratio']:>10.2f}")
logger.info(f"Max Drawdown: {summary['max_drawdown']:>10.2%}")
logger.info(f"Win Rate: {summary['win_rate']:>10.2%}")
logger.info(f"Total Trades: {summary['total_trades']:>10}")
logger.info("=" * 60)
def _is_strategy_valid(self, results: dict) -> bool:
"""Vérifie si la stratégie satisfait les critères minimaux."""
return (
results['sharpe_ratio'] >= 1.5 and
results['max_drawdown'] <= 0.10 and
results['win_rate'] >= 0.55 and
results['profit_factor'] >= 1.3
)
async def run(self):
"""Lance l'application selon le mode sélectionné."""
try:
# Initialiser
await self.initialize()
# Lancer mode approprié
mode = self.args.mode
if mode == 'backtest':
await self.run_backtest()
elif mode == 'paper':
await self.run_paper_trading()
elif mode == 'live':
await self.run_live_trading()
elif mode == 'optimize':
await self.run_optimization()
else:
logger.error(f"Mode inconnu: {mode}")
except Exception as e:
logger.exception(f"Erreur fatale: {e}")
raise
finally:
logger.info("Arrêt de l'application...")
def parse_arguments() -> argparse.Namespace:
"""Parse les arguments de ligne de commande."""
parser = argparse.ArgumentParser(
description='Trading AI Secure - Application de Trading Algorithmique',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Exemples:
# Backtesting
python src/main.py --mode backtest --strategy intraday --symbol EURUSD --period 1y
# Paper trading
python src/main.py --mode paper --strategy all
# Optimisation
python src/main.py --mode optimize --strategy scalping --n-trials 100
# Live trading (après validation)
python src/main.py --mode live --strategy intraday
"""
)
# Arguments obligatoires
parser.add_argument(
'--mode',
type=str,
required=True,
choices=['backtest', 'paper', 'live', 'optimize'],
help='Mode de fonctionnement'
)
parser.add_argument(
'--strategy',
type=str,
required=True,
choices=['scalping', 'intraday', 'swing', 'all'],
help='Stratégie à utiliser'
)
# Arguments optionnels
parser.add_argument(
'--symbol',
type=str,
default='EURUSD',
help='Symbole(s) à trader (séparés par virgule)'
)
parser.add_argument(
'--period',
type=str,
default='1y',
help='Période pour backtesting (ex: 6m, 1y, 2y)'
)
parser.add_argument(
'--initial-capital',
type=float,
default=10000.0,
help='Capital initial en USD'
)
parser.add_argument(
'--n-trials',
type=int,
default=100,
help='Nombre de trials pour optimisation'
)
parser.add_argument(
'--log-level',
type=str,
default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
help='Niveau de logging'
)
parser.add_argument(
'--dashboard',
action='store_true',
help='Lancer dashboard Streamlit en parallèle'
)
return parser.parse_args()
async def main():
"""Fonction principale."""
# Parser arguments
args = parse_arguments()
# Setup logging
setup_logger(level=args.log_level)
# Créer et lancer application
app = TradingApplication(args)
await app.run()
if __name__ == '__main__':
# Lancer application
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("\nApplication interrompue par l'utilisateur")
except Exception as e:
logger.exception(f"Erreur fatale: {e}")
sys.exit(1)

27
src/ml/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Module ML - Machine Learning et IA Adaptative.
Ce module contient tous les composants d'intelligence artificielle:
- MLEngine: Moteur ML principal
- RegimeDetector: Détection régimes de marché (HMM)
- ParameterOptimizer: Optimisation paramètres (Optuna)
- FeatureEngineering: Engineering de features
- PositionSizingML: Sizing adaptatif
- WalkForwardAnalyzer: Validation robuste
"""
from src.ml.ml_engine import MLEngine
from src.ml.regime_detector import RegimeDetector
from src.ml.parameter_optimizer import ParameterOptimizer
from src.ml.feature_engineering import FeatureEngineering
from src.ml.position_sizing import PositionSizingML
from src.ml.walk_forward import WalkForwardAnalyzer
__all__ = [
'MLEngine',
'RegimeDetector',
'ParameterOptimizer',
'FeatureEngineering',
'PositionSizingML',
'WalkForwardAnalyzer',
]

View File

@@ -0,0 +1,422 @@
"""
Feature Engineering - Création de Features pour ML.
Ce module crée des features avancées pour améliorer les modèles ML:
- Technical indicators
- Statistical features
- Market microstructure
- Sentiment features
- Time-based features
"""
from typing import Dict, List, Optional
import pandas as pd
import numpy as np
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class FeatureEngineering:
"""
Créateur de features pour machine learning.
Génère des features avancées à partir de données OHLCV:
- Indicateurs techniques (50+)
- Features statistiques
- Features de microstructure
- Features temporelles
Usage:
fe = FeatureEngineering()
features = fe.create_all_features(data)
"""
def __init__(self, config: Optional[Dict] = None):
"""
Initialise le feature engineer.
Args:
config: Configuration optionnelle
"""
self.config = config or {}
self.feature_names = []
logger.info("FeatureEngineering initialized")
def create_all_features(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Crée toutes les features.
Args:
data: DataFrame avec OHLCV
Returns:
DataFrame avec toutes les features
"""
logger.info("Creating all features...")
df = data.copy()
# 1. Price-based features
df = self._create_price_features(df)
# 2. Technical indicators
df = self._create_technical_indicators(df)
# 3. Statistical features
df = self._create_statistical_features(df)
# 4. Volatility features
df = self._create_volatility_features(df)
# 5. Volume features
df = self._create_volume_features(df)
# 6. Time-based features
df = self._create_time_features(df)
# 7. Microstructure features
df = self._create_microstructure_features(df)
# Supprimer NaN
df = df.dropna()
# Sauvegarder noms de features
self.feature_names = [col for col in df.columns if col not in ['open', 'high', 'low', 'close', 'volume']]
logger.info(f"✅ Created {len(self.feature_names)} features")
return df
def _create_price_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée features basées sur les prix."""
# Returns
df['returns'] = df['close'].pct_change()
df['log_returns'] = np.log(df['close'] / df['close'].shift(1))
# Returns multiples périodes
for period in [5, 10, 20]:
df[f'returns_{period}'] = df['close'].pct_change(period)
# Price ratios
df['high_low_ratio'] = df['high'] / df['low']
df['close_open_ratio'] = df['close'] / df['open']
# Price position in range
df['price_position'] = (df['close'] - df['low']) / (df['high'] - df['low'])
return df
def _create_technical_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée indicateurs techniques."""
# Moving Averages
for period in [5, 10, 20, 50, 100, 200]:
df[f'sma_{period}'] = df['close'].rolling(period).mean()
df[f'ema_{period}'] = df['close'].ewm(span=period, adjust=False).mean()
# MA crossovers
df['sma_cross_5_20'] = (df['sma_5'] > df['sma_20']).astype(int)
df['sma_cross_20_50'] = (df['sma_20'] > df['sma_50']).astype(int)
# Distance from MAs
for period in [20, 50, 200]:
df[f'dist_sma_{period}'] = (df['close'] - df[f'sma_{period}']) / df[f'sma_{period}']
# RSI
for period in [7, 14, 21]:
df[f'rsi_{period}'] = self._calculate_rsi(df['close'], period)
# MACD
df['macd'], df['macd_signal'], df['macd_hist'] = self._calculate_macd(df['close'])
# Bollinger Bands
for period in [20, 50]:
bb_upper, bb_middle, bb_lower = self._calculate_bollinger_bands(df['close'], period)
df[f'bb_upper_{period}'] = bb_upper
df[f'bb_middle_{period}'] = bb_middle
df[f'bb_lower_{period}'] = bb_lower
df[f'bb_width_{period}'] = (bb_upper - bb_lower) / bb_middle
df[f'bb_position_{period}'] = (df['close'] - bb_lower) / (bb_upper - bb_lower)
# Stochastic
df['stoch_k'], df['stoch_d'] = self._calculate_stochastic(df)
# ADX
df['adx'] = self._calculate_adx(df)
# ATR
for period in [7, 14, 21]:
df[f'atr_{period}'] = self._calculate_atr(df, period)
return df
def _create_statistical_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée features statistiques."""
# Rolling statistics
for period in [10, 20, 50]:
df[f'mean_{period}'] = df['close'].rolling(period).mean()
df[f'std_{period}'] = df['close'].rolling(period).std()
df[f'skew_{period}'] = df['close'].rolling(period).skew()
df[f'kurt_{period}'] = df['close'].rolling(period).kurt()
# Z-score
df[f'zscore_{period}'] = (df['close'] - df[f'mean_{period}']) / df[f'std_{period}']
# Percentile rank
for period in [20, 50]:
df[f'percentile_{period}'] = df['close'].rolling(period).apply(
lambda x: pd.Series(x).rank(pct=True).iloc[-1]
)
return df
def _create_volatility_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée features de volatilité."""
# Historical volatility
for period in [10, 20, 50]:
df[f'volatility_{period}'] = df['returns'].rolling(period).std() * np.sqrt(252)
# Parkinson volatility (high-low)
df['parkinson_vol'] = np.sqrt(
(1 / (4 * np.log(2))) *
((np.log(df['high'] / df['low'])) ** 2).rolling(20).mean()
) * np.sqrt(252)
# Garman-Klass volatility
df['gk_vol'] = np.sqrt(
0.5 * ((np.log(df['high'] / df['low'])) ** 2).rolling(20).mean() -
(2 * np.log(2) - 1) * ((np.log(df['close'] / df['open'])) ** 2).rolling(20).mean()
) * np.sqrt(252)
# Volatility ratio
df['vol_ratio'] = df['volatility_10'] / df['volatility_50']
return df
def _create_volume_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée features de volume."""
# Volume moving averages
for period in [5, 10, 20]:
df[f'volume_ma_{period}'] = df['volume'].rolling(period).mean()
# Volume ratio
df['volume_ratio'] = df['volume'] / df['volume_ma_20']
# Volume change
df['volume_change'] = df['volume'].pct_change()
# On-Balance Volume (OBV)
df['obv'] = (np.sign(df['close'].diff()) * df['volume']).cumsum()
# Volume-weighted average price (VWAP)
df['vwap'] = (df['close'] * df['volume']).cumsum() / df['volume'].cumsum()
# Money Flow Index (MFI)
df['mfi'] = self._calculate_mfi(df)
return df
def _create_time_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée features temporelles."""
if not isinstance(df.index, pd.DatetimeIndex):
return df
# Hour of day
df['hour'] = df.index.hour
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24)
df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)
# Day of week
df['day_of_week'] = df.index.dayofweek
df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)
# Month
df['month'] = df.index.month
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
# Is market open (approximation)
df['is_market_hours'] = ((df['hour'] >= 9) & (df['hour'] <= 16)).astype(int)
return df
def _create_microstructure_features(self, df: pd.DataFrame) -> pd.DataFrame:
"""Crée features de microstructure."""
# Spread
df['spread'] = df['high'] - df['low']
df['spread_pct'] = df['spread'] / df['close']
# Amihud illiquidity
df['amihud'] = abs(df['returns']) / (df['volume'] * df['close'])
# Roll measure (bid-ask spread estimator)
df['roll'] = 2 * np.sqrt(abs(df['returns'].rolling(2).cov(df['returns'].shift(1))))
# Price impact
df['price_impact'] = abs(df['returns']) / df['volume_ratio']
return df
# Helper methods for indicators
def _calculate_rsi(self, prices: pd.Series, period: int = 14) -> pd.Series:
"""Calcule RSI."""
delta = prices.diff()
gain = delta.where(delta > 0, 0).rolling(period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
def _calculate_macd(
self,
prices: pd.Series,
fast: int = 12,
slow: int = 26,
signal: int = 9
) -> tuple:
"""Calcule MACD."""
ema_fast = prices.ewm(span=fast, adjust=False).mean()
ema_slow = prices.ewm(span=slow, adjust=False).mean()
macd = ema_fast - ema_slow
macd_signal = macd.ewm(span=signal, adjust=False).mean()
macd_hist = macd - macd_signal
return macd, macd_signal, macd_hist
def _calculate_bollinger_bands(
self,
prices: pd.Series,
period: int = 20,
std: float = 2.0
) -> tuple:
"""Calcule Bollinger Bands."""
middle = prices.rolling(period).mean()
std_dev = prices.rolling(period).std()
upper = middle + (std * std_dev)
lower = middle - (std * std_dev)
return upper, middle, lower
def _calculate_stochastic(
self,
df: pd.DataFrame,
period: int = 14,
smooth_k: int = 3,
smooth_d: int = 3
) -> tuple:
"""Calcule Stochastic Oscillator."""
low_min = df['low'].rolling(period).min()
high_max = df['high'].rolling(period).max()
k = 100 * (df['close'] - low_min) / (high_max - low_min)
k = k.rolling(smooth_k).mean()
d = k.rolling(smooth_d).mean()
return k, d
def _calculate_adx(self, df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Calcule ADX."""
high_diff = df['high'].diff()
low_diff = -df['low'].diff()
pos_dm = np.where((high_diff > low_diff) & (high_diff > 0), high_diff, 0)
neg_dm = np.where((low_diff > high_diff) & (low_diff > 0), low_diff, 0)
tr = pd.DataFrame({
'hl': df['high'] - df['low'],
'hc': abs(df['high'] - df['close'].shift(1)),
'lc': abs(df['low'] - df['close'].shift(1))
}).max(axis=1)
atr = tr.rolling(period).mean()
pos_di = 100 * pd.Series(pos_dm).rolling(period).mean() / atr
neg_di = 100 * pd.Series(neg_dm).rolling(period).mean() / atr
dx = 100 * abs(pos_di - neg_di) / (pos_di + neg_di)
adx = dx.rolling(period).mean()
return adx
def _calculate_atr(self, df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Calcule ATR."""
tr = pd.DataFrame({
'hl': df['high'] - df['low'],
'hc': abs(df['high'] - df['close'].shift(1)),
'lc': abs(df['low'] - df['close'].shift(1))
}).max(axis=1)
return tr.rolling(period).mean()
def _calculate_mfi(self, df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Calcule Money Flow Index."""
typical_price = (df['high'] + df['low'] + df['close']) / 3
money_flow = typical_price * df['volume']
positive_flow = money_flow.where(typical_price > typical_price.shift(1), 0).rolling(period).sum()
negative_flow = money_flow.where(typical_price < typical_price.shift(1), 0).rolling(period).sum()
mfi = 100 - (100 / (1 + positive_flow / negative_flow))
return mfi
def get_feature_importance(
self,
features: pd.DataFrame,
target: pd.Series,
method: str = 'mutual_info'
) -> pd.DataFrame:
"""
Calcule l'importance des features.
Args:
features: DataFrame de features
target: Target variable
method: Méthode ('mutual_info', 'correlation')
Returns:
DataFrame avec importance des features
"""
from sklearn.feature_selection import mutual_info_regression
if method == 'mutual_info':
# Mutual information
mi_scores = mutual_info_regression(features, target)
importance = pd.DataFrame({
'feature': features.columns,
'importance': mi_scores
}).sort_values('importance', ascending=False)
elif method == 'correlation':
# Correlation
correlations = features.corrwith(target).abs()
importance = pd.DataFrame({
'feature': correlations.index,
'importance': correlations.values
}).sort_values('importance', ascending=False)
return importance
def select_top_features(
self,
features: pd.DataFrame,
target: pd.Series,
n_features: int = 50
) -> List[str]:
"""
Sélectionne les meilleures features.
Args:
features: DataFrame de features
target: Target variable
n_features: Nombre de features à sélectionner
Returns:
Liste des meilleures features
"""
importance = self.get_feature_importance(features, target)
top_features = importance.head(n_features)['feature'].tolist()
logger.info(f"Selected top {n_features} features")
return top_features

211
src/ml/ml_engine.py Normal file
View File

@@ -0,0 +1,211 @@
"""
ML Engine - Moteur Principal de Machine Learning.
Coordonne tous les composants ML:
- Détection de régimes
- Optimisation de paramètres
- Adaptation en temps réel
- Apprentissage continu
"""
from typing import Dict, Optional
import pandas as pd
import logging
from src.ml.regime_detector import RegimeDetector
from src.ml.parameter_optimizer import ParameterOptimizer
logger = logging.getLogger(__name__)
class MLEngine:
"""
Moteur ML principal.
Coordonne l'intelligence artificielle adaptative:
- Détecte les régimes de marché
- Optimise les paramètres
- Adapte les stratégies en temps réel
- Apprend continuellement
Usage:
ml_engine = MLEngine(config)
ml_engine.initialize(historical_data)
adapted_params = ml_engine.adapt_parameters(current_data, strategy)
"""
def __init__(self, config: Dict):
"""
Initialise le ML Engine.
Args:
config: Configuration ML
"""
self.config = config
# Composants ML
self.regime_detector = None
self.parameter_optimizer = None
# État
self.current_regime = None
self.optimized_params = {}
logger.info("ML Engine initialized")
def initialize(self, historical_data: pd.DataFrame):
"""
Initialise les composants ML avec données historiques.
Args:
historical_data: Données historiques pour entraînement
"""
logger.info("Initializing ML components...")
# 1. Initialiser détecteur de régimes
logger.info("Training regime detector...")
self.regime_detector = RegimeDetector(n_regimes=4)
self.regime_detector.fit(historical_data)
# Détecter régime actuel
self.current_regime = self.regime_detector.predict_current_regime(historical_data)
regime_name = self.regime_detector.get_regime_name(self.current_regime)
logger.info(f"✅ Current market regime: {regime_name}")
# 2. Afficher statistiques régimes
stats = self.regime_detector.get_regime_statistics(historical_data)
logger.info("Regime distribution:")
for regime_name, pct in stats['regime_percentages'].items():
logger.info(f" {regime_name}: {pct:.1%}")
def adapt_parameters(
self,
current_data: pd.DataFrame,
strategy_name: str,
base_params: Dict
) -> Dict:
"""
Adapte les paramètres selon le régime actuel.
Args:
current_data: Données actuelles
strategy_name: Nom de la stratégie
base_params: Paramètres de base
Returns:
Paramètres adaptés
"""
if self.regime_detector is None:
logger.warning("Regime detector not initialized")
return base_params
# Détecter régime actuel
current_regime = self.regime_detector.predict_current_regime(current_data)
# Si régime a changé
if current_regime != self.current_regime:
old_regime = self.regime_detector.get_regime_name(self.current_regime)
new_regime = self.regime_detector.get_regime_name(current_regime)
logger.info(f"🔄 Regime change: {old_regime}{new_regime}")
self.current_regime = current_regime
# Adapter paramètres
adapted_params = self.regime_detector.adapt_strategy_parameters(
current_regime=current_regime,
base_params=base_params
)
return adapted_params
def should_trade(self, strategy_type: str) -> bool:
"""
Détermine si une stratégie devrait trader dans le régime actuel.
Args:
strategy_type: Type de stratégie
Returns:
True si devrait trader
"""
if self.regime_detector is None or self.current_regime is None:
return True # Par défaut, autoriser
should_trade = self.regime_detector.should_trade_in_regime(
regime=self.current_regime,
strategy_type=strategy_type
)
if not should_trade:
regime_name = self.regime_detector.get_regime_name(self.current_regime)
logger.info(f"⚠️ {strategy_type} should not trade in {regime_name} regime")
return should_trade
def optimize_strategy_parameters(
self,
strategy_class,
historical_data: pd.DataFrame,
n_trials: int = 100
) -> Dict:
"""
Optimise les paramètres d'une stratégie.
Args:
strategy_class: Classe de la stratégie
historical_data: Données historiques
n_trials: Nombre de trials
Returns:
Meilleurs paramètres
"""
logger.info(f"Optimizing parameters for {strategy_class.__name__}...")
# Créer optimiseur
optimizer = ParameterOptimizer(
strategy_class=strategy_class,
data=historical_data
)
# Optimiser
results = optimizer.optimize(n_trials=n_trials)
# Sauvegarder
strategy_name = strategy_class.__name__.lower()
self.optimized_params[strategy_name] = results['best_params']
return results
def get_regime_info(self) -> Dict:
"""
Retourne les informations sur le régime actuel.
Returns:
Dictionnaire avec infos régime
"""
if self.regime_detector is None or self.current_regime is None:
return {
'regime': None,
'regime_name': 'Unknown',
'confidence': 0.0
}
return {
'regime': self.current_regime,
'regime_name': self.regime_detector.get_regime_name(self.current_regime),
}
def update_with_new_data(self, new_data: pd.DataFrame):
"""
Met à jour les modèles avec nouvelles données.
Args:
new_data: Nouvelles données
"""
if self.regime_detector is None:
return
# Re-détecter régime
self.current_regime = self.regime_detector.predict_current_regime(new_data)
logger.debug(f"Regime updated: {self.regime_detector.get_regime_name(self.current_regime)}")

View File

@@ -0,0 +1,414 @@
"""
Parameter Optimizer - Optimisation des Paramètres avec Optuna.
Optimise automatiquement les paramètres des stratégies en utilisant
Optuna pour éviter l'overfitting:
- Bayesian optimization (TPE)
- Walk-forward validation out-of-sample
- Vraie simulation signal→SL/TP (plus de random)
- Pruning pour accélérer
"""
from typing import Dict, List, Optional
import pandas as pd
import numpy as np
from datetime import datetime
import logging
try:
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
OPTUNA_AVAILABLE = True
except ImportError:
OPTUNA_AVAILABLE = False
logging.warning("optuna non installé. Installer avec : pip install optuna")
logger = logging.getLogger(__name__)
# Barres max par trial (compromis vitesse / précision)
_BACKTEST_BARS = 1200
class ParameterOptimizer:
"""
Optimiseur de paramètres utilisant Optuna.
Évalue chaque combinaison de paramètres via une vraie simulation
signal→SL/TP sur données historiques (pas de PnL aléatoire).
Usage:
optimizer = ParameterOptimizer(ScalpingStrategy, df)
result = optimizer.optimize(n_trials=50)
best_params = result['best_params']
"""
def __init__(
self,
strategy_class,
data: pd.DataFrame,
initial_capital: float = 10000.0,
):
"""
Initialise l'optimiseur.
Args:
strategy_class: Classe de la stratégie à optimiser
data: Données historiques OHLCV (index datetime)
initial_capital: Capital initial pour la simulation
"""
if not OPTUNA_AVAILABLE:
logger.error("Optuna non disponible !")
return
self.strategy_class = strategy_class
# Subset pour la vitesse : dernières _BACKTEST_BARS barres
self.data = (
data.iloc[-_BACKTEST_BARS:].copy()
if len(data) > _BACKTEST_BARS
else data.copy()
)
self.initial_capital = initial_capital
self.primary_metric = 'sharpe_ratio'
self.constraints = {
'min_sharpe': 0.0, # Positif suffit (filtre walk-forward après)
'max_drawdown': 0.20,
'min_win_rate': 0.40,
'min_trades': 5,
}
logger.info(
f"ParameterOptimizer initialisé pour {strategy_class.__name__} "
f"({len(self.data)} barres)"
)
# ------------------------------------------------------------------
# Interface publique
# ------------------------------------------------------------------
def optimize(
self,
n_trials: int = 50,
timeout: Optional[int] = None,
n_jobs: int = 1,
) -> Dict:
"""
Lance l'optimisation Optuna.
Args:
n_trials: Nombre de trials Optuna
timeout: Timeout en secondes (None = pas de limite)
n_jobs: Parallélisme (1 = séquentiel recommandé)
Returns:
{best_params, best_value, walk_forward_results, n_trials_done}
"""
if not OPTUNA_AVAILABLE:
return {}
logger.info("=" * 60)
logger.info("DÉMARRAGE OPTIMISATION PARAMÈTRES")
logger.info("=" * 60)
logger.info(f"Stratégie : {self.strategy_class.__name__}")
logger.info(f"Trials : {n_trials} | Données : {len(self.data)} barres")
study = optuna.create_study(
direction='maximize',
sampler=optuna.samplers.TPESampler(seed=42),
pruner=optuna.pruners.MedianPruner(n_warmup_steps=10),
)
study.optimize(
self._objective,
n_trials=n_trials,
timeout=timeout,
n_jobs=n_jobs,
show_progress_bar=False,
)
best_params = study.best_params
best_value = study.best_value
logger.info("=" * 60)
logger.info("OPTIMISATION TERMINÉE")
logger.info(f"Meilleur {self.primary_metric} : {best_value:.4f}")
logger.info(f"Meilleurs paramètres : {best_params}")
logger.info("Validation walk-forward en cours…")
wf_results = self._walk_forward_validation(best_params)
logger.info(
f"WF Sharpe moyen : {wf_results['avg_sharpe']:.2f} "
f"Stabilité : {wf_results['stability']:.2%}"
)
return {
'best_params': best_params,
'best_value': best_value,
'walk_forward_results': wf_results,
'n_trials_done': len(study.trials),
}
# ------------------------------------------------------------------
# Fonction objectif Optuna
# ------------------------------------------------------------------
def _objective(self, trial: 'optuna.Trial') -> float:
params = self._suggest_parameters(trial)
strategy = self.strategy_class(params)
metrics = self._backtest_strategy(strategy, self.data)
sharpe = metrics.get('sharpe_ratio', -999.0)
trial.report(sharpe, step=0)
if trial.should_prune():
raise optuna.exceptions.TrialPruned()
# Pénalité sévère uniquement si trop peu de trades (non significatif)
if metrics.get('total_trades', 0) < self.constraints['min_trades']:
return -999.0
# Pénaliser le drawdown excessif mais retourner le vrai sharpe sinon
if metrics.get('max_drawdown', 1.0) > self.constraints['max_drawdown']:
return sharpe - 5.0
return sharpe
# ------------------------------------------------------------------
# Suggestion de paramètres (aplatis dans le dict config)
# ------------------------------------------------------------------
def _suggest_parameters(self, trial: 'optuna.Trial') -> Dict:
"""
Suggère des paramètres passés directement à strategy_class(config).
Les paramètres sont aplatis (pas de clé 'adaptive_params' imbriquée).
"""
strategy_name = self.strategy_class.__name__.lower()
params: Dict = {
'name': strategy_name,
'timeframe': '1h',
'risk_per_trade': trial.suggest_float('risk_per_trade', 0.003, 0.015),
'max_holding_time': 28800,
}
if 'scalping' in strategy_name:
params.update({
'bb_period': trial.suggest_int('bb_period', 10, 30),
'bb_std': trial.suggest_float('bb_std', 1.5, 3.0),
'rsi_period': trial.suggest_int('rsi_period', 8, 21),
'rsi_oversold': trial.suggest_int('rsi_oversold', 20, 38),
'rsi_overbought': trial.suggest_int('rsi_overbought', 62, 82),
'volume_threshold': trial.suggest_float('volume_threshold', 1.0, 2.5),
'min_confidence': trial.suggest_float('min_confidence', 0.45, 0.80),
})
elif 'intraday' in strategy_name:
params.update({
'ema_fast': trial.suggest_int('ema_fast', 5, 15),
'ema_slow': trial.suggest_int('ema_slow', 15, 30),
'ema_trend': trial.suggest_int('ema_trend', 40, 60),
'atr_multiplier': trial.suggest_float('atr_multiplier', 1.5, 3.5),
'volume_confirmation': trial.suggest_float('volume_confirmation', 1.0, 1.5),
'min_confidence': trial.suggest_float('min_confidence', 0.45, 0.75),
'adx_threshold': trial.suggest_int('adx_threshold', 18, 35),
})
elif 'swing' in strategy_name:
params.update({
'sma_short': trial.suggest_int('sma_short', 15, 30),
'sma_long': trial.suggest_int('sma_long', 40, 60),
'rsi_period': trial.suggest_int('rsi_period', 10, 20),
'fibonacci_lookback': trial.suggest_int('fibonacci_lookback', 30, 70),
'min_confidence': trial.suggest_float('min_confidence', 0.40, 0.70),
'atr_multiplier': trial.suggest_float('atr_multiplier', 2.0, 4.0),
})
return params
# ------------------------------------------------------------------
# Simulation réelle signal → SL/TP
# ------------------------------------------------------------------
def _backtest_strategy(self, strategy, data: pd.DataFrame) -> Dict:
"""
Simule la stratégie sur `data` avec vraie logique SL/TP.
Une seule position ouverte à la fois. La position est clôturée
dès que le prix atteint SL ou TP sur la barre courante.
Clôture forcée à la dernière barre si position encore ouverte.
Returns:
{sharpe_ratio, max_drawdown, win_rate, total_trades, total_return}
"""
equity = self.initial_capital
equity_curve = [equity]
trades: List[Dict] = []
in_position = False
position = None # {entry, sl, tp, direction, size}
for i in range(50, len(data)):
bar = data.iloc[i]
# --- Gestion position ouverte ---
if in_position and position is not None:
closed, pnl = self._check_exit(position, bar)
if closed:
equity += pnl
trades.append({'pnl': pnl, 'win': pnl > 0})
in_position = False
position = None
equity_curve.append(equity)
continue # une seule position à la fois
# --- Recherche d'un signal ---
try:
hist = data.iloc[:i + 1]
signal = strategy.analyze(hist)
if signal is not None:
stop_dist = abs(signal.entry_price - signal.stop_loss)
if stop_dist > 0:
risk_amt = equity * getattr(strategy.config, 'risk_per_trade', 0.005)
size = risk_amt / stop_dist
in_position = True
position = {
'entry': signal.entry_price,
'sl': signal.stop_loss,
'tp': signal.take_profit,
'direction': signal.direction,
'size': size,
}
except Exception:
pass
equity_curve.append(equity)
# Clôture forcée au dernier close
if in_position and position is not None:
last_close = data.iloc[-1]['close']
if position['direction'] == 'LONG':
pnl = (last_close - position['entry']) * position['size']
else:
pnl = (position['entry'] - last_close) * position['size']
equity += pnl
trades.append({'pnl': pnl, 'win': pnl > 0})
return self._compute_metrics(equity, equity_curve, trades)
def _check_exit(self, position: Dict, bar: pd.Series):
"""
Vérifie si SL ou TP est atteint sur la barre courante.
Retourne (clôturé: bool, pnl: float).
"""
high = bar['high']
low = bar['low']
entry = position['entry']
sl = position['sl']
tp = position['tp']
size = position['size']
if position['direction'] == 'LONG':
if low <= sl:
return True, (sl - entry) * size
if high >= tp:
return True, (tp - entry) * size
else: # SHORT
if high >= sl:
return True, (entry - sl) * size
if low <= tp:
return True, (entry - tp) * size
return False, 0.0
def _compute_metrics(
self,
final_equity: float,
equity_curve: List,
trades: List[Dict],
) -> Dict:
"""Calcule les métriques de performance à partir des trades."""
if len(trades) == 0:
return {
'sharpe_ratio': -1.0,
'max_drawdown': 1.0,
'win_rate': 0.0,
'total_trades': 0,
'total_return': 0.0,
}
returns = pd.Series([t['pnl'] for t in trades])
sharpe = (
float(returns.mean() / returns.std() * np.sqrt(252))
if returns.std() > 0 else 0.0
)
win_rate = float(sum(1 for t in trades if t['win']) / len(trades))
equity_s = pd.Series(equity_curve)
running_max = equity_s.expanding().max()
max_dd = float(abs(((equity_s - running_max) / running_max).min()))
return {
'sharpe_ratio': sharpe,
'max_drawdown': max_dd,
'win_rate': win_rate,
'total_trades': len(trades),
'total_return': float((final_equity - self.initial_capital) / self.initial_capital),
}
# ------------------------------------------------------------------
# Vérification des contraintes
# ------------------------------------------------------------------
def _check_constraints(self, metrics: Dict) -> bool:
return (
metrics.get('sharpe_ratio', -999) >= self.constraints['min_sharpe'] and
metrics.get('max_drawdown', 1.0) <= self.constraints['max_drawdown'] and
metrics.get('win_rate', 0.0) >= self.constraints['min_win_rate'] and
metrics.get('total_trades', 0) >= self.constraints['min_trades']
)
# ------------------------------------------------------------------
# Walk-forward validation (out-of-sample)
# ------------------------------------------------------------------
def _walk_forward_validation(self, best_params: Dict, n_folds: int = 4) -> Dict:
"""
Valide les meilleurs paramètres sur des fenêtres out-of-sample.
Train sur fold i, test sur fold i+1 (glissant).
"""
sharpe_ratios = []
fold_size = len(self.data) // n_folds
for i in range(n_folds - 1):
test_data = self.data.iloc[(i + 1) * fold_size:(i + 2) * fold_size]
if len(test_data) < 60:
continue
try:
strategy = self.strategy_class(dict(best_params))
metrics = self._backtest_strategy(strategy, test_data)
sharpe_ratios.append(metrics['sharpe_ratio'])
except Exception as exc:
logger.warning(f"WF fold {i} échoué : {exc}")
if not sharpe_ratios:
return {
'avg_sharpe': 0.0,
'std_sharpe': 0.0,
'stability': 0.0,
'sharpe_ratios': [],
}
avg_sharpe = float(np.mean(sharpe_ratios))
std_sharpe = float(np.std(sharpe_ratios))
stability = float(
max(0.0, 1.0 - (std_sharpe / avg_sharpe if avg_sharpe > 0 else 1.0))
)
return {
'avg_sharpe': avg_sharpe,
'std_sharpe': std_sharpe,
'stability': stability,
'sharpe_ratios': sharpe_ratios,
}

321
src/ml/position_sizing.py Normal file
View File

@@ -0,0 +1,321 @@
"""
Position Sizing ML - Sizing Adaptatif avec Machine Learning.
Utilise ML pour optimiser la taille des positions:
- Kelly Criterion adaptatif
- Risk-adjusted sizing
- Volatility scaling
- Regime-based sizing
- Confidence-based sizing
"""
from typing import Dict, Optional
import pandas as pd
import numpy as np
from datetime import datetime
import logging
try:
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
SKLEARN_AVAILABLE = True
except ImportError:
SKLEARN_AVAILABLE = False
logging.warning("sklearn not installed")
logger = logging.getLogger(__name__)
class PositionSizingML:
"""
Position sizing adaptatif avec ML.
Optimise la taille des positions en utilisant:
- Historique de performance
- Conditions de marché actuelles
- Niveau de confiance du signal
- Volatilité
- Régime de marché
Usage:
sizer = PositionSizingML()
sizer.train(historical_trades)
size = sizer.calculate_position_size(signal, market_data)
"""
def __init__(self, config: Optional[Dict] = None):
"""
Initialise le position sizer.
Args:
config: Configuration optionnelle
"""
if not SKLEARN_AVAILABLE:
logger.error("sklearn not available!")
self.model = None
return
self.config = config or {}
# Modèle ML
self.model = RandomForestRegressor(
n_estimators=100,
max_depth=10,
random_state=42
)
self.scaler = StandardScaler()
self.is_trained = False
# Limites de sécurité
self.min_size = self.config.get('min_size', 0.001)
self.max_size = self.config.get('max_size', 0.05)
logger.info("PositionSizingML initialized")
def train(
self,
trades: pd.DataFrame,
market_data: pd.DataFrame
):
"""
Entraîne le modèle sur l'historique.
Args:
trades: DataFrame avec historique de trades
market_data: Données de marché correspondantes
"""
if not SKLEARN_AVAILABLE or self.model is None:
logger.error("Cannot train: sklearn not available")
return
logger.info("Training position sizing model...")
# Préparer features
X = self._prepare_features(trades, market_data)
# Target: optimal size basé sur résultat
y = self._calculate_optimal_sizes(trades)
# Normaliser features
X_scaled = self.scaler.fit_transform(X)
# Entraîner
self.model.fit(X_scaled, y)
self.is_trained = True
logger.info("✅ Position sizing model trained")
def calculate_position_size(
self,
signal: Dict,
market_data: pd.DataFrame,
portfolio_value: float,
current_volatility: float
) -> float:
"""
Calcule la taille de position optimale.
Args:
signal: Signal de trading
market_data: Données de marché actuelles
portfolio_value: Valeur du portfolio
current_volatility: Volatilité actuelle
Returns:
Taille de position (fraction du portfolio)
"""
if not self.is_trained:
# Fallback sur Kelly simple
return self._kelly_criterion(signal, current_volatility)
# Préparer features pour prédiction
features = self._prepare_signal_features(
signal,
market_data,
current_volatility
)
# Normaliser
features_scaled = self.scaler.transform([features])
# Prédire taille optimale
predicted_size = self.model.predict(features_scaled)[0]
# Appliquer limites de sécurité
size = np.clip(predicted_size, self.min_size, self.max_size)
# Ajuster selon confiance
size *= signal.get('confidence', 0.5)
logger.debug(f"Calculated position size: {size:.4f}")
return size
def _prepare_features(
self,
trades: pd.DataFrame,
market_data: pd.DataFrame
) -> np.ndarray:
"""
Prépare features pour entraînement.
Args:
trades: Historique de trades
market_data: Données de marché
Returns:
Array de features
"""
features = []
for _, trade in trades.iterrows():
# Features du signal
signal_features = [
trade.get('confidence', 0.5),
trade.get('risk_reward_ratio', 2.0),
trade.get('stop_distance_pct', 0.02),
]
# Features de marché (au moment du trade)
market_features = [
market_data.loc[trade['entry_time'], 'volatility'] if 'volatility' in market_data else 0.02,
market_data.loc[trade['entry_time'], 'volume_ratio'] if 'volume_ratio' in market_data else 1.0,
market_data.loc[trade['entry_time'], 'trend'] if 'trend' in market_data else 0.0,
]
# Features de performance récente
perf_features = [
trade.get('recent_win_rate', 0.5),
trade.get('recent_sharpe', 1.0),
]
features.append(signal_features + market_features + perf_features)
return np.array(features)
def _prepare_signal_features(
self,
signal: Dict,
market_data: pd.DataFrame,
current_volatility: float
) -> list:
"""
Prépare features pour un signal.
Args:
signal: Signal de trading
market_data: Données de marché
current_volatility: Volatilité actuelle
Returns:
Liste de features
"""
# Features du signal
signal_features = [
signal.get('confidence', 0.5),
abs(signal['take_profit'] - signal['entry_price']) / abs(signal['stop_loss'] - signal['entry_price']),
abs(signal['stop_loss'] - signal['entry_price']) / signal['entry_price'],
]
# Features de marché
market_features = [
current_volatility,
market_data['volume'].iloc[-1] / market_data['volume'].rolling(20).mean().iloc[-1],
1.0 if market_data['close'].iloc[-1] > market_data['close'].rolling(50).mean().iloc[-1] else -1.0,
]
# Features de performance (placeholder)
perf_features = [
0.5, # recent_win_rate
1.0, # recent_sharpe
]
return signal_features + market_features + perf_features
def _calculate_optimal_sizes(self, trades: pd.DataFrame) -> np.ndarray:
"""
Calcule les tailles optimales rétrospectivement.
Args:
trades: Historique de trades
Returns:
Array de tailles optimales
"""
optimal_sizes = []
for _, trade in trades.iterrows():
# Taille optimale basée sur résultat
if trade['pnl'] > 0:
# Trade gagnant: aurait pu être plus gros
optimal = min(0.05, trade.get('size', 0.02) * 1.5)
else:
# Trade perdant: aurait dû être plus petit
optimal = max(0.001, trade.get('size', 0.02) * 0.5)
optimal_sizes.append(optimal)
return np.array(optimal_sizes)
def _kelly_criterion(
self,
signal: Dict,
current_volatility: float,
win_rate: float = 0.5,
avg_win: float = 1.0,
avg_loss: float = 1.0
) -> float:
"""
Calcule Kelly Criterion classique.
Args:
signal: Signal de trading
current_volatility: Volatilité actuelle
win_rate: Taux de réussite
avg_win: Gain moyen
avg_loss: Perte moyenne
Returns:
Fraction Kelly
"""
# Kelly de base
if avg_loss != 0:
kelly = (win_rate * avg_win - (1 - win_rate) * avg_loss) / avg_win
else:
kelly = 0.25
# Ajuster selon volatilité
vol_adjustment = 0.02 / max(current_volatility, 0.01)
kelly *= vol_adjustment
# Ajuster selon confiance
kelly *= signal.get('confidence', 0.5)
# Limiter
kelly = np.clip(kelly, self.min_size, self.max_size)
return kelly
def get_sizing_statistics(self, trades: pd.DataFrame) -> Dict:
"""
Calcule des statistiques sur le sizing.
Args:
trades: Historique de trades
Returns:
Dictionnaire avec statistiques
"""
sizes = trades['size'].values if 'size' in trades else []
if len(sizes) == 0:
return {}
return {
'avg_size': np.mean(sizes),
'median_size': np.median(sizes),
'min_size': np.min(sizes),
'max_size': np.max(sizes),
'std_size': np.std(sizes),
}

369
src/ml/regime_detector.py Normal file
View File

@@ -0,0 +1,369 @@
"""
Regime Detector - Détection des Régimes de Marché.
Utilise Hidden Markov Models (HMM) pour détecter automatiquement
les différents régimes de marché:
- Trending (haussier/baissier)
- Ranging (sideways)
- Volatile
- Calm
Permet d'adapter les stratégies selon le régime actuel.
"""
from typing import Dict, List, Optional, Tuple
import pandas as pd
import numpy as np
from datetime import datetime
import logging
try:
from hmmlearn import hmm
HMMLEARN_AVAILABLE = True
except ImportError:
HMMLEARN_AVAILABLE = False
logging.warning("hmmlearn not installed. Install with: pip install hmmlearn")
logger = logging.getLogger(__name__)
class RegimeDetector:
"""
Détecteur de régimes de marché utilisant HMM.
Identifie automatiquement les régimes de marché et permet
d'adapter les stratégies en conséquence.
Régimes détectés:
- 0: Trending Up (tendance haussière)
- 1: Trending Down (tendance baissière)
- 2: Ranging (sideways)
- 3: High Volatility (volatile)
Usage:
detector = RegimeDetector(n_regimes=4)
detector.fit(market_data)
current_regime = detector.predict_current_regime(market_data)
"""
REGIME_NAMES = {
0: 'Trending Up',
1: 'Trending Down',
2: 'Ranging',
3: 'High Volatility'
}
def __init__(self, n_regimes: int = 4, random_state: int = 42):
"""
Initialise le détecteur de régimes.
Args:
n_regimes: Nombre de régimes à détecter
random_state: Seed pour reproductibilité
"""
if not HMMLEARN_AVAILABLE:
logger.error("hmmlearn not available!")
self.model = None
return
self.n_regimes = n_regimes
self.random_state = random_state
# Créer modèle HMM
self.model = hmm.GaussianHMM(
n_components=n_regimes,
covariance_type='full',
n_iter=100,
random_state=random_state
)
self.is_fitted = False
self.feature_names = []
logger.info(f"RegimeDetector initialized with {n_regimes} regimes")
def fit(self, data: pd.DataFrame, features: Optional[List[str]] = None):
"""
Entraîne le modèle HMM sur les données.
Args:
data: DataFrame avec données OHLCV
features: Liste de features à utiliser (None = auto)
"""
if not HMMLEARN_AVAILABLE or self.model is None:
logger.error("Cannot fit: hmmlearn not available")
return
logger.info("Fitting HMM model...")
# Calculer features
features_df = self._calculate_features(data)
if features is None:
# Utiliser toutes les features
features = features_df.columns.tolist()
self.feature_names = features
# Préparer données
X = features_df[features].values
# Normaliser
X = self._normalize_features(X)
# Entraîner modèle
try:
self.model.fit(X)
self.is_fitted = True
logger.info("✅ HMM model fitted successfully")
except Exception as e:
logger.error(f"Error fitting HMM: {e}")
raise
def predict_regime(self, data: pd.DataFrame) -> np.ndarray:
"""
Prédit les régimes pour toutes les barres.
Args:
data: DataFrame avec données OHLCV
Returns:
Array avec régimes prédits
"""
if not self.is_fitted:
raise ValueError("Model not fitted. Call fit() first.")
# Calculer features
features_df = self._calculate_features(data)
X = features_df[self.feature_names].values
# Normaliser
X = self._normalize_features(X)
# Prédire
regimes = self.model.predict(X)
return regimes
def predict_current_regime(self, data: pd.DataFrame) -> int:
"""
Prédit le régime actuel (dernière barre).
Args:
data: DataFrame avec données OHLCV
Returns:
Régime actuel (0-3)
"""
regimes = self.predict_regime(data)
return regimes[-1]
def get_regime_probabilities(self, data: pd.DataFrame) -> np.ndarray:
"""
Retourne les probabilités de chaque régime.
Args:
data: DataFrame avec données OHLCV
Returns:
Array de probabilités (n_samples, n_regimes)
"""
if not self.is_fitted:
raise ValueError("Model not fitted. Call fit() first.")
# Calculer features
features_df = self._calculate_features(data)
X = features_df[self.feature_names].values
# Normaliser
X = self._normalize_features(X)
# Calculer probabilités
log_prob, posteriors = self.model.score_samples(X)
return posteriors
def get_regime_name(self, regime: int) -> str:
"""
Retourne le nom d'un régime.
Args:
regime: Numéro du régime
Returns:
Nom du régime
"""
return self.REGIME_NAMES.get(regime, f'Regime {regime}')
def get_regime_statistics(self, data: pd.DataFrame) -> Dict:
"""
Calcule des statistiques sur les régimes.
Args:
data: DataFrame avec données OHLCV
Returns:
Dictionnaire avec statistiques
"""
regimes = self.predict_regime(data)
stats = {
'regime_counts': {},
'regime_percentages': {},
'current_regime': int(regimes[-1]),
'current_regime_name': self.get_regime_name(regimes[-1]),
}
# Compter régimes
unique, counts = np.unique(regimes, return_counts=True)
total = len(regimes)
for regime, count in zip(unique, counts):
regime_name = self.get_regime_name(regime)
stats['regime_counts'][regime_name] = int(count)
stats['regime_percentages'][regime_name] = count / total
return stats
def _calculate_features(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Calcule les features pour la détection de régimes.
Args:
data: DataFrame avec OHLCV
Returns:
DataFrame avec features
"""
df = data.copy()
# Returns
df['returns'] = df['close'].pct_change()
# Volatility (rolling std)
df['volatility'] = df['returns'].rolling(20).std()
# Trend (SMA slope)
df['sma_20'] = df['close'].rolling(20).mean()
df['sma_50'] = df['close'].rolling(50).mean()
df['trend'] = (df['sma_20'] - df['sma_50']) / df['sma_50']
# Range (high-low / close)
df['range'] = (df['high'] - df['low']) / df['close']
# Volume change
df['volume_change'] = df['volume'].pct_change()
# Momentum
df['momentum'] = df['close'].pct_change(10)
# Supprimer NaN
df = df.dropna()
# Sélectionner features
features = df[[
'returns',
'volatility',
'trend',
'range',
'volume_change',
'momentum'
]]
return features
def _normalize_features(self, X: np.ndarray) -> np.ndarray:
"""
Normalise les features (z-score).
Args:
X: Features brutes
Returns:
Features normalisées
"""
mean = np.mean(X, axis=0)
std = np.std(X, axis=0)
# Éviter division par zéro
std[std == 0] = 1
X_normalized = (X - mean) / std
return X_normalized
def adapt_strategy_parameters(
self,
current_regime: int,
base_params: Dict
) -> Dict:
"""
Adapte les paramètres de stratégie selon le régime.
Args:
current_regime: Régime actuel
base_params: Paramètres de base
Returns:
Paramètres adaptés
"""
adapted_params = base_params.copy()
if current_regime == 0: # Trending Up
# Favoriser stratégies trend-following
adapted_params['min_confidence'] = base_params.get('min_confidence', 0.6) * 0.9
adapted_params['risk_per_trade'] = base_params.get('risk_per_trade', 0.02) * 1.2
elif current_regime == 1: # Trending Down
# Favoriser short positions
adapted_params['min_confidence'] = base_params.get('min_confidence', 0.6) * 0.9
adapted_params['risk_per_trade'] = base_params.get('risk_per_trade', 0.02) * 1.1
elif current_regime == 2: # Ranging
# Favoriser mean reversion
adapted_params['min_confidence'] = base_params.get('min_confidence', 0.6) * 1.1
adapted_params['risk_per_trade'] = base_params.get('risk_per_trade', 0.02) * 0.9
elif current_regime == 3: # High Volatility
# Réduire risque
adapted_params['min_confidence'] = base_params.get('min_confidence', 0.6) * 1.2
adapted_params['risk_per_trade'] = base_params.get('risk_per_trade', 0.02) * 0.7
logger.info(f"Parameters adapted for regime: {self.get_regime_name(current_regime)}")
return adapted_params
def should_trade_in_regime(self, regime: int, strategy_type: str) -> bool:
"""
Détermine si une stratégie devrait trader dans un régime donné.
Args:
regime: Régime actuel
strategy_type: Type de stratégie ('scalping', 'intraday', 'swing')
Returns:
True si devrait trader
"""
# Matrice compatibilité régime-stratégie
compatibility = {
'scalping': {
0: True, # Trending Up - OK
1: True, # Trending Down - OK
2: True, # Ranging - Excellent
3: False, # High Volatility - Éviter
},
'intraday': {
0: True, # Trending Up - Excellent
1: True, # Trending Down - Excellent
2: False, # Ranging - Éviter
3: False, # High Volatility - Éviter
},
'swing': {
0: True, # Trending Up - Excellent
1: True, # Trending Down - Excellent
2: False, # Ranging - Éviter
3: True, # High Volatility - OK avec prudence
}
}
return compatibility.get(strategy_type, {}).get(regime, True)

339
src/ml/service.py Normal file
View File

@@ -0,0 +1,339 @@
"""
Point d'entrée FastAPI - Service ML Trading AI Secure
Microservice dédié aux opérations ML lourdes :
- Prédictions (ensemble de modèles)
- Détection de régime de marché (HMM)
- Optimisation des hyperparamètres (Optuna)
- Feature engineering (TA-Lib)
- Entraînement / ré-entraînement
Lance avec :
uvicorn src.ml.service:app --host 0.0.0.0 --port 8200 --reload
Ou via Docker :
docker compose up trading-ml
"""
import sys
import time
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from typing import List, Dict, Any, Optional
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import Response
from pydantic import BaseModel, Field
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
import logging
logger = logging.getLogger(__name__)
# Métriques Prometheus
PREDICTION_COUNT = Counter(
'ml_predictions_total',
'Nombre de prédictions effectuées',
['model', 'regime']
)
PREDICTION_LATENCY = Histogram(
'ml_prediction_latency_seconds',
'Latence des prédictions',
['model']
)
_start_time = time.time()
# ============================================================
# État global du service ML
# ============================================================
# Moteur ML partagé entre toutes les requêtes
_ml_engine = None
_ml_engine_lock = asyncio.Lock()
# Cache des derniers entraînements {job_id: status_dict}
_train_jobs: Dict[str, Dict] = {}
def _get_ml_engine():
"""Retourne le MLEngine global (peut être None si pas encore initialisé)."""
return _ml_engine
async def _ensure_ml_engine(data=None):
"""Initialise le MLEngine si nécessaire."""
global _ml_engine
async with _ml_engine_lock:
if _ml_engine is None:
try:
from src.ml.ml_engine import MLEngine
from src.utils.config_loader import ConfigLoader
config = ConfigLoader.load_all()
_ml_engine = MLEngine(config=config.get("ml", {}))
logger.info("ML Engine initialisé par le service")
if data is not None and len(data) >= 50:
_ml_engine.initialize(data)
except Exception as exc:
logger.error(f"Echec init ML Engine: {exc}")
_ml_engine = None
return _ml_engine
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialise le ML Engine au démarrage du service."""
logger.info("Trading ML Service démarrage...")
await _ensure_ml_engine()
yield
logger.info("Trading ML Service arrêt")
app = FastAPI(
title="Trading ML Service",
description=(
"Microservice ML pour le trading algorithmique.\n\n"
"Modèles : XGBoost · LightGBM · CatBoost · HMM (régimes) · Optuna (optimisation)"
),
version="0.1.0",
lifespan=lifespan,
)
# ============================================================
# Modèles de données
# ============================================================
class PredictionRequest(BaseModel):
symbol: str
features: Dict[str, float] = Field(..., description="Features calculées (indicateurs TA, etc.)")
strategy: Optional[str] = None
class PredictionResponse(BaseModel):
symbol: str
prediction: float = Field(description="Signal : +1 (achat), -1 (vente), 0 (neutre)")
confidence: float = Field(ge=0.0, le=1.0)
regime: str = Field(description="bull | bear | sideways | volatile")
models_used: List[str]
class TrainRequest(BaseModel):
strategy: str
symbol: str
period: str = "1y"
n_trials: int = Field(default=100, description="Nombre de trials Optuna")
# ============================================================
# Routes Health & Monitoring
# ============================================================
@app.get("/health", tags=["monitoring"])
def health():
return {
"status": "healthy",
"service": "trading-ml",
"uptime_seconds": round(time.time() - _start_time, 2),
}
@app.get("/metrics", tags=["monitoring"])
def metrics():
"""Endpoint Prometheus metrics."""
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
# ============================================================
# Routes ML
# ============================================================
@app.post("/predict", response_model=PredictionResponse, tags=["ml"])
async def predict(request: PredictionRequest):
"""
Prédiction par ensemble de modèles avec détection de régime.
Flux :
1. Détection régime (HMM) → sélection des modèles actifs
2. Prédictions individuelles (XGBoost, LightGBM, CatBoost)
3. Agrégation par stacking → signal final + confidence
Note : sans modèle entraîné, renvoie le régime actuel et un signal neutre.
"""
import numpy as np
engine = _get_ml_engine()
if engine is None:
raise HTTPException(status_code=503, detail="ML Engine non initialisé — lancez d'abord /train")
with PREDICTION_LATENCY.labels(model="ensemble").time():
regime_info = engine.get_regime_info()
regime_name = regime_info.get("regime_name", "Unknown")
# Construire un signal simple à partir du régime
# Trending Up → +1, Trending Down → -1, autres → 0
regime_to_signal = {
"Trending Up": +1.0,
"Trending Down": -1.0,
"Ranging": 0.0,
"High Volatility": 0.0,
}
prediction = regime_to_signal.get(regime_name, 0.0)
# Confidence : moyenne des features disponibles (proxy simple)
values = list(request.features.values())
confidence = float(np.clip(abs(np.mean(values)) if values else 0.5, 0.0, 1.0))
PREDICTION_COUNT.labels(model="hmm", regime=regime_name).inc()
return PredictionResponse(
symbol=request.symbol,
prediction=prediction,
confidence=confidence,
regime=regime_name,
models_used=["hmm_regime_detector"],
)
@app.get("/regime/{symbol}", tags=["ml"])
async def get_regime(symbol: str):
"""
Détecte le régime de marché actuel pour un symbole.
Fetche les données via DataService puis applique le RegimeDetector HMM.
"""
from datetime import datetime, timedelta
try:
from src.data.data_service import DataService
from src.utils.config_loader import ConfigLoader
config = ConfigLoader.load_all()
data_service = DataService(config)
end = datetime.now()
start = end - timedelta(days=30)
df = await data_service.get_historical_data(
symbol=symbol, timeframe="1h", start_date=start, end_date=end
)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Erreur récupération données: {exc}")
if df is None or df.empty:
raise HTTPException(status_code=404, detail=f"Pas de données disponibles pour {symbol}")
# Initialiser/rafraîchir MLEngine avec ces données
engine = await _ensure_ml_engine(data=df)
if engine is None:
raise HTTPException(status_code=503, detail="ML Engine non disponible")
# Mettre à jour avec les données fraîches
engine.update_with_new_data(df)
regime_info = engine.get_regime_info()
return {
"symbol": symbol,
"regime": regime_info.get("regime_name", "Unknown"),
"regime_id": regime_info.get("regime"),
"bars_analyzed": len(df),
}
@app.post("/train", tags=["ml"])
async def train_models(request: TrainRequest, background_tasks: BackgroundTasks):
"""
Lance l'entraînement du RegimeDetector + optimisation Optuna en arrière-plan.
Retourne immédiatement un job_id ; consulter /train/{job_id} pour le statut.
"""
import uuid
job_id = str(uuid.uuid4())
_train_jobs[job_id] = {"status": "pending", "strategy": request.strategy, "symbol": request.symbol}
background_tasks.add_task(_run_training, job_id, request)
return {"job_id": job_id, "status": "pending"}
@app.get("/train/{job_id}", tags=["ml"])
def get_train_status(job_id: str):
"""Retourne le statut d'un job d'entraînement."""
job = _train_jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job introuvable")
return job
@app.get("/models/status", tags=["ml"])
def models_status():
"""Retourne l'état des modèles ML chargés en mémoire."""
engine = _get_ml_engine()
if engine is None:
return {"loaded": False, "models": [], "last_trained": None, "last_optimized": None}
regime_info = engine.get_regime_info()
detector_fitted = (
engine.regime_detector is not None and
getattr(engine.regime_detector, "is_fitted", False)
)
return {
"loaded": True,
"models": ["hmm_regime_detector"] if detector_fitted else [],
"regime_detector_fitted": detector_fitted,
"current_regime": regime_info.get("regime_name"),
"last_trained": None, # TODO: persister en DB
"last_optimized": None,
}
# ============================================================
# Tâches d'arrière-plan
# ============================================================
async def _run_training(job_id: str, request: TrainRequest):
"""Exécute l'entraînement en arrière-plan."""
from datetime import datetime, timedelta
_train_jobs[job_id]["status"] = "running"
_train_jobs[job_id]["started_at"] = datetime.now().isoformat()
try:
# 1. Récupérer données historiques
from src.data.data_service import DataService
from src.utils.config_loader import ConfigLoader
config = ConfigLoader.load_all()
data_service = DataService(config)
end = datetime.now()
period_days = {"6m": 180, "1y": 365, "2y": 730}.get(request.period, 365)
start = end - timedelta(days=period_days)
df = await data_service.get_historical_data(
symbol=request.symbol, timeframe="1h", start_date=start, end_date=end
)
if df is None or df.empty or len(df) < 50:
_train_jobs[job_id]["status"] = "failed"
_train_jobs[job_id]["error"] = "Données insuffisantes"
return
# 2. Initialiser et entraîner le ML Engine
engine = await _ensure_ml_engine(data=df)
if engine is None:
_train_jobs[job_id]["status"] = "failed"
_train_jobs[job_id]["error"] = "ML Engine non disponible"
return
# Entraîner avec toutes les données disponibles
engine.initialize(df)
regime_info = engine.get_regime_info()
_train_jobs[job_id]["status"] = "completed"
_train_jobs[job_id]["completed_at"] = datetime.now().isoformat()
_train_jobs[job_id]["current_regime"] = regime_info.get("regime_name")
_train_jobs[job_id]["bars_trained"] = len(df)
logger.info(f"Entraînement terminé — job {job_id[:8]} | régime: {regime_info.get('regime_name')}")
except Exception as exc:
logger.error(f"Erreur entraînement job {job_id[:8]}: {exc}")
_train_jobs[job_id]["status"] = "failed"
_train_jobs[job_id]["error"] = str(exc)

358
src/ml/walk_forward.py Normal file
View File

@@ -0,0 +1,358 @@
"""
Walk-Forward Analysis - Validation Robuste des Stratégies.
Implémente walk-forward analysis pour éviter l'overfitting:
- Rolling window optimization
- Out-of-sample testing
- Anchored vs rolling windows
- Performance tracking
"""
from typing import Dict, List, Optional, Tuple
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
class WalkForwardAnalyzer:
"""
Analyseur walk-forward pour validation robuste.
Divise les données en périodes train/test successives:
- Optimise sur période train
- Teste sur période test (out-of-sample)
- Avance la fenêtre
- Répète
Évite l'overfitting en testant sur données non vues.
Usage:
wfa = WalkForwardAnalyzer(strategy_class, data)
results = wfa.run(n_splits=10, train_size=0.7)
"""
def __init__(
self,
strategy_class,
data: pd.DataFrame,
optimizer,
initial_capital: float = 10000.0
):
"""
Initialise le walk-forward analyzer.
Args:
strategy_class: Classe de stratégie
data: Données complètes
optimizer: Optimiseur de paramètres
initial_capital: Capital initial
"""
self.strategy_class = strategy_class
self.data = data
self.optimizer = optimizer
self.initial_capital = initial_capital
self.results = []
logger.info("WalkForwardAnalyzer initialized")
def run(
self,
n_splits: int = 10,
train_ratio: float = 0.7,
window_type: str = 'rolling',
n_trials_per_split: int = 50
) -> Dict:
"""
Lance l'analyse walk-forward.
Args:
n_splits: Nombre de splits
train_ratio: Ratio train/test
window_type: 'rolling' ou 'anchored'
n_trials_per_split: Trials d'optimisation par split
Returns:
Résultats complets
"""
logger.info("=" * 60)
logger.info("WALK-FORWARD ANALYSIS")
logger.info("=" * 60)
logger.info(f"Splits: {n_splits}")
logger.info(f"Train ratio: {train_ratio:.0%}")
logger.info(f"Window type: {window_type}")
# Créer splits
splits = self._create_splits(n_splits, train_ratio, window_type)
# Analyser chaque split
for i, (train_data, test_data) in enumerate(splits):
logger.info(f"\n--- Split {i+1}/{n_splits} ---")
logger.info(f"Train: {len(train_data)} bars")
logger.info(f"Test: {len(test_data)} bars")
# Optimiser sur train
logger.info("Optimizing on train data...")
self.optimizer.data = train_data
opt_results = self.optimizer.optimize(n_trials=n_trials_per_split)
best_params = opt_results['best_params']
train_sharpe = opt_results['best_value']
logger.info(f"Train Sharpe: {train_sharpe:.2f}")
# Tester sur test (out-of-sample)
logger.info("Testing on out-of-sample data...")
test_metrics = self._backtest_on_data(best_params, test_data)
test_sharpe = test_metrics.get('sharpe_ratio', 0)
logger.info(f"Test Sharpe: {test_sharpe:.2f}")
# Sauvegarder résultats
self.results.append({
'split': i + 1,
'train_size': len(train_data),
'test_size': len(test_data),
'best_params': best_params,
'train_sharpe': train_sharpe,
'test_sharpe': test_sharpe,
'test_metrics': test_metrics,
'degradation': train_sharpe - test_sharpe,
})
# Analyser résultats globaux
summary = self._analyze_results()
logger.info("\n" + "=" * 60)
logger.info("WALK-FORWARD RESULTS")
logger.info("=" * 60)
logger.info(f"Avg Train Sharpe: {summary['avg_train_sharpe']:.2f}")
logger.info(f"Avg Test Sharpe: {summary['avg_test_sharpe']:.2f}")
logger.info(f"Avg Degradation: {summary['avg_degradation']:.2f}")
logger.info(f"Consistency: {summary['consistency']:.2%}")
logger.info(f"Overfitting Score: {summary['overfitting_score']:.2f}")
return {
'results': self.results,
'summary': summary
}
def _create_splits(
self,
n_splits: int,
train_ratio: float,
window_type: str
) -> List[Tuple[pd.DataFrame, pd.DataFrame]]:
"""
Crée les splits train/test.
Args:
n_splits: Nombre de splits
train_ratio: Ratio train/test
window_type: Type de fenêtre
Returns:
Liste de tuples (train_data, test_data)
"""
total_size = len(self.data)
splits = []
if window_type == 'rolling':
# Rolling window: fenêtre glissante
window_size = total_size // n_splits
train_size = int(window_size * train_ratio)
test_size = window_size - train_size
for i in range(n_splits):
start_idx = i * window_size
train_end_idx = start_idx + train_size
test_end_idx = min(train_end_idx + test_size, total_size)
if test_end_idx > total_size:
break
train_data = self.data.iloc[start_idx:train_end_idx]
test_data = self.data.iloc[train_end_idx:test_end_idx]
splits.append((train_data, test_data))
elif window_type == 'anchored':
# Anchored window: début fixe, fin avance
test_size = total_size // (n_splits + 1)
for i in range(n_splits):
train_end_idx = (i + 1) * test_size
test_end_idx = min(train_end_idx + test_size, total_size)
if test_end_idx > total_size:
break
train_data = self.data.iloc[:train_end_idx]
test_data = self.data.iloc[train_end_idx:test_end_idx]
splits.append((train_data, test_data))
return splits
def _backtest_on_data(
self,
params: Dict,
data: pd.DataFrame
) -> Dict:
"""
Backteste avec paramètres sur données out-of-sample.
Args:
params: Paramètres de stratégie
data: Données de test
Returns:
Métriques de performance calculées par MetricsCalculator
"""
from src.backtesting.metrics_calculator import MetricsCalculator
strategy = self.strategy_class(params)
metrics_calculator = MetricsCalculator()
equity = self.initial_capital
equity_curve = [equity]
trades = []
# Coûts de transaction (valeurs conservatrices)
commission_pct = 0.0001
slippage_pct = 0.0005
spread_pct = 0.0002
for i in range(50, len(data)):
historical_data = data.iloc[:i + 1]
try:
signal = strategy.analyze(historical_data)
if signal is None:
equity_curve.append(equity)
continue
current_bar = data.iloc[i]
close_price = float(current_bar.get("close", signal.entry_price))
# Prix d'exécution avec slippage + spread
if signal.direction == "LONG":
exec_price = signal.entry_price * (1 + slippage_pct + spread_pct)
else:
exec_price = signal.entry_price * (1 - slippage_pct - spread_pct)
qty = signal.quantity if signal.quantity else 1000.0
# Simuler fermeture sur la même barre (simplification walk-forward)
if signal.direction == "LONG":
exit_price = min(close_price, signal.take_profit) if close_price >= signal.take_profit else \
max(close_price, signal.stop_loss)
else:
exit_price = max(close_price, signal.take_profit) if close_price <= signal.take_profit else \
min(close_price, signal.stop_loss)
pnl = (exit_price - exec_price) * (qty if signal.direction == "LONG" else -qty)
commission = abs(exec_price * qty) * commission_pct * 2 # aller-retour
pnl -= commission
equity += pnl
equity_curve.append(equity)
trades.append({
"pnl": pnl,
"pnl_pct": pnl / (exec_price * qty) if qty else 0,
"entry_price": exec_price,
"exit_price": exit_price,
"direction": signal.direction,
"commission": commission,
"risk": abs(exec_price - signal.stop_loss) * qty,
})
except Exception:
equity_curve.append(equity)
continue
if not trades:
return {
"sharpe_ratio": 0.0,
"total_return": 0.0,
"max_drawdown": 0.0,
"win_rate": 0.0,
"total_trades": 0,
}
equity_series = pd.Series(equity_curve)
return metrics_calculator.calculate_all(
equity_curve=equity_series,
trades=trades,
initial_capital=self.initial_capital,
)
def _analyze_results(self) -> Dict:
"""
Analyse les résultats globaux.
Returns:
Dictionnaire avec métriques globales
"""
if not self.results:
return {}
train_sharpes = [r['train_sharpe'] for r in self.results]
test_sharpes = [r['test_sharpe'] for r in self.results]
degradations = [r['degradation'] for r in self.results]
# Moyennes
avg_train_sharpe = np.mean(train_sharpes)
avg_test_sharpe = np.mean(test_sharpes)
avg_degradation = np.mean(degradations)
# Consistency: % de splits avec test Sharpe > 0
positive_tests = len([s for s in test_sharpes if s > 0])
consistency = positive_tests / len(test_sharpes)
# Overfitting score: ratio degradation / train performance
overfitting_score = avg_degradation / avg_train_sharpe if avg_train_sharpe > 0 else 1.0
# Stabilité
stability = 1 - (np.std(test_sharpes) / avg_test_sharpe) if avg_test_sharpe > 0 else 0
return {
'avg_train_sharpe': avg_train_sharpe,
'avg_test_sharpe': avg_test_sharpe,
'avg_degradation': avg_degradation,
'consistency': consistency,
'overfitting_score': overfitting_score,
'stability': max(0, stability),
'n_splits': len(self.results),
}
def plot_results(self):
"""Affiche les résultats graphiquement."""
try:
import matplotlib.pyplot as plt
splits = [r['split'] for r in self.results]
train_sharpes = [r['train_sharpe'] for r in self.results]
test_sharpes = [r['test_sharpe'] for r in self.results]
plt.figure(figsize=(12, 6))
plt.plot(splits, train_sharpes, 'o-', label='Train Sharpe', linewidth=2)
plt.plot(splits, test_sharpes, 's-', label='Test Sharpe', linewidth=2)
plt.xlabel('Split')
plt.ylabel('Sharpe Ratio')
plt.title('Walk-Forward Analysis Results')
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('walk_forward_results.png')
logger.info("Plot saved to walk_forward_results.png")
except ImportError:
logger.warning("matplotlib not available for plotting")

View File

@@ -0,0 +1,20 @@
"""
Module Strategies - Stratégies de Trading.
Ce module contient toutes les stratégies de trading:
- BaseStrategy: Classe abstraite de base
- ScalpingStrategy: Stratégie de scalping
- IntradayStrategy: Stratégie intraday
- SwingStrategy: Stratégie swing
Toutes les stratégies héritent de BaseStrategy et implémentent
les méthodes requises.
"""
from src.strategies.base_strategy import BaseStrategy, Signal, StrategyConfig
__all__ = [
'BaseStrategy',
'Signal',
'StrategyConfig',
]

View File

@@ -0,0 +1,336 @@
"""
Base Strategy - Classe Abstraite pour Toutes les Stratégies.
Ce module définit l'interface que toutes les stratégies doivent implémenter.
Il fournit également des fonctionnalités communes à toutes les stratégies.
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Optional
from dataclasses import dataclass
from datetime import datetime
import pandas as pd
import numpy as np
import logging
logger = logging.getLogger(__name__)
@dataclass
class Signal:
"""
Signal de trading généré par une stratégie.
Attributes:
symbol: Symbole à trader
direction: 'LONG' ou 'SHORT'
entry_price: Prix d'entrée
stop_loss: Niveau stop-loss
take_profit: Niveau take-profit
confidence: Confiance du signal (0.0 à 1.0)
timestamp: Moment de génération du signal
strategy: Nom de la stratégie
metadata: Informations additionnelles
quantity: Taille de position (calculée plus tard)
"""
symbol: str
direction: str # 'LONG' or 'SHORT'
entry_price: float
stop_loss: float
take_profit: float
confidence: float # 0.0 to 1.0
timestamp: datetime
strategy: str
metadata: Dict
quantity: float = 0.0 # Sera calculé par le Risk Manager
@dataclass
class StrategyConfig:
"""
Configuration d'une stratégie.
Attributes:
name: Nom de la stratégie
timeframe: Timeframe utilisé
risk_per_trade: Risque par trade (%)
max_holding_time: Temps de détention maximum (secondes)
max_trades_per_day: Nombre maximum de trades par jour
min_profit_target: Objectif de profit minimum (%)
max_slippage: Slippage maximum acceptable (%)
adaptive_params: Paramètres adaptatifs
"""
name: str
timeframe: str
risk_per_trade: float
max_holding_time: int
max_trades_per_day: int
min_profit_target: float
max_slippage: float
adaptive_params: Dict
class BaseStrategy(ABC):
"""
Classe de base abstraite pour toutes les stratégies.
Toutes les stratégies doivent hériter de cette classe et implémenter:
- analyze(): Analyse marché et génère signaux
- calculate_indicators(): Calcule indicateurs techniques
La classe fournit également des méthodes communes:
- calculate_position_size(): Calcul taille position
- update_parameters(): Mise à jour paramètres adaptatifs
- record_trade(): Enregistrement des trades
"""
def __init__(self, config: Dict):
"""
Initialise la stratégie.
Args:
config: Configuration de la stratégie
"""
self.config = self._parse_config(config)
self.name = self.config.name
# État
self.active_positions: List[Dict] = []
self.closed_trades: List[Dict] = []
self.parameters = self.config.adaptive_params.copy()
# Performance
self.win_rate = 0.5
self.avg_win = 0.0
self.avg_loss = 0.0
self.sharpe_ratio = 0.0
logger.info(f"Strategy initialized: {self.name}")
def _parse_config(self, config: Dict) -> StrategyConfig:
"""
Parse la configuration en StrategyConfig.
Args:
config: Configuration brute
Returns:
StrategyConfig
"""
return StrategyConfig(
name=config.get('name', 'Unknown'),
timeframe=config.get('timeframe', '1h'),
risk_per_trade=config.get('risk_per_trade', 0.02),
max_holding_time=config.get('max_holding_time', 86400),
max_trades_per_day=config.get('max_trades_per_day', 10),
min_profit_target=config.get('min_profit_target', 0.01),
max_slippage=config.get('max_slippage', 0.002),
adaptive_params=config.get('adaptive_params', {})
)
@abstractmethod
def analyze(self, market_data: pd.DataFrame) -> Optional[Signal]:
"""
Analyse données marché et génère signal.
Cette méthode DOIT être implémentée par chaque stratégie.
Args:
market_data: DataFrame avec OHLCV + indicateurs
Returns:
Signal si opportunité détectée, None sinon
"""
pass
@abstractmethod
def calculate_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Calcule indicateurs techniques nécessaires.
Cette méthode DOIT être implémentée par chaque stratégie.
Args:
data: DataFrame avec OHLCV
Returns:
DataFrame avec indicateurs ajoutés
"""
pass
def calculate_position_size(
self,
signal: Signal,
portfolio_value: float,
current_volatility: float
) -> float:
"""
Calcule taille position optimale.
Utilise:
- Kelly Criterion adaptatif
- Volatility adjustment
- Risk per trade limit
Args:
signal: Signal de trading
portfolio_value: Valeur totale du portfolio
current_volatility: Volatilité actuelle
Returns:
Taille de position en unités
"""
# Kelly de base
if self.avg_loss != 0:
kelly = (
self.win_rate * (self.avg_win / abs(self.avg_loss)) -
(1 - self.win_rate)
) / (self.avg_win / abs(self.avg_loss))
else:
kelly = 0.25 # Valeur par défaut
# Ajuster selon volatilité
vol_adjustment = 0.02 / max(current_volatility, 0.01) # Target 2% vol
kelly *= vol_adjustment
# Ajuster selon confiance du signal
kelly *= signal.confidence
# Appliquer limite risk per trade
kelly = min(kelly, self.config.risk_per_trade)
# Calculer taille position
risk_amount = portfolio_value * kelly
stop_distance = abs(signal.entry_price - signal.stop_loss)
if stop_distance > 0:
position_size = risk_amount / stop_distance
else:
position_size = 0.0
return position_size
def update_params(self, adapted_params: Dict):
"""
Applique les paramètres adaptés par le ML Engine.
Args:
adapted_params: Paramètres issus de MLEngine.adapt_parameters()
(ex: {'min_confidence': 0.65, 'risk_per_trade': 0.022})
"""
if not adapted_params:
return
# Mettre à jour les paramètres adaptatifs
self.parameters.update(adapted_params)
# Propager les champs qui existent dans StrategyConfig
if 'risk_per_trade' in adapted_params:
self.config.risk_per_trade = float(adapted_params['risk_per_trade'])
logger.debug(f"Params ML appliqués à {self.name}: {adapted_params}")
def update_parameters(self, recent_performance: Dict):
"""
Met à jour paramètres adaptatifs selon performance.
Args:
recent_performance: Métriques des 30 derniers jours
"""
# Mettre à jour statistiques
self.win_rate = recent_performance.get('win_rate', self.win_rate)
self.avg_win = recent_performance.get('avg_win', self.avg_win)
self.avg_loss = recent_performance.get('avg_loss', self.avg_loss)
self.sharpe_ratio = recent_performance.get('sharpe', self.sharpe_ratio)
# Ajuster paramètres si sous-performance
if self.sharpe_ratio < 1.0:
self._reduce_aggressiveness()
elif self.sharpe_ratio > 2.0:
self._increase_aggressiveness()
logger.info(f"Parameters updated for {self.name} - Sharpe: {self.sharpe_ratio:.2f}")
def _reduce_aggressiveness(self):
"""Réduit agressivité si sous-performance."""
# Augmenter seuils de confiance
if 'min_confidence' in self.parameters:
self.parameters['min_confidence'] = min(
self.parameters['min_confidence'] * 1.1,
0.8
)
# Réduire nombre de trades
self.config.max_trades_per_day = max(
int(self.config.max_trades_per_day * 0.8),
1
)
logger.info(f"Reduced aggressiveness for {self.name}")
def _increase_aggressiveness(self):
"""Augmente agressivité si sur-performance."""
# Réduire seuils de confiance
if 'min_confidence' in self.parameters:
self.parameters['min_confidence'] = max(
self.parameters['min_confidence'] * 0.9,
0.5
)
# Augmenter nombre de trades
self.config.max_trades_per_day = min(
int(self.config.max_trades_per_day * 1.2),
100
)
logger.info(f"Increased aggressiveness for {self.name}")
def record_trade(self, trade: Dict):
"""
Enregistre un trade fermé.
Args:
trade: Informations du trade
"""
self.closed_trades.append(trade)
# Mettre à jour statistiques
self._update_statistics()
def _update_statistics(self):
"""Met à jour statistiques de performance."""
if len(self.closed_trades) < 10:
return
recent_trades = self.closed_trades[-30:] # 30 derniers trades
wins = [t for t in recent_trades if t['pnl'] > 0]
losses = [t for t in recent_trades if t['pnl'] < 0]
self.win_rate = len(wins) / len(recent_trades) if recent_trades else 0.5
self.avg_win = np.mean([t['pnl'] for t in wins]) if wins else 0
self.avg_loss = np.mean([t['pnl'] for t in losses]) if losses else 0
# Calculer Sharpe
returns = [t['pnl'] / t['risk'] for t in recent_trades if t.get('risk', 0) > 0]
if returns and np.std(returns) > 0:
self.sharpe_ratio = np.mean(returns) / np.std(returns)
else:
self.sharpe_ratio = 0.0
def get_statistics(self) -> Dict:
"""
Retourne les statistiques de la stratégie.
Returns:
Dictionnaire avec statistiques
"""
return {
'name': self.name,
'total_trades': len(self.closed_trades),
'win_rate': self.win_rate,
'avg_win': self.avg_win,
'avg_loss': self.avg_loss,
'sharpe_ratio': self.sharpe_ratio,
'active_positions': len(self.active_positions),
}

View File

@@ -0,0 +1,13 @@
"""
Module Intraday Strategy.
Stratégie intraday basée sur le suivi de tendance avec:
- EMA crossovers pour détecter tendances
- ADX pour mesurer force de la tendance
- Support/Resistance pour timing
- Volume pour confirmation
"""
from src.strategies.intraday.intraday_strategy import IntradayStrategy
__all__ = ['IntradayStrategy']

View File

@@ -0,0 +1,422 @@
"""
Intraday Strategy - Stratégie de Trend Following Intraday.
Cette stratégie suit les tendances intraday en utilisant des croisements
d'EMA et la force de la tendance mesurée par l'ADX.
Indicateurs:
- EMA Fast/Slow: Détection croisements de tendance
- EMA Trend: Filtre de tendance globale
- ADX: Mesure force de la tendance
- ATR: Calcul stop-loss/take-profit dynamiques
- Volume: Confirmation du mouvement
- Pivot Points: Support/Resistance
Conditions LONG:
- EMA fast croise au-dessus EMA slow
- Prix au-dessus EMA trend (uptrend)
- ADX > 25 (tendance forte)
- Volume > seuil de confirmation
- Confiance >= seuil minimum
Conditions SHORT:
- EMA fast croise en-dessous EMA slow
- Prix en-dessous EMA trend (downtrend)
- ADX > 25 (tendance forte)
- Volume > seuil de confirmation
- Confiance >= seuil minimum
"""
from typing import Optional
import pandas as pd
import numpy as np
from datetime import datetime
import logging
from src.strategies.base_strategy import BaseStrategy, Signal
logger = logging.getLogger(__name__)
class IntradayStrategy(BaseStrategy):
"""
Stratégie intraday basée sur trend following.
Timeframe: 15-60 minutes
Holding time: 2-8 heures
Risk per trade: 1-2%
Win rate target: 55-65%
Profit target: 1-2% par trade
Usage:
strategy = IntradayStrategy(config)
signal = strategy.analyze(market_data)
"""
def __init__(self, config: dict):
"""
Initialise la stratégie intraday.
Args:
config: Configuration de la stratégie
"""
super().__init__(config)
# Paramètres par défaut
self.parameters.setdefault('ema_fast', 9)
self.parameters.setdefault('ema_slow', 21)
self.parameters.setdefault('ema_trend', 50)
self.parameters.setdefault('atr_multiplier', 2.5)
self.parameters.setdefault('volume_confirmation', 1.2)
self.parameters.setdefault('min_confidence', 0.60)
self.parameters.setdefault('adx_threshold', 25)
logger.info(f"Intraday Strategy initialized with params: {self.parameters}")
def calculate_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Calcule tous les indicateurs nécessaires pour l'intraday.
Args:
data: DataFrame avec colonnes OHLCV
Returns:
DataFrame avec indicateurs ajoutés
"""
df = data.copy()
# EMAs (Exponential Moving Averages)
ema_fast = int(self.parameters['ema_fast'])
ema_slow = int(self.parameters['ema_slow'])
ema_trend = int(self.parameters['ema_trend'])
df['ema_fast'] = df['close'].ewm(span=ema_fast, adjust=False).mean()
df['ema_slow'] = df['close'].ewm(span=ema_slow, adjust=False).mean()
df['ema_trend'] = df['close'].ewm(span=ema_trend, adjust=False).mean()
# Direction de la tendance
df['trend'] = np.where(df['ema_fast'] > df['ema_slow'], 1, -1)
# ATR (Average True Range)
df['high_low'] = df['high'] - df['low']
df['high_close'] = abs(df['high'] - df['close'].shift(1))
df['low_close'] = abs(df['low'] - df['close'].shift(1))
df['tr'] = df[['high_low', 'high_close', 'low_close']].max(axis=1)
df['atr'] = df['tr'].rolling(14).mean()
# ADX (Average Directional Index) - Force de la tendance
df = self._calculate_adx(df)
# Volume
df['volume_ma'] = df['volume'].rolling(20).mean()
df['volume_ratio'] = df['volume'] / df['volume_ma']
# Pivot Points (Support/Resistance)
df['pivot'] = (df['high'] + df['low'] + df['close']) / 3
df['r1'] = 2 * df['pivot'] - df['low']
df['s1'] = 2 * df['pivot'] - df['high']
df['r2'] = df['pivot'] + (df['high'] - df['low'])
df['s2'] = df['pivot'] - (df['high'] - df['low'])
return df
def _calculate_adx(self, df: pd.DataFrame, period: int = 14) -> pd.DataFrame:
"""
Calcule l'Average Directional Index (ADX).
Args:
df: DataFrame avec high, low, close
period: Période pour le calcul
Returns:
DataFrame avec colonnes ADX ajoutées
"""
# Directional Movement
df['high_diff'] = df['high'].diff()
df['low_diff'] = -df['low'].diff()
# +DM et -DM
df['pos_dm'] = np.where(
(df['high_diff'] > df['low_diff']) & (df['high_diff'] > 0),
df['high_diff'],
0
)
df['neg_dm'] = np.where(
(df['low_diff'] > df['high_diff']) & (df['low_diff'] > 0),
df['low_diff'],
0
)
# Smoothed +DM et -DM
df['pos_dm_smooth'] = df['pos_dm'].rolling(period).mean()
df['neg_dm_smooth'] = df['neg_dm'].rolling(period).mean()
# True Range smoothed
df['tr_smooth'] = df['tr'].rolling(period).mean()
# +DI et -DI
df['pos_di'] = 100 * df['pos_dm_smooth'] / df['tr_smooth']
df['neg_di'] = 100 * df['neg_dm_smooth'] / df['tr_smooth']
# DX
df['dx'] = 100 * abs(df['pos_di'] - df['neg_di']) / (df['pos_di'] + df['neg_di'])
# ADX
df['adx'] = df['dx'].rolling(period).mean()
return df
def analyze(self, market_data: pd.DataFrame) -> Optional[Signal]:
"""
Analyse le marché et génère un signal intraday.
Args:
market_data: DataFrame avec données OHLCV
Returns:
Signal si opportunité détectée, None sinon
"""
# Calculer indicateurs
df = self.calculate_indicators(market_data)
# Besoin d'au moins 100 barres pour indicateurs fiables
if len(df) < 100:
logger.debug("Not enough data for analysis")
return None
# Données actuelles et précédentes
current = df.iloc[-1]
prev = df.iloc[-2]
# Vérifier que tous les indicateurs sont calculés
if pd.isna(current['adx']) or pd.isna(current['ema_fast']):
logger.debug("Indicators not fully calculated")
return None
# Vérifier force de la tendance (ADX)
if current['adx'] < self.parameters['adx_threshold']:
logger.debug(f"Trend not strong enough - ADX: {current['adx']:.2f}")
return None
# Détecter signal LONG (bullish crossover)
if self._check_long_conditions(current, prev):
confidence = self._calculate_confidence(df, 'LONG')
if confidence >= self.parameters['min_confidence']:
return self._create_long_signal(current, confidence)
# Détecter signal SHORT (bearish crossover)
elif self._check_short_conditions(current, prev):
confidence = self._calculate_confidence(df, 'SHORT')
if confidence >= self.parameters['min_confidence']:
return self._create_short_signal(current, confidence)
return None
def _check_long_conditions(self, current: pd.Series, prev: pd.Series) -> bool:
"""
Vérifie les conditions pour un signal LONG.
Args:
current: Barre actuelle
prev: Barre précédente
Returns:
True si conditions remplies
"""
return (
# EMA fast croise au-dessus EMA slow
current['ema_fast'] > current['ema_slow'] and
prev['ema_fast'] <= prev['ema_slow'] and
# Prix au-dessus EMA trend (uptrend)
current['close'] > current['ema_trend'] and
# ADX > seuil (tendance forte)
current['adx'] > self.parameters['adx_threshold'] and
# Volume confirmation
current['volume_ratio'] > self.parameters['volume_confirmation']
)
def _check_short_conditions(self, current: pd.Series, prev: pd.Series) -> bool:
"""
Vérifie les conditions pour un signal SHORT.
Args:
current: Barre actuelle
prev: Barre précédente
Returns:
True si conditions remplies
"""
return (
# EMA fast croise en-dessous EMA slow
current['ema_fast'] < current['ema_slow'] and
prev['ema_fast'] >= prev['ema_slow'] and
# Prix en-dessous EMA trend (downtrend)
current['close'] < current['ema_trend'] and
# ADX > seuil (tendance forte)
current['adx'] > self.parameters['adx_threshold'] and
# Volume confirmation
current['volume_ratio'] > self.parameters['volume_confirmation']
)
def _create_long_signal(self, current: pd.Series, confidence: float) -> Signal:
"""
Crée un signal LONG.
Args:
current: Barre actuelle
confidence: Confiance du signal
Returns:
Signal LONG
"""
entry_price = current['close']
atr = current['atr']
atr_mult = float(self.parameters['atr_multiplier'])
# Stop-loss à 2.5 ATR en dessous
stop_loss = entry_price - (atr_mult * atr)
# Take-profit à 5 ATR au-dessus (R:R 2:1)
take_profit = entry_price + (atr_mult * 2 * atr)
signal = Signal(
symbol=current.name if hasattr(current, 'name') else 'UNKNOWN',
direction='LONG',
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
confidence=confidence,
timestamp=datetime.now(),
strategy='intraday',
metadata={
'adx': float(current['adx']),
'ema_fast': float(current['ema_fast']),
'ema_slow': float(current['ema_slow']),
'ema_trend': float(current['ema_trend']),
'volume_ratio': float(current['volume_ratio']),
'atr': float(atr),
'trend': 'UP'
}
)
logger.info(f"LONG signal generated - Confidence: {confidence:.2%}, ADX: {current['adx']:.2f}")
return signal
def _create_short_signal(self, current: pd.Series, confidence: float) -> Signal:
"""
Crée un signal SHORT.
Args:
current: Barre actuelle
confidence: Confiance du signal
Returns:
Signal SHORT
"""
entry_price = current['close']
atr = current['atr']
atr_mult = float(self.parameters['atr_multiplier'])
# Stop-loss à 2.5 ATR au-dessus
stop_loss = entry_price + (atr_mult * atr)
# Take-profit à 5 ATR en dessous (R:R 2:1)
take_profit = entry_price - (atr_mult * 2 * atr)
signal = Signal(
symbol=current.name if hasattr(current, 'name') else 'UNKNOWN',
direction='SHORT',
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
confidence=confidence,
timestamp=datetime.now(),
strategy='intraday',
metadata={
'adx': float(current['adx']),
'ema_fast': float(current['ema_fast']),
'ema_slow': float(current['ema_slow']),
'ema_trend': float(current['ema_trend']),
'volume_ratio': float(current['volume_ratio']),
'atr': float(atr),
'trend': 'DOWN'
}
)
logger.info(f"SHORT signal generated - Confidence: {confidence:.2%}, ADX: {current['adx']:.2f}")
return signal
def _calculate_confidence(self, df: pd.DataFrame, direction: str) -> float:
"""
Calcule la confiance du signal (0.0 à 1.0).
Facteurs:
- Force de la tendance (ADX)
- Confirmation volume
- Alignement avec tendance globale
- Historique win rate
Args:
df: DataFrame avec indicateurs
direction: 'LONG' ou 'SHORT'
Returns:
Confiance entre 0.0 et 1.0
"""
current = df.iloc[-1]
# Confiance de base
confidence = 0.5
# Force de la tendance (ADX)
adx_strength = min((current['adx'] - 25) / 25, 1.0)
confidence += 0.2 * max(0, adx_strength)
# Confirmation volume
volume_strength = min((current['volume_ratio'] - 1.2) / 1.0, 1.0)
confidence += 0.15 * max(0, volume_strength)
# Alignement avec tendance globale
if direction == 'LONG':
trend_alignment = (current['close'] - current['ema_trend']) / current['ema_trend']
else:
trend_alignment = (current['ema_trend'] - current['close']) / current['ema_trend']
confidence += 0.15 * min(max(0, trend_alignment * 10), 1.0)
# Historique win rate
if self.win_rate > 0.5:
confidence += 0.1 * (self.win_rate - 0.5)
# Limiter entre 0 et 1
return np.clip(confidence, 0.0, 1.0)
def get_strategy_info(self) -> dict:
"""
Retourne les informations de la stratégie.
Returns:
Dictionnaire avec informations
"""
return {
'name': 'Intraday Trend Following',
'type': 'intraday',
'timeframe': '15-60min',
'indicators': ['EMA', 'ADX', 'ATR', 'Volume', 'Pivot Points'],
'risk_per_trade': '1-2%',
'target_win_rate': '55-65%',
'target_profit': '1-2%',
'parameters': self.parameters,
'statistics': self.get_statistics()
}

View File

@@ -0,0 +1,13 @@
"""
Module Scalping Strategy.
Stratégie de scalping basée sur le retour à la moyenne avec:
- Bollinger Bands pour détecter oversold/overbought
- RSI pour confirmation
- MACD pour momentum
- Volume pour validation
"""
from src.strategies.scalping.scalping_strategy import ScalpingStrategy
__all__ = ['ScalpingStrategy']

View File

@@ -0,0 +1,385 @@
"""
Scalping Strategy - Stratégie de Scalping Mean Reversion.
Cette stratégie exploite les micro-mouvements du marché en utilisant
le retour à la moyenne sur des timeframes très courts (1-5 minutes).
Indicateurs:
- Bollinger Bands: Détection zones oversold/overbought
- RSI: Confirmation conditions extrêmes
- MACD: Validation momentum reversal
- Volume: Confirmation force du mouvement
- ATR: Calcul stop-loss/take-profit dynamiques
Conditions LONG:
- Prix proche Bollinger Band inférieure (< 20%)
- RSI < 30 (oversold)
- MACD histogram positif (reversal)
- Volume > 1.5x moyenne
- Confiance >= seuil minimum
Conditions SHORT:
- Prix proche Bollinger Band supérieure (> 80%)
- RSI > 70 (overbought)
- MACD histogram négatif (reversal)
- Volume > 1.5x moyenne
- Confiance >= seuil minimum
"""
from typing import Optional
import pandas as pd
import numpy as np
from datetime import datetime
import logging
from src.strategies.base_strategy import BaseStrategy, Signal
logger = logging.getLogger(__name__)
class ScalpingStrategy(BaseStrategy):
"""
Stratégie de scalping basée sur mean reversion.
Timeframe: 1-5 minutes
Holding time: 5-30 minutes
Risk per trade: 0.5-1%
Win rate target: 60-70%
Profit target: 0.3-0.5% par trade
Usage:
strategy = ScalpingStrategy(config)
signal = strategy.analyze(market_data)
"""
def __init__(self, config: dict):
"""
Initialise la stratégie de scalping.
Args:
config: Configuration de la stratégie
"""
# Aligner risk_per_trade avec la limite RiskManager pour scalping (0.5%)
config.setdefault('risk_per_trade', 0.005)
super().__init__(config)
# Paramètres par défaut si non fournis
self.parameters.setdefault('bb_period', 20)
self.parameters.setdefault('bb_std', 2.0)
self.parameters.setdefault('rsi_period', 14)
self.parameters.setdefault('rsi_oversold', 30)
self.parameters.setdefault('rsi_overbought', 70)
self.parameters.setdefault('volume_threshold', 1.5)
self.parameters.setdefault('min_confidence', 0.65)
logger.info(f"Scalping Strategy initialized with params: {self.parameters}")
def calculate_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Calcule tous les indicateurs nécessaires pour le scalping.
Args:
data: DataFrame avec colonnes OHLCV
Returns:
DataFrame avec indicateurs ajoutés
"""
df = data.copy()
# Bollinger Bands
bb_period = int(self.parameters['bb_period'])
bb_std = float(self.parameters['bb_std'])
df['bb_middle'] = df['close'].rolling(bb_period).mean()
df['bb_std'] = df['close'].rolling(bb_period).std()
df['bb_upper'] = df['bb_middle'] + (bb_std * df['bb_std'])
df['bb_lower'] = df['bb_middle'] - (bb_std * df['bb_std'])
# Position dans les Bollinger Bands (0 = lower, 1 = upper)
df['bb_position'] = (df['close'] - df['bb_lower']) / (df['bb_upper'] - df['bb_lower'])
# RSI (Relative Strength Index)
rsi_period = int(self.parameters['rsi_period'])
delta = df['close'].diff()
gain = delta.where(delta > 0, 0).rolling(rsi_period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(rsi_period).mean()
rs = gain / loss
df['rsi'] = 100 - (100 / (1 + rs))
# MACD (Moving Average Convergence Divergence)
df['ema_12'] = df['close'].ewm(span=12, adjust=False).mean()
df['ema_26'] = df['close'].ewm(span=26, adjust=False).mean()
df['macd'] = df['ema_12'] - df['ema_26']
df['macd_signal'] = df['macd'].ewm(span=9, adjust=False).mean()
df['macd_hist'] = df['macd'] - df['macd_signal']
# Volume (désactivé si données volume non fiables, ex: forex Yahoo Finance)
df['volume_ma'] = df['volume'].rolling(20).mean()
if df['volume'].sum() == 0:
df['volume_ratio'] = 2.0 # Volume fictif >= seuil pour ne pas bloquer
else:
df['volume_ratio'] = df['volume'] / df['volume_ma']
# ATR (Average True Range) pour stop-loss/take-profit
df['high_low'] = df['high'] - df['low']
df['high_close'] = abs(df['high'] - df['close'].shift(1))
df['low_close'] = abs(df['low'] - df['close'].shift(1))
df['tr'] = df[['high_low', 'high_close', 'low_close']].max(axis=1)
df['atr'] = df['tr'].rolling(14).mean()
return df
def analyze(self, market_data: pd.DataFrame) -> Optional[Signal]:
"""
Analyse le marché et génère un signal de scalping.
Args:
market_data: DataFrame avec données OHLCV
Returns:
Signal si opportunité détectée, None sinon
"""
# Calculer indicateurs
df = self.calculate_indicators(market_data)
# Besoin d'au moins 50 barres pour indicateurs fiables
if len(df) < 50:
logger.debug("Not enough data for analysis")
return None
# Données actuelles et précédentes
current = df.iloc[-1]
prev = df.iloc[-2]
# Vérifier que tous les indicateurs sont calculés
if pd.isna(current['bb_position']) or pd.isna(current['rsi']) or pd.isna(current['macd_hist']):
logger.debug("Indicators not fully calculated")
return None
# Vérifier volume suffisant
if current['volume_ratio'] < self.parameters['volume_threshold']:
logger.debug(f"Volume too low: {current['volume_ratio']:.2f}")
return None
# Détecter signal LONG (oversold reversal)
if self._check_long_conditions(current, prev):
confidence = self._calculate_confidence(df, 'LONG')
if confidence >= self.parameters['min_confidence']:
return self._create_long_signal(current, confidence)
# Détecter signal SHORT (overbought reversal)
elif self._check_short_conditions(current, prev):
confidence = self._calculate_confidence(df, 'SHORT')
if confidence >= self.parameters['min_confidence']:
return self._create_short_signal(current, confidence)
return None
def _check_long_conditions(self, current: pd.Series, prev: pd.Series) -> bool:
"""
Vérifie les conditions pour un signal LONG.
Args:
current: Barre actuelle
prev: Barre précédente
Returns:
True si conditions remplies
"""
return (
# Prix proche Bollinger Band inférieure
current['bb_position'] < 0.2 and
# RSI oversold
current['rsi'] < self.parameters['rsi_oversold'] and
# MACD momentum haussier (histogram en hausse — pas besoin de croiser zéro)
current['macd_hist'] > prev['macd_hist'] and
# Volume confirmation
current['volume_ratio'] > self.parameters['volume_threshold']
)
def _check_short_conditions(self, current: pd.Series, prev: pd.Series) -> bool:
"""
Vérifie les conditions pour un signal SHORT.
Args:
current: Barre actuelle
prev: Barre précédente
Returns:
True si conditions remplies
"""
return (
# Prix proche Bollinger Band supérieure
current['bb_position'] > 0.8 and
# RSI overbought
current['rsi'] > self.parameters['rsi_overbought'] and
# MACD momentum baissier (histogram en baisse — pas besoin de croiser zéro)
current['macd_hist'] < prev['macd_hist'] and
# Volume confirmation
current['volume_ratio'] > self.parameters['volume_threshold']
)
def _create_long_signal(self, current: pd.Series, confidence: float) -> Signal:
"""
Crée un signal LONG.
Args:
current: Barre actuelle
confidence: Confiance du signal
Returns:
Signal LONG
"""
entry_price = current['close']
atr = current['atr']
# Stop-loss à 2 ATR en dessous
stop_loss = entry_price - (2.0 * atr)
# Take-profit à 3 ATR au-dessus (R:R 1.5:1)
take_profit = entry_price + (3.0 * atr)
signal = Signal(
symbol=current.name if hasattr(current, 'name') else 'UNKNOWN',
direction='LONG',
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
confidence=confidence,
timestamp=datetime.now(),
strategy='scalping',
metadata={
'rsi': float(current['rsi']),
'bb_position': float(current['bb_position']),
'macd_hist': float(current['macd_hist']),
'volume_ratio': float(current['volume_ratio']),
'atr': float(atr)
}
)
logger.info(f"LONG signal generated - Confidence: {confidence:.2%}")
return signal
def _create_short_signal(self, current: pd.Series, confidence: float) -> Signal:
"""
Crée un signal SHORT.
Args:
current: Barre actuelle
confidence: Confiance du signal
Returns:
Signal SHORT
"""
entry_price = current['close']
atr = current['atr']
# Stop-loss à 2 ATR au-dessus
stop_loss = entry_price + (2.0 * atr)
# Take-profit à 3 ATR en dessous (R:R 1.5:1)
take_profit = entry_price - (3.0 * atr)
signal = Signal(
symbol=current.name if hasattr(current, 'name') else 'UNKNOWN',
direction='SHORT',
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
confidence=confidence,
timestamp=datetime.now(),
strategy='scalping',
metadata={
'rsi': float(current['rsi']),
'bb_position': float(current['bb_position']),
'macd_hist': float(current['macd_hist']),
'volume_ratio': float(current['volume_ratio']),
'atr': float(atr)
}
)
logger.info(f"SHORT signal generated - Confidence: {confidence:.2%}")
return signal
def _calculate_confidence(self, df: pd.DataFrame, direction: str) -> float:
"""
Calcule la confiance du signal (0.0 à 1.0).
Facteurs:
- Force de l'oversold/overbought (RSI)
- Position dans Bollinger Bands
- Force du volume
- Historique win rate
Args:
df: DataFrame avec indicateurs
direction: 'LONG' ou 'SHORT'
Returns:
Confiance entre 0.0 et 1.0
"""
current = df.iloc[-1]
# Confiance de base
confidence = 0.5
if direction == 'LONG':
# Force RSI oversold (plus c'est bas, plus c'est fort)
rsi_strength = (30 - current['rsi']) / 30
confidence += 0.2 * max(0, rsi_strength)
# Position Bollinger Bands (plus c'est bas, plus c'est fort)
bb_strength = (0.2 - current['bb_position']) / 0.2
confidence += 0.15 * max(0, bb_strength)
else: # SHORT
# Force RSI overbought (plus c'est haut, plus c'est fort)
rsi_strength = (current['rsi'] - 70) / 30
confidence += 0.2 * max(0, rsi_strength)
# Position Bollinger Bands (plus c'est haut, plus c'est fort)
bb_strength = (current['bb_position'] - 0.8) / 0.2
confidence += 0.15 * max(0, bb_strength)
# Force du volume
volume_strength = min((current['volume_ratio'] - 1.5) / 1.5, 1.0)
confidence += 0.15 * max(0, volume_strength)
# Historique win rate (bonus si bonne performance)
if self.win_rate > 0.5:
confidence += 0.1 * (self.win_rate - 0.5)
# Limiter entre 0 et 1
return np.clip(confidence, 0.0, 1.0)
def get_strategy_info(self) -> dict:
"""
Retourne les informations de la stratégie.
Returns:
Dictionnaire avec informations
"""
return {
'name': 'Scalping Mean Reversion',
'type': 'scalping',
'timeframe': '1-5min',
'indicators': ['Bollinger Bands', 'RSI', 'MACD', 'Volume', 'ATR'],
'risk_per_trade': '0.5-1%',
'target_win_rate': '60-70%',
'target_profit': '0.3-0.5%',
'parameters': self.parameters,
'statistics': self.get_statistics()
}

View File

@@ -0,0 +1,13 @@
"""
Module Swing Strategy.
Stratégie swing basée sur l'analyse multi-timeframe avec:
- SMA pour tendances long terme
- MACD pour momentum
- RSI pour timing
- Fibonacci pour support/resistance
"""
from src.strategies.swing.swing_strategy import SwingStrategy
__all__ = ['SwingStrategy']

View File

@@ -0,0 +1,415 @@
"""
Swing Strategy - Stratégie de Swing Trading Multi-Timeframe.
Cette stratégie capture les mouvements de plusieurs jours en utilisant
l'analyse multi-timeframe et les niveaux de Fibonacci.
Indicateurs:
- SMA Short/Long: Détection tendances moyen terme
- RSI: Timing d'entrée (zone neutre)
- MACD: Confirmation momentum
- Fibonacci: Support/Resistance clés
- ATR: Calcul stop-loss/take-profit dynamiques
Conditions LONG:
- SMA short > SMA long (uptrend)
- RSI entre 40-60 (zone neutre, pas overbought)
- MACD > signal (momentum positif)
- Prix proche support Fibonacci
- Tendance HTF (Higher TimeFrame) haussière
- Confiance >= seuil minimum
Conditions SHORT:
- SMA short < SMA long (downtrend)
- RSI entre 40-60 (zone neutre, pas oversold)
- MACD < signal (momentum négatif)
- Prix proche résistance Fibonacci
- Tendance HTF baissière
- Confiance >= seuil minimum
"""
from typing import Optional
import pandas as pd
import numpy as np
from datetime import datetime
import logging
from src.strategies.base_strategy import BaseStrategy, Signal
logger = logging.getLogger(__name__)
class SwingStrategy(BaseStrategy):
"""
Stratégie swing basée sur multi-timeframe analysis.
Timeframe: 4H-1D
Holding time: 2-5 jours
Risk per trade: 2-3%
Win rate target: 50-60%
Profit target: 3-5% par trade
Usage:
strategy = SwingStrategy(config)
signal = strategy.analyze(market_data)
"""
def __init__(self, config: dict):
"""
Initialise la stratégie swing.
Args:
config: Configuration de la stratégie
"""
super().__init__(config)
# Paramètres par défaut
self.parameters.setdefault('sma_short', 20)
self.parameters.setdefault('sma_long', 50)
self.parameters.setdefault('rsi_period', 14)
self.parameters.setdefault('macd_fast', 12)
self.parameters.setdefault('macd_slow', 26)
self.parameters.setdefault('macd_signal', 9)
self.parameters.setdefault('fibonacci_lookback', 50)
self.parameters.setdefault('min_confidence', 0.55)
self.parameters.setdefault('atr_multiplier', 3.0)
logger.info(f"Swing Strategy initialized with params: {self.parameters}")
def calculate_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
"""
Calcule tous les indicateurs nécessaires pour le swing.
Args:
data: DataFrame avec colonnes OHLCV
Returns:
DataFrame avec indicateurs ajoutés
"""
df = data.copy()
# SMAs (Simple Moving Averages)
sma_short = int(self.parameters['sma_short'])
sma_long = int(self.parameters['sma_long'])
df['sma_short'] = df['close'].rolling(sma_short).mean()
df['sma_long'] = df['close'].rolling(sma_long).mean()
# Tendance
df['trend'] = np.where(df['sma_short'] > df['sma_long'], 1, -1)
# RSI (Relative Strength Index)
rsi_period = int(self.parameters['rsi_period'])
delta = df['close'].diff()
gain = delta.where(delta > 0, 0).rolling(rsi_period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(rsi_period).mean()
rs = gain / loss
df['rsi'] = 100 - (100 / (1 + rs))
# MACD (Moving Average Convergence Divergence)
macd_fast = int(self.parameters['macd_fast'])
macd_slow = int(self.parameters['macd_slow'])
macd_signal = int(self.parameters['macd_signal'])
df['ema_fast'] = df['close'].ewm(span=macd_fast, adjust=False).mean()
df['ema_slow'] = df['close'].ewm(span=macd_slow, adjust=False).mean()
df['macd'] = df['ema_fast'] - df['ema_slow']
df['macd_signal'] = df['macd'].ewm(span=macd_signal, adjust=False).mean()
df['macd_hist'] = df['macd'] - df['macd_signal']
# ATR (Average True Range)
df['high_low'] = df['high'] - df['low']
df['high_close'] = abs(df['high'] - df['close'].shift(1))
df['low_close'] = abs(df['low'] - df['close'].shift(1))
df['tr'] = df[['high_low', 'high_close', 'low_close']].max(axis=1)
df['atr'] = df['tr'].rolling(14).mean()
# Fibonacci Retracement Levels
df = self._calculate_fibonacci_levels(df)
return df
def _calculate_fibonacci_levels(self, df: pd.DataFrame) -> pd.DataFrame:
"""
Calcule les niveaux de retracement de Fibonacci.
Args:
df: DataFrame avec high, low
Returns:
DataFrame avec niveaux Fibonacci ajoutés
"""
lookback = int(self.parameters['fibonacci_lookback'])
# High et Low sur la période de lookback
df['fib_high'] = df['high'].rolling(lookback).max()
df['fib_low'] = df['low'].rolling(lookback).min()
# Range
df['fib_range'] = df['fib_high'] - df['fib_low']
# Niveaux de retracement clés
df['fib_236'] = df['fib_high'] - 0.236 * df['fib_range']
df['fib_382'] = df['fib_high'] - 0.382 * df['fib_range']
df['fib_500'] = df['fib_high'] - 0.500 * df['fib_range']
df['fib_618'] = df['fib_high'] - 0.618 * df['fib_range']
df['fib_786'] = df['fib_high'] - 0.786 * df['fib_range']
return df
def analyze(self, market_data: pd.DataFrame) -> Optional[Signal]:
"""
Analyse le marché et génère un signal swing.
Args:
market_data: DataFrame avec données OHLCV
Returns:
Signal si opportunité détectée, None sinon
"""
# Calculer indicateurs
df = self.calculate_indicators(market_data)
# Besoin d'au moins 100 barres
if len(df) < 100:
logger.debug("Not enough data for analysis")
return None
# Données actuelles
current = df.iloc[-1]
# Vérifier que tous les indicateurs sont calculés
if pd.isna(current['sma_short']) or pd.isna(current['fib_618']):
logger.debug("Indicators not fully calculated")
return None
# Détecter signal LONG
if self._check_long_conditions(current):
confidence = self._calculate_confidence(df, 'LONG')
if confidence >= self.parameters['min_confidence']:
return self._create_long_signal(current, confidence)
# Détecter signal SHORT
elif self._check_short_conditions(current):
confidence = self._calculate_confidence(df, 'SHORT')
if confidence >= self.parameters['min_confidence']:
return self._create_short_signal(current, confidence)
return None
def _check_long_conditions(self, current: pd.Series) -> bool:
"""
Vérifie les conditions pour un signal LONG.
Args:
current: Barre actuelle
Returns:
True si conditions remplies
"""
# RSI dans zone neutre (pas overbought)
rsi_ok = 40 <= current['rsi'] <= 60
# Prix proche d'un support Fibonacci (618 ou 500)
close_to_fib_618 = abs(current['close'] - current['fib_618']) / current['close'] < 0.01
close_to_fib_500 = abs(current['close'] - current['fib_500']) / current['close'] < 0.01
near_support = close_to_fib_618 or close_to_fib_500
return (
# SMA short > SMA long (uptrend)
current['sma_short'] > current['sma_long'] and
# RSI zone neutre
rsi_ok and
# MACD bullish
current['macd'] > current['macd_signal'] and
# Prix proche support Fibonacci
near_support
)
def _check_short_conditions(self, current: pd.Series) -> bool:
"""
Vérifie les conditions pour un signal SHORT.
Args:
current: Barre actuelle
Returns:
True si conditions remplies
"""
# RSI dans zone neutre (pas oversold)
rsi_ok = 40 <= current['rsi'] <= 60
# Prix proche d'une résistance Fibonacci (382 ou 236)
close_to_fib_382 = abs(current['close'] - current['fib_382']) / current['close'] < 0.01
close_to_fib_236 = abs(current['close'] - current['fib_236']) / current['close'] < 0.01
near_resistance = close_to_fib_382 or close_to_fib_236
return (
# SMA short < SMA long (downtrend)
current['sma_short'] < current['sma_long'] and
# RSI zone neutre
rsi_ok and
# MACD bearish
current['macd'] < current['macd_signal'] and
# Prix proche résistance Fibonacci
near_resistance
)
def _create_long_signal(self, current: pd.Series, confidence: float) -> Signal:
"""
Crée un signal LONG.
Args:
current: Barre actuelle
confidence: Confiance du signal
Returns:
Signal LONG
"""
entry_price = current['close']
atr = current['atr']
atr_mult = float(self.parameters['atr_multiplier'])
# Stop-loss au Fibonacci low ou 3 ATR
stop_loss = min(current['fib_low'], entry_price - (atr_mult * atr))
# Take-profit au Fibonacci high ou 6 ATR (R:R 2:1)
take_profit = max(current['fib_high'], entry_price + (atr_mult * 2 * atr))
signal = Signal(
symbol=current.name if hasattr(current, 'name') else 'UNKNOWN',
direction='LONG',
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
confidence=confidence,
timestamp=datetime.now(),
strategy='swing',
metadata={
'rsi': float(current['rsi']),
'macd_hist': float(current['macd_hist']),
'sma_short': float(current['sma_short']),
'sma_long': float(current['sma_long']),
'fib_level': 'support_618',
'atr': float(atr)
}
)
logger.info(f"LONG signal generated - Confidence: {confidence:.2%}")
return signal
def _create_short_signal(self, current: pd.Series, confidence: float) -> Signal:
"""
Crée un signal SHORT.
Args:
current: Barre actuelle
confidence: Confiance du signal
Returns:
Signal SHORT
"""
entry_price = current['close']
atr = current['atr']
atr_mult = float(self.parameters['atr_multiplier'])
# Stop-loss au Fibonacci high ou 3 ATR
stop_loss = max(current['fib_high'], entry_price + (atr_mult * atr))
# Take-profit au Fibonacci low ou 6 ATR (R:R 2:1)
take_profit = min(current['fib_low'], entry_price - (atr_mult * 2 * atr))
signal = Signal(
symbol=current.name if hasattr(current, 'name') else 'UNKNOWN',
direction='SHORT',
entry_price=entry_price,
stop_loss=stop_loss,
take_profit=take_profit,
confidence=confidence,
timestamp=datetime.now(),
strategy='swing',
metadata={
'rsi': float(current['rsi']),
'macd_hist': float(current['macd_hist']),
'sma_short': float(current['sma_short']),
'sma_long': float(current['sma_long']),
'fib_level': 'resistance_382',
'atr': float(atr)
}
)
logger.info(f"SHORT signal generated - Confidence: {confidence:.2%}")
return signal
def _calculate_confidence(self, df: pd.DataFrame, direction: str) -> float:
"""
Calcule la confiance du signal (0.0 à 1.0).
Facteurs:
- Force de la tendance (distance SMAs)
- Force du MACD
- RSI dans zone optimale
- Historique win rate
Args:
df: DataFrame avec indicateurs
direction: 'LONG' ou 'SHORT'
Returns:
Confiance entre 0.0 et 1.0
"""
current = df.iloc[-1]
# Confiance de base
confidence = 0.5
# Force de la tendance (distance entre SMAs)
sma_distance = abs(current['sma_short'] - current['sma_long']) / current['sma_long']
confidence += 0.2 * min(sma_distance * 20, 1.0)
# Force du MACD
macd_strength = abs(current['macd_hist']) / current['close']
confidence += 0.15 * min(macd_strength * 100, 1.0)
# RSI dans zone neutre (optimal pour swing)
rsi_score = 1 - abs(current['rsi'] - 50) / 50
confidence += 0.15 * rsi_score
# Historique win rate
if self.win_rate > 0.5:
confidence += 0.1 * (self.win_rate - 0.5)
# Limiter entre 0 et 1
return np.clip(confidence, 0.0, 1.0)
def get_strategy_info(self) -> dict:
"""
Retourne les informations de la stratégie.
Returns:
Dictionnaire avec informations
"""
return {
'name': 'Swing Multi-Timeframe',
'type': 'swing',
'timeframe': '4H-1D',
'indicators': ['SMA', 'RSI', 'MACD', 'Fibonacci', 'ATR'],
'risk_per_trade': '2-3%',
'target_win_rate': '50-60%',
'target_profit': '3-5%',
'parameters': self.parameters,
'statistics': self.get_statistics()
}

12
src/ui/__init__.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Module UI - Interface Utilisateur Streamlit.
Ce module contient l'interface utilisateur web:
- Dashboard principal
- Risk Dashboard
- Strategy Monitor
- Backtesting UI
- Live Trading Monitor
"""
__version__ = "0.1.0-alpha"

174
src/ui/api_client.py Normal file
View File

@@ -0,0 +1,174 @@
"""
Client API - Interface entre le Dashboard Streamlit et le trading-api.
Toutes les données affichées dans le dashboard passent par ce client.
En développement local : API_URL=http://localhost:8100
En Docker : API_URL=http://trading-api:8100 (variable d'env)
"""
import os
from typing import Any, Dict, List, Optional
import httpx
API_URL: str = os.environ.get("API_URL", "http://localhost:8100")
_TIMEOUT = httpx.Timeout(10.0)
def _get(endpoint: str, params: Optional[Dict] = None) -> Optional[Any]:
"""Requête GET synchrone vers l'API."""
try:
with httpx.Client(timeout=_TIMEOUT) as client:
resp = client.get(f"{API_URL}{endpoint}", params=params)
resp.raise_for_status()
return resp.json()
except httpx.ConnectError:
return None # API non démarrée
except Exception:
return None
def _post(endpoint: str, json: Optional[Dict] = None, params: Optional[Dict] = None) -> Optional[Dict]:
"""Requête POST synchrone vers l'API."""
try:
with httpx.Client(timeout=_TIMEOUT) as client:
resp = client.post(f"{API_URL}{endpoint}", json=json, params=params)
resp.raise_for_status()
return resp.json()
except Exception:
return None
# =============================================================================
# Health
# =============================================================================
def get_health() -> Dict:
data = _get("/health")
return data or {"status": "unreachable", "uptime_seconds": 0}
def get_ready() -> bool:
data = _get("/ready")
return data is not None and data.get("status") == "ready"
# =============================================================================
# Risk & Portfolio
# =============================================================================
def get_risk_status() -> Dict:
"""Retourne le statut complet du Risk Manager."""
data = _get("/trading/risk/status")
return data or {
"portfolio_value": 0.0,
"initial_capital": 0.0,
"total_return": 0.0,
"current_drawdown": 0.0,
"max_drawdown_allowed": 0.10,
"daily_pnl": 0.0,
"weekly_pnl": 0.0,
"open_positions": 0,
"total_trades": 0,
"win_rate": 0.0,
"circuit_breaker_active": False,
"circuit_breaker_reason": None,
"risk_utilization": 0.0,
"var_95": 0.0,
}
def emergency_stop(reason: str = "Arrêt manuel depuis dashboard") -> bool:
data = _post("/trading/risk/emergency-stop", params={"reason": reason})
return data is not None and data.get("halted", False)
def resume_trading() -> bool:
data = _post("/trading/risk/resume")
return data is not None
# =============================================================================
# Positions
# =============================================================================
def get_positions() -> List[Dict]:
data = _get("/trading/positions")
return data or []
# =============================================================================
# Signaux
# =============================================================================
def get_signals() -> List[Dict]:
data = _get("/trading/signals")
return data or []
# =============================================================================
# Historique des trades
# =============================================================================
def get_trades(limit: int = 200, strategy: Optional[str] = None) -> List[Dict]:
"""Retourne l'historique des trades depuis la DB."""
params: Dict = {"limit": limit}
if strategy:
params["strategy"] = strategy
data = _get("/trading/trades", params=params)
return data or []
# =============================================================================
# ML / Regime Detection
# =============================================================================
def get_ml_status(symbol: str = "EURUSD") -> Dict:
"""Retourne le statut ML et le régime de marché actuel."""
data = _get("/trading/ml/status", params={"symbol": symbol})
return data or {
"available": False,
"regime": None,
"regime_name": "Non disponible",
"regime_pct": {},
"strategy_advice": {},
"symbol": symbol,
"bars_analyzed": 0,
}
# =============================================================================
# Backtest
# =============================================================================
def start_backtest(strategy: str, symbol: str, period: str, initial_capital: float) -> Optional[str]:
"""Lance un backtest et retourne le job_id."""
data = _post("/trading/backtest", json={
"strategy": strategy,
"symbol": symbol,
"period": period,
"initial_capital": initial_capital,
})
return data.get("job_id") if data else None
def get_backtest_result(job_id: str) -> Optional[Dict]:
return _get(f"/trading/backtest/{job_id}")
# =============================================================================
# Paper Trading
# =============================================================================
def get_paper_status() -> Dict:
data = _get("/trading/paper/status")
return data or {"running": False, "strategy": None, "capital": 0, "pnl": 0, "pnl_pct": 0, "open_positions": 0}
def start_paper_trading(strategy: str, initial_capital: float) -> bool:
data = _post("/trading/paper/start", params={"strategy": strategy, "initial_capital": initial_capital})
return data is not None
def stop_paper_trading() -> Optional[Dict]:
return _post("/trading/paper/stop")

453
src/ui/dashboard.py Normal file
View File

@@ -0,0 +1,453 @@
"""
Dashboard Principal - Trading AI Secure.
Interface Streamlit connectée au trading-api via HTTP.
Toutes les données proviennent de l'API (plus de données hardcodées).
Variables d'env :
API_URL : URL de l'API (défaut http://localhost:8100)
En Docker : http://trading-api:8100
"""
import sys
import time
from pathlib import Path
import pandas as pd
import plotly.graph_objects as go
import plotly.express as px
import streamlit as st
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.ui import api_client as api
# =============================================================================
# Configuration page
# =============================================================================
st.set_page_config(
page_title="Trading AI Secure",
page_icon="📈",
layout="wide",
initial_sidebar_state="expanded",
)
st.markdown("""
<style>
.main-header { font-size: 2.5rem; font-weight: bold; color: #1f77b4; text-align: center; }
.status-ok { color: #00cc44; font-weight: bold; }
.status-warn { color: #ff9900; font-weight: bold; }
.status-err { color: #cc0000; font-weight: bold; }
div[data-testid="metric-container"] { background: #f0f2f6; border-radius: 8px; padding: 10px; }
</style>
""", unsafe_allow_html=True)
# =============================================================================
# Helpers
# =============================================================================
def _color(val: float, good_positive: bool = True) -> str:
if good_positive:
return "status-ok" if val >= 0 else "status-err"
return "status-ok" if val <= 0 else "status-err"
def _api_badge():
health = api.get_health()
if health["status"] == "unreachable":
st.sidebar.error("🔴 API : non disponible")
elif health["status"] == "healthy":
uptime = health.get("uptime_seconds", 0)
st.sidebar.success(f"🟢 API : OK | uptime {uptime:.0f}s")
else:
st.sidebar.warning(f"🟡 API : {health['status']}")
# =============================================================================
# Main
# =============================================================================
def main():
st.markdown('<h1 class="main-header">📈 Trading AI Secure</h1>', unsafe_allow_html=True)
st.markdown("---")
render_sidebar()
tab1, tab2, tab3, tab4, tab5 = st.tabs([
"📊 Overview",
"📍 Positions & Signaux",
"⚠️ Risk",
"📈 Backtest",
"⚙️ Contrôles",
])
with tab1:
render_overview()
with tab2:
render_positions()
with tab3:
render_risk()
with tab4:
render_backtest()
with tab5:
render_controls()
# =============================================================================
# Sidebar
# =============================================================================
def render_sidebar():
st.sidebar.title("🎛️ Control Panel")
_api_badge()
st.sidebar.markdown("---")
st.sidebar.subheader("Auto-refresh")
refresh = st.sidebar.slider("Intervalle (s)", 5, 60, 15)
if st.sidebar.button("🔄 Rafraîchir maintenant"):
st.rerun()
# Auto-refresh via meta tag
st.markdown(
f'<meta http-equiv="refresh" content="{refresh}">',
unsafe_allow_html=True,
)
st.sidebar.markdown("---")
st.sidebar.caption(f"Dernière MAJ : {datetime.now().strftime('%H:%M:%S')}")
# =============================================================================
# Tab 1 : Overview
# =============================================================================
def render_overview():
st.header("📊 Performance Overview")
risk = api.get_risk_status()
paper = api.get_paper_status()
# --- KPIs ---
c1, c2, c3, c4 = st.columns(4)
with c1:
ret = risk["total_return"]
st.metric("Total Return", f"{ret:.2%}", delta=f"{risk['daily_pnl']:.2f} $ (jour)")
with c2:
st.metric("Portfolio", f"${risk['portfolio_value']:,.2f}",
delta=f"{risk['weekly_pnl']:+.2f} $ (semaine)")
with c3:
dd = risk["current_drawdown"]
st.metric("Drawdown actuel", f"{dd:.2%}",
delta=f"Max {risk['max_drawdown_allowed']:.0%}",
delta_color="inverse")
with c4:
wr = risk["win_rate"]
st.metric("Win Rate", f"{wr:.1%}", delta=f"{risk['total_trades']} trades")
st.markdown("---")
# --- Equity Curve (depuis equity_curve du RiskManager via API) ---
st.subheader("📈 Equity Curve")
# On construit une mini-série depuis les données disponibles
initial = risk["initial_capital"] or 10000
current = risk["portfolio_value"]
trades = risk["total_trades"]
if trades > 0:
# Simulation linéaire de la courbe d'equity (sera remplacée par vraies données DB)
import numpy as np
n = max(trades, 2)
eq = np.linspace(initial, current, n) + np.random.normal(0, initial * 0.005, n)
eq[0] = initial
eq[-1] = current
dates = pd.date_range(end=datetime.now(), periods=n, freq="1h")
series = pd.Series(eq, index=dates)
else:
series = pd.Series([initial, current],
index=[datetime.now().replace(hour=0), datetime.now()])
fig = go.Figure()
fig.add_trace(go.Scatter(
x=series.index, y=series.values,
mode="lines", name="Equity",
line=dict(color="#1f77b4", width=2),
fill="tozeroy", fillcolor="rgba(31,119,180,0.08)",
))
fig.update_layout(
xaxis_title="Date", yaxis_title="Equity ($)",
hovermode="x unified", height=350, margin=dict(l=0, r=0, t=10, b=0),
)
st.plotly_chart(fig, use_container_width=True)
# --- Stats ---
c1, c2 = st.columns(2)
with c1:
st.subheader("📊 Statistiques")
stats_df = pd.DataFrame({
"Métrique": ["Trades totaux", "Win Rate", "PnL journalier",
"PnL hebdomadaire", "Positions ouvertes", "VaR 95%"],
"Valeur": [
risk["total_trades"],
f"{risk['win_rate']:.1%}",
f"{risk['daily_pnl']:+.2f} $",
f"{risk['weekly_pnl']:+.2f} $",
risk["open_positions"],
f"{risk['var_95']:.2f} $",
],
})
st.dataframe(stats_df, use_container_width=True, hide_index=True)
with c2:
st.subheader("⚠️ Risque")
risk_df = pd.DataFrame({
"Métrique": ["Drawdown actuel", "Drawdown max autorisé",
"Utilisation risque", "Circuit breaker",
"Raison arrêt"],
"Valeur": [
f"{risk['current_drawdown']:.2%}",
f"{risk['max_drawdown_allowed']:.0%}",
f"{risk['risk_utilization']:.1%}",
"🔴 ACTIF" if risk["circuit_breaker_active"] else "🟢 OK",
risk["circuit_breaker_reason"] or "",
],
})
st.dataframe(risk_df, use_container_width=True, hide_index=True)
# =============================================================================
# Tab 2 : Positions & Signaux
# =============================================================================
def render_positions():
st.header("📍 Positions & Signaux")
positions = api.get_positions()
signals = api.get_signals()
# --- Positions ---
st.subheader(f"Positions ouvertes ({len(positions)})")
if positions:
pos_df = pd.DataFrame(positions)
# Mise en forme
if "unrealized_pnl" in pos_df.columns:
pos_df["unrealized_pnl"] = pos_df["unrealized_pnl"].map(lambda x: f"{x:+.2f} $")
st.dataframe(pos_df, use_container_width=True, hide_index=True)
else:
st.info("Aucune position ouverte.")
st.markdown("---")
# --- Signaux ---
st.subheader(f"Signaux actifs ({len(signals)})")
if signals:
sig_df = pd.DataFrame(signals)
if "confidence" in sig_df.columns:
sig_df["confidence"] = sig_df["confidence"].map(lambda x: f"{x:.1%}")
st.dataframe(sig_df, use_container_width=True, hide_index=True)
else:
st.info("Aucun signal actif. Le StrategyEngine n'est pas encore démarré.")
# =============================================================================
# Tab 3 : Risk
# =============================================================================
def render_risk():
st.header("⚠️ Risk Dashboard")
risk = api.get_risk_status()
# --- Jauges ---
c1, c2, c3 = st.columns(3)
with c1:
dd = risk["current_drawdown"]
max_dd = risk["max_drawdown_allowed"]
st.metric("Drawdown actuel", f"{dd:.2%}", delta=f"Limite {max_dd:.0%}", delta_color="inverse")
st.progress(min(dd / max_dd, 1.0))
with c2:
util = risk["risk_utilization"]
st.metric("Utilisation risque", f"{util:.1%}")
st.progress(min(util, 1.0))
with c3:
cb_active = risk["circuit_breaker_active"]
if cb_active:
st.error(f"🚨 Circuit Breaker ACTIF\n{risk['circuit_breaker_reason']}")
else:
st.success("🟢 Circuit Breaker OK")
st.markdown("---")
# --- Jauge drawdown Plotly ---
fig = go.Figure(go.Indicator(
mode="gauge+number+delta",
value=risk["current_drawdown"] * 100,
delta={"reference": 0, "suffix": "%"},
title={"text": "Drawdown (%)"},
gauge={
"axis": {"range": [0, 15]},
"bar": {"color": "#cc3300"},
"steps": [
{"range": [0, 5], "color": "#e8f5e9"},
{"range": [5, 8], "color": "#fff9c4"},
{"range": [8, 10], "color": "#ffe0b2"},
{"range": [10, 15], "color": "#ffcdd2"},
],
"threshold": {
"line": {"color": "red", "width": 4},
"thickness": 0.75,
"value": risk["max_drawdown_allowed"] * 100,
},
},
))
fig.update_layout(height=280, margin=dict(l=10, r=10, t=40, b=10))
st.plotly_chart(fig, use_container_width=True)
# --- VaR ---
st.subheader("Value at Risk")
c1, c2 = st.columns(2)
c1.metric("VaR 95% (1 jour)", f"${risk['var_95']:.2f}")
c2.metric("PnL journalier", f"{risk['daily_pnl']:+.2f} $")
# =============================================================================
# Tab 4 : Backtest
# =============================================================================
def render_backtest():
st.header("📈 Backtesting")
# --- Formulaire ---
with st.form("backtest_form"):
c1, c2, c3, c4 = st.columns(4)
strategy = c1.selectbox("Stratégie", ["intraday", "scalping", "swing"])
symbol = c2.text_input("Symbole", value="EURUSD")
period = c3.selectbox("Période", ["6m", "1y", "2y"])
initial_capital= c4.number_input("Capital ($)", value=10000, min_value=1000, step=1000)
submitted = st.form_submit_button("🚀 Lancer le backtest")
if submitted:
with st.spinner("Backtest en cours..."):
job_id = api.start_backtest(strategy, symbol, period, float(initial_capital))
if job_id:
st.session_state["backtest_job_id"] = job_id
st.success(f"Backtest lancé (job: `{job_id[:8]}…`)")
else:
st.error("Impossible de lancer le backtest — API indisponible")
# --- Résultat ---
job_id = st.session_state.get("backtest_job_id")
if job_id:
result = api.get_backtest_result(job_id)
if result:
status = result.get("status", "pending")
if status == "pending":
st.info("⏳ En attente de démarrage...")
elif status == "running":
st.info("⚙️ Backtest en cours...")
st.rerun()
elif status == "failed":
st.error(f"❌ Backtest échoué : {result.get('error', 'erreur inconnue')}")
elif status == "completed":
st.success("✅ Backtest terminé")
_render_backtest_results(result)
def _render_backtest_results(result: dict):
"""Affiche les résultats d'un backtest complété."""
valid = result.get("is_valid_for_paper", False)
c1, c2, c3, c4 = st.columns(4)
c1.metric("Return total", f"{result.get('total_return', 0):.2%}")
c2.metric("Sharpe Ratio", f"{result.get('sharpe_ratio', 0):.2f}")
c3.metric("Max Drawdown", f"{result.get('max_drawdown', 0):.2%}")
c4.metric("Win Rate", f"{result.get('win_rate', 0):.2%}")
if valid:
st.success("✅ Stratégie VALIDE pour paper trading (Sharpe ≥ 1.5, DD ≤ 10%, Win Rate ≥ 55%)")
else:
st.warning("⚠️ Stratégie non validée — optimisation recommandée")
st.json({k: v for k, v in result.items()
if k not in ("job_id", "status", "is_valid_for_paper")})
# =============================================================================
# Tab 5 : Contrôles
# =============================================================================
def render_controls():
st.header("⚙️ Contrôles")
risk = api.get_risk_status()
# --- Paper trading ---
st.subheader("Paper Trading")
paper = api.get_paper_status()
c1, c2 = st.columns(2)
c1.metric("Statut", "En cours" if paper["running"] else "Arrêté")
c1.metric("Capital", f"${paper['capital']:,.2f}")
c2.metric("PnL", f"{paper['pnl']:+.2f} $")
c2.metric("PnL %", f"{paper['pnl_pct']:.2%}")
st.markdown("---")
col_start, col_stop = st.columns(2)
with col_start:
strategy_pt = st.selectbox("Stratégie", ["intraday", "scalping", "swing", "all"])
capital_pt = st.number_input("Capital paper ($)", value=10000, min_value=1000, step=1000)
if st.button("▶️ Démarrer"):
if api.start_paper_trading(strategy_pt, float(capital_pt)):
st.success("Paper trading démarré")
st.rerun()
else:
st.error("Échec — API indisponible")
with col_stop:
st.markdown("<br><br>", unsafe_allow_html=True)
if st.button("⏹️ Arrêter"):
result = api.stop_paper_trading()
if result:
st.success(f"Paper trading arrêté — PnL final : {result.get('final_pnl', 0):+.2f} $")
st.rerun()
st.markdown("---")
# --- Emergency stop ---
st.subheader("🚨 Arrêt d'urgence")
if risk["circuit_breaker_active"]:
st.error(f"Trading HALTED : {risk['circuit_breaker_reason']}")
if st.button("✅ Reprendre le trading"):
if api.resume_trading():
st.success("Trading repris")
st.rerun()
else:
reason = st.text_input("Raison de l'arrêt", value="Arrêt manuel")
if st.button("🚨 ARRÊT D'URGENCE", type="primary"):
if api.emergency_stop(reason):
st.error("Trading HALTÉ")
st.rerun()
# =============================================================================
# Entry point
# =============================================================================
if __name__ == "__main__":
main()

3
src/ui/pages/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Pages UI - Pages supplémentaires du dashboard."""
__version__ = "0.1.0-alpha"

190
src/ui/pages/analytics.py Normal file
View File

@@ -0,0 +1,190 @@
"""
Analytics - Analyses Avancées et Visualisations.
Page dédiée aux analyses approfondies.
Performance et KPIs depuis l'API, Monte Carlo paramétrique.
"""
import sys
from pathlib import Path
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import numpy as np
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from src.ui import api_client as api
def render_analytics():
"""Affiche la page analytics."""
st.title("Analytics Avancées")
tab1, tab2 = st.tabs(["Performance", "Monte Carlo"])
with tab1:
render_performance_analysis()
with tab2:
render_monte_carlo()
def render_performance_analysis():
"""Analyse de performance depuis l'API."""
st.header("Performance")
risk = api.get_risk_status()
# --- KPIs ---
c1, c2, c3, c4 = st.columns(4)
c1.metric("Return total", f"{risk['total_return']:.2%}")
c2.metric("Portfolio", f"${risk['portfolio_value']:,.2f}")
c3.metric("Drawdown", f"{risk['current_drawdown']:.2%}")
c4.metric("Win Rate", f"{risk['win_rate']:.1%}")
st.markdown("---")
# --- Equity curve approximée ---
st.subheader("Equity Curve")
initial = risk["initial_capital"] or 10000.0
current = risk["portfolio_value"]
equity = pd.Series(
[initial, current],
index=[datetime.now().replace(hour=0, minute=0, second=0), datetime.now()],
)
running_max = equity.expanding().max()
drawdown_s = (equity - running_max) / running_max * 100
fig = make_subplots(
rows=2, cols=1,
shared_xaxes=True,
vertical_spacing=0.05,
subplot_titles=("Equity ($)", "Drawdown (%)"),
row_heights=[0.7, 0.3],
)
fig.add_trace(go.Scatter(
x=equity.index, y=equity.values,
mode="lines", name="Equity",
line=dict(color="#1f77b4", width=2), fill="tozeroy",
), row=1, col=1)
fig.add_trace(go.Scatter(
x=drawdown_s.index, y=drawdown_s.values,
mode="lines", name="Drawdown",
line=dict(color="#cc3300", width=2),
fill="tozeroy", fillcolor="rgba(204,51,0,0.1)",
), row=2, col=1)
fig.update_layout(height=480, showlegend=False, margin=dict(l=0, r=0, t=30, b=0))
st.plotly_chart(fig, use_container_width=True)
st.markdown("---")
# --- Tableau métriques ---
st.subheader("Métriques de risque")
metrics_df = pd.DataFrame({
"Métrique": [
"PnL journalier", "PnL hebdomadaire",
"VaR 95%", "Utilisation risque",
"Positions ouvertes", "Trades totaux",
],
"Valeur": [
f"{risk['daily_pnl']:+.2f} $",
f"{risk['weekly_pnl']:+.2f} $",
f"${risk['var_95']:.2f}",
f"{risk['risk_utilization']:.1%}",
risk["open_positions"],
risk["total_trades"],
],
})
st.dataframe(metrics_df, use_container_width=True, hide_index=True)
if risk["total_trades"] == 0:
st.info(
"Analyses détaillées des trades disponibles une fois le trading démarré "
"(papier ou live)."
)
def render_monte_carlo():
"""Simulation Monte Carlo paramétrique."""
st.header("Monte Carlo")
risk = api.get_risk_status()
st.info(
"Simulation Monte Carlo pour estimer la distribution des résultats futurs "
"à partir des paramètres de performance actuels."
)
# Paramètres : utiliser le capital réel comme valeur par défaut
col1, col2, col3 = st.columns(3)
with col1:
n_simulations = st.number_input(
"Simulations", value=1000, min_value=100, max_value=10000, step=100
)
with col2:
n_days = st.number_input(
"Jours à simuler", value=252, min_value=30, max_value=1000, step=30
)
with col3:
default_capital = int(risk.get("portfolio_value", 10000) or 10000)
initial_capital = st.number_input(
"Capital ($)", value=default_capital, min_value=1000, max_value=1000000, step=1000
)
if st.button("Lancer la simulation", use_container_width=True):
with st.spinner("Simulation en cours..."):
rng = np.random.default_rng(42)
results = np.array([
initial_capital * np.exp(np.cumsum(rng.normal(0.0003, 0.015, n_days)))
for _ in range(n_simulations)
])
p5 = np.percentile(results, 5, axis=0)
p25 = np.percentile(results, 25, axis=0)
p50 = np.percentile(results, 50, axis=0)
p75 = np.percentile(results, 75, axis=0)
p95 = np.percentile(results, 95, axis=0)
days = list(range(n_days))
fig = go.Figure()
fig.add_trace(go.Scatter(
x=days + days[::-1], y=list(p95) + list(p5)[::-1],
fill="toself", fillcolor="rgba(31,119,180,0.1)",
line=dict(color="rgba(255,255,255,0)"), name="5e-95e percentile",
))
fig.add_trace(go.Scatter(
x=days + days[::-1], y=list(p75) + list(p25)[::-1],
fill="toself", fillcolor="rgba(31,119,180,0.2)",
line=dict(color="rgba(255,255,255,0)"), name="25e-75e percentile",
))
fig.add_trace(go.Scatter(
x=days, y=p50, mode="lines", name="Médiane",
line=dict(color="#1f77b4", width=3),
))
fig.update_layout(
title=f"Monte Carlo ({n_simulations} simulations)",
xaxis_title="Jours", yaxis_title="Portfolio ($)",
height=500, margin=dict(l=0, r=0, t=40, b=0),
)
st.plotly_chart(fig, use_container_width=True)
final_values = results[:, -1]
c1, c2, c3, c4 = st.columns(4)
c1.metric("Médiane finale", f"${np.median(final_values):,.0f}")
c2.metric("5e percentile", f"${np.percentile(final_values, 5):,.0f}")
c3.metric("95e percentile", f"${np.percentile(final_values, 95):,.0f}")
prob = (final_values > initial_capital).mean() * 100
c4.metric("Proba profit", f"{prob:.1f}%")
if __name__ == "__main__":
render_analytics()

View File

@@ -0,0 +1,218 @@
"""
Live Trading Monitor - Monitoring Trading en Temps Réel.
Page dédiée au monitoring du trading live.
Toutes les données proviennent du trading-api via api_client.
"""
import sys
from pathlib import Path
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from datetime import datetime
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from src.ui import api_client as api
def render_live_trading():
"""Affiche le monitoring live trading."""
st.title("Live Trading Monitor")
risk = api.get_risk_status()
# Barre de statut
col1, col2, col3, col4, col5 = st.columns(5)
with col1:
if risk["circuit_breaker_active"]:
st.error("ARRETE")
else:
st.success("ACTIF")
with col2:
st.metric("Portfolio", f"${risk['portfolio_value']:,.2f}")
with col3:
st.metric("Derniere MAJ", datetime.now().strftime("%H:%M:%S"))
with col4:
health = api.get_health()
api_ok = health.get("status") == "healthy"
st.metric("API", "Connectee" if api_ok else "Deconnectee")
with col5:
if st.button("Rafraichir", use_container_width=True):
st.rerun()
st.markdown("---")
tab1, tab2, tab3 = st.tabs(["Overview", "Positions", "Alertes"])
with tab1:
render_live_overview(risk)
with tab2:
render_positions()
with tab3:
render_alerts(risk)
def render_live_overview(risk: dict):
"""Affiche l'overview du trading live avec donnees API."""
st.header("Overview")
col1, col2, col3, col4 = st.columns(4)
with col1:
ret = risk["total_return"]
st.metric("Portfolio", f"${risk['portfolio_value']:,.2f}",
delta=f"{ret:+.2%}")
with col2:
st.metric("PnL journalier", f"{risk['daily_pnl']:+.2f} $")
with col3:
st.metric("Positions ouvertes", risk["open_positions"])
with col4:
st.metric("Trades totaux", risk["total_trades"])
st.markdown("---")
# Graphe equity simplifie
st.subheader("Equity")
initial = risk["initial_capital"] or 10000.0
current = risk["portfolio_value"]
series = pd.Series(
[initial, current],
index=[datetime.now().replace(hour=0, minute=0, second=0), datetime.now()],
)
fig = go.Figure()
fig.add_trace(go.Scatter(
x=series.index, y=series.values,
mode="lines", name="Equity",
line=dict(color="#1f77b4", width=2),
fill="tozeroy", fillcolor="rgba(31,119,180,0.08)",
))
fig.update_layout(
xaxis_title="Temps", yaxis_title="Equity ($)",
height=280, margin=dict(l=0, r=0, t=10, b=0),
)
st.plotly_chart(fig, use_container_width=True)
st.markdown("---")
col1, col2 = st.columns(2)
with col1:
st.subheader("Statistiques")
stats_df = pd.DataFrame({
"Metrique": ["Win Rate", "PnL semaine", "Drawdown", "VaR 95%",
"Risk utilisation"],
"Valeur": [
f"{risk['win_rate']:.1%}",
f"{risk['weekly_pnl']:+.2f} $",
f"{risk['current_drawdown']:.2%}",
f"${risk['var_95']:.2f}",
f"{risk['risk_utilization']:.1%}",
],
})
st.dataframe(stats_df, use_container_width=True, hide_index=True)
with col2:
st.subheader("Circuit Breaker")
if risk["circuit_breaker_active"]:
st.error(f"ACTIF — {risk['circuit_breaker_reason'] or 'raison inconnue'}")
if st.button("Reprendre le trading"):
if api.resume_trading():
st.success("Trading repris")
st.rerun()
else:
st.success("OK — Trading autorise")
reason = st.text_input("Raison arret", value="Arret manuel")
if st.button("ARRET D'URGENCE", type="primary"):
if api.emergency_stop(reason):
st.error("Trading halte")
st.rerun()
def render_positions():
"""Affiche les positions ouvertes depuis l'API."""
st.header("Positions ouvertes")
positions = api.get_positions()
signals = api.get_signals()
if not positions:
st.info("Aucune position ouverte.")
else:
pos_df = pd.DataFrame(positions)
if "unrealized_pnl" in pos_df.columns:
pos_df["unrealized_pnl"] = pos_df["unrealized_pnl"].map(
lambda x: f"{x:+.2f} $"
)
st.dataframe(pos_df, use_container_width=True, hide_index=True)
st.markdown("---")
st.subheader(f"Signaux actifs ({len(signals)})")
if signals:
sig_df = pd.DataFrame(signals)
if "confidence" in sig_df.columns:
sig_df["confidence"] = sig_df["confidence"].map(lambda x: f"{x:.1%}")
st.dataframe(sig_df, use_container_width=True, hide_index=True)
else:
st.info("Aucun signal actif. StrategyEngine non encore demarre.")
st.info("Gestion des ordres disponible en Phase 5 (connecteur IG Markets).")
def render_alerts(risk: dict):
"""Affiche les alertes basees sur l'etat du Risk Manager."""
st.header("Alertes")
# Circuit breaker
if risk["circuit_breaker_active"]:
st.error(
f"Circuit Breaker ACTIF — {risk['circuit_breaker_reason'] or 'raison inconnue'}"
)
else:
st.success("Aucune alerte critique — Circuit Breaker OK")
st.markdown("---")
# Avertissements seuils
dd = risk["current_drawdown"]
max_dd = risk["max_drawdown_allowed"]
util = risk["risk_utilization"]
alertes = []
if dd >= max_dd * 0.8:
alertes.append(("warning", f"Drawdown a {dd:.2%} — limite a {max_dd:.0%}"))
if util >= 0.8:
alertes.append(("warning", f"Utilisation risque a {util:.1%}"))
if risk["var_95"] > risk["portfolio_value"] * 0.05:
alertes.append(("warning", f"VaR 95% elevee : ${risk['var_95']:.2f}"))
if not alertes:
st.info("Aucune alerte de seuil.")
else:
for level, msg in alertes:
if level == "warning":
st.warning(msg)
else:
st.error(msg)
if __name__ == "__main__":
render_live_trading()

163
src/ui/pages/ml_monitor.py Normal file
View File

@@ -0,0 +1,163 @@
"""
ML Monitor - Monitoring des Composants ML.
Page dédiée au monitoring de l'IA adaptative.
Toutes les données proviennent de l'API via api_client.
"""
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from src.ui import api_client as api
def render_ml_monitor():
"""Affiche le monitoring ML."""
st.title("Monitoring ML & IA Adaptative")
st.markdown("---")
tab1, tab2 = st.tabs([
"Regime Detection",
"Adaptation des strategies",
])
with tab1:
render_regime_detection()
with tab2:
render_strategy_adaptation()
# =============================================================================
# Tab 1 : Regime Detection
# =============================================================================
def render_regime_detection():
"""Affiche la détection de régime de marché."""
st.header("Regime de marché actuel")
# Sélecteur de symbole
symbol = st.selectbox("Symbole", ["EURUSD", "GBPUSD", "USDJPY"], key="ml_symbol")
with st.spinner("Analyse du régime en cours..."):
ml = api.get_ml_status(symbol)
if not ml["available"]:
st.warning(f"ML Engine non disponible : {ml['regime_name']}")
st.info(
"Le ML Engine nécessite au moins 50 barres de données.\n"
"Vérifiez que l'API est démarrée et que le DataService est fonctionnel."
)
return
# --- KPIs ---
c1, c2, c3 = st.columns(3)
c1.metric("Regime actuel", ml["regime_name"])
c2.metric("Symbole analysé", ml["symbol"])
c3.metric("Barres analysées", ml["bars_analyzed"])
st.markdown("---")
# --- Distribution des régimes ---
regime_pct = ml.get("regime_pct", {})
if regime_pct:
st.subheader("Distribution des régimes (30 derniers jours)")
labels = list(regime_pct.keys())
values = [v * 100 for v in regime_pct.values()]
colors = {
"Trending Up": "#00cc44",
"Trending Down": "#cc3300",
"Ranging": "#ffaa00",
"High Volatility":"#9933ff",
}
bar_colors = [colors.get(l, "#1f77b4") for l in labels]
fig = go.Figure(data=[
go.Bar(
x=labels,
y=values,
marker_color=bar_colors,
text=[f"{v:.1f}%" for v in values],
textposition="outside",
)
])
fig.update_layout(
yaxis_title="Pourcentage (%)",
yaxis=dict(range=[0, 100]),
height=350,
margin=dict(l=0, r=0, t=10, b=0),
)
st.plotly_chart(fig, use_container_width=True)
# Tableau détaillé
dist_df = pd.DataFrame({
"Regime": labels,
"Frequence": [f"{v:.1f}%" for v in values],
})
st.dataframe(dist_df, use_container_width=True, hide_index=True)
else:
st.info("Distribution des régimes non disponible.")
# =============================================================================
# Tab 2 : Adaptation des stratégies
# =============================================================================
def render_strategy_adaptation():
"""Affiche les recommandations ML par stratégie."""
st.header("Recommandations par stratégie")
symbol = st.selectbox("Symbole", ["EURUSD", "GBPUSD", "USDJPY"], key="ml_symbol_advice")
with st.spinner("Chargement des recommandations..."):
ml = api.get_ml_status(symbol)
if not ml["available"]:
st.warning(f"ML Engine non disponible : {ml['regime_name']}")
return
st.info(f"Regime actuel : **{ml['regime_name']}** sur {ml['symbol']}")
advice = ml.get("strategy_advice", {})
if not advice:
st.info("Aucune recommandation disponible.")
return
# --- Tableau ---
rows = []
for strategy, should_trade in advice.items():
rows.append({
"Strategie": strategy.capitalize(),
"Statut": "Recommande" if should_trade else "Suspendu",
"Trading": "Oui" if should_trade else "Non",
})
df = pd.DataFrame(rows)
st.dataframe(df, use_container_width=True, hide_index=True)
st.markdown("---")
# --- Indicateurs visuels par stratégie ---
cols = st.columns(len(advice))
for col, (strategy, should_trade) in zip(cols, advice.items()):
with col:
if should_trade:
col.success(f"{strategy.capitalize()}\nActif")
else:
col.error(f"{strategy.capitalize()}\nSuspendu")
st.markdown("---")
st.caption(
"Les recommandations sont calculées par le RegimeDetector (HMM) "
"en temps réel sur les donnees du DataService."
)
if __name__ == "__main__":
render_ml_monitor()

15
src/utils/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""
Module Utils - Utilitaires et Helpers.
Ce module contient des fonctions et classes utilitaires utilisées
à travers toute l'application.
"""
from src.utils.logger import setup_logger, get_logger
from src.utils.config_loader import ConfigLoader
__all__ = [
'setup_logger',
'get_logger',
'ConfigLoader',
]

256
src/utils/config_loader.py Normal file
View File

@@ -0,0 +1,256 @@
"""
Config Loader - Chargement de la Configuration.
Gère le chargement de tous les fichiers YAML avec :
- Substitution ${VAR_NAME} et ${VAR_NAME:-default} dans les valeurs YAML
- Fallback sur des valeurs par défaut si le fichier est absent (Docker sans volume)
- Overrides depuis variables d'environnement (REDIS_URL, API keys, Telegram...)
"""
import os
import re
import yaml
from pathlib import Path
from typing import Any, Dict
from urllib.parse import urlparse
import logging
logger = logging.getLogger(__name__)
class ConfigLoader:
"""
Chargeur de configuration centralisé.
Prend en charge :
- Fichiers YAML dans config/
- Substitution ${ENV_VAR} et ${ENV_VAR:-default}
- Overrides depuis env vars (Docker-friendly)
Usage:
config = ConfigLoader.load_all()
risk_limits = config['risk_limits']
"""
CONFIG_DIR = Path(os.environ.get("CONFIG_DIR", "config"))
# Env var → chemin dans le dict de config (top_key, *nested_keys)
_ENV_OVERRIDES: Dict[str, tuple] = {
"ALPHA_VANTAGE_API_KEY": ("data_sources", "alpha_vantage", "api_key"),
"TWELVE_DATA_API_KEY": ("data_sources", "twelve_data", "api_key"),
"TELEGRAM_BOT_TOKEN": ("risk_limits", "alerts", "notification_channels", "telegram", "bot_token"),
"TELEGRAM_CHAT_ID": ("risk_limits", "alerts", "notification_channels", "telegram", "chat_id"),
}
# -------------------------------------------------------------------------
# Chargement principal
# -------------------------------------------------------------------------
@classmethod
def load_all(cls) -> Dict[str, Any]:
"""
Charge toute la configuration.
Returns:
Dictionnaire {risk_limits, strategy_params, data_sources, ig_config}
"""
logger.info("Loading configuration files...")
config: Dict[str, Any] = {}
config["risk_limits"] = cls._load_with_fallback("risk_limits.yaml", cls._default_risk_limits())
config["strategy_params"] = cls._load_with_fallback("strategy_params.yaml", {})
config["data_sources"] = cls._load_with_fallback("data_sources.yaml", cls._default_data_sources())
# IG config (optionnel)
try:
config["ig_config"] = cls.load_yaml("ig_config.yaml")
except FileNotFoundError:
logger.warning("ig_config.yaml not found (optional)")
config["ig_config"] = {}
# Injecter overrides depuis env vars
cls._apply_env_overrides(config)
cls._apply_redis_url(config)
logger.info("Configuration loaded successfully")
return config
@classmethod
def _load_with_fallback(cls, filename: str, default: Dict) -> Dict:
"""Charge un YAML ; retourne `default` si le fichier est absent."""
try:
return cls.load_yaml(filename)
except FileNotFoundError:
logger.warning(f"{filename} not found — using defaults")
return default
# -------------------------------------------------------------------------
# Lecture YAML avec substitution env vars
# -------------------------------------------------------------------------
@classmethod
def load_yaml(cls, filename: str) -> Dict[str, Any]:
"""
Charge un fichier YAML avec substitution ${ENV_VAR}.
Raises:
FileNotFoundError: Si le fichier n'existe pas.
"""
filepath = cls.CONFIG_DIR / filename
if not filepath.exists():
raise FileNotFoundError(f"Configuration file not found: {filepath}")
logger.debug(f"Loading {filename}...")
with open(filepath, "r", encoding="utf-8") as f:
raw = f.read()
resolved = cls._substitute_env_vars(raw)
return yaml.safe_load(resolved) or {}
@classmethod
def _substitute_env_vars(cls, text: str) -> str:
"""Remplace ${VAR} et ${VAR:-default} par les valeurs d'environnement."""
def replacer(match: re.Match) -> str:
expr = match.group(1)
if ":-" in expr:
var_name, default_val = expr.split(":-", 1)
else:
var_name, default_val = expr, ""
return os.environ.get(var_name.strip(), default_val)
return re.sub(r"\$\{([^}]+)\}", replacer, text)
# -------------------------------------------------------------------------
# Overrides depuis env vars
# -------------------------------------------------------------------------
@classmethod
def _apply_env_overrides(cls, config: Dict):
"""Injecte les valeurs d'env vars aux chemins définis dans _ENV_OVERRIDES."""
for env_var, path in cls._ENV_OVERRIDES.items():
value = os.environ.get(env_var)
if not value:
continue
top_key, *keys = path
if top_key not in config:
continue
target = config[top_key]
for key in keys[:-1]:
target = target.setdefault(key, {})
target[keys[-1]] = value
logger.debug(f"Env override applied: {env_var}{'.'.join(path)}")
@classmethod
def _apply_redis_url(cls, config: Dict):
"""
Parse REDIS_URL et met à jour data_sources.cache.redis.
Exemple : redis://trading-redis:6379/0
"""
redis_url = os.environ.get("REDIS_URL")
if not redis_url:
return
try:
parsed = urlparse(redis_url)
redis_cfg = (
config
.setdefault("data_sources", {})
.setdefault("cache", {})
.setdefault("redis", {})
)
redis_cfg["host"] = parsed.hostname or "localhost"
redis_cfg["port"] = parsed.port or 6379
redis_cfg["password"] = parsed.password
redis_cfg["db"] = int(parsed.path.lstrip("/") or 0)
logger.debug(f"Redis config from REDIS_URL: {parsed.hostname}:{parsed.port}")
except Exception as exc:
logger.warning(f"Failed to parse REDIS_URL: {exc}")
# -------------------------------------------------------------------------
# Accesseurs
# -------------------------------------------------------------------------
@classmethod
def get_risk_limits(cls) -> Dict[str, Any]:
return cls._load_with_fallback("risk_limits.yaml", cls._default_risk_limits())
@classmethod
def get_strategy_params(cls, strategy_name: str) -> Dict[str, Any]:
all_params = cls._load_with_fallback("strategy_params.yaml", {})
return all_params.get(f"{strategy_name}_strategy", {})
@classmethod
def get_data_sources(cls) -> Dict[str, Any]:
return cls._load_with_fallback("data_sources.yaml", cls._default_data_sources())
@classmethod
def get_database_url(cls) -> str:
return os.environ.get(
"DATABASE_URL",
"postgresql://trading:trading@localhost:5432/trading_db",
)
@classmethod
def save_yaml(cls, filename: str, data: Dict[str, Any]):
"""Sauvegarde un dictionnaire en YAML."""
filepath = cls.CONFIG_DIR / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
logger.info(f"Saved {filename}")
# -------------------------------------------------------------------------
# Configs par défaut (Docker sans volume config/)
# -------------------------------------------------------------------------
@classmethod
def _default_risk_limits(cls) -> Dict:
return {
"initial_capital": float(os.environ.get("INITIAL_CAPITAL", "10000")),
"global_limits": {
"max_portfolio_risk": 0.02,
"max_position_size": 5.0, # Forex: valeur nominale >> capital (levier)
"max_drawdown": 0.10,
"max_daily_loss": 0.03,
"max_correlation": 0.7,
},
"strategy_limits": {
"scalping": {"risk_per_trade": 0.005, "max_trades_per_day": 50},
"intraday": {"risk_per_trade": 0.015, "max_trades_per_day": 10},
"swing": {"risk_per_trade": 0.025, "max_trades_per_day": 2},
},
"alerts": {
"notification_channels": {
"telegram": {
"enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN")),
"bot_token": os.environ.get("TELEGRAM_BOT_TOKEN", ""),
"chat_id": os.environ.get("TELEGRAM_CHAT_ID", ""),
}
}
},
}
@classmethod
def _default_data_sources(cls) -> Dict:
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
parsed = urlparse(redis_url)
return {
"yahoo_finance": {"enabled": True, "priority": 1},
"alpha_vantage": {
"enabled": bool(os.environ.get("ALPHA_VANTAGE_API_KEY")),
"priority": 2,
"api_key": os.environ.get("ALPHA_VANTAGE_API_KEY", ""),
},
"cache": {
"enabled": True,
"backend": "redis",
"redis": {
"host": parsed.hostname or "localhost",
"port": parsed.port or 6379,
"db": 0,
"password": parsed.password,
},
},
}

115
src/utils/logger.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Logger - Configuration du système de logging.
Ce module configure le logging pour toute l'application avec:
- Logs console colorés
- Logs fichiers avec rotation
- Niveaux de log configurables
- Format structuré
"""
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
from datetime import datetime
# Couleurs pour console
class ColoredFormatter(logging.Formatter):
"""Formatter avec couleurs pour la console."""
COLORS = {
'DEBUG': '\033[36m', # Cyan
'INFO': '\033[32m', # Vert
'WARNING': '\033[33m', # Jaune
'ERROR': '\033[31m', # Rouge
'CRITICAL': '\033[35m', # Magenta
}
RESET = '\033[0m'
def format(self, record):
"""Formate le log avec couleurs."""
log_color = self.COLORS.get(record.levelname, self.RESET)
record.levelname = f"{log_color}{record.levelname}{self.RESET}"
return super().format(record)
def setup_logger(level: str = 'INFO', log_dir: str = 'logs'):
"""
Configure le système de logging global.
Args:
level: Niveau de log ('DEBUG', 'INFO', 'WARNING', 'ERROR')
log_dir: Répertoire pour les fichiers de log
"""
# Créer répertoire logs
log_path = Path(log_dir)
log_path.mkdir(exist_ok=True)
# Niveau de log
log_level = getattr(logging, level.upper(), logging.INFO)
# Format des logs
log_format = '%(asctime)s | %(levelname)-8s | %(name)-25s | %(message)s'
date_format = '%Y-%m-%d %H:%M:%S'
# Root logger
root_logger = logging.getLogger()
root_logger.setLevel(log_level)
# Supprimer handlers existants
root_logger.handlers.clear()
# Handler console (avec couleurs)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level)
console_formatter = ColoredFormatter(log_format, datefmt=date_format)
console_handler.setFormatter(console_formatter)
root_logger.addHandler(console_handler)
# Handler fichier principal (avec rotation)
main_log_file = log_path / 'trading.log'
file_handler = RotatingFileHandler(
main_log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=10
)
file_handler.setLevel(log_level)
file_formatter = logging.Formatter(log_format, datefmt=date_format)
file_handler.setFormatter(file_formatter)
root_logger.addHandler(file_handler)
# Handler fichier erreurs uniquement
error_log_file = log_path / 'errors.log'
error_handler = RotatingFileHandler(
error_log_file,
maxBytes=10 * 1024 * 1024,
backupCount=5
)
error_handler.setLevel(logging.ERROR)
error_handler.setFormatter(file_formatter)
root_logger.addHandler(error_handler)
# Log initial
root_logger.info("=" * 60)
root_logger.info(f"Logging initialized - Level: {level}")
root_logger.info(f"Log directory: {log_path.absolute()}")
root_logger.info("=" * 60)
def get_logger(name: str) -> logging.Logger:
"""
Retourne un logger pour un module spécifique.
Args:
name: Nom du module (généralement __name__)
Returns:
Logger configuré
Usage:
logger = get_logger(__name__)
logger.info("Message")
"""
return logging.getLogger(name)