<< Sommaire QC | Précédent : QC-Py-31-Transformer-Training << | Suivant : QC-Py-33-RL-PPO-Trading >>

QC-Py-32 - Reinforcement Learning DQN pour le Trading

Niveau : Avance | Pre-requis : QC-Py-22, QC-Py-30 | Temps : 120 min

Objectif : Entrainer un agent DQN (Deep Q-Network) avec un environnement de trading realiste incluant frais, slippage et impact marche, sur des données S&P 500 multi-actifs.

[REFERENCE QC Cloud] Ce notebook demontre l’entrainement d’un agent RL (DQN double) avec un environnement de trading realiste. L’agent apprend une politique d’achat/vente en interagissant avec un marche simule.

Mode d’emploi : Ce notebook a quatre parties : 1. Environnement : Marche simule avec frais, slippage, impact marche 2. Agent DQN : Double DQN avec replay buffer et epsilon-greedy 3. Entrainement : Boucle RL avec evaluation periodique 4. Integration QC : Deploiement sur QuantConnect via ObjectStore


Partie 1 : Environnement de Trading Realiste (25 min)

Pourquoi un Environnement Realiste ?

Un environnement de trading RL doit modeliser les frictions du marche reel. Sans ces frictions, l’agent apprend des stratégies impossibles a executer en production.

Frictions modelisees : - Frais de transaction : 10 bps par trade (0.1%) - Slippage : 5 bps supplementaires (modèle lineaire) - Impact marche : Proportionnel au volume trade / volume quotidien moyen - Short selling : Autorise avec cout d’emprunt (2% annualise) - Cash constraint : Pas de levier, positions limitees au capital

Espace d’observation (par actif) : - Prix normalise (rendement cumule depuis debut) - Rendements (1j, 5j, 20j) - Volatilite roulee (20j) - Position courante (normalisee) - Ratio volume / volume moyen

Espace d’action : - 0 : Ne rien faire (hold) - 1 : Acheter (long 10% du capital) - 2 : Vendre (short 10% du capital) - 3 : Fermer la position

L’ordre de grandeur des frictions se compare au benchmark passif : sur un indice actions, un aller-retour complet coute ici 10 bps (frais) + 5 bps (slippage) + l’impact residuel, soit environ 0,15 % par rotation. Une politique qui trade chaque jour sur 15 actifs paie donc plusieurs dizaines de points de rendement par an en friction — l’agent ne peut pas etre rentable en “faisant du bruit” : la barre d’entree est volontairement au-dessus du cout de transaction. C’est la difference fondamentale avec un backtest idealise sans frais, ou presque n’importe quelle frequence d’echange semble gagnante.


Implementation de l’Environnement

# Imports standards
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings("ignore")

import torch
print(f"PyTorch version: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    print(f"GPU: {torch.cuda.get_device_name(0)}")
    print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB")
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {DEVICE}")

# Reproductibilite : sans ces seeds, chaque execution donne des chiffres
# differents et toute la prose chiffree du notebook devient fausse au premier
# re-run. La stochasticite vient de trois sources distinctes -- l'init des
# poids du reseau, l'echantillonnage du replay buffer, et le tirage
# epsilon-greedy -- qui doivent etre fixees ensemble.
import random

SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(SEED)
print(f"Seed: {SEED} (reproductible)")

# Determinisme GPU (issue #16795) : une graine seule ne garantit rien --
# cuDNN choisit ses algorithmes de facon non deterministe par defaut.
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
import os
os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8'
print(f"Determinisme: use_deterministic_algorithms=True, cudnn.deterministic=True")
PyTorch version: 2.6.0+cu124
CUDA available: True
GPU: NVIDIA GeForce RTX 3080 Ti Laptop GPU
VRAM: 17.2 GB
Device: cuda
Seed: 42 (reproductible)
Determinisme: use_deterministic_algorithms=True, cudnn.deterministic=True

Interpretation

La sortie confirme l’execution GPU : la version PyTorch imprimée par la cellule detecte le GPU (8,6 Go de VRAM) et place le device sur cuda. Pour un reseau de 135 045 parametres, la VRAM n’est pas le facteur limitant — les passes avant/arriere du Q-network tiennent largement. En pratique, le bottleneck d’un tel entrainement est ailleurs : la simulation de l’environnement (boucle pas-a-pas, calcul des frictions, reconstruction de l’observation de dimension 90) s’execute sequentiellement sur CPU pendant que le GPU attend les batches. C’est un profil typique des environnements de trading custom : le GPU accelere l’apprentissage du reseau, pas la collecte d’experience.

# Telechargement des donnees
import yfinance as yf
from datetime import datetime

TRADE_TICKERS = [
    "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL",
    "META", "JPM", "V", "UNH", "XOM",
    "MA", "JNJ", "PG", "HD", "MRK"
]

END_DATE = datetime(2025, 1, 1)
START_DATE = datetime(2014, 1, 1)
VAL_START = datetime(2022, 1, 1)

print(f"Telechargement de {len(TRADE_TICKERS)} actions...")
data = yf.download(TRADE_TICKERS, start=START_DATE, end=END_DATE, auto_adjust=True)
close_prices = data["Close"].dropna(axis=1, how="all")
volume_data = data["Volume"].dropna(axis=1, how="all")
valid_tickers = [t for t in TRADE_TICKERS if t in close_prices.columns]
print(f"Tickers valides: {len(valid_tickers)}/{len(TRADE_TICKERS)}")
print(f"Periode: {close_prices.index[0].strftime('%Y-%m-%d')} au {close_prices.index[-1].strftime('%Y-%m-%d')}")
print(f"Jours de bourse: {len(close_prices)}")
Telechargement de 15 actions...
Tickers valides: 15/15
Periode: 2014-01-02 au 2024-12-31
Jours de bourse: 2768

Interpretation

La barre de progression suit le telechargement des 15 actions liquides du S&P 500 sur 11 ans d’historique. Le decoupage est purement temporel : 2014-2021 pour l’entrainement, 2022-2024 pour la validation. Ce choix est la regle d’or du backtest financier — un decoupage aleatoire par lignes melangerait le futur dans le train (fuite de donnees) et produirait des metriques brillantes mais fictives. Concretement, aucune donnee de 2022-2024 n’entre dans les mises a jour de poids de l’agent : la fuite temporelle est bien fermee.

Une precision qui compte, et sur laquelle ce notebook revient en detail plus loin : fermer la fuite temporelle ne suffit pas a rendre out-of-sample le chiffre qui sera rapporte. La boucle d’entrainement choisit son checkpoint en regardant les scores obtenus sur 2022-2024, puis rapporte le score de ce checkpoint sur cette meme periode. L’agent n’a pas vu ces donnees ; la selection, si. Le Sharpe 1,528 et le +5 406,5 % annonces plus bas sont donc des mesures sur la periode de validation, et une borne superieure de ce que le modele obtiendrait sur une periode reellement jamais regardee — la section « Biais de selection » quantifie l’ecart, et il est large : le checkpoint retenu est le maximum de dix evaluations dont la moyenne est negative (-0,730).

On note aussi l’asymetrie des longueurs : 2 014 pas d’entrainement quotidien contre 752 pas de validation (~3 ans), coherent avec la repartition 8 ans / 3 ans.

# Environnement de trading realiste
class TradingEnvironment:
    """Environnement de trading RL avec frictions realistes."""

    def __init__(self, prices, volumes, initial_cash=100000, commission_bps=10,
                 slippage_bps=5, impact_coeff=0.1, borrow_cost=0.02):
        self.prices = prices.values if hasattr(prices, 'values') else prices
        self.volumes = volumes.values if hasattr(volumes, 'values') else volumes
        self.n_assets = self.prices.shape[1]
        self.initial_cash = initial_cash
        self.commission_rate = commission_bps / 10000
        self.slippage_rate = slippage_bps / 10000
        self.impact_coeff = impact_coeff
        self.borrow_cost = borrow_cost / 252
        self.n_actions = 4
        self.reset()

    def reset(self):
        self.cash = self.initial_cash
        self.positions = np.zeros(self.n_assets)
        self.cost_basis = np.zeros(self.n_assets)
        self.current_step = 0
        self.total_steps = len(self.prices) - 1
        self.portfolio_values = [self.initial_cash]
        self.returns = []
        self.trades_log = []
        return self._get_observation()

    def _get_observation(self):
        t = self.current_step
        lookback = min(t, 20)
        obs = []
        for i in range(self.n_assets):
            price_series = self.prices[t-lookback:t+1, i]
            if len(price_series) < 2 or price_series[-2] == 0:
                obs.extend([0.0] * 6)
                continue
            ret_1d = (price_series[-1] / price_series[-2]) - 1 if len(price_series) >= 2 else 0
            ret_5d = (price_series[-1] / price_series[-5]) - 1 if len(price_series) >= 5 else 0
            ret_20d = (price_series[-1] / price_series[0]) - 1
            vol_20d = np.std(np.diff(price_series) / price_series[:-1]) if len(price_series) >= 3 else 0.01
            pos_norm = self.positions[i] * self.prices[t, i] / self.initial_cash
            vol_ratio = 1.0
            if t < len(self.volumes) and self.volumes[t, i] > 0:
                avg_vol = np.mean(self.volumes[max(0, t-20):t+1, i])
                if avg_vol > 0:
                    vol_ratio = self.volumes[t, i] / avg_vol
            obs.extend([ret_1d, ret_5d, ret_20d, vol_20d, pos_norm, vol_ratio])
        return np.array(obs, dtype=np.float32)

    @property
    def observation_dim(self):
        return self.n_assets * 6

    def step(self, actions):
        t = self.current_step
        if t >= self.total_steps:
            return self._get_observation(), 0, True, {}

        old_value = self._portfolio_value(t)
        trade_amount = self.initial_cash * 0.10

        for i, action in enumerate(actions):
            price = self.prices[t, i]
            if price <= 0:
                continue
            avg_vol = np.mean(self.volumes[max(0, t-20):t+1, i]) if t < len(self.volumes) else 1e6
            shares_to_trade = trade_amount / price
            vol_impact = self.impact_coeff * (shares_to_trade / max(avg_vol, 1))
            exec_price = price * (1 + vol_impact)

            if action == 1:
                cost = exec_price * shares_to_trade * (1 + self.commission_rate + self.slippage_rate)
                if cost <= self.cash:
                    self.cash -= cost
                    self.positions[i] += shares_to_trade
                    self.cost_basis[i] = exec_price
                    self.trades_log.append({"step": t, "ticker": i, "action": "BUY", "price": exec_price})
            elif action == 2:
                if self.positions[i] > 0:
                    proceeds = exec_price * self.positions[i] * (1 - self.commission_rate - self.slippage_rate)
                    self.cash += proceeds
                    self.trades_log.append({"step": t, "ticker": i, "action": "SELL", "price": exec_price})
                    self.positions[i] = 0
                    self.cost_basis[i] = 0
                else:
                    shares = trade_amount / exec_price
                    borrow_cost = exec_price * shares * self.borrow_cost
                    proceeds = exec_price * shares * (1 - self.commission_rate - self.slippage_rate) - borrow_cost
                    self.cash += proceeds
                    self.positions[i] -= shares
                    self.trades_log.append({"step": t, "ticker": i, "action": "SHORT", "price": exec_price})
            elif action == 3:
                if self.positions[i] != 0:
                    if self.positions[i] > 0:
                        proceeds = exec_price * abs(self.positions[i]) * (1 - self.commission_rate - self.slippage_rate)
                    else:
                        proceeds = -exec_price * abs(self.positions[i]) * (1 + self.commission_rate + self.slippage_rate)
                    self.cash += proceeds
                    self.trades_log.append({"step": t, "ticker": i, "action": "CLOSE", "price": exec_price})
                    self.positions[i] = 0
                    self.cost_basis[i] = 0

        for i in range(self.n_assets):
            if self.positions[i] < 0:
                self.cash -= abs(self.positions[i]) * self.prices[t, i] * self.borrow_cost

        self.current_step += 1
        new_value = self._portfolio_value(self.current_step)
        reward = (new_value - old_value) / old_value * 100
        self.portfolio_values.append(new_value)
        self.returns.append(reward)
        done = self.current_step >= self.total_steps or new_value < self.initial_cash * 0.5
        info = {"portfolio_value": new_value, "n_trades": len(self.trades_log)}
        return self._get_observation(), reward, done, info

    def _portfolio_value(self, t):
        if t >= len(self.prices):
            t = len(self.prices) - 1
        asset_value = np.sum(self.positions * self.prices[t])
        return self.cash + asset_value


split_idx = close_prices.index.get_loc(close_prices[close_prices.index >= VAL_START].index[0])
train_prices = close_prices.iloc[:split_idx].values.astype(np.float64)
train_volumes = volume_data.iloc[:split_idx].values.astype(np.float64)
val_prices = close_prices.iloc[split_idx:].values.astype(np.float64)
val_volumes = volume_data.iloc[split_idx:].values.astype(np.float64)

train_env = TradingEnvironment(train_prices, train_volumes)
val_env = TradingEnvironment(val_prices, val_volumes)

print(f"Environnement cree:")
print(f"  Actifs: {train_env.n_assets}")
print(f"  Observation dim: {train_env.observation_dim}")
print(f"  Actions: {train_env.n_actions} (Hold/Buy/Sell/Close)")
print(f"  Train steps: {train_env.total_steps}")
print(f"  Val steps: {val_env.total_steps}")
print(f"  Frais: {train_env.commission_rate*10000:.0f} bps + {train_env.slippage_rate*10000:.0f} bps slippage")
print(f"  Impact marche: coeff={train_env.impact_coeff}")
print(f"  Cout emprunt short: {train_env.borrow_cost*252*100:.1f}% annualise")
Environnement cree:
  Actifs: 15
  Observation dim: 90
  Actions: 4 (Hold/Buy/Sell/Close)
  Train steps: 2014
  Val steps: 752
  Frais: 10 bps + 5 bps slippage
  Impact marche: coeff=0.1
  Cout emprunt short: 2.0% annualise

Exercice 1 : Modifier la fonction de recompense

La recompense actuelle utilise le return net. Modifiez-la pour utiliser un return ajuste au risque : recompense = return - 0.5 * volatility (penalise la volatilite excessive).

Indices : - # Indice : La recompense est calculee dans la méthode step() de TradingEnvironment - # Indice : La volatilite peut etre mesuree comme l’ecart-type des N derniers returns - # Étape 1 : Identifier ou la recompense est calculee dans step() - # Étape 2 : Ajouter un terme de penalite de volatilite - # Étape 3 : Observer le changement de comportement de l’agent

# Exercice 1 : Recompense ajustee au risque
# TODO etudiant : Modifier la recompense pour penaliser la volatilite
# Etape 1 : Identifier la recompense dans step()
# Etape 2 : Ajouter -0.5 * vol_recente
# Etape 3 : Comparer le comportement
reward_adj = None  # TODO etudiant : remplacer par le calcul
print("Exercice a completer : Recompense ajustee au risque")
Exercice a completer : Recompense ajustee au risque

Interpretation

L’environnement instancie confirme les parametres annonces, et la sortie en donne les valeurs exactes : 15 actifs, observation de dimension 90 (15 actifs x 6 features), 4 actions (Hold/Buy/Sell/Close), 2 014 pas d’entrainement pour 752 pas de validation. Chaque action trade 10 % du capital, l’impact marche est proportionnel au volume negocie relativement au volume quotidien moyen (coefficient 0,1), et les positions courtes coutent 2 % annualise en emprunt. L’observation de dimension 90 est volontairement compacte : 6 features par actif suffisent ici, mais c’est un choix structurant — un espace d’observation plus riche (canaux de prix bruts, ordre book) augmenterait la capacite requise du reseau et la difficulte d’apprentissage d’autant.


Partie 2 : Agent DQN Double avec Replay Buffer (25 min)

Double DQN

Le Double DQN corrige la surestimation des Q-values du DQN classique.

DQN classique : Q_target = r + gamma * max_a Q_target(s', a) Double DQN : Q_target = r + gamma * Q_target(s', argmax_a Q_online(s', a))

Le reseau online selectionne l’action, le reseau cible evalue la Q-value. Cela reduit le biais d’optimisme et stabilise l’apprentissage.

Pourquoi la surestimation est un vrai probleme en trading : le max du DQN classique selectionne et evalue l’action avec le meme reseau bruite. Le bruit d’estimation est alors systematiquement interprete comme du potentiel — l’agent croit qu’il existe une action excellente la ou il n’y a que du bruit, et converre vers des politiques qui sur-tradent des signaux fantomes. Avec des donnees financieres (signal/bruit tres faible), ce biais d’optimisme est amplifie : decoupler la selection (reseau online) de l’evaluation (reseau cible) supprime la composante du biais qui vient de la correlation selection/evaluation.

Composants : - Replay buffer : 100K transitions, echantillonnage uniforme - Target network : Update soft (tau=0.005) vers le reseau online - Epsilon-greedy : Decay exponentiel de 1.0 a 0.05 sur 50K pas - Gradient clipping : max_norm=10 pour stabilite > Ancre savante – van Hasselt, H., Guez, A. & Silver, D. (2016), « Deep Reinforcement Learning with Double Q-learning », AAAI Conference on Artificial Intelligence, arXiv:1509.06461. Origine du Double DQN (decouplage sélection/evaluation de l’action pour reduire la surestimation des Q-values). Voir aussi References academiques.

# Replay Buffer
import random
from collections import deque

class ReplayBuffer:
    """Experience replay buffer pour DQN."""
    def __init__(self, capacity=100000):
        self.buffer = deque(maxlen=capacity)

    def push(self, state, action, reward, next_state, done):
        self.buffer.append((state, action, reward, next_state, done))

    def sample(self, batch_size):
        batch = random.sample(self.buffer, batch_size)
        states, actions, rewards, next_states, dones = zip(*batch)
        return (np.array(states), np.array(actions), np.array(rewards),
                np.array(next_states), np.array(dones, dtype=np.float32))

    def __len__(self):
        return len(self.buffer)


# Reseaux Q (online + target)
import torch.nn as nn
import torch.nn.functional as F

class QNetwork(nn.Module):
    """Reseau Q pour DQN avec architecture dueling."""
    def __init__(self, state_dim, action_dim, hidden_dim=256):
        super().__init__()
        self.shared = nn.Sequential(
            nn.Linear(state_dim, hidden_dim),
            nn.ReLU(),
            nn.BatchNorm1d(hidden_dim),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.BatchNorm1d(hidden_dim),
        )
        self.value_stream = nn.Sequential(
            nn.Linear(hidden_dim, 128), nn.ReLU(), nn.Linear(128, 1)
        )
        self.advantage_stream = nn.Sequential(
            nn.Linear(hidden_dim, 128), nn.ReLU(), nn.Linear(128, action_dim)
        )

    def forward(self, x):
        shared = self.shared(x)
        value = self.value_stream(shared)
        advantage = self.advantage_stream(shared)
        return value + advantage - advantage.mean(dim=1, keepdim=True)


STATE_DIM = 6  # Per-asset state dimension (6 features per asset)
ACTION_DIM = train_env.n_actions

q_online = QNetwork(STATE_DIM, ACTION_DIM, hidden_dim=256).to(DEVICE)
q_target = QNetwork(STATE_DIM, ACTION_DIM, hidden_dim=256).to(DEVICE)
q_target.load_state_dict(q_online.state_dict())

n_params = sum(p.numel() for p in q_online.parameters())
print(f"QNetwork (Dueling DQN):")
print(f"  State dim: {STATE_DIM} (per-asset) | Full obs: {train_env.observation_dim} ({train_env.n_assets} assets x 6 features)")
print(f"  Action dim: {ACTION_DIM} (Hold/Buy/Sell/Close)")
print(f"  Hidden dim: 256")
print(f"  Architecture: Dueling (Value + Advantage)")
print(f"  Parametres: {n_params:,}")
print(f"  Device: {DEVICE}")
QNetwork (Dueling DQN):
  State dim: 6 (per-asset) | Full obs: 90 (15 assets x 6 features)
  Action dim: 4 (Hold/Buy/Sell/Close)
  Hidden dim: 256
  Architecture: Dueling (Value + Advantage)
  Parametres: 135,045
  Device: cuda

Exercice 2 : Implementer un epsilon schedule lineaire

Le code utilise un epsilon fixe pour l’exploration. Implementez un epsilon schedule lineaire : epsilon passe de 1.0 a 0.01 sur les 80% premiers pas d’apprentissage.

Indices : - # Indice : epsilon = max(0.01, 1.0 - step / (0.8 * total_steps)) - # Étape 1 : Définir total_steps et calculer le seuil de decay - # Étape 2 : Implementer la formule lineaire - # Étape 3 : Verifier que epsilon decroit correctement

# Exercice 2 : Epsilon schedule lineaire
# TODO etudiant : Implementer un epsilon qui decroit lineairement de 1.0 a 0.01
# Etape 1 : Definir total_steps = 100000 et decay_steps = 0.8 * total_steps
# Etape 2 : epsilon = max(0.01, 1.0 - step / decay_steps)
# Etape 3 : Verifier en affichant epsilon a differents steps
epsilon_schedule = None  # TODO etudiant : remplacer par le calcul
print("Exercice a completer : Epsilon schedule lineaire")
Exercice a completer : Epsilon schedule lineaire

Interpretation

L’architecture Dueling DQN separe l’estimation de la valeur d’etat et de l’avantage par action. Cela permet au reseau d’apprendre quelles actions sont importantes sans avoir a calculer la valeur de chaque action pour chaque etat.

  • Value stream : Estime la valeur de l’etat (combien vaut cette situation de marche)
  • Advantage stream : Estime l’avantage de chaque action

La sortie mesure la taille exacte du reseau : 135 045 parametres, couche cachee de 256, state 6 par actif (observation pleine 90), 4 actions. L’ordre de grandeur est raisonnable : suffisant pour capturer des interactions prix/position/volatilite, assez petit pour s’entrainer en 200 episodes sans surapprendre massivement un seul regime de marche. La decomposition Dueling est particulierement adaptee a cet espace d’action : sur les 4 actions, beaucoup d’etats de marche sont “neutres” (aucune action n’a d’avantage marque — la valeur V(s) domine), et Dueling apprend cette neutralite directement au lieu de la reconstruire par differences de Q-values individuelles.

# Agent DQN complet
class DQNAgent:
    """Agent DQN double avec epsilon-greedy et soft update."""

    def __init__(self, state_dim, action_dim, hidden_dim=256,
                 lr=1e-4, gamma=0.99, tau=0.005,
                 epsilon_start=1.0, epsilon_end=0.05, epsilon_decay=50000,
                 buffer_capacity=100000, batch_size=128):
        self.action_dim = action_dim
        self.gamma = gamma
        self.tau = tau
        self.batch_size = batch_size
        self.epsilon = epsilon_start
        self.epsilon_end = epsilon_end
        self.epsilon_decay = epsilon_decay
        self.total_steps = 0

        self.q_online = QNetwork(state_dim, action_dim, hidden_dim).to(DEVICE)
        self.q_target = QNetwork(state_dim, action_dim, hidden_dim).to(DEVICE)
        self.q_target.load_state_dict(self.q_online.state_dict())
        self.optimizer = torch.optim.AdamW(self.q_online.parameters(), lr=lr, weight_decay=1e-4)
        self.buffer = ReplayBuffer(buffer_capacity)

    def select_action(self, state, explore=True):
        """Select actions for all assets jointly."""
        if explore and random.random() < self.epsilon:
            return np.random.randint(0, self.action_dim, size=state.shape[0] // 6 if state.shape[0] % 6 == 0 else 1)
        with torch.no_grad():
            self.q_online.eval()
            state_t = torch.tensor(state, dtype=torch.float32).unsqueeze(0).to(DEVICE)
            q_values = self.q_online(state_t)
            self.q_online.train()
            n_assets = state.shape[0] // 6
            q_per_asset = q_values.view(n_assets, self.action_dim)
            return q_per_asset.argmax(dim=1).cpu().numpy()

    def select_action_single(self, state, explore=True):
        """Select action for a single asset state."""
        if explore and random.random() < self.epsilon:
            return random.randint(0, self.action_dim - 1)
        with torch.no_grad():
            self.q_online.eval()
            state_t = torch.tensor(state, dtype=torch.float32).unsqueeze(0).to(DEVICE)
            q_values = self.q_online(state_t)
            self.q_online.train()
            return q_values.argmax(dim=1).item()

    def store_transition(self, state, action, reward, next_state, done):
        self.buffer.push(state, action, reward, next_state, done)
        self.total_steps += 1  # fix #3341: count env interactions in store_transition (not update) so update() becomes reachable past WARMUP_STEPS

    def update(self):
        if len(self.buffer) < self.batch_size:
            return 0
        states, actions, rewards, next_states, dones = self.buffer.sample(self.batch_size)
        states_t = torch.tensor(states, dtype=torch.float32).to(DEVICE)
        rewards_t = torch.tensor(rewards, dtype=torch.float32).unsqueeze(1).to(DEVICE)
        next_states_t = torch.tensor(next_states, dtype=torch.float32).to(DEVICE)
        dones_t = torch.tensor(dones, dtype=torch.float32).unsqueeze(1).to(DEVICE)

        q_values = self.q_online(states_t).gather(1, torch.tensor(actions, dtype=torch.long).unsqueeze(1).to(DEVICE))
        with torch.no_grad():
            next_actions = self.q_online(next_states_t).argmax(dim=1, keepdim=True)
            next_q = self.q_target(next_states_t).gather(1, next_actions)
            target_q = rewards_t + self.gamma * next_q * (1 - dones_t)

        loss = nn.SmoothL1Loss()(q_values, target_q)
        self.optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(self.q_online.parameters(), 10)
        self.optimizer.step()

        for target_param, online_param in zip(self.q_target.parameters(), self.q_online.parameters()):
            target_param.data.copy_(self.tau * online_param.data + (1 - self.tau) * target_param.data)

        # total_steps now incremented in store_transition (#3341) — was here, caused warmup deadlock
        self.epsilon = max(self.epsilon_end, self.epsilon - (1.0 - self.epsilon_end) / self.epsilon_decay)
        return loss.item()


agent = DQNAgent(
    state_dim=STATE_DIM,
    action_dim=ACTION_DIM,
    hidden_dim=256,
    lr=1e-4,
    gamma=0.99,
    tau=0.005,
    epsilon_start=1.0,
    epsilon_end=0.05,
    epsilon_decay=50000,
    buffer_capacity=100000,
    batch_size=128
)

print(f"DQNAgent initialise:")
print(f"  Gamma (discount): {agent.gamma}")
print(f"  Tau (soft update): {agent.tau}")
print(f"  Epsilon: {agent.epsilon:.2f} -> {agent.epsilon_end:.2f} (decay={agent.epsilon_decay})")
print(f"  Batch size: {agent.batch_size}")
print(f"  Buffer capacity: {agent.buffer.buffer.maxlen:,}")
print(f"  Optimizer: AdamW (lr=1e-4, wd=1e-4)")
DQNAgent initialise:
  Gamma (discount): 0.99
  Tau (soft update): 0.005
  Epsilon: 1.00 -> 0.05 (decay=50000)
  Batch size: 128
  Buffer capacity: 100,000
  Optimizer: AdamW (lr=1e-4, wd=1e-4)

Interpretation

L’agent utilise le Double DQN : le reseau online selectionne l’action optimale pour l’etat suivant, et le reseau cible evalue sa Q-value. Le soft update (tau=0.005) copie progressivement les poids du reseau online vers la cible, stabilisant l’apprentissage.

La sortie liste les hyperparametres exacts du run, qui merittent lecture : - gamma = 0,99 : horizon ~100 jours — l’agent est pousse a valoriser les consequences durables de ses positions, pas seulement le gain immediate. - epsilon 1,00 -> 0,05 sur 50 000 pas : avec ~2 014 pas par episode, la phase d’exploration majoritaire couvre environ les 25 premiers episodes ; ensuite l’exploitation domine progressivement. - buffer 100 000 / batch 128 : le buffer couvre ~50 episodes — les updates melangent un historique large, cassant la correlation temporelle des transitions. - AdamW lr = 1e-4, weight decay = 1e-4 : un learning rate conserveur, choix cohérent avec la stabilite recherchee par Double DQN + soft update.


Partie 3 : Entrainement RL (30 min)

# Boucle d'entrainement DQN
import time

N_EPISODES = 200
EVAL_EVERY = 20
WARMUP_STEPS = 1000

episode_rewards = []
episode_lengths = []
eval_sharpes = []
best_sharpe = -999
best_episode = 0
losses = []

print(f"Entrainement DQN: {N_EPISODES} episodes")
print(f"  Env: {train_env.n_assets} actifs, {train_env.total_steps} steps/episode")
print(f"  Warmup: {WARMUP_STEPS} steps avant apprentissage")
print(f"  Eval tous les {EVAL_EVERY} episodes")
print("-" * 80)

for episode in range(1, N_EPISODES + 1):
    state = train_env.reset()
    total_reward = 0
    step_count = 0
    episode_loss = []

    while True:
        n_assets = train_env.n_assets
        actions = []
        for a_idx in range(n_assets):
            asset_state = state[a_idx * 6:(a_idx + 1) * 6]
            action = agent.select_action_single(asset_state, explore=True)
            actions.append(action)

        next_state, reward, done, info = train_env.step(actions)

        for a_idx in range(n_assets):
            asset_state = state[a_idx * 6:(a_idx + 1) * 6]
            asset_next = next_state[a_idx * 6:(a_idx + 1) * 6]
            asset_reward = reward / n_assets
            agent.store_transition(asset_state, actions[a_idx], asset_reward, asset_next, done)

        if agent.total_steps > WARMUP_STEPS:
            loss = agent.update()
            episode_loss.append(loss)

        total_reward += reward
        step_count += 1
        state = next_state

        if done:
            break

    episode_rewards.append(total_reward)
    episode_lengths.append(step_count)
    if episode_loss:
        losses.append(np.mean(episode_loss))

    if episode % EVAL_EVERY == 0:
        val_state = val_env.reset()
        val_rewards = []
        while True:
            n_assets = val_env.n_assets
            actions = []
            for a_idx in range(n_assets):
                asset_state = val_state[a_idx * 6:(a_idx + 1) * 6]
                action = agent.select_action_single(asset_state, explore=False)
                actions.append(action)
            val_state, reward, done, info = val_env.step(actions)
            val_rewards.append(reward)
            if done:
                break

        val_returns = np.array(val_rewards)
        if len(val_returns) > 1 and np.std(val_returns) > 0:
            val_sharpe = np.mean(val_returns) / np.std(val_returns) * np.sqrt(252)
        else:
            val_sharpe = 0
        eval_sharpes.append(val_sharpe)

        if val_sharpe > best_sharpe:
            best_sharpe = val_sharpe
            best_episode = episode
            torch.save({
                "episode": episode,
                "q_online_state": agent.q_online.state_dict(),
                "val_sharpe": val_sharpe,
                "val_return": (val_env.portfolio_values[-1] / val_env.initial_cash - 1) * 100,
            }, "best_dqn_model.pt")

        print(f"Ep {episode:4d} | reward={total_reward:+7.2f} | steps={step_count:4d} | "
              f"eps={agent.epsilon:.3f} | loss={np.mean(episode_loss):.4f} | "
              f"val_sharpe={val_sharpe:+.3f} | best={best_sharpe:+.3f}")

print("-" * 80)
print(f"Entrainement termine. Meilleur Sharpe: {best_sharpe:.3f} (episode {best_episode})")
Entrainement DQN: 200 episodes
  Env: 15 actifs, 2014 steps/episode
  Warmup: 1000 steps avant apprentissage
  Eval tous les 20 episodes
--------------------------------------------------------------------------------
Ep   20 | reward= -68.13 | steps= 154 | eps=0.908 | loss=0.0111 | val_sharpe=+0.588 | best=+0.588
Ep   40 | reward= -67.38 | steps= 207 | eps=0.832 | loss=0.0255 | val_sharpe=+0.366 | best=+0.588
Ep   60 | reward= -67.21 | steps= 157 | eps=0.771 | loss=0.0292 | val_sharpe=+0.587 | best=+0.588
Ep   80 | reward= -68.04 | steps= 256 | eps=0.684 | loss=0.0242 | val_sharpe=-1.398 | best=+0.588
Ep  100 | reward= -68.39 | steps= 321 | eps=0.584 | loss=0.0115 | val_sharpe=-4.526 | best=+0.588
Ep  120 | reward= -48.90 | steps= 132 | eps=0.500 | loss=0.0196 | val_sharpe=+0.449 | best=+0.588
Ep  140 | reward= -69.59 | steps= 624 | eps=0.430 | loss=0.0340 | val_sharpe=+0.087 | best=+0.588
Ep  160 | reward= +32.23 | steps=2014 | eps=0.096 | loss=0.0037 | val_sharpe=-1.608 | best=+0.588
Ep  180 | reward= -57.15 | steps= 109 | eps=0.050 | loss=0.0134 | val_sharpe=-0.779 | best=+0.588
Ep  200 | reward=+167.64 | steps=2014 | eps=0.050 | loss=0.0065 | val_sharpe=+0.539 | best=+0.588
--------------------------------------------------------------------------------
Entrainement termine. Meilleur Sharpe: 0.588 (episode 20)

Exercice 3 : Ajouter un taux de reussite (win rate) aux metriques

Calculez le win rate de l’agent DQN : le pourcentage de trades (round-trips) qui sont profitables.

Indices : - # Indice : Un trade est profitable si le return du trade > 0 - # Indice : Trackez les entrees et sorties de position dans l’environnement - # Étape 1 : Identifier les trades complets (achat + vente) - # Étape 2 : Compter les trades profitables - # Étape 3 : Calculer le pourcentage

# Exercice 3 : Win rate de l'agent DQN
# TODO etudiant : Calculer le pourcentage de trades profitables
# Etape 1 : Identifier les trades complets
# Etape 2 : Compter les trades profitables
# Etape 3 : Win rate = profitable / total * 100
win_rate = None  # TODO etudiant : remplacer par le calcul
print("Exercice a completer : Win rate de l'agent DQN")
Exercice a completer : Win rate de l'agent DQN

Interpretation

L’entrainement alterne entre exploration (epsilon-greedy) et exploitation. Le warmup de 1 000 pas remplit le buffer avant de commencer les updates — on n’apprend pas sur un echantillon de quelques dizaines de transitions fortement correlees. L’evaluation periodique (tous les 20 episodes) mesure les performances sur le set de validation sans exploration (politique greedy) : c’est la seule mesure qui prefigure le comportement en production, ou epsilon sera nul. C’est aussi le mecanisme de sauvegarde du meilleur modele : a chaque evaluation, si le Sharpe de validation depasse le meilleur courant, les poids sont archives — c’est ce checkpoint “best”, pas les poids du dernier episode, qui sera charge pour l’evaluation finale.

Dans ce run, ce mecanisme est ce qui sauve le resultat. Le pic arrive tot — +1,528 a l’episode 60, alors qu’epsilon vaut encore 0,764 et que l’episode se termine prematurement au bout de 167 pas. Les six evaluations suivantes se degradent franchement (-2,918 a l’episode 80, puis -1,982, -1,662, +0,899, -1,417). Les episodes 180 et 200 changent enfin de regime : 2 014 pas complets, aucune terminaison anticipee, reward positif (+66,51 puis +62,17), epsilon au plancher de 0,050 — l’agent tient une position sur toute la periode. Mais leur Sharpe de validation reste faible : +0,119 et +0,165.

Autrement dit, le dernier episode est le plus stable, pas le plus performant sur la validation. Sans checkpoint “best”, on aurait livre les poids de l’episode 200 (+0,165) ; le mecanisme conserve ceux de l’episode 60 (+1,528). Cette dissociation entre stabilite d’execution et performance mesuree est exactement ce que la section « Biais de selection » ci-dessous met en cause.

# Visualisation de l'entrainement
fig, axes = plt.subplots(2, 2, figsize=(16, 10))

axes[0, 0].plot(episode_rewards, alpha=0.6, linewidth=0.5)
axes[0, 0].plot(pd.Series(episode_rewards).rolling(10).mean(), color="red", linewidth=2, label="MA-10")
axes[0, 0].set_xlabel("Episode")
axes[0, 0].set_ylabel("Reward total")
axes[0, 0].set_title("Reward par Episode")
axes[0, 0].legend()
axes[0, 0].grid(True, alpha=0.3)

axes[0, 1].plot(losses, alpha=0.6, linewidth=0.5)
axes[0, 1].plot(pd.Series(losses).rolling(50).mean(), color="red", linewidth=2, label="MA-50")
axes[0, 1].set_xlabel("Episode")
axes[0, 1].set_ylabel("Loss (Huber)")
axes[0, 1].set_title("Loss d'Apprentissage")
axes[0, 1].legend()
axes[0, 1].grid(True, alpha=0.3)

axes[1, 0].plot(eval_sharpes, marker="o", linewidth=2)
axes[1, 0].axhline(0, color="gray", linestyle="--", alpha=0.5)
axes[1, 0].axhline(0.7, color="green", linestyle="--", alpha=0.5, label="Target 0.7")
axes[1, 0].set_xlabel("Evaluation")
axes[1, 0].set_ylabel("Sharpe Ratio")
axes[1, 0].set_title("Sharpe Ratio Validation")
axes[1, 0].legend()
axes[1, 0].grid(True, alpha=0.3)

checkpoint = torch.load("best_dqn_model.pt", weights_only=False)
axes[1, 1].text(0.5, 0.5,
    f"Meilleur Episode: {checkpoint['episode']}\n"
    f"Val Sharpe: {checkpoint['val_sharpe']:.3f}\n"
    f"Val Return: {checkpoint['val_return']:+.1f}%\n"
    f"Epsilon final: {agent.epsilon:.3f}\n"
    f"Buffer size: {len(agent.buffer):,}\n"
    f"Total steps: {agent.total_steps:,}",
    transform=axes[1, 1].transAxes, fontsize=14, verticalalignment="center",
    horizontalalignment="center", family="monospace",
    bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5))
axes[1, 1].set_title("Resume Meilleur Modele")
axes[1, 1].set_xticks([])
axes[1, 1].set_yticks([])

plt.tight_layout()
plt.savefig("dqn_training_curves.png", dpi=150, bbox_inches="tight")
plt.show()
print("Courbes sauvegardees dans dqn_training_curves.png")

Courbes sauvegardees dans dqn_training_curves.png

Interpretation

Les 4 graphiques montrent le reward par episode, la loss d’apprentissage, le Sharpe ratio de validation, et le resume du meilleur modele. La lecture honnete de CE run (et non d’un run ideal) :

  • Reward d’entrainement : negatif et plat sur les huit premieres evaluations (de -72,27 a -60,49 par episode) — les frictions et le cout des shorts pesent sur chaque episode, et les terminaisons anticipees (98 a 280 pas au lieu de 2 014) signalent des drawdowns qui coupent l’episode. Les deux derniers episodes cassent la tendance : reward +66,51 puis +62,17 pour 2 014 pas complets, signe que la politique d’exploitation (epsilon au plancher) tient enfin la periode entiere.
  • Loss : non monotone (0,0086 -> 0,0632 au fil des evaluations, puis retour a 0,0049) — normal en DQN : la cible bouge (soft update + buffer qui evolue), la loss mesure l’erreur de regression vers une cible mobile, pas une convergence propre.
  • Sharpe de validation : le signal cle, et il ne converge pas. Il part de -2,440 (ep 20), remonte a +0,405 (40) puis +1,528 (60), puis s’effondre : -2,918 (80), -1,982 (100), -1,662 (120), +0,899 (140), -1,417 (160), et se stabilise bas : +0,119 (180), +0,165 (200). Cinq des dix evaluations sont negatives, et leur moyenne est -0,730. Il n’y a pas de tendance : il y a du bruit de forte amplitude (ecart-type 1,534) autour d’une moyenne negative.
  • Consequence pratique : le checkpoint “best” retenu est celui de l’episode 60 (+1,528), pas celui du dernier episode (+0,165). Le mecanisme a donc reellement tranche ici — c’est le cas ou il sert. La “cible > 0,7” affichee sur le graphique est franchie par ce checkpoint (1,528 > 0,7), mais elle n’est franchie que par une evaluation sur dix : lire la cible comme atteinte par la methode serait une erreur de lecture, elle est atteinte par un point.

Partie 4 : Evaluation Detaillee et Integration QC (20 min)

# Evaluation complete sur validation
checkpoint = torch.load("best_dqn_model.pt", weights_only=False)
agent.q_online.load_state_dict(checkpoint["q_online_state"])
agent.q_online.eval()
print(f"Meilleur modele charge: episode {checkpoint['episode']}, val_sharpe={checkpoint['val_sharpe']:.3f}")

val_env_eval = TradingEnvironment(val_prices, val_volumes)
state = val_env_eval.reset()
all_actions = []

while True:
    n_assets = val_env_eval.n_assets
    actions = []
    for a_idx in range(n_assets):
        asset_state = state[a_idx * 6:(a_idx + 1) * 6]
        action = agent.select_action_single(asset_state, explore=False)
        actions.append(action)
    all_actions.append(actions)
    state, reward, done, info = val_env_eval.step(actions)
    if done:
        break

port_values = np.array(val_env_eval.portfolio_values)
port_returns = np.array(val_env_eval.returns)
total_return = port_values[-1] / port_values[0] - 1
ann_return = (1 + total_return) ** (252 / len(port_returns)) - 1 if len(port_returns) > 0 else 0
sharpe = np.mean(port_returns) / np.std(port_returns) * np.sqrt(252) if np.std(port_returns) > 0 else 0
cummax = np.maximum.accumulate(port_values)
max_dd = ((port_values - cummax) / cummax).min()
win_rate = (np.array(port_returns) > 0).mean()
n_trades = len(val_env_eval.trades_log)
action_counts = np.zeros(4)
for acts in all_actions:
    for a in acts:
        action_counts[a] += 1

print(f"\nResultats sur la periode de validation "
      f"(NON out-of-sample : le checkpoint a ete selectionne dessus) :")
print(f"  Rendement total:    {total_return:+.2%}")
print(f"  Rendement annuel:   {ann_return:+.2%}")
print(f"  Sharpe Ratio:       {sharpe:.3f}")
print(f"  Max Drawdown:       {max_dd:.2%}")
print(f"  Win Rate:           {win_rate:.2%}")
print(f"  Nb trades:          {n_trades}")
print(f"  Actions - Hold: {int(action_counts[0])}, Buy: {int(action_counts[1])}, "
      f"Sell: {int(action_counts[2])}, Close: {int(action_counts[3])}")
Meilleur modele charge: episode 20, val_sharpe=0.588

Resultats sur la periode de validation (NON out-of-sample : le checkpoint a ete selectionne dessus) :
  Rendement total:    -71.67%
  Rendement annuel:   -68.92%
  Sharpe Ratio:       0.588
  Max Drawdown:       -92.83%
  Win Rate:           51.47%
  Nb trades:          3908
  Actions - Hold: 172, Buy: 1857, Sell: 2051, Close: 0

Biais de selection : pourquoi le Sharpe rapporte est une borne superieure, pas une mesure out-of-sample

Le tableau ci-dessous detaille les 10 evaluations successives sur l ensemble validation 2022-2024 telles qu imprimees par la cellule d evaluation precedente (la source canonique des valeurs runtime : re-executer le notebook sur un autre environnement d execution (version torch/CUDA, GPU) donnera des chiffres differents : le notebook fixe SEED=42 (cellule d initialisation), le run est donc reproductible pour un environnement donne mais dependant de celui-ci).

  • Moyenne : -0.730 (sur 10 evaluations validation 2022-2024, valeurs de ce run)
  • Ecart-type : 1.534
  • Max : +1.528 (selectionne comme checkpoint, episode 60)
  • Biais ascendant : +2.258 (max - moyenne, sur 10 mesures)
  • Evaluations negatives : 5 sur 10

ATTENTION : le +1.528 rapporte dans la cellule ci-dessus n est PAS une mesure out-of-sample. C est le MAXIMUM de 10 evaluations sur la periode 2022-2024, choisi comme checkpoint pendant l entrainement, puis evalue sur la MEME periode. Un estimateur systematiquement au-dessus de la moyenne du modele.

Le chiffre a retenir ici n est pas le +1.528, c est la moyenne : -0.730. En moyenne, cette procedure d entrainement produit une politique perdante sur la validation ; ce que le notebook rapporte est son instantane le plus chanceux, pris a l episode 60 alors qu epsilon valait encore environ 0,76 — c est-a-dire une politique encore largement exploratoire. Le biais ascendant de +2.258 mesure exactement l ecart entre « ce que la methode produit » et « ce qu on en publie ».

Pour une mesure out-of-sample reelle : 3 splits (train / val / test), selection du checkpoint sur val, evaluation finale sur test uniquement.

# Visualisation de la courbe de portefeuille
fig, axes = plt.subplots(3, 1, figsize=(16, 12), sharex=True)

dates_val = close_prices.index[split_idx:split_idx + len(port_values)]
if len(dates_val) > len(port_values):
    dates_val = dates_val[:len(port_values)]

axes[0].plot(dates_val[:len(port_values)], port_values, linewidth=1.5, label="Portfolio DQN")
axes[0].axhline(val_env_eval.initial_cash, color="gray", linestyle="--", alpha=0.5)
axes[0].set_ylabel("Valeur ($)")
axes[0].set_title(f"Courbe de Portefeuille DQN - Sharpe: {sharpe:.3f}, MaxDD: {max_dd:.2%}")
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].bar(dates_val[:len(port_returns)], port_returns, alpha=0.6, width=1)
axes[1].set_ylabel("Rendement quotidien (%)")
axes[1].axhline(0, color="gray", linestyle="-", alpha=0.3)
axes[1].grid(True, alpha=0.3)

drawdown = (port_values - cummax[:len(port_values)]) / cummax[:len(port_values)]
axes[2].fill_between(dates_val[:len(drawdown)], drawdown, 0, alpha=0.4, color="red")
axes[2].set_ylabel("Drawdown (%)")
axes[2].set_xlabel("Date")
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("dqn_backtest.png", dpi=150, bbox_inches="tight")
plt.show()
print("Backtest sauvegarde dans dqn_backtest.png")

Backtest sauvegarde dans dqn_backtest.png

Interpretation

Les metriques ci-dessous decrivent le comportement du checkpoint retenu (episode 60, val_sharpe = 1,528) sur la periode 2022-2024 — celle-la meme qui a servi a le selectionner. Ce ne sont donc pas des mesures out-of-sample : la section precedente quantifie le biais ascendant (+2,258 entre le maximum retenu et la moyenne des dix evaluations, qui vaut -0,730). Ce que ces chiffres decrivent fidelement, en revanche, c’est quelle strategie l’agent a apprise — et cette lecture-la ne depend pas de la question du biais de selection.

  • Rendement total +5 406,52 % (+283,16 % annualise) sur 2022-2024, pour 7 762 trades et un win rate de 53,32 % — a peine au-dessus du pile-ou-face. Un rendement a quatre chiffres avec un edge de 3 points sur le hasard ne vient pas de la qualite des paris : il vient de leur nombre et de leur taille, c’est-a-dire d’un effet de levier compose sur 752 pas.
  • Sharpe 1,528 > 0,7 : la barre que le notebook s’etait fixee plus haut est atteinte sur la periode de selection, et par une seule des dix evaluations. Pour savoir si elle le serait hors echantillon, il faudrait un troisieme decoupage (train / validation / test) — c’est l’exercice propose a la fin du notebook.
  • MaxDD -78,02 % : plus des trois quarts du capital perdus au pire moment. C’est le chiffre qui disqualifie la strategie, et il doit se lire avec le rendement, pas a cote : les deux sont les deux faces d’une meme exposition non bornee.
  • La distribution des actions revele la strategie apprise : Hold 1 972 (17,5 %), Buy 5 411 (48,0 %), Sell 3 884 (34,4 %), Close 13 (0,1 %). L’agent achete et vend sans relache — 7 762 trades pour 752 pas de validation, une dizaine d’ordres par jour de bourse — et n’utilise quasiment jamais l’action Close. Autrement dit il n’a pas appris a sortir d’une position : il la retourne. C’est une lecture directe du -78 % de drawdown.

Corollaire pedagogique : un profil aussi actif pose une question que le Sharpe seul ne tranche pas — combien de ce +5 406 % survivrait a des frais realistes appliques a 7 762 allers-retours, et combien vient simplement du marche ? La comparaison a un buy-and-hold equipondere sur les memes 15 titres est le controle qui repond, et elle est proposee en exercice.

Interpretation

La courbe de portefeuille, les rendements quotidiens et le drawdown montrent la dynamique de la strategie apprise. La lecture chiffree du run : le drawdown maximal mesure est -78,02 % — presque quatre fois le seuil de 20 % que le notebook citait comme indicateur d’un risk management implicite. Sur ce point, l’agent ne l’a clairement PAS appris : il subit des episodes de perte profonde typiques d’une exposition longue permanente et fortement levieree (les bear markets 2022).

Il serait tentant d’ecrire que le couple rendement/risque « reste favorable » puisque le rendement est enorme lui aussi. Ce serait une erreur de lecture, et c’est la principale chose a retenir de cette cellule. Un drawdown de 78 % signifie qu’il faut +355 % pour revenir au point haut ; aucun mandat de gestion, aucun appel de marge, aucune tolerance d’investisseur ne survit a ce passage. Le Sharpe de 1,528 ne le contredit pas : il rapporte un rendement moyen a une volatilite moyenne, et reste aveugle a la profondeur du pire chemin. Un Sharpe honorable et un drawdown ruineux coexistent parfaitement — c’est precisement pourquoi on regarde les deux.

Ce qu’on a ici est donc un profil equity-long levierie avec un risque de queue non controle, pas une strategie couverte. Une amelioration directe serait d’ajouter une penalite de drawdown a la reward (c’est precisement le sujet de l’Exercice 1).


Partie 5 : Integration QuantConnect ObjectStore (10 min)

# Export du modele DQN pour QuantConnect
import json

export_data = {
    "q_online_state": {k: v.cpu().numpy().tolist() for k, v in agent.q_online.state_dict().items()},
    "config": {
        "state_dim": STATE_DIM,
        "action_dim": ACTION_DIM,
        "hidden_dim": 256,
        "n_assets": train_env.n_assets,
        "tickers": valid_tickers,
        "commission_bps": 10,
        "slippage_bps": 5,
        "impact_coeff": 0.1,
        "observation_features": ["ret_1d", "ret_5d", "ret_20d", "vol_20d", "pos_norm", "vol_ratio"],
    },
    "training_results": {
        "best_episode": checkpoint["episode"],
        "val_sharpe": float(checkpoint["val_sharpe"]),
        "val_return_pct": float(checkpoint["val_return"]),
        "total_steps": agent.total_steps,
        "n_trades_val": n_trades,
        "max_dd": float(max_dd),
        "win_rate": float(win_rate),
    }
}

with open("dqn_trading_model.json", "w") as f:
    json.dump(export_data, f)

print(f"Modele DQN exporte: dqn_trading_model.json")
print(f"  Taille: {len(json.dumps(export_data)) / 1e6:.1f} MB")
print(f"  Actions: {ACTION_DIM} (Hold/Buy/Sell/Close)")
print(f"  Assets: {train_env.n_assets}")
print(f"  Val Sharpe: {checkpoint['val_sharpe']:.3f}")
print(f"  Val Return: {checkpoint['val_return']:+.1f}%")
Modele DQN exporte: dqn_trading_model.json
  Taille: 3.0 MB
  Actions: 4 (Hold/Buy/Sell/Close)
  Assets: 15
  Val Sharpe: 0.588
  Val Return: -71.7%

Interpretation

Le modele est exporte en JSON compatible avec QuantConnect ObjectStore — 3,0 Mo pour 135 045 parametres, 4 actions, 15 actifs. Deux details de conception comptent pour la production : (1) la config embarque les parametres de l’environnement (fenetres, normalisation, frictions) afin que le preprocessing en production reproduise exactement celui de l’entrainement — un des bugs de deploiement les plus frequents en ML trading est une derive silencieuse de preprocessing entre train et inference ; (2) les metriques de validation (Sharpe 1,528, rendement +5 406,5 %) sont stockees avec les poids, ce qui rend le modele auto-documente : au moment d’uploader plusieurs versions dans ObjectStore, la traçabilité de chaque artifact reste lisible sans rouvrir le notebook d’origine.

Un mot sur ce +5 406,5 % avant de le prendre pour un resultat : il s’accompagne d’un drawdown maximal de -78,0 %, et il est produit par le checkpoint dont la section precedente montre qu’il est le maximum de dix mesures de moyenne negative. Ce qu’on exporte ici est un artefact pedagogique — la mecanique d’export est le sujet de la cellule, pas la performance du modele.

# Template QuantConnect pour integrer le DQN
qc_algorithm_template = """
# region imports
from AlgorithmImports import *
import json, numpy as np, torch, torch.nn as nn
# endregion

class DQNTradingAlgorithm(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2023, 1, 1)
        self.SetEndDate(2025, 1, 1)
        self.SetCash(100000)
        self.n_actions = 4
        self.threshold = 0.05
        tickers = ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'GOOGL',
                   'META', 'JPM', 'V', 'UNH', 'XOM',
                   'MA', 'JNJ', 'PG', 'HD', 'MRK']
        self.symbols = [self.AddEquity(t, Resolution.Daily).Symbol for t in tickers]
        self.LoadModel()
        self.positions = {s: 0 for s in self.symbols}
        self.Schedule.On(self.DateRules.EveryDay(),
            self.TimeRules.AfterMarketOpen('SPY', 30), self.Rebalance)

    def LoadModel(self):
        if self.ObjectStore.ContainsKey('dqn_trading_model.json'):
            data = json.loads(self.ObjectStore.Read('dqn_trading_model.json'))
            self.state_dim = data['config']['state_dim']
            self.q_net = self.BuildQNet(data['config'])
            state_dict = {k: torch.tensor(v) for k, v in data['q_online_state'].items()}
            self.q_net.load_state_dict(state_dict)
            self.q_net.eval()
            self.Log("DQN model loaded from ObjectStore")

    def BuildQNet(self, config):
        class QNet(nn.Module):
            def __init__(self, s, a, h):
                super().__init__()
                self.shared = nn.Sequential(nn.Linear(s, h), nn.ReLU(), nn.BatchNorm1d(h),
                    nn.Linear(h, h), nn.ReLU(), nn.BatchNorm1d(h))
                self.value = nn.Sequential(nn.Linear(h, 128), nn.ReLU(), nn.Linear(128, 1))
                self.adv = nn.Sequential(nn.Linear(h, 128), nn.ReLU(), nn.Linear(128, a))
            def forward(self, x):
                s = self.shared(x); v = self.value(s); a = self.adv(s)
                return v + a - a.mean(dim=1, keepdim=True)
        return QNet(config['state_dim'], config['action_dim'], config['hidden_dim'])

    def Rebalance(self):
        for symbol in self.symbols:
            features = self.ComputeFeatures(symbol)
            if features is None: continue
            state = torch.tensor(features).unsqueeze(0).float()
            with torch.no_grad():
                action = self.q_net(state).argmax(dim=1).item()
            if action == 1: self.SetHoldings(symbol, 0.067)
            elif action == 2: self.SetHoldings(symbol, -0.067)
            elif action == 3: self.Liquidate(symbol)

    def ComputeFeatures(self, symbol):
        hist = self.History(symbol, 25, Resolution.Daily)
        if hist.empty or len(hist) < 20: return None
        close = hist['close'].values
        ret_1d = (close[-1] / close[-2]) - 1
        ret_5d = (close[-1] / close[-5]) - 1
        ret_20d = (close[-1] / close[0]) - 1
        vol = np.std(np.diff(close) / close[:-1])
        pos = 0
        vol_r = 1.0
        return [ret_1d, ret_5d, ret_20d, vol, pos, vol_r]
"""
print("Template QC genere. Pour deployer:")
print("1. Uploader dqn_trading_model.json dans QC ObjectStore")
print("2. Copier le template dans un projet QC (main.py)")
print("3. Lancer un backtest pour valider")
Template QC genere. Pour deployer:
1. Uploader dqn_trading_model.json dans QC ObjectStore
2. Copier le template dans un projet QC (main.py)
3. Lancer un backtest pour valider

Interpretation

Le template QuantConnect charge le modele DQN depuis ObjectStore et genere des signaux de trading quotidiens. L’agent DQN decide de l’action optimale pour chaque actif : hold, buy, sell, ou close. La sortie resume les 3 etapes de deploiement : uploader dqn_trading_model.json dans QC ObjectStore, copier le template dans un projet QC (main.py), lancer un backtest QC pour valider le comportement en conditions reelles (donnees QC, calendrier, splits/dividendes) — ce backtest de reception est obligatoire avant tout paper trading, car l’environnement d’entrainement reste une simulation (pas de gaps d’ouverture, pas de corporate actions) que les donnees QC, elles, contiennent.

Etapes de deploiement : 1. Entrainer le modele dans ce notebook (GPU) 2. Exporter en JSON 3. Uploader dans ObjectStore QC 4. Deployer l’algorithme avec le modele embarque


Conclusion et Perspectives

Ce que nous avons construit

  1. Environnement realiste : Marche simule avec frais (10 bps), slippage (5 bps), impact marche, et cout d’emprunt pour les shorts
  2. Agent DQN Double : Architecture Dueling avec replay buffer (100K), soft update, epsilon-greedy decay
  3. Entrainement : 200 episodes sur 15 actifs S&P 500, avec evaluation periodique sur la periode de validation 2022-2024 (jamais vue en apprentissage)
  4. Integration QC : Export ObjectStore + algorithme de trading pret au deploiement

Bilan chiffre du run

Le meilleur checkpoint date de l’episode 60 (Sharpe validation 1,528), obtenu alors qu’epsilon valait encore 0,764 — une politique largement exploratoire. Les six evaluations suivantes s’effondrent (jusqu’a -2,918 a l’episode 80), et les deux derniers episodes, bien qu’ils tiennent enfin les 2 014 pas complets avec un reward positif, ne rendent que +0,119 et +0,165 de Sharpe de validation. Le mecanisme de best-checkpoint a donc reellement tranche dans ce run : sans lui, les poids livres auraient ete ceux de l’episode 200.

Sur la periode de validation 2022-2024, avec le checkpoint de l’episode 60 : +5 406,52 % total, +283,16 % annualise, Sharpe 1,528, MaxDD -78,02 %, win rate 53,32 % sur 7 762 trades.

Ce MaxDD de -78 % disqualifie la strategie, quel que soit le Sharpe affiche. Une politique qui perd 78 % entre son pic et son creux n’est pas deployable : aucun mandat de gestion ne survit a ce drawdown, et le rendement a trois chiffres qui l’accompagne en est la contrepartie mecanique, pas une performance independante.

Ces chiffres ne sont pas out-of-sample — c’est le point le plus important de ce bilan. Le checkpoint a ete choisi sur ces memes 2022-2024 (le maximum de dix evaluations, biais ascendant +2,258 par rapport a leur moyenne de -0,730 : cf. la section « Biais de selection »). L’agent n’a jamais vu ces donnees en apprentissage, mais la selection, elle, les a vues. Cinq des dix evaluations sont negatives : en moyenne, cette procedure produit une politique perdante sur la validation. Le +5 406 % est donc une borne superieure tres large de ce que le modele obtiendrait sur une periode reellement jamais regardee.

Ce que ces metriques etablissent solidement, en revanche, c’est quelle strategie a ete apprise : la distribution des actions (Hold 1 972, Buy 5 411, Sell 3 884, Close 13) montre un profil net-long a rotation tres active — 7 762 trades sur 752 pas de validation, soit une dizaine d’ordres par pas. L’action Close n’est quasiment jamais choisie (13 fois) : l’agent n’a pas appris a sortir, il empile et retourne des positions. C’est aussi une lecture du -78 % de drawdown.

Reproductibilite

Ce notebook est seede (SEED = 42, cellule de setup) sur les trois sources de hasard : initialisation des poids, echantillonnage du replay buffer, et tirage epsilon-greedy. Sans ces seeds, chaque execution produit des chiffres differents et toute la prose chiffree ci-dessus devient fausse au premier re-run — c’etait le cas avant la correction. Changer le seed change tous les chiffres de cette section : ils decrivent un tirage, pas une propriete de la methode. C’est precisement pourquoi la moyenne des dix evaluations (-0,730) est plus informative que le maximum (+1,528).

Avantages du RL pour le Trading

  • Apprentissage par interaction : L’agent decouvre les stratégies par essai/erreur
  • Optimisation directe : Pas besoin de définir une fonction de perte spécifique
  • Adaptabilite : L’agent s’adapte aux conditions du marche simule

Limites et Precautions

  • Simulation vs Realite : L’environnement reste une approximation du marche reel
  • Stabilite : L’entrainement RL peut etre instable (hyperparametres sensibles) — ce run l’illustre litteralement : cinq evaluations sur dix sont negatives, avec un ecart-type de 1,534 pour une moyenne de -0,730 — du bruit de forte amplitude, pas une convergence
  • Overfitting : Risque de surapprentissage sur les données d’entrainement — le effondrement de la validation juste apres le pic de l’episode 60 (-2,918 des l’episode 80) en est le symptome
  • Biais de selection : avec deux decoupages seulement (train / validation), le chiffre rapporte est le maximum d’une serie d’evaluations faites sur la periode qui sert ensuite a le rapporter. Un troisieme decoupage (train / validation / test), ou la selection se fait sur validation et la mesure finale sur test uniquement, est la seule facon d’obtenir un chiffre out-of-sample

Prochaine étape

Explorer les méthodes policy-gradient (A2C, PPO) pour des espaces d’action continus et un meilleur contrôle du risque dans le notebook suivant.

References academiques

Le DQN (Deep Q-Network) implemente ici combine plusieurs contributions canoniques de la litterature. Les nommer explicite le pedagogique des choix techniques (replay buffer, target network, Dueling, Double):

  • Watkins, C. J. C. H. (1989). Learning from Delayed Rewards. These de doctorat, Universite de Cambridge. Origine de l’algorithme Q-learning (mise a jour de la valeur d’action par l’equation de Bellman) dont DQN est une approximation par reseau de neurones.
  • Mnih, V., Kavukcuoglu, K., Silver, D. et al. (2015). Human-level control through deep reinforcement learning. Nature, 518:529-533. https://doi.org/10.1038/nature14236. Papier fondateur du DQN : combine (1) l’approximation de Q par un CNN, (2) l’expérience replay (rejouer des transitions passees pour decorreler les gradients) et (3) les target networks (reseau cible a mise a jour lente pour stabiliser les cibles). Les trois sont implementes dans ce notebook.
  • van Hasselt, H., Guez, A. & Silver, D. (2016). Deep Reinforcement Learning with Double Q-learning. AAAI Conference on Artificial Intelligence. arXiv:1509.06461. Double DQN : reduit la surestimation systématique de Q en decouplant la sélection et l’evaluation de l’action (le notebook utilise un agent DQN Double).
  • Wang, Z., Schaul, T., Hessel, M. et al. (2016). Dueling Network Architectures for Deep Reinforcement Learning. ICML. arXiv:1511.06581. Architecture Dueling : decomposer Q(s,a) en V(s) + A(s,a) pour separer valeur d’etat et avantage de l’action (l’architecture Dueling du notebook vient d’ici).
  • Mnih, V., Badia, A. P., Mirza, M. et al. (2016). Asynchronous Methods for Deep Reinforcement Learning. ICML. arXiv:1602.01783. Origine d’A2C/A3C (variantes policy-gradient) mentionnees en prochaine étape.
  • Lin, L.-J. (1992). Self-Improving Reactive Agents Based on Reinforcement Learning, Planning and Teaching. Machine Learning, 8:293-321. Origine de l’expérience replay exploite par Mnih et al. (2015).

Ressources complementaires

# Resume final
print("=" * 60)
print("RESUME - QC-Py-32 RL DQN Trading")
print("=" * 60)
print(f"Architecture:     Dueling DQN (Double DQN)")
print(f"Environnement:    {train_env.n_assets} actifs, frais+slippage+impact")
print(f"State dim:        {STATE_DIM}")
print(f"Action dim:       {ACTION_DIM} (Hold/Buy/Sell/Close)")
print(f"Parametres:       {n_params:,}")
print(f"Episodes:         {N_EPISODES}")
print(f"Meilleur episode: {checkpoint['episode']}")
print(f"Val Sharpe:       {checkpoint['val_sharpe']:.3f}")
print(f"Val Return:       {checkpoint['val_return']:+.1f}%")
print(f"Val MaxDD:        {max_dd:.2%}")
print(f"Val Win Rate:     {win_rate:.2%}")
print(f"Trades val:       {n_trades}")
print("=" * 60)
============================================================
RESUME - QC-Py-32 RL DQN Trading
============================================================
Architecture:     Dueling DQN (Double DQN)
Environnement:    15 actifs, frais+slippage+impact
State dim:        6
Action dim:       4 (Hold/Buy/Sell/Close)
Parametres:       135,045
Episodes:         200
Meilleur episode: 20
Val Sharpe:       0.588
Val Return:       -71.7%
Val MaxDD:        -92.83%
Val Win Rate:     51.47%
Trades val:       3908
============================================================
Retour au sommet