Python Code for Portfolio Optimization
Chapter 6 – Portfolio Basics
Daniel P. Palomar (2025). Portfolio Optimization: Theory and Application. Cambridge University Press.
Last update: February 19, 2026
Contributors:
Libraries¶
The following libraries 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") import plotly.io as pio pio.renderers.default = "notebook" pio.templates.default = "plotly_white" # Optimization import cvxpy as cp # interface for convex optimization solvers import riskparityportfolio as rpp
$\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 library 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.184555 | 47.539192 | 139.384995 | 155.335144 | 102.449997 | 231.163666 | 55.564445 | 72.291588 | 271.510010 |
| 2021-10-04 | 147.850250 | 46.975636 | 135.955338 | 152.448776 | 100.339996 | 229.870621 | 55.200928 | 72.068001 | 269.500000 |
| 2021-10-05 | 148.500397 | 46.851460 | 137.880249 | 153.478928 | 101.809998 | 230.795532 | 54.919197 | 71.921814 | 269.410004 |
| 2021-10-06 | 149.102066 | 44.941101 | 138.749878 | 154.110062 | 103.639999 | 234.144974 | 54.392082 | 71.500473 | 270.510010 |
| 2021-10-07 | 150.722626 | 44.941101 | 140.010315 | 154.973190 | 106.449997 | 236.479660 | 55.128220 | 72.403366 | 277.119995 |
However, for convenience we will use stock data and cryptocurrency data from the official portfolio optimization book library 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 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: The minimum value for train_size in skfolio is train_size=1
# 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 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, but soon will be) import time from datetime import timedelta 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 # 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)") ) portf_EWP = Portfolio_via_CVXPY( cvxpy_fun=EWP, portfolio_params=dict(name="1/N") ) portf_GMRP = Portfolio_via_CVXPY( cvxpy_fun=GMRP, portfolio_params=dict(name="GMRP") ) portf_QuintP_mu = Portfolio_via_CVXPY( cvxpy_fun=QuintP_mu, portfolio_params=dict(name="QuintP (sorted by mu)") ) portf_QuintP_mu_over_sigma = Portfolio_via_CVXPY( cvxpy_fun=QuintP_mu_over_sigma, portfolio_params=dict(name="QuintP (sorted by mu/sigma)") ) portf_QuintP_mu_over_sigma2 = Portfolio_via_CVXPY( cvxpy_fun=QuintP_mu_over_sigma2, portfolio_params=dict(name="QuintP (sorted by mu/sigma2)") ) 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) bt_population.set_portfolio_params(compounded=True) 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 | |
|---|---|---|---|---|---|
| 1/N | 3.98 | 4.37% | 8.91% | 142.32% | 35.74% |
| GMRP | 2.68 | 6.65% | 14.63% | 171.61% | 63.95% |
| QuintP (sorted by mu) | 3.35 | 5.76% | 15.27% | 159.45% | 47.55% |
| QuintP (sorted by mu/sigma) | 3.52 | 5.37% | 15.27% | 161.09% | 45.71% |
| QuintP (sorted by mu/sigma2) | 3.22 | 4.97% | 12.96% | 126.74% | 39.31% |
# 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, ObjectiveFunction from skfolio import RiskMeasure 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") ) portf_GMVP_skfolio = MeanRisk( objective_function=ObjectiveFunction.MINIMIZE_RISK, risk_measure=RiskMeasure.VARIANCE, portfolio_params=dict(name="GMVP (skfolio)") ) portf_IVP = Portfolio_via_CVXPY( cvxpy_fun=IVP, portfolio_params=dict(name="IVP") ) portf_IVP_skfolio = InverseVolatility( portfolio_params=dict(name="IVP (skfolio)") ) portf_RPP = Portfolio_via_CVXPY( cvxpy_fun=RPP, portfolio_params=dict(name="RPP") ) portf_MDP = Portfolio_via_CVXPY( cvxpy_fun=MDP, portfolio_params=dict(name="MDP") ) portf_MDCP = Portfolio_via_CVXPY( cvxpy_fun=MDCP, portfolio_params=dict(name="MDCP") ) 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
| Annualized Sharpe Ratio | CVaR at 95% | MAX Drawdown | Annualized Mean | Annualized Standard Deviation | |
|---|---|---|---|---|---|
| GMVP | 4.32 | 3.46% | 9.10% | 125.75% | 29.10% |
| IVP | 4.01 | 4.16% | 9.33% | 133.60% | 33.31% |
| RPP | 4.06 | 4.09% | 9.05% | 134.33% | 33.07% |
| MDP | 4.45 | 3.88% | 7.99% | 144.42% | 32.46% |
| MDCP | 4.22 | 4.40% | 9.45% | 156.81% | 37.14% |
# Plot NAV bt_population.set_portfolio_params(compounded=True) 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()