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