## Nouveaux modules
### src/ml/cnn_image/
- chart_renderer.py : CandlestickImageRenderer — OHLCV → images 128×128 RGB (mplfinance)
Fond #0d1117, bougies vertes/rouges, volume, sans axes, rendu en mémoire
Fallback 2D si mplfinance absent
- cnn_image_model.py : CandlestickCNN — Conv2D 4-blocs (3→32→64→128→256) + AvgPool + Dense(3)
- cnn_image_strategy_model.py : CNNImageStrategyModel — même interface que MLStrategyModel
### src/strategies/cnn_image_driven/
- cnn_image_strategy.py : CNNImageDrivenStrategy(BaseStrategy), SL/TP ATR, seq_len=64
## Modifications
- ensemble_model.py : attach_cnn_image(), poids XGB=0.30/CNN1D=0.30/CNNImage=0.40
- trading.py : POST /train-cnn-image, GET /train-cnn-image/{id}, GET /cnn-image-models
- docker/requirements/api.txt : mplfinance>=0.12.10b0, Pillow>=10.0.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""
|
||
CandlestickImageRenderer — Convertit des données OHLCV en images de graphiques.
|
||
|
||
Ce module transforme des séquences de bougies OHLCV en images 128×128 RGB
|
||
qui peuvent être passées à un CNN Conv2D pour l'analyse visuelle des patterns.
|
||
|
||
Rendu :
|
||
- Fond noir (#0d1117), style TradingView
|
||
- Bougies vertes (#26a69a) pour la hausse, rouges (#ef5350) pour la baisse
|
||
- Volume en bas de l'image (via mplfinance)
|
||
- Pas d'axes, pas de labels, pas de titre
|
||
- Taille fixe : 128×128 pixels, 3 canaux RGB
|
||
|
||
Si mplfinance ou PIL ne sont pas disponibles, un rendu de fallback basique
|
||
encode les données OHLCV numériquement sous forme d'image 2D.
|
||
"""
|
||
|
||
import io
|
||
import logging
|
||
from typing import Optional
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# --- Détection optionnelle de mplfinance et PIL ---
|
||
try:
|
||
import mplfinance as mpf
|
||
import matplotlib
|
||
matplotlib.use('Agg') # Backend non-interactif (pas d'écran requis)
|
||
import matplotlib.pyplot as plt
|
||
MPLFINANCE_AVAILABLE = True
|
||
logger.debug("mplfinance disponible — rendu haute qualité activé")
|
||
except ImportError:
|
||
MPLFINANCE_AVAILABLE = False
|
||
logger.warning("mplfinance non disponible — utilisation du rendu fallback")
|
||
|
||
try:
|
||
from PIL import Image
|
||
PIL_AVAILABLE = True
|
||
except ImportError:
|
||
PIL_AVAILABLE = False
|
||
logger.warning("Pillow (PIL) non disponible — rendu fallback uniquement")
|
||
|
||
|
||
# Taille cible des images en pixels
|
||
IMAGE_SIZE = 128
|
||
|
||
|
||
class CandlestickImageRenderer:
|
||
"""
|
||
Convertit des données OHLCV en images de graphiques en chandeliers.
|
||
|
||
Chaque image est un instantané visuel de `seq_len` bougies consécutives,
|
||
rendu avec mplfinance (style fond noir, bougies colorées, volume en bas).
|
||
Le résultat est normalisé en float32 dans [0, 1].
|
||
|
||
Args:
|
||
image_size: Taille carrée de l'image en pixels (défaut 128)
|
||
"""
|
||
|
||
def __init__(self, image_size: int = IMAGE_SIZE):
|
||
self.image_size = image_size
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Interface publique
|
||
# -------------------------------------------------------------------------
|
||
|
||
def encode(self, df: pd.DataFrame, seq_len: int = 64) -> np.ndarray:
|
||
"""
|
||
Encode toutes les fenêtres glissantes du DataFrame en images.
|
||
|
||
Produit N = len(df) - seq_len fenêtres, chacune rendue en image
|
||
128×128 RGB normalisée [0, 1].
|
||
|
||
Args:
|
||
df: DataFrame OHLCV avec colonnes open/high/low/close/volume
|
||
seq_len: Nombre de bougies par fenêtre (défaut 64)
|
||
|
||
Returns:
|
||
np.ndarray de forme (N, 3, 128, 128), dtype float32, valeurs [0, 1]
|
||
Retourne un tableau vide (0, 3, 128, 128) si df est trop court.
|
||
"""
|
||
df = self._prepare_df(df)
|
||
n_windows = len(df) - seq_len
|
||
|
||
if n_windows <= 0:
|
||
logger.warning(
|
||
f"DataFrame trop court ({len(df)} barres) pour seq_len={seq_len}"
|
||
)
|
||
return np.zeros((0, 3, self.image_size, self.image_size), dtype=np.float32)
|
||
|
||
images = np.zeros(
|
||
(n_windows, 3, self.image_size, self.image_size), dtype=np.float32
|
||
)
|
||
|
||
for i in range(n_windows):
|
||
window = df.iloc[i: i + seq_len]
|
||
try:
|
||
img = self._render_single(window)
|
||
images[i] = img
|
||
except Exception as e:
|
||
# Fenêtre problématique → on laisse des zéros pour cette position
|
||
logger.debug(f"Erreur rendu fenêtre {i} : {e}")
|
||
|
||
return images
|
||
|
||
def encode_last(self, df: pd.DataFrame, seq_len: int = 64) -> np.ndarray:
|
||
"""
|
||
Encode uniquement la dernière fenêtre du DataFrame.
|
||
|
||
Utilisé pour la prédiction en temps réel : retourne (1, 3, 128, 128).
|
||
|
||
Args:
|
||
df: DataFrame OHLCV
|
||
seq_len: Nombre de bougies pour la dernière fenêtre (défaut 64)
|
||
|
||
Returns:
|
||
np.ndarray de forme (1, 3, 128, 128), dtype float32, valeurs [0, 1]
|
||
|
||
Raises:
|
||
ValueError si df est trop court pour seq_len bougies
|
||
"""
|
||
df = self._prepare_df(df)
|
||
|
||
if len(df) < seq_len:
|
||
raise ValueError(
|
||
f"DataFrame insuffisant : {len(df)} barres < seq_len={seq_len}"
|
||
)
|
||
|
||
window = df.iloc[-seq_len:]
|
||
img = self._render_single(window)
|
||
# Ajouter dimension batch
|
||
return img[np.newaxis, ...] # (1, 3, H, W)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Rendu d'une fenêtre
|
||
# -------------------------------------------------------------------------
|
||
|
||
def _render_single(self, df_window: pd.DataFrame) -> np.ndarray:
|
||
"""
|
||
Rend une fenêtre OHLCV en image numpy (3, 128, 128).
|
||
|
||
Utilise mplfinance si disponible, sinon fallback encodage numérique.
|
||
|
||
Args:
|
||
df_window: DataFrame OHLCV pour une fenêtre (seq_len barres)
|
||
|
||
Returns:
|
||
np.ndarray de forme (3, 128, 128), float32, valeurs [0, 1]
|
||
"""
|
||
if MPLFINANCE_AVAILABLE and PIL_AVAILABLE:
|
||
return self._render_with_mplfinance(df_window)
|
||
else:
|
||
return self._render_fallback(df_window)
|
||
|
||
def _render_with_mplfinance(self, df_window: pd.DataFrame) -> np.ndarray:
|
||
"""
|
||
Rendu haute qualité via mplfinance.
|
||
|
||
Style : fond noir (#0d1117), bougies vertes/rouges, volume en bas,
|
||
aucun axe ni label ni titre. Image 128×128 RGB.
|
||
"""
|
||
# Style personnalisé : fond noir, bougies colorées TradingView
|
||
style = mpf.make_mpf_style(
|
||
base_mpf_style='nightclouds',
|
||
marketcolors=mpf.make_marketcolors(
|
||
up='#26a69a', # vert TradingView (hausse)
|
||
down='#ef5350', # rouge TradingView (baisse)
|
||
wick={'up': '#26a69a', 'down': '#ef5350'},
|
||
edge={'up': '#26a69a', 'down': '#ef5350'},
|
||
volume={'up': '#26a69a', 'down': '#ef5350'},
|
||
),
|
||
facecolor='#0d1117', # Fond noir GitHub-style
|
||
figcolor='#0d1117',
|
||
gridcolor='#0d1117',
|
||
)
|
||
|
||
# Rendu en mémoire via BytesIO
|
||
buf = io.BytesIO()
|
||
|
||
fig, axes = mpf.plot(
|
||
df_window,
|
||
type='candle',
|
||
style=style,
|
||
volume=True,
|
||
axisoff=True, # Pas d'axes
|
||
tight_layout=True,
|
||
returnfig=True,
|
||
figsize=(1.28, 1.28), # 128 DPI × 1.28 inch = 128 pixels
|
||
)
|
||
|
||
# Suppression de tous les éléments décoratifs
|
||
for ax in axes:
|
||
ax.set_axis_off()
|
||
ax.set_facecolor('#0d1117')
|
||
for spine in ax.spines.values():
|
||
spine.set_visible(False)
|
||
|
||
fig.savefig(
|
||
buf,
|
||
format='png',
|
||
dpi=100,
|
||
bbox_inches='tight',
|
||
pad_inches=0,
|
||
facecolor='#0d1117',
|
||
)
|
||
plt.close(fig)
|
||
|
||
buf.seek(0)
|
||
pil_img = Image.open(buf).convert('RGB')
|
||
pil_img = pil_img.resize(
|
||
(self.image_size, self.image_size), Image.LANCZOS
|
||
)
|
||
|
||
# Conversion en array numpy (H, W, 3) → (3, H, W), float32, [0,1]
|
||
arr = np.array(pil_img, dtype=np.float32) / 255.0
|
||
arr = arr.transpose(2, 0, 1) # HWC → CHW
|
||
|
||
return arr
|
||
|
||
def _render_fallback(self, df_window: pd.DataFrame) -> np.ndarray:
|
||
"""
|
||
Rendu de secours sans mplfinance : encode les données OHLCV
|
||
directement en image 2D normalisée.
|
||
|
||
Chaque colonne de l'image correspond à une bougie :
|
||
- Canal 0 (R) : position relative du close dans le range [low, high]
|
||
- Canal 1 (G) : amplitude de la bougie (high - low normalisé)
|
||
- Canal 2 (B) : volume normalisé
|
||
|
||
L'image est ensuite redimensionnée à image_size × image_size.
|
||
"""
|
||
cols = df_window[['open', 'high', 'low', 'close', 'volume']].copy()
|
||
n = len(cols)
|
||
|
||
if n == 0:
|
||
return np.zeros((3, self.image_size, self.image_size), dtype=np.float32)
|
||
|
||
# Normalisation par colonne
|
||
highs = cols['high'].values.astype(np.float32)
|
||
lows = cols['low'].values.astype(np.float32)
|
||
closes = cols['close'].values.astype(np.float32)
|
||
opens = cols['open'].values.astype(np.float32)
|
||
vols = cols['volume'].values.astype(np.float32)
|
||
|
||
price_range = highs - lows
|
||
price_range = np.where(price_range == 0, 1e-8, price_range)
|
||
|
||
# Canal R : position du close dans le range de la bougie [0, 1]
|
||
close_pos = (closes - lows) / price_range
|
||
|
||
# Canal G : corps de la bougie (|close - open| / range)
|
||
body = np.abs(closes - opens) / price_range
|
||
|
||
# Canal B : volume normalisé [0, 1]
|
||
vol_max = vols.max()
|
||
vol_norm = vols / (vol_max if vol_max > 0 else 1.0)
|
||
|
||
# Construction image : 3 × 1 × n → 3 × image_size × image_size
|
||
# On crée une image de height=image_size, width=n puis on redimensionne
|
||
img = np.zeros((3, 1, n), dtype=np.float32)
|
||
img[0, 0, :] = close_pos
|
||
img[1, 0, :] = body
|
||
img[2, 0, :] = vol_norm
|
||
|
||
# Redimensionnement vers (3, image_size, image_size) via répétition
|
||
# On étire chaque canal sur les deux dimensions
|
||
img_resized = np.zeros(
|
||
(3, self.image_size, self.image_size), dtype=np.float32
|
||
)
|
||
for c in range(3):
|
||
# Répétition en hauteur (axe 0 du canal) et interpolation en largeur
|
||
channel = img[c, 0, :] # (n,)
|
||
# Interpolation 1D vers image_size
|
||
x_orig = np.linspace(0, 1, n)
|
||
x_new = np.linspace(0, 1, self.image_size)
|
||
channel_resized = np.interp(x_new, x_orig, channel).astype(np.float32)
|
||
# Étirer sur toute la hauteur
|
||
img_resized[c] = np.tile(channel_resized, (self.image_size, 1))
|
||
|
||
return img_resized
|
||
|
||
# -------------------------------------------------------------------------
|
||
# Utilitaires
|
||
# -------------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _prepare_df(df: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
Normalise le DataFrame : colonnes en minuscules, index DatetimeIndex.
|
||
|
||
Args:
|
||
df: DataFrame OHLCV brut
|
||
|
||
Returns:
|
||
DataFrame nettoyé avec colonnes open/high/low/close/volume
|
||
"""
|
||
df = df.copy()
|
||
df.columns = [c.lower() for c in df.columns]
|
||
|
||
# S'assurer que l'index est un DatetimeIndex (requis par mplfinance)
|
||
if not isinstance(df.index, pd.DatetimeIndex):
|
||
try:
|
||
df.index = pd.to_datetime(df.index)
|
||
except Exception:
|
||
# Créer un index artificiel si la conversion échoue
|
||
df.index = pd.date_range(
|
||
start='2020-01-01', periods=len(df), freq='1h'
|
||
)
|
||
|
||
# Conserver uniquement les colonnes OHLCV
|
||
required = ['open', 'high', 'low', 'close', 'volume']
|
||
for col in required:
|
||
if col not in df.columns:
|
||
df[col] = 0.0
|
||
|
||
df = df[required].dropna(subset=['open', 'high', 'low', 'close'])
|
||
df = df.ffill().bfill()
|
||
|
||
return df
|