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>
181 lines
6.3 KiB
Python
181 lines
6.3 KiB
Python
"""
|
||
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%})"
|
||
)
|