1. Learning Objectives
By the end of this lesson, you will be able to:
-
Understand the mathematical formulation of Markov Decision Processes (MDPs) and their application to trading.
-
Derive the Bellman equations for state-value and action-value functions.
-
Implement Q-learning and Deep Q-Networks (DQN) for discrete trading actions.
-
Implement Policy Gradient methods (REINFORCE, PPO) for continuous action spaces.
-
Design reward functions that align with financial objectives (Sharpe ratio, risk-adjusted return).
-
Apply reinforcement learning to optimal execution (VWAP, TWAP) and market making.
-
Understand the challenges of reinforcement learning in finance: non-stationarity, partial observability, and transaction costs.
-
Implement a complete RL trading agent with experience replay and target networks.
2. Markov Decision Processes (MDPs) – The Mathematical Foundation
An MDP is a mathematical framework for sequential decision-making under uncertainty. Trading is a natural MDP: states (market conditions), actions (buy/sell/hold), rewards (profit/loss), and transitions (price movements).
2.1 MDP Definition
An MDP is defined by the tuple (S, A, P, R, γ):
-
S: Set of states (market features, portfolio composition). -
A: Set of actions (buy, sell, hold, or continuous quantities). -
P(s' | s, a): Transition probability to states'from statesafter actiona. -
R(s, a): Reward function (profit, Sharpe ratio, risk-adjusted return). -
γ ∈ [0, 1]: Discount factor (weights future rewards).
2.2 The Policy
A policy π(a | s) is a distribution over actions given a state. The goal is to find the optimal policy π* that maximises the expected cumulative discounted reward.
2.3 The Bellman Equations
State-Value Function (V): The expected cumulative reward starting from state s and following policy π.V^π(s) = E_π[ Σ_{t=0}^{∞} γ^t R_t | S_0 = s ]
Action-Value Function (Q): The expected cumulative reward starting from state s, taking action a, and then following policy π.Q^π(s, a) = E_π[ Σ_{t=0}^{∞} γ^t R_t | S_0 = s, A_0 = a ]
Bellman Expectation Equations:V^π(s) = Σ_a π(a | s) [ R(s, a) + γ Σ_{s'} P(s' | s, a) V^π(s') ]Q^π(s, a) = R(s, a) + γ Σ_{s'} P(s' | s, a) Σ_{a'} π(a' | s') Q^π(s', a')
Bellman Optimality Equations:V*(s) = max_a [ R(s, a) + γ Σ_{s'} P(s' | s, a) V*(s') ]Q*(s, a) = R(s, a) + γ Σ_{s'} P(s' | s, a) max_{a'} Q*(s', a')
3. Q-Learning – Off-Policy Temporal Difference Learning
Q-learning learns the optimal action-value function Q* directly, without requiring a transition model P.
3.1 The Q-Learning Update RuleQ(S_t, A_t) ← Q(S_t, A_t) + α [ R_{t+1} + γ max_a Q(S_{t+1}, a) - Q(S_t, A_t) ]
where α is the learning rate.
3.2 Tabular Q-Learning Implementation
class TabularQLearning:
def __init__(self, n_states, n_actions, learning_rate=0.1, discount=0.95, epsilon=0.1):
self.n_states = n_states
self.n_actions = n_actions
self.lr = learning_rate
self.gamma = discount
self.epsilon = epsilon # Exploration rate
# Initialise Q-table
self.Q = np.zeros((n_states, n_actions))
def get_action(self, state):
"""
Epsilon-greedy action selection.
"""
if np.random.random() < self.epsilon:
return np.random.randint(self.n_actions) # Explore
else:
return np.argmax(self.Q[state, :]) # Exploit
def update(self, state, action, reward, next_state, done):
"""
Update Q-value using Q-learning update rule.
"""
best_next_action = np.argmax(self.Q[next_state, :])
td_target = reward + self.gamma * self.Q[next_state, best_next_action]
td_error = td_target - self.Q[state, action]
self.Q[state, action] += self.lr * td_error
3.3 Q-Learning for Trading (Discrete Actions)
class TradingQLearning:
def __init__(self, n_state_features, n_actions=3, lr=0.01, gamma=0.95, epsilon=0.1):
self.n_actions = n_actions # 0=Hold, 1=Buy, 2=Sell
self.lr = lr
self.gamma = gamma
self.epsilon = epsilon
# Use a neural network for Q-learning (DQN) for continuous states
self.q_network = self.build_network(n_state_features, n_actions)
self.target_network = self.build_network(n_state_features, n_actions)
self.target_network.load_state_dict(self.q_network.state_dict())
def build_network(self, input_dim, output_dim):
return nn.Sequential(
nn.Linear(input_dim, 128),
nn.ReLU(),
nn.Linear(128, 64),
nn.ReLU(),
nn.Linear(64, output_dim)
)
def get_action(self, state):
if np.random.random() < self.epsilon:
return np.random.randint(self.n_actions)
else:
with torch.no_grad():
q_values = self.q_network(torch.FloatTensor(state).unsqueeze(0))
return torch.argmax(q_values).item()
4. Deep Q-Networks (DQN) – Scaling Q-Learning with Neural Networks
DQN uses a neural network as a function approximator for Q(s, a). It addresses the instability of non-linear function approximation with two key innovations: Experience Replay and Target Networks.
4.1 Experience Replay
Store transitions (s, a, r, s', done) in a replay buffer. Sample random mini-batches for training. This breaks the temporal correlation between consecutive samples.
4.2 Target Network
Use a separate target network Q_target to compute the TD target. The target network is updated periodically (every N steps) from the online network. This stabilises training.
4.3 DQN Implementation
import random
from collections import deque
class ReplayBuffer:
def __init__(self, capacity=100000):
self.buffer = deque(maxlen=capacity)
def push(self, state, action, reward, next_state, done):
self.buffer.append((state, action, reward, next_state, done))
def sample(self, batch_size):
batch = random.sample(self.buffer, batch_size)
states, actions, rewards, next_states, dones = zip(*batch)
return (np.array(states), np.array(actions), np.array(rewards),
np.array(next_states), np.array(dones))
def __len__(self):
return len(self.buffer)
class DQNAgent:
def __init__(self, state_dim, action_dim, lr=1e-4, gamma=0.99, epsilon=1.0,
epsilon_decay=0.995, epsilon_min=0.01, batch_size=64):
self.state_dim = state_dim
self.action_dim = action_dim
self.gamma = gamma
self.epsilon = epsilon
self.epsilon_decay = epsilon_decay
self.epsilon_min = epsilon_min
self.batch_size = batch_size
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# Networks
self.q_network = self.build_network().to(self.device)
self.target_network = self.build_network().to(self.device)
self.target_network.load_state_dict(self.q_network.state_dict())
self.target_network.eval()
self.optimizer = optim.Adam(self.q_network.parameters(), lr=lr)
self.replay_buffer = ReplayBuffer()
def build_network(self):
return nn.Sequential(
nn.Linear(self.state_dim, 256),
nn.ReLU(),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, self.action_dim)
)
def get_action(self, state):
if np.random.random() < self.epsilon:
return np.random.randint(self.action_dim)
state = torch.FloatTensor(state).unsqueeze(0).to(self.device)
with torch.no_grad():
q_values = self.q_network(state)
return torch.argmax(q_values).item()
def update(self):
if len(self.replay_buffer) < self.batch_size:
return None
# Sample batch
states, actions, rewards, next_states, dones = self.replay_buffer.sample(self.batch_size)
# Convert to tensors
states = torch.FloatTensor(states).to(self.device)
actions = torch.LongTensor(actions).to(self.device)
rewards = torch.FloatTensor(rewards).to(self.device)
next_states = torch.FloatTensor(next_states).to(self.device)
dones = torch.FloatTensor(dones).to(self.device)
# Current Q-values
current_q = self.q_network(states).gather(1, actions.unsqueeze(1)).squeeze(1)
# Target Q-values (using target network)
with torch.no_grad():
next_q = self.target_network(next_states).max(1)[0]
target_q = rewards + (1 - dones) * self.gamma * next_q
# Loss
loss = nn.MSELoss()(current_q, target_q)
# Backpropagation
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.q_network.parameters(), 1.0)
self.optimizer.step()
# Decay epsilon
self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)
return loss.item()
def update_target_network(self):
self.target_network.load_state_dict(self.q_network.state_dict())
5. Policy Gradient Methods – Directly Optimising the Policy
Policy gradient methods directly optimise the policy π(a | s) by climbing the gradient of the expected cumulative reward.
5.1 The Policy Gradient Theorem∇J(θ) = E_π[ Σ_{t=0}^{∞} ∇_θ ln π_θ(a_t | s_t) * Q^π(s_t, a_t) ]
The gradient of the expected reward is the expected sum of the gradient of the log policy times the action-value function.
5.2 REINFORCE (Monte Carlo Policy Gradient)
REINFORCE uses the complete return G_t as an unbiased estimate of Q^π(s_t, a_t).∇J(θ) ≈ (1/N) Σ_{i=1}^{N} Σ_{t=0}^{T-1} ∇_θ ln π_θ(a_{i,t} | s_{i,t}) * G_{i,t}
5.3 REINFORCE Implementation
class REINFORCEAgent:
def __init__(self, state_dim, action_dim, hidden_dim=128, lr=1e-3, gamma=0.99):
self.state_dim = state_dim
self.action_dim = action_dim
self.gamma = gamma
self.policy_network = nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, action_dim),
nn.Softmax(dim=-1)
)
self.optimizer = optim.Adam(self.policy_network.parameters(), lr=lr)
def get_action(self, state):
state = torch.FloatTensor(state).unsqueeze(0)
probs = self.policy_network(state)
action = torch.multinomial(probs, 1).item()
return action
def compute_returns(self, rewards):
"""
Compute discounted returns (Monte Carlo returns).
"""
returns = []
G = 0
for r in reversed(rewards):
G = r + self.gamma * G
returns.insert(0, G)
return np.array(returns)
def update(self, states, actions, rewards):
"""
Update policy using REINFORCE.
"""
states = torch.FloatTensor(np.array(states))
actions = torch.LongTensor(actions)
returns = torch.FloatTensor(self.compute_returns(rewards))
# Normalise returns (reduces variance)
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
# Compute policy gradient
probs = self.policy_network(states)
log_probs = torch.log(probs.gather(1, actions.unsqueeze(1))).squeeze(1)
loss = -(log_probs * returns).mean()
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
return loss.item()
6. Proximal Policy Optimisation (PPO) – The Industry Standard
PPO is a state-of-the-art policy gradient method that constrains policy updates to prevent destructive large changes.
6.1 The PPO ObjectiveL^{CLIP}(θ) = E_t[ min( r_t(θ) * A_t, clip(r_t(θ), 1-ε, 1+ε) * A_t ) ]
where:
-
r_t(θ) = π_θ(a_t | s_t) / π_{θ_old}(a_t | s_t)is the probability ratio. -
A_tis the advantage function (how much better is this action than average). -
εis the clipping hyperparameter (typically 0.1-0.2).
6.2 Advantage Estimation (GAE)A_t = δ_t + γλ δ_{t+1} + (γλ)^2 δ_{t+2} + ...δ_t = R_t + γ V(s_{t+1}) - V(s_t)
where λ is the GAE parameter (typically 0.95).
6.3 PPO Implementation (Simplified)
class PPOAgent:
def __init__(self, state_dim, action_dim, hidden_dim=128, lr=3e-4,
gamma=0.99, lam=0.95, epsilon=0.2, epochs=10):
self.state_dim = state_dim
self.action_dim = action_dim
self.gamma = gamma
self.lam = lam
self.epsilon = epsilon
self.epochs = epochs
# Actor (policy) and Critic (value function)
self.actor = self.build_actor(state_dim, action_dim, hidden_dim)
self.critic = self.build_critic(state_dim, hidden_dim)
self.optimizer = optim.Adam(
list(self.actor.parameters()) + list(self.critic.parameters()),
lr=lr
)
def build_actor(self, state_dim, action_dim, hidden_dim):
return nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, action_dim),
nn.Softmax(dim=-1)
)
def build_critic(self, state_dim, hidden_dim):
return nn.Sequential(
nn.Linear(state_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, hidden_dim),
nn.Tanh(),
nn.Linear(hidden_dim, 1)
)
def get_action(self, state):
state = torch.FloatTensor(state).unsqueeze(0)
probs = self.actor(state)
action = torch.multinomial(probs, 1).item()
log_prob = torch.log(probs[0, action])
return action, log_prob.item()
def compute_gae(self, rewards, values, dones):
"""
Compute Generalised Advantage Estimation (GAE).
"""
advantages = []
gae = 0
for t in reversed(range(len(rewards))):
next_value = 0 if dones[t] else values[t+1]
delta = rewards[t] + self.gamma * next_value - values[t]
gae = delta + self.gamma * self.lam * (1 - dones[t]) * gae
advantages.insert(0, gae)
return np.array(advantages)
def update(self, states, actions, log_probs, rewards, dones, values):
"""
PPO update with clipping.
"""
states = torch.FloatTensor(np.array(states))
actions = torch.LongTensor(actions)
old_log_probs = torch.FloatTensor(log_probs)
# Compute advantages
advantages = self.compute_gae(rewards, values, dones)
advantages = torch.FloatTensor(advantages)
# Normalise advantages
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# PPO update for multiple epochs
for _ in range(self.epochs):
# New log probabilities
probs = self.actor(states)
new_log_probs = torch.log(probs.gather(1, actions.unsqueeze(1))).squeeze(1)
# Probability ratio
ratio = torch.exp(new_log_probs - old_log_probs)
# Surrogate loss
surr1 = ratio * advantages
surr2 = torch.clamp(ratio, 1 - self.epsilon, 1 + self.epsilon) * advantages
actor_loss = -torch.min(surr1, surr2).mean()
# Critic loss (value function)
values_pred = self.critic(states).squeeze(1)
critic_loss = nn.MSELoss()(values_pred, torch.FloatTensor(rewards))
# Total loss
loss = actor_loss + 0.5 * critic_loss
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(
list(self.actor.parameters()) + list(self.critic.parameters()),
0.5
)
self.optimizer.step()
return loss.item()
7. Reward Engineering for Financial RL
The reward function is the most critical design choice. It must align with the financial objective.
7.1 Simple Profit/Loss RewardR_t = P_{t+1} * (S_{t+1} - S_t) / S_t (position times price change)
7.2 Sharpe Ratio Reward
def sharpe_reward(returns, window=20):
"""
Reward based on rolling Sharpe ratio.
"""
if len(returns) < window:
return np.mean(returns)
recent_returns = returns[-window:]
sharpe = np.mean(recent_returns) / (np.std(recent_returns) + 1e-8)
return sharpe
7.3 Risk-Adjusted Reward
def risk_adjusted_reward(returns, risk_free=0.0, risk_penalty=1.0):
"""
Reward = Return - risk_penalty * Risk (standard deviation)
"""
ret = np.mean(returns)
risk = np.std(returns)
return ret - risk_penalty * risk
7.4 Sparse Reward – Only Reward at Episode End
def terminal_reward(portfolio_value, initial_capital):
"""
Only reward at the end of the episode.
"""
return (portfolio_value / initial_capital) - 1
7.5 Multi-Objective Reward
def multi_objective_reward(portfolio_value, initial_capital, holdings, returns):
"""
Combine multiple objectives: return, drawdown, turnover.
"""
profit = (portfolio_value / initial_capital) - 1
# Drawdown penalty
peak_value = max(initial_capital, portfolio_value)
drawdown = (peak_value - portfolio_value) / peak_value
drawdown_penalty = -5 * drawdown
# Turnover penalty (transaction costs)
turnover = np.abs(np.diff(holdings)).sum()
turnover_penalty = -0.001 * turnover # 10 bps per trade
return profit + drawdown_penalty + turnover_penalty
8. Trading Environment Design
8.1 Custom Gym Environment
import gym
from gym import spaces
class TradingEnv(gym.Env):
def __init__(self, data, initial_capital=100000, transaction_cost=0.001, max_position=1.0):
super(TradingEnv, self).__init__()
self.data = data
self.initial_capital = initial_capital
self.transaction_cost = transaction_cost
self.max_position = max_position
# Action space: continuous (position size) or discrete (buy/sell/hold)
# Here we use discrete: -1 (sell), 0 (hold), 1 (buy)
self.action_space = spaces.Discrete(3)
# Observation space: price features, portfolio state
self.observation_space = spaces.Box(
low=-np.inf, high=np.inf, shape=(len(data.columns) + 2,)
)
self.reset()
def reset(self):
self.current_step = 0
self.capital = self.initial_capital
self.position = 0.0
self.portfolio_value = self.initial_capital
self.returns = []
return self._get_observation()
def _get_observation(self):
"""
Construct observation vector.
"""
features = self.data.iloc[self.current_step].values
portfolio_state = np.array([self.position, self.portfolio_value / self.initial_capital])
return np.concatenate([features, portfolio_state])
def step(self, action):
"""
Execute one step in the environment.
"""
# Current price
current_price = self.data.iloc[self.current_step]['Close']
# Next price
next_step = min(self.current_step + 1, len(self.data) - 1)
next_price = self.data.iloc[next_step]['Close']
# Convert action: 0=sell, 1=hold, 2=buy
# In this simple version, action determines position change
action_scale = {0: -0.2, 1: 0, 2: 0.2} # Sell 20%, Hold, Buy 20%
target_position = np.clip(self.position + action_scale[action], -1, 1)
# Transaction cost
trade_size = abs(target_position - self.position)
cost = trade_size * self.transaction_cost * self.portfolio_value
# Execute trade
self.position = target_position
# Update portfolio value
price_change = (next_price / current_price) - 1
self.portfolio_value = self.portfolio_value * (1 + self.position * price_change) - cost
# Reward
reward = (self.portfolio_value - self.initial_capital) / self.initial_capital
# Update step
self.current_step = next_step
done = self.current_step >= len(self.data) - 1
# Additional reward: Sharpe ratio at the end
if done:
reward += self._compute_sharpe_reward()
return self._get_observation(), reward, done, {}
def _compute_sharpe_reward(self):
"""
Compute Sharpe ratio reward at the end of the episode.
"""
if len(self.returns) > 0:
return np.mean(self.returns) / (np.std(self.returns) + 1e-8)
return 0
9. Challenges and Solutions in Financial RL
| Challenge | Description | Solution |
|---|---|---|
| Non-Stationarity | Market dynamics change over time. | Use online learning, continual adaptation, regime detection. |
| Partial Observability | We cannot observe all market drivers. | Use recurrent policies (LSTM policies) that maintain internal state. |
| Transaction Costs | Trading costs erode profits. | Include transaction costs in the reward function. |
| Sparse Rewards | Profit only at the end of trades. | Use dense rewards (paper profits, risk metrics). |
| Sample Inefficiency | RL requires many episodes. | Use offline RL with historical data (batch RL). |
| Overfitting | RL agents can overfit to historical patterns. | Use robust cross-validation, adversarial training. |
| Exploration | Need to explore to find profitable strategies. | Use epsilon-greedy, entropy bonus in policy gradient. |
10. Summary for the AI Practitioner
-
MDPs formalise the trading problem. States are market features; actions are trading decisions; rewards are financial metrics.
-
Q-Learning is for discrete action spaces (buy/sell/hold). Use DQN with Experience Replay and Target Networks for continuous states.
-
Policy Gradient methods (REINFORCE, PPO) are for continuous action spaces (position sizing).
-
PPO is the industry standard. It clips policy updates to prevent destructive large changes.
-
Reward engineering is critical. Use risk-adjusted rewards (Sharpe, drawdown penalty) not just raw profit.
-
Environmental design should include transaction costs, slippage, and realistic market impact.
-
Challenges: Non-stationarity requires continual learning and regime detection. Sample inefficiency requires offline RL or large replay buffers.
-
Production deployment: RL agents must be monitored for drift and retrained periodically.
In Lessons 3.7 and 3.8, we will cover Model Interpretability and Explainability (SHAP, LIME, Integrated Gradients) and Production Pipelines and Deployment (Kubernetes, CI/CD, model monitoring, and regulatory compliance).