Série RL for Trading (#1461) | RL-1 Q-Learning · RL-2 PPO · RL-3 Reward Shaping · RL-4 Multi-Asset · RL-5 Tactical Overlay
Objectifs
Utiliser un agent RL comme couche tactique au-dessus d’un portefeuille B&H equal-weight
L’agent décide d’un biais de sur/sous-pondération par actif (pas des poids absolus)
Comparer : B&H seul vs B&H + RL overlay (long-only)
Validation multi-seed (0/1/7/42) avec edge ≥ 2σ
Principe
Au lieu de remplacer l’allocation B&H, l’agent RL ajuste marginalement chaque position : - Action ∈ [-1, +1] par actif (biais tactique) - Poids final = poids_B&H × (1 + action × overlay_scale) - Si overlay_scale=0.3, l’agent peut surpondérer de 30% ou sous-pondérer de 30%
L’hypothèse : un petit ajustement RL ajouté à B&H peut capturer des opportunités tactiques sans détruire la diversification du portefeuille passif.
Durée estimée : ~5 min (Papermill) | Prérequis : PyTorch, panier L1/L2
import numpy as np
import pandas as pd
import os
os.environ.setdefault('CUBLAS_WORKSPACE_CONFIG' , ':4096:8' ) # avant import torch : workspace deterministe CuBLAS
import torch
import torch.nn as nn
import torch.optim as optim
from pathlib import Path
import warnings
warnings.filterwarnings("ignore" )
# Determinisme explicite : les seeds par-run (cellule d'entrainement) ne suffisent pas seuls
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
torch.use_deterministic_algorithms(True , warn_only= True )
SEEDS = [0 , 1 , 7 , 42 ]
LOOKBACK = 20
N_EPISODES = 150
EPISODE_LEN = 252
HIDDEN_DIM = 128
LR = 3e-4
GAMMA = 0.99
GAE_LAMBDA = 0.95
CLIP_EPS = 0.2
ENTROPY_COEF = 0.01
OVERLAY_SCALE = 0.3
FEE_BPS = 10
BATCH_SIZE_EP = 64
DATA_PATH = Path("../datasets/panier/panier_close_all.csv" )
print (f"PyTorch { torch. __version__} , CUDA= { torch. cuda. is_available()} " )
print (f"Config: overlay_scale= { OVERLAY_SCALE} , fee_bps= { FEE_BPS} , episodes= { N_EPISODES} " )
PyTorch 2.6.0+cu124, CUDA=True
Config: overlay_scale=0.3, fee_bps=10, episodes=150
1. Chargement du panier anti-biais
Même panier 24-actifs que RL-04 (excluant XLC pour sparsity).
df_raw = pd.read_csv(DATA_PATH, index_col= 0 , parse_dates= True )
df_raw.index = pd.DatetimeIndex(df_raw.index)
df_raw = df_raw.sort_index()
# Exclude XLC (sparse, <50% coverage)
exclude = [c for c in df_raw.columns if df_raw[c].notna().sum () < len (df_raw) * 0.5 ]
df = df_raw.drop(columns= exclude)
df = df.ffill().dropna()
n_assets = df.shape[1 ]
symbols = list (df.columns)
returns = df.pct_change().dropna()
print (f"Panier: { n_assets} assets, { len (returns)} business days" )
print (f"Range: { returns. index. min (). date()} to { returns. index. max (). date()} " )
print (f"Assets: { symbols[:6 ]} ... + { n_assets- 6 } more" )
Panier: 25 assets, 3116 business days
Range: 2017-11-10 to 2026-05-22
Assets: ['SPY', 'RSP', 'IWM', 'XLF', 'XLK', 'XLV']... + 19 more
2. Environnement Tactical Overlay
L’environnement wrappe un portefeuille B&H equal-weight. L’agent RL observe les rendements récents et produit un biais par actif.
State
Rendements normalisés LOOKBACK × N_ASSETS
Action
Biais ∈ [-1, +1] par actif (tanh-squashed)
Poids final
w_i = (1/N) × (1 + action_i × OVERLAY_SCALE)
Reward
Rendement différentiel (overlay - B&H)
class TacticalOverlayEnv:
"""B&H equal-weight portfolio with RL tactical overlay adjustments."""
def __init__ (self , returns_df, lookback= 20 , episode_len= 252 , overlay_scale= 0.3 , fee_bps= 10 ):
self .returns = returns_df.values
self .dates = returns_df.index
self .n_assets = returns_df.shape[1 ]
self .lookback = lookback
self .episode_len = episode_len
self .overlay_scale = overlay_scale
self .fee = fee_bps / 10000
self .base_weight = 1.0 / self .n_assets
self ._idx = None
self ._prev_action = None
@property
def state_dim(self ):
return self .lookback * self .n_assets
@property
def action_dim(self ):
return self .n_assets
def reset(self , start_idx= None ):
max_start = len (self .returns) - self .lookback - self .episode_len - 1
if start_idx is None :
start_idx = np.random.randint(self .lookback, max (max_start, self .lookback + 1 ))
self ._idx = start_idx
self ._prev_action = np.zeros(self .n_assets)
return self ._get_state()
def _get_state(self ):
window = self .returns[self ._idx - self .lookback:self ._idx]
std = window.std(axis= 0 , keepdims= True ) + 1e-8
normalized = window / std
return normalized.flatten()
def step(self , action):
self ._idx += 1
done = self ._idx >= len (self .returns) - 1
# Compute overlay weights
bias = action * self .overlay_scale
weights = np.full(self .n_assets, self .base_weight) * (1.0 + bias)
weights = np.clip(weights, 0 , None )
w_sum = weights.sum ()
if w_sum > 0 :
weights /= w_sum
else :
weights = np.full(self .n_assets, self .base_weight)
# Portfolio return
day_ret = self .returns[self ._idx]
port_ret = np.dot(weights, day_ret)
# B&H equal-weight return
bh_ret = day_ret.mean()
# Transaction cost on weight changes
turnover = np.abs (weights - self ._prev_action).sum ()
fee_cost = turnover * self .fee * 0.5
port_ret -= fee_cost
self ._prev_action = weights.copy()
# Reward = differential return
reward = (port_ret - bh_ret) * 100
info = {"port_ret" : port_ret, "bh_ret" : bh_ret, "turnover" : turnover}
return self ._get_state() if not done else np.zeros(self .state_dim), reward, done, info
3. Réseau Actor-Critic
Actor produit un biais ∈ [-1, +1] par actif via tanh. Critic estime la valeur de l’état.
class TacticalActorCritic(nn.Module):
"""Actor-Critic for tactical overlay. Actor outputs tanh-squashed biases."""
def __init__ (self , state_dim, action_dim, hidden_dim= 128 ):
super ().__init__ ()
self .backbone = nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU()
)
self .actor_mean = nn.Linear(hidden_dim, action_dim)
self .actor_log_std = nn.Parameter(torch.zeros(action_dim))
self .critic = nn.Linear(hidden_dim, 1 )
def forward(self , state):
feat = self .backbone(state)
mean = torch.tanh(self .actor_mean(feat))
std = torch.exp(self .actor_log_std.clamp(- 2 , 0 ))
value = self .critic(feat).squeeze(- 1 )
return mean, std, value
def collect_rollout(env, model, device):
"""Collect one episode of experience."""
states, actions, rewards, log_probs, values, dones = [], [], [], [], [], []
state = env.reset()
for _ in range (env.episode_len):
s = torch.FloatTensor(state).unsqueeze(0 ).to(device)
with torch.no_grad():
mean, std, value = model(s)
dist = torch.distributions.Normal(mean.squeeze(0 ), std.squeeze(0 ))
action = dist.sample()
log_prob = dist.log_prob(action).sum ()
action_np = action.cpu().numpy()
next_state, reward, done, _ = env.step(action_np)
states.append(state)
actions.append(action_np)
rewards.append(reward)
log_probs.append(log_prob.item())
values.append(value.item())
dones.append(done)
state = next_state
if done:
break
return {
"states" : np.array(states),
"actions" : np.array(actions),
"rewards" : np.array(rewards),
"log_probs" : np.array(log_probs),
"values" : np.array(values),
"dones" : np.array(dones)
}
4. Entraînement PPO
PPO clip + GAE + entropy bonus. Même pipeline que RL-03/RL-04.
def compute_gae(rewards, values, dones, gamma= 0.99 , lam= 0.95 ):
"""Generalized Advantage Estimation."""
advantages = []
gae = 0
values_list = list (values) + [0 ]
for t in reversed (range (len (rewards))):
delta = rewards[t] + gamma * values_list[t+ 1 ] * (1 - dones[t]) - values_list[t]
gae = delta + gamma * lam * (1 - dones[t]) * gae
advantages.insert(0 , gae)
return np.array(advantages)
def ppo_update(model, optimizer, rollout, device, clip_eps= 0.2 , entropy_coef= 0.01 ):
"""One PPO update epoch on collected rollout."""
states = torch.FloatTensor(rollout["states" ]).to(device)
actions = torch.FloatTensor(rollout["actions" ]).to(device)
old_log_probs = torch.FloatTensor(rollout["log_probs" ]).to(device)
advantages = torch.FloatTensor(rollout["advantages" ]).to(device)
returns = torch.FloatTensor(rollout["returns" ]).to(device)
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8 )
total_loss = 0
n_batches = 0
indices = np.arange(len (states))
np.random.shuffle(indices)
for start in range (0 , len (states), BATCH_SIZE_EP):
idx = indices[start:start+ BATCH_SIZE_EP]
batch_s = states[idx]
batch_a = actions[idx]
batch_old_lp = old_log_probs[idx]
batch_adv = advantages[idx]
batch_ret = returns[idx]
mean, std, value = model(batch_s)
dist = torch.distributions.Normal(mean, std)
new_log_prob = dist.log_prob(batch_a).sum (dim=- 1 )
entropy = dist.entropy().sum (dim=- 1 ).mean()
ratio = torch.exp(new_log_prob - batch_old_lp)
surr1 = ratio * batch_adv
surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * batch_adv
actor_loss = - torch.min (surr1, surr2).mean()
critic_loss = nn.MSELoss()(value, batch_ret)
loss = actor_loss + 0.5 * critic_loss - entropy_coef * entropy
optimizer.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 0.5 )
optimizer.step()
total_loss += loss.item()
n_batches += 1
return total_loss / max (n_batches, 1 )
5. Boucle d’entraînement multi-seed
4 seeds × 150 épisodes. Sharpe OOS sur la dernière portion du dataset.
def train_one_seed(returns_df, seed, device= "cpu" ):
"""Train tactical overlay PPO for one seed, return OOS metrics."""
torch.manual_seed(seed)
np.random.seed(seed)
env = TacticalOverlayEnv(
returns_df, lookback= LOOKBACK, episode_len= EPISODE_LEN,
overlay_scale= OVERLAY_SCALE, fee_bps= FEE_BPS
)
model = TacticalActorCritic(env.state_dim, env.action_dim, HIDDEN_DIM).to(device)
optimizer = optim.Adam(model.parameters(), lr= LR)
losses = []
for ep in range (N_EPISODES):
rollout = collect_rollout(env, model, device)
advantages = compute_gae(rollout["rewards" ], rollout["values" ], rollout["dones" ], GAMMA, GAE_LAMBDA)
rollout["advantages" ] = advantages
rollout["returns" ] = advantages + np.array(rollout["values" ])
loss = ppo_update(model, optimizer, rollout, device, CLIP_EPS, ENTROPY_COEF)
losses.append(loss)
# OOS evaluation: last 500 days
oos_start = len (returns_df) - 500
oos_rets_port = []
oos_rets_bh = []
state = env.reset(start_idx= oos_start)
model.eval ()
with torch.no_grad():
for t in range (min (500 , len (returns_df) - oos_start - 1 )):
s = torch.FloatTensor(state).unsqueeze(0 ).to(device)
mean, std, _ = model(s)
action = mean.squeeze(0 ).cpu().numpy()
next_state, reward, done, info = env.step(action)
oos_rets_port.append(info["port_ret" ])
oos_rets_bh.append(info["bh_ret" ])
state = next_state
if done:
break
port_arr = np.array(oos_rets_port)
bh_arr = np.array(oos_rets_bh)
def sharpe(r):
return float (np.mean(r) / (np.std(r, ddof= 1 ) + 1e-8 ) * np.sqrt(252 ))
return {
"seed" : seed,
"sharpe_port" : sharpe(port_arr),
"sharpe_bh" : sharpe(bh_arr),
"delta_sharpe" : sharpe(port_arr) - sharpe(bh_arr),
"mean_ret_port" : float (np.mean(port_arr)) * 252 ,
"mean_ret_bh" : float (np.mean(bh_arr)) * 252 ,
"n_oos" : len (port_arr),
"final_loss" : losses[- 1 ] if losses else float ("nan" )
}
# Run all seeds
device = "cuda" if torch.cuda.is_available() else "cpu"
results = []
for seed in SEEDS:
r = train_one_seed(returns, seed, device)
results.append(r)
print (f"Seed { seed} : Sharpe port= { r['sharpe_port' ]:.3f} , BH= { r['sharpe_bh' ]:.3f} , "
f"delta= { r['delta_sharpe' ]:+.3f} , OOS= { r['n_oos' ]} days" )
print (f" \n Done: { len (results)} seeds on { device} " )
Seed 0: Sharpe port=1.020, BH=1.080, delta=-0.059, OOS=499 days
Seed 1: Sharpe port=1.069, BH=1.080, delta=-0.011, OOS=499 days
Seed 7: Sharpe port=1.144, BH=1.080, delta=+0.064, OOS=499 days
Seed 42: Sharpe port=1.031, BH=1.080, delta=-0.048, OOS=499 days
Done: 4 seeds on cuda
6. Résultats et verdict
Edge ≥ 2σ cross-seed pour BEATS. Baseline = B&H equal-weight.
print ("=" * 70 )
print ("VERDICT -- QC-Py-RL-05: Tactical Overlay on B&H" )
print ("=" * 70 )
deltas = [r["delta_sharpe" ] for r in results]
mean_delta = np.mean(deltas)
std_delta = np.std(deltas, ddof= 1 ) if len (deltas) > 1 else float ("nan" )
sigma_edge = mean_delta / std_delta if std_delta > 1e-9 else float ("nan" )
n_positive = sum (1 for d in deltas if d > 0 )
mean_port_sharpe = np.mean([r["sharpe_port" ] for r in results])
mean_bh_sharpe = np.mean([r["sharpe_bh" ] for r in results])
print (f"RL+Overlay Sharpe (mean): { mean_port_sharpe:+.3f} " )
print (f"B&H Equal-Weight Sharpe: { mean_bh_sharpe:.3f} " )
print (f"Delta mean: { mean_delta:+.3f} " )
print (f"Sigma edge: { sigma_edge:+.2f} " )
print (f"Seeds positive vs B&H: { n_positive} / { len (results)} " )
print ()
if sigma_edge >= 2.0 and n_positive >= 3 :
verdict = "BEATS"
elif n_positive == 0 :
verdict = "NO BEATS"
else :
verdict = "INCONCLUSIVE"
print (f">>> VERDICT: { verdict} <<<" )
print ()
if verdict == "NO BEATS" :
print ("L'overlay tactique RL ne capture pas d'opportunite exploitable." )
print ("Le B&H equal-weight reste superieur meme avec ajustement marginal." )
elif verdict == "INCONCLUSIVE" :
print ("Resultats mixtes. L'overlay montre un signal partiel mais non robuste." )
else :
print ("L'overlay tactique capture un signal exploitable apres couts." )
print ()
print ("=" * 70 )
print ("RL Series Progress (#1461)" )
print ("=" * 70 )
series = [
("RL-1. Q-Learning Tabulaire" , "NO BEATS" , "PR #1581" ),
("RL-2. PPO (numpy)" , "NO BEATS" , "PR #1583" ),
("RL-3. Reward Shaping (PyTorch)" , "NO BEATS" , "PR #1584" ),
("RL-4. Multi-Asset PPO Portfolio" , "NO BEATS" , "PR #1585" ),
("RL-5. Tactical Overlay + B&H" , verdict, "This PR" ),
]
for name, v, pr in series:
print (f" { name:40s} | { v:15s} | { pr} " )
======================================================================
VERDICT -- QC-Py-RL-05: Tactical Overlay on B&H
======================================================================
RL+Overlay Sharpe (mean): +1.066
B&H Equal-Weight Sharpe: 1.080
Delta mean: -0.014
Sigma edge: -0.24
Seeds positive vs B&H: 1/4
>>> VERDICT: INCONCLUSIVE <<<
Resultats mixtes. L'overlay montre un signal partiel mais non robuste.
======================================================================
RL Series Progress (#1461)
======================================================================
RL-1. Q-Learning Tabulaire | NO BEATS | PR #1581
RL-2. PPO (numpy) | NO BEATS | PR #1583
RL-3. Reward Shaping (PyTorch) | NO BEATS | PR #1584
RL-4. Multi-Asset PPO Portfolio | NO BEATS | PR #1585
RL-5. Tactical Overlay + B&H | INCONCLUSIVE | This PR
Retour au sommet