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()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
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 @ wScenario 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() / 100We 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.
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 |
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 |