feat: ML-Driven Strategy — apprentissage des patterns TA humains

Nouveau module complet pour entraîner un modèle XGBoost/LightGBM
qui apprend à détecter des opportunités depuis des indicateurs classiques :
RSI (divergences), MACD (crossovers), Bollinger (squeeze/rebond),
Supports/Résistances (pivots locaux), Points Pivots (classiques + Fibonacci),
patterns chandeliers (marteau, engulfing), alignement EMAs, volume.

Fichiers créés :
- src/ml/features/technical_features.py  (~50 features TA)
- src/ml/features/label_generator.py     (labels LONG/SHORT/NEUTRAL par forward simulation ATR)
- src/ml/ml_strategy_model.py            (entraînement + walk-forward + sauvegarde joblib)
- src/strategies/ml_driven/ml_strategy.py (stratégie compatible StrategyEngine)

Routes API ajoutées :
- POST   /trading/train                              (entraînement async)
- GET    /trading/train/{job_id}                     (état du job)
- GET    /trading/ml-models                          (liste modèles disponibles)
- GET    /trading/ml-models/{symbol}/{tf}/importance (feature importance)

Documentation : docs/ML_STRATEGY_GUIDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tika
2026-03-08 17:45:39 +00:00
parent da30ef19ed
commit cc05ddb7c4
8 changed files with 1725 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
"""
Générateur de Labels — Supervision du modèle ML stratégie.
Pour chaque barre, on regarde ce qui se passe dans les N barres suivantes :
- LONG (1) : le prix monte de `tp_pct` avant de baisser de `sl_pct`
- SHORT (-1) : le prix baisse de `sl_pct` avant de monter de `tp_pct`
- NEUTRAL (0) : aucune des deux conditions n'est atteinte
Cette approche "forward simulation" reproduit la logique réelle d'un trade :
chaque label correspond à un trade qui aurait été rentable (TP atteint avant SL).
"""
import numpy as np
import pandas as pd
from typing import Tuple
import logging
logger = logging.getLogger(__name__)
class LabelGenerator:
"""
Génère des labels de classification pour l'entraînement supervisé.
Args:
tp_pct: Mouvement haussier minimal pour valider un LONG (ex: 0.003 = 0.3%)
sl_pct: Mouvement baissier maximal avant stop-loss (ex: 0.002 = 0.2%)
horizon: Nombre de barres max pour atteindre TP ou SL
min_ratio: Ratio TP/SL minimum (ignore les configs déséquilibrées)
Exemple avec ATR dynamique:
gen = LabelGenerator(tp_pct=None, sl_pct=None)
labels = gen.generate_atr(df, atr_tp_mult=2.0, atr_sl_mult=1.0)
"""
def __init__(
self,
tp_pct: float = 0.003, # 0.3% TP par défaut
sl_pct: float = 0.002, # 0.2% SL par défaut → R:R = 1.5
horizon: int = 30, # Fenêtre de 30 barres
min_ratio: float = 1.0, # TP doit être >= SL
):
self.tp_pct = tp_pct
self.sl_pct = sl_pct
self.horizon = horizon
self.min_ratio = min_ratio
def generate(self, df: pd.DataFrame) -> pd.Series:
"""
Génère les labels à partir de seuils fixes en pourcentage.
Args:
df: DataFrame OHLCV (colonnes en minuscules)
Returns:
pd.Series avec valeurs 1 (LONG), -1 (SHORT), 0 (NEUTRAL)
"""
df = df.copy()
df.columns = [c.lower() for c in df.columns]
labels = self._forward_simulate(df, self.tp_pct, self.sl_pct)
self._log_distribution(labels)
return labels
def generate_atr_based(
self,
df: pd.DataFrame,
atr_period: int = 14,
atr_tp_mult: float = 2.0,
atr_sl_mult: float = 1.0,
) -> pd.Series:
"""
Génère les labels avec des seuils TP/SL dynamiques basés sur l'ATR.
Plus adapté aux marchés forex où la volatilité varie beaucoup.
Args:
df: DataFrame OHLCV
atr_period: Période ATR
atr_tp_mult: Multiplicateur ATR pour TP (ex: 2.0 = 2×ATR)
atr_sl_mult: Multiplicateur ATR pour SL (ex: 1.0 = 1×ATR)
Returns:
pd.Series labels 1 / -1 / 0
"""
df = df.copy()
df.columns = [c.lower() for c in df.columns]
# Calcul ATR
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(atr_period).mean()
labels = np.zeros(len(df), dtype=int)
for i in range(len(df) - self.horizon):
close = df['close'].iloc[i]
atr_i = atr.iloc[i]
if np.isnan(atr_i) or atr_i <= 0:
continue
tp_long = close + atr_tp_mult * atr_i
sl_long = close - atr_sl_mult * atr_i
tp_short = close - atr_tp_mult * atr_i
sl_short = close + atr_sl_mult * atr_i
future = df.iloc[i + 1: i + 1 + self.horizon]
label = self._classify_bar(future, tp_long, sl_long, tp_short, sl_short)
labels[i] = label
result = pd.Series(labels, index=df.index)
self._log_distribution(result)
return result
# -------------------------------------------------------------------------
# Internals
# -------------------------------------------------------------------------
def _forward_simulate(
self, df: pd.DataFrame, tp_pct: float, sl_pct: float
) -> pd.Series:
"""Simule chaque barre : TP ou SL atteint en premier dans l'horizon."""
labels = np.zeros(len(df), dtype=int)
for i in range(len(df) - self.horizon):
close = df['close'].iloc[i]
tp_long = close * (1 + tp_pct)
sl_long = close * (1 - sl_pct)
tp_short = close * (1 - tp_pct)
sl_short = close * (1 + sl_pct)
future = df.iloc[i + 1: i + 1 + self.horizon]
labels[i] = self._classify_bar(future, tp_long, sl_long, tp_short, sl_short)
return pd.Series(labels, index=df.index)
@staticmethod
def _classify_bar(
future: pd.DataFrame,
tp_long: float,
sl_long: float,
tp_short: float,
sl_short: float,
) -> int:
"""
Parcourt les barres futures bar par bar et retourne le label.
Vérifie HIGH pour TP LONG et LOW pour SL LONG (et inversement pour SHORT).
"""
for _, bar in future.iterrows():
# LONG : TP atteint ?
if bar['high'] >= tp_long and bar['low'] > sl_long:
return 1
# LONG : SL atteint en premier ?
if bar['low'] <= sl_long:
# Vérifie si TP atteint le même bar (candle ambiguë)
if bar['high'] >= tp_long:
return 0 # Ambigu → neutre
return 0 # SL touché → pas de LONG
# SHORT : TP atteint ?
if bar['low'] <= tp_short and bar['high'] < sl_short:
return -1
# SHORT : SL atteint en premier ?
if bar['high'] >= sl_short:
if bar['low'] <= tp_short:
return 0
return 0
return 0 # Ni TP ni SL atteint dans l'horizon
@staticmethod
def _log_distribution(labels: pd.Series) -> None:
total = len(labels)
if total == 0:
return
n_long = (labels == 1).sum()
n_short = (labels == -1).sum()
n_neutral = (labels == 0).sum()
logger.info(
f"Distribution labels : LONG={n_long} ({n_long/total:.1%}), "
f"SHORT={n_short} ({n_short/total:.1%}), "
f"NEUTRAL={n_neutral} ({n_neutral/total:.1%})"
)