Passer d’un seul actif (SPY) a un panier diversifie 24 symbols (equities, bonds, commodities, FX, indices)
Utiliser une action continue : portfolio weights via softmax (allocation proportionnelle)
Reward = differentiel Sharpe rolling vs equal-weight benchmark
Comparer : RL portfolio vs equal-weight B&H vs 60/40 SPY/TLT
Verdict honnete : le RL multi-asset bat-il l’allocation passive ?
1. Pourquoi le multi-asset change la donne
Les notebooks RL-01 a RL-03 operaient sur SPY seul. Le signal etait faible car : - Un seul actif = pas d’avantage d’allocation - B&H SPY Sharpe ~1.4 = barre très elevee
L’hypothese multi-asset : un agent RL peut apprendre une allocation dynamique entre equities/bonds/commodities/crypto qui surpasse l’allocation statique equal-weight. L’avantage potentiel :
Rotation sectorielle (equities -> bonds en bear market)
Cross-asset momentum (commodities trending quand equities range)
Diversification time-varying (adapter les poids au regime)
Architecture : - State : matrice de returns (LOOKBACK x N_ASSETS) flattenee - Action : N_ASSETS logits -> softmax -> portfolio weights - Reward : rolling Sharpe du portfolio - rolling Sharpe de l’equal-weight
"""QC-Py-RL-04: Multi-Asset PPO Portfolio Allocation.Tests whether RL on a diversified 24-symbol panier can beatequal-weight passive allocation using PyTorch Actor-Critic PPO."""import numpy as npimport pandas as pdimport torchimport torch.nn as nnimport torch.optim as optimfrom torch.distributions import Dirichletimport matplotlib.pyplot as pltimport warningswarnings.filterwarnings("ignore")# --- Config ---LOOKBACK =20N_EPISODES =150EPISODE_LEN =252HIDDEN_DIM =128LR =3e-4GAMMA =0.99GAE_LAMBDA =0.95CLIP_EPS =0.2ENTROPY_COEF =0.02SEEDS = [0, 1, 7, 42]FEE_BPS =10# higher tx cost for portfolio rebalancingSHARPE_WINDOW =20EXCLUDE_SYMBOLS = ["XLC"] # too sparse (>50% NaN)# Load panier dataPANIER_PATH ="../datasets/panier/panier_close_all.csv"panier = pd.read_csv(PANIER_PATH, index_col=0, parse_dates=True)# Exclude sparse symbolsfor sym in EXCLUDE_SYMBOLS:if sym in panier.columns: panier = panier.drop(columns=[sym])# Forward-fill, then drop any remaining NaN rows (align to business days)panier = panier.ffill().dropna()# Compute log returnslog_returns = np.log(panier / panier.shift(1)).dropna()symbols =list(log_returns.columns)n_assets =len(symbols)returns_arr = log_returns.valuesdates = log_returns.indexprint(f"Panier: {n_assets} symbols, {len(returns_arr)} days [{dates[0].date()}..{dates[-1].date()}]")print(f"Symbols: {', '.join(symbols[:6])}... ({n_assets} total)")print(f"Config: LOOKBACK={LOOKBACK}, HIDDEN={HIDDEN_DIM}, EPISODES={N_EPISODES}, FEE={FEE_BPS}bps")print(f"PyTorch {torch.__version__} | State dim = {LOOKBACK * n_assets}")
Panier: 24 symbols, 3097 days [2017-11-10..2026-05-03]
Symbols: SPY, RSP, IWM, XLF, XLK, XLV... (24 total)
Config: LOOKBACK=20, HIDDEN=128, EPISODES=150, FEE=10bps
PyTorch 2.11.0+cu128 | State dim = 480
2. Environnement de Portfolio avec action continue
L’action est un vecteur de poids : softmax de N_ASSETS logits. A chaque step, l’agent choisit une allocation, paie les frais de transaction sur le changement de poids, et recoit un reward base sur le differentiel de Sharpe par rapport a l’allocation equal-weight.
L’actor produit N_ASSETS concentrations positives -> Dirichlet pour echantillonner les poids du portefeuille. Le critic estime la valeur de l’etat. On utilise Dirichlet au lieu de softmax+Gumbel pour garantir des poids toujours valides (positifs, somme=1).
class PortfolioActorCritic(nn.Module):"""Actor-Critic for portfolio allocation with Dirichlet policy."""def__init__(self, state_dim, action_dim, hidden_dim=HIDDEN_DIM):super().__init__()self.backbone = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), )# Actor: outputs positive concentrations for Dirichletself.actor = nn.Sequential( nn.Linear(hidden_dim, action_dim), nn.Softplus(), # ensures positive concentrations )self.critic = nn.Linear(hidden_dim, 1)# Base concentration for explorationself.base_concentration = nn.Parameter(torch.ones(action_dim) *2.0)def forward(self, x): features =self.backbone(x) concentrations =self.actor(features) +self.base_concentration value =self.critic(features)return concentrations, valuedef get_action(self, state, deterministic=False): state_t = torch.FloatTensor(state).unsqueeze(0) concentrations, value =self.forward(state_t) dist = Dirichlet(concentrations)if deterministic:# Mean of Dirichlet = concentrations / sum weights = concentrations / concentrations.sum(dim=-1, keepdim=True)else: weights = dist.sample() log_prob = dist.log_prob(weights) entropy = dist.entropy()return weights.squeeze(0).detach().numpy(), log_prob, value.squeeze(-1), entropydef compute_gae(rewards, values, dones, gamma=GAMMA, lam=GAE_LAMBDA): advantages = [] gae =0 next_value =0for t inreversed(range(len(rewards))):if dones[t]: next_value =0 gae =0 delta = rewards[t] + gamma * next_value - values[t] gae = delta + gamma * lam * gae advantages.insert(0, gae) next_value = values[t] returns = [a + v for a, v inzip(advantages, values)]return advantages, returnsprint(f"PortfolioActorCritic: {LOOKBACK * n_assets} -> {HIDDEN_DIM} -> {HIDDEN_DIM} -> ({n_assets} concentrations, 1 value)")print(f"Policy: Dirichlet (always valid portfolio weights, sum=1)")