Python Code for Portfolio Optimization Chapter 6 – Portfolio Basics
Daniel P. Palomar (2025). Portfolio Optimization: Theory and Application. Cambridge University Press.
Last update: February 9, 2026
Contributors:
Packages¶
The following packages are used in the examples:
# Core numerical computing
import numpy as np
import pandas as pd
# For financial data
import yfinance as yf # Loading financial data
# Book data (pip install "git+https://github.com/dppalomar/pob.git#subdirectory=python")
from pob_python import SP500_stocks_2015to2020, SP500_index_2015to2020
# Plotting
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
sns.set_theme(style="darkgrid")
# Optimization
import cvxpy as cp # interface for convex optimization solvers
import riskparityportfolio as rpp
Some math definitions for the equations:
$\def\bm#1{\boldsymbol{#1}}$ $\def\textm#1{\textsf{#1}}$ $\def\T{{\mkern-2mu\raise-1mu\mathsf{T}}}}$ $\def\R{\mathbb{R}}$ $\def\E{\rm I\kern-.2em E}$ $\def\w{\bm{w}}$ $\def\bmu{\bm{\mu}}}$ $\def\bSigma{\bm{\Sigma}}}$
Preliminaries¶
We start with basic aspects such as data loading and plotting.
Loading market data¶
Loading market data could be conveniently done with the package yfinance:
stocks = yf.download(["AAPL", "AMD", "ADI", "A", "MOH", "CVS", "APD", "AA", "CF"], start='2021-10-01', end='2021-12-31', auto_adjust=False)
stocks[['Adj Close']].head()
| Price | Adj Close | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| Ticker | A | AA | AAPL | ADI | AMD | APD | CF | CVS | MOH |
| Date | |||||||||
| 2021-10-01 | 151.184540 | 47.539185 | 139.515396 | 155.335144 | 102.449997 | 231.163712 | 55.861076 | 72.291573 | 271.510010 |
| 2021-10-04 | 147.850220 | 46.975628 | 136.082565 | 152.448807 | 100.339996 | 229.870605 | 55.495613 | 72.068016 | 269.500000 |
| 2021-10-05 | 148.500412 | 46.851456 | 138.009277 | 153.478989 | 101.809998 | 230.795532 | 55.212379 | 71.921806 | 269.410004 |
| 2021-10-06 | 149.102051 | 44.941090 | 138.879669 | 154.110062 | 103.639999 | 234.144928 | 54.682453 | 71.500481 | 270.510010 |
| 2021-10-07 | 150.722641 | 44.941090 | 140.141357 | 154.973175 | 106.449997 | 236.479645 | 55.422520 | 72.403381 | 277.119995 |
However, for convenience we will use stock data and cryptocurrency data from the official portfolio optimization book package pob_python:
from pob_python import SP500_stocks_2015to2020, cryptos_2017to2021_hourly
# stock S&P500 market data
SP500_stocks_2015to2020.iloc[:, :5].head()
| A | AAL | AAP | AAPL | ABBV | |
|---|---|---|---|---|---|
| Date | |||||
| 2015-01-05 | 37.7523 | 51.0467 | 154.217 | 24.239 | 50.8722 |
| 2015-01-06 | 37.1642 | 50.2556 | 154.108 | 24.241 | 50.6204 |
| 2015-01-07 | 37.6574 | 50.2272 | 157.420 | 24.581 | 52.6663 |
| 2015-01-08 | 38.7862 | 50.8430 | 158.800 | 25.526 | 53.2171 |
| 2015-01-09 | 38.5016 | 49.2891 | 157.991 | 25.553 | 51.7614 |
SP500_stocks_2015to2020[["AMD", "MGM", "AAPL"]].head()
| AMD | MGM | AAPL | |
|---|---|---|---|
| Date | |||
| 2015-01-05 | 2.66 | 19.3206 | 24.239 |
| 2015-01-06 | 2.63 | 18.5833 | 24.241 |
| 2015-01-07 | 2.58 | 19.3112 | 24.581 |
| 2015-01-08 | 2.61 | 19.5853 | 25.526 |
| 2015-01-09 | 2.63 | 19.3868 | 25.553 |
# crypto data
cryptos_2017to2021_hourly.iloc[:, :5].head()
| BTC | ETH | ADA | DOT | XRP | |
|---|---|---|---|---|---|
| Date | |||||
| 2021-01-07 09:00:00 | 37485.61 | 1204.525106 | 0.330998 | 10.005659 | 0.305133 |
| 2021-01-07 10:00:00 | 37040.38 | 1183.403101 | 0.308546 | 9.677910 | 0.323733 |
| 2021-01-07 11:00:00 | 37806.57 | 1201.001309 | 0.311904 | 9.823281 | 0.357990 |
| 2021-01-07 12:00:00 | 37936.25 | 1227.161815 | 0.317906 | 9.966991 | 0.336457 |
| 2021-01-07 13:00:00 | 38154.69 | 1218.126633 | 0.318592 | 9.882065 | 0.328512 |
Plotting data¶
# Plot stock prices
stock_prices = SP500_stocks_2015to2020[["AMD", "MGM", "AAPL"]].loc["2019-10":]
fig, ax = plt.subplots(figsize=(12, 6))
stock_prices.plot(ax=ax)
ax.set_title('Prices of stocks')
ax.set_xlabel(None)
ax.legend(title="Stocks")
plt.show()
stock_returns = stock_prices.pct_change()
# Plot returns
fig, ax = plt.subplots(figsize=(12, 6))
stock_returns.plot(ax=ax)
ax.set_title('Returns of stocks')
ax.set_xlabel(None)
ax.legend(title="Stocks")
plt.show()
# Compute drawdowns
cumulative_returns = (1 + stock_returns).cumprod()
running_max = cumulative_returns.cummax()
drawdowns = (cumulative_returns - running_max) / running_max
# Plot drawdowns
fig, ax = plt.subplots(figsize=(12, 6))
drawdowns.plot(ax=ax)
ax.set_title('Drawdown of stocks')
ax.set_xlabel(None)
ax.legend(title="Stocks")
plt.show()
Backtesting with Base Python¶
We now consider how to perform backtests and explore rebalancing aspects. For illustrative purposes we now backtest the $1/N$ portfolio.
stock_prices = SP500_stocks_2015to2020[["AMD", "MGM", "AAPL", "AMZN", "TSCO"]].loc["2019-10":]
T, N = stock_prices.shape
# linear returns
X = stock_prices / stock_prices.shift(1) - 1
X = X.dropna()
# portfolio
w = np.repeat(1/N, N)
Naive backtesting¶
# naive backtest (assuming daily rebalancing and no transaction cost)
portf_ret = X @ w
portf_ret.head()
Date 2019-10-02 -0.013131 2019-10-03 0.012374 2019-10-04 0.009848 2019-10-07 -0.003872 2019-10-08 -0.016877 dtype: float64
However, multiplying the matrix of linear returns of the assets X by the portfolio w implicitly assumes that we are rebalancing at every period (and also ignoring the transaction costs). To perform more realistic backtests, we now define a rolling-window backtest function that rebalances the portfolio every certain number of periods using a specified lookback window of the data (as well as transaction costs).
def EWP(X):
N = X.shape[1]
return np.repeat(1/N, N)
def backtest_single_portfolio(portfolio_func, portfolio_name, prices, lookback, rebalance_every=1, cost_bps=0):
N = prices.shape[1]
# Calculate returns
X = prices.pct_change().dropna()
# Initialize variables
current_w = np.repeat(0, N)
w = pd.DataFrame(index=X.index, columns=X.columns)
portf_ret = pd.Series(index=X.index)
portf_ret.name = portfolio_name
for t in range(lookback, len(X)):
# Rebalance portfolio if necessary
if (t - lookback) % rebalance_every == 0:
new_w = portfolio_func(X.iloc[t-lookback:t]) # Note that the row at time t is not included
turnover = np.abs(new_w - current_w).sum()
transaction_cost = turnover * cost_bps / 1e4
current_w = new_w
# Store weights
w.iloc[t] = current_w
# Calculate portfolio return for this period
period_return = (X.iloc[t] * current_w).sum()
portf_ret.iloc[t] = period_return - transaction_cost
# Update normalized weights based on asset performance
current_w = current_w * (1 + X.iloc[t])
current_w = current_w / current_w.sum()
return portf_ret, w
As a sanity check, let’s start by reproducing the previous naive backtest assuming daily rebalancing (note that we choose a lookback of 0 since the $1/N$ portfolio does not really need any data):
bt_ret, bt_w = backtest_single_portfolio(EWP, "1/N", stock_prices, lookback=0, rebalance_every=1)
bt_ret.head(8)
Date 2019-10-02 -0.013131 2019-10-03 0.012374 2019-10-04 0.009848 2019-10-07 -0.003872 2019-10-08 -0.016877 2019-10-09 0.011320 2019-10-10 0.006912 2019-10-11 0.023625 Name: 1/N, dtype: float64
Rebalancing in backtesting¶
Now we can perform a more realistic backtest rebalancing, say, every week (i.e., 5 days), including transaction costs:
bt_ret, bt_w = backtest_single_portfolio(EWP, "1/N", stock_prices.iloc[1:], lookback=0, rebalance_every=5, cost_bps=30e-4)
# Note: We exclude the first row for a simpler comparison later with skfolio
bt_ret.head(8)
Date 2019-10-03 0.012373 2019-10-04 0.009814 2019-10-07 -0.003875 2019-10-08 -0.016830 2019-10-09 0.011288 2019-10-10 0.006912 2019-10-11 0.023620 2019-10-14 0.003571 Name: 1/N, dtype: float64
def plot_cum_return(returns):
cumulative_returns = (1 + returns).cumprod() - 1
fig, ax = plt.subplots(figsize=(12, 6))
cumulative_returns.plot(ax=ax)
ax.set_title('Cumulative Return')
ax.set_xlabel(None)
ax.legend(title="portfolio")
plt.show()
plot_cum_return(bt_ret)
def plot_drawdown(returns):
cumulative_returns = (1 + returns).cumprod()
running_max = cumulative_returns.cummax()
drawdowns = (cumulative_returns - running_max) / running_max
fig, ax = plt.subplots(figsize=(12, 6))
drawdowns.plot(ax=ax)
ax.set_title('Drawdown')
ax.set_xlabel(None)
ax.legend(title="portfolio")
plt.show()
plot_drawdown(bt_ret)
Let's observe the evolution of the $1/N$ portfolio over time for a universe of 5 stocks, showing the effect of rebalancing (indicated with black vertical lines):
# Run backtest
bt_ret, bt_w = backtest_single_portfolio(EWP, "1/N", stock_prices, lookback=0, rebalance_every=90)
# Filter the weights DataFrame for the desired date range
filtered_w = bt_w.loc["2020-01":"2020-08"]
# Create the plot
fig, ax = plt.subplots(figsize=(12, 6))
# Calculate bar width (in days)
bar_width = (filtered_w.index[-1] - filtered_w.index[0]).days / len(filtered_w)
# Plot stacked bars
bottom = np.zeros(len(filtered_w))
for column in filtered_w.columns:
ax.bar(filtered_w.index, filtered_w[column], bottom=bottom, width=3*bar_width, label=column, edgecolor='none')
bottom += filtered_w[column]
# Customize the x-axis
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y'))
plt.xticks(rotation=45, ha='right')
# Customize other aspects of the plot
plt.title("Weight Allocation Over Time for Portfolio 1/N")
plt.xlabel(None)
plt.ylabel("Weight")
plt.legend(title="Stocks", bbox_to_anchor=(1.05, 1), loc='upper left')
# Add vertical lines for specific dates
plt.axvline(pd.to_datetime("2020-02-11"), color="black", linestyle="-", linewidth=3)
plt.axvline(pd.to_datetime("2020-06-19"), color="black", linestyle="-", linewidth=3)
plt.axhline(0.0, color="black", linestyle="--")
plt.axhline(0.2, color="black", linestyle="--")
plt.axhline(0.4, color="black", linestyle="--")
plt.axhline(0.6, color="black", linestyle="--")
plt.axhline(0.8, color="black", linestyle="--")
plt.axhline(1.0, color="black", linestyle="--")
# Adjust layout and display
plt.tight_layout()
plt.show()
Backtest function¶
Let's now rewrite the backtest function to accept multiple portfolios:
def backtest(portfolio_funcs, prices, lookback, rebalance_every=1, cost_bps=0):
N = prices.shape[1]
X = prices.pct_change().dropna()
portf_rets = {}
ws = {}
for portfolio_name, portfolio_func in portfolio_funcs.items():
# Initialize variables
current_w = np.repeat(0, N)
w = pd.DataFrame(index=X.index, columns=X.columns)
portf_ret = pd.Series(index=X.index)
portf_ret.name = portfolio_name
for t in range(lookback, len(X)):
# Rebalance portfolio if necessary
if (t - lookback) % rebalance_every == 0:
new_w = portfolio_func(X.iloc[t-lookback:t]) # Note that the row at time t is not included
turnover = np.abs(new_w - current_w).sum()
transaction_cost = turnover * cost_bps / 1e4
current_w = new_w
# Store weights
w.iloc[t] = current_w
# Calculate portfolio return for this period
period_return = (X.iloc[t] * current_w).sum()
portf_ret.iloc[t] = period_return - transaction_cost
# Update normalized weights based on asset performance
current_w = current_w * (1 + X.iloc[t])
current_w = current_w / current_w.sum()
# Keep a record (remove inital NaNs)
portf_rets[portfolio_name] = portf_ret[lookback:]
ws[portfolio_name] = w[lookback:]
# Combine all portfolio returns into a single DataFrame
portf_rets_df = pd.DataFrame(portf_rets)
return portf_rets_df, ws
Backtesting via skfolio¶
We now consider how to perform backtests and explore rebalancing aspects. For illustrative purposes we consider again the $1/N$ portfolio.
Naive backtesting¶
from skfolio.optimization import EqualWeighted
import plotly.io as pio
pio.renderers.default = "notebook"
pio.templates.default = "plotly_white"
model_ewp = EqualWeighted()
model_ewp.fit(X)
print("Weights of the equally weighted portfolio:")
print(np.round(model_ewp.weights_, 4))
Weights of the equally weighted portfolio: [0.2 0.2 0.2 0.2 0.2]
# Run backtest
bt_ewp = model_ewp.predict(X)
# Display first returns
bt_ewp.returns_df.head()
2019-10-02 -0.013131 2019-10-03 0.012374 2019-10-04 0.009848 2019-10-07 -0.003872 2019-10-08 -0.016877 Name: returns, dtype: float64
# Plot cumulative returns
bt_ewp.plot_cumulative_returns()
Rebalancing in backtesting¶
Now we can perform a more realistic backtest rebalancing, say, every week (i.e., 5 days), including transaction costs. This is called walkforward backtest.
# WalkForward backtest
from skfolio.model_selection import cross_val_predict, WalkForward
model_ewp = EqualWeighted(
portfolio_params=dict(name="EWP", transaction_costs=30e-4 / 5)
)
bt_ewp = cross_val_predict(
model_ewp,
X,
cv=WalkForward(test_size=5, train_size=1),
)
# Note: It should be train_size=0 for EqualWeighted(), but this is not allowed in skfolio
# Show last designed portfolio
bt_ewp[-1].composition
| EWP | |
|---|---|
| asset | |
| AMD | 0.2 |
| MGM | 0.2 |
| AAPL | 0.2 |
| AMZN | 0.2 |
| TSCO | 0.2 |
# Plot last designed portfolio
bt_ewp[-1].plot_composition()
import plotly.graph_objects as go
# Get the composition DataFrame
df = bt_ewp[-1].composition # shape: (n_assets, 1) with portfolio name as column
# Create vertical bar chart
fig = go.Figure(go.Bar(
x=df.index, # asset names on x-axis
y=df.iloc[:, 0] # weights on y-axis
))
fig.update_layout(
title="Portfolio Composition",
xaxis_title="Assets",
yaxis_title="Weight"
)
fig.show()
bt_ewp.returns_df.head(8)
2019-10-03 0.011774 2019-10-04 0.009248 2019-10-07 -0.004472 2019-10-08 -0.017477 2019-10-09 0.010720 2019-10-10 0.006312 2019-10-11 0.023025 2019-10-14 0.002906 Name: returns, dtype: float64
# Plot cumulative returns
bt_ewp.compounded = True
bt_ewp.clear() # to recalculate the cumulative returns
fig = bt_ewp.plot_cumulative_returns()
fig.update_layout(title="Wealth", xaxis_title=None, yaxis_title="Net Asset Value (NAV)")
fig
# Plot drawdowns
bt_ewp.plot_drawdowns()
Let's observe the evolution of the $1/N$ portfolio over time for a universe of 5 stocks, showing the effect of rebalancing (every 90 days).
Note: The library skfolio recalculates and reoptimizes the portfolio weights as indicated in the argument test_size=90 as expected. However, by default, during these 90 days, the weights remain fixed and do not drift as the prices evolve (see discussion). This choice will eventually be controllable via an argument in skfolio. Be aware of this detail.
# WalkForward backtest rebalancing every 90 days
bt_ewp = cross_val_predict(
model_ewp,
X,
cv=WalkForward(test_size=90, train_size=1),
)
# Plot last designed portfolio
bt_ewp[-1].plot_composition()
# Plot weights evolution over time
bt_ewp.plot_weights_per_observation()
Heuristic Portfolios¶
We now compare the following heuristic portfolios:
- $1/N$ portfolio: $$ \w = \frac{1}{N}\bm{1}; $$
- GMRP: $$ \begin{array}{ll} \underset{\w}{\textm{maximize}} & \w^\T\bmu\\ \textm{subject to} & \bm{1}^\T\w=1, \quad \w\ge\bm{0}; \end{array} $$
- Quintile portfolio (sorting stocks by $\bm{\mu}$): $$ \w = \frac{1}{N/5}\left[\begin{array}{c} \begin{array}{c} 1\\ \vdots\\ 1 \end{array}\\ \begin{array}{c} 0\\ \vdots\\ 0 \end{array} \end{array}\right]\begin{array}{c} \left.\begin{array}{c} \\ \\ \\ \end{array}\right\} 20\%\\ \left.\begin{array}{c} \\ \\ \\ \end{array}\right\} 80\% \end{array} $$
- Quintile portfolio (sorting stocks by $\bm{\mu}/\bm{\sigma}$); and
- Quintile portfolio (sorting stocks by $\bm{\mu}/\bm{\sigma}^2$).
# Get data
from pob_python import SP500_stocks_2015to2020, SP500_index_2015to2020
stock_prices = SP500_stocks_2015to2020[
["AAPL", "AMZN", "AMD", "GM", "GOOGL", "MGM", "MSFT", "QCOM", "TSCO", "UPS"]
].loc["2019":]
T = stock_prices.shape[0]
T_trn = round(0.70*T)
# Define heuristic portfolios
def EWP(X):
N = X.shape[1]
return np.repeat(1/N, N)
def QuintP_mu(X_lin):
X_log = np.log(1 + X_lin)
mu = X_log.mean()
idx = mu.argsort()[::-1]
w = np.zeros(len(mu))
w[idx[:len(mu)//5]] = 1 / (len(mu)//5)
return w
def QuintP_mu_over_sigma(X_lin):
X_log = np.log(1 + X_lin)
mu = X_log.mean()
sigma = X_log.std()
idx = (mu / sigma).argsort()[::-1]
w = np.zeros(len(mu))
w[idx[:len(mu)//5]] = 1 / (len(mu)//5)
return w
def QuintP_mu_over_sigma2(X_lin):
X_log = np.log(1 + X_lin)
mu = X_log.mean()
sigma = X_log.std()
idx = (mu / sigma**2).argsort()[::-1]
w = np.zeros(len(mu))
w[idx[:len(mu)//5]] = 1 / (len(mu)//5)
return w
def GMRP(X_lin):
X_log = np.log(1 + X_lin)
mu = X_log.mean()
w = np.zeros(len(mu))
w[mu.argmax()] = 1
return w
#
# Run backtest using skfolio
#
# Create wrapper to backtest multiple backtests (currently not available in skfolio)
import pandas as pd
import time
from datetime import timedelta
from skfolio.model_selection import cross_val_predict
def batch_cross_val_predict(optimizers, X, cv, **kwargs) -> list:
"""Run cross_val_predict on multiple optimizers with timing information"""
bt_list = []
n_splits = cv.get_n_splits(X)
total_start_time = time.time()
print(f"\n🚀 Starting batch backtesting with {len(optimizers)} portfolio designs...")
print(f"📊 Cross-validation: {n_splits} splits")
print("=" * 70)
for i, optim in enumerate(optimizers, 1):
# Handle missing or None portfolio_params (fixes the original error)
if hasattr(optim, 'portfolio_params') and optim.portfolio_params is not None:
name = optim.portfolio_params.get('name', f'Portfolio {i}')
else:
name = f'Portfolio {i} ({type(optim).__name__})'
print(f"\n[{i}/{len(optimizers)}] 🏃 Backtesting '{name}'...")
# Start timing for this specific backtest
start_time = time.time()
try:
bt = cross_val_predict(
optim, X, cv=cv,
portfolio_params=getattr(optim, 'portfolio_params', {}),
**kwargs
)
# Calculate elapsed time
elapsed_time = time.time() - start_time
elapsed_str = str(timedelta(seconds=int(elapsed_time)))
bt_list.append(bt)
print(f"✅ Completed '{name}' in {elapsed_str}")
except Exception as e:
elapsed_time = time.time() - start_time
elapsed_str = str(timedelta(seconds=int(elapsed_time)))
print(f"❌ Failed '{name}' after {elapsed_str}: {str(e)}")
raise # Re-raise the exception to maintain original behavior
# Calculate total elapsed time
total_elapsed = time.time() - total_start_time
total_elapsed_str = str(timedelta(seconds=int(total_elapsed)))
print("=" * 70)
print(f"🎉 Batch backtesting completed!")
print(f"⏱️ Total time: {total_elapsed_str}")
print(f"📈 Successfully processed {len(bt_list)} portfolios")
return bt_list
# Create wrapper to plot portfolio compositions as a barplot
def plot_composition_sidebyside(population, asset_names=None):
"""
Plot portfolio compositions side-by-side for a Population of portfolios.
Parameters
----------
population : Population
A skfolio Population object containing multiple portfolios.
asset_names : list of str, optional
List of asset names in the desired order. If provided, all assets
will be displayed (with zero weights for missing assets).
Returns
-------
fig : plotly.graph_objects.Figure
The plotly figure object.
"""
import plotly.graph_objects as go
import pandas as pd
# Determine the asset order
if asset_names is None:
asset_names = population[0].composition.index.tolist()
fig = go.Figure()
# Add a bar trace for each portfolio
for portfolio in population:
df = portfolio.composition # shape: (n_assets, 1)
# Reindex to include all assets in the specified order, filling missing with 0
weights = df.iloc[:, 0].reindex(asset_names, fill_value=0)
fig.add_trace(go.Bar(
x=asset_names, # asset names in specified order
y=weights, # weights (0 for missing assets)
name=portfolio.name # legend label (portfolio name)
))
fig.update_layout(
title="Portfolio Compositions",
xaxis_title="Assets",
yaxis_title="Weight",
barmode='group', # bars side-by-side (not stacked)
xaxis={'categoryorder': 'array', # enforce asset order
'categoryarray': asset_names}
)
return fig
# Create a class to be able to specify portfolios direclty via a CVXPY code
from skfolio.optimization import ConvexOptimization
class Portfolio_via_CVXPY(ConvexOptimization):
"""Portfolio based on custom CVXPY function."""
def __init__(self, cvxpy_fun=None, **kwargs):
super().__init__(**kwargs)
self.cvxpy_fun = cvxpy_fun
def fit(self, X, y=None):
"""Fit the custom optimization problem using the provided function."""
if self.cvxpy_fun is None:
raise ValueError("cvxpy_fun must be provided at initialization")
# Call the custom function to compute weights
weights = self.cvxpy_fun(X)
# Store results
self.weights_ = np.asarray(weights)
return self
from skfolio.optimization import MeanRisk, EqualWeighted, ConvexOptimization
from skfolio import Population
from skfolio.model_selection import cross_val_predict, WalkForward
from skfolio.preprocessing import prices_to_returns
# Wrap each portfolio within skfolio
portf_EWP_skfolio = EqualWeighted(
portfolio_params=dict(name="1/N (skfolio)", compounded=True)
)
portf_EWP = Portfolio_via_CVXPY(
cvxpy_fun=EWP,
portfolio_params=dict(name="1/N", compounded=True)
)
portf_GMRP = Portfolio_via_CVXPY(
cvxpy_fun=GMRP,
portfolio_params=dict(name="GMRP", compounded=True)
)
portf_QuintP_mu = Portfolio_via_CVXPY(
cvxpy_fun=QuintP_mu,
portfolio_params=dict(name="QuintP (sorted by mu)", compounded=True)
)
portf_QuintP_mu_over_sigma = Portfolio_via_CVXPY(
cvxpy_fun=QuintP_mu_over_sigma,
portfolio_params=dict(name="QuintP (sorted by mu/sigma)", compounded=True)
)
portf_QuintP_mu_over_sigma2 = Portfolio_via_CVXPY(
cvxpy_fun=QuintP_mu_over_sigma2,
portfolio_params=dict(name="QuintP (sorted by mu/sigma2)", compounded=True)
)
all_formulations = [
portf_EWP,
portf_GMRP,
portf_QuintP_mu,
portf_QuintP_mu_over_sigma,
portf_QuintP_mu_over_sigma2,
]
n_portfolios = len(all_formulations)
# WalkForward backtest
bt_list = batch_cross_val_predict(
all_formulations,
X=prices_to_returns(stock_prices), #or: stock_prices.pct_change(fill_method=None)
cv=WalkForward(test_size=30, train_size=T_trn, purged_size=1),
)
bt_population = Population(bt_list)
🚀 Starting batch backtesting with 5 portfolio designs... 📊 Cross-validation: 4 splits ====================================================================== [1/5] 🏃 Backtesting '1/N'... ✅ Completed '1/N' in 0:00:00 [2/5] 🏃 Backtesting 'GMRP'... ✅ Completed 'GMRP' in 0:00:00 [3/5] 🏃 Backtesting 'QuintP (sorted by mu)'... ✅ Completed 'QuintP (sorted by mu)' in 0:00:00 [4/5] 🏃 Backtesting 'QuintP (sorted by mu/sigma)'... ✅ Completed 'QuintP (sorted by mu/sigma)' in 0:00:00 [5/5] 🏃 Backtesting 'QuintP (sorted by mu/sigma2)'... ✅ Completed 'QuintP (sorted by mu/sigma2)' in 0:00:00 ====================================================================== 🎉 Batch backtesting completed! ⏱️ Total time: 0:00:00 📈 Successfully processed 5 portfolios
# Extract last portfolio composition of each portfolio model
last_portfolios = Population([
setattr(mpp[-1], 'name', mpp.name) or mpp[-1]
for mpp in bt_population
])
# Plot stacked barplot
last_portfolios.plot_composition()
# Plot side-by-side barplot
plot_composition_sidebyside(last_portfolios)
# Portfolio NAV (Net Asset Value)
nav_df = bt_population.cumulative_returns_df()
nav_df.head()
| 1/N | GMRP | QuintP (sorted by mu) | QuintP (sorted by mu/sigma) | QuintP (sorted by mu/sigma2) | |
|---|---|---|---|---|---|
| 2020-03-20 | 0.994526 | 0.994726 | 0.965617 | 0.965617 | 0.949474 |
| 2020-03-23 | 0.999542 | 1.045706 | 0.980104 | 0.980104 | 0.934653 |
| 2020-03-24 | 1.102685 | 1.160723 | 1.083177 | 1.083177 | 1.024023 |
| 2020-03-25 | 1.091181 | 1.120794 | 1.061557 | 1.061557 | 1.016297 |
| 2020-03-26 | 1.156108 | 1.192868 | 1.123626 | 1.123626 | 1.074827 |
# Summary
bt_population.summary().loc[[
'Annualized Sharpe Ratio',
'CVaR at 95%',
'MAX Drawdown',
'Annualized Mean',
'Annualized Standard Deviation'
]].T
| Annualized Sharpe Ratio | CVaR at 95% | MAX Drawdown | Annualized Mean | Annualized Standard Deviation | |
|---|---|---|---|---|---|
| GMVP | 4.32 | 3.46% | 8.85% | 125.75% | 29.10% |
| IVP | 4.01 | 4.16% | 9.09% | 133.60% | 33.31% |
| RPP | 4.06 | 4.09% | 8.83% | 134.33% | 33.07% |
| MDP | 4.45 | 3.88% | 7.84% | 144.42% | 32.46% |
| MDCP | 4.22 | 4.40% | 9.25% | 156.81% | 37.14% |
# Plot NAV
fig = bt_population.plot_cumulative_returns()
fig.update_layout(title="Wealth", xaxis_title=None, yaxis_title="Net Asset Value (NAV)")
fig
# Plot drawdowns
bt_population.plot_drawdowns()
Risk-Based Portfolios¶
We now compare the following risk-based portfolios:
Global minimum variance portfolio (GMVP): $$ \begin{array}{ll} \underset{\w}{\textm{minimize}} & \w^\T\bSigma\w\\ \textm{subject to} & \bm{1}^\T\w=1, \quad \w\ge\bm{0}; \end{array} $$
Inverse volatility portfolio (IVP): $$ \w = \frac{\bm{\sigma}^{-1}}{\bm{1}^\T\bm{\sigma}^{-1}}; $$
Risk parity portfolio (RPP);
Most diversified portfolio (MDP): $$ \begin{array}{ll} \underset{\w}{\textm{minimize}} & \dfrac{\w^\T\bm{\sigma}}{\sqrt{\w^\T\bmu\w}}\\ \textm{subject to} & \bm{1}^\T\w=1, \quad \w\ge\bm{0}; \end{array} $$
Maximum decorrelation portfolio (MDCP): $$ \begin{array}{ll} \underset{\w}{\textm{minimize}} & \w^\T\bm{C}\w\\ \textm{subject to} & \bm{1}^\T\w=1, \quad \w\ge\bm{0}, \end{array} $$ where $\bm{C} = \textm{Diag}\left(\bSigma\right)^{-1/2} \bSigma \textm{Diag}\left(\bSigma\right)^{-1/2}.$
# Define risk-based portfolios
def GMVP(X_lin):
# Estimate parameters
X_log = np.log(1 + X_lin)
Sigma = X_log.cov().values
# Optimize portfolio
N = len(Sigma)
w = cp.Variable(N)
prob = cp.Problem(cp.Minimize(cp.quad_form(w, Sigma)),
[cp.sum(w) == 1, w >= 0])
prob.solve()
return w.value
def IVP(X_lin):
# Estimate parameters
X_log = np.log(1 + X_lin)
sigma = X_log.std()
# Design portfolio
w = 1 / sigma
return w / w.sum()
def RPP(X_lin):
# Estimate parameters
X_log = np.log(1 + X_lin)
Sigma = X_log.cov().values
# Optimize portfolio
N = len(Sigma)
my_portfolio = rpp.RiskParityPortfolio(covariance=Sigma, weights=np.ones(N) / N, budget=np.ones(N) / N)
my_portfolio.design()
return my_portfolio.weights
def MDP(X_lin):
# Estimate parameters
X_log = np.log(1 + X_lin)
Sigma = X_log.cov().values
mu = np.sqrt(np.diag(Sigma))
# Optimize portfolio
N = len(Sigma)
w = cp.Variable(N)
prob = cp.Problem(cp.Minimize(cp.quad_form(w, Sigma)),
[mu @ w == 1, w >= 0])
prob.solve()
return w.value / w.value.sum()
def MDCP(X_lin):
# Estimate parameters
X_log = np.log(1 + X_lin)
Sigma = X_log.cov().values
D = np.diag(1 / np.sqrt(np.diag(Sigma)))
C = D @ Sigma @ D
# Optimize portfolio
N = len(C)
w = cp.Variable(N)
prob = cp.Problem(cp.Minimize(cp.quad_form(w, C)),
[cp.sum(w) == 1, w >= 0])
prob.solve()
return w.value
from skfolio.optimization import InverseVolatility, MeanRisk, EqualWeighted, ConvexOptimization
from skfolio import Population
from skfolio.model_selection import cross_val_predict, WalkForward
from skfolio.preprocessing import prices_to_returns
# Wrap each portfolio within skfolio
portf_GMVP = Portfolio_via_CVXPY(
cvxpy_fun=GMVP,
portfolio_params=dict(name="GMVP", compounded=True)
)
portf_GMVP_skfolio = MeanRisk(
portfolio_params=dict(name="GMVP (skfolio)", compounded=True)
) # Default is minimum variance
portf_IVP = Portfolio_via_CVXPY(
cvxpy_fun=IVP,
portfolio_params=dict(name="IVP", compounded=True)
)
portf_IVP_skfolio = InverseVolatility(
portfolio_params=dict(name="IVP (skfolio)", compounded=True)
)
portf_RPP = Portfolio_via_CVXPY(
cvxpy_fun=RPP,
portfolio_params=dict(name="RPP", compounded=True)
)
portf_MDP = Portfolio_via_CVXPY(
cvxpy_fun=MDP,
portfolio_params=dict(name="MDP", compounded=True)
)
portf_MDCP = Portfolio_via_CVXPY(
cvxpy_fun=MDCP,
portfolio_params=dict(name="MDCP", compounded=True)
)
all_formulations = [
portf_GMVP,
#portf_GMVP_skfolio,
portf_IVP,
#portf_IVP_skfolio,
portf_RPP,
portf_MDP,
portf_MDCP,
]
n_portfolios = len(all_formulations)
# WalkForward backtest
bt_list = batch_cross_val_predict(
all_formulations,
X=prices_to_returns(stock_prices), #or: stock_prices.pct_change(fill_method=None)
cv=WalkForward(test_size=30, train_size=T_trn, purged_size=1),
)
bt_population = Population(bt_list)
🚀 Starting batch backtesting with 5 portfolio designs... 📊 Cross-validation: 4 splits ====================================================================== [1/5] 🏃 Backtesting 'GMVP'... ✅ Completed 'GMVP' in 0:00:00 [2/5] 🏃 Backtesting 'IVP'... ✅ Completed 'IVP' in 0:00:00 [3/5] 🏃 Backtesting 'RPP'...
✅ Completed 'RPP' in 0:00:00 [4/5] 🏃 Backtesting 'MDP'... ✅ Completed 'MDP' in 0:00:00 [5/5] 🏃 Backtesting 'MDCP'... ✅ Completed 'MDCP' in 0:00:00 ====================================================================== 🎉 Batch backtesting completed! ⏱️ Total time: 0:00:00 📈 Successfully processed 5 portfolios
# Extract last portfolio composition of each portfolio model
last_portfolios = Population([
setattr(mpp[-1], 'name', mpp.name) or mpp[-1]
for mpp in bt_population
])
# Plot side-by-side barplot
plot_composition_sidebyside(last_portfolios, asset_names=stock_prices.columns)
# Summary
bt_population.summary().loc[[
'Annualized Sharpe Ratio',
'CVaR at 95%',
'MAX Drawdown',
'Annualized Mean',
'Annualized Standard Deviation'
]].T
| GMVP | IVP | RPP | MDP | MDCP | |
|---|---|---|---|---|---|
| Annualized Sharpe Ratio | 4.32 | 4.01 | 4.06 | 4.45 | 4.22 |
| CVaR at 95% | 3.46% | 4.16% | 4.09% | 3.88% | 4.40% |
| MAX Drawdown | 8.85% | 9.09% | 8.83% | 7.84% | 9.25% |
| Annualized Mean | 125.75% | 133.60% | 134.33% | 144.42% | 156.81% |
| Annualized Standard Deviation | 29.10% | 33.31% | 33.07% | 32.46% | 37.14% |
# Plot NAV
fig = bt_population.plot_cumulative_returns()
fig.update_layout(title="Wealth", xaxis_title=None, yaxis_title="Net Asset Value (NAV)")
fig
# Plot drawdowns
bt_population.plot_drawdowns()