Files
trader-ml/src/ml/cnn_image/chart_renderer.py
Tika 80e1308a1e feat: Phase 4c-bis — CNN image-based (analyse visuelle graphiques chandeliers)
## 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>
2026-03-10 20:22:41 +00:00

323 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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