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