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()