From acc3338213755d3d562b56d6e4a73865c70ba219 Mon Sep 17 00:00:00 2001 From: Tika Date: Tue, 10 Mar 2026 19:34:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204c=20=E2=80=94=20CNN=20+=20Ense?= =?UTF-8?q?mble=20architecture=20(multi-signal=20trading)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- docker/requirements/api.txt | 9 + src/api/routers/trading.py | 325 ++++++++++- src/core/strategy_engine.py | 3 + src/ml/cnn/__init__.py | 3 + src/ml/cnn/candlestick_encoder.py | 129 ++++ src/ml/cnn/cnn_model.py | 113 ++++ src/ml/cnn/cnn_strategy_model.py | 585 +++++++++++++++++++ src/ml/ensemble/__init__.py | 3 + src/ml/ensemble/ensemble_model.py | 252 ++++++++ src/strategies/cnn_driven/__init__.py | 3 + src/strategies/cnn_driven/cnn_strategy.py | 249 ++++++++ src/strategies/ensemble/__init__.py | 3 + src/strategies/ensemble/ensemble_strategy.py | 190 ++++++ 13 files changed, 1861 insertions(+), 6 deletions(-) create mode 100644 src/ml/cnn/__init__.py create mode 100644 src/ml/cnn/candlestick_encoder.py create mode 100644 src/ml/cnn/cnn_model.py create mode 100644 src/ml/cnn/cnn_strategy_model.py create mode 100644 src/ml/ensemble/__init__.py create mode 100644 src/ml/ensemble/ensemble_model.py create mode 100644 src/strategies/cnn_driven/__init__.py create mode 100644 src/strategies/cnn_driven/cnn_strategy.py create mode 100644 src/strategies/ensemble/__init__.py create mode 100644 src/strategies/ensemble/ensemble_strategy.py diff --git a/docker/requirements/api.txt b/docker/requirements/api.txt index 86bb794..4efc7b8 100644 --- a/docker/requirements/api.txt +++ b/docker/requirements/api.txt @@ -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 diff --git a/src/api/routers/trading.py b/src/api/routers/trading.py index 002d576..c2a60e3 100644 --- a/src/api/routers/trading.py +++ b/src/api/routers/trading.py @@ -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 diff --git a/src/core/strategy_engine.py b/src/core/strategy_engine.py index a7b08b1..e5c96a2 100644 --- a/src/core/strategy_engine.py +++ b/src/core/strategy_engine.py @@ -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}") diff --git a/src/ml/cnn/__init__.py b/src/ml/cnn/__init__.py new file mode 100644 index 0000000..eba366f --- /dev/null +++ b/src/ml/cnn/__init__.py @@ -0,0 +1,3 @@ +from .cnn_strategy_model import CNNStrategyModel + +__all__ = ['CNNStrategyModel'] diff --git a/src/ml/cnn/candlestick_encoder.py b/src/ml/cnn/candlestick_encoder.py new file mode 100644 index 0000000..bd730c9 --- /dev/null +++ b/src/ml/cnn/candlestick_encoder.py @@ -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 diff --git a/src/ml/cnn/cnn_model.py b/src/ml/cnn/cnn_model.py new file mode 100644 index 0000000..da3faf9 --- /dev/null +++ b/src/ml/cnn/cnn_model.py @@ -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") diff --git a/src/ml/cnn/cnn_strategy_model.py b/src/ml/cnn/cnn_strategy_model.py new file mode 100644 index 0000000..a8b11cd --- /dev/null +++ b/src/ml/cnn/cnn_strategy_model.py @@ -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) diff --git a/src/ml/ensemble/__init__.py b/src/ml/ensemble/__init__.py new file mode 100644 index 0000000..7003f73 --- /dev/null +++ b/src/ml/ensemble/__init__.py @@ -0,0 +1,3 @@ +from .ensemble_model import EnsembleModel + +__all__ = ['EnsembleModel'] diff --git a/src/ml/ensemble/ensemble_model.py b/src/ml/ensemble/ensemble_model.py new file mode 100644 index 0000000..320bbc1 --- /dev/null +++ b/src/ml/ensemble/ensemble_model.py @@ -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}") diff --git a/src/strategies/cnn_driven/__init__.py b/src/strategies/cnn_driven/__init__.py new file mode 100644 index 0000000..aa671b5 --- /dev/null +++ b/src/strategies/cnn_driven/__init__.py @@ -0,0 +1,3 @@ +from .cnn_strategy import CNNDrivenStrategy + +__all__ = ['CNNDrivenStrategy'] diff --git a/src/strategies/cnn_driven/cnn_strategy.py b/src/strategies/cnn_driven/cnn_strategy.py new file mode 100644 index 0000000..2ea8aab --- /dev/null +++ b/src/strategies/cnn_driven/cnn_strategy.py @@ -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]) diff --git a/src/strategies/ensemble/__init__.py b/src/strategies/ensemble/__init__.py new file mode 100644 index 0000000..3991152 --- /dev/null +++ b/src/strategies/ensemble/__init__.py @@ -0,0 +1,3 @@ +from .ensemble_strategy import EnsembleStrategy + +__all__ = ['EnsembleStrategy'] diff --git a/src/strategies/ensemble/ensemble_strategy.py b/src/strategies/ensemble/ensemble_strategy.py new file mode 100644 index 0000000..c9bc82a --- /dev/null +++ b/src/strategies/ensemble/ensemble_strategy.py @@ -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])