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:
Tika
2026-03-10 19:34:41 +00:00
parent 8732acf3d0
commit acc3338213
13 changed files with 1861 additions and 6 deletions

View File

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

View File

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

View File

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

@@ -0,0 +1,3 @@
from .cnn_strategy_model import CNNStrategyModel
__all__ = ['CNNStrategyModel']

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

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

View File

@@ -0,0 +1,3 @@
from .ensemble_model import EnsembleModel
__all__ = ['EnsembleModel']

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

View File

@@ -0,0 +1,3 @@
from .cnn_strategy import CNNDrivenStrategy
__all__ = ['CNNDrivenStrategy']

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

View File

@@ -0,0 +1,3 @@
from .ensemble_strategy import EnsembleStrategy
__all__ = ['EnsembleStrategy']

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