<< Sommaire QC | Précédent : QC-Py-34-RL-SAC-A2C-Trading << | Suivant : QC-Py-40-PaperTrading-Binance >>

QC-Py-35 - Reinforcement Learning pour la Construction de Portefeuille

Niveau : Avance | Pre-requis : QC-Py-32, QC-Py-22 | Temps : 90 min

Objectif : Utiliser l’apprentissage par renforcement pour optimiser l’allocation d’actifs dans un portefeuille multi-actifs, en maximisant le ratio de Sharpe ajuste au risque.

Navigation : Index | << QC-Py-34

Objectifs d’apprentissage

A la fin de ce notebook, vous saurez : 1. Définir un espace d’etat et d’action adapte a l’allocation de portefeuille 2. Concevoir une fonction de recompense basee sur le ratio de Sharpe 3. Implementer un agent Q-learning tabulaire pour l’allocation a 2 actifs 4. Identifier les limites de l’approche tabulaire et les extensions vers DQN

Prerequis

  • Reinforcement Learning : concepts de base (Q-learning, epsilon-greedy)
  • Finance : ratio de Sharpe, matrice de correlation, volatilite
  • Python : numpy, pandas, matplotlib

Duree estimee : 90 minutes

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

# Reproductibilite
SEED = 42
np.random.seed(SEED)

# References QuantConnect (pour execution QC Cloud)
# from AlgorithmImports import *
# Les imports QC sont commentes ici pour execution locale.
# En environnement QuantConnect, decommenter la ligne ci-dessus.

print("Imports charges avec succes.")
print(f"NumPy version: {np.__version__}")
print(f"Pandas version: {pd.__version__}")
Imports charges avec succes.NumPy version: 1.26.4Pandas version: 2.3.3

Partie 1 : Pourquoi le RL pour l’allocation de portefeuille ?

L’optimisation de portefeuille classique (Markowitz) cherche les poids optimaux en resolvant un problème quadratique. Le RL offre une alternative qui :

  • Apprend par interaction : l’agent decouvre la politique d’allocation par essai-erreur, sans hypothese de normalite des rendements
  • Optimise directement le critere de performance : la fonction de recompense peut etre le ratio de Sharpe, le rendement ajuste au risque, ou toute metrique custom
  • S’adapte aux frictions : frais de transaction, contraintes de liquidite, et impact marche peuvent etre integres directement dans l’environnement

Dans ce notebook : nous construisons un environnement minimal a 2 actifs (SPY pour les actions US, TLT pour les obligations US) et un agent Q-learning tabulaire qui apprend a allouer entre ces deux classes d’actifs.

Contrainte Docker QC : seuls 5 tickers ont des données disponibles : SPY, QQQ, AAPL, GOOGL, IWM. Nous utilisons SPY comme proxy equity et simulons un proxy obligataire via une serie synthetique correlee negativement.

Ancre savante – Markowitz, H. (1952), Portfolio Sélection, The Journal of Finance 7(1):77-91 (DOI 10.1111/j.1540-6261.1952.tb01525.x). (Théorie moderne du portefeuille / Modern Portfolio Theory : allocation optimale des poids par minimisation de la variance pour un rendement cible donne, formalisation moyenne-variance. C’est la reference a “l’optimisation de portefeuille classique (Markowski)” que le RL vient completer en apprenant la politique par interaction au lieu de resoudre le problème quadratique.)

Pourquoi SPY + TLT ? Les deux classes d’actifs ont des caractéristiques opposées — actions (rendement attendu 10 %, volatilité 16 %) contre obligations (4 %, 12 %) — et une corrélation de -0.3 : quand l’action baisse, l’obligation tend à compenser. C’est exactement la structure que la diversification exploite : le portefeuille 50/50 a une variance inférieure à celle de chacun de ses composants pris isolément. La contrainte Docker QC (5 tickers disponibles) impose de simuler le proxy obligataire : TLT est une série synthétique corrélée négativement, générée par décomposition de Cholesky de la matrice de covariance — le même mécanisme que le générateur de scénarios d’un backtest Monte-Carlo.

# Environnement de portefeuille RL a 2 actifs

class PortfolioEnv:
    """Environnement RL pour l'allocation de portefeuille a 2 actifs.
    
    Espace d'etat (discretise) :
      - Regime de marche : 0 = baissier, 1 = neutre, 2 = haussier
        (base sur le rendement SPY roule sur 20 jours)
      - Niveau de volatilite : 0 = basse, 1 = moyenne, 2 = haute
        (base sur l'ecart-type roule sur 20 jours)
    
    Espace d'action (5 actions discretes) :
      - 0 : 100% SPY  /   0% TLT  (agressif)
      - 1 :  75% SPY  /  25% TLT
      - 2 :  50% SPY  /  50% TLT  (equilibre)
      - 3 :  25% SPY  /  75% TLT
      - 4 :   0% SPY  / 100% TLT  (defensif)
    """
    
    # Allocations correspondant aux 5 actions
    ALLOCATIONS = {
        0: np.array([1.0, 0.0]),
        1: np.array([0.75, 0.25]),
        2: np.array([0.50, 0.50]),
        3: np.array([0.25, 0.75]),
        4: np.array([0.0, 1.0]),
    }
    
    def __init__(self, n_steps=252, seed=None):
        """Initialise l'environnement.
        
        Args:
            n_steps: nombre de pas de temps (1 an de bourse = 252 jours)
            seed: graine pour la reproductibilite
        """
        self.n_steps = n_steps
        self.rng = np.random.RandomState(seed)
        self.n_actions = 5
        self.n_states = 9  # 3 regimes x 3 niveaux de volatilite
        self._generate_market_data()
        self.reset()
    
    def _generate_market_data(self):
        """Genere des rendements synthetiques correles pour SPY et TLT."""
        # Parametres annuelises realistes
        # SPY : mu=10%, sigma=16%
        # TLT : mu=4%, sigma=12%, correlation=-0.3 avec SPY
        mu_spy = 0.10 / 252
        mu_tlt = 0.04 / 252
        sigma_spy = 0.16 / np.sqrt(252)
        sigma_tlt = 0.12 / np.sqrt(252)
        rho = -0.3
        
        # Generation avec correlation via Cholesky
        cov = np.array([
            [sigma_spy**2, rho * sigma_spy * sigma_tlt],
            [rho * sigma_spy * sigma_tlt, sigma_tlt**2]
        ])
        L = np.linalg.cholesky(cov)
        z = self.rng.randn(self.n_steps + 40, 2)  # +40 pour le lookback initial
        returns = z @ L.T + np.array([mu_spy, mu_tlt])
        
        self.returns_spy = returns[:, 0]
        self.returns_tlt = returns[:, 1]
        
        # Prix cumulatifs
        self.prices_spy = 100 * np.cumprod(1 + self.returns_spy)
        self.prices_tlt = 100 * np.cumprod(1 + self.returns_tlt)
    
    def _get_state(self):
        """Calcule l'etat discretise a partir des donnees de marche."""
        lookback = 20
        if self.t < lookback:
            return 2 * 3 + 1  # etat par defaut : neutre + vol moyenne
        
        spy_ret = np.sum(self.returns_spy[self.t - lookback:self.t])
        spy_vol = np.std(self.returns_spy[self.t - lookback:self.t]) * np.sqrt(252)
        
        # Discretisation du regime
        if spy_ret < -0.05:
            regime = 0  # baissier
        elif spy_ret > 0.05:
            regime = 2  # haussier
        else:
            regime = 1  # neutre
        
        # Discretisation de la volatilite
        if spy_vol < 0.10:
            vol_level = 0  # basse
        elif spy_vol > 0.20:
            vol_level = 2  # haute
        else:
            vol_level = 1  # moyenne
        
        return regime * 3 + vol_level
    
    def reset(self):
        """Reinitialise l'environnement."""
        self.t = 20  # commencer apres le lookback
        self.portfolio_value = 100000.0
        self.values = [self.portfolio_value]
        self.portfolio_returns = []
        self.current_allocation = np.array([0.5, 0.5])  # 50/50 au depart
        return self._get_state()
    
    def step(self, action):
        """Execute un pas de temps.
        
        Args:
            action: indice de l'action (0-4)
        
        Returns:
            (next_state, reward, done, info)
        """
        new_alloc = self.ALLOCATIONS[action]
        
        # Cout de transaction (frais de rebalancement)
        turnover = np.sum(np.abs(new_alloc - self.current_allocation))
        tcost = turnover * self.portfolio_value * 0.001  # 10 bps
        
        self.current_allocation = new_alloc
        
        # Rendement du portefeuille
        asset_returns = np.array([
            self.returns_spy[self.t],
            self.returns_tlt[self.t]
        ])
        port_return = np.dot(self.current_allocation, asset_returns)
        
        # Appliquer le cout de transaction
        self.portfolio_value *= (1 + port_return)
        self.portfolio_value -= tcost
        
        self.portfolio_returns.append(port_return)
        self.values.append(self.portfolio_value)
        self.t += 1
        
        done = self.t >= self.n_steps + 20
        
        # Recompense = ratio de Sharpe roule sur 20 jours
        if len(self.portfolio_returns) >= 20:
            recent = np.array(self.portfolio_returns[-20:])
            if np.std(recent) > 0:
                reward = np.mean(recent) / np.std(recent) * np.sqrt(252)
            else:
                reward = 0.0
        else:
            reward = 0.0
        
        info = {
            "portfolio_value": self.portfolio_value,
            "allocation": self.current_allocation.copy(),
            "turnover": turnover,
        }
        
        return self._get_state(), reward, done, info


# Creation de l'environnement
env = PortfolioEnv(n_steps=252, seed=SEED)

print("Environnement PortfolioEnv cree :")
print(f"  Etats  : {env.n_states} (3 regimes x 3 niveaux de volatilite)")
print(f"  Actions: {env.n_actions} allocations (0%=defensif .. 100%=agressif)")
print(f"  Pas    : {env.n_steps} jours (~1 an de bourse)")
print(f"  Actifs : SPY (equity) + TLT synthetique (obligations)")
print(f"  Cout transaction : 10 bps de turnover")
Environnement PortfolioEnv cree :  Etats  : 9 (3 regimes x 3 niveaux de volatilite)  Actions: 5 allocations (0%=defensif .. 100%=agressif)  Pas    : 252 jours (~1 an de bourse)  Actifs : SPY (equity) + TLT synthetique (obligations)  Cout transaction : 10 bps de turnover

Interpretation : espace d’etats et d’actions

Composante Valeurs Signification
Regime de marche 0, 1, 2 Baissier, neutre, haussier (rendement SPY 20j)
Volatilite 0, 1, 2 Basse (<10%), moyenne, haute (>20%) annualisee
Action 0 100% SPY Agressif : tout en actions
Action 2 50/50 Equilibre : diversification
Action 4 100% TLT Defensif : tout en obligations

Total : 9 etats x 5 actions = 45 entrees dans la Q-table.

Note : L’espace d’etat est discretise intentionnellement pour le Q-learning tabulaire. En pratique, on utiliserait un espace continu avec DQN ou PPO.

Lecture de la sortie committée : la création de l’environnement affiche 9 états = le produit cartésien 3 régimes × 3 volatilités, et 5 actions d’allocation de 100 % SPY à 100 % TLT par paliers de 25 %. Le coût de transaction est fixé à 10 bps de turnover — le turnover étant la somme des variations absolues de poids entre deux réallocations, un passage de 50/50 à 100/0 coûte 0.5 × 0.001 × la valeur du portefeuille. La fenêtre de lookback de 20 jours sert à la fois au calcul du régime et de la volatilité ; avant le pas 20, l’état par défaut est 2*3+1 = 7 (neutre / volatilité moyenne), ce qui donne à l’agent une information dégradée en début d’épisode.

# Fonction de recompense et metriques de portefeuille

def compute_sharpe(returns, annual_factor=252):
    """Calcule le ratio de Sharpe annualise.
    
    Args:
        returns: array de rendements quotidiens
        annual_factor: facteur d'annualisation (252 pour actions, 365 pour crypto)
    
    Returns:
        Sharpe ratio annualise (sans taux sans risque)
    """
    if len(returns) < 2 or np.std(returns) == 0:
        return 0.0
    return np.mean(returns) / np.std(returns) * np.sqrt(annual_factor)


def compute_max_drawdown(values):
    """Calcule le drawdown maximum d'une serie de valeurs."""
    values = np.array(values)
    cummax = np.maximum.accumulate(values)
    drawdowns = (values - cummax) / cummax
    return np.min(drawdowns)


# Verification rapide sur l'environnement
state = env.reset()
print(f"Etat initial : {state}")
print(f"Allocation initiale : {env.current_allocation}")

# Simulation d'un episode aleatoire
done = False
random_returns = []
while not done:
    action = np.random.randint(env.n_actions)
    state, reward, done, info = env.step(action)
    random_returns.append(info["portfolio_value"])

sharpe_random = compute_sharpe(env.portfolio_returns)
maxdd_random = compute_max_drawdown(random_returns)
print(f"\nPolitique aleatoire (baseline) :")
print(f"  Valeur finale : {env.portfolio_value:,.0f} $")
print(f"  Sharpe ratio  : {sharpe_random:.3f}")
print(f"  Max drawdown  : {maxdd_random:.2%}")
Etat initial : 4Allocation initiale : [0.5 0.5]Politique aleatoire (baseline) :  Valeur finale : 89,284 $  Sharpe ratio  : 0.781  Max drawdown  : -16.62%

Exercice 1 : Fonction de reward avec penalite de drawdown

La reward basee uniquement sur le rendement encourage les allocations trop agressives. Une penalite de drawdown rend l’agent plus prudent.

Objectif : Modifier la fonction de reward pour penaliser le drawdown maximum.

Règles : - Reward = retour_portefeuille - lambda * max_drawdown_glissant - lambda a tester : 0 (pas de penalite), 0.5, 1.0, 2.0 - Calculez le max drawdown glissant sur une fenêtre de 20 pas - Comparez les 4 configurations (reward, allocation, max DD final)

Indices : - Indice : running_max = np.maximum.accumulate(portfolio_value) puis dd = (running_max - portfolio_value) / running_max - Indice : Utilisez le même agent Q-learning pour chaque lambda

Critère de validation : pour chaque lambda, affichez le reward moyen, l’allocation finale apprise et le MaxDD final sur la même série. Le résultat attendu : un lambda croissant réduit le drawdown au prix d’un reward moyen plus faible — c’est le compromis que vous devez quantifier. Vérifiez aussi que l’allocation finale passe progressivement de la plus agressive (lambda 0) vers la plus défensive (lambda 2).

# Exercice 2 : Reward avec penalite drawdown
# TODO etudiant : Modifier la reward et tester 4 valeurs de lambda
# Indice : Reward = retour - lambda * maxDD_glissant, tester lambda=0/0.5/1.0/2.0
# Etape 1 : Implementer le calcul du drawdown glissant
# Etape 2 : Modifier la fonction de reward
# Etape 3 : Tester les 4 valeurs de lambda
# Etape 4 : Comparer les resultats

result = None  # TODO etudiant : remplacer par la reward penalisee
print("Exercice a completer")

Exercice 2 : Implementer le Maximum Drawdown

Implementez une fonction compute_max_drawdown(equity_curve) qui calcule le drawdown maximum a partir d’une serie de valeurs de portefeuille.

Indices : - # Indice : Le drawdown = (equity - cummax) / cummax ou cummax est le maximum cumulatif - # Étape 1 : Calculer le cummax de l’equity curve - # Étape 2 : Calculer le drawdown a chaque pas - # Étape 3 : Retourner le minimum du drawdown (la plus grosse perte)

Critère de validation : testez votre fonction sur la série de la baseline aléatoire committée (89 284 $ finaux) — le MaxDD attendu est d’environ -16.6 %. Une implémentation correcte retourne une valeur négative ou nulle, jamais positive : le drawdown est une perte relative au pic antérieur.

# Exercice 1 : Maximum Drawdown
# TODO etudiant : Implementer compute_max_drawdown(equity_curve)
# Etape 1 : Calculer cummax = equity.cummax()
# Etape 2 : drawdown = (equity - cummax) / cummax
# Etape 3 : Retourner drawdown.min()
def compute_max_drawdown(equity_curve):  # TODO etudiant : completer l'implementation
    pass
print("Exercice a completer : Maximum Drawdown")
Exercice a completer

Trade-off exploration/exploitation en allocation

En allocation de portefeuille, le dilemme exploration/exploitation est particulierement delicate :

  • Exploitation : Maintenir l’allocation qui a historiquement le meilleur ratio de Sharpe (ex: 60/40 actions/obligations)
  • Exploration : Tester des allocations non conventionnelles pour decouvrir des regimes de marche ou une autre allocation serait superieure

Stratégie epsilon-greedy : Avec probabilite \(\epsilon\), choisir une action aleatoire ; sinon, choisir l’action qui maximise \(Q(s, a)\).

\[Q(s, a) \leftarrow Q(s, a) + \alpha \left[ r + \gamma \max_{a'} Q(s', a') - Q(s, a) \right]\]

Ou : - \(\alpha\) = taux d’apprentissage (0.1) - \(\gamma\) = facteur d’actualisation (0.95) - \(r\) = recompense (Sharpe roule 20j) - \(\epsilon\) decay de 1.0 a 0.05 sur les episodes

Ancre savante – Sutton, R.S. & Barto, A.G. (2018), Reinforcement Learning: An Introduction (2nd ed.), MIT Press, ISBN 978-0-262-03924-6. (Manuel de reference du RL : formalise le dilemme exploration/exploitation, la politique epsilon-greedy et son decay, et la taxonomie on-policy vs off-policy utilisee pour opposer SARSA et Q-learning dans l’exercice 3. Source canonique des concepts de reward, d’etat, d’action et de politique manipules dans tout le notebook.)

Lecture de la Q-table committée : l’agent appris après 500 épisodes associe le régime baissier/volatilité moyenne à l’allocation 50/50 (+9.755), le régime neutre/vol moyenne à 25/75 (+28.749) et le régime haussier/vol moyenne à 75/25 (+42.358) — il déplace le risque selon la conjoncture au lieu de s’y accrocher. Les lignes « Vol-Basse » restent à zéro : ces états n’ont quasiment jamais été visités (une volatilité annualisée sous 10 % est rare dans la série simulée), donc aucun apprentissage n’a pu s’y produire — un exemple concret d’état sous-exploré.

# Agent Q-learning tabulaire pour l'allocation 2-actifs

class TabularQLearning:
    """Agent Q-learning tabulaire pour l'allocation de portefeuille."""
    
    def __init__(self, n_states, n_actions, lr=0.1, gamma=0.95,
                 epsilon_start=1.0, epsilon_end=0.05, epsilon_decay=0.995):
        self.n_states = n_states
        self.n_actions = n_actions
        self.lr = lr
        self.gamma = gamma
        self.epsilon = epsilon_start
        self.epsilon_end = epsilon_end
        self.epsilon_decay = epsilon_decay
        
        # Q-table initialisee a zero
        self.q_table = np.zeros((n_states, n_actions))
    
    def select_action(self, state):
        """Selection d'action epsilon-greedy."""
        if np.random.random() < self.epsilon:
            return np.random.randint(self.n_actions)
        return np.argmax(self.q_table[state])
    
    def update(self, state, action, reward, next_state, done):
        """Mise a jour Q-learning."""
        if done:
            target = reward
        else:
            target = reward + self.gamma * np.max(self.q_table[next_state])
        
        td_error = target - self.q_table[state, action]
        self.q_table[state, action] += self.lr * td_error
        return td_error
    
    def decay_epsilon(self):
        """Decroissance d'epsilon."""
        self.epsilon = max(self.epsilon_end, self.epsilon * self.epsilon_decay)


# Entrainement
N_EPISODES = 500
agent = TabularQLearning(
    n_states=env.n_states,
    n_actions=env.n_actions,
    lr=0.1,
    gamma=0.95,
    epsilon_start=1.0,
    epsilon_end=0.05,
    epsilon_decay=0.995,
)

episode_sharpes = []
episode_values = []
best_sharpe = -999
best_q = None

print(f"Entrainement Q-learning tabulaire : {N_EPISODES} episodes")
print(f"  alpha={agent.lr}, gamma={agent.gamma}, eps_decay={agent.epsilon_decay}")
print("-" * 70)

for ep in range(1, N_EPISODES + 1):
    state = env.reset()
    done = False
    ep_td_errors = []
    
    while not done:
        action = agent.select_action(state)
        next_state, reward, done, info = env.step(action)
        td_error = agent.update(state, action, reward, next_state, done)
        ep_td_errors.append(td_error)
        state = next_state
    
    agent.decay_epsilon()
    ep_sharpe = compute_sharpe(env.portfolio_returns)
    episode_sharpes.append(ep_sharpe)
    episode_values.append(env.portfolio_value)
    
    if ep_sharpe > best_sharpe:
        best_sharpe = ep_sharpe
        best_q = agent.q_table.copy()
    
    if ep % 50 == 0:
        avg_sharpe = np.mean(episode_sharpes[-50:])
        print(f"Ep {ep:4d} | Sharpe={ep_sharpe:+.3f} | "
              f"Moy-50={avg_sharpe:+.3f} | eps={agent.epsilon:.3f} | "
              f"Valeur={env.portfolio_value:,.0f}$")

# Charger la meilleure Q-table
agent.q_table = best_q
print("-" * 70)
print(f"Entrainement termine. Meilleur Sharpe: {best_sharpe:.3f}")
print(f"\nQ-table finale (etat x action) :")
print(f"{'Etat':>6} | {'100%SPY':>8} | {'75/25':>8} | {'50/50':>8} | {'25/75':>8} | {'100%TLT':>8}")
print("-" * 70)
regime_names = ['Baissier', 'Neutre', 'Haussier']
vol_names = ['Vol-Basse', 'Vol-Moyen', 'Vol-Haute']
for regime in range(3):
    for vol in range(3):
        s = regime * 3 + vol
        q_vals = agent.q_table[s]
        best_a = np.argmax(q_vals)
        marker = " <--" 
        print(f"{regime_names[regime]:>8}/{vol_names[vol]:>9} | " +
              " | ".join(f"{v:+.3f}{marker if i == best_a else '':>4}" for i, v in enumerate(q_vals)))
Entrainement Q-learning tabulaire : 500 episodes  alpha=0.1, gamma=0.95, eps_decay=0.995----------------------------------------------------------------------Ep   50 | Sharpe=+1.849 | Moy-50=+0.718 | eps=0.778 | Valeur=99,889$Ep  100 | Sharpe=+0.949 | Moy-50=+0.765 | eps=0.606 | Valeur=92,266$Ep  150 | Sharpe=+1.190 | Moy-50=+0.924 | eps=0.471 | Valeur=94,979$Ep  200 | Sharpe=+1.113 | Moy-50=+0.985 | eps=0.367 | Valeur=96,640$Ep  250 | Sharpe=+0.453 | Moy-50=+0.822 | eps=0.286 | Valeur=90,761$Ep  300 | Sharpe=+1.750 | Moy-50=+0.867 | eps=0.222 | Valeur=107,608$Ep  350 | Sharpe=+1.585 | Moy-50=+0.931 | eps=0.173 | Valeur=105,058$Ep  400 | Sharpe=+0.629 | Moy-50=+1.161 | eps=0.135 | Valeur=94,436$Ep  450 | Sharpe=+0.908 | Moy-50=+1.437 | eps=0.105 | Valeur=101,324$Ep  500 | Sharpe=+0.654 | Moy-50=+1.312 | eps=0.082 | Valeur=97,641$----------------------------------------------------------------------Entrainement termine. Meilleur Sharpe: 2.750Q-table finale (etat x action) :  Etat |  100%SPY |    75/25 |    50/50 |    25/75 |  100%TLT----------------------------------------------------------------------Baissier/Vol-Basse | +0.000 <-- | +0.000     | +0.000     | +0.000     | +0.000    Baissier/Vol-Moyen | +5.492     | +6.158     | +9.755 <-- | +7.988     | +9.183    Baissier/Vol-Haute | +0.000 <-- | +0.000     | +0.000     | +0.000     | +0.000      Neutre/Vol-Basse | +18.434     | +17.470     | +18.771 <-- | +18.298     | +17.153      Neutre/Vol-Moyen | +28.445     | +27.260     | +25.470     | +28.749 <-- | +28.511      Neutre/Vol-Haute | +8.096 <-- | +7.002     | +7.166     | +6.351     | +6.651    Haussier/Vol-Basse | +0.000 <-- | +0.000     | +0.000     | +0.000     | +0.000    Haussier/Vol-Moyen | +38.827     | +42.358 <-- | +34.734     | +37.588     | +36.497    Haussier/Vol-Haute | +6.042     | +7.043     | +3.744     | +8.167     | +10.323 <--

Exercice 3 : Agent SARSA pour l’allocation

Q-learning est off-policy (utilise max Q pour la mise a jour). SARSA est on-policy (utilise l’action reellement prise), ce qui le rend plus conservateur.

Objectif : Implementer SARSA et comparer avec Q-learning sur le même environnement.

Règles : - SARSA update : Q(s,a) += alpha * (r + gamma * Q(s',a') - Q(s,a)) - Utilisez le même PortfolioEnv que ci-dessus - Même hyperparametres : alpha=0.1, gamma=0.95, epsilon=0.1 - Comparez : reward cumule, allocation finale, variance inter-episodes

Indices : - Indice : La différence avec Q-learning est Q(s',a') (action prise) vs max_a Q(s',a) (action optimale) - Indice : L’action a’ est choisie avec la même politique epsilon-greedy

Ancres savantes – Watkins, C.J.C.H. (1989), Learning from Delayed Rewards, PhD thesis, University of Cambridge (Q-learning : update off-policy Q(s,a) <- Q(s,a) + alpha[r + gamma * max_a’ Q(s’,a’) - Q(s,a)], l’agent apprend la valeur de la politique greedy optimale en suivant une politique comportementale différente ; cf. Watkins & Dayan 1992, Machine Learning 8(3-4):279-292 pour la convergence) ; Rummery, G.A. & Niranjan, M. (1994), On-line Q-learning using connectionist systems, Technical Report CUED/F-INFENG/TR 166, Cambridge University Engineering Department (SARSA – a l’origine “Modified Connectionist Q-Learning” : update on-policy Q(s,a) <- Q(s,a) + alpha[r + gamma * Q(s’,a’) - Q(s,a)] utilisant l’action reellement prise, plus conservateur que Q-learning comme note dans l’exercice).)

Critère de validation : entraînez SARSA et Q-learning avec les mêmes hyperparamètres (alpha 0.1, gamma 0.95, epsilon 0.1) et comparez sur 3 à 5 seeds : reward cumulé moyen, allocation finale et variance inter-épisodes. Le résultat attendu : SARSA converge vers une politique plus prudente (variance inter-épisodes plus faible, allocation moins agressive) parce qu’il évalue l’action réellement prise, y compris l’exploration, au lieu de l’optimum greedy.

# Exercice 3 : SARSA on-policy
# TODO etudiant : Implementer SARSA et comparer avec Q-learning
# Indice : Meme structure que QL mais update avec Q(s',a') au lieu de max Q(s',a)
# Etape 1 : Copier la structure du TabularQLearning
# Etape 2 : Modifier update() pour SARSA
# Etape 3 : Entrainer et collecter les resultats
# Etape 4 : Comparer avec Q-learning

result = None  # TODO etudiant : remplacer par l'agent SARSA
print("Exercice a completer")

Exercice 4 : Politique epsilon-greedy avec decay

Ajoutez un epsilon decay a l’agent Q-learning tabulaire : epsilon commence a 1.0 et decroit vers 0.01 après chaque episode.

Indices : - # Indice : Après chaque episode, epsilon = max(0.01, epsilon * decay_rate) - # Indice : Un decay_rate de 0.995 est un bon point de depart - # Étape 1 : Ajouter epsilon et decay_rate comme paramètres - # Étape 2 : Implementer le decay après chaque episode - # Étape 3 : Observer l’amelioration de la convergence

Critère de validation : tracez l’epsilon sur les épisodes (1.0 → 0.01 en ~800 épisodes avec decay 0.995) et le Sharpe moyen glissant. Le résultat attendu : une phase d’exploration initiale où le Sharpe oscille fortement, puis une stabilisation quand l’exploitation prend le relais — comparée à un epsilon constant de 0.1, la convergence doit être plus rapide en fin d’entraînement.

# Exercice 2 : Epsilon-greedy avec decay
# TODO etudiant : Ajouter un epsilon decroissant au Q-learning tabulaire
# Etape 1 : Ajouter epsilon=1.0 et decay_rate=0.995 comme parametres
# Etape 2 : epsilon = max(0.01, epsilon * decay_rate) apres chaque episode
# Etape 3 : Comparer la convergence
epsilon_decay_result = None  # TODO etudiant : remplacer par les resultats
print("Exercice a completer : Epsilon-greedy avec decay")
Exercice a completer

Interpretation : limites et extensions

Limites de l’approche tabulaire :

Limite Impact Solution
Etats discretises Perte d’information fine DQN avec etat continu
2 actifs uniquement Pas de vraie diversification Reseau pour N actifs
Actions discretisees Allocation par paliers (0/25/50/75/100%) PPO pour actions continues
Q-table petite (9x5) Sous-capacite pour problemes reels Deep Q-Network (QC-Py-32)

Extensions possibles : 1. DQN pour N actifs : Utiliser un reseau de neurones pour approximer Q(s, a) avec un espace d’etat continu (rendements, volatilite, correlations) 2. PPO/A2C : Méthodes policy-gradient pour des actions d’allocation continues (ex: poids dans [0, 1] avec somme = 1) 3. Reward shaping : Integrer le max drawdown ou la Value-at-Risk dans la recompense 4. Rebalancement dynamique : Ajouter un cout de transaction realiste (Proportional Transaction Cost model)

Pour aller plus loin : Le notebook QC-Py-32 implemente un agent DQN complet pour le trading multi-actifs avec replay buffer et target network.

References academiques

  • Markowitz, H. (1952). Portfolio Sélection. The Journal of Finance, 7(1):77-91. DOI 10.1111/j.1540-6261.1952.tb01525.x.
  • Rummery, G.A. & Niranjan, M. (1994). On-line Q-learning using connectionist systems. Technical Report CUED/F-INFENG/TR 166, Cambridge University Engineering Department.
  • Sutton, R.S. & Barto, A.G. (2018). Reinforcement Learning: An Introduction (2nd ed.). MIT Press. ISBN 978-0-262-03924-6.
  • Watkins, C.J.C.H. (1989). Learning from Delayed Rewards. PhD thesis, University of Cambridge.
  • Watkins, C.J.C.H. & Dayan, P. (1992). Q-learning. Machine Learning, 8(3-4):279-292.

La comparaison chiffrée committée : la politique aléatoire (baseline) termine à 89 284 $ avec un Sharpe de 0.781 et un MaxDD de -16.62 %, quand le meilleur épisode Q-learning atteint un Sharpe de 2.750 — un écart d’un facteur 3.5. La lecture honnête : les deux nombres ne comparent pas la même chose (une seule trajectoire aléatoire contre le meilleur des 500 épisodes), et le MaxDD de la politique apprise n’est pas affiché dans ce notebook. La moyenne glissante des 50 derniers épisodes, qui monte de 0.718 à 1.312 au fil de l’entraînement, est l’indicateur le plus fiable de l’apprentissage : elle lisse la variance d’échantillonnage épisode par épisode.

# Exercice a completer
#
# Objectif : Implementez un agent DQN pour l'allocation de portefeuille
# a N actifs (parmi les 5 tickers QC disponibles : SPY, QQQ, AAPL, GOOGL, IWM).
#
# Etapes suggerees :
# 1. Etendez PortfolioEnv pour supporter N actifs avec des rendements
#    reellement correles (matrice de covariance empirique)
# 2. Remplacez la Q-table par un reseau de neurones (PyTorch)
# 3. Ajoutez un replay buffer et un target network
# 4. Utilisez une recompende basee sur le Sharpe ratio roule
# 5. Comparez avec un baseline 60/40 et un portefeuille equipondere
#
# Indice : Inspirez-vous de l'architecture Dueling DQN du notebook QC-Py-32.
# L'espace d'action peut etre un vecteur de poids continus (approche DDPG/SAC)
# ou discretise en K allocations predefinies (approche DQN classique).

print("Exercice a completer -- implementez un agent DQN pour N actifs")
print("Inspirez-vous de QC-Py-32 (DQN Dueling) et de la classe PortfolioEnv ci-dessus.")
print("Tickers QC disponibles : SPY, QQQ, AAPL, GOOGL, IWM")
Exercice a completer -- implementez un agent DQN pour N actifsInspirez-vous de QC-Py-32 (DQN Dueling) et de la classe PortfolioEnv ci-dessus.Tickers QC disponibles : SPY, QQQ, AAPL, GOOGL, IWM
Retour au sommet