feat: Phase 4c — CNN + Ensemble architecture (multi-signal trading)
## Nouveaux modules
### src/ml/cnn/
- candlestick_encoder.py : CandlestickEncoder, fenêtres OHLCV z-score (N, 64, 5)
- cnn_model.py : TradingCNN — 3 blocs Conv1D(5→32→64→128) + BN + ReLU + GlobalAvgPool
- cnn_strategy_model.py : CNNStrategyModel, API identique à MLStrategyModel (train/predict/save/load)
### src/ml/ensemble/
- ensemble_model.py : EnsembleModel, poids {xgboost:0.40, cnn:0.60}, accord requis entre modèles
### src/strategies/cnn_driven/
- cnn_strategy.py : CNNDrivenStrategy(BaseStrategy), SL/TP ATR-based, fallback CNN_AVAILABLE=False
### src/strategies/ensemble/
- ensemble_strategy.py : EnsembleStrategy(BaseStrategy), auto-load XGBoost + CNN au démarrage
## Modifications
- trading.py : routes POST /train-cnn, GET /train-cnn/{job_id}, GET /cnn-models,
POST /ensemble/configure, GET /ensemble/status + fix bugs (logging, _get_data_service, period_map)
- strategy_engine.py : support 'ml_driven' dans load_strategy()
- docker/requirements/api.txt : ajout torch>=2.0.0 + dépendances ML (scikit-learn, xgboost, lightgbm)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,3 +20,12 @@ prometheus-client==0.19.0
|
||||
|
||||
# Notifications
|
||||
python-telegram-bot==20.7
|
||||
|
||||
# ML — requis pour MLDrivenStrategy (entraînement et prédiction dans l'API)
|
||||
scikit-learn==1.3.2
|
||||
xgboost==2.0.3
|
||||
lightgbm==4.1.0
|
||||
joblib>=1.3.0
|
||||
|
||||
# ML — Deep Learning (CNN pour patterns chandeliers)
|
||||
torch>=2.0.0
|
||||
|
||||
@@ -4,11 +4,14 @@ Routes de trading : risk, positions, signaux, backtesting, paper trading.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -412,8 +415,8 @@ async def run_backtest(request: BacktestRequest, background_tasks: BackgroundTas
|
||||
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")
|
||||
if request.strategy not in ("scalping", "intraday", "swing", "ml_driven"):
|
||||
raise HTTPException(400, detail="strategy doit être : scalping | intraday | swing | ml_driven")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
_backtest_jobs[job_id] = {
|
||||
@@ -776,11 +779,23 @@ async def _run_train_task(job_id: str, request: TrainRequest) -> None:
|
||||
_train_jobs[job_id]["status"] = "running"
|
||||
try:
|
||||
# Récupération des données historiques
|
||||
data_service = _get_data_service()
|
||||
from src.data.data_service import DataService
|
||||
from src.utils.config_loader import ConfigLoader
|
||||
from datetime import timedelta
|
||||
config = ConfigLoader.load_all()
|
||||
data_service = DataService(config)
|
||||
|
||||
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 data_service.get_historical_data(
|
||||
symbol = request.symbol,
|
||||
timeframe = request.timeframe,
|
||||
period = request.period,
|
||||
symbol = request.symbol,
|
||||
timeframe = request.timeframe,
|
||||
start_date = start_date,
|
||||
end_date = end_date,
|
||||
)
|
||||
if df is None or len(df) < 200:
|
||||
raise ValueError(f"Données insuffisantes : {len(df) if df is not None else 0} barres (min 200)")
|
||||
@@ -917,3 +932,301 @@ def get_feature_importance(symbol: str, timeframe: str, model_type: str = "xgboo
|
||||
raise HTTPException(404, detail=f"Modèle non trouvé pour {symbol}/{timeframe}/{model_type}")
|
||||
except Exception as e:
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CNN STRATEGY — Entraînement et gestion des modèles CNN-Driven
|
||||
# =============================================================================
|
||||
|
||||
try:
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
CNN_AVAILABLE = True
|
||||
except ImportError:
|
||||
CNN_AVAILABLE = False
|
||||
|
||||
try:
|
||||
from src.strategies.cnn_driven import CNNDrivenStrategy
|
||||
from src.strategies.ensemble import EnsembleStrategy
|
||||
from src.ml.ensemble import EnsembleModel
|
||||
ENSEMBLE_AVAILABLE = True
|
||||
except ImportError:
|
||||
ENSEMBLE_AVAILABLE = False
|
||||
|
||||
# Stockage en mémoire des jobs d'entraînement CNN
|
||||
_cnn_train_jobs: Dict[str, dict] = {}
|
||||
|
||||
|
||||
class CNNTrainRequest(BaseModel):
|
||||
"""Requête d'entraînement du modèle CNN."""
|
||||
symbol: str = "EURUSD"
|
||||
timeframe: str = "1h"
|
||||
period: str = "2y"
|
||||
seq_len: int = 64
|
||||
tp_atr_mult: float = 2.0
|
||||
sl_atr_mult: float = 1.0
|
||||
horizon: int = 30
|
||||
min_confidence: float = 0.55
|
||||
|
||||
|
||||
class CNNTrainResponse(BaseModel):
|
||||
"""Réponse d'un job d'entraînement CNN."""
|
||||
job_id: str
|
||||
status: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
wf_accuracy: Optional[float] = None
|
||||
wf_precision: Optional[float] = None
|
||||
label_dist: Optional[dict] = None
|
||||
n_samples: Optional[int] = None
|
||||
trained_at: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
async def _run_cnn_train_task(job_id: str, request: CNNTrainRequest) -> None:
|
||||
"""Tâche d'entraînement CNN exécutée en arrière-plan."""
|
||||
_cnn_train_jobs[job_id]["status"] = "running"
|
||||
try:
|
||||
from src.data.data_service import DataService
|
||||
from src.utils.config_loader import ConfigLoader
|
||||
from datetime import timedelta
|
||||
|
||||
config = ConfigLoader.load_all()
|
||||
data_service = DataService(config)
|
||||
|
||||
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 data_service.get_historical_data(
|
||||
symbol = request.symbol,
|
||||
timeframe = request.timeframe,
|
||||
start_date = start_date,
|
||||
end_date = end_date,
|
||||
)
|
||||
if df is None or len(df) < 200:
|
||||
raise ValueError(f"Données insuffisantes : {len(df) if df is not None else 0} barres (min 200)")
|
||||
|
||||
# Entraînement dans un thread (opération CPU-bound)
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(None, _sync_cnn_train, df, request)
|
||||
|
||||
_cnn_train_jobs[job_id].update({
|
||||
"status": "completed",
|
||||
"symbol": request.symbol,
|
||||
"timeframe": request.timeframe,
|
||||
"n_samples": result.get("n_samples"),
|
||||
"wf_accuracy": result.get("wf_metrics", {}).get("avg_accuracy"),
|
||||
"wf_precision": result.get("wf_metrics", {}).get("avg_precision"),
|
||||
"label_dist": result.get("label_dist"),
|
||||
"trained_at": result.get("trained_at"),
|
||||
})
|
||||
|
||||
# Auto-attachement à la stratégie CNN active si elle existe
|
||||
_attach_cnn_model_to_strategy(request)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Erreur entraînement CNN job {job_id} : {exc}", exc_info=True)
|
||||
_cnn_train_jobs[job_id]["status"] = "failed"
|
||||
_cnn_train_jobs[job_id]["error"] = str(exc)
|
||||
|
||||
|
||||
def _sync_cnn_train(df, request: CNNTrainRequest) -> dict:
|
||||
"""Wrapper synchrone pour CNNStrategyModel.train() (exécuté dans un thread)."""
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
model = CNNStrategyModel(
|
||||
symbol = request.symbol,
|
||||
timeframe = request.timeframe,
|
||||
seq_len = request.seq_len,
|
||||
tp_atr_mult = request.tp_atr_mult,
|
||||
sl_atr_mult = request.sl_atr_mult,
|
||||
horizon = request.horizon,
|
||||
min_confidence = request.min_confidence,
|
||||
)
|
||||
return model.train(df)
|
||||
|
||||
|
||||
def _attach_cnn_model_to_strategy(request: CNNTrainRequest) -> None:
|
||||
"""Attache le modèle CNN entraîné à la stratégie cnn_driven active (paper trading)."""
|
||||
try:
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
from src.strategies.cnn_driven import CNNDrivenStrategy
|
||||
|
||||
engine = _paper_state.get("engine")
|
||||
if engine and hasattr(engine, 'strategy_engine'):
|
||||
strat = engine.strategy_engine.strategies.get('cnn_driven')
|
||||
if strat and isinstance(strat, CNNDrivenStrategy):
|
||||
model = CNNStrategyModel.load(request.symbol, request.timeframe)
|
||||
strat.attach_model(model)
|
||||
logger.info("Modèle CNN attaché à la stratégie cnn_driven active")
|
||||
except Exception as e:
|
||||
logger.debug(f"Auto-attach modèle CNN ignoré : {e}")
|
||||
|
||||
|
||||
@router.post("/train-cnn", response_model=CNNTrainResponse, summary="Entraîner le modèle CNN")
|
||||
async def train_cnn_model(request: CNNTrainRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Lance l'entraînement du modèle CNN en arrière-plan.
|
||||
|
||||
Le CNN 1D apprend directement les patterns visuels dans les séquences
|
||||
OHLCV brutes (double bottom, squeeze Bollinger, alignements...).
|
||||
|
||||
- Retourne un `job_id` à interroger via `GET /trading/train-cnn/{job_id}`
|
||||
- Le modèle est sauvegardé sur disque après entraînement
|
||||
- Si un paper trading CNN est actif, le modèle lui est automatiquement attaché
|
||||
"""
|
||||
if not CNN_AVAILABLE:
|
||||
raise HTTPException(503, detail="PyTorch requis — rebuilder le container trading-api")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
_cnn_train_jobs[job_id] = {
|
||||
"status": "pending",
|
||||
"symbol": request.symbol,
|
||||
"timeframe": request.timeframe,
|
||||
}
|
||||
|
||||
background_tasks.add_task(_run_cnn_train_task, job_id, request)
|
||||
|
||||
return CNNTrainResponse(
|
||||
job_id = job_id,
|
||||
status = "pending",
|
||||
symbol = request.symbol,
|
||||
timeframe = request.timeframe,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/train-cnn/{job_id}", response_model=CNNTrainResponse, summary="Résultat entraînement CNN")
|
||||
def get_cnn_train_status(job_id: str):
|
||||
"""Retourne l'état d'un job d'entraînement CNN."""
|
||||
job = _cnn_train_jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404, detail=f"Job {job_id} introuvable")
|
||||
return CNNTrainResponse(job_id=job_id, **job)
|
||||
|
||||
|
||||
@router.get("/cnn-models", summary="Liste des modèles CNN entraînés")
|
||||
def list_cnn_models():
|
||||
"""
|
||||
Retourne la liste de tous les modèles CNN disponibles sur disque,
|
||||
avec leurs métriques (accuracy, date d'entraînement, nombre de samples...).
|
||||
"""
|
||||
if not CNN_AVAILABLE:
|
||||
return {"error": "PyTorch requis — rebuilder le container trading-api", "models": [], "count": 0}
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
models = CNNStrategyModel.list_trained_models()
|
||||
return {"models": models, "count": len(models)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ensemble — Configuration et statut du modèle d'ensemble (ML + CNN)
|
||||
# =============================================================================
|
||||
|
||||
class EnsembleConfigRequest(BaseModel):
|
||||
"""Configuration de l'ensemble ML + CNN."""
|
||||
weights: dict = {"xgboost": 0.40, "cnn": 0.60}
|
||||
min_confidence: float = 0.60
|
||||
require_agreement: bool = True
|
||||
|
||||
|
||||
# Configuration globale de l'ensemble (en mémoire)
|
||||
_ensemble_config: Dict = {
|
||||
"weights": {"xgboost": 0.40, "cnn": 0.60},
|
||||
"min_confidence": 0.60,
|
||||
"require_agreement": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/ensemble/configure", summary="Configurer l'ensemble ML + CNN")
|
||||
async def configure_ensemble(request: EnsembleConfigRequest):
|
||||
"""
|
||||
Configure les poids et paramètres de l'ensemble ML + CNN.
|
||||
|
||||
- weights: poids relatifs de chaque composant (ex: {"xgboost": 0.40, "cnn": 0.60})
|
||||
- min_confidence: seuil minimum de confiance de l'ensemble
|
||||
- require_agreement: si True, les deux modèles doivent être d'accord sur la direction
|
||||
"""
|
||||
if not ENSEMBLE_AVAILABLE:
|
||||
raise HTTPException(503, detail="Ensemble non disponible — modules manquants")
|
||||
|
||||
_ensemble_config.update({
|
||||
"weights": request.weights,
|
||||
"min_confidence": request.min_confidence,
|
||||
"require_agreement": request.require_agreement,
|
||||
})
|
||||
|
||||
# Propager la config à la stratégie ensemble active si elle existe
|
||||
engine = _paper_state.get("engine")
|
||||
if engine and hasattr(engine, 'strategy_engine'):
|
||||
strat = engine.strategy_engine.strategies.get('ensemble')
|
||||
if strat and isinstance(strat, EnsembleStrategy):
|
||||
strat.update_params({
|
||||
"weights": request.weights,
|
||||
"min_confidence": request.min_confidence,
|
||||
"require_agreement": request.require_agreement,
|
||||
})
|
||||
logger.info("Configuration ensemble appliquée à la stratégie active")
|
||||
|
||||
return {
|
||||
"status": "configured",
|
||||
"config": _ensemble_config,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ensemble/status", summary="Statut de l'ensemble ML + CNN")
|
||||
async def get_ensemble_status():
|
||||
"""
|
||||
Retourne le statut de chaque composant de l'ensemble :
|
||||
- Modèles ML (XGBoost/LightGBM) disponibles
|
||||
- Modèles CNN disponibles
|
||||
- Configuration active (poids, seuil, agreement)
|
||||
- État de la stratégie ensemble si active en paper trading
|
||||
"""
|
||||
status = {
|
||||
"config": _ensemble_config,
|
||||
"components": {},
|
||||
"paper_trading_active": False,
|
||||
}
|
||||
|
||||
# Vérifier les modèles ML disponibles
|
||||
try:
|
||||
from src.ml.ml_strategy_model import MLStrategyModel
|
||||
ml_models = MLStrategyModel.list_trained_models()
|
||||
status["components"]["ml"] = {
|
||||
"available": True,
|
||||
"models_count": len(ml_models),
|
||||
"models": ml_models,
|
||||
}
|
||||
except Exception:
|
||||
status["components"]["ml"] = {"available": False, "models_count": 0, "models": []}
|
||||
|
||||
# Vérifier les modèles CNN disponibles
|
||||
if CNN_AVAILABLE:
|
||||
try:
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
cnn_models = CNNStrategyModel.list_trained_models()
|
||||
status["components"]["cnn"] = {
|
||||
"available": True,
|
||||
"models_count": len(cnn_models),
|
||||
"models": cnn_models,
|
||||
}
|
||||
except Exception:
|
||||
status["components"]["cnn"] = {"available": False, "models_count": 0, "models": []}
|
||||
else:
|
||||
status["components"]["cnn"] = {
|
||||
"available": False,
|
||||
"error": "PyTorch requis — rebuilder le container trading-api",
|
||||
}
|
||||
|
||||
# Vérifier si la stratégie ensemble est active en paper trading
|
||||
engine = _paper_state.get("engine")
|
||||
if engine and hasattr(engine, 'strategy_engine'):
|
||||
strat = engine.strategy_engine.strategies.get('ensemble')
|
||||
if strat and ENSEMBLE_AVAILABLE and isinstance(strat, EnsembleStrategy):
|
||||
status["paper_trading_active"] = True
|
||||
try:
|
||||
status["ensemble_info"] = strat.get_status()
|
||||
except Exception:
|
||||
status["ensemble_info"] = {"error": "Impossible de récupérer le statut"}
|
||||
|
||||
return status
|
||||
|
||||
@@ -84,6 +84,9 @@ class StrategyEngine:
|
||||
elif strategy_name == 'swing':
|
||||
from src.strategies.swing.swing_strategy import SwingStrategy
|
||||
strategy_class = SwingStrategy
|
||||
elif strategy_name == 'ml_driven':
|
||||
from src.strategies.ml_driven.ml_strategy import MLDrivenStrategy
|
||||
strategy_class = MLDrivenStrategy
|
||||
else:
|
||||
raise ValueError(f"Unknown strategy: {strategy_name}")
|
||||
|
||||
|
||||
3
src/ml/cnn/__init__.py
Normal file
3
src/ml/cnn/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cnn_strategy_model import CNNStrategyModel
|
||||
|
||||
__all__ = ['CNNStrategyModel']
|
||||
129
src/ml/cnn/candlestick_encoder.py
Normal file
129
src/ml/cnn/candlestick_encoder.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Encodeur de bougies OHLCV en séquences normalisées pour CNN 1D.
|
||||
|
||||
Transforme un DataFrame OHLCV en tenseurs (N, seq_len, 5) prêts pour le CNN.
|
||||
Chaque séquence est normalisée indépendamment (z-score glissant) pour que
|
||||
le modèle apprenne des patterns relatifs, pas des niveaux de prix absolus.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CandlestickEncoder:
|
||||
"""
|
||||
Encode les données OHLCV brutes en séquences normalisées pour le CNN.
|
||||
|
||||
La normalisation par fenêtre glissante garantit que le CNN voit des
|
||||
patterns de forme (doji, engulfing, etc.) indépendamment du niveau de prix.
|
||||
Le volume est normalisé séparément (ratio vs moyenne).
|
||||
|
||||
Args:
|
||||
seq_len: Longueur des séquences (nombre de bougies par échantillon)
|
||||
"""
|
||||
|
||||
def __init__(self, seq_len: int = 64):
|
||||
self.seq_len = seq_len
|
||||
|
||||
def encode(self, df_ohlcv: pd.DataFrame, seq_len: int = None) -> np.ndarray:
|
||||
"""
|
||||
Encode toutes les séquences glissantes depuis le DataFrame OHLCV.
|
||||
|
||||
Args:
|
||||
df_ohlcv: DataFrame avec colonnes open, high, low, close, volume
|
||||
seq_len: Override de la longueur de séquence (optionnel)
|
||||
|
||||
Returns:
|
||||
np.ndarray de shape (N_samples, seq_len, 5)
|
||||
Colonnes : [open, high, low, close, volume]
|
||||
"""
|
||||
seq_len = seq_len or self.seq_len
|
||||
df = self._prepare_df(df_ohlcv)
|
||||
|
||||
if len(df) < seq_len + 1:
|
||||
logger.warning(f"Pas assez de données : {len(df)} barres < {seq_len + 1} minimum")
|
||||
return np.empty((0, seq_len, 5))
|
||||
|
||||
n_samples = len(df) - seq_len + 1
|
||||
sequences = np.zeros((n_samples, seq_len, 5), dtype=np.float32)
|
||||
|
||||
for i in range(n_samples):
|
||||
window = df.iloc[i:i + seq_len]
|
||||
sequences[i] = self._normalize_window(window)
|
||||
|
||||
# Supprimer les séquences avec NaN
|
||||
valid_mask = ~np.isnan(sequences).any(axis=(1, 2))
|
||||
sequences = sequences[valid_mask]
|
||||
|
||||
logger.info(f"Encodage : {len(sequences)} séquences de {seq_len} bougies")
|
||||
return sequences
|
||||
|
||||
def encode_last(self, df_ohlcv: pd.DataFrame, seq_len: int = None) -> np.ndarray:
|
||||
"""
|
||||
Encode uniquement la dernière séquence (pour prédiction temps réel).
|
||||
|
||||
Args:
|
||||
df_ohlcv: DataFrame OHLCV (au moins seq_len barres)
|
||||
seq_len: Override de la longueur de séquence (optionnel)
|
||||
|
||||
Returns:
|
||||
np.ndarray de shape (1, seq_len, 5)
|
||||
"""
|
||||
seq_len = seq_len or self.seq_len
|
||||
df = self._prepare_df(df_ohlcv)
|
||||
|
||||
if len(df) < seq_len:
|
||||
logger.warning(f"Pas assez de données pour encode_last : {len(df)} < {seq_len}")
|
||||
return np.empty((0, seq_len, 5))
|
||||
|
||||
window = df.iloc[-seq_len:]
|
||||
normalized = self._normalize_window(window)
|
||||
return normalized.reshape(1, seq_len, 5)
|
||||
|
||||
def _prepare_df(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Prépare le DataFrame : colonnes en minuscules, sélection OHLCV."""
|
||||
df = df.copy()
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
|
||||
required = ['open', 'high', 'low', 'close', 'volume']
|
||||
missing = [c for c in required if c not in df.columns]
|
||||
if missing:
|
||||
raise ValueError(f"Colonnes manquantes : {missing}")
|
||||
|
||||
return df[required].reset_index(drop=True)
|
||||
|
||||
def _normalize_window(self, window: pd.DataFrame) -> np.ndarray:
|
||||
"""
|
||||
Normalise une fenêtre OHLCV.
|
||||
|
||||
Prix (OHLC) : z-score sur la fenêtre (moyenne et écart-type du close)
|
||||
Volume : ratio par rapport à la moyenne du volume sur la fenêtre
|
||||
"""
|
||||
result = np.zeros((len(window), 5), dtype=np.float32)
|
||||
|
||||
# Normalisation prix par z-score du close
|
||||
close_values = window['close'].values.astype(np.float64)
|
||||
mean_price = close_values.mean()
|
||||
std_price = close_values.std()
|
||||
|
||||
if std_price < 1e-10:
|
||||
# Prix constant → tout à zéro
|
||||
std_price = 1.0
|
||||
|
||||
result[:, 0] = (window['open'].values - mean_price) / std_price
|
||||
result[:, 1] = (window['high'].values - mean_price) / std_price
|
||||
result[:, 2] = (window['low'].values - mean_price) / std_price
|
||||
result[:, 3] = (window['close'].values - mean_price) / std_price
|
||||
|
||||
# Normalisation volume : ratio vs moyenne
|
||||
vol_values = window['volume'].values.astype(np.float64)
|
||||
mean_vol = vol_values.mean()
|
||||
if mean_vol < 1e-10:
|
||||
result[:, 4] = 0.0
|
||||
else:
|
||||
result[:, 4] = vol_values / mean_vol
|
||||
|
||||
return result
|
||||
113
src/ml/cnn/cnn_model.py
Normal file
113
src/ml/cnn/cnn_model.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
CNN 1D pour la détection de patterns dans les bougies OHLCV.
|
||||
|
||||
Architecture Conv1d → BatchNorm → ReLU → Pool, empilée sur 3 couches,
|
||||
suivie d'un classifieur linéaire pour prédire LONG / SHORT / NEUTRAL.
|
||||
|
||||
Conçu pour capturer des patterns visuels (doji, engulfing, head&shoulders...)
|
||||
que le réseau apprend directement depuis les données brutes normalisées,
|
||||
sans features pré-calculées (contrairement à XGBoost).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
logger.warning("PyTorch non disponible — le CNN ne peut pas être utilisé")
|
||||
|
||||
|
||||
if TORCH_AVAILABLE:
|
||||
class TradingCNN(nn.Module):
|
||||
"""
|
||||
CNN 1D pour classification de séquences OHLCV.
|
||||
|
||||
Architecture :
|
||||
Input (batch, seq_len=64, 5)
|
||||
→ Permute → (batch, 5, 64)
|
||||
→ Conv1d(5→32, k=3) + BN + ReLU + MaxPool(2) → (batch, 32, 32)
|
||||
→ Conv1d(32→64, k=3) + BN + ReLU + MaxPool(2) → (batch, 64, 16)
|
||||
→ Conv1d(64→128, k=3) + BN + ReLU + AdaptiveAvgPool(1) → (batch, 128, 1)
|
||||
→ Flatten → Linear(128→64) + ReLU + Dropout(0.3)
|
||||
→ Linear(64→3) : logits [LONG, SHORT, NEUTRAL]
|
||||
|
||||
Args:
|
||||
n_features: Nombre de canaux d'entrée (5 = OHLCV)
|
||||
n_classes: Nombre de classes de sortie (3)
|
||||
dropout: Taux de dropout dans le classifieur
|
||||
"""
|
||||
|
||||
def __init__(self, n_features: int = 5, n_classes: int = 3, dropout: float = 0.3):
|
||||
super().__init__()
|
||||
|
||||
# Couches convolutives
|
||||
self.conv1 = nn.Conv1d(n_features, 32, kernel_size=3, padding=1)
|
||||
self.bn1 = nn.BatchNorm1d(32)
|
||||
self.pool1 = nn.MaxPool1d(2)
|
||||
|
||||
self.conv2 = nn.Conv1d(32, 64, kernel_size=3, padding=1)
|
||||
self.bn2 = nn.BatchNorm1d(64)
|
||||
self.pool2 = nn.MaxPool1d(2)
|
||||
|
||||
self.conv3 = nn.Conv1d(64, 128, kernel_size=3, padding=1)
|
||||
self.bn3 = nn.BatchNorm1d(128)
|
||||
self.adaptive_pool = nn.AdaptiveAvgPool1d(1)
|
||||
|
||||
# Classifieur
|
||||
self.fc1 = nn.Linear(128, 64)
|
||||
self.dropout = nn.Dropout(dropout)
|
||||
self.fc2 = nn.Linear(64, n_classes)
|
||||
|
||||
def forward(self, x: 'torch.Tensor') -> 'torch.Tensor':
|
||||
"""
|
||||
Forward pass.
|
||||
|
||||
Args:
|
||||
x: Tensor de shape (batch, seq_len, n_features)
|
||||
|
||||
Returns:
|
||||
Logits de shape (batch, n_classes)
|
||||
"""
|
||||
# Permute pour Conv1d : (batch, n_features, seq_len)
|
||||
x = x.permute(0, 2, 1)
|
||||
|
||||
# Bloc 1
|
||||
x = self.pool1(F.relu(self.bn1(self.conv1(x))))
|
||||
# Bloc 2
|
||||
x = self.pool2(F.relu(self.bn2(self.conv2(x))))
|
||||
# Bloc 3
|
||||
x = self.adaptive_pool(F.relu(self.bn3(self.conv3(x))))
|
||||
|
||||
# Flatten + classifieur
|
||||
x = x.squeeze(-1) # (batch, 128)
|
||||
x = F.relu(self.fc1(x))
|
||||
x = self.dropout(x)
|
||||
x = self.fc2(x)
|
||||
|
||||
return x
|
||||
|
||||
def predict_proba(self, x: 'torch.Tensor') -> 'torch.Tensor':
|
||||
"""
|
||||
Retourne les probabilités de chaque classe via Softmax.
|
||||
|
||||
Args:
|
||||
x: Tensor de shape (batch, seq_len, n_features)
|
||||
|
||||
Returns:
|
||||
Probabilités de shape (batch, n_classes)
|
||||
"""
|
||||
logits = self.forward(x)
|
||||
return F.softmax(logits, dim=1)
|
||||
|
||||
else:
|
||||
# Placeholder si PyTorch non disponible
|
||||
class TradingCNN:
|
||||
"""Placeholder — PyTorch non disponible."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise RuntimeError("PyTorch non disponible — impossible de créer TradingCNN")
|
||||
585
src/ml/cnn/cnn_strategy_model.py
Normal file
585
src/ml/cnn/cnn_strategy_model.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
CNN Strategy Model — Modèle CNN 1D qui apprend des patterns de bougies.
|
||||
|
||||
Contrairement à MLStrategyModel (XGBoost sur features TA pré-calculées),
|
||||
ce modèle travaille directement sur les séquences OHLCV brutes normalisées.
|
||||
Le CNN détecte lui-même les patterns visuels pertinents (doji, engulfing, etc.).
|
||||
|
||||
Pipeline :
|
||||
1. Chargement données OHLCV
|
||||
2. Encodage séquences (CandlestickEncoder : z-score glissant)
|
||||
3. Génération labels (LabelGenerator — partagé avec MLStrategyModel)
|
||||
4. Entraînement CNN (PyTorch, Adam, CrossEntropy, early stopping)
|
||||
5. Walk-forward validation (2 folds temporels)
|
||||
6. Sauvegarde state_dict + métadonnées JSON
|
||||
|
||||
Usage:
|
||||
model = CNNStrategyModel(symbol='EURUSD', timeframe='1h')
|
||||
result = model.train(df_ohlcv)
|
||||
signal = model.predict(df_recent)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.ml.features.label_generator import LabelGenerator
|
||||
from src.ml.cnn.candlestick_encoder import CandlestickEncoder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Répertoire de sauvegarde des modèles CNN
|
||||
MODELS_DIR = Path(__file__).parent.parent.parent.parent / "models" / "cnn_strategy"
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
from torch.utils.data import TensorDataset, DataLoader
|
||||
from src.ml.cnn.cnn_model import TradingCNN
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
logger.warning("PyTorch non disponible — CNNStrategyModel ne peut pas fonctionner")
|
||||
|
||||
try:
|
||||
from sklearn.metrics import precision_recall_fscore_support
|
||||
SKLEARN_AVAILABLE = True
|
||||
except ImportError:
|
||||
SKLEARN_AVAILABLE = False
|
||||
|
||||
|
||||
# Mapping indices CNN → signaux de trading
|
||||
# CNN : 0=LONG, 1=SHORT, 2=NEUTRAL
|
||||
# Trading : 1=LONG, -1=SHORT, 0=NEUTRAL
|
||||
CLASS_MAP = {0: 1, 1: -1, 2: 0}
|
||||
|
||||
# Mapping inverse : labels LabelGenerator → indices CNN
|
||||
# LabelGenerator : 1=LONG, -1=SHORT, 0=NEUTRAL
|
||||
LABEL_TO_INDEX = {1: 0, -1: 1, 0: 2}
|
||||
|
||||
|
||||
class CNNStrategyModel:
|
||||
"""
|
||||
Modèle CNN qui apprend les patterns visuels des bougies.
|
||||
|
||||
Le modèle :
|
||||
- Travaille sur les 64 dernières bougies OHLCV (données brutes normalisées)
|
||||
- Prédit LONG (1) / SHORT (-1) / NEUTRAL (0)
|
||||
- Donne un score de confiance [0..1] par prédiction
|
||||
- Se sauvegarde sur disque (state_dict PyTorch + métadonnées JSON)
|
||||
|
||||
Args:
|
||||
symbol: Paire tradée (ex: 'EURUSD')
|
||||
timeframe: Timeframe (ex: '1h', '15m')
|
||||
seq_len: Longueur des séquences d'entrée
|
||||
min_confidence: Seuil de confiance pour signal tradeable
|
||||
tp_atr_mult: Multiplicateur ATR pour TP (labels)
|
||||
sl_atr_mult: Multiplicateur ATR pour SL (labels)
|
||||
horizon: Nombre de barres pour évaluer TP/SL (labels)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
symbol: str = 'EURUSD',
|
||||
timeframe: str = '1h',
|
||||
seq_len: int = 64,
|
||||
min_confidence: float = 0.55,
|
||||
tp_atr_mult: float = 2.0,
|
||||
sl_atr_mult: float = 1.0,
|
||||
horizon: int = 30,
|
||||
):
|
||||
self.symbol = symbol
|
||||
self.timeframe = timeframe
|
||||
self.model_type = 'cnn'
|
||||
self.seq_len = seq_len
|
||||
self.min_confidence = min_confidence
|
||||
self.tp_atr_mult = tp_atr_mult
|
||||
self.sl_atr_mult = sl_atr_mult
|
||||
self.horizon = horizon
|
||||
|
||||
self.model = None
|
||||
self.is_trained = False
|
||||
self.metadata: Dict = {}
|
||||
self.encoder = CandlestickEncoder(seq_len=seq_len)
|
||||
|
||||
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entraînement
|
||||
# -------------------------------------------------------------------------
|
||||
def train(self, data: pd.DataFrame) -> Dict:
|
||||
"""
|
||||
Entraîne le CNN sur les données OHLCV.
|
||||
|
||||
Utilise les mêmes labels que MLStrategyModel (LabelGenerator.generate_atr_based).
|
||||
Walk-forward validation sur 2 folds temporels.
|
||||
Sauvegarde automatiquement le modèle après entraînement.
|
||||
|
||||
Args:
|
||||
data: DataFrame OHLCV (au moins 200 barres recommandées)
|
||||
|
||||
Returns:
|
||||
Dict avec wf_metrics, label_dist, n_samples, trained_at
|
||||
"""
|
||||
if not TORCH_AVAILABLE:
|
||||
return {'error': 'PyTorch non disponible'}
|
||||
|
||||
logger.info(f"Début entraînement CNNStrategyModel pour {self.symbol}/{self.timeframe}")
|
||||
logger.info(f" Données : {len(data)} barres, seq_len={self.seq_len}")
|
||||
|
||||
# 1. Génération des labels (même méthode que MLStrategyModel)
|
||||
gen = LabelGenerator(horizon=self.horizon)
|
||||
labels_series = gen.generate_atr_based(
|
||||
data,
|
||||
atr_tp_mult=self.tp_atr_mult,
|
||||
atr_sl_mult=self.sl_atr_mult,
|
||||
)
|
||||
|
||||
# 2. Encodage des séquences OHLCV
|
||||
sequences = self.encoder.encode(data, seq_len=self.seq_len)
|
||||
|
||||
if len(sequences) == 0:
|
||||
return {'error': 'Pas assez de données pour encoder des séquences'}
|
||||
|
||||
# 3. Aligner labels sur les séquences
|
||||
# Chaque séquence[i] correspond aux barres [i : i+seq_len]
|
||||
# Le label associé est celui de la dernière barre de la séquence (i + seq_len - 1)
|
||||
n_samples = len(sequences)
|
||||
label_indices = []
|
||||
for i in range(n_samples):
|
||||
target_idx = i + self.seq_len - 1
|
||||
if target_idx < len(labels_series):
|
||||
label_indices.append(target_idx)
|
||||
else:
|
||||
label_indices.append(None)
|
||||
|
||||
# Filtrer les séquences valides (label disponible et pas en fin de horizon)
|
||||
valid_mask = []
|
||||
labels_aligned = []
|
||||
for i, idx in enumerate(label_indices):
|
||||
if idx is not None and idx < len(labels_series) - self.horizon:
|
||||
raw_label = labels_series.iloc[idx]
|
||||
labels_aligned.append(LABEL_TO_INDEX.get(raw_label, 2))
|
||||
valid_mask.append(i)
|
||||
|
||||
if len(valid_mask) < 50:
|
||||
return {'error': f'Trop peu de données valides : {len(valid_mask)} échantillons'}
|
||||
|
||||
X = sequences[valid_mask]
|
||||
y = np.array(labels_aligned, dtype=np.int64)
|
||||
|
||||
logger.info(f" {len(X)} échantillons après alignement")
|
||||
n_long = (y == 0).sum()
|
||||
n_short = (y == 1).sum()
|
||||
n_neutral = (y == 2).sum()
|
||||
logger.info(f" Distribution : LONG={n_long}, SHORT={n_short}, NEUTRAL={n_neutral}")
|
||||
|
||||
# 4. Walk-forward validation (2 folds)
|
||||
wf_metrics = self._walk_forward_eval(X, y, n_folds=2)
|
||||
|
||||
# 5. Entraînement final sur toutes les données
|
||||
self.model = TradingCNN(n_features=5, n_classes=3)
|
||||
class_weights = self._compute_class_weights(y)
|
||||
self._train_model(self.model, X, y, class_weights, max_epochs=100, patience=10)
|
||||
self.is_trained = True
|
||||
|
||||
# 6. Métadonnées
|
||||
self.metadata = {
|
||||
'symbol': self.symbol,
|
||||
'timeframe': self.timeframe,
|
||||
'model_type': self.model_type,
|
||||
'trained_at': datetime.utcnow().isoformat(),
|
||||
'n_samples': len(X),
|
||||
'seq_len': self.seq_len,
|
||||
'tp_atr_mult': self.tp_atr_mult,
|
||||
'sl_atr_mult': self.sl_atr_mult,
|
||||
'horizon': self.horizon,
|
||||
'label_dist': {
|
||||
'long': int(n_long),
|
||||
'short': int(n_short),
|
||||
'neutral': int(n_neutral),
|
||||
},
|
||||
'wf_metrics': wf_metrics,
|
||||
}
|
||||
|
||||
# 7. Sauvegarde
|
||||
self.save()
|
||||
|
||||
logger.info(f"Entraînement CNN terminé. WF accuracy={wf_metrics.get('avg_accuracy', 0):.2%}")
|
||||
return self.metadata
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Prédiction
|
||||
# -------------------------------------------------------------------------
|
||||
def predict(self, data: pd.DataFrame) -> Dict:
|
||||
"""
|
||||
Prédit le signal pour les dernières barres.
|
||||
|
||||
Args:
|
||||
data: DataFrame OHLCV récent (au moins seq_len barres)
|
||||
|
||||
Returns:
|
||||
Dict : {
|
||||
'signal': 1 (LONG) / -1 (SHORT) / 0 (NEUTRAL),
|
||||
'confidence': float [0..1],
|
||||
'probas': {'long': float, 'short': float, 'neutral': float},
|
||||
'tradeable': bool (confidence >= min_confidence et signal != 0)
|
||||
}
|
||||
"""
|
||||
if not self.is_trained or self.model is None:
|
||||
return {'signal': 0, 'confidence': 0.0, 'tradeable': False, 'error': 'Modèle non entraîné'}
|
||||
|
||||
if not TORCH_AVAILABLE:
|
||||
return {'signal': 0, 'confidence': 0.0, 'tradeable': False, 'error': 'PyTorch non disponible'}
|
||||
|
||||
try:
|
||||
# Encoder la dernière séquence
|
||||
seq = self.encoder.encode_last(data, seq_len=self.seq_len)
|
||||
if len(seq) == 0:
|
||||
return {'signal': 0, 'confidence': 0.0, 'tradeable': False, 'error': 'Données insuffisantes'}
|
||||
|
||||
# Prédiction
|
||||
self.model.eval()
|
||||
x_tensor = torch.FloatTensor(seq)
|
||||
|
||||
with torch.no_grad():
|
||||
probas_tensor = self.model.predict_proba(x_tensor)
|
||||
probas_np = probas_tensor.numpy()[0]
|
||||
|
||||
# Mapping vers signaux de trading
|
||||
pred_idx = int(np.argmax(probas_np))
|
||||
signal = CLASS_MAP[pred_idx]
|
||||
confidence = float(probas_np[pred_idx])
|
||||
|
||||
probas = {
|
||||
'long': float(probas_np[0]),
|
||||
'short': float(probas_np[1]),
|
||||
'neutral': float(probas_np[2]),
|
||||
}
|
||||
|
||||
tradeable = confidence >= self.min_confidence and signal != 0
|
||||
|
||||
return {
|
||||
'signal': signal,
|
||||
'confidence': confidence,
|
||||
'probas': probas,
|
||||
'tradeable': tradeable,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur prédiction CNN : {e}")
|
||||
return {'signal': 0, 'confidence': 0.0, 'tradeable': False, 'error': str(e)}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Sauvegarde / Chargement
|
||||
# -------------------------------------------------------------------------
|
||||
def save(self) -> None:
|
||||
"""Sauvegarde le state_dict PyTorch + métadonnées JSON."""
|
||||
if not TORCH_AVAILABLE or not self.is_trained or self.model is None:
|
||||
raise RuntimeError("Modèle non entraîné")
|
||||
|
||||
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
model_id = f"{self.symbol}_{self.timeframe}_cnn"
|
||||
model_path = MODELS_DIR / f"{model_id}.pt"
|
||||
meta_path = MODELS_DIR / f"{model_id}_meta.json"
|
||||
|
||||
# Sauvegarder state_dict PyTorch
|
||||
torch.save({
|
||||
'state_dict': self.model.state_dict(),
|
||||
'config': {
|
||||
'symbol': self.symbol,
|
||||
'timeframe': self.timeframe,
|
||||
'seq_len': self.seq_len,
|
||||
'min_confidence': self.min_confidence,
|
||||
'tp_atr_mult': self.tp_atr_mult,
|
||||
'sl_atr_mult': self.sl_atr_mult,
|
||||
'horizon': self.horizon,
|
||||
},
|
||||
}, model_path)
|
||||
|
||||
# Sauvegarder métadonnées JSON
|
||||
with open(meta_path, 'w') as f:
|
||||
json.dump(self.metadata, f, indent=2, default=str)
|
||||
|
||||
logger.info(f"Modèle CNN sauvegardé : {model_path}")
|
||||
|
||||
@classmethod
|
||||
def load(cls, symbol: str, timeframe: str) -> 'CNNStrategyModel':
|
||||
"""
|
||||
Charge un modèle CNN depuis le disque.
|
||||
|
||||
Args:
|
||||
symbol: Paire (ex: 'EURUSD')
|
||||
timeframe: Timeframe (ex: '1h')
|
||||
|
||||
Returns:
|
||||
Instance CNNStrategyModel prête à prédire
|
||||
|
||||
Raises:
|
||||
FileNotFoundError si le modèle n'existe pas
|
||||
RuntimeError si PyTorch non disponible
|
||||
"""
|
||||
if not TORCH_AVAILABLE:
|
||||
raise RuntimeError("PyTorch non disponible")
|
||||
|
||||
model_id = f"{symbol}_{timeframe}_cnn"
|
||||
model_path = MODELS_DIR / f"{model_id}.pt"
|
||||
meta_path = MODELS_DIR / f"{model_id}_meta.json"
|
||||
|
||||
if not model_path.exists():
|
||||
raise FileNotFoundError(f"Modèle CNN non trouvé : {model_path}")
|
||||
|
||||
# Charger le checkpoint
|
||||
checkpoint = torch.load(model_path, map_location='cpu', weights_only=False)
|
||||
cfg = checkpoint.get('config', {})
|
||||
|
||||
instance = cls(
|
||||
symbol=cfg.get('symbol', symbol),
|
||||
timeframe=cfg.get('timeframe', timeframe),
|
||||
seq_len=cfg.get('seq_len', 64),
|
||||
min_confidence=cfg.get('min_confidence', 0.55),
|
||||
tp_atr_mult=cfg.get('tp_atr_mult', 2.0),
|
||||
sl_atr_mult=cfg.get('sl_atr_mult', 1.0),
|
||||
horizon=cfg.get('horizon', 30),
|
||||
)
|
||||
|
||||
# Reconstruire le modèle et charger les poids
|
||||
instance.model = TradingCNN(n_features=5, n_classes=3)
|
||||
instance.model.load_state_dict(checkpoint['state_dict'])
|
||||
instance.model.eval()
|
||||
instance.is_trained = True
|
||||
|
||||
# Charger métadonnées si disponibles
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path) as f:
|
||||
instance.metadata = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Modèle CNN chargé depuis {model_path}")
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def list_trained_models(cls) -> List[Dict]:
|
||||
"""Liste les modèles CNN entraînés disponibles."""
|
||||
if not MODELS_DIR.exists():
|
||||
return []
|
||||
|
||||
models = []
|
||||
for f in MODELS_DIR.glob("*_meta.json"):
|
||||
try:
|
||||
with open(f) as fp:
|
||||
meta = json.load(fp)
|
||||
models.append({
|
||||
'symbol': meta.get('symbol', '?'),
|
||||
'timeframe': meta.get('timeframe', '?'),
|
||||
'model_type': 'cnn',
|
||||
'trained_at': meta.get('trained_at', '?'),
|
||||
'n_samples': meta.get('n_samples', 0),
|
||||
'wf_accuracy': meta.get('wf_metrics', {}).get('avg_accuracy', 0),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return models
|
||||
|
||||
def get_feature_importance(self, top_n: int = 10) -> List[Dict]:
|
||||
"""
|
||||
Pour CNN : pas de feature importance classique.
|
||||
|
||||
Retourne une liste vide — les CNN n'ont pas d'importance par feature
|
||||
au sens des arbres de décision. Une analyse par gradient (GradCAM)
|
||||
serait possible mais hors scope pour l'instant.
|
||||
"""
|
||||
return []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Walk-forward évaluation
|
||||
# -------------------------------------------------------------------------
|
||||
def _walk_forward_eval(self, X: np.ndarray, y: np.ndarray, n_folds: int = 2) -> Dict:
|
||||
"""
|
||||
Évalue le CNN en walk-forward validation temporelle.
|
||||
|
||||
Découpage : train 60%, test 20%, hold-out 20% (sur 2 folds).
|
||||
"""
|
||||
n = len(X)
|
||||
fold_size = n // (n_folds + 1)
|
||||
accuracies, precisions, recalls = [], [], []
|
||||
|
||||
for fold in range(n_folds):
|
||||
train_end = fold_size * (fold + 1)
|
||||
test_end = train_end + fold_size
|
||||
|
||||
if test_end > n:
|
||||
break
|
||||
|
||||
X_tr, y_tr = X[:train_end], y[:train_end]
|
||||
X_te, y_te = X[train_end:test_end], y[train_end:test_end]
|
||||
|
||||
if len(X_tr) < 30 or len(X_te) < 10:
|
||||
logger.warning(f" Fold {fold + 1} ignoré : pas assez de données")
|
||||
continue
|
||||
|
||||
# Entraîner un modèle temporaire
|
||||
model = TradingCNN(n_features=5, n_classes=3)
|
||||
class_weights = self._compute_class_weights(y_tr)
|
||||
self._train_model(model, X_tr, y_tr, class_weights, max_epochs=50, patience=5)
|
||||
|
||||
# Évaluer
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
x_tensor = torch.FloatTensor(X_te)
|
||||
logits = model(x_tensor)
|
||||
y_pred = logits.argmax(dim=1).numpy()
|
||||
|
||||
acc = (y_pred == y_te).mean()
|
||||
|
||||
if SKLEARN_AVAILABLE:
|
||||
prec, rec, _, _ = precision_recall_fscore_support(
|
||||
y_te, y_pred, average='macro', zero_division=0
|
||||
)
|
||||
else:
|
||||
prec, rec = 0.0, 0.0
|
||||
|
||||
accuracies.append(acc)
|
||||
precisions.append(prec)
|
||||
recalls.append(rec)
|
||||
logger.info(f" Fold {fold + 1}/{n_folds} : acc={acc:.2%}, prec={prec:.2%}, rec={rec:.2%}")
|
||||
|
||||
if not accuracies:
|
||||
return {'avg_accuracy': 0.0, 'avg_precision': 0.0, 'avg_recall': 0.0, 'fold_accuracies': []}
|
||||
|
||||
return {
|
||||
'avg_accuracy': float(np.mean(accuracies)),
|
||||
'avg_precision': float(np.mean(precisions)),
|
||||
'avg_recall': float(np.mean(recalls)),
|
||||
'fold_accuracies': [float(a) for a in accuracies],
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entraînement interne
|
||||
# -------------------------------------------------------------------------
|
||||
def _train_model(
|
||||
self,
|
||||
model: 'nn.Module',
|
||||
X: np.ndarray,
|
||||
y: np.ndarray,
|
||||
class_weights: 'torch.Tensor' = None,
|
||||
max_epochs: int = 100,
|
||||
patience: int = 10,
|
||||
lr: float = 1e-3,
|
||||
batch_size: int = 64,
|
||||
) -> None:
|
||||
"""
|
||||
Boucle d'entraînement PyTorch avec early stopping.
|
||||
|
||||
Args:
|
||||
model: Instance TradingCNN
|
||||
X: Séquences (N, seq_len, 5)
|
||||
y: Labels (N,) — indices 0, 1, 2
|
||||
class_weights: Poids par classe pour CrossEntropy
|
||||
max_epochs: Nombre max d'époques
|
||||
patience: Nombre d'époques sans amélioration avant arrêt
|
||||
lr: Learning rate
|
||||
batch_size: Taille des batchs
|
||||
"""
|
||||
# Séparer un petit set de validation (derniers 15%)
|
||||
val_size = max(int(len(X) * 0.15), 10)
|
||||
X_train, X_val = X[:-val_size], X[-val_size:]
|
||||
y_train, y_val = y[:-val_size], y[-val_size:]
|
||||
|
||||
# Tenseurs
|
||||
X_train_t = torch.FloatTensor(X_train)
|
||||
y_train_t = torch.LongTensor(y_train)
|
||||
X_val_t = torch.FloatTensor(X_val)
|
||||
y_val_t = torch.LongTensor(y_val)
|
||||
|
||||
dataset = TensorDataset(X_train_t, y_train_t)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
|
||||
|
||||
# Loss avec poids de classe
|
||||
if class_weights is not None:
|
||||
criterion = nn.CrossEntropyLoss(weight=class_weights)
|
||||
else:
|
||||
criterion = nn.CrossEntropyLoss()
|
||||
|
||||
optimizer = optim.Adam(model.parameters(), lr=lr)
|
||||
|
||||
# Early stopping
|
||||
best_val_loss = float('inf')
|
||||
best_state = None
|
||||
epochs_without_improvement = 0
|
||||
|
||||
model.train()
|
||||
for epoch in range(max_epochs):
|
||||
# Phase entraînement
|
||||
total_loss = 0.0
|
||||
n_batches = 0
|
||||
for x_batch, y_batch in loader:
|
||||
optimizer.zero_grad()
|
||||
logits = model(x_batch)
|
||||
loss = criterion(logits, y_batch)
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
|
||||
# Phase validation
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
val_logits = model(X_val_t)
|
||||
val_loss = criterion(val_logits, y_val_t).item()
|
||||
model.train()
|
||||
|
||||
# Early stopping
|
||||
if val_loss < best_val_loss:
|
||||
best_val_loss = val_loss
|
||||
best_state = {k: v.clone() for k, v in model.state_dict().items()}
|
||||
epochs_without_improvement = 0
|
||||
else:
|
||||
epochs_without_improvement += 1
|
||||
|
||||
if epochs_without_improvement >= patience:
|
||||
logger.info(f" Early stopping à l'époque {epoch + 1} (patience={patience})")
|
||||
break
|
||||
|
||||
if (epoch + 1) % 20 == 0:
|
||||
avg_loss = total_loss / max(n_batches, 1)
|
||||
logger.info(f" Époque {epoch + 1}/{max_epochs} — loss={avg_loss:.4f}, val_loss={val_loss:.4f}")
|
||||
|
||||
# Restaurer les meilleurs poids
|
||||
if best_state is not None:
|
||||
model.load_state_dict(best_state)
|
||||
|
||||
model.eval()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Utilitaires
|
||||
# -------------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _compute_class_weights(y: np.ndarray) -> 'torch.Tensor':
|
||||
"""
|
||||
Calcule les poids inversement proportionnels à la fréquence des classes.
|
||||
|
||||
Permet de compenser le déséquilibre (ex: trop de NEUTRAL).
|
||||
"""
|
||||
if not TORCH_AVAILABLE:
|
||||
return None
|
||||
|
||||
classes, counts = np.unique(y, return_counts=True)
|
||||
n_samples = len(y)
|
||||
n_classes = 3
|
||||
|
||||
weights = np.ones(n_classes, dtype=np.float32)
|
||||
for cls, count in zip(classes, counts):
|
||||
if cls < n_classes:
|
||||
weights[int(cls)] = n_samples / (n_classes * count)
|
||||
|
||||
return torch.FloatTensor(weights)
|
||||
3
src/ml/ensemble/__init__.py
Normal file
3
src/ml/ensemble/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .ensemble_model import EnsembleModel
|
||||
|
||||
__all__ = ['EnsembleModel']
|
||||
252
src/ml/ensemble/ensemble_model.py
Normal file
252
src/ml/ensemble/ensemble_model.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Ensemble Model — Combine plusieurs modèles ML pour un signal de trading robuste.
|
||||
|
||||
L'EnsembleModel agrège les prédictions de modèles indépendants (XGBoost, CNN,
|
||||
et plus tard RL) via une moyenne pondérée. Un signal n'est émis que si les
|
||||
modèles actifs sont en accord ET que le score pondéré dépasse un seuil.
|
||||
|
||||
Duck typing : ce module n'importe PAS directement MLStrategyModel ni
|
||||
CNNStrategyModel. Tout objet exposant `.predict(df)` → dict et `.is_trained`
|
||||
→ bool est compatible.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnsembleModel:
|
||||
"""
|
||||
Combine plusieurs modèles ML pour produire un signal de trading robuste.
|
||||
|
||||
Logique :
|
||||
- Chaque modèle prédit indépendamment (signal + confidence)
|
||||
- Score final = somme pondérée des confidences pour les modèles en accord
|
||||
- Signal validé uniquement si :
|
||||
1. Au moins 2 modèles actifs sont en accord sur la direction
|
||||
2. Score pondéré >= min_confidence
|
||||
|
||||
Poids par défaut : xgboost=0.40, cnn=0.60 (CNN légèrement favorisé car
|
||||
il voit les données brutes sans biais de feature engineering)
|
||||
"""
|
||||
|
||||
DEFAULT_WEIGHTS = {
|
||||
'xgboost': 0.40,
|
||||
'cnn': 0.60,
|
||||
'rl': 0.00, # Réservé Phase 4d
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
weights: Optional[Dict[str, float]] = None,
|
||||
min_confidence: float = 0.60,
|
||||
require_agreement: bool = True,
|
||||
):
|
||||
self.weights = dict(weights) if weights else dict(self.DEFAULT_WEIGHTS)
|
||||
self.min_confidence = min_confidence
|
||||
self.require_agreement = require_agreement
|
||||
|
||||
# Modèles attachés (duck typing : .predict(df), .is_trained)
|
||||
self._models: Dict[str, Any] = {}
|
||||
|
||||
logger.info(
|
||||
f"EnsembleModel initialisé — poids={self.weights}, "
|
||||
f"seuil={self.min_confidence}, accord_requis={self.require_agreement}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attachement des modèles
|
||||
# ------------------------------------------------------------------
|
||||
def attach_xgboost(self, model) -> None:
|
||||
"""Attache un MLStrategyModel entraîné."""
|
||||
self._attach('xgboost', model)
|
||||
|
||||
def attach_cnn(self, model) -> None:
|
||||
"""Attache un CNNStrategyModel entraîné."""
|
||||
self._attach('cnn', model)
|
||||
|
||||
def attach_rl(self, model) -> None:
|
||||
"""Attache un agent RL (Phase 4d)."""
|
||||
self._attach('rl', model)
|
||||
|
||||
def _attach(self, name: str, model) -> None:
|
||||
"""Attache un modèle générique avec vérification duck typing."""
|
||||
if not hasattr(model, 'predict') or not callable(model.predict):
|
||||
raise ValueError(f"Le modèle '{name}' doit exposer une méthode predict()")
|
||||
if not hasattr(model, 'is_trained'):
|
||||
raise ValueError(f"Le modèle '{name}' doit exposer un attribut is_trained")
|
||||
self._models[name] = model
|
||||
# Ajouter le poids par défaut s'il n'existe pas
|
||||
if name not in self.weights:
|
||||
self.weights[name] = 0.0
|
||||
logger.warning(f"Poids pour '{name}' non défini — initialisé à 0.0")
|
||||
logger.info(f"Modèle '{name}' attaché (is_trained={model.is_trained})")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Prédiction combinée
|
||||
# ------------------------------------------------------------------
|
||||
def predict(self, df: pd.DataFrame) -> Dict:
|
||||
"""
|
||||
Prédit le signal combiné à partir de tous les modèles actifs.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'signal': int, # 1 LONG, -1 SHORT, 0 NEUTRAL
|
||||
'confidence': float, # score pondéré [0..1]
|
||||
'tradeable': bool,
|
||||
'agreement': bool, # True si tous les modèles actifs concordent
|
||||
'components': dict, # résultats individuels par modèle
|
||||
}
|
||||
"""
|
||||
components: Dict[str, Dict] = {}
|
||||
neutral_result = {
|
||||
'signal': 0, 'confidence': 0.0, 'tradeable': False,
|
||||
'agreement': False, 'components': components,
|
||||
}
|
||||
|
||||
# 1. Collecter les prédictions des modèles disponibles et entraînés
|
||||
for name, model in self._models.items():
|
||||
if not model.is_trained:
|
||||
logger.debug(f"Ensemble : modèle '{name}' non entraîné, ignoré")
|
||||
continue
|
||||
if self.weights.get(name, 0.0) <= 0.0:
|
||||
logger.debug(f"Ensemble : modèle '{name}' poids=0, ignoré")
|
||||
continue
|
||||
try:
|
||||
result = model.predict(df)
|
||||
components[name] = {
|
||||
'signal': result.get('signal', 0),
|
||||
'confidence': result.get('confidence', 0.0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Ensemble : erreur predict '{name}' — {e}")
|
||||
continue
|
||||
|
||||
if not components:
|
||||
logger.debug("Ensemble : aucun modèle actif n'a produit de prédiction")
|
||||
neutral_result['components'] = components
|
||||
return neutral_result
|
||||
|
||||
# 2. Filtrer les signaux non-neutres
|
||||
directional = {
|
||||
k: v for k, v in components.items() if v['signal'] != 0
|
||||
}
|
||||
|
||||
if not directional:
|
||||
# Tous les modèles sont neutres
|
||||
return {
|
||||
'signal': 0, 'confidence': 0.0, 'tradeable': False,
|
||||
'agreement': True, 'components': components,
|
||||
}
|
||||
|
||||
# 3. Vérifier l'accord entre modèles directionnels
|
||||
directions = set(v['signal'] for v in directional.values())
|
||||
agreement = len(directions) == 1
|
||||
|
||||
if self.require_agreement and not agreement:
|
||||
logger.debug(
|
||||
f"Ensemble : désaccord entre modèles — {directional}"
|
||||
)
|
||||
return {
|
||||
'signal': 0, 'confidence': 0.0, 'tradeable': False,
|
||||
'agreement': False, 'components': components,
|
||||
}
|
||||
|
||||
# 4. Vérifier qu'au moins 2 modèles actifs sont en accord
|
||||
if len(directional) < 2:
|
||||
logger.debug("Ensemble : un seul modèle directionnel, signal insuffisant")
|
||||
return {
|
||||
'signal': 0, 'confidence': 0.0, 'tradeable': False,
|
||||
'agreement': True, 'components': components,
|
||||
}
|
||||
|
||||
# 5. Calculer le score pondéré (normalisé sur les modèles actifs)
|
||||
consensus_dir = directions.pop() # direction unique
|
||||
total_weight = sum(self.weights.get(k, 0.0) for k in directional)
|
||||
|
||||
if total_weight <= 0:
|
||||
return {
|
||||
'signal': 0, 'confidence': 0.0, 'tradeable': False,
|
||||
'agreement': agreement, 'components': components,
|
||||
}
|
||||
|
||||
weighted_score = sum(
|
||||
self.weights.get(k, 0.0) * v['confidence']
|
||||
for k, v in directional.items()
|
||||
) / total_weight
|
||||
|
||||
# 6. Signal final
|
||||
tradeable = weighted_score >= self.min_confidence
|
||||
|
||||
logger.info(
|
||||
f"Ensemble : direction={'LONG' if consensus_dir == 1 else 'SHORT'} | "
|
||||
f"score={weighted_score:.2%} | accord={agreement} | tradeable={tradeable}"
|
||||
)
|
||||
|
||||
return {
|
||||
'signal': consensus_dir,
|
||||
'confidence': weighted_score,
|
||||
'tradeable': tradeable,
|
||||
'agreement': agreement,
|
||||
'components': components,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Statut et configuration
|
||||
# ------------------------------------------------------------------
|
||||
def is_ready(self) -> bool:
|
||||
"""True si au moins 2 modèles sont attachés et entraînés."""
|
||||
trained = sum(
|
||||
1 for m in self._models.values()
|
||||
if m.is_trained and self.weights.get(
|
||||
next(k for k, v in self._models.items() if v is m), 0
|
||||
) > 0
|
||||
)
|
||||
return trained >= 2
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Statut de chaque composant + poids actifs."""
|
||||
status = {
|
||||
'ready': self.is_ready(),
|
||||
'min_confidence': self.min_confidence,
|
||||
'require_agreement': self.require_agreement,
|
||||
'weights': dict(self.weights),
|
||||
'models': {},
|
||||
}
|
||||
for name, model in self._models.items():
|
||||
status['models'][name] = {
|
||||
'attached': True,
|
||||
'is_trained': model.is_trained,
|
||||
'weight': self.weights.get(name, 0.0),
|
||||
}
|
||||
# Modèles non attachés mais présents dans les poids
|
||||
for name in self.weights:
|
||||
if name not in status['models']:
|
||||
status['models'][name] = {
|
||||
'attached': False,
|
||||
'is_trained': False,
|
||||
'weight': self.weights[name],
|
||||
}
|
||||
return status
|
||||
|
||||
def update_weights(self, weights: Dict[str, float]) -> None:
|
||||
"""
|
||||
Mise à jour dynamique des poids.
|
||||
|
||||
Si la somme != 1.0, normalise automatiquement et log un warning.
|
||||
"""
|
||||
total = sum(weights.values())
|
||||
if total <= 0:
|
||||
raise ValueError("La somme des poids doit être > 0")
|
||||
|
||||
if abs(total - 1.0) > 1e-6:
|
||||
logger.warning(
|
||||
f"Somme des poids = {total:.4f} != 1.0 — normalisation automatique"
|
||||
)
|
||||
weights = {k: v / total for k, v in weights.items()}
|
||||
|
||||
self.weights.update(weights)
|
||||
logger.info(f"Poids mis à jour : {self.weights}")
|
||||
3
src/strategies/cnn_driven/__init__.py
Normal file
3
src/strategies/cnn_driven/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .cnn_strategy import CNNDrivenStrategy
|
||||
|
||||
__all__ = ['CNNDrivenStrategy']
|
||||
249
src/strategies/cnn_driven/cnn_strategy.py
Normal file
249
src/strategies/cnn_driven/cnn_strategy.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
CNN-Driven Strategy — Stratégie pilotée par un réseau convolutif 1D.
|
||||
|
||||
Cette stratégie utilise un CNN 1D entraîné sur des séquences OHLCV brutes
|
||||
pour détecter des patterns visuels dans les bougies (double bottom, squeeze
|
||||
Bollinger, alignements, etc.) sans features pré-calculées.
|
||||
|
||||
Fonctionnement :
|
||||
1. Le modèle CNN est chargé depuis le disque (entraîné via POST /trading/train-cnn)
|
||||
2. À chaque barre, la séquence OHLCV récente est passée au CNN
|
||||
3. Le modèle prédit LONG / SHORT / NEUTRAL avec un score de confiance
|
||||
4. Si confidence >= min_confidence, un signal est émis avec SL/TP basés sur ATR
|
||||
|
||||
Intégration :
|
||||
- Compatible avec StrategyEngine (même interface que ScalpingStrategy / MLDrivenStrategy)
|
||||
- Chargé automatiquement si un modèle entraîné existe pour le symbole/timeframe
|
||||
- Le RiskManager applique les mêmes contrôles que pour les stratégies classiques
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base_strategy import BaseStrategy, Signal, StrategyConfig
|
||||
|
||||
try:
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
CNN_AVAILABLE = True
|
||||
except ImportError:
|
||||
CNN_AVAILABLE = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CNNDrivenStrategy(BaseStrategy):
|
||||
"""
|
||||
Stratégie de trading pilotée par un CNN 1D pré-entraîné.
|
||||
|
||||
Le modèle apprend directement les patterns visuels des bougies :
|
||||
- Double bottom / double top
|
||||
- Squeeze Bollinger + expansion
|
||||
- Alignements de moyennes mobiles
|
||||
- Patterns chandeliers complexes
|
||||
- Structures de prix multi-barres
|
||||
|
||||
Args:
|
||||
config: Dict de configuration (timeframe, risk_per_trade, symbol, etc.)
|
||||
|
||||
Config keys supplémentaires (optionnelles) :
|
||||
min_confidence: Seuil de confiance minimum [0..1] (défaut: 0.55)
|
||||
tp_atr_mult: Multiplicateur ATR pour TP (défaut: 2.0)
|
||||
sl_atr_mult: Multiplicateur ATR pour SL (défaut: 1.0)
|
||||
seq_len: Longueur de séquence d'entrée (défaut: 64)
|
||||
auto_load: Charger automatiquement le modèle existant (défaut: True)
|
||||
"""
|
||||
|
||||
STRATEGY_NAME = 'cnn_driven'
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
super().__init__(config)
|
||||
|
||||
self.symbol = config.get('symbol', 'EURUSD')
|
||||
self.min_confidence = config.get('min_confidence', 0.55)
|
||||
self.tp_atr_mult = config.get('tp_atr_mult', 2.0)
|
||||
self.sl_atr_mult = config.get('sl_atr_mult', 1.0)
|
||||
self.seq_len = config.get('seq_len', 64)
|
||||
|
||||
self.cnn_model: Optional['CNNStrategyModel'] = None
|
||||
|
||||
if not CNN_AVAILABLE:
|
||||
logger.warning("CNN non disponible (PyTorch requis)")
|
||||
return
|
||||
|
||||
# Tentative de chargement automatique du modèle existant
|
||||
if config.get('auto_load', True):
|
||||
self._try_load_model()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Interface BaseStrategy
|
||||
# -------------------------------------------------------------------------
|
||||
def analyze(self, market_data: pd.DataFrame) -> Optional[Signal]:
|
||||
"""
|
||||
Génère un signal de trading via le modèle CNN.
|
||||
|
||||
Args:
|
||||
market_data: DataFrame OHLCV (minimum seq_len barres)
|
||||
|
||||
Returns:
|
||||
Signal si le modèle est confiant, None sinon
|
||||
"""
|
||||
if not CNN_AVAILABLE:
|
||||
logger.debug("CNN Strategy : PyTorch non disponible, aucun signal")
|
||||
return None
|
||||
|
||||
if self.cnn_model is None or not self.cnn_model.is_trained:
|
||||
logger.debug("CNN Strategy : modèle non chargé, aucun signal")
|
||||
return None
|
||||
|
||||
if len(market_data) < self.seq_len:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self.cnn_model.predict(market_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"CNN Strategy predict error : {e}")
|
||||
return None
|
||||
|
||||
if not result.get('tradeable', False):
|
||||
return None
|
||||
|
||||
signal_dir = result['signal'] # 1 = LONG, -1 = SHORT
|
||||
confidence = result['confidence']
|
||||
|
||||
# Prix et ATR pour SL/TP
|
||||
last_close = float(market_data['close'].iloc[-1])
|
||||
atr = self._compute_atr(market_data)
|
||||
if atr <= 0:
|
||||
return None
|
||||
|
||||
if signal_dir == 1:
|
||||
direction = 'LONG'
|
||||
stop_loss = last_close - self.sl_atr_mult * atr
|
||||
take_profit = last_close + self.tp_atr_mult * atr
|
||||
elif signal_dir == -1:
|
||||
direction = 'SHORT'
|
||||
stop_loss = last_close + self.sl_atr_mult * atr
|
||||
take_profit = last_close - self.tp_atr_mult * atr
|
||||
else:
|
||||
return None
|
||||
|
||||
signal = Signal(
|
||||
symbol = self.symbol,
|
||||
direction = direction,
|
||||
entry_price = last_close,
|
||||
stop_loss = stop_loss,
|
||||
take_profit = take_profit,
|
||||
confidence = confidence,
|
||||
timestamp = datetime.now(timezone.utc),
|
||||
strategy = self.STRATEGY_NAME,
|
||||
metadata = {
|
||||
'probas': result.get('probas', {}),
|
||||
'seq_len': self.seq_len,
|
||||
'atr': atr,
|
||||
'tp_atr_mult': self.tp_atr_mult,
|
||||
'sl_atr_mult': self.sl_atr_mult,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"CNN Signal : {direction} {self.symbol} | "
|
||||
f"entry={last_close:.5f} SL={stop_loss:.5f} TP={take_profit:.5f} | "
|
||||
f"confidence={confidence:.2%}"
|
||||
)
|
||||
return signal
|
||||
|
||||
def calculate_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Retourne les données telles quelles — le CNN travaille sur les séquences brutes."""
|
||||
return data
|
||||
|
||||
def update_params(self, params: Dict) -> None:
|
||||
"""Mise à jour dynamique des paramètres (depuis API ou Optuna)."""
|
||||
if 'min_confidence' in params:
|
||||
self.min_confidence = params['min_confidence']
|
||||
if self.cnn_model:
|
||||
self.cnn_model.min_confidence = params['min_confidence']
|
||||
if 'tp_atr_mult' in params:
|
||||
self.tp_atr_mult = params['tp_atr_mult']
|
||||
if 'sl_atr_mult' in params:
|
||||
self.sl_atr_mult = params['sl_atr_mult']
|
||||
if 'seq_len' in params:
|
||||
self.seq_len = params['seq_len']
|
||||
logger.info(f"CNN Strategy params mis à jour : {params}")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Gestion du modèle
|
||||
# -------------------------------------------------------------------------
|
||||
def load_model(self, symbol: Optional[str] = None, timeframe: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Charge un modèle CNN depuis le disque.
|
||||
|
||||
Args:
|
||||
symbol: Paire (défaut: self.symbol)
|
||||
timeframe: Timeframe (défaut: self.config.timeframe)
|
||||
|
||||
Returns:
|
||||
True si chargement réussi
|
||||
"""
|
||||
if not CNN_AVAILABLE:
|
||||
logger.warning("CNN non disponible (PyTorch requis)")
|
||||
return False
|
||||
|
||||
sym = symbol or self.symbol
|
||||
tf = timeframe or self.config.timeframe
|
||||
try:
|
||||
self.cnn_model = CNNStrategyModel.load(sym, tf)
|
||||
logger.info(f"Modèle CNN chargé : {sym}/{tf}")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.info(f"Aucun modèle CNN trouvé pour {sym}/{tf}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur chargement modèle CNN : {e}")
|
||||
return False
|
||||
|
||||
def attach_model(self, model: 'CNNStrategyModel') -> None:
|
||||
"""Attache directement un modèle CNN (après entraînement via API)."""
|
||||
self.cnn_model = model
|
||||
self.symbol = model.symbol
|
||||
logger.info(f"Modèle CNN attaché : {model.symbol}/{model.timeframe}")
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""Retourne True si le modèle CNN est chargé et entraîné."""
|
||||
if not CNN_AVAILABLE:
|
||||
return False
|
||||
return self.cnn_model is not None and self.cnn_model.is_trained
|
||||
|
||||
def get_model_info(self) -> Dict:
|
||||
"""Retourne les métadonnées du modèle CNN actif."""
|
||||
if not CNN_AVAILABLE:
|
||||
return {'status': 'PyTorch non disponible'}
|
||||
if not self.is_ready():
|
||||
return {'status': 'non entraîné'}
|
||||
meta = self.cnn_model.metadata.copy()
|
||||
meta['is_ready'] = True
|
||||
meta['seq_len'] = self.seq_len
|
||||
return meta
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
def _try_load_model(self) -> None:
|
||||
"""Tente un chargement silencieux du modèle au démarrage."""
|
||||
try:
|
||||
self.load_model()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _compute_atr(df: pd.DataFrame, period: int = 14) -> float:
|
||||
"""Calcule l'ATR moyen sur les dernières barres."""
|
||||
if len(df) < period + 1:
|
||||
return float(df['high'].iloc[-1] - df['low'].iloc[-1])
|
||||
h, l, pc = df['high'], df['low'], df['close'].shift(1)
|
||||
tr = pd.concat([h - l, (h - pc).abs(), (l - pc).abs()], axis=1).max(axis=1)
|
||||
atr = tr.rolling(period).mean().iloc[-1]
|
||||
return float(atr) if not np.isnan(atr) else float(df['high'].iloc[-1] - df['low'].iloc[-1])
|
||||
3
src/strategies/ensemble/__init__.py
Normal file
3
src/strategies/ensemble/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .ensemble_strategy import EnsembleStrategy
|
||||
|
||||
__all__ = ['EnsembleStrategy']
|
||||
190
src/strategies/ensemble/ensemble_strategy.py
Normal file
190
src/strategies/ensemble/ensemble_strategy.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Ensemble Strategy — Stratégie combinant XGBoost + CNN (+ RL futur).
|
||||
|
||||
Cette stratégie utilise l'EnsembleModel pour agréger les signaux de plusieurs
|
||||
modèles ML. Un signal n'est émis que si les modèles sont en accord et que
|
||||
le score pondéré dépasse le seuil configuré.
|
||||
|
||||
Config keys :
|
||||
weights: dict poids par modèle (défaut: XGB=0.40, CNN=0.60)
|
||||
min_confidence: seuil score pondéré (défaut: 0.60)
|
||||
require_agreement: exiger accord entre modèles (défaut: True)
|
||||
tp_atr_mult: TP en multiples d'ATR (défaut: 2.0)
|
||||
sl_atr_mult: SL en multiples d'ATR (défaut: 1.0)
|
||||
auto_load: charger modèles existants au démarrage (défaut: True)
|
||||
symbol: paire tradée (défaut: 'EURUSD')
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.strategies.base_strategy import BaseStrategy, Signal
|
||||
from src.ml.ensemble.ensemble_model import EnsembleModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnsembleStrategy(BaseStrategy):
|
||||
"""
|
||||
Stratégie de trading combinant plusieurs modèles ML via EnsembleModel.
|
||||
|
||||
Nécessite au minimum 2 modèles entraînés et attachés pour émettre
|
||||
des signaux. Les SL/TP sont calculés à partir de l'ATR.
|
||||
"""
|
||||
|
||||
STRATEGY_NAME = 'ensemble'
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
# Forcer le nom de la stratégie
|
||||
config.setdefault('name', self.STRATEGY_NAME)
|
||||
super().__init__(config)
|
||||
|
||||
self.symbol = config.get('symbol', 'EURUSD')
|
||||
self.tp_atr_mult = config.get('tp_atr_mult', 2.0)
|
||||
self.sl_atr_mult = config.get('sl_atr_mult', 1.0)
|
||||
|
||||
self.ensemble = EnsembleModel(
|
||||
weights=config.get('weights'),
|
||||
min_confidence=config.get('min_confidence', 0.60),
|
||||
require_agreement=config.get('require_agreement', True),
|
||||
)
|
||||
|
||||
if config.get('auto_load', True):
|
||||
self._try_load_models()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Interface BaseStrategy
|
||||
# ------------------------------------------------------------------
|
||||
def analyze(self, market_data: pd.DataFrame) -> Optional[Signal]:
|
||||
"""
|
||||
Génère un signal via l'ensemble de modèles ML.
|
||||
|
||||
Args:
|
||||
market_data: DataFrame OHLCV (minimum 50 barres)
|
||||
|
||||
Returns:
|
||||
Signal si l'ensemble est confiant et en accord, None sinon
|
||||
"""
|
||||
if not self.ensemble.is_ready():
|
||||
logger.debug("Ensemble Strategy : ensemble non prêt (< 2 modèles)")
|
||||
return None
|
||||
|
||||
if len(market_data) < 50:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = self.ensemble.predict(market_data)
|
||||
except Exception as e:
|
||||
logger.warning(f"Ensemble Strategy predict error : {e}")
|
||||
return None
|
||||
|
||||
if not result.get('tradeable', False):
|
||||
return None
|
||||
|
||||
signal_dir = result['signal'] # 1 = LONG, -1 = SHORT
|
||||
confidence = result['confidence']
|
||||
|
||||
# Prix et ATR pour SL/TP
|
||||
last_close = float(market_data['close'].iloc[-1])
|
||||
atr = self._compute_atr(market_data)
|
||||
if atr <= 0:
|
||||
return None
|
||||
|
||||
if signal_dir == 1:
|
||||
direction = 'LONG'
|
||||
stop_loss = last_close - self.sl_atr_mult * atr
|
||||
take_profit = last_close + self.tp_atr_mult * atr
|
||||
elif signal_dir == -1:
|
||||
direction = 'SHORT'
|
||||
stop_loss = last_close + self.sl_atr_mult * atr
|
||||
take_profit = last_close - self.tp_atr_mult * atr
|
||||
else:
|
||||
return None
|
||||
|
||||
signal = Signal(
|
||||
symbol=self.symbol,
|
||||
direction=direction,
|
||||
entry_price=last_close,
|
||||
stop_loss=stop_loss,
|
||||
take_profit=take_profit,
|
||||
confidence=confidence,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
strategy=self.STRATEGY_NAME,
|
||||
metadata={
|
||||
'ensemble_agreement': result.get('agreement', False),
|
||||
'components': result.get('components', {}),
|
||||
'atr': atr,
|
||||
'tp_atr_mult': self.tp_atr_mult,
|
||||
'sl_atr_mult': self.sl_atr_mult,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Ensemble Signal : {direction} {self.symbol} | "
|
||||
f"entry={last_close:.5f} SL={stop_loss:.5f} TP={take_profit:.5f} | "
|
||||
f"confidence={confidence:.2%} | accord={result.get('agreement')}"
|
||||
)
|
||||
return signal
|
||||
|
||||
def calculate_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Retourne les données telles quelles — les features sont dans predict()."""
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gestion des modèles
|
||||
# ------------------------------------------------------------------
|
||||
def attach_xgboost(self, model) -> None:
|
||||
"""Attache un modèle XGBoost à l'ensemble."""
|
||||
self.ensemble.attach_xgboost(model)
|
||||
|
||||
def attach_cnn(self, model) -> None:
|
||||
"""Attache un modèle CNN à l'ensemble."""
|
||||
self.ensemble.attach_cnn(model)
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""True si l'ensemble a au moins 2 modèles entraînés."""
|
||||
return self.ensemble.is_ready()
|
||||
|
||||
def get_status(self) -> Dict:
|
||||
"""Retourne le statut de l'ensemble."""
|
||||
return self.ensemble.get_status()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Chargement automatique
|
||||
# ------------------------------------------------------------------
|
||||
def _try_load_models(self) -> None:
|
||||
"""Tente de charger les modèles existants au démarrage."""
|
||||
# XGBoost via MLStrategyModel
|
||||
try:
|
||||
from src.ml.ml_strategy_model import MLStrategyModel
|
||||
xgb = MLStrategyModel.load(self.symbol, self.config.timeframe, 'xgboost')
|
||||
self.ensemble.attach_xgboost(xgb)
|
||||
logger.info(f"Ensemble : modèle XGBoost chargé pour {self.symbol}")
|
||||
except Exception:
|
||||
logger.debug(f"Ensemble : pas de modèle XGBoost pour {self.symbol}")
|
||||
|
||||
# CNN via CNNStrategyModel (peut ne pas encore exister)
|
||||
try:
|
||||
from src.ml.cnn import CNNStrategyModel
|
||||
cnn = CNNStrategyModel.load(self.symbol, self.config.timeframe)
|
||||
self.ensemble.attach_cnn(cnn)
|
||||
logger.info(f"Ensemble : modèle CNN chargé pour {self.symbol}")
|
||||
except Exception:
|
||||
logger.debug(f"Ensemble : pas de modèle CNN pour {self.symbol}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _compute_atr(df: pd.DataFrame, period: int = 14) -> float:
|
||||
"""Calcule l'ATR moyen sur les dernières barres."""
|
||||
if len(df) < period + 1:
|
||||
return float(df['high'].iloc[-1] - df['low'].iloc[-1])
|
||||
h, l, pc = df['high'], df['low'], df['close'].shift(1)
|
||||
tr = pd.concat([h - l, (h - pc).abs(), (l - pc).abs()], axis=1).max(axis=1)
|
||||
atr = tr.rolling(period).mean().iloc[-1]
|
||||
return float(atr) if not np.isnan(atr) else float(df['high'].iloc[-1] - df['low'].iloc[-1])
|
||||
Reference in New Issue
Block a user