Portfolio Optimization with PyBroker

Ed West
5 min readJul 7, 2024

--

Portfolio optimization is a method for allocating assets in a portfolio in order to meet specific objectives. For example, it can be used to construct a portfolio of assets with the objective of minimizing risk while also maximizing returns.

Portfolio optimization can be a useful technique for periodically rebalancing a portfolio of stocks. This approach allows us to buy and sell shares in the most optimal way to meet our desired objectives for the portfolio.

In this article, we will look at using Python and PyBroker to simulate a trading strategy that rebalances a portfolio at the beginning of every month.

Setup

To simulate the trading strategy, we can use a backtesting framework in Python. We will use PyBroker, an open-source Python framework for developing trading strategies. To begin using PyBroker, you can install the library with pip:

pip install -U lib-pybroker

Next, let’s install Riskfolio-Lib, which will be used to perform portfolio optimization for the strategy we will implement in PyBroker:

pip install -U riskfolio-lib

After installing the above packages, let’s create a new notebook with the required imports:

import pandas as pd
import pybroker as pyb
import riskfolio as rp
from datetime import datetime
from pybroker import ExecContext, Strategy, YFinance

We can also enable data caching in PyBroker. This will cache the historical data that will be downloaded from Yahoo Finance for testing our strategy:

pyb.enable_data_source_cache('rebalancing')

Rebalancing Positions

Let’s begin with writing a simple strategy in PyBroker that rebalances a long-only portfolio at the beginning of every month using equal position sizing. In other words, at the beginning of every month, our strategy will buy or sell enough shares to guarantee that each stock in our portfolio has a roughly equal allocation.

We first start by implementing a function that will either buy or sell enough shares of a stock to reach a target allocation.

def set_target_shares(
ctxs: dict[str, ExecContext], # Ticker symbol -> ExecContext
targets: dict[str, float] # Ticker symbol -> target weight
):
for symbol, target in targets.items():
ctx = ctxs[symbol]
# Calculate target shares using target weight.
target_shares = ctx.calc_target_shares(target)
pos = ctx.long_pos()
# Buy if no current long position in the stock.
if pos is None:
ctx.buy_shares = target_shares
# Otherwise, buy enough shares to reach the target.
elif pos.shares < target_shares:
ctx.buy_shares = target_shares - pos.shares
# Or sell enough shares to reach the target.
elif pos.shares > target_shares:
ctx.sell_shares = pos.shares - target_shares

If the current allocation is above the target level, the function will sell the needed shares of the asset, while if the current allocation is below the target level, the function will buy the needed shares.

Next, we write a rebalance function to target each stock to an equal allocation at the beginning of every month:

def rebalance(ctxs: dict[str, ExecContext]):
if start_of_month(ctxs):
target = 1 / len(ctxs) # Equal weighting
set_target_shares(ctxs, {symbol: target for symbol in ctxs.keys()})

Then we implement the helper function to detect the start of a new month:

def start_of_month(ctxs: dict[str, ExecContext]) -> bool:
dt = tuple(ctxs.values())[0].dt
if dt.month != pyb.param('current_month'):
pyb.param('current_month', dt.month)
return True
return False

Now, we can use these functions to backtest a rebalancing strategy for a portfolio of five stocks.

strategy = Strategy(YFinance(), start_date='1/1/2018', end_date='1/1/2023')
strategy.add_execution(None, ['TSLA', 'NFLX', 'AAPL', 'NVDA', 'AMZN'])
strategy.set_after_exec(rebalance)
result = strategy.backtest()

After running the backtest, we can view the orders that were placed:

result.orders
     type symbol date        shares  limit_price  fill_price  fees
id
1 buy NFLX 2018-01-03 99 NaN 203.86 0.0
2 buy AAPL 2018-01-03 464 NaN 43.31 0.0
3 buy TSLA 2018-01-03 935 NaN 21.36 0.0
4 buy AMZN 2018-01-03 336 NaN 59.84 0.0
5 buy NVDA 2018-01-03 376 NaN 52.18 0.0
... ... ... ... ... ... ... ...
292 sell NFLX 2022-12-02 15 NaN 315.99 0.0
293 sell NVDA 2022-12-02 97 NaN 166.89 0.0
294 buy AAPL 2022-12-02 27 NaN 146.82 0.0
295 buy TSLA 2022-12-02 70 NaN 193.68 0.0
296 buy AMZN 2022-12-02 41 NaN 94.57 0.0

And we can view performance metrics for evaluating our strategy:

result.metrics_df
trade_count                 207.000000
initial_market_value 100000.000000
end_market_value 320498.810000
total_pnl 305804.840000
total_return_pct 305.804840
max_drawdown -332039.770000
max_drawdown_pct -52.068777

Using Portfolio Optimization

Instead of allocating each stock to an equal position size in our portfolio, let’s use portfolio optimization to determine the allocation for each stock.

We can use Riskfolio-Lib to calculate how much of our portfolio should be allocated to each stock to minimize risk. This can be done by measuring the historical risk associated with each stock.

Conditional Value at Risk (CVaR) is one way to assess the risk of each stock from historical returns. It provides an estimate of expected losses in the worst-case scenarios beyond a given confidence level. For example, CVaR can estimate the average loss in the worst 5% of scenarios when considering a 95% confidence level.

Below we use RiskFolio-Lib to allocate shares to minimize the portfolio’s CVaR using the past year of returns.

pyb.param('lookback', 252)  # Use past year of returns -> 252 bars.

def calculate_returns(ctxs: dict[str, ExecContext], lookback: int):
prices = {}
for ctx in ctxs.values():
prices[ctx.symbol] = ctx.adj_close[-lookback:]
df = pd.DataFrame(prices)
return df.pct_change().dropna()

def optimization(ctxs: dict[str, ExecContext]):
if start_of_month(ctxs):
Y = calculate_returns(ctxs, lookback)
port = rp.Portfolio(returns=Y)
port.assets_stats(method_mu='hist', method_cov='hist', d=0.94)
# Get target weights after minimizing CVaR.
w = port.optimization(
model='Classic',
rm='CVaR',
obj='MinRisk',
rf=0, # Risk free rate.
l=0, # Risk aversion factor.
hist=True # Use historical scenarios.
)
targets = {
symbol: w.T[symbol].values[0]
for symbol in ctxs.keys()
}
set_target_shares(ctxs, targets)

You can find more information and examples of using Riskfolio-Lib on its official documentation. Now, let’s move on to backtesting the strategy!

strategy.set_after_exec(optimization)
result = strategy.backtest(warmup=pyb.param('lookback')

Here we examine the new strategy’s performance metrics:

trade_count                     100.000000
initial_market_value 100000.000000
end_market_value 201318.070000
total_pnl 139465.420000
total_return_pct 139.465420
max_drawdown -106042.150000
max_drawdown_pct -35.190829

Interesting! While the returns of this strategy are less than the previous one using equal position sizing (139% vs 305%), we also see that its maximum drawdown is lower (35% vs 52%). Even though minimizing CVaR did significantly decrease the overall returns of our portfolio, it also significantly decreased our portfolio’s downside!

And that concludes this article! You should now be well equipped to use portfolio optimization in your own trading strategies. You can also find more tutorials on using PyBroker on https://www.pybroker.com, with all of its code available in the Github repository.

Thanks for reading!

--

--