RL PPO Trading Agent
Train a reinforcement learning trading agent using Proximal Policy Optimization that learns optimal entry, exit, and position sizing policies through direct interaction with historical and simulated market environment rollouts.
Proximal Policy Optimization (PPO) for Algorithmic Trading
Overview
This notebook details the construction and evaluation of a Proximal Policy Optimization (PPO) agent designed for financial asset trading. The content encompasses the fundamental principles of PPO, its application within a custom trading environment, and methods for performance assessment.
Reinforcement Learning in Financial Markets
Traditional machine learning methodologies typically focus on predictive tasks (e.g., forecasting price movements). Reinforcement Learning (RL), conversely, emphasizes sequential decision-making, enabling an agent to learn optimal actions within an environment to maximize cumulative rewards.
In the context of financial trading:
- The Agent represents the trading entity.
- The Environment simulates the financial market.
- The State comprises observable market data (prices, technical indicators, portfolio valuation).
- Actions are discrete trading decisions (e.g., buy, hold, sell).
- Rewards are directly correlated with trading profits or losses.
Through iterative interaction with historical market data, the agent develops a policy, which maps observed market states to optimal trading actions.
Notebook Structure
The notebook is organized into logical sections:
- Environment Setup: Installation of dependencies and library imports.
- Data Management: Generation or acquisition of market data and feature engineering.
- Trading Environment: Implementation of a custom Gymnasium-compatible trading environment.
- Actor-Critic Network: Design of the neural network architecture for policy and value approximation.
- PPO Memory Buffer: Design of the experience replay buffer for on-policy learning.
- PPO Loss Functions: Formulation of the clipped PPO objective and auxiliary loss components.
- PPO Agent: Integration of network, buffer, and loss functions into a unified agent structure.
- Training Procedure: Execution and monitoring of the PPO training loop.
- Evaluation & Backtesting: Performance assessment against unseen data and a benchmark.
- Visualization: Graphical representation of training progress and backtest results.
- Hyperparameter Sensitivity: Exploration of hyperparameter impact on agent performance.
┌─────────────────────────────────────────────────────┐
│ 1. Installation & Imports │
│ 2. Data Generation & Feature Engineering │
│ 3. Trading Environment (OpenAI Gym style) │
│ 4. Actor-Critic Neural Network │
│ 5. PPO Memory Buffer │
│ 6. PPO Loss Functions │
│ 7. PPO Agent │
│ 8. Training Loop │
│ 9. Evaluation & Backtesting │
│ 10. Visualization & Performance Metrics │
└─────────────────────────────────────────────────────┘
Disclaimer
This notebook is provided strictly for educational purposes. The content herein does not constitute financial advice. Trading in financial markets involves substantial risk, including the potential for complete loss of capital.
1. Environment Setup
1.1. Dependency Installation
Required libraries include:
torch: Deep learning framework for neural network construction and gradient-based optimization.numpy/pandas: Essential for numerical computation and data manipulation.gymnasium: Standardized API for reinforcement learning environment interaction, succeeding OpenAI Gym.matplotlib/seaborn: Visualization tools for training progress and backtest analysis.yfinance(Optional): Facilitates the acquisition of real-world market data.
Other dependencies are part of the Python standard library.
1.2. Library Installation
This cell executes pip install commands to ensure all necessary Python libraries are available in the Colab environment. The gymnasium library is fundamental for defining the reinforcement learning environment, while yfinance is utilized for fetching real-world financial data. The --quiet flag suppresses extensive output during installation.
# ── Install any missing packages ──────────────────────────────────────────────
# Gymnasium is the maintained fork of OpenAI Gym.
# yfinance fetches real OHLCV data from Yahoo Finance.
!pip install gymnasium yfinance --quiet1.3. Import Statements & Configuration
This cell centralizes all required import statements and configures global settings to ensure consistency and reproducibility throughout the notebook. Key configurations include:
- Standard Libraries:
os,random,warnings,collections,typing. - Numerical & Data Handling:
numpyfor numerical operations,pandasfor data manipulation. - Deep Learning Framework:
torchfor neural network construction and optimization. - RL Environment:
gymnasiumfor environment definition. - Visualization:
matplotlib.pyplotandseabornfor data plotting. - Optional Data:
yfinancefor real market data, conditionally imported.
Global Settings: Warnings are suppressed, seaborn darkgrid style is applied, and a default figure size for matplotlib is set.
Reproducibility: A fixed SEED (42) is used for random, numpy, and torch to ensure consistent results across multiple executions.
Device Selection: The DEVICE variable is dynamically set to use a CUDA-enabled GPU if available, otherwise defaulting to the CPU for computational efficiency.
"""
Central import block.
Keeping all imports here makes dependencies explicit and easy to audit.
"""
# ── Standard library ──────────────────────────────────────────────────────────
import os
import random
import warnings
from collections import deque
from typing import Tuple, List, Dict, Optional
# ── Numerical & data ─────────────────────────────────────────────────────────
import numpy as np
import pandas as pd
# ── Deep learning ─────────────────────────────────────────────────────────────
import torch
import torch.nn as nn
import torch.optim as optim
from torch.distributions import Categorical
# ── RL environment ────────────────────────────────────────────────────────────
import gymnasium as gym
from gymnasium import spaces
# ── Visualization ─────────────────────────────────────────────────────────────
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
# ── Optional: real market data ────────────────────────────────────────────────
try:
import yfinance as yf
YFINANCE_AVAILABLE = True
except ImportError:
YFINANCE_AVAILABLE = False
# ── Global settings ───────────────────────────────────────────────────────────
warnings.filterwarnings('ignore') # Suppress deprecation noise
sns.set_theme(style='darkgrid') # Consistent plot style
plt.rcParams['figure.figsize'] = (14, 5) # Default figure size
# ── Reproducibility ───────────────────────────────────────────────────────────
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
# ── Device selection: GPU if available, else CPU ──────────────────────────────
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f" All imports successful")
print(f" Using device: {DEVICE}")
print(f" PyTorch version: {torch.__version__}")All imports successful Using device: cpu PyTorch version: 2.11.0+cpu
2. Data Management
2.1. Data Acquisition
Historical price data is fundamental for agent training. Two primary data sources are supported:
- Synthetic Data: Generated via geometric Brownian motion, a mathematical model commonly employed in quantitative finance (e.g., Black-Scholes model). This approach is suitable for rapid experimentation and reproducible simulations.
- Real-World Data: Acquired from Yahoo Finance utilizing the
yfinancelibrary. This source provides realistic market conditions, albeit with potential complexities such as data anomalies or download inconsistencies.
2.2. Feature Engineering
Raw price data alone typically lacks sufficient informational content for effective learning. Therefore, a set of normalized technical features is engineered to provide contextual and interpretable signals to the neural network:
| Feature | Description |
|---|---|
log_return | Daily logarithmic percentage change, ensuring scale-invariance. |
sma_ratio | Price relative to its Simple Moving Average (SMA), indicating trend direction. |
rsi | Relative Strength Index, quantifying momentum and identifying overbought/oversold conditions. |
bb_pct | Bollinger Band %B, illustrating the price's position within its volatility bands. |
volatility | Rolling standard deviation of log-returns, reflecting market instability. |
volume_ratio | Current trading volume relative to its moving average, suggesting institutional interest or market activity. |
These features collectively form the observation space, providing the agent with a comprehensive representation of the market state at each timestep.
2.1.1. Synthetic Price Data Generation (generate_synthetic_price_data)
This function synthesizes asset price data using Geometric Brownian Motion (GBM), a stochastic process frequently employed in quantitative finance to model asset prices. GBM simulates a price path where log-returns are normally distributed, providing a controlled and reproducible environment for testing trading algorithms. The output includes Open, High, Low, Close, and Volume, structured as a pandas DataFrame with a business-day date index.
Key parameters:
n_steps: Number of daily timesteps.initial_price: Starting asset price.mu: Drift coefficient (expected return).sigma: Volatility coefficient (standard deviation of log-returns).seed: Random seed for reproducibility.
The demonstration block below calls this function and prints the shape, date range, and a preview of the generated DataFrame.
def generate_synthetic_price_data(
n_steps: int = 2000,
initial_price: float = 100.0,
mu: float = 0.0002,
sigma: float = 0.015,
seed: int = SEED
) -> pd.DataFrame:
"""
Generate synthetic OHLCV price data using Geometric Brownian Motion (GBM).
GBM is the standard model for stock prices in quantitative finance. It assumes
log-returns are normally distributed — a simplification, but useful for learning.
The formula for each step:
S(t+1) = S(t) * exp((mu - 0.5*sigma^2)*dt + sigma*sqrt(dt)*Z)
where Z ~ Normal(0, 1) and dt = 1 (daily steps).
Parameters
----------
n_steps : int
Number of daily timesteps to generate (default 2000 ≈ 8 years of trading days).
initial_price : float
Starting price of the asset.
mu : float
Annualised drift (expected daily return). Positive = upward trend.
sigma : float
Daily volatility (standard deviation of log-returns). ~1.5% per day is realistic.
seed : int
Random seed for reproducibility.
Returns
-------
pd.DataFrame
DataFrame with columns: ['Open', 'High', 'Low', 'Close', 'Volume'].
Example
-------
>>> df = generate_synthetic_price_data(n_steps=500)
>>> print(df.shape) # (500, 5)
"""
rng = np.random.default_rng(seed)
# ── Simulate log-returns and cumulative price path ────────────────────────
# Each daily return is drawn from a normal distribution
log_returns = (mu - 0.5 * sigma ** 2) + sigma * rng.standard_normal(n_steps)
prices = initial_price * np.exp(np.cumsum(log_returns)) # Cumulative product via exp(sum)
# ── Build OHLC from close prices ──────────────────────────────────────────
# Intraday noise creates High/Low around the close
intraday_noise = sigma * 0.5 # High/Low spread ≈ half the daily sigma
opens = prices * np.exp(rng.normal(0, intraday_noise * 0.3, n_steps))
highs = prices * np.exp(np.abs(rng.normal(0, intraday_noise, n_steps)))
lows = prices * np.exp(-np.abs(rng.normal(0, intraday_noise, n_steps)))
closes = prices # The simulated path IS the close series
# ── Simulate volume with mean-reverting log-normal process ────────────────
# Volume spikes on large price moves (stylised fact of real markets)
base_volume = 1_000_000
volume_noise = rng.lognormal(mean=0, sigma=0.5, size=n_steps)
volumes = base_volume * volume_noise * (1 + 5 * np.abs(log_returns)) # Spike on big moves
# ── Assemble into a DataFrame with a business-day date index ─────────────
dates = pd.bdate_range(start='2015-01-01', periods=n_steps)
df = pd.DataFrame({
'Open': opens,
'High': highs,
'Low': lows,
'Close': closes,
'Volume': volumes.astype(int)
}, index=dates)
return df
# ── Demonstration ─────────────────────────────────────────────────────────────
raw_df = generate_synthetic_price_data(n_steps=2000)
print(f"Dataset shape: {raw_df.shape}")
print(f"Date range : {raw_df.index[0].date()} → {raw_df.index[-1].date()}")
print(f"Price range : ${raw_df['Close'].min():.2f} – ${raw_df['Close'].max():.2f}")
print()
print(raw_df.head())Dataset shape: (2000, 5)
Date range : 2015-01-01 → 2022-08-31
Price range : $21.28 – $109.73
Open High Low Close Volume
2015-01-01 100.364800 100.657884 100.297430 100.466912 1209038
2015-01-02 98.772375 99.586869 98.880901 98.920469 1999449
2015-01-05 100.146786 100.254340 99.402784 100.049038 1940771
2015-01-06 101.536982 103.197813 101.018727 101.479461 854785
2015-01-07 98.250232 99.623870 96.378652 98.561271 1262621
2.2.1. Feature Engineering (engineer_features)
This function transforms raw OHLCV (Open, High, Low, Close, Volume) price data into a set of normalized technical features. Raw prices are non-stationary and difficult for neural networks to interpret directly. Technical indicators, such as moving averages, momentum oscillators, and volatility measures, convert price action into relative signals. These signals are then normalized to ensure scale-invariance and prevent any single feature from dominating the learning process.
Computed Features:
log_return: Daily logarithmic percentage change.sma_ratio_short/long: Price relative to short-term/long-term Simple Moving Averages (SMAs).rsi: Relative Strength Index, indicating momentum.bb_pct: Bollinger Band %B, showing price position within volatility bands.volatility: Rolling standard deviation of log-returns.volume_ratio: Current volume relative to its moving average.
Rows containing NaN values, introduced by rolling window calculations, are dropped. Extreme outliers in the feature set are clipped to ±5 standard deviations to enhance numerical stability during neural network training.
The demonstration below applies the feature engineering to the raw_df and displays descriptive statistics of the resulting features_df.
def engineer_features(df: pd.DataFrame, window_short: int = 10, window_long: int = 50) -> pd.DataFrame:
"""
Transform raw OHLCV price data into normalised technical features.
Raw prices are non-stationary (they drift over time) and not comparable
across assets. Technical indicators convert prices into *relative* signals
that a neural network can interpret meaningfully.
Features Computed
-----------------
log_return : log(Close_t / Close_{t-1}) — daily log return
sma_ratio_short : Close / SMA(window_short) — short-term trend
sma_ratio_long : Close / SMA(window_long) — long-term trend
rsi : Relative Strength Index (0–100 normalised to 0–1)
bb_pct : Bollinger Band %B — price position within 2σ band
volatility : Rolling std of log-returns (normalised)
volume_ratio : Volume / SMA(volume, 20) — relative volume
Parameters
----------
df : pd.DataFrame
Raw OHLCV data with columns ['Open', 'High', 'Low', 'Close', 'Volume'].
window_short : int
Lookback window for the short-term moving average (default 10 days).
window_long : int
Lookback window for the long-term moving average (default 50 days).
Returns
-------
pd.DataFrame
Feature DataFrame with NaN rows dropped. Shape: (n_steps - window_long, n_features).
Example
-------
>>> features = engineer_features(raw_df)
>>> print(features.columns.tolist())
"""
feat = pd.DataFrame(index=df.index)
close = df['Close']
volume = df['Volume']
# ── 1. Log returns ─────────────────────────────────────────────────────────
# Log returns are additive and approximately normally distributed
feat['log_return'] = np.log(close / close.shift(1))
# ── 2. Price / Moving Average ratios ──────────────────────────────────────
# Values > 1 → price above average (bullish); < 1 → below (bearish)
feat['sma_ratio_short'] = close / close.rolling(window_short).mean()
feat['sma_ratio_long'] = close / close.rolling(window_long).mean()
# ── 3. RSI (Relative Strength Index) ──────────────────────────────────────
# RSI measures momentum: >0.7 = overbought, <0.3 = oversold
rsi_window = 14
delta = close.diff()
gain = delta.clip(lower=0).rolling(rsi_window).mean() # Average of up-days
loss = (-delta.clip(upper=0)).rolling(rsi_window).mean() # Average of down-days
rs = gain / (loss + 1e-8) # Relative strength; epsilon prevents division by zero
feat['rsi'] = 1 - (1 / (1 + rs)) # Normalised RSI to [0, 1]
# ── 4. Bollinger Band %B ──────────────────────────────────────────────────
# %B = (Price - Lower Band) / (Upper Band - Lower Band)
# 1.0 = at upper band, 0.0 = at lower band, 0.5 = at middle (SMA)
bb_window = 20
bb_mid = close.rolling(bb_window).mean()
bb_std = close.rolling(bb_window).std()
bb_upper = bb_mid + 2 * bb_std
bb_lower = bb_mid - 2 * bb_std
feat['bb_pct'] = (close - bb_lower) / (bb_upper - bb_lower + 1e-8)
# ── 5. Rolling volatility ─────────────────────────────────────────────────
# High volatility = high risk; the agent should learn to be cautious
feat['volatility'] = feat['log_return'].rolling(window_short).std()
# ── 6. Volume ratio ───────────────────────────────────────────────────────
# Volume spike often precedes or confirms price movement
feat['volume_ratio'] = volume / volume.rolling(20).mean()
# ── Drop NaN rows introduced by rolling windows ────────────────────────────
feat.dropna(inplace=True)
# ── Clip extreme outliers to ±5 standard deviations ───────────────────────
# Outliers can destabilise neural network training
for col in feat.columns:
col_mean = feat[col].mean()
col_std = feat[col].std()
feat[col] = feat[col].clip(col_mean - 5 * col_std, col_mean + 5 * col_std)
return feat
# ── Demonstration ─────────────────────────────────────────────────────────────
features_df = engineer_features(raw_df)
print(f"Feature matrix shape : {features_df.shape}")
print(f"Features : {features_df.columns.tolist()}")
print()
print(features_df.describe().round(4))Feature matrix shape : (1951, 7)
Features : ['log_return', 'sma_ratio_short', 'sma_ratio_long', 'rsi', 'bb_pct', 'volatility', 'volume_ratio']
log_return sma_ratio_short sma_ratio_long rsi bb_pct \
count 1951.0000 1951.0000 1951.0000 1951.0000 1951.0000
mean -0.0008 0.9966 0.9818 0.4703 0.4430
std 0.0151 0.0262 0.0587 0.1680 0.3226
min -0.0546 0.9040 0.8081 0.1023 -0.2889
25% -0.0108 0.9788 0.9419 0.3436 0.1862
50% -0.0003 0.9967 0.9824 0.4565 0.4029
75% 0.0090 1.0145 1.0208 0.5858 0.7123
max 0.0478 1.0763 1.1361 0.8995 1.2716
volatility volume_ratio
count 1951.0000 1951.0000
mean 0.0146 0.9994
std 0.0035 0.5158
min 0.0045 0.1523
25% 0.0121 0.6362
50% 0.0145 0.8951
75% 0.0170 1.2375
max 0.0257 3.6154
2.1.2. Real Market Data Acquisition (load_real_market_data)
This function facilitates the download of historical OHLCV data for a specified ticker symbol from Yahoo Finance using the yfinance library. It offers an alternative to synthetic data, providing real-world market conditions for more realistic evaluations. Error handling is included to manage yfinance availability or download failures, reverting to synthetic data if necessary.
Parameters:
ticker: The stock ticker symbol (e.g., 'SPY', 'AAPL').start/end: Date range for data retrieval.
Data Splitting: Following data acquisition (either synthetic or real), the dataset is split into training and testing sets. An 80/20 ratio is applied, with the training set comprising the initial 80% of data points and the test set the remaining 20%. This ensures the agent is evaluated on unseen data, which is crucial for assessing its generalization capabilities. The corresponding price series are also extracted for both sets.
The code block below demonstrates how to load either real or synthetic data based on the USE_REAL_DATA flag and then performs the train/test split. The shapes and date ranges of the resulting datasets are printed to confirm the split.
def load_real_market_data(
ticker: str = 'SPY',
start: str = '2015-01-01',
end: str = '2023-12-31'
) -> pd.DataFrame:
"""
Download real OHLCV data from Yahoo Finance and engineer features.
Uses yfinance under the hood. Falls back gracefully to synthetic data
if yfinance is unavailable or the download fails (e.g., network issues
in some Colab environments).
Parameters
----------
ticker : str
Yahoo Finance ticker symbol (e.g., 'SPY', 'AAPL', 'BTC-USD').
start : str
Start date in 'YYYY-MM-DD' format.
end : str
End date in 'YYYY-MM-DD' format.
Returns
-------
pd.DataFrame
Raw OHLCV DataFrame aligned with engineer_features() expectations.
Example
-------
>>> df = load_real_market_data('AAPL', '2018-01-01', '2023-01-01')
"""
if not YFINANCE_AVAILABLE:
print(" yfinance not available — using synthetic data instead.")
return generate_synthetic_price_data()
try:
print(f" Downloading {ticker} from Yahoo Finance ...")
raw = yf.download(ticker, start=start, end=end, progress=False, auto_adjust=True)
# yfinance returns multi-level columns for some versions — flatten
if isinstance(raw.columns, pd.MultiIndex):
raw.columns = raw.columns.get_level_values(0)
required = ['Open', 'High', 'Low', 'Close', 'Volume']
missing = [c for c in required if c not in raw.columns]
if missing:
raise ValueError(f"Missing columns after download: {missing}")
raw.dropna(inplace=True)
print(f" Downloaded {len(raw)} rows for {ticker}")
return raw
except Exception as exc:
print(f" Download failed ({exc}) — using synthetic data instead.")
return generate_synthetic_price_data()
# ── Choose your data source here ──────────────────────────────────────────────
USE_REAL_DATA = False # Set True to download SPY from Yahoo Finance
if USE_REAL_DATA:
raw_df = load_real_market_data('SPY', '2015-01-01', '2023-12-31')
else:
print("Using synthetic data (set USE_REAL_DATA=True for live data)")
# Re-engineer features on the chosen data source
features_df = engineer_features(raw_df)
# ── Train / Test split at 80% ─────────────────────────────────────────────────
split_idx = int(len(features_df) * 0.80)
train_features = features_df.iloc[:split_idx]
test_features = features_df.iloc[split_idx:]
train_prices = raw_df['Close'].loc[train_features.index]
test_prices = raw_df['Close'].loc[test_features.index]
print(f"\nTrain period: {train_features.index[0].date()} → {train_features.index[-1].date()} ({len(train_features)} rows)")
print(f"Test period : {test_features.index[0].date()} → {test_features.index[-1].date()} ({len(test_features)} rows)")Using synthetic data (set USE_REAL_DATA=True for live data) Train period: 2015-03-11 → 2021-03-02 (1560 rows) Test period : 2021-03-03 → 2022-08-31 (391 rows)
2.3. Feature Visualization (plot_feature_overview)
This function generates a multi-panel plot to visually inspect the raw asset price series and the engineered technical features. This visualization serves as a crucial sanity check, allowing for an intuitive understanding of the features and their relationship to price movements. It helps in verifying that the features behave as expected and are free from obvious errors or abnormal patterns.
The plot displays:
- Close Price: The raw asset price over time.
- RSI (Relative Strength Index): A momentum oscillator, with typical overbought (0.7) and oversold (0.3) thresholds marked.
- Bollinger Band %B & SMA Ratio: Indicators showing price relative to its moving averages and volatility bands.
- Rolling Volatility: A measure of price fluctuations over a given period.
The function below is called with the raw_df and features_df to display this comprehensive overview.
def plot_feature_overview(raw_df: pd.DataFrame, features_df: pd.DataFrame) -> None:
"""
Visualise the raw price series alongside the engineered features.
This plot lets you sanity-check that features look sensible before
feeding them into the trading environment.
Parameters
----------
raw_df : pd.DataFrame
Original OHLCV DataFrame.
features_df : pd.DataFrame
Feature DataFrame produced by engineer_features().
Returns
-------
None (displays matplotlib figure)
"""
fig, axes = plt.subplots(4, 1, figsize=(14, 14), sharex=True)
fig.suptitle('Market Data Overview', fontsize=16, fontweight='bold', y=1.01)
# ── Panel 1: Close price ───────────────────────────────────────────────────
common_idx = features_df.index # Use feature index (NaN rows dropped)
axes[0].plot(raw_df['Close'].loc[common_idx], color='steelblue', linewidth=1)
axes[0].set_ylabel('Price ($)')
axes[0].set_title('Close Price')
# ── Panel 2: RSI ──────────────────────────────────────────────────────────
axes[1].plot(features_df['rsi'], color='darkorange', linewidth=0.8)
axes[1].axhline(0.7, color='red', linestyle='--', linewidth=0.8, label='Overbought 0.7')
axes[1].axhline(0.3, color='green', linestyle='--', linewidth=0.8, label='Oversold 0.3')
axes[1].set_ylabel('RSI (normalised)')
axes[1].set_title('RSI Momentum')
axes[1].legend(fontsize=8)
# ── Panel 3: Bollinger %B and SMA ratios ──────────────────────────────────
axes[2].plot(features_df['bb_pct'], color='purple', linewidth=0.8, label='BB %B')
axes[2].plot(features_df['sma_ratio_long'], color='teal', linewidth=0.8, label='SMA ratio (50)')
axes[2].axhline(1.0, color='grey', linestyle=':', linewidth=0.7)
axes[2].set_ylabel('Ratio')
axes[2].set_title('Bollinger %B & SMA Ratio')
axes[2].legend(fontsize=8)
# ── Panel 4: Volatility ───────────────────────────────────────────────────
axes[3].fill_between(features_df.index, features_df['volatility'],
alpha=0.5, color='crimson', label='Rolling Volatility')
axes[3].set_ylabel('Volatility')
axes[3].set_title('Rolling Volatility (10-day std of log-returns)')
axes[3].legend(fontsize=8)
plt.tight_layout()
plt.show()
plot_feature_overview(raw_df, features_df)3.3. Trading Environment Class (TradingEnvironment)
This class defines a custom single-asset trading environment adhering to the Gymnasium API. It encapsulates the rules and dynamics of financial market interaction, allowing an agent to learn optimal trading policies. The environment is initialized with market features, historical prices, an initial cash balance, and a transaction cost.
Key Attributes:
features: Normalized technical indicators (fromengineer_features).prices: Raw close prices for reward calculation.initial_balance: Starting capital for the agent.transaction_cost: Proportional cost applied to each trade.
Observation Space: A continuous Box space combining market features, normalized portfolio value, current position (flat or long), and normalized remaining steps in the episode. This provides the agent with a comprehensive view of its state and the market.
Action Space: A Discrete space with three possible actions: HOLD (0), BUY (1), and SELL (2).
Reward Calculation: Rewards are based on the logarithmic return of the portfolio value, incentivizing profitable growth. Transaction costs are factored in as penalties.
reset() method: Initializes the environment to its starting state, clearing portfolio, trade history, and setting initial balance and position.
step() method: Executes one trading day (timestep), processes the agent's action, updates portfolio state, calculates reward, and advances the environment. It returns the next observation, reward, and episode termination flags.
render() method: Provides a textual summary of the current step, price, portfolio value, and position for human readability.
The demonstration block below instantiates the TradingEnvironment and performs a series of random actions to illustrate its functionality and the outputs of the step() method.
3. Trading Environment (Gymnasium API)
3.1. Reinforcement Learning Environment Framework
In Reinforcement Learning, the environment defines the interactive space for the agent. The interaction follows a cyclical process:
┌──────────────────────────────────────────┐
│ RL Interaction Loop │
│ │
│ State s_t ──► Agent ──► Action a_t │
│ │ │
│ Environment │
│ │ │
│ Reward r_t + Next State s_{t+1} │
└──────────────────────────────────────────┘
3.2. Trading Environment Specification
This custom TradingEnvironment class adheres to the Gymnasium API, providing a structured interface for agent interaction.
Observation Space
The observation vector, representing the market state, includes:
- Six normalized technical features derived from market data.
- Current portfolio value, normalized relative to the initial balance.
- Current trading position (0 = flat, 1 = long).
- Remaining steps in the episode, normalized to provide temporal context.
Action Space
The agent's actions are discrete:
0: Hold (maintain current position).1: Buy (enter a long position with available capital).2: Sell (exit an existing long position).
Reward Function
The reward signal is formulated as:
- The logarithmic return of the portfolio's value, which inherently accounts for compounding effects.
- A penalty proportional to transaction costs incurred during buy or sell operations.
This reward structure incentivizes profitable trades while penalizing excessive or costly trading activity.
class TradingEnvironment(gym.Env):
"""
Custom single-asset trading environment following the Gymnasium API.
The agent steps through historical price data day by day, choosing
to buy, hold, or sell. Profit/loss is tracked and returned as rewards.
This class inherits from gym.Env, which enforces the standard interface:
- reset() → returns initial observation
- step() → returns (observation, reward, terminated, truncated, info)
- render() → optional visualisation
Parameters
----------
features : pd.DataFrame
Feature matrix from engineer_features().
prices : pd.Series
Close price series aligned with features.index.
initial_balance : float
Starting cash balance in dollars (default $10,000).
transaction_cost : float
Proportional cost per trade as a fraction (default 0.001 = 0.1%).
lookback_window : int
Number of past timesteps stacked into the observation (default 1 = no history).
Observation Space
-----------------
Box(n_features + 3,) — technical features + portfolio_value + position + steps_left
Action Space
------------
Discrete(3) — 0: Hold, 1: Buy, 2: Sell
Example
-------
>>> env = TradingEnvironment(train_features, train_prices)
>>> obs, info = env.reset()
>>> obs, reward, done, truncated, info = env.step(action=1)
"""
metadata = {'render_modes': ['human']}
# ── Action constants for readability ──────────────────────────────────────
HOLD = 0
BUY = 1
SELL = 2
def __init__(
self,
features: pd.DataFrame,
prices: pd.Series,
initial_balance: float = 10_000.0,
transaction_cost: float = 0.001
):
super().__init__()
# ── Store data ────────────────────────────────────────────────────────
self.features = features.values.astype(np.float32) # Convert to numpy for speed
self.prices = prices.values.astype(np.float32)
self.initial_balance = initial_balance
self.transaction_cost = transaction_cost
self.n_steps = len(self.features)
self.n_features = self.features.shape[1]
# ── Define action space: 3 discrete actions ───────────────────────────
self.action_space = spaces.Discrete(3)
# ── Define observation space ──────────────────────────────────────────
# Observation = [market_features (n_features), portfolio_val, position, steps_left]
obs_dim = self.n_features + 3
self.observation_space = spaces.Box(
low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32
)
# ── Internal state (initialised in reset) ─────────────────────────────
self.current_step = 0
self.balance = initial_balance # Cash on hand
self.shares_held = 0.0 # Number of shares currently owned
self.portfolio_value = initial_balance # Total value = cash + shares * price
self.position = 0 # 0 = flat, 1 = long
self.trade_history = [] # Log of all trades
self._prev_portfolio = initial_balance # Initialize _prev_portfolio here
# ─────────────────────────────────────────────────────────────────────────
def _get_observation(self) -> np.ndarray:
"""
Construct the observation vector for the current timestep.
Combines raw market features with portfolio state information.
All values normalised so no single feature dominates.
Returns
-------
np.ndarray of shape (n_features + 3,)
"""
market_feats = self.features[self.current_step] # Shape: (n_features,)
# Normalise portfolio value relative to initial capital
norm_portfolio = self.portfolio_value / self.initial_balance
# Position: 0.0 = flat, 1.0 = fully long
norm_position = float(self.position)
# Fraction of episode remaining — helps agent reason about time horizon
steps_remaining = (self.n_steps - self.current_step) / self.n_steps
return np.concatenate([
market_feats,
[norm_portfolio, norm_position, steps_remaining]
]).astype(np.float32)
# ─────────────────────────────────────────────────────────────────────────
def reset(
self,
seed: Optional[int] = None,
options: Optional[dict] = None
) -> Tuple[np.ndarray, dict]:
"""
Reset the environment to the beginning of the episode.
Called at the start of every new training episode.
Returns
-------
observation : np.ndarray
Initial observation.
info : dict
Diagnostic information (empty dict for compatibility).
"""
super().reset(seed=seed)
self.current_step = 0
self.balance = self.initial_balance
self.shares_held = 0.0
self.portfolio_value = self.initial_balance
self.position = 0
self.trade_history = []
self._prev_portfolio = self.initial_balance # Track previous value for reward calc
return self._get_observation(), {}
# ─────────────────────────────────────────────────────────────────────────
def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, dict]:
"""
Execute one timestep (one trading day) in the environment.
Applies the chosen action (buy/hold/sell), updates portfolio value,
computes reward, and advances the clock by one day.
Parameters
----------
action : int
0 = Hold, 1 = Buy, 2 = Sell.
Returns
-------
observation : np.ndarray
reward : float
terminated : bool — True if episode ends naturally (end of data)
truncated : bool — True if episode cut short (not used here)
info : dict — Diagnostic data for logging
"""
current_price = self.prices[self.current_step]
trade_cost = 0.0 # Will be non-zero only when trading
# ── Execute action ─────────────────────────────────────────────────────
if action == self.BUY and self.position == 0:
# Invest all available cash into shares
cost_basis = self.balance * (1 - self.transaction_cost) # Deduct trading fee
self.shares_held = cost_basis / current_price
trade_cost = self.balance * self.transaction_cost
self.balance = 0.0
self.position = 1
self.trade_history.append(('BUY', self.current_step, current_price))
elif action == self.SELL and self.position == 1:
# Liquidate all shares to cash
proceeds = self.shares_held * current_price * (1 - self.transaction_cost)
trade_cost = self.shares_held * current_price * self.transaction_cost
self.balance = proceeds
self.shares_held = 0.0
self.position = 0
self.trade_history.append(('SELL', self.current_step, current_price))
# ── Update portfolio value ─────────────────────────────────────────────
self.portfolio_value = self.balance + self.shares_held * current_price
# ── Compute reward ─────────────────────────────────────────────────────
# Log return of portfolio value — proportional P&L, handles compounding
if self._prev_portfolio > 0:
reward = np.log(self.portfolio_value / self._prev_portfolio)
else:
reward = 0.0
# Small penalty for holding a losing position (encourages active management)
# (optional: uncomment to enable)
# if self.position == 1 and reward < 0:
# reward *= 1.1 # Amplify pain of holding through losses
self._prev_portfolio = self.portfolio_value
# ── Advance time ───────────────────────────────────────────────────────
self.current_step += 1
terminated = self.current_step >= self.n_steps - 1 # End of episode
truncated = False
info = {
'portfolio_value': self.portfolio_value,
'position': self.position,
'price': current_price,
'trade_cost': trade_cost,
'step': self.current_step
}
return self._get_observation(), reward, terminated, truncated, info
# ─────────────────────────────────────────────────────────────────────────
def render(self, mode: str = 'human') -> None:
"""Print a one-line status update to stdout."""
print(
f"Step {self.current_step:4d} | "
f"Price ${self.prices[self.current_step]:.2f} | "
f"Portfolio ${self.portfolio_value:,.2f} | "
f"Position: {'LONG' if self.position else 'FLAT'}"
)
# ── Sanity check: step through environment with random actions ─────────────
env_test = TradingEnvironment(train_features, train_prices)
obs, info = env_test.reset()
print(f"Observation shape : {obs.shape}")
print(f"Action space : {env_test.action_space}")
print(f"Obs space : {env_test.observation_space}")
print()
# Run 5 random steps
for i in range(5):
action = env_test.action_space.sample()
obs, reward, done, trunc, info = env_test.step(action)
print(f" Step {i+1}: action={action}, reward={reward:.6f}, portfolio=${info['portfolio_value']:,.2f}")Observation shape : (10,) Action space : Discrete(3) Obs space : Box(-inf, inf, (10,), float32) Step 1: action=0, reward=0.000000, portfolio=$10,000.00 Step 2: action=0, reward=0.000000, portfolio=$10,000.00 Step 3: action=1, reward=-0.001000, portfolio=$9,990.00 Step 4: action=0, reward=-0.021770, portfolio=$9,774.87 Step 5: action=0, reward=-0.004707, portfolio=$9,728.96
4.3. Actor-Critic Network Implementation (ActorCriticNetwork)
This class implements the actor-critic neural network architecture, which forms the core of the PPO agent. It leverages a shared backbone for feature extraction, followed by separate heads for the actor (policy) and critic (value function).
Network Components:
- Shared Backbone: Two fully connected (
nn.Linear) layers withTanhactivation. This shared component learns a common, compressed representation of the market state. - Actor Head: A linear layer outputting unnormalized logits for each action. These logits are then passed to a
Categoricaldistribution to generate action probabilities. - Critic Head: A linear layer outputting a single scalar value, representing the critic's estimate of the state's value $V(s)$.
Weight Initialization (_init_weights): Orthogonal initialization is used for all linear layers, a practice known to improve stability in policy gradient methods. The actor's output layer has a smaller gain (0.01) to encourage higher initial policy entropy, facilitating broader exploration early in training.
forward() method: Takes an observation tensor as input and returns a Categorical distribution over actions (from the actor) and the state-value estimate (from the critic).
get_action_and_value() method: A convenience function for retrieving actions, log-probabilities, policy entropy, and state values, used during both rollout collection and policy updates.
The demonstration block instantiates the ActorCriticNetwork with appropriate dimensions and performs a dummy forward pass to illustrate its outputs and verify its structure. The total number of trainable parameters is also displayed.
4. Actor-Critic Neural Network Architecture
4.1. Actor-Critic Paradigm
PPO employs an actor-critic architecture, a neural network design comprising two interconnected components that often share a common feature extraction backbone:
┌─────────────────────┐
Observation │ Shared Backbone │
───────────► │ (Feature Extractor)│
└─────────┬───────────┘
│
┌────────────┴─────────────┐
▼ ▼
┌─────────────┐ ┌─────────────────┐
│ Actor │ │ Critic │
│ (Policy) │ │ (Value function)│
│ π(a|s) │ │ V(s) │
└─────────────┘ └─────────────────┘
│ │
Action probabilities Scalar state value
(Optimal actions) (Expected future reward)
- The Actor (policy network) generates a probability distribution over possible actions. Actions are sampled from this distribution.
- The Critic (value network) estimates the expected cumulative future reward (value) for a given state. This estimate informs the actor's updates by quantifying the desirability of different states.
- The critic's state-value estimates are utilized to compute advantage functions, which measure how much better an action was than expected. These advantage functions guide the actor's policy improvements.
4.2. Network Design
The ActorCriticNetwork integrates the following design choices:
- Hidden Layers: Two fully connected layers, each with 256 units. This configuration balances representational capacity with computational efficiency.
- Activation Function: The
Tanhactivation function is employed. Tanh constrains activations within the range [-1, 1], which is often beneficial in policy gradient methods, particularly when observations are normalized. - Actor Output: The actor head produces unnormalized logits for each action. A
Categoricaldistribution subsequently applies a softmax transformation to yield action probabilities. - Critic Output: The critic head outputs a single scalar value representing the state-value estimate. No activation function is applied, allowing for an unbounded output range.
- Weight Initialization: Orthogonal initialization is applied to network weights. This technique helps preserve variance across layers during training, a practice recommended for stable policy gradient optimization. The actor's output layer utilizes a smaller gain (0.01) to encourage higher initial policy entropy, promoting effective exploration in early training phases.
class ActorCriticNetwork(nn.Module):
"""
Shared-backbone actor-critic neural network for PPO.
Architecture
------------
Input (obs) → Shared Backbone (2 × FC + Tanh)
→ Actor Head → action logits → Categorical distribution
→ Critic Head → scalar state value V(s)
Sharing the backbone means both actor and critic learn a common
representation of the market, which improves sample efficiency.
Parameters
----------
obs_dim : int
Dimension of the observation vector.
n_actions : int
Number of discrete actions (3 for buy/hold/sell).
hidden_dim : int
Width of each hidden layer (default 256).
Example
-------
>>> net = ActorCriticNetwork(obs_dim=9, n_actions=3)
>>> dist, value = net(torch.randn(1, 9))
>>> action = dist.sample()
>>> log_prob = dist.log_prob(action)
"""
def __init__(self, obs_dim: int, n_actions: int, hidden_dim: int = 256):
super(ActorCriticNetwork, self).__init__()
# ── Shared backbone: learns a rich representation of market state ──────
self.backbone = nn.Sequential(
nn.Linear(obs_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh()
)
# ── Actor head: outputs unnormalised scores (logits) for each action ───
# We use logits (not probabilities) because Categorical handles the softmax
self.actor_head = nn.Linear(hidden_dim, n_actions)
# ── Critic head: scalar state-value estimate V(s) ──────────────────────
self.critic_head = nn.Linear(hidden_dim, 1)
# ── Weight initialisation: orthogonal init is standard for RL ─────────
self._init_weights()
def _init_weights(self) -> None:
"""
Initialise weights with orthogonal initialisation.
Orthogonal initialisation preserves variance through layers and
is recommended for policy gradient methods (Schulman et al., 2015).
Smaller gain for actor output ensures initial policies are near-uniform
(high entropy = good exploration at the start).
"""
for module in self.backbone.modules():
if isinstance(module, nn.Linear):
nn.init.orthogonal_(module.weight, gain=np.sqrt(2))
nn.init.constant_(module.bias, 0.0)
# Actor: small gain → near-uniform initial distribution → maximum exploration
nn.init.orthogonal_(self.actor_head.weight, gain=0.01)
nn.init.constant_(self.actor_head.bias, 0.0)
# Critic: standard gain for value estimation
nn.init.orthogonal_(self.critic_head.weight, gain=1.0)
nn.init.constant_(self.critic_head.bias, 0.0)
def forward(self, obs: torch.Tensor) -> Tuple[Categorical, torch.Tensor]:
"""
Forward pass: given observations, return action distribution and value.
Parameters
----------
obs : torch.Tensor of shape (batch_size, obs_dim)
Batch of observations.
Returns
-------
dist : torch.distributions.Categorical
Probability distribution over actions. Sample with dist.sample().
value : torch.Tensor of shape (batch_size, 1)
Critic's estimate of V(s) for each observation.
"""
# Extract shared features from the market state
features = self.backbone(obs)
# Actor: logits → Categorical distribution
action_logits = self.actor_head(features)
dist = Categorical(logits=action_logits) # Handles softmax internally
# Critic: scalar value estimate
value = self.critic_head(features)
return dist, value
def get_action_and_value(self, obs: torch.Tensor, action: Optional[torch.Tensor] = None):
"""
Convenience method used inside the PPO update loop.
If action is provided (training), computes log-prob and entropy of that action.
If not provided (acting), samples a new action.
Parameters
----------
obs : torch.Tensor
action : torch.Tensor or None
Returns
-------
action : torch.Tensor
log_prob : torch.Tensor — log π(action | obs)
entropy : torch.Tensor — H[π(·|obs)] for entropy regularisation
value : torch.Tensor — V(obs)
"""
dist, value = self.forward(obs)
if action is None:
action = dist.sample() # Sample during rollout collection
log_prob = dist.log_prob(action) # log probability of the chosen action
entropy = dist.entropy() # Entropy encourages exploration
return action, log_prob, entropy, value
# ── Demonstration ─────────────────────────────────────────────────────────────
obs_dim = env_test.observation_space.shape[0]
n_actions = env_test.action_space.n
net = ActorCriticNetwork(obs_dim=obs_dim, n_actions=n_actions).to(DEVICE)
# Forward pass with a dummy batch of 4 observations
dummy_obs = torch.randn(4, obs_dim).to(DEVICE)
dist, value = net(dummy_obs)
print(f"Network architecture:\n{net}")
print(f"\nDummy forward pass:")
print(f" Sampled actions : {dist.sample()}")
print(f" Action probs : {dist.probs.detach().cpu().numpy().round(3)}")
print(f" Value estimates : {value.detach().cpu().numpy().flatten().round(4)}")
print(f"\nTotal parameters : {sum(p.numel() for p in net.parameters()):,}")Network architecture:
ActorCriticNetwork(
(backbone): Sequential(
(0): Linear(in_features=10, out_features=256, bias=True)
(1): Tanh()
(2): Linear(in_features=256, out_features=256, bias=True)
(3): Tanh()
)
(actor_head): Linear(in_features=256, out_features=3, bias=True)
(critic_head): Linear(in_features=256, out_features=1, bias=True)
)
Dummy forward pass:
Sampled actions : tensor([0, 1, 1, 2])
Action probs : [[0.334 0.334 0.333]
[0.332 0.334 0.334]
[0.335 0.332 0.332]
[0.333 0.334 0.332]]
Value estimates : [ 0.1244 -0.403 0.1295 0.2147]
Total parameters : 69,636
5.3. PPO Rollout Buffer Implementation (PPORolloutBuffer)
This class provides a specialized memory buffer for storing experience during PPO rollouts. As an on-policy algorithm, PPO requires that experiences are collected with the current policy and then discarded after policy updates. This buffer efficiently manages this process by pre-allocating memory and handling the computation of Generalized Advantage Estimates (GAE).
Key Attributes:
rollout_steps: The fixed size of the buffer, determining how many steps of experience are collected per rollout.gamma: The discount factor for future rewards.gae_lambda: The GAE smoothing parameter, balancing bias and variance in advantage estimation.- Pre-allocated
torch.Tensors forobs,actions,rewards,dones,values,log_probs,advantages, andreturnsto minimize memory allocation overhead.
store() method: Appends a single transition (observation, action, reward, done flag, value estimate, and action log-probability) to the buffer.
compute_advantages() method: Calculates GAE advantages and lambda-returns. This is performed after a full rollout by iterating backward through the stored experiences, using the provided last_value (bootstrapped value from the next state) and last_done (whether the episode terminated). Advantages are subsequently normalized for training stability.
get_batches() method: Yields shuffled mini-batches of data from the collected rollout for use in policy optimization. This allows for multiple passes (epochs) over the same data.
reset() method: Resets the buffer's internal pointer, preparing it for a new rollout collection.
The demonstration block confirms the successful definition of the PPORolloutBuffer and provides an estimate of its memory footprint for a typical rollout_steps configuration.
5. PPO Memory Buffer (Rollout Storage)
5.1. On-Policy Experience Management
PPO operates as an on-policy reinforcement learning algorithm. This implies a specific interaction pattern:
- A batch of environmental interactions (a "rollout") is collected using the current policy.
- This collected experience is then used to update the policy multiple times through optimization epochs.
- After policy updates, the collected experience is discarded, and a new rollout is generated with the updated policy.
The PPORolloutBuffer facilitates this process by storing the transitions from a single rollout. Each stored transition includes:
| Data Component | Description |
|---|---|
obs | Observed state at timestep t. |
action | Action taken at timestep t. |
reward | Reward received after action a_t. |
done | Boolean indicating episode termination. |
value | Critic's value estimate $V(s_t)$. |
log_prob | Log-probability of action $a_t$ under $\pi_{\text{old}}$. |
5.2. Generalized Advantage Estimation (GAE)
Upon completion of a rollout, Generalized Advantage Estimates (GAE) are computed for each timestep. GAE effectively combines Monte Carlo returns with bootstrapped value estimates, balancing the bias-variance trade-off in advantage estimation (Schulman et al., 2016).
GAE Formula:
$\delta_t = r_t + \gamma \cdot V(s_{t+1}) \cdot (1 - \text{done}_t) - V(s_t)$ (Temporal Difference Error)
$A_t = \sum_{l=0}^{\infty} (\gamma\lambda)^l \cdot \delta_{t+l}$ (GAE)
- $\lambda = 1$: Corresponds to pure Monte Carlo estimation (high variance, low bias).
- $\lambda = 0$: Corresponds to pure TD(0) estimation (low variance, high bias).
- $\lambda = 0.95$: A common empirical choice that offers a good balance between bias and variance.
The computed advantages are subsequently normalized to improve training stability.
class PPORolloutBuffer:
"""
Storage buffer for one PPO rollout (on-policy experience collection).
Stores transitions and computes Generalised Advantage Estimates (GAE)
at the end of each rollout before the policy update.
GAE Formula (Schulman et al., 2016)
------------------------------------
δ_t = r_t + γ * V(s_{t+1}) * (1 - done_t) - V(s_t) [TD error]
A_t = Σ_{l=0}^{∞} (γλ)^l * δ_{t+l} [GAE]
λ=1 → pure Monte Carlo (high variance, low bias)
λ=0 → pure TD(0) (low variance, high bias)
λ=0.95 → standard trade-off in practice
Parameters
----------
rollout_steps : int
Number of timesteps to collect per rollout (e.g., 2048).
obs_dim : int
Dimension of the observation vector.
gamma : float
Discount factor γ — how much to value future rewards (default 0.99).
gae_lambda : float
GAE smoothing parameter λ (default 0.95).
device : torch.device
Compute device for tensors.
Example
-------
>>> buf = PPORolloutBuffer(rollout_steps=512, obs_dim=9)
>>> buf.store(obs, action, reward, done, value, log_prob)
>>> buf.compute_advantages(last_value, last_done)
>>> batches = buf.get_batches(batch_size=64)
"""
def __init__(
self,
rollout_steps: int,
obs_dim: int,
gamma: float = 0.99,
gae_lambda: float = 0.95,
device: torch.device = DEVICE
):
self.rollout_steps = rollout_steps
self.gamma = gamma
self.gae_lambda = gae_lambda
self.device = device
self.ptr = 0 # Pointer to current storage position
# ── Pre-allocate tensors for efficiency ───────────────────────────────
# Pre-allocation avoids repeated memory allocation during rollout
self.obs = torch.zeros(rollout_steps, obs_dim, device=device)
self.actions = torch.zeros(rollout_steps, device=device, dtype=torch.long)
self.rewards = torch.zeros(rollout_steps, device=device)
self.dones = torch.zeros(rollout_steps, device=device)
self.values = torch.zeros(rollout_steps, device=device)
self.log_probs = torch.zeros(rollout_steps, device=device)
self.advantages = torch.zeros(rollout_steps, device=device)
self.returns = torch.zeros(rollout_steps, device=device)
def store(
self,
obs: np.ndarray,
action: int,
reward: float,
done: bool,
value: float,
log_prob: float
) -> None:
"""
Store a single (s, a, r, done, V, log π) transition in the buffer.
Parameters
----------
obs : np.ndarray — current observation
action : int — action taken
reward : float — reward received
done : bool — whether the episode ended
value : float — critic's value estimate V(s)
log_prob : float — log probability of the action log π(a|s)
"""
idx = self.ptr # Current write position
self.obs[idx] = torch.tensor(obs, dtype=torch.float32, device=self.device)
self.actions[idx] = torch.tensor(action, dtype=torch.long, device=self.device)
self.rewards[idx] = torch.tensor(reward, dtype=torch.float32, device=self.device)
self.dones[idx] = torch.tensor(done, dtype=torch.float32, device=self.device)
self.values[idx] = torch.tensor(value, dtype=torch.float32, device=self.device)
self.log_probs[idx] = torch.tensor(log_prob, dtype=torch.float32, device=self.device)
self.ptr += 1
def compute_advantages(self, last_value: float, last_done: bool) -> None:
"""
Compute GAE advantages and lambda-returns (targets for the critic).
This must be called after the rollout is complete but before sampling batches.
Works backwards through the buffer (standard GAE implementation).
Parameters
----------
last_value : float — V(s_T+1), the bootstrap value from the next state
last_done : bool — whether the last step ended the episode
"""
last_gae = 0.0 # GAE accumulator, initialised to zero
last_val = last_value
last_done_flag = float(last_done)
# ── Backward pass through the buffer ──────────────────────────────────
for t in reversed(range(self.rollout_steps)):
# Mask future value if episode ended at this step
next_non_terminal = 1.0 - (self.dones[t].item() if t < self.rollout_steps - 1 else last_done_flag)
next_value = (self.values[t + 1].item() if t < self.rollout_steps - 1 else last_val)
# TD error δ_t = r_t + γ * V(s_{t+1}) * (1 - done) - V(s_t)
delta = self.rewards[t] + self.gamma * next_value * next_non_terminal - self.values[t]
# Accumulate GAE: A_t = δ_t + γλ * A_{t+1}
last_gae = delta + self.gamma * self.gae_lambda * next_non_terminal * last_gae
self.advantages[t] = last_gae
# Lambda-returns = advantage + value (used as critic target)
self.returns = self.advantages + self.values
# ── Normalise advantages ───────────────────────────────────────────────
# Zero-mean, unit-variance advantages reduce variance in policy gradient
adv_mean = self.advantages.mean()
adv_std = self.advantages.std() + 1e-8
self.advantages = (self.advantages - adv_mean) / adv_std
def get_batches(self, batch_size: int):
"""
Yield random mini-batches for PPO update epochs.
Shuffles indices and yields non-overlapping mini-batches.
Multiple calls to this (for different epochs) all use the same
stored rollout data.
Parameters
----------
batch_size : int
Size of each mini-batch.
Yields
------
Tuple of (obs, actions, log_probs, advantages, returns) tensors.
"""
indices = torch.randperm(self.rollout_steps, device=self.device) # Shuffle
for start in range(0, self.rollout_steps, batch_size):
batch_idx = indices[start: start + batch_size]
yield (
self.obs[batch_idx],
self.actions[batch_idx],
self.log_probs[batch_idx],
self.advantages[batch_idx],
self.returns[batch_idx]
)
def reset(self) -> None:
"""Reset the write pointer for the next rollout."""
self.ptr = 0
print("PPORolloutBuffer defined successfully.")
print()
# Quick size check
buf_test = PPORolloutBuffer(rollout_steps=512, obs_dim=obs_dim)
mem_bytes = sum(t.element_size() * t.nelement() for t in [
buf_test.obs, buf_test.actions, buf_test.rewards,
buf_test.dones, buf_test.values, buf_test.log_probs
])
print(f"Buffer memory footprint (512 steps): {mem_bytes / 1024:.1f} KB")PPORolloutBuffer defined successfully. Buffer memory footprint (512 steps): 32.0 KB
6.4. PPO Loss Function Implementation (compute_ppo_loss)
This function calculates the composite PPO loss for a given mini-batch of experience. It integrates three primary loss components: the clipped surrogate objective for the actor, the Mean Squared Error (MSE) for the critic's value function, and an entropy bonus for exploration.
Inputs:
network: TheActorCriticNetworkbeing optimized.obs,actions,old_log_probs,advantages,returns: Data from thePPORolloutBufferfor the current mini-batch.clip_eps: The PPO clipping parameter ($\epsilon$).vf_coef: Coefficient for the value function loss.entropy_coef: Coefficient for the entropy bonus.
Calculations:
- New Policy Probabilities: Retrieves current log-probabilities and value estimates from the network for the given observations and actions.
- Probability Ratio: Computes the ratio of new-to-old policy probabilities ($r_t(\theta)$).
- Approximate KL Divergence: Calculated for diagnostic purposes to monitor policy divergence (not part of the loss itself).
- Clipped Surrogate Loss (Actor Loss): Implements the core PPO objective, taking the minimum of the unclipped and clipped advantage-weighted ratios. This ensures policy updates are conservative.
- Value Function Loss (Critic Loss): Calculates the MSE between the critic's current value estimates and the GAE lambda-returns.
- Entropy Bonus: Computes the entropy of the current policy, which is added to the loss (with a negative sign, as it's typically maximized).
Total Loss: The sum of the (negated) clipped surrogate loss, weighted value loss, and weighted entropy bonus.
Metrics: A dictionary of diagnostic metrics (total_loss, policy_loss, value_loss, entropy, approx_kl, clip_fraction) is returned for monitoring training progress.
The print statements below confirm the successful definition of the compute_ppo_loss function and reiterate the role of its key hyperparameters.
6. PPO Loss Functions
6.1. The PPO Objective
Proximal Policy Optimization (PPO), introduced by Schulman et al. (2017), refines policy gradient methods by mitigating the risk of excessively large policy updates. Unconstrained updates can lead to detrimental changes in policy, destabilizing training.
PPO addresses this by introducing a clipping mechanism that constrains the magnitude of policy alterations during each optimization step. This mechanism ensures that the new policy does not diverge too significantly from the policy that generated the experience.
6.2. PPO Clipped Surrogate Objective
Let $r_t(\theta) = \frac{\pi_\theta(a_t|s_t)}{\pi_{\theta_{\text{old}}}(a_t|s_t)}$ denote the probability ratio, which quantifies the change in action probability between the new policy $\pi_\theta$ and the old policy $\pi_{\theta_{\text{old}}}$.
The clipped surrogate objective function is defined as:
$\mathcal{L}^{\text{CLIP}}(\theta) = \mathbb{E}_t \left[ \min\left( r_t(\theta) \hat{A}_t,\ \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) \hat{A}_t \right) \right]$
- If $\hat{A}_t > 0$ (indicating a favorable action), the objective encourages increasing the action's probability, but only up to a factor of $1+\epsilon$.
- If $\hat{A}_t < 0$ (indicating an unfavorable action), the objective discourages the action, but limits the decrease in probability to a factor of $1-\epsilon$.
- The parameter $\epsilon$ typically defaults to $0.2$.
6.3. Total PPO Loss Function
The comprehensive PPO loss function combines the clipped surrogate objective with auxiliary terms:
$\mathcal{L}(\theta) = -\mathcal{L}^{\text{CLIP}} + c_1 \cdot \mathcal{L}^{\text{VF}} - c_2 \cdot H[\pi_\theta]$
| Term | Purpose |
|---|---|
| $-\mathcal{L}^{\text{CLIP}}$ | Actor Loss: Maximizes the clipped advantage-weighted probability of actions. |
| $c_1 \cdot \mathcal{L}^{\text{VF}}$ | Critic Loss: Mean Squared Error (MSE) between the predicted state values and the target returns, weighted by $c_1$ (typically 0.5). |
| $-c_2 \cdot H[\pi_\theta]$ | Entropy Bonus: Encourages exploration by penalizing deterministic policies (maximizes policy entropy), weighted by $c_2$ (typically 0.01). |
This composite loss function drives policy improvement while maintaining training stability and promoting exploration.
def compute_ppo_loss(
network: ActorCriticNetwork,
obs: torch.Tensor,
actions: torch.Tensor,
old_log_probs: torch.Tensor,
advantages: torch.Tensor,
returns: torch.Tensor,
clip_eps: float = 0.2,
vf_coef: float = 0.5,
entropy_coef: float = 0.01
) -> Tuple[torch.Tensor, dict]:
"""
Compute the total PPO loss for one mini-batch update.
This function implements the full clipped PPO objective:
L_total = -L_clip + vf_coef * L_value - entropy_coef * H[π]
Parameters
----------
network : ActorCriticNetwork — the network being updated
obs : Tensor (B, obs_dim) — observations
actions : Tensor (B,) — actions taken during rollout
old_log_probs : Tensor (B,) — log π_old(a|s), computed during rollout
advantages : Tensor (B,) — GAE advantages (normalised)
returns : Tensor (B,) — lambda-return targets for critic
clip_eps : float — PPO clipping range ε (default 0.2)
vf_coef : float — value function loss coefficient c₁ (default 0.5)
entropy_coef : float — entropy bonus coefficient c₂ (default 0.01)
Returns
-------
total_loss : torch.Tensor — scalar loss to backpropagate
metrics : dict — diagnostic breakdown for logging
Example
-------
>>> loss, metrics = compute_ppo_loss(net, obs_batch, act_batch, ...)
>>> loss.backward()
"""
# ── Get NEW log probs and values from the network (after update so far) ───
_, new_log_probs, entropy, new_values = network.get_action_and_value(obs, actions)
new_values = new_values.squeeze(-1) # Shape: (B,) from (B, 1)
# ── Probability ratio r_t(θ) = π_new(a|s) / π_old(a|s) ─────────────────
# Computed in log space for numerical stability, then exponentiated
log_ratio = new_log_probs - old_log_probs # log(new/old) = log(new) - log(old)
ratio = torch.exp(log_ratio)
# ── Approximate KL divergence (used for diagnostics, not directly in loss) ─
# If KL > 0.02, the update is too large and training may be unstable
approx_kl = ((ratio - 1) - log_ratio).mean().item()
# ── Clipped surrogate loss ─────────────────────────────────────────────────
# Unclipped: ratio * advantage (standard policy gradient)
surr1 = ratio * advantages
# Clipped: cap the ratio in [1-ε, 1+ε] to prevent too-large updates
surr2 = torch.clamp(ratio, 1 - clip_eps, 1 + clip_eps) * advantages
# Take the minimum (pessimistic bound) — this is the key PPO innovation
# The min prevents the loss from benefiting from going beyond the clip range
policy_loss = -torch.min(surr1, surr2).mean() # Negative because we maximise
# ── Value function loss (MSE between predicted and target returns) ─────────
value_loss = nn.functional.mse_loss(new_values, returns)
# ── Entropy bonus: H[π] = -Σ π(a) log π(a) ───────────────────────────────
# Maximising entropy (minimising -entropy) prevents premature convergence
entropy_loss = -entropy.mean() # Negative to turn maximisation into minimisation
# ── Total loss ─────────────────────────────────────────────────────────────
total_loss = policy_loss + vf_coef * value_loss + entropy_coef * entropy_loss
# ── Clip fraction: what % of samples exceeded the clip boundary? ───────────
clip_fraction = ((ratio - 1).abs() > clip_eps).float().mean().item()
metrics = {
'total_loss': total_loss.item(),
'policy_loss': policy_loss.item(),
'value_loss': value_loss.item(),
'entropy': -entropy_loss.item(), # Return positive entropy for readability
'approx_kl': approx_kl,
'clip_fraction': clip_fraction
}
return total_loss, metrics
print(" PPO loss function defined.")
print()
print("Key hyperparameters explained:")
print(f" clip_eps = 0.2 → allows ratio r_t ∈ [0.8, 1.2] before clipping")
print(f" vf_coef = 0.5 → weight of value loss relative to policy loss")
print(f" entropy_coef = 0.01 → encourages ~1% of loss from exploration bonus")PPO loss function defined. Key hyperparameters explained: clip_eps = 0.2 → allows ratio r_t ∈ [0.8, 1.2] before clipping vf_coef = 0.5 → weight of value loss relative to policy loss entropy_coef = 0.01 → encourages ~1% of loss from exploration bonus
7.3. PPO Agent Class (PPOAgent)
This class orchestrates the entire PPO algorithm, integrating the ActorCriticNetwork, PPORolloutBuffer, and the compute_ppo_loss function. It manages the full lifecycle of the agent, including interaction with the environment, experience collection, and policy optimization.
Initialization:
- Sets up the
ActorCriticNetworkandAdamoptimizer. - Initializes the
PPORolloutBufferwith specified parameters. - Stores key PPO hyperparameters such as
rollout_steps,n_epochs,batch_size,clip_eps,vf_coef,entropy_coef, andmax_grad_norm.
act() method:
- Samples an action from the current policy given an observation. This method is used during rollout collection and evaluation.
- It operates in
torch.no_grad()mode to prevent gradient accumulation during inference.
collect_rollout() method:
- Interacts with the
TradingEnvironmentforrollout_stepstimesteps. - Stores observed states, actions, rewards, termination flags, value estimates, and action log-probabilities in the
PPORolloutBuffer. - Handles environment resets upon episode termination.
- Computes GAE advantages at the end of the rollout by bootstrapping the value of the last state.
- Returns episode rewards collected during the rollout.
update() method:
- Performs
n_epochsof gradient descent over the collected rollout data. - Iterates through mini-batches obtained from the
PPORolloutBuffer. - Calls
compute_ppo_lossto calculate the loss for each mini-batch. - Applies
optimizer.zero_grad(),loss.backward(),nn.utils.clip_grad_norm_, andoptimizer.step()for gradient-based optimization. - Includes an early stopping mechanism if the approximate KL divergence becomes too large, preventing destabilizing policy updates.
- Aggregates and returns mean metrics across all mini-batches for the update cycle.
save() and load() methods:
- Utility functions for serializing and deserializing the agent's network weights and optimizer state, enabling checkpointing and model persistence.
This block instantiates the PPOAgent with predefined hyperparameters, providing a ready-to-train agent for the trading environment. It also prints key agent configurations for verification.
7. PPO Agent Implementation
7.1. Agent Architecture
The PPOAgent class encapsulates the core components of the PPO algorithm, providing a unified interface for reinforcement learning tasks. This class integrates the ActorCriticNetwork, PPORolloutBuffer, and the PPO loss computation logic. Its primary functionalities include:
- Action Selection: Determining an action based on an observed state using the current policy.
- Rollout Collection: Interacting with the environment to gather a batch of experience and populate the rollout buffer.
- Policy Update: Performing multiple epochs of mini-batch gradient descent to refine the policy and value networks.
7.2. Key Hyperparameters
Critical hyperparameters influencing PPO agent performance are detailed below:
| Hyperparameter | Typical Value | Effect |
|---|---|---|
rollout_steps | 1024–4096 | Defines the number of environment steps collected per rollout. Larger values can yield more stable gradients but increase the time per update cycle. |
n_epochs | 4–10 | Specifies the number of optimization passes over the collected rollout data. More epochs enhance sample efficiency but risk overfitting the batch. |
batch_size | 64–512 | Determines the size of mini-batches used for gradient updates during policy optimization. |
lr | 3e-4 | Learning rate for the Adam optimizer. Strategic scheduling (e.g., cosine annealing) can benefit long training durations. |
max_grad_norm | 0.5 | Gradient clipping threshold, preventing exploding gradients by scaling gradients to a maximum norm. |
gamma | 0.99 | Discount factor, weighing the importance of future rewards. A value of 0.99 typically considers approximately 100 future timesteps. |
gae_lambda | 0.95 | GAE smoothing parameter, balancing the bias-variance trade-off in advantage estimation. |
clip_eps | 0.2 | PPO clipping parameter $\epsilon$, defining the acceptable range for policy ratio deviation. |
vf_coef | 0.5 | Coefficient $c_1$ for the value function loss, balancing its contribution to the total loss. |
entropy_coef | 0.01 | Coefficient $c_2$ for the entropy bonus, encouraging exploration. An appropriate value ensures approximately 1% of the total loss originates from this term. |
Careful tuning of these hyperparameters is essential for achieving robust and effective agent performance.
class PPOAgent:
"""
Full PPO agent for the trading environment.
Wraps the actor-critic network, rollout buffer, and PPO update logic
into a single interface. Handles rollout collection, advantage estimation,
and multi-epoch mini-batch updates.
Parameters
----------
obs_dim : int — observation dimension
n_actions : int — number of discrete actions
hidden_dim : int — hidden layer width (default 256)
lr : float — Adam learning rate (default 3e-4)
gamma : float — discount factor (default 0.99)
gae_lambda : float — GAE lambda (default 0.95)
rollout_steps : int — steps per rollout (default 1024)
n_epochs : int — update epochs per rollout (default 10)
batch_size : int — mini-batch size (default 128)
clip_eps : float — PPO clipping ε (default 0.2)
vf_coef : float — value loss weight (default 0.5)
entropy_coef : float — entropy bonus weight (default 0.01)
max_grad_norm : float — gradient clipping norm (default 0.5)
device : torch.device
Example
-------
>>> agent = PPOAgent(obs_dim=9, n_actions=3)
>>> agent.collect_rollout(env, n_steps=1024)
>>> metrics = agent.update()
"""
def __init__(
self,
obs_dim: int,
n_actions: int,
hidden_dim: int = 256,
lr: float = 3e-4,
gamma: float = 0.99,
gae_lambda: float = 0.95,
rollout_steps: int = 1024,
n_epochs: int = 10,
batch_size: int = 128,
clip_eps: float = 0.2,
vf_coef: float = 0.5,
entropy_coef: float = 0.01,
max_grad_norm: float = 0.5,
device: torch.device = DEVICE
):
self.device = device
self.rollout_steps = rollout_steps
self.n_epochs = n_epochs
self.batch_size = batch_size
self.clip_eps = clip_eps
self.vf_coef = vf_coef
self.entropy_coef = entropy_coef
self.max_grad_norm = max_grad_norm
# ── Network ───────────────────────────────────────────────────────────
self.network = ActorCriticNetwork(obs_dim, n_actions, hidden_dim).to(device)
# ── Optimiser: Adam with default betas ─────────────────────────────────
self.optimiser = optim.Adam(self.network.parameters(), lr=lr, eps=1e-5)
# ── Rollout buffer ────────────────────────────────────────────────────
self.buffer = PPORolloutBuffer(
rollout_steps=rollout_steps,
obs_dim=obs_dim,
gamma=gamma,
gae_lambda=gae_lambda,
device=device
)
# ── Internal state ────────────────────────────────────────────────────
self._current_obs = None # Carry observation across rollout boundaries
self._current_done = False
self.training_metrics: List[dict] = [] # Track all update metrics
# ─────────────────────────────────────────────────────────────────────────
@torch.no_grad()
def act(self, obs: np.ndarray) -> Tuple[int, float, float]:
"""
Sample an action from the current policy (no gradient computation).
Used during rollout collection and evaluation.
Parameters
----------
obs : np.ndarray of shape (obs_dim,)
Returns
-------
action : int — sampled action
log_prob : float — log π(action | obs)
value : float — V(obs) from critic
"""
obs_tensor = torch.tensor(obs, dtype=torch.float32, device=self.device).unsqueeze(0)
action, log_prob, _, value = self.network.get_action_and_value(obs_tensor)
return action.item(), log_prob.item(), value.item()
# ─────────────────────────────────────────────────────────────────────────
def collect_rollout(self, env: TradingEnvironment) -> dict:
"""
Run the current policy in the environment for `rollout_steps` steps.
Stores all transitions in the buffer and computes GAE advantages
at the end. Handles environment resets transparently.
Parameters
----------
env : TradingEnvironment
Returns
-------
rollout_info : dict — episode returns and lengths observed this rollout
"""
self.buffer.reset()
# Initialise or reuse the current observation
if self._current_obs is None:
self._current_obs, _ = env.reset()
self._current_done = False
episode_rewards = [] # Track returns per completed episode
ep_reward = 0.0
for step in range(self.rollout_steps):
obs = self._current_obs
# ── Sample action from policy ─────────────────────────────────────
action, log_prob, value = self.act(obs)
# ── Step environment ──────────────────────────────────────────────
next_obs, reward, terminated, truncated, _ = env.step(action)
done = terminated or truncated
# ── Store transition ──────────────────────────────────────────────
self.buffer.store(obs, action, reward, done, value, log_prob)
ep_reward += reward
# ── Handle episode boundary ───────────────────────────────────────
if done:
episode_rewards.append(ep_reward)
ep_reward = 0.0
self._current_obs, _ = env.reset() # Start fresh episode
self._current_done = False
else:
self._current_obs = next_obs
self._current_done = done
# ── Bootstrap value of the last state for GAE computation ─────────────
with torch.no_grad():
_, _, last_value = self.act(self._current_obs)
self.buffer.compute_advantages(last_value, self._current_done)
return {'episode_rewards': episode_rewards}
# ─────────────────────────────────────────────────────────────────────────
def update(self) -> dict:
"""
Perform PPO update: multiple epochs of mini-batch gradient descent.
Runs `n_epochs` passes over the rollout buffer, sampling mini-batches
of size `batch_size` each time, and applying the clipped PPO loss.
Returns
-------
update_metrics : dict — mean losses, KL, clip fraction over this update
"""
all_metrics = []
for epoch in range(self.n_epochs):
for obs_b, act_b, logp_b, adv_b, ret_b in self.buffer.get_batches(self.batch_size):
# Compute PPO loss for this mini-batch
loss, metrics = compute_ppo_loss(
self.network, obs_b, act_b, logp_b, adv_b, ret_b,
clip_eps=self.clip_eps,
vf_coef=self.vf_coef,
entropy_coef=self.entropy_coef
)
# Gradient update
self.optimiser.zero_grad()
loss.backward()
# Clip gradients to prevent large destabilising steps
nn.utils.clip_grad_norm_(self.network.parameters(), self.max_grad_norm)
self.optimiser.step()
all_metrics.append(metrics)
# ── Early stopping if KL divergence is too large ─────────────────
# If the policy changed too much this epoch, stop updating
mean_kl = np.mean([m['approx_kl'] for m in all_metrics[-10:]]) # Last 10 batches
if mean_kl > 0.02:
break # Prevent policy from drifting too far
# ── Aggregate metrics across all mini-batch updates ───────────────────
agg = {key: np.mean([m[key] for m in all_metrics]) for key in all_metrics[0]}
self.training_metrics.append(agg)
return agg
def save(self, path: str) -> None:
"""Persist network weights and optimiser state to disk."""
torch.save({
'network_state': self.network.state_dict(),
'optimiser_state': self.optimiser.state_dict()
}, path)
print(f" Agent saved to {path}")
def load(self, path: str) -> None:
"""Load network weights and optimiser state from disk."""
checkpoint = torch.load(path, map_location=self.device)
self.network.load_state_dict(checkpoint['network_state'])
self.optimiser.load_state_dict(checkpoint['optimiser_state'])
print(f" Agent loaded from {path}")
# ── Instantiate agent ──────────────────────────────────────────────────────
agent = PPOAgent(
obs_dim = obs_dim,
n_actions = n_actions,
hidden_dim = 256,
lr = 3e-4,
gamma = 0.99,
gae_lambda = 0.95,
rollout_steps = 1024,
n_epochs = 10,
batch_size = 128,
clip_eps = 0.2,
vf_coef = 0.5,
entropy_coef = 0.01,
max_grad_norm = 0.5,
device = DEVICE
)
print(f"PPO Agent ready.")
print(f"Network params : {sum(p.numel() for p in agent.network.parameters()):,}")
print(f"Rollout steps : {agent.rollout_steps}")
print(f"Update epochs : {agent.n_epochs}")
print(f"Mini-batch size : {agent.batch_size}")PPO Agent ready. Network params : 69,636 Rollout steps : 1024 Update epochs : 10 Mini-batch size : 128
8.3. PPO Training Function (train_ppo_agent)
This function orchestrates the complete PPO training loop. It iteratively alternates between collecting experience from the environment and updating the agent's policy, continuing until a predefined total number of environment steps (total_steps) has been reached. The function tracks various training metrics and provides verbose logging to monitor the learning process.
Training Cycle:
- Rollout Collection: The agent interacts with the
TradingEnvironmentforagent.rollout_stepsand stores the experience in its internal buffer viaagent.collect_rollout(). - Policy Update: The agent's policy and value networks are refined using the collected experience. This involves
agent.n_epochspasses over the data, with mini-batch gradient descent applied throughagent.update().
Monitoring & Logging:
- The function maintains a
historydictionary to record cumulative steps, mean episode returns, and loss components (policy, value, entropy, approximate KL divergence, clip fraction) for each update cycle. - Progress is printed at specified
log_intervals, displaying key metrics in a tabular format.
Checkpointing: The save_best option enables the saving of the agent's weights and optimizer state to checkpoint_path whenever a new best rolling mean of episode returns is achieved. This ensures that the most performant agent configuration is preserved.
The code block first creates an instance of the TradingEnvironment using the train_features and train_prices. Then, it initiates the training process by calling train_ppo_agent, configuring the total steps and logging frequency. The output provides a real-time view of the agent's learning progression.
8. Training Procedure
8.1. PPO Training Cycle
PPO training involves an iterative cycle that alternates between experience collection and policy optimization:
┌──────────────────────────────────────────────────────────────┐
│ PPO Training Cycle │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ ROLLOUT │ ─────► │ UPDATE │ │
│ │ COLLECTION │ │ • n_epochs of mini-batches │ │
│ │ │ │ • Clipped PPO loss │ │
│ │ ~1024 steps │ │ • Gradient clip + Adam step │ │
│ └──────────────┘ ◄───── └──────────────────────────────┘ │
│ │ │
│ │ Repeat until total_steps budget exhausted │
└──────────────────────────────────────────────────────────────┘
During the Rollout Collection phase, the agent interacts with the environment for a predetermined number of steps (rollout_steps), gathering observations, actions, rewards, and policy-related metrics. This data is stored in the PPORolloutBuffer.
The Update phase then uses the collected data to optimize the actor-critic network. This involves multiple epochs of mini-batch gradient descent, applying the PPO loss function, and clipping gradients to ensure stability. The agent's policy is updated based on the advantages computed from the rollout.
This cycle repeats until a predefined total_steps budget is exhausted.
8.2. Training Progress Monitoring
Effective training requires monitoring key metrics to diagnose agent learning and identify potential issues:
| Metric | Healthy Range | Warning Sign |
|---|---|---|
approx_kl | 0.005 – 0.015 | Values exceeding 0.02 typically indicate an excessively large policy update, potentially leading to instability. |
clip_fraction | 0.05 – 0.25 | A clip fraction above 0.3 suggests the policy is changing too rapidly, potentially causing premature convergence or divergence. |
entropy | Gradually decreasing | A sudden or rapid decrease in entropy may indicate premature convergence to a suboptimal policy, reducing exploration. |
value_loss | Decreasing | Stagnant or increasing value loss implies the critic network is not effectively learning to estimate state values. |
episode_returns | Increasing | A flat or decreasing trend in episode returns suggests the policy is not improving or is stuck in a local optimum. |
def train_ppo_agent(
agent: PPOAgent,
env: TradingEnvironment,
total_steps: int = 200_000,
log_interval: int = 10,
save_best: bool = True,
checkpoint_path: str = '/tmp/ppo_trading_best.pt'
) -> Dict[str, List]:
"""
Run the full PPO training loop.
Alternates between rollout collection and policy update until the
total_steps budget is exhausted. Logs progress and saves the best
checkpoint based on average episode return.
Parameters
----------
agent : PPOAgent — the agent to train
env : TradingEnvironment — training environment
total_steps : int — total environment steps to train for (default 200k)
log_interval : int — print progress every N updates (default 10)
save_best : bool — save checkpoint when return improves (default True)
checkpoint_path : str — path to save best checkpoint
Returns
-------
history : dict — training history with keys:
'steps' : cumulative environment steps
'episode_returns' : mean episode return per update
'policy_loss' : mean policy loss per update
'value_loss' : mean value loss per update
'entropy' : mean policy entropy per update
'approx_kl' : mean KL divergence per update
Example
-------
>>> history = train_ppo_agent(agent, env, total_steps=100_000)
"""
# ── Training history tracking ─────────────────────────────────────────────
history = {
'steps': [],
'episode_returns': [],
'policy_loss': [],
'value_loss': [],
'entropy': [],
'approx_kl': [],
'clip_fraction': []
}
n_updates = total_steps // agent.rollout_steps # Number of update cycles
best_return = -np.inf
steps_done = 0
print(f"{'─' * 70}")
print(f" PPO Training | {total_steps:,} steps | {n_updates} updates")
print(f"{'─' * 70}")
print(f"{'Update':>7} {'Steps':>9} {'Ep.Return':>11} {'Policy L':>10} "
f"{'Value L':>9} {'Entropy':>9} {'KL':>8}")
print(f"{'─' * 70}")
recent_returns = deque(maxlen=50) # Rolling window for best-checkpoint logic
for update_idx in range(1, n_updates + 1):
# ── Phase 1: Collect rollout ──────────────────────────────────────────
rollout_info = agent.collect_rollout(env)
steps_done += agent.rollout_steps
# ── Phase 2: Update policy ────────────────────────────────────────────
update_metrics = agent.update()
# ── Track episode returns from this rollout ───────────────────────────
ep_returns = rollout_info['episode_rewards']
if ep_returns: # At least one episode completed
mean_return = np.mean(ep_returns)
recent_returns.extend(ep_returns)
else:
mean_return = np.nan # Mid-episode rollout
# ── Log to history ────────────────────────────────────────────────────
history['steps'].append(steps_done)
history['episode_returns'].append(mean_return)
history['policy_loss'].append(update_metrics['policy_loss'])
history['value_loss'].append(update_metrics['value_loss'])
history['entropy'].append(update_metrics['entropy'])
history['approx_kl'].append(update_metrics['approx_kl'])
history['clip_fraction'].append(update_metrics['clip_fraction'])
# ── Save best checkpoint ──────────────────────────────────────────────
if save_best and len(recent_returns) >= 5:
rolling_mean = np.mean(list(recent_returns))
if rolling_mean > best_return:
best_return = rolling_mean
agent.save(checkpoint_path)
# ── Print progress ────────────────────────────────────────────────────
if update_idx % log_interval == 0 or update_idx == 1:
ret_str = f"{mean_return:>11.4f}" if not np.isnan(mean_return) else f"{'N/A':>11}"
print(
f"{update_idx:>7d} "
f"{steps_done:>9,} "
f"{ret_str} "
f"{update_metrics['policy_loss']:>10.4f} "
f"{update_metrics['value_loss']:>9.4f} "
f"{update_metrics['entropy']:>9.4f} "
f"{update_metrics['approx_kl']:>8.5f}"
)
print(f"{'─' * 70}")
print(f"Training complete. Best rolling return: {best_return:.4f}")
return history
# ── Create training environment ────────────────────────────────────────────
train_env = TradingEnvironment(
features = train_features,
prices = train_prices,
initial_balance = 10_000.0,
transaction_cost = 0.001
)
# ── Run training (200k steps ≈ 1–2 min on CPU) ────────────────────────────
print("Starting training...")
history = train_ppo_agent(
agent = agent,
env = train_env,
total_steps = 200_000,
log_interval = 20,
save_best = True,
checkpoint_path = '/tmp/ppo_trading_best.pt'
)Starting training...
──────────────────────────────────────────────────────────────────────
PPO Training | 200,000 steps | 195 updates
──────────────────────────────────────────────────────────────────────
Update Steps Ep.Return Policy L Value L Entropy KL
──────────────────────────────────────────────────────────────────────
1 1,024 N/A -0.0031 0.0021 1.0930 0.00571
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
20 20,480 0.0896 -0.0088 0.0005 0.9430 0.00751
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
40 40,960 -0.2644 -0.0086 0.0002 0.6308 0.00781
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
60 61,440 -0.0375 -0.0051 0.0000 0.4108 0.00441
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
80 81,920 0.0000 -0.0060 0.0000 0.4468 0.00429
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
100 102,400 N/A -0.0009 0.0001 0.5650 0.00331
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
120 122,880 N/A -0.0089 0.0000 0.6527 0.00460
Agent saved to /tmp/ppo_trading_best.pt
Agent saved to /tmp/ppo_trading_best.pt
140 143,360 N/A -0.0030 0.0000 0.4711 0.00807
160 163,840 -0.0542 -0.0010 0.0002 0.4533 0.00562
Agent saved to /tmp/ppo_trading_best.pt
180 184,320 0.0000 -0.0016 0.0001 0.3972 0.00324
──────────────────────────────────────────────────────────────────────
Training complete. Best rolling return: -0.0053
9.1. Training History Visualization (plot_training_history)
This function generates a comprehensive multi-panel dashboard to visualize the agent's training progression. By plotting various metrics over the course of training, it allows for diagnostic analysis of the learning dynamics, helping to identify potential issues such as unstable updates, insufficient exploration, or premature convergence.
Dashboard Panels:
- Episode Returns: Shows the mean reward obtained per episode, with a smoothed trend line to highlight overall learning progression. An upward trend indicates effective learning.
- Policy Loss: Displays the evolution of the actor's clipped surrogate loss, which should generally decrease.
- Value Loss: Tracks the critic's Mean Squared Error (MSE) loss, expected to decrease as the critic learns to accurately estimate state values.
- Policy Entropy: Illustrates the exploration behavior of the agent. Entropy should gradually decrease as the policy becomes more deterministic, but a sudden drop might signal premature convergence.
- Approx. KL Divergence: Monitors the change in policy between updates. A horizontal line at 0.02 serves as a visual threshold; exceeding this value indicates potentially unstable updates.
- Clip Fraction: Shows the proportion of samples where the PPO clipping mechanism was active. A high fraction (e.g., >0.3) suggests overly aggressive policy updates, while a low fraction indicates insufficient policy change.
Each plot includes smoothed lines to better discern trends through noisy data. This visualization is crucial for iteratively refining hyperparameters and ensuring robust agent development.
The code below calls plot_training_history with the history dictionary generated during training, rendering the diagnostic dashboard.
9. Training Visualisation
Before evaluating the agent on the test dataset, training curves are inspected to diagnose learning dynamics and ensure stable convergence. This analysis focuses on:
- Learning Progression: Assessing if the agent's returns exhibit an upward trend.
- Critic Convergence: Verifying if the value loss is consistently decreasing.
- Exploration Health: Monitoring policy entropy to ensure adequate exploration without premature collapse.
- Policy Stability: Confirming that KL divergences remain within acceptable bounds, indicating controlled policy updates.
These visualizations provide critical insights into the training process, allowing for early detection and correction of issues such.
def plot_training_history(history: Dict[str, List]) -> None:
"""
Plot PPO training metrics across all update cycles.
Provides a 6-panel dashboard covering returns, losses, entropy,
KL divergence, and gradient statistics.
Parameters
----------
history : dict
Output of train_ppo_agent() — keys are metric names, values are lists.
Returns
-------
None (displays matplotlib figure)
"""
steps = history['steps']
fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle('PPO Training Dashboard', fontsize=16, fontweight='bold')
# ── Helper: smooth noisy series with rolling mean ─────────────────────────
def smooth(series, window=10):
s = pd.Series(series).fillna(method='ffill')
return s.rolling(window, min_periods=1).mean().values
# ── 1. Episode Returns ─────────────────────────────────────────────────────
ax = axes[0, 0]
raw = history['episode_returns']
ax.plot(steps, raw, alpha=0.3, color='steelblue', linewidth=0.8)
ax.plot(steps, smooth(raw, 20), color='steelblue', linewidth=2, label='Smoothed')
ax.set_title('Episode Returns')
ax.set_xlabel('Steps')
ax.set_ylabel('Mean Return')
ax.legend()
# ── 2. Policy Loss ────────────────────────────────────────────────────────
ax = axes[0, 1]
ax.plot(steps, smooth(history['policy_loss']), color='darkorange', linewidth=1.5)
ax.set_title('Policy Loss (Clipped Surrogate)')
ax.set_xlabel('Steps')
ax.set_ylabel('Loss')
# ── 3. Value Loss ─────────────────────────────────────────────────────────
ax = axes[0, 2]
ax.plot(steps, smooth(history['value_loss']), color='green', linewidth=1.5)
ax.set_title('Value Loss (Critic MSE)')
ax.set_xlabel('Steps')
ax.set_ylabel('MSE')
# ── 4. Policy Entropy ──────────────────────────────────────────────────────
ax = axes[1, 0]
ax.plot(steps, smooth(history['entropy']), color='purple', linewidth=1.5)
ax.set_title('Policy Entropy (Exploration)')
ax.set_xlabel('Steps')
ax.set_ylabel('Entropy')
ax.annotate('Higher = more exploration', xy=(0.05, 0.9), xycoords='axes fraction', fontsize=9, color='grey')
# ── 5. KL Divergence ──────────────────────────────────────────────────────
ax = axes[1, 1]
kl_vals = smooth(history['approx_kl'])
ax.plot(steps, kl_vals, color='crimson', linewidth=1.5)
ax.axhline(0.02, color='grey', linestyle='--', linewidth=1, label='KL threshold 0.02')
ax.set_title('Approx. KL Divergence')
ax.set_xlabel('Steps')
ax.set_ylabel('KL')
ax.legend(fontsize=8)
# ── 6. Clip Fraction ──────────────────────────────────────────────────────
ax = axes[1, 2]
ax.plot(steps, smooth(history['clip_fraction']), color='teal', linewidth=1.5)
ax.axhline(0.25, color='grey', linestyle='--', linewidth=1, label='25% threshold')
ax.set_title('Clip Fraction')
ax.set_xlabel('Steps')
ax.set_ylabel('Fraction')
ax.legend(fontsize=8)
plt.tight_layout()
plt.show()
plot_training_history(history)10.4. Agent Evaluation (evaluate_agent)
This function systematically evaluates the performance of the trained PPO agent on an unseen test dataset. It simulates the agent's interaction with the environment for a full episode, recording all actions, portfolio values, and trade events. The function then computes a suite of standard financial performance metrics.
Key Steps:
- Evaluation Mode: Sets the agent's network to evaluation mode (
agent.network.eval()) to disable dropout or batch normalization if present, ensuring consistent inference. - Greedy vs. Stochastic Actions: Allows for selection between greedy (argmax of action probabilities) and stochastic (sampling from action probabilities) policies during evaluation. Greedy is typically used for final assessment.
- Environment Interaction: Steps through the
TradingEnvironmentusing the agent's policy until the episode concludes, collectingportfolio_values,actions_taken, andprice_history. - Performance Metrics Calculation: Computes:
- Total Return: Percentage change from initial to final portfolio value.
- Sharpe Ratio: Risk-adjusted return, annualized based on daily returns.
- Max Drawdown: The largest percentage drop from a peak in portfolio value.
- Win Rate: The percentage of profitable round-trip trades.
- Number of Trades: Total count of completed buy-sell cycles.
Return Value: A dictionary containing all recorded data and computed performance metrics.
The code block first initializes a TradingEnvironment using test_features and test_prices. It attempts to load the best-performing agent checkpoint (if saved during training) before evaluating the agent using the evaluate_agent function. Finally, a summary of the PPO agent's performance on the test set is printed.
10. Evaluation & Backtesting
10.1. Evaluation on Unseen Data
Evaluation on a separate test set is critical to assess the agent's generalization capabilities. This mitigates the risk of overfitting to patterns present solely in the training data. The test set comprises the final 20% of the dataset, ensuring it remains entirely unobserved during the training phase.
10.2. Benchmark Comparison
Agent performance is benchmarked against a Buy-and-Hold strategy. This passive approach involves an initial investment on the first day of the evaluation period, holding the asset until the final day, and then liquidating. In long-only markets, buy-and-hold serves as a fundamental baseline; an effective algorithmic strategy must demonstrate superior performance on the test set to be considered viable.
10.3. Performance Metrics
Key metrics for evaluating trading strategy performance include:
| Metric | Description |
|---|---|
| Total Return | The percentage change in portfolio value from initial to final. Calculated as: $(\text{Final Value} - \text{Initial Value}) / \text{Initial Value} \times 100%$. |
| Sharpe Ratio | Measures risk-adjusted return, representing return per unit of risk (annualized). A Sharpe ratio greater than 1 is generally considered good, while values exceeding 2 are excellent. |
| Max Drawdown | The largest peak-to-trough decline in portfolio value, expressed as a percentage. This metric quantifies the worst-case loss experienced over the evaluation period. |
| Win Rate | The percentage of closed round-trip trades that resulted in a profit. |
| Number of Trades | The total count of completed round-trip trades executed by the agent. |
def evaluate_agent(
agent: PPOAgent,
env: TradingEnvironment,
greedy: bool = True
) -> dict:
"""
Run the trained agent through one full episode and record all trades.
Parameters
----------
agent : PPOAgent — trained agent (loads best checkpoint if available)
env : TradingEnvironment — evaluation environment (usually test set)
greedy : bool — if True, pick argmax action (no sampling). Default True.
Returns
-------
results : dict with keys:
portfolio_values : List[float] — portfolio value at each step
actions : List[int] — action chosen at each step
prices : List[float] — asset price at each step
trades : List[tuple] — (type, step, price) for each trade
total_return : float — percentage total return
sharpe_ratio : float — annualised Sharpe ratio
max_drawdown : float — maximum drawdown percentage
win_rate : float — fraction of profitable trades
n_trades : int — total number of trades
Example
-------
>>> results = evaluate_agent(agent, test_env)
>>> print(f"Total return: {results['total_return']:.2f}%")
"""
agent.network.eval() # Disable dropout / batch norm (if any)
portfolio_values = []
actions_taken = []
price_history = []
obs, _ = env.reset()
done = False
with torch.no_grad():
while not done:
obs_t = torch.tensor(obs, dtype=torch.float32, device=agent.device).unsqueeze(0)
dist, value = agent.network(obs_t)
if greedy:
action = dist.probs.argmax(dim=-1).item() # Deterministic: pick most probable
else:
action = dist.sample().item() # Stochastic: sample distribution
obs, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated
portfolio_values.append(info['portfolio_value'])
actions_taken.append(action)
price_history.append(info['price'])
agent.network.train() # Restore training mode
# ── Compute performance metrics ────────────────────────────────────────────
initial_value = env.initial_balance
final_value = portfolio_values[-1]
total_return = (final_value - initial_value) / initial_value * 100
# Daily returns for Sharpe calculation
daily_returns = np.diff(portfolio_values) / np.array(portfolio_values[:-1])
sharpe_ratio = (
(daily_returns.mean() / (daily_returns.std() + 1e-8)) * np.sqrt(252) # Annualise
if len(daily_returns) > 1 else 0.0
)
# Maximum drawdown
rolling_peak = np.maximum.accumulate(portfolio_values)
drawdowns = (np.array(portfolio_values) - rolling_peak) / rolling_peak
max_drawdown = drawdowns.min() * 100 # In percentage
# Win rate: profit on each completed round-trip trade
trades = env.trade_history # List of (type, step, price)
buys = [(s, p) for t, s, p in trades if t == 'BUY']
sells = [(s, p) for t, s, p in trades if t == 'SELL']
n_roundtrips = min(len(buys), len(sells))
win_rate = 0.0
if n_roundtrips > 0:
wins = sum(1 for i in range(n_roundtrips) if sells[i][1] > buys[i][1])
win_rate = wins / n_roundtrips * 100
return {
'portfolio_values': portfolio_values,
'actions': actions_taken,
'prices': price_history,
'trades': trades,
'total_return': total_return,
'sharpe_ratio': sharpe_ratio,
'max_drawdown': max_drawdown,
'win_rate': win_rate,
'n_trades': len(trades) // 2 # Round-trips
}
# ── Create test environment and evaluate ──────────────────────────────────
test_env = TradingEnvironment(
features = test_features,
prices = test_prices,
initial_balance = 10_000.0,
transaction_cost = 0.001
)
# Try loading the best checkpoint first
try:
agent.load('/tmp/ppo_trading_best.pt')
except Exception:
print("No checkpoint found — evaluating current weights.")
results = evaluate_agent(agent, test_env, greedy=True)
print(f"\n{'═' * 45}")
print(f" PPO Agent — Test Set Performance")
print(f"{'═' * 45}")
print(f" Total Return : {results['total_return']:>8.2f}%")
print(f" Sharpe Ratio : {results['sharpe_ratio']:>8.2f}")
print(f" Max Drawdown : {results['max_drawdown']:>8.2f}%")
print(f" Win Rate : {results['win_rate']:>8.2f}%")
print(f" No. Trades : {results['n_trades']:>8d}")
print(f"{'═' * 45}")Agent loaded from /tmp/ppo_trading_best.pt ═════════════════════════════════════════════ PPO Agent — Test Set Performance ═════════════════════════════════════════════ Total Return : -12.28% Sharpe Ratio : -0.69 Max Drawdown : -14.42% Win Rate : 44.44% No. Trades : 9 ═════════════════════════════════════════════
10.5. Buy-and-Hold Benchmark (compute_buyandhold_benchmark)
This function calculates the performance metrics for a passive Buy-and-Hold (B&H) strategy. This strategy serves as a fundamental benchmark in financial markets: an initial investment is made on the first day of the evaluation period, the asset is held indefinitely, and then liquidated on the final day. For any active trading strategy, demonstrating superior performance over B&H on the test set is a critical validation.
Parameters:
prices: The close price series for the evaluation period.initial_balance: The starting capital.
Calculations:
- Portfolio Values: The daily value of the portfolio, calculated by multiplying the initial number of shares by the daily price.
- Total Return: The overall percentage gain or loss.
- Sharpe Ratio: Risk-adjusted return, annualized.
- Max Drawdown: The largest percentage decline from a peak.
Return Value: A dictionary containing the portfolio values and the computed performance metrics for the B&H strategy.
The code block below calculates the Buy-and-Hold benchmark performance using the test_prices. It then prints a detailed summary of the B&H strategy's performance and calculates the alpha, which represents the PPO agent's excess return relative to the benchmark.
def compute_buyandhold_benchmark(
prices: pd.Series,
initial_balance: float = 10_000.0
) -> dict:
"""
Compute buy-and-hold portfolio value and performance metrics.
Buy-and-hold is the passive baseline: invest everything on day 1,
never trade, liquidate on the final day. It's the de facto benchmark
in long-only markets — any active strategy should aim to beat it.
Parameters
----------
prices : pd.Series — close price series for the evaluation period
initial_balance : float — starting cash
Returns
-------
dict with portfolio_values, total_return, sharpe_ratio, max_drawdown
Example
-------
>>> bh = compute_buyandhold_benchmark(test_prices, 10_000)
>>> print(f"Buy-and-Hold return: {bh['total_return']:.2f}%")
"""
prices_arr = prices.values
shares = initial_balance / prices_arr[0] # Invest all cash on day 1
portfolio_values = shares * prices_arr # Daily portfolio value
daily_returns = np.diff(portfolio_values) / portfolio_values[:-1]
total_return = (portfolio_values[-1] - initial_balance) / initial_balance * 100
sharpe_ratio = (daily_returns.mean() / (daily_returns.std() + 1e-8)) * np.sqrt(252)
rolling_peak = np.maximum.accumulate(portfolio_values)
drawdowns = (portfolio_values - rolling_peak) / rolling_peak
max_drawdown = drawdowns.min() * 100
return {
'portfolio_values': portfolio_values.tolist(),
'total_return': total_return,
'sharpe_ratio': sharpe_ratio,
'max_drawdown': max_drawdown
}
bh = compute_buyandhold_benchmark(test_prices, initial_balance=10_000.0)
print(f"\n{'═' * 45}")
print(f" Buy & Hold Benchmark — Test Set")
print(f"{'═' * 45}")
print(f" Total Return : {bh['total_return']:>8.2f}%")
print(f" Sharpe Ratio : {bh['sharpe_ratio']:>8.2f}")
print(f" Max Drawdown : {bh['max_drawdown']:>8.2f}%")
print(f"{'═' * 45}")
print(f"\n Alpha vs Buy-and-Hold: {results['total_return'] - bh['total_return']:+.2f}%")═════════════════════════════════════════════ Buy & Hold Benchmark — Test Set ═════════════════════════════════════════════ Total Return : -55.89% Sharpe Ratio : -2.11 Max Drawdown : -61.97% ═════════════════════════════════════════════ Alpha vs Buy-and-Hold: +43.60%
11.1. Backtest Dashboard Visualization (plot_backtest_dashboard)
This function generates a comprehensive visual dashboard that summarizes the backtest results, providing a side-by-side comparison of the PPO agent's performance against the Buy-and-Hold benchmark. This dashboard is essential for a thorough understanding of the agent's efficacy, trading behavior, and risk characteristics.
Dashboard Panels:
- Portfolio Growth: A time-series plot comparing the PPO agent's portfolio value against the Buy-and-Hold strategy. Areas of outperformance and underperformance are highlighted, providing a clear visual of the agent's cumulative returns relative to the benchmark.
- Asset Price with Trade Signals: Displays the asset's price over the test period, with markers indicating the agent's buy (green triangles) and sell (red inverted triangles) actions. This panel helps in understanding the timing and context of the agent's trading decisions.
- Action Distribution: A pie chart illustrating the proportional distribution of the agent's actions (Hold, Buy, Sell) during the evaluation period. This provides insight into the agent's overall trading frequency and strategy.
- Drawdown (%): A plot showing the percentage drawdown from peak portfolio value for both the PPO agent and Buy-and-Hold. This highlights periods and magnitudes of capital erosion for risk assessment.
- Performance Scorecard: A tabular summary comparing key performance metrics (Total Return, Sharpe Ratio, Max Drawdown, Win Rate, No. Trades) for the PPO agent and the Buy-and-Hold benchmark. This table offers a concise, quantitative overview of their respective performances.
This code block calls plot_backtest_dashboard with the results from the PPO agent evaluation, the bh benchmark results, and the test_prices to generate and display the comprehensive backtest visualization.
11. Final Visualization — Backtest Dashboard
The backtest dashboard provides a comprehensive visual summary of the agent's performance, contrasting it with the Buy-and-Hold benchmark. The dashboard integrates:
- Portfolio Growth: A comparative plot illustrating the evolution of the PPO agent's portfolio value against the Buy-and-Hold strategy, with explicit markers for trade entry and exit points.
- Action Distribution: A pie chart detailing the proportional distribution of the agent's actions (buy, hold, sell) over the evaluation period.
- Drawdown Chart: A graphical representation of the maximum drawdown experienced by both the PPO agent and the benchmark, highlighting periods and magnitudes of losses.
- Summary Scorecard: A tabular comparison of key performance metrics, facilitating a side-by-side analysis of the PPO agent and the Buy-and-Hold strategy.
This dashboard enables a thorough assessment of the agent's efficacy and strategic characteristics.
def plot_backtest_dashboard(
results: dict,
bh: dict,
test_prices: pd.Series
) -> None:
"""
Render a comprehensive backtest dashboard comparing the PPO agent to buy-and-hold.
Parameters
----------
results : dict — output of evaluate_agent()
bh : dict — output of compute_buyandhold_benchmark()
test_prices : pd.Series — price series for the test period
Returns
-------
None (displays matplotlib figure)
"""
fig = plt.figure(figsize=(18, 14))
gs = gridspec.GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.3)
ax_main = fig.add_subplot(gs[0, :]) # Full-width portfolio growth
ax_price = fig.add_subplot(gs[1, :2]) # Asset price with trade markers
ax_action = fig.add_subplot(gs[1, 2]) # Action distribution pie
ax_dd = fig.add_subplot(gs[2, :2]) # Drawdown chart
ax_score = fig.add_subplot(gs[2, 2]) # Scorecard table
n = len(results['portfolio_values'])
x = np.arange(n)
# ── Panel 1: Portfolio Growth ──────────────────────────────────────────────
ppo_vals = np.array(results['portfolio_values'])
bh_vals = np.array(bh['portfolio_values'][:n]) # Trim to same length
ax_main.plot(x, ppo_vals, color='steelblue', linewidth=1.5, label='PPO Agent')
ax_main.plot(x, bh_vals, color='darkorange', linewidth=1.5, linestyle='--', label='Buy & Hold')
ax_main.fill_between(x, ppo_vals, bh_vals,
where=ppo_vals >= bh_vals, alpha=0.15, color='steelblue', label='Outperformance')
ax_main.fill_between(x, ppo_vals, bh_vals,
where=ppo_vals < bh_vals, alpha=0.15, color='red', label='Underperformance')
ax_main.set_title('Portfolio Value: PPO Agent vs Buy-and-Hold', fontweight='bold')
ax_main.set_ylabel('Portfolio Value ($)')
ax_main.legend()
ax_main.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f'${v:,.0f}'))
# ── Panel 2: Price with Trade Markers ─────────────────────────────────────
prices_arr = np.array(results['prices'])
ax_price.plot(x, prices_arr, color='grey', linewidth=0.8, alpha=0.8, label='Price')
# Overlay buy / sell markers
trades = results['trades']
buy_steps = [s for t, s, p in trades if t == 'BUY']
sell_steps = [s for t, s, p in trades if t == 'SELL']
if buy_steps:
ax_price.scatter(buy_steps, [prices_arr[min(s, n-1)] for s in buy_steps],
marker='^', color='green', s=80, zorder=5, label='Buy')
if sell_steps:
ax_price.scatter(sell_steps, [prices_arr[min(s, n-1)] for s in sell_steps],
marker='v', color='red', s=80, zorder=5, label='Sell')
ax_price.set_title('Asset Price with Trade Signals')
ax_price.set_ylabel('Price ($)')
ax_price.legend(fontsize=8)
# ── Panel 3: Action Distribution ──────────────────────────────────────────
action_counts = [
results['actions'].count(0), # Hold
results['actions'].count(1), # Buy
results['actions'].count(2) # Sell
]
colors = ['steelblue', 'green', 'red']
wedge_props = {'edgecolor': 'white', 'linewidth': 2}
ax_action.pie(action_counts, labels=['Hold', 'Buy', 'Sell'],
colors=colors, autopct='%1.1f%%', wedgeprops=wedge_props,
startangle=90)
ax_action.set_title('Action Distribution')
# ── Panel 4: Drawdown ─────────────────────────────────────────────────────
rolling_peak = np.maximum.accumulate(ppo_vals)
drawdown = (ppo_vals - rolling_peak) / rolling_peak * 100
bh_peak = np.maximum.accumulate(bh_vals)
bh_drawdown = (bh_vals - bh_peak) / bh_peak * 100
ax_dd.fill_between(x, drawdown, 0, alpha=0.5, color='steelblue', label='PPO Drawdown')
ax_dd.fill_between(x, bh_drawdown, 0, alpha=0.3, color='orange', label='B&H Drawdown')
ax_dd.set_title('Drawdown (%)')
ax_dd.set_ylabel('Drawdown (%)')
ax_dd.legend(fontsize=8)
# ── Panel 5: Scorecard Table ───────────────────────────────────────────────
ax_score.axis('off')
table_data = [
['Metric', 'PPO Agent', 'Buy & Hold'],
['Total Return', f"{results['total_return']:.2f}%", f"{bh['total_return']:.2f}%"],
['Sharpe Ratio', f"{results['sharpe_ratio']:.2f}", f"{bh['sharpe_ratio']:.2f}"],
['Max Drawdown', f"{results['max_drawdown']:.2f}%", f"{bh['max_drawdown']:.2f}%"],
['Win Rate', f"{results['win_rate']:.1f}%", 'N/A'],
['No. Trades', str(results['n_trades']), '1'],
]
tbl = ax_score.table(
cellText = table_data[1:],
colLabels = table_data[0],
cellLoc = 'center',
loc = 'center',
bbox = [0, 0, 1, 1]
)
tbl.auto_set_font_size(False)
tbl.set_fontsize(11)
# Colour the header row
for col in range(3):
tbl[(0, col)].set_facecolor('#2d6a9f')
tbl[(0, col)].set_text_props(color='white', fontweight='bold')
ax_score.set_title('Performance Scorecard', fontweight='bold', pad=15)
fig.suptitle('PPO Trading Agent — Backtest Dashboard', fontsize=17, fontweight='bold', y=1.01)
plt.show()
plot_backtest_dashboard(results, bh, test_prices)12.2. Hyperparameter Sweep Function (run_hyperparameter_sweep)
This function performs a simplified grid search to evaluate the sensitivity of the PPO agent's performance to different hyperparameter configurations. It systematically iterates through a provided param_grid, training a new PPO agent for each unique combination of hyperparameters and then evaluating its performance on the test set.
Key Steps:
- Iteration over Grid: Loops through each dictionary in
param_grid, where each dictionary represents a unique set of hyperparameters to test. - Agent Instantiation: For each configuration, a fresh
PPOAgentis instantiated with the specified hyperparameters. To expedite the sweep,rollout_stepsandn_epochsare set to smaller values than in full training. - Short Training Run: The agent undergoes a brief training phase (defined by
steps_per_run) in a dedicated training environment. This run is performed silently (log_interval=999) and without checkpointing (save_best=False). - Evaluation: After training, the agent is evaluated on the test set using
evaluate_agent. - Results Storage: The hyperparameter values and the resulting test performance metrics (
total_return,sharpe_ratio,max_drawdown,n_trades) are stored as a row in a list.
Return Value: A pd.DataFrame summarizing the performance of each hyperparameter configuration, sorted by Sharpe Ratio to highlight the most effective combinations.
The code block below defines a small param_grid to explore different values for lr, entropy_coef, and clip_eps. It then executes the run_hyperparameter_sweep function with these parameters and prints the sorted results, providing insights into which hyperparameter settings yield better performance.
12. Advanced Topics: Hyperparameter Sensitivity
12.1. Hyperparameter Grid Search
The provided function run_hyperparameter_sweep executes multiple short training runs to analyze the impact of various hyperparameters on agent performance. Specifically, it investigates the sensitivity of the agent to changes in the learning rate (lr), entropy coefficient (entropy_coef), and PPO clipping parameter (clip_eps).
This utility is instrumental for guiding further hyperparameter tuning efforts, allowing practitioners to identify optimal configurations that enhance agent robustness and profitability.
def run_hyperparameter_sweep(
train_features: pd.DataFrame,
train_prices: pd.Series,
test_features: pd.DataFrame,
test_prices: pd.Series,
param_grid: List[dict],
steps_per_run: int = 50_000
) -> pd.DataFrame:
"""
Run a grid search over PPO hyperparameters and compare test performance.
Each configuration is trained independently from scratch. Useful for
understanding sensitivity to learning rate, clip epsilon, and entropy.
Parameters
----------
train_features : pd.DataFrame — training feature matrix
train_prices : pd.Series — training price series
test_features : pd.DataFrame — test feature matrix
test_prices : pd.Series — test price series
param_grid : List[dict] — list of hyperparameter dicts to try
steps_per_run : int — training steps per configuration
Returns
-------
results_df : pd.DataFrame — one row per config with test metrics
Example
-------
>>> grid = [{'lr': 1e-3, 'entropy_coef': 0.01}, {'lr': 3e-4, 'entropy_coef': 0.05}]
>>> df = run_hyperparameter_sweep(train_features, train_prices, ..., grid)
"""
rows = []
for i, params in enumerate(param_grid):
print(f"\n[{i+1}/{len(param_grid)}] Config: {params}")
# Build fresh agent with this config
ag = PPOAgent(
obs_dim = obs_dim,
n_actions = n_actions,
lr = params.get('lr', 3e-4),
entropy_coef = params.get('entropy_coef', 0.01),
clip_eps = params.get('clip_eps', 0.2),
rollout_steps = 512, # Short for speed during sweep
n_epochs = 5,
device = DEVICE
)
env_tr = TradingEnvironment(train_features, train_prices)
# Short training run
_ = train_ppo_agent(ag, env_tr, total_steps=steps_per_run,
log_interval=999, save_best=False) # Silent run
# Evaluate on test set
env_te = TradingEnvironment(test_features, test_prices)
res = evaluate_agent(ag, env_te, greedy=True)
row = {**params,
'total_return': res['total_return'],
'sharpe_ratio': res['sharpe_ratio'],
'max_drawdown': res['max_drawdown'],
'n_trades': res['n_trades']}
rows.append(row)
print(f" → Return: {res['total_return']:.2f}% | Sharpe: {res['sharpe_ratio']:.2f}")
results_df = pd.DataFrame(rows)
return results_df.sort_values('sharpe_ratio', ascending=False)
# ── Define a small grid ───────────────────────────────────────────────────────
param_grid = [
{'lr': 3e-4, 'entropy_coef': 0.01, 'clip_eps': 0.2},
{'lr': 1e-3, 'entropy_coef': 0.01, 'clip_eps': 0.2},
{'lr': 3e-4, 'entropy_coef': 0.05, 'clip_eps': 0.2},
{'lr': 3e-4, 'entropy_coef': 0.01, 'clip_eps': 0.1},
]
print("Running hyperparameter sweep (4 configurations × 50k steps each)...")
sweep_results = run_hyperparameter_sweep(
train_features, train_prices,
test_features, test_prices,
param_grid,
steps_per_run = 50_000
)
print("\n── Hyperparameter Sweep Results (sorted by Sharpe) ──────────────────")
print(sweep_results.to_string(index=False))Running hyperparameter sweep (4 configurations × 50k steps each)...
[1/4] Config: {'lr': 0.0003, 'entropy_coef': 0.01, 'clip_eps': 0.2}
──────────────────────────────────────────────────────────────────────
PPO Training | 50,000 steps | 97 updates
──────────────────────────────────────────────────────────────────────
Update Steps Ep.Return Policy L Value L Entropy KL
──────────────────────────────────────────────────────────────────────
1 512 N/A -0.0026 0.0048 1.0977 0.00084
──────────────────────────────────────────────────────────────────────
Training complete. Best rolling return: -inf
→ Return: -57.37% | Sharpe: -2.22
[2/4] Config: {'lr': 0.001, 'entropy_coef': 0.01, 'clip_eps': 0.2}
──────────────────────────────────────────────────────────────────────
PPO Training | 50,000 steps | 97 updates
──────────────────────────────────────────────────────────────────────
Update Steps Ep.Return Policy L Value L Entropy KL
──────────────────────────────────────────────────────────────────────
1 512 N/A -0.0041 0.0697 1.0905 0.00793
──────────────────────────────────────────────────────────────────────
Training complete. Best rolling return: -inf
→ Return: 0.00% | Sharpe: 0.00
[3/4] Config: {'lr': 0.0003, 'entropy_coef': 0.05, 'clip_eps': 0.2}
──────────────────────────────────────────────────────────────────────
PPO Training | 50,000 steps | 97 updates
──────────────────────────────────────────────────────────────────────
Update Steps Ep.Return Policy L Value L Entropy KL
──────────────────────────────────────────────────────────────────────
1 512 N/A -0.0053 0.0024 1.0960 0.00276
──────────────────────────────────────────────────────────────────────
Training complete. Best rolling return: -inf
→ Return: 0.00% | Sharpe: 0.00
[4/4] Config: {'lr': 0.0003, 'entropy_coef': 0.01, 'clip_eps': 0.1}
──────────────────────────────────────────────────────────────────────
PPO Training | 50,000 steps | 97 updates
──────────────────────────────────────────────────────────────────────
Update Steps Ep.Return Policy L Value L Entropy KL
──────────────────────────────────────────────────────────────────────
1 512 N/A -0.0047 0.0038 1.0969 0.00165
──────────────────────────────────────────────────────────────────────
Training complete. Best rolling return: -inf
→ Return: 0.00% | Sharpe: 0.00
── Hyperparameter Sweep Results (sorted by Sharpe) ──────────────────
lr entropy_coef clip_eps total_return sharpe_ratio max_drawdown n_trades
0.0010 0.01 0.2 0.000000 0.000000 0.000000 0
0.0003 0.05 0.2 0.000000 0.000000 0.000000 0
0.0003 0.01 0.1 0.000000 0.000000 0.000000 0
0.0003 0.01 0.2 -57.372742 -2.218772 -61.970375 0
Resources
This section lists relevant academic papers, articles, and libraries that underpin the methodologies and implementations within this notebook. It serves as a reference for further exploration and understanding of the concepts presented.
Academic Papers:
-
Proximal Policy Optimization Algorithms
- John Schulman, Filip Wolski, Prafulla Dhariwal, Alec Radford, Oleg Klimov
- arXiv preprint arXiv:1707.06347, 2017
- Link to Paper
- This seminal paper introduces the PPO algorithm, detailing its clipped surrogate objective function that facilitates stable and efficient policy optimization in reinforcement learning.
-
High-Dimensional Continuous Control Using Generalized Advantage Estimation
- John Schulman, Philipp Moritz, Sergey Levine, Michael Jordan, Pieter Abbeel
- arXiv preprint arXiv:1506.02438, 2015
- Link to Paper
- This work introduces Generalized Advantage Estimation (GAE), a method for significantly reducing variance in policy gradient estimates, thereby improving the stability and speed of learning in complex environments.
Libraries & Tools:
-
PyTorch
- An open-source machine learning framework that accelerates the path from research prototyping to production deployment. Used for building and training neural networks in this notebook.
- Official Website
-
Gymnasium
- A maintained fork of OpenAI Gym, providing a standard API for developing and comparing reinforcement learning algorithms. Used to define the trading environment.
- GitHub Repository
-
yfinance
- A popular open-source library that provides a reliable, threaded, and Pythonic way to download historical market data from Yahoo Finance. Utilized for acquiring real-world asset prices.
- GitHub Repository
Further Reading:
- Reinforcement Learning: An Introduction
- Richard S. Sutton, Andrew G. Barto
- MIT Press, 2nd Edition, 2018
- A foundational textbook for understanding the theoretical underpinnings of reinforcement learning.
Conclusion
This notebook has provided a complete implementation of a Proximal Policy Optimization (PPO) Reinforcement Learning Trading Agent from initial data processing to final performance evaluation.
Summary of Components:
| Component | Implementation Details |
|---|---|
| Data Handling | Generation of synthetic Geometric Brownian Motion (GBM) prices and calculation of 6 key technical features. |
| Environment | Development of a custom Gymnasium-compatible environment supporting buy, hold, and sell actions. |
| Neural Network | Construction of a shared-backbone actor-critic network utilizing a 256-dimension Multi-Layer Perceptron (MLP) architecture. |
| Memory Buffer | Design of a PPO-specific rollout storage mechanism incorporating Generalized Advantage Estimation (GAE). |
| Loss Function | Formulation of a composite loss function comprising the clipped surrogate objective, value Mean Squared Error (MSE), and an entropy bonus. |
| Agent | Integration of all components into a comprehensive PPO agent with a structured rollout and update cycle. |
| Evaluation | Performance assessment through metrics such as Sharpe ratio, maximum drawdown, and win rate, benchmarked against a Buy-and-Hold strategy. |
| Hyperparameter Tuning | Implementation of a mini-grid search for analyzing hyperparameter sensitivity. |
Key Takeaways:
- PPO's Clipping Mechanism: The stability of PPO is primarily attributed to its clipping mechanism, which prevents overly aggressive policy updates that could otherwise lead to training instability.
- Reward Function Design: The formulation of the reward function is as critical as the network architecture. Employing logarithmic returns for rewards naturally handles compounding effects, aligning agent objectives with long-term portfolio growth.
- Transaction Costs: The inclusion of transaction costs is crucial. Neglecting these costs typically leads to agents learning to overtrade, thereby overfitting to market noise.
- Market Efficiency: Outperforming a simple Buy-and-Hold strategy in efficient markets is a significant challenge. Any observed edge of the RL agent often stems from its ability to adapt to varying market regimes.
Important Consideration:
This framework is designed for educational and research purposes. Practical application in real-world trading necessitates advanced considerations, including: live data feeds, robust execution infrastructure, rigorous walk-forward validation, and sophisticated risk management strategies.
Next Steps
To further enhance this trading agent and explore its capabilities, consider the following:
-
Multi-Asset Trading: Extend the
TradingEnvironmentto support trading multiple assets simultaneously, allowing the agent to manage a portfolio rather than a single stock. -
Advanced Feature Engineering: Incorporate more sophisticated technical indicators (e.g., MACD, ADX, Fibonacci retracements) or fundamental data (e.g., earnings reports, economic indicators) into the observation space.
-
Risk Management: Implement explicit risk management strategies within the environment or as part of the agent's reward function, such as position sizing, stop-loss orders, or value-at-risk (VaR) constraints.
-
Recurrent Neural Networks (RNNs): Replace the feed-forward backbone of the
ActorCriticNetworkwith RNNs (e.g., LSTMs or GRUs) to enable the agent to learn from temporal dependencies and memory in market data. -
Real-time Data Integration: Explore integrating live or near-live data feeds to transition towards a more operational trading system.
-
Hyperparameter Optimization: Employ more advanced hyperparameter tuning techniques like Bayesian optimization or evolutionary algorithms to systematically search for optimal PPO configurations.
-
Different RL Algorithms: Experiment with other state-of-the-art RL algorithms such as Soft Actor-Critic (SAC) or Rainbow DQN to compare performance against PPO.
-
Scalability: For large-scale training, consider distributed RL frameworks (e.g., Ray RLlib) to leverage multiple environments and computational resources efficiently.