The Minimum Variance Portfolio

Minimizing Risk Without Forecasting Returns

The Sharpe-ratio-maximizing portfolio in the previous notebook requires estimates of expected returns, which are notoriously difficult to pin down from historical data. A ten-year sample of monthly returns typically yields a standard error on the annualized mean return large enough to make individual point estimates nearly uninformative.

The minimum variance portfolio (MVP) sidesteps this problem entirely. Instead of maximizing reward per unit of risk, we simply ask: what allocation minimizes portfolio variance? The objective involves only the covariance matrix \(\Sigma\), \[ \min_{\mathbf{w}}\; \mathbf{w}^\top \Sigma\, \mathbf{w} \quad \text{subject to} \quad \mathbf{w}^\top \mathbf{1} = 1. \] Because no return forecast enters the objective, the MVP avoids the error-maximization problem that makes unconstrained mean-variance weights so unstable. The practical trade-off is that the MVP ignores expected returns altogether — it will happily overweight low-volatility assets even if their expected returns are poor.

A shorter training window is more defensible here than it would be for the Sharpe-ratio strategy. Covariances are much better estimated than means from finite samples: volatilities and correlations are relatively stable over years, whereas average returns over the same horizon are largely noise. We therefore use a five-year window of daily returns, which provides roughly 1,250 observations — far more than a monthly series would — while still reflecting a recent, relevant market regime.

Loading the Libraries

import yfinance as yf
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import seaborn as sns
from scipy.optimize import minimize

sns.set_theme()

Loading the Data

We use the same seven sector ETFs as in the previous notebook.

Ticker Name
XLF Financial Select Sector SPDR Fund
XLE Energy Select Sector SPDR Fund
XLU Utilities Select Sector SPDR Fund
XLI Industrial Select Sector SPDR Fund
XLP Consumer Staples Select Sector SPDR Fund
XLV Health Care Select Sector SPDR Fund
XLK Technology Select Sector SPDR Fund

The training window covers five years of daily returns (January 2020 – December 2024).

start_date = '2020-01-01'
end_date   = '2025-01-01'

tickers = ['XLF', 'XLE', 'XLU', 'XLI', 'XLP', 'XLV', 'XLK']

ret = (yf
      .download(tickers, start=start_date, end=end_date, auto_adjust=False, progress=False)['Adj Close']
      .pct_change()
      .dropna()
      )

Computing the Covariance Matrix

The MVP requires only \(\Sigma\). We compute the sample covariance matrix of daily returns and do not need expected returns or a risk-free rate.

cov = ret.cov()

Finding the Minimum Variance Portfolio

The objective is portfolio variance \(\mathbf{w}^\top \Sigma\, \mathbf{w}\). We consider two scenarios: unconstrained weights and long-only weights. Both share the constraint that weights sum to one.

Before passing the covariance matrix to the optimizer we rescale it so that the equal-weight portfolio \(\mathbf{x}_0 = \frac{1}{n}\mathbf{1}\) has a scaled variance of exactly one: \[ \tilde\Sigma = \frac{\Sigma}{\mathbf{x}_0^\top \Sigma\, \mathbf{x}_0}. \] Multiplying the objective by a positive constant does not change the minimizer, so the optimal weights are identical whether we use \(\Sigma\) or \(\tilde\Sigma\). What changes is the magnitude of the objective values. With daily returns the equal-weight portfolio variance sits near \(10^{-6}\), well below scipy’s default absolute convergence tolerance, which can cause the optimizer to stop prematurely or report a spurious success. After scaling, the equal-weight portfolio has a scaled variance of exactly one, so the tolerance is meaningful.

n    = len(tickers)
x0   = np.ones(n) / n
cons = {'type': 'eq', 'fun': lambda w: w.sum() - 1}

cov_scaled = cov / (x0 @ cov @ x0)

def port_var(w, cov):
    return w @ cov @ w

Scenario 1 — unconstrained: the optimizer is free to short any asset.

res_unc = minimize(port_var, x0, args=(cov_scaled,), constraints=cons)

weights_unc = pd.DataFrame({'Weight (%)': (100 * res_unc.x).round(2)}, index=tickers)
weights_unc.index.name = 'Ticker'
weights_unc
Weight (%)
Ticker
XLF 3.79
XLE -20.84
XLU 14.35
XLI -9.51
XLP 80.47
XLV -13.61
XLK 45.36

Scenario 2 — no short-selling: each weight is bounded to \([0,\, 1]\).

bounds_long = [(0, 1)] * n

res_long = minimize(port_var, x0, args=(cov_scaled,), constraints=cons, bounds=bounds_long)

weights_long = pd.DataFrame({'Weight (%)': (100 * res_long.x).round(2)}, index=tickers)
weights_long.index.name = 'Ticker'
weights_long
Weight (%)
Ticker
XLF 0.00
XLE 0.00
XLU 0.00
XLI 0.00
XLP 68.24
XLV 0.00
XLK 31.76

Out-of-Sample Performance

We evaluate both portfolios, equal-weight, and SPY over the period immediately following the training window.

test_start = '2025-01-01'
test_end   = '2026-02-26'

ret_test = (yf
    .download(tickers, start=test_start, end=test_end, auto_adjust=False, progress=False)['Adj Close']
    .pct_change()
    .dropna()
    )

spy_test = (yf
    .download(['SPY'], start=test_start, end=test_end, auto_adjust=False, progress=False, multi_level_index=False)['Adj Close']
    .pct_change()
    .dropna()
    )

irx_test = (yf
    .download(['^IRX'], start=test_start, end=test_end, auto_adjust=False,
               progress=False, multi_level_index=False)['Adj Close']
    .dropna())
r_f_test = irx_test.mean() / 100

We apply each set of fixed weights to each day’s return and chain the results into cumulative growth of $1. The oos_stats function scales by 252 and \(\sqrt{252}\) to annualize the daily return and volatility, consistent with how the covariance matrix was estimated.

w_ew = np.ones(n) / n

cum_unc  = (1 + (ret_test[tickers] * res_unc.x).sum(axis=1)).cumprod()
cum_long = (1 + (ret_test[tickers] * res_long.x).sum(axis=1)).cumprod()
cum_ew   = (1 + (ret_test[tickers] * w_ew).sum(axis=1)).cumprod()
cum_spy  = (1 + spy_test).cumprod()

fig, ax = plt.subplots()
cum_unc.plot(ax=ax, label='Unconstrained MVP')
cum_long.plot(ax=ax, label='Long-Only MVP')
cum_ew.plot(ax=ax, label='Equal-Weight (1/N)')
cum_spy.plot(ax=ax, label='SPY')
ax.set_title('Out-of-Sample Performance: Jan 2025 – Feb 2026')
ax.set_ylabel('Growth of $1')
ax.legend()
plt.show()

def oos_stats(daily_rets, rf):
    ann_ret = daily_rets.mean() * 252
    sigma   = daily_rets.std() * np.sqrt(252)
    sr      = (ann_ret - rf) / sigma
    tot     = (1 + daily_rets).prod() - 1
    return {'Ann. Return': f'{ann_ret:.1%}', 'Ann. Vol': f'{sigma:.1%}',
            'Sharpe': f'{sr:.2f}', 'Total Return': f'{tot:.1%}'}

rets = {
    'Unconstrained MVP': (ret_test[tickers] * res_unc.x).sum(axis=1),
    'Long-Only MVP':     (ret_test[tickers] * res_long.x).sum(axis=1),
    'Equal-Weight':      (ret_test[tickers] * w_ew).sum(axis=1),
    'SPY':               spy_test,
}

pd.DataFrame({k: oos_stats(v, r_f_test) for k, v in rets.items()}).T
Ann. Return Ann. Vol Sharpe Total Return
Unconstrained MVP 15.3% 14.5% 0.78 17.6%
Long-Only MVP 17.1% 13.2% 0.99 20.3%
Equal-Weight 20.0% 14.5% 1.10 24.0%
SPY 17.8% 18.7% 0.74 20.0%

Interpreting the Results

The MVP is designed to minimize portfolio variance, not to maximize returns. The right way to evaluate it is therefore to check whether its out-of-sample standard deviation is indeed lower than that of the benchmarks — not whether its total return beats SPY.

The long-only constraint tends to be more valuable here than in the Sharpe-ratio setting. Without it, the unconstrained MVP can take large offsetting positions to cancel variance, producing weights that are sensitive to small changes in the estimated covariance matrix. The long-only version accepts a modest loss of variance-reduction in exchange for a more stable, implementable portfolio. Jagannathan and Ma (2003) formalize this intuition: imposing no-short-sale constraints is algebraically equivalent to shrinking the elements of the sample covariance matrix toward zero, so the long-only MVP implicitly regularizes against estimation noise and frequently achieves lower out-of-sample variance than the unconstrained solution. As with any strategy estimated on a finite sample, a single fourteen-month test window is too short to draw reliable conclusions; the rankings can shift with a different test period or asset universe.

Jagannathan, Ravi, and Tongshu Ma. 2003. “Risk Reduction in Large Portfolios: Why Imposing the Wrong Constraints Helps.” Journal of Finance 58 (4): 1651–83.

Practice Problems

Problem 1 Compute the in-sample annualized standard deviation of the unconstrained MVP, the long-only MVP, and an equal-weight portfolio. Verify that both MVPs achieve lower volatility than equal-weight, confirming the optimizer worked correctly.

Solution
w_ew = np.ones(n) / n

pd.DataFrame(
    {'Ann. Volatility (%)': {
        'Unconstrained MVP': 100 * np.sqrt(252 * res_unc.x  @ cov @ res_unc.x),
        'Long-Only MVP':     100 * np.sqrt(252 * res_long.x @ cov @ res_long.x),
        'Equal-Weight':      100 * np.sqrt(252 * w_ew        @ cov @ w_ew),
    }}
).round(4)
Ann. Volatility (%)
Unconstrained MVP 16.1074
Long-Only MVP 16.5130
Equal-Weight 20.4047
The unconstrained MVP has the lowest annualized volatility — it is free to take short positions that cancel covariance across assets. The long-only MVP’s volatility is slightly higher because non-negativity prevents those offsetting shorts, but it is still lower than equal-weight. Both outcomes confirm that the optimizer is working correctly: by construction, the MVP must achieve lower in-sample variance than any other feasible portfolio in its respective scenario.

Problem 2 Re-run the minimum variance optimization with a 30% cap on each weight (bounds = [(0, 0.30)] * n). How do the resulting weights compare to the unconstrained long-only MVP? Does the cap significantly increase in-sample portfolio variance?

Solution
bounds_cap = [(0, 0.30)] * n

res_cap = minimize(port_var, x0, args=(cov_scaled,), constraints=cons, bounds=bounds_cap)

weights_cap = pd.DataFrame({'Weight (%)': (100 * res_cap.x).round(2)}, index=tickers)
weights_cap.index.name = 'Ticker'
weights_cap
Weight (%)
Ticker
XLF 0.00
XLE 0.00
XLU 19.36
XLI 1.42
XLP 30.00
XLV 19.22
XLK 30.00
pd.DataFrame(
    {'Ann. Volatility (%)': {
        'Long-Only MVP':   100 * np.sqrt(252 * res_long.x @ cov @ res_long.x),
        'Capped MVP (30%)': 100 * np.sqrt(252 * res_cap.x  @ cov @ res_cap.x),
    }}
).round(4)
Ann. Volatility (%)
Long-Only MVP 16.5130
Capped MVP (30%) 17.7547
Any ETF that the long-only MVP assigned more than 30% is capped, with the excess redistributed by the optimizer. The variance increase from adding the cap is typically small: if the long-only MVP was already well-diversified, the 30% ceiling binds on few assets and the portfolio remains close to the unconstrained minimum. If the uncapped solution was concentrated in one or two low-volatility ETFs, the increase in variance will be more noticeable.