Python Code for Portfolio Optimization Chapter 13 – Index Tracking Portfolios
Daniel P. Palomar (2025). Portfolio Optimization: Theory and Application. Cambridge University Press.
Last update: March 18, 2026
Contributors:
- Daniel Palomar
- Claude Opus 4.6 (Anthropic, via Perplexity)
Libraries¶
The following libraries are used in the examples:
# Core numerical computing
import numpy as np
import pandas as pd
import warnings
warnings.filterwarnings('ignore')
# For financial data
import yfinance as yf # Loading financial data
import empyrical as emp # Performance metrics
# 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
from qpsolvers import solve_qp
$\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}}$
Helper Functions¶
Let's define the core backtest function:
def backtest(
portfolio_funcs: dict[str, callable],
dataset: dict[str, pd.DataFrame],
lookback: int,
rebalance_every: int = 1,
optimize_every: int = 1,
cost_bps: float = 0,
verbose: bool = True
) -> tuple[pd.DataFrame, dict[str, pd.DataFrame]]:
"""
Backtest portfolio strategies over historical data.
At each time step from ``lookback`` to ``T``, the function:
1. Re-optimizes portfolio weights every ``optimize_every`` periods by
calling the user-supplied portfolio function.
2. Rebalances to the latest optimized weights every ``rebalance_every``
periods, computing turnover-based transaction costs.
3. Computes the portfolio return for the period and drifts the weights
according to realised asset returns.
Parameters
----------
portfolio_funcs : dict[str, callable]
Dictionary mapping strategy names to portfolio weight functions.
Each function receives a dict with keys ``'prices'``
(``pd.DataFrame``) and ``'index'`` (``pd.DataFrame | None``) for the
current lookback window, and must return a 1-D ``np.ndarray`` of
portfolio weights.
dataset : dict[str, pd.DataFrame]
Must contain the key ``'prices'`` (T × N asset price DataFrame).
May optionally contain ``'index'`` (T × 1 benchmark price DataFrame).
lookback : int
Number of past periods fed to each portfolio function for estimation.
rebalance_every : int, default 1
Number of periods between rebalancing to the target weights.
optimize_every : int, default 1
Number of periods between re-optimizing the target weights.
Must be a multiple of ``rebalance_every``.
cost_bps : float, default 0
One-way transaction cost in basis points applied to turnover.
verbose : bool, default True
If True, print progress and re-optimization messages.
Returns
-------
portf_rets_df : pd.DataFrame
DataFrame of portfolio returns (one column per strategy), indexed
from ``lookback`` to ``T - 1``.
ws : dict[str, pd.DataFrame]
Dictionary mapping each strategy name to a DataFrame of portfolio
weights over the same period.
Raises
------
ValueError
If ``optimize_every`` is not a multiple of ``rebalance_every``.
"""
if optimize_every % rebalance_every != 0:
raise ValueError(
f"optimize_every ({optimize_every}) must be a multiple of "
f"rebalance_every ({rebalance_every})."
)
stock_prices = dataset['prices']
index_prices = dataset.get('index')
T, N = stock_prices.shape
portf_rets = {}
ws = {}
for portfolio_name, portfolio_func in portfolio_funcs.items():
if verbose:
print(f"Backtesting portfolio '{portfolio_name}'...")
current_w = np.zeros(N)
target_w = np.zeros(N)
w = pd.DataFrame(index=stock_prices.index, columns=stock_prices.columns)
portf_ret = pd.Series(index=stock_prices.index, dtype=float)
portf_ret.name = portfolio_name
for t in range(lookback, T):
if (t - lookback) % rebalance_every == 0:
# Re-optimize target weights if necessary
if (t - lookback) % optimize_every == 0:
target_w = portfolio_func({
'prices': stock_prices.iloc[t - lookback:t],
'index': index_prices.iloc[t - lookback:t] if index_prices is not None else None
})
if verbose:
print(f" Reoptimizing at t={t} "
f"(cardinality={np.sum(target_w > 1e-5)})")
# Rebalance to target weights
turnover = np.abs(target_w - current_w).sum()
current_w = target_w.copy()
transaction_cost = turnover * cost_bps / 10_000
else:
transaction_cost = 0.0
# Store weights
w.iloc[t] = current_w
# Period return
Xt = stock_prices.iloc[t] / stock_prices.iloc[t - 1] - 1
portf_ret.iloc[t] = (Xt * current_w).sum() - transaction_cost
# Drift weights with asset returns
current_w = current_w * (1 + Xt)
w_sum = current_w.sum()
if abs(w_sum) > 1e-12:
current_w = current_w / w_sum
portf_rets[portfolio_name] = portf_ret[lookback:]
ws[portfolio_name] = w[lookback:]
portf_rets_df = pd.DataFrame(portf_rets)
return portf_rets_df, ws
What is index tracking?¶
Index tracking is a portfolio management strategy that aims to replicate the performance of a specific market index. Let's visualize how the SPDR S&P 500 ETF (SPY) tracks the S&P 500 index:
# Download S&P 500 index and SPY ETF data
SP500_SPY_prices = yf.download(["^GSPC", "SPY"], start="2007-01-01")['Close']
SP500_SPY_prices.columns = ['S&P 500', 'SPY']
# Plot
SP500_SPY_prices.plot(figsize=(12, 6), logy=True, linewidth=1.0)
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
plt.gca().xaxis.set_major_locator(mdates.YearLocator())
plt.gca().xaxis.set_minor_locator(mdates.MonthLocator())
plt.title("Tracking of S&P 500 index")
plt.ylabel("dollars")
plt.xlabel(None)
plt.legend(title=None)
plt.tight_layout()
plt.show()
Sparse index tracking¶
Sparse index tracking is a type of sparse regression problem formulated as $$ \begin{array}{ll} \underset{\w}{\textm{minimize}} & \frac{1}{T}\left\|\bm{r}^\textm{b} - \bm{X}\w\right\|^2_2 + \lambda \|\w\|_0\\ \textm{subject to} & \w \in \mathcal{W}, \end{array} $$ where the matrix $\bm{X}$ contains the returns of the constituent assets, the vector $\bm{r}^\textm{b}$ contains the returns of the index to replicate, the parameter $\lambda$ controls the level of sparsity in the portfolio, and $\mathcal{W}$ is the constraint set which could be $\{\w \mid \bm{1}^\T\w=1, \w\ge\bm{0} \}$.
To design sparse tracking portfolios, we define a function with the simple iterative code based on the MM (Majorization-Minimization) algorithm applied to index tracking:
def sparse_tracking_via_MM(
X: pd.DataFrame,
r: pd.Series,
lambda_val: float,
w0: np.ndarray | None = None,
max_iter: int = 5,
thres: float = 1e-9,
p: float = 1e-3,
u: float = 1,
reg: float = 1e-7,
rtol: float = 1e-3,
return_obj: bool = False,
verbose: bool = False,
) -> np.ndarray | tuple[np.ndarray, list[float]]:
"""
Sparse index tracking via the MM (majorization-minimization) algorithm.
Solves the non-convex problem
min_w (1/T)||r - Xw||^2 + lambda * sum log(1 + |w_i|/p) / log(1 + u/p)
s.t. w >= 0, 1'w = 1
by iteratively majorizing the log penalty with a weighted-L1 surrogate and
solving the resulting QP subproblem.
Parameters
----------
X : pd.DataFrame
T x N DataFrame of asset returns.
r : pd.Series
Length-T Series of benchmark (index) returns.
lambda_val : float
Regularization parameter controlling sparsity.
w0 : np.ndarray or None, default None
Initial weight vector of length N. If None, uses 1/N uniform.
max_iter : int, default 5
Maximum number of MM iterations.
thres : float, default 1e-9
Weights below this value are zeroed after convergence.
p : float, default 1e-3
Shape parameter of the log penalty (controls approximation to L0).
u : float, default 1
Scale parameter of the log penalty.
reg : float, default 1e-7
Tikhonov regularization added to the QP cost matrix, expressed as a
fraction of the mean diagonal of X'X. Doubled automatically on
QP solver failure.
rtol : float, default 1e-3
Relative tolerance on the weight vector for early stopping.
return_obj : bool, default False
If True, also return the sequence of objective values.
verbose : bool, default False
If True, print progress messages.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N summing to 1.
obj_values : list[float]
Objective value at each iteration. Only returned when
``return_obj=True``.
"""
X_mat = X.values
r_vec = r.values.reshape(-1, 1)
T, N = X_mat.shape
# Precompute Gram matrix and cross term
XtX = X_mat.T @ X_mat
Xtr = X_mat.T @ r_vec # (N, 1)
w = np.ones(N) / N if w0 is None else w0.copy()
log_denom = np.log(1 + u / p)
def _objective(w):
res = r_vec - X_mat @ w.reshape(-1, 1)
tracking = (1 / T) * np.dot(res.ravel(), res.ravel())
penalty = lambda_val * np.sum(np.log(1 + np.abs(w) / p) / log_denom)
return tracking + penalty
obj_values = [_objective(w)] if return_obj else []
# QP components that don't change across iterations
P = (2 / T) * XtX
P = 0.5 * (P + P.T) # ensure exact symmetry
A_eq = np.ones((1, N))
b_eq = np.array([1.0])
G_ub = -np.eye(N)
h_ub = np.zeros(N)
current_reg = reg
for k in range(max_iter):
# MM surrogate weights
d = 1.0 / (log_denom * (p + np.abs(w))) # (N,)
w_prev = w.copy()
# # Solve the convex subproblem using CVXPY
# w_var = cp.Variable(N)
# objective = cp.Minimize((1/T) * cp.sum_squares(r_vec - X_mat @ w_var) +
# lambda_val * cp.sum(cp.multiply(d, cp.abs(w_var))))
# constraints = [cp.sum(w_var) == 1, w_var >= 0]
# problem = cp.Problem(objective, constraints)
# Build and solve the QP subproblem
P_reg = P + current_reg * np.mean(np.diag(P)) * np.eye(N)
q = (-(2 / T) * Xtr).ravel() + lambda_val * d # must be 1-D
w_new = solve_qp(P_reg, q, G_ub, h_ub, A_eq, b_eq, solver="quadprog")
if w_new is None:
if verbose:
print(f" Iter {k}: QP solver failed, doubling regularization.")
current_reg *= 2
continue
w = w_new
if return_obj:
obj_values.append(_objective(w))
if verbose:
print(f" Iter {k}: ||dw||/||w|| = "
f"{np.linalg.norm(w - w_prev) / max(np.linalg.norm(w_prev), 1e-12):.2e}")
if np.linalg.norm(w - w_prev) / max(np.linalg.norm(w_prev), 1e-12) < rtol:
break
# Threshold and renormalize
w[w < thres] = 0.0
w_sum = w.sum()
if w_sum > 0:
w /= w_sum
if return_obj:
return w, obj_values
return w
Simple sanity check:
X = SP500_stocks_2015to2020.iloc[:, :10].pct_change().dropna()
r = SP500_index_2015to2020.pct_change().dropna()
sparse_tracking_via_MM(X.iloc[:, :10], r, lambda_val=5e-7)
array([0.12490579, 0.04051468, 0.08009517, 0.16234165, 0.0636828 ,
0.07883649, 0. , 0.11939667, 0.24794548, 0.08228126])
Fixed versus time-varying portfolio¶
The tracking error (TE) of a fixed portfolio $\w_t=\w$ is $$ \textm{TE} = \frac{1}{T}\left\|\bm{r}^\textm{b} - \bm{X}\w\right\|^2_2, $$ where $\bm{X} \in \R^{T\times N}$ is the matrix containing the $T$ return vectors $\bm{r}_t$ of the $N$ constituent assets along the rows and $\bm{r}^\textm{b} \in \R^{T}$ is the vector containing the $T$ returns of the index.
Now, it the portfolio is not rebalanced, it will naturally evolve over time as $$ \w_t = \frac{\w_{t-1} \odot \left(\bm{1} + \bm{r}_t\right)}{\w_{t-1}^\T \left(\bm{1} + \bm{r}_t\right)} \approx \w_{0} \odot \bm{\alpha}_t, $$ where $\w_{0}$ is the initial portfolio and $$ \bm{\alpha}_t = \prod_{t'=1}^{t}\frac{\bm{1} + \bm{r}_{t'}}{1 + r_{t'}^\textm{b}} $$ denotes weights based on the cumulative returns. The resulting tracking error can be conveniently written as $$ \textm{TE}^\textm{time-varying} = \frac{1}{T}\left\|\bm{r}^\textm{b} - \tilde{\bm{X}}\w\right\|^2_2, $$ where now $\tilde{\bm{X}}$ contains the weighted returns $\tilde{\bm{r}}_t = \bm{r}_t \odot \bm{\alpha}_{t-1}$ along the rows and $\w$ denotes the initial portfolio $\w_0$.
The code to compute $\bm{\alpha}_{t}$ is straightforward:
def compute_alpha(
X: pd.DataFrame,
r: pd.Series,
) -> dict[str, pd.DataFrame | np.ndarray]:
"""
Compute cumulative return ratios and transformed returns.
Given asset returns X (T x N) and benchmark returns r (T x 1), define
.. math::
\\alpha_{t,n} = \\prod_{s=1}^{t}(1 + X_{s,n}) \\;/\\;
\\prod_{s=1}^{t}(1 + r_s)
and the transformed returns
:math:`\\tilde X_{t,n} = X_{t,n} \\, \\alpha_{t-1,n}` with
:math:`\\alpha_{0,n} = 1`.
Parameters
----------
X : pd.DataFrame
T x N DataFrame of asset returns.
r : pd.Series
Length-T Series of benchmark returns.
Returns
-------
dict[str, pd.DataFrame | np.ndarray]
``'alpha'`` : T x N cumulative return ratios.
``'alpha_lagged'`` : T x N lagged cumulative return ratios
(first row = 1).
``'last_alpha_lagged'`` : length-N array of final-row lagged alpha.
``'X_tilde'`` : T x N transformed returns.
"""
X_cum = (1 + X).cumprod()
r_cum = (1 + r).cumprod()
alpha = X_cum.div(r_cum, axis=0)
alpha_lagged = alpha.shift(1)
alpha_lagged.iloc[0] = 1.0
X_tilde = X * alpha_lagged
return {
'alpha': alpha,
'alpha_lagged': alpha_lagged,
'last_alpha_lagged': alpha_lagged.iloc[-1].values,
'X_tilde': X_tilde,
}
We now perform a backtest to assess the tracking error over time with $K=20$ active assets under the approximation (or assumption) of a fixed portfolio $\w$ and with the more accurate approximation of a time-varying portfolio, which shows improved results.
def _prepare_returns(
dataset: dict[str, pd.DataFrame],
) -> tuple[pd.DataFrame, pd.Series]:
"""
Compute asset and benchmark returns, aligned on common valid dates.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
Returns
-------
X : pd.DataFrame
T x N asset returns (NaN rows dropped).
r : pd.Series
Length-T benchmark returns.
"""
X = dataset['prices'].pct_change().dropna()
r = dataset['index'].pct_change().dropna().squeeze()
common_idx = X.index.intersection(r.index)
return X.loc[common_idx], r.loc[common_idx]
def tracking_using_plain_returns_wfixed(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 5e-7,
) -> np.ndarray:
"""
Index tracking with fixed portfolio weights.
Solves the sparse tracking problem on plain asset returns,
yielding a weight vector that is held constant over time.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 5e-7
Sparsity regularization parameter.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
X, r = _prepare_returns(dataset)
return sparse_tracking_via_MM(X, r, lambda_val=lambda_val, max_iter=10)
def tracking_using_plain_returns_wt(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 5e-7,
) -> np.ndarray:
"""
Index tracking with time-varying portfolio weights.
Optimizes over alpha-transformed returns so that the resulting
portfolio drifts naturally with the index. The initial weights
``w0`` are scaled by the final lagged alpha and renormalized.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 5e-7
Sparsity regularization parameter.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
X, r = _prepare_returns(dataset)
alpha = compute_alpha(X, r)
w0 = sparse_tracking_via_MM(alpha['X_tilde'], r, lambda_val=lambda_val, max_iter=10)
w = w0 * alpha['last_alpha_lagged']
w_sum = w.sum()
if w_sum > 0:
w /= w_sum
return w
Some sanity checks:
w = tracking_using_plain_returns_wfixed(dataset={
'prices': SP500_stocks_2015to2020.loc["2018":],
'index': SP500_index_2015to2020.loc["2018":]
})
sum(w), min(w)
(np.float64(1.0000000000000002), np.float64(0.0))
w = tracking_using_plain_returns_wt(dataset={
'prices': SP500_stocks_2015to2020.loc["2018":],
'index': SP500_index_2015to2020.loc["2018":]
})
sum(w), min(w)
(np.float64(0.9999999999999999), np.float64(0.0))
Let's run the backtest:
# Prep data
T, N = SP500_stocks_2015to2020.shape
T_trn = 2*252
# Run backtest
returns, weights = backtest(
portfolio_funcs={
"Tracking assuming fixed portfolio": tracking_using_plain_returns_wfixed,
"Tracking assuming time-varying portfolio": tracking_using_plain_returns_wt
},
dataset={
'prices': SP500_stocks_2015to2020, #.loc["2018":]
'index': SP500_index_2015to2020 # .loc["2018":]
},
lookback=T_trn,
rebalance_every=6*21, # Rebalance every 6 months (assuming 21 trading days per month)
optimize_every=6*21,
)
Backtesting portfolio 'Tracking assuming fixed portfolio'... Reoptimizing at t=504 (cardinality=21) Reoptimizing at t=630 (cardinality=27) Reoptimizing at t=756 (cardinality=22) Reoptimizing at t=882 (cardinality=17) Reoptimizing at t=1008 (cardinality=22) Reoptimizing at t=1134 (cardinality=21) Reoptimizing at t=1260 (cardinality=20) Reoptimizing at t=1386 (cardinality=27) Backtesting portfolio 'Tracking assuming time-varying portfolio'... Reoptimizing at t=504 (cardinality=24) Reoptimizing at t=630 (cardinality=24) Reoptimizing at t=756 (cardinality=20) Reoptimizing at t=882 (cardinality=20) Reoptimizing at t=1008 (cardinality=22) Reoptimizing at t=1134 (cardinality=22) Reoptimizing at t=1260 (cardinality=23) Reoptimizing at t=1386 (cardinality=26)
We can then plot the tracking error and cardinality over time:
def _align_with_benchmark(
returns: pd.DataFrame,
benchmark: pd.DataFrame,
) -> tuple[pd.DataFrame, pd.Series]:
"""Compute benchmark returns and align with portfolio returns."""
benchmark_rets = benchmark.pct_change().dropna().squeeze()
common_idx = returns.index.intersection(benchmark_rets.index)
return returns.loc[common_idx], benchmark_rets.loc[common_idx]
def compute_tracking_error(
returns: pd.DataFrame,
benchmark: pd.DataFrame,
) -> pd.Series:
"""
Compute mean squared tracking error for each portfolio.
The tracking error is defined as
:math:`(1/T) \\sum_t (r_{p,t} - r_{b,t})^2`, consistent with the
least-squares objective used in ``sparse_tracking_via_MM``.
Parameters
----------
returns : pd.DataFrame
T x K DataFrame of portfolio returns (one column per strategy).
benchmark : pd.DataFrame
T x 1 DataFrame of benchmark prices (converted to returns internally).
Returns
-------
pd.Series
Mean squared tracking error for each portfolio.
"""
returns_aligned, benchmark_rets = _align_with_benchmark(returns, benchmark)
excess = returns_aligned.subtract(benchmark_rets, axis=0)
return (excess ** 2).mean()
def plot_tracking_error(
returns: pd.DataFrame,
benchmark: pd.DataFrame,
window: int = 252,
) -> pd.DataFrame:
"""
Plot rolling mean squared tracking error over time.
Parameters
----------
returns : pd.DataFrame
T x K DataFrame of portfolio returns.
benchmark : pd.DataFrame
T x 1 DataFrame of benchmark prices.
window : int, default 252
Rolling window size (in trading days).
Returns
-------
pd.DataFrame
T x K DataFrame of squared tracking errors (unsmoothed).
"""
returns_aligned, benchmark_rets = _align_with_benchmark(returns, benchmark)
excess = returns_aligned.subtract(benchmark_rets, axis=0)
tracking_errors = excess ** 2
tracking_errors.rolling(window=window).mean().plot(figsize=(12, 6))
plt.title(f'Tracking Error (Rolling {window}-day window)')
plt.xlabel(None)
plt.ylabel('Tracking Error')
plt.legend(title='Portfolio')
plt.grid(True)
plt.tight_layout()
plt.show()
return tracking_errors
TE = plot_tracking_error(returns, benchmark=SP500_index_2015to2020, window=252)
def _compute_cardinality(
weights: dict[str, pd.DataFrame],
thres: float = 1e-5,
) -> pd.DataFrame:
"""
Compute portfolio cardinality over time.
Parameters
----------
weights : dict[str, pd.DataFrame]
Dictionary mapping strategy names to T x N weight DataFrames.
thres : float, default 1e-5
Weights below this threshold are treated as zero.
Returns
-------
pd.DataFrame
T x K DataFrame with the number of active assets per strategy.
"""
return pd.DataFrame({
name: (w > thres).sum(axis=1)
for name, w in weights.items()
})
def compute_cardinality(
weights: dict[str, pd.DataFrame],
thres: float = 1e-5,
) -> pd.Series:
"""
Compute mean portfolio cardinality for each strategy.
Parameters
----------
weights : dict[str, pd.DataFrame]
Dictionary mapping strategy names to T x N weight DataFrames.
thres : float, default 1e-5
Weights below this threshold are treated as zero.
Returns
-------
pd.Series
Mean cardinality per strategy.
"""
return _compute_cardinality(weights, thres).mean()
def plot_cardinality(
weights: dict[str, pd.DataFrame],
thres: float = 1e-5,
) -> pd.DataFrame:
"""
Plot portfolio cardinality over time.
Parameters
----------
weights : dict[str, pd.DataFrame]
Dictionary mapping strategy names to T x N weight DataFrames.
thres : float, default 1e-5
Weights below this threshold are treated as zero.
Returns
-------
pd.DataFrame
T x K DataFrame with the number of active assets per strategy.
"""
cardinality_df = _compute_cardinality(weights, thres)
cardinality_df.plot(figsize=(12, 6))
plt.title('Portfolio Cardinality Over Time')
plt.xlabel(None)
plt.ylabel('Number of Assets')
plt.legend(title='Portfolio')
plt.grid(True)
plt.tight_layout()
plt.show()
return cardinality_df
cardinality = plot_cardinality(weights)
# Calculate column means for TE and cardinality
summary_df = pd.DataFrame({
"TE": TE.mean(),
"cardinality": cardinality.mean()
})
# Display
summary_df.style.format({
'TE': '{:.2e}', # Scientific notation with 6 decimal places
'cardinality': '{:.2f}' # Fixed-point with 2 decimal places
})
| TE | cardinality | |
|---|---|---|
| Tracking assuming fixed portfolio | 4.63e-06 | 21.75 |
| Tracking assuming time-varying portfolio | 3.71e-06 | 22.37 |
Plain versus cumulative returns¶
Minimizing errors in period-by-period returns does not guarantee better long-term cumulative return tracking, as return errors can accumulate unevenly over time. To control long-term cumulative return deviations, the error measure should use long-term or cumulative returns. However, for short-term hedging, period-by-period return tracking is essential, as outperformance is not the objective.
The price error can be measured by the tracking error in terms of the cumulative returns as $$ \textm{TE}^\textm{cum} = \frac{1}{T}\left\|\bm{r}^\textm{b,cum} - \bm{X}^\textm{cum}\w\right\|^2_2, $$ where $\bm{X}^\textm{cum}$ (similarly $\bm{r}^\textm{b,cum}$) denotes the cumulative returns, whose rows can be obtained as $$ \bm{r}^\textm{cum}_t \approx \sum_{t'=1}^{t}\bm{r}_{t'}. $$
We now perform a backtest to assess the tracking error over time using plain returns and cumulative returns:
def tracking_using_cumreturns_wfixed(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 20e-7,
) -> np.ndarray:
"""
Index tracking with fixed weights on cumulative returns.
Solves the sparse tracking problem on cumulative asset returns,
yielding a weight vector that is held constant over time.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 20e-7
Sparsity regularization parameter.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
X, r = _prepare_returns(dataset)
return sparse_tracking_via_MM(X.cumsum(), r.cumsum(), lambda_val=lambda_val, max_iter=10)
def tracking_using_cumreturns_wt(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 15e-7,
) -> np.ndarray:
"""
Index tracking with time-varying weights on cumulative returns.
Optimizes over cumulative alpha-transformed returns and rescales
the resulting weights by the final lagged alpha.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 15e-7
Sparsity regularization parameter.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
X, r = _prepare_returns(dataset)
alpha = compute_alpha(X, r)
w0 = sparse_tracking_via_MM(
alpha['X_tilde'].cumsum(), r.cumsum(),
lambda_val=lambda_val, max_iter=10,
)
w = w0 * alpha['last_alpha_lagged']
w_sum = w.sum()
if w_sum > 0:
w /= w_sum
return w
Let's run the backtest:
# Prep data
T, N = SP500_stocks_2015to2020.shape
T_trn = 2*252
# Run backtest
returns, weights = backtest(
portfolio_funcs={
"Tracking using plain returns": tracking_using_plain_returns_wfixed,
"Tracking using cumulative returns": tracking_using_cumreturns_wfixed
},
dataset={
'prices': SP500_stocks_2015to2020, #.loc["2018":]
'index': SP500_index_2015to2020 # .loc["2018":]
},
lookback=T_trn,
rebalance_every=6*21, # Rebalance every 6 months (assuming 21 trading days per month)
optimize_every=6*21,
)
Backtesting portfolio 'Tracking using plain returns'... Reoptimizing at t=504 (cardinality=21) Reoptimizing at t=630 (cardinality=27) Reoptimizing at t=756 (cardinality=22) Reoptimizing at t=882 (cardinality=17) Reoptimizing at t=1008 (cardinality=22) Reoptimizing at t=1134 (cardinality=21) Reoptimizing at t=1260 (cardinality=20) Reoptimizing at t=1386 (cardinality=27) Backtesting portfolio 'Tracking using cumulative returns'... Reoptimizing at t=504 (cardinality=19) Reoptimizing at t=630 (cardinality=19) Reoptimizing at t=756 (cardinality=18) Reoptimizing at t=882 (cardinality=17) Reoptimizing at t=1008 (cardinality=22) Reoptimizing at t=1134 (cardinality=24) Reoptimizing at t=1260 (cardinality=23) Reoptimizing at t=1386 (cardinality=24)
We can then plot the (normalized) price tracked over time:
def plot_prices(
returns: pd.DataFrame,
benchmark: pd.DataFrame,
title: str = 'Tracking of benchmark index',
) -> None:
"""
Plot cumulative portfolio value against the benchmark.
Portfolio returns are compounded into a NAV series and rescaled so
that all curves start at the benchmark's initial price.
Parameters
----------
returns : pd.DataFrame
T x K DataFrame of portfolio returns (one column per strategy).
benchmark : pd.DataFrame
T x 1 DataFrame of benchmark prices.
title : str, default 'Tracking of benchmark index'
Plot title.
"""
NAV = (1 + returns).cumprod()
benchmark_aligned = benchmark.loc[NAV.index]
B0 = benchmark_aligned.iloc[0, 0]
all_prices = pd.concat([benchmark_aligned, B0 * NAV], axis=1)
all_prices.plot(figsize=(12, 6))
plt.title(title)
plt.xlabel(None)
plt.ylabel('dollars')
plt.legend(title='Portfolio')
plt.grid(True)
plt.tight_layout()
plt.show()
plot_prices(returns, benchmark=SP500_index_2015to2020)
Convergence of the iterative reweighted ℓ₁-norm method¶
The convergence of the iterative reweighted $\ell_1$-norm method (based on the MM framework) for sparse index tracking is very fast:
# Prep data
X = SP500_stocks_2015to2020.pct_change().dropna()
r = SP500_index_2015to2020.pct_change().dropna()
# Run index tracking MM algorithm
w, obj_values = sparse_tracking_via_MM(X, r, lambda_val=5e-7, max_iter=10, return_obj=True)
# Plot convergence
plt.figure(figsize=(10, 6))
plt.plot(range(len(obj_values)), obj_values, linewidth=1.2, color='blue')
plt.title('Convergence over iterations')
plt.xlabel('k')
plt.ylabel('Objective Value')
plt.xticks(range(len(obj_values)))
plt.grid(True)
plt.tight_layout()
plt.show()
Comparison of algorithms¶
We now compare the tracking of the S&P 500 index based on different tracking methods, namely:
- the naive two-step approach with proportional weights to the index definition;
- the two-step approach with refitted weights;
- the iterative reweighted $\ell_1$-norm method; and
- the MIP formulation (although the computational complexity is too high).
We now perform a backtest to assess the tracking error over time of the S&P 500 index with $K=20$ active assets for different algorithms. The tracking portfolios are computed on a rolling window basis with a lookback period of two years and recomputed every six months.
def _solve_tracking_qp(
X_mat: np.ndarray,
r_vec: np.ndarray,
reg: float = 1e-7,
) -> np.ndarray:
"""
Solve the dense least-squares tracking QP.
Parameters
----------
X_mat : np.ndarray
T x N matrix of asset returns.
r_vec : np.ndarray
T x 1 vector of benchmark returns.
reg : float, default 1e-7
Tikhonov regularization as a fraction of the mean diagonal of X'X.
Returns
-------
w : np.ndarray
Weight vector of length N.
"""
T, N = X_mat.shape
# # Solve the convex subproblem using CVXPY
# N = X_mat.shape[1]
# w = cp.Variable(N)
# objective = cp.Minimize(cp.sum_squares(X_mat.values @ w - r_vec.values))
# constraints = [cp.sum(w) == 1, w >= 0]
# Solve the convex subproblem using quadprog
P = (2 / T) * X_mat.T @ X_mat
P = 0.5 * (P + P.T)
P += reg * np.mean(np.diag(P)) * np.eye(N)
q = (-(2 / T) * X_mat.T @ r_vec).ravel()
w = solve_qp(P, q, -np.eye(N), np.zeros(N),
np.ones((1, N)), np.array([1.0]), solver="quadprog")
return w
def full_tracking(
dataset: dict[str, pd.DataFrame],
reg: float = 1e-7,
) -> np.ndarray:
"""
Full (dense) index tracking via least-squares QP.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
reg : float, default 1e-7
Tikhonov regularization as a fraction of the mean diagonal of X'X.
Returns
-------
w : np.ndarray
Portfolio weight vector of length N.
"""
X, r = _prepare_returns(dataset)
return _solve_tracking_qp(X.values, r.values.reshape(-1, 1), reg=reg)
Sanity check:
w = full_tracking(dataset={
'prices': SP500_stocks_2015to2020.loc["2018":],
'index': SP500_index_2015to2020.loc["2018":]
})
sum(w), min(w)
(np.float64(0.9999999999999997), np.float64(-2.4190528545014647e-17))
def two_stage_naive_tracking(
dataset: dict[str, pd.DataFrame],
K: int = 20,
reg: float = 1e-7,
) -> np.ndarray:
"""
Two-stage naive index tracking.
Stage 1: solve the dense (full) least-squares tracking QP.
Stage 2: keep only the K largest weights and renormalize.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
K : int, default 20
Number of assets to retain in the sparse portfolio.
reg : float, default 1e-7
Tikhonov regularization as a fraction of the mean diagonal of X'X.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N with at most K non-zeros.
"""
# Stage 1: dense portfolio
w_dense = full_tracking(dataset, reg=reg)
# Stage 2: keep K largest weights and renormalize
N = len(w_dense)
idx_keep = np.argsort(w_dense)[-K:]
w = np.zeros(N)
w[idx_keep] = w_dense[idx_keep]
w_sum = w.sum()
if w_sum > 0:
w /= w_sum
return w
Sanity check:
w = two_stage_naive_tracking(
dataset={
'prices': SP500_stocks_2015to2020.loc['2018':],
'index': SP500_index_2015to2020.loc['2018':],
},
K=10,
)
print(f"Sum: {w.sum():.6f}, Min: {w.min():.6f}, Cardinality: {np.sum(w > 1e-5)}")
Sum: 1.000000, Min: 0.000000, Cardinality: 10
def two_stage_refitted_tracking(
dataset: dict[str, pd.DataFrame],
K: int = 20,
reg: float = 1e-7,
) -> np.ndarray:
"""
Two-stage index tracking with refitted weights.
Stage 1: solve the dense tracking QP.
Stage 2: select the K largest weights, then re-solve the QP
restricted to those K assets.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
K : int, default 20
Number of assets to retain.
reg : float, default 1e-7
Tikhonov regularization as a fraction of the mean diagonal of X'X.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N with at most K non-zeros.
"""
X, r = _prepare_returns(dataset)
X_mat = X.values
r_vec = r.values.reshape(-1, 1)
N = X_mat.shape[1]
# Stage 1: dense portfolio
w_dense = _solve_tracking_qp(X_mat, r_vec, reg=reg)
# Stage 2: refit on K largest
idx_keep = np.argsort(w_dense)[-K:]
w_refit = _solve_tracking_qp(X_mat[:, idx_keep], r_vec, reg=reg)
w = np.zeros(N)
w[idx_keep] = w_refit
return w
Sanity check:
w = two_stage_refitted_tracking(dataset={
'prices': SP500_stocks_2015to2020.loc["2018":],
'index': SP500_index_2015to2020.loc["2018":]
}, K=15)
sum(w), min(w), sum(w > 1e-5)
(np.float64(1.0), np.float64(0.0), np.int64(15))
def iterative_reweighted_l1_norm_tracking(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 5e-7,
) -> np.ndarray:
"""
Sparse index tracking via iteratively reweighted L1-norm (MM algorithm).
Equivalent to fixed-weight tracking on plain returns using the
log-penalty MM solver in ``sparse_tracking_via_MM``.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 5e-7
Sparsity regularization parameter.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
X, r = _prepare_returns(dataset)
return sparse_tracking_via_MM(X, r, lambda_val=lambda_val, max_iter=10)
Sanity check:
w = iterative_reweighted_l1_norm_tracking(dataset={
'prices': SP500_stocks_2015to2020.loc["2018":],
'index': SP500_index_2015to2020.loc["2018":]
})
sum(w), min(w), sum(w > 1e-5)
(np.float64(1.0000000000000002), np.float64(0.0), np.int64(27))
Let's run the backtest:
# Prep data
T, N = SP500_stocks_2015to2020.shape
T_trn = 2*252
# Parameters for the desired sparsity
K = 20
lambda_val=5e-7
# Run backtest
returns, weights = backtest(
portfolio_funcs={
"Full tracking": full_tracking,
"Two-step design with proportional weights": two_stage_naive_tracking,
"Two-step design with refitted weights": two_stage_refitted_tracking,
"Iterative reweighted l1-norm method": iterative_reweighted_l1_norm_tracking
},
dataset={
'prices': SP500_stocks_2015to2020, #.loc["2018":]
'index': SP500_index_2015to2020 # .loc["2018":]
},
lookback=T_trn,
rebalance_every=6*21, # Rebalance every 6 months (assuming 21 trading days per month)
optimize_every=6*21,
)
Backtesting portfolio 'Full tracking'... Reoptimizing at t=504 (cardinality=267) Reoptimizing at t=630 (cardinality=272) Reoptimizing at t=756 (cardinality=277) Reoptimizing at t=882 (cardinality=278) Reoptimizing at t=1008 (cardinality=283) Reoptimizing at t=1134 (cardinality=271) Reoptimizing at t=1260 (cardinality=288) Reoptimizing at t=1386 (cardinality=243) Backtesting portfolio 'Two-step design with proportional weights'... Reoptimizing at t=504 (cardinality=20) Reoptimizing at t=630 (cardinality=20) Reoptimizing at t=756 (cardinality=20) Reoptimizing at t=882 (cardinality=20) Reoptimizing at t=1008 (cardinality=20) Reoptimizing at t=1134 (cardinality=20) Reoptimizing at t=1260 (cardinality=20) Reoptimizing at t=1386 (cardinality=20) Backtesting portfolio 'Two-step design with refitted weights'... Reoptimizing at t=504 (cardinality=20) Reoptimizing at t=630 (cardinality=19) Reoptimizing at t=756 (cardinality=20) Reoptimizing at t=882 (cardinality=20) Reoptimizing at t=1008 (cardinality=20) Reoptimizing at t=1134 (cardinality=20) Reoptimizing at t=1260 (cardinality=20) Reoptimizing at t=1386 (cardinality=20) Backtesting portfolio 'Iterative reweighted l1-norm method'... Reoptimizing at t=504 (cardinality=21) Reoptimizing at t=630 (cardinality=27) Reoptimizing at t=756 (cardinality=22) Reoptimizing at t=882 (cardinality=17) Reoptimizing at t=1008 (cardinality=22) Reoptimizing at t=1134 (cardinality=21) Reoptimizing at t=1260 (cardinality=20) Reoptimizing at t=1386 (cardinality=27)
We can then plot the price, tracking error, and cardinality over time:
plot_prices(returns, benchmark=SP500_index_2015to2020)
methods = ["Two-step design with proportional weights",
"Two-step design with refitted weights",
"Iterative reweighted l1-norm method"]
TE = plot_tracking_error(returns[methods], benchmark=SP500_index_2015to2020, window=252)
cardinality = plot_cardinality({key: weights[key] for key in methods})
# Calculate column means for TE and cardinality
summary_df = pd.DataFrame({
"TE": TE.mean(),
"cardinality": cardinality.mean()
})
# Display
summary_df.style.format({
'TE': '{:.2e}', # Scientific notation with 6 decimal places
'cardinality': '{:.2f}' # Fixed-point with 2 decimal places
})
| TE | cardinality | |
|---|---|---|
| Two-step design with proportional weights | 7.56e-06 | 20.00 |
| Two-step design with refitted weights | 5.50e-06 | 19.87 |
| Iterative reweighted l1-norm method | 4.63e-06 | 21.75 |
Comparison of formulations¶
We consider again the comparison between assuming a fixed portfolio and a time-varying one in the definition of tracking error. More specifically, we now study the tradeoff of tracking error versus cardinality. Indeed, the assumption of the time-varying portfolio is more accurate.
# Prep data
T, N = SP500_stocks_2015to2020.shape
T_trn = 2*252
# Sweeping loop
lmd_sweep = 1e-5 * np.array([1, 2, 5, 10, 20, 50, 100, 200, 1000]) / 300
df_list = []
for lmd in lmd_sweep:
print(f"Backtesting for lambda: {lmd:.2e}")
# Run backtest
returns, weights = backtest(
portfolio_funcs={
"Assuming fixed portfolio": lambda dataset: tracking_using_plain_returns_wfixed(dataset, lambda_val=lmd),
"Assuming time-varying portfolio": lambda dataset: tracking_using_plain_returns_wt(dataset, lambda_val=lmd)
},
dataset={
'prices': SP500_stocks_2015to2020,
'index': SP500_index_2015to2020
},
lookback=T_trn,
rebalance_every=6*21, # Rebalance every 6 months (assuming 21 trading days per month)
optimize_every=6*21,
verbose=False
)
# Calculate tracking error and cardinality
TE = compute_tracking_error(returns, benchmark=SP500_index_2015to2020)
cardinality = compute_cardinality(weights)
df_list.append(pd.DataFrame({
"method": TE.index,
"TE": TE.values,
"K": cardinality.values,
}))
# Combine all rows into a single DataFrame
df = pd.concat(df_list, ignore_index=True)
Backtesting for lambda: 3.33e-08 Backtesting for lambda: 6.67e-08 Backtesting for lambda: 1.67e-07 Backtesting for lambda: 3.33e-07 Backtesting for lambda: 6.67e-07 Backtesting for lambda: 1.67e-06 Backtesting for lambda: 3.33e-06 Backtesting for lambda: 6.67e-06 Backtesting for lambda: 3.33e-05
# Plot tracking error versus sparsity level
sns.lineplot(data=df, x='K', y='TE', hue='method', style='method', linewidth=1.2)
plt.title('Tracking error versus sparsity level')
plt.xlabel('K (active assets)')
plt.ylabel('Tracking Error')
plt.legend(title=None)
plt.grid(True)
plt.tight_layout()
plt.show()
Enhanced index tracking¶
We now compare the following tracking error (TE) measures to track the S&P 500 index:
basic TE: $$ \textm{TE}(\w) = \frac{1}{T}\left\|\bm{r}^\textm{b} - \bm{X}\w\right\|^2_2; $$
robust Huberized TE: $$ \textm{Hub-TE}(\w) = \frac{1}{T}\sum_{t=1}^T \phi^{\textm{hub}}(r_t^\textm{b} - \bm{X}_{t,:}\w), $$ where $$\phi^{\textm{hub}}(x)=\begin{cases} x^{2}, & \quad|x|\leq M\\ M\left(2|x| - M\right), & \quad|x|>M; \end{cases}$$
$\ell_1$-norm TE: $$ \textm{TE}_1(\w) = \frac{1}{T}\left\|\bm{r}^\textm{b} - \bm{X}\w\right\|_1; $$
downside risk (DR) error measure: $$ \textm{DR}(\w) = \frac{1}{T}\left\|\left(\bm{r}^\textm{b} - \bm{X}\w\right)^+\right\|^2_2, $$ where $(\cdot)^+\triangleq\textm{max}(0,\cdot)$.
First, let's define the design functions for each of the methods based on the MM approach for enhanced index tracking:
def _prepare_cumulative_returns(
dataset: dict[str, pd.DataFrame],
) -> tuple[pd.DataFrame, pd.Series, pd.DataFrame, pd.Series]:
"""
Compute plain and cumulative returns, aligned on common valid dates.
Returns
-------
X, r, X_cum, r_cum
"""
X, r = _prepare_returns(dataset)
return X, r, X.cumsum(), r.cumsum()
def _tracking_residual(X_cum: pd.DataFrame, w: np.ndarray, r_cum: pd.Series) -> np.ndarray:
"""Compute tracking residual e_t = X_cum @ w - r_cum as a 1-D array."""
return X_cum.values @ w - r_cum.values
def TE_index_tracking(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 150 * 6e-7,
u: float = 0.5,
max_iter: int = 10,
) -> np.ndarray:
"""
Sparse index tracking on cumulative returns (squared tracking error).
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 9e-5
Sparsity regularization parameter.
u : float, default 0.5
Scale parameter of the log penalty.
max_iter : int, default 10
Maximum MM iterations.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
_, _, X_cum, r_cum = _prepare_cumulative_returns(dataset)
return sparse_tracking_via_MM(X_cum, r_cum, lambda_val=lambda_val, u=u, max_iter=max_iter)
def DR_index_tracking(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 150 * 6e-7,
u: float = 0.5,
max_iter_mm: int = 10,
max_iter_outer: int = 5,
) -> np.ndarray:
"""
Downside-risk index tracking on cumulative returns.
Iteratively modifies the benchmark by adding the positive part of
the tracking residual, so that only underperformance is penalized.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 9e-5
Sparsity regularization parameter.
u : float, default 0.5
Scale parameter of the log penalty.
max_iter_mm : int, default 10
Maximum MM iterations per subproblem.
max_iter_outer : int, default 5
Number of outer reweighting iterations.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
_, _, X_cum, r_cum = _prepare_cumulative_returns(dataset)
w = sparse_tracking_via_MM(X_cum, r_cum, lambda_val=lambda_val, u=u, max_iter=max_iter_mm)
for _ in range(max_iter_outer):
residual = _tracking_residual(X_cum, w, r_cum) # (T,)
r_cum_tilde = r_cum + np.maximum(0, residual)
w = sparse_tracking_via_MM(X_cum, r_cum_tilde, lambda_val=lambda_val, u=u, max_iter=max_iter_mm)
return w
def TE1_index_tracking(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 150 * 6e-7,
u: float = 0.5,
max_iter_mm: int = 10,
max_iter_outer: int = 5,
) -> np.ndarray:
"""
L1-norm index tracking on cumulative returns via IRLS.
Iteratively reweights observations by the inverse square root of
the absolute tracking residual to approximate the L1-norm objective.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 9e-5
Sparsity regularization parameter.
u : float, default 0.5
Scale parameter of the log penalty.
max_iter_mm : int, default 10
Maximum MM iterations per subproblem.
max_iter_outer : int, default 5
Number of outer IRLS iterations.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
_, _, X_cum, r_cum = _prepare_cumulative_returns(dataset)
w = sparse_tracking_via_MM(X_cum, r_cum, lambda_val=100 * 6e-7, u=u, max_iter=max_iter_mm)
for _ in range(max_iter_outer):
residual = np.abs(_tracking_residual(X_cum, w, r_cum)) # (T,)
alpha_sqrt = np.sqrt(1 / (2 * np.maximum(residual, 1e-12)))
X_cum_w = X_cum.multiply(alpha_sqrt, axis=0)
r_cum_w = r_cum * alpha_sqrt
w = sparse_tracking_via_MM(X_cum_w, r_cum_w, lambda_val=lambda_val, u=u, max_iter=max_iter_mm)
return w
def HubTE_index_tracking(
dataset: dict[str, pd.DataFrame],
lambda_val: float = 50 * 5e-8,
u: float = 0.5,
M: float = 1e-4,
max_iter_mm: int = 10,
max_iter_outer: int = 5,
) -> np.ndarray:
"""
Huber-loss index tracking on cumulative returns via IRLS.
Like ``TE1_index_tracking`` but uses a Huber-style weight that clips
the reweighting at threshold ``M``, making it robust to large
tracking deviations.
Parameters
----------
dataset : dict[str, pd.DataFrame]
Must contain ``'prices'`` and ``'index'``.
lambda_val : float, default 2.5e-6
Sparsity regularization parameter.
u : float, default 0.5
Scale parameter of the log penalty.
M : float, default 1e-4
Huber threshold controlling the transition from L2 to L1.
max_iter_mm : int, default 10
Maximum MM iterations per subproblem.
max_iter_outer : int, default 5
Number of outer IRLS iterations.
Returns
-------
w : np.ndarray
Sparse portfolio weight vector of length N.
"""
_, _, X_cum, r_cum = _prepare_cumulative_returns(dataset)
w = sparse_tracking_via_MM(X_cum, r_cum, lambda_val=lambda_val, u=u, max_iter=max_iter_mm)
for _ in range(max_iter_outer):
residual = np.abs(_tracking_residual(X_cum, w, r_cum)) # (T,)
alpha_sqrt = np.sqrt(np.minimum(1.0, M / np.maximum(residual, 1e-12)))
X_cum_w = X_cum.multiply(alpha_sqrt, axis=0)
r_cum_w = r_cum * alpha_sqrt
w = sparse_tracking_via_MM(X_cum_w, r_cum_w, lambda_val=lambda_val, u=u, max_iter=max_iter_mm)
return w
Let's run the backtest:
# Prep data
T, N = SP500_stocks_2015to2020.shape
T_trn = 2*252
# Run backtest
returns, weights = backtest(
portfolio_funcs={
"TE based design": TE_index_tracking,
"Hub-TE based design": HubTE_index_tracking,
"TE_1 based design": TE1_index_tracking,
"DR based design": DR_index_tracking
},
dataset={
'prices': SP500_stocks_2015to2020,
'index': SP500_index_2015to2020
},
lookback=T_trn,
rebalance_every=6*21,
optimize_every=6*21,
verbose=True
)
Backtesting portfolio 'TE based design'... Reoptimizing at t=504 (cardinality=4) Reoptimizing at t=630 (cardinality=5) Reoptimizing at t=756 (cardinality=5) Reoptimizing at t=882 (cardinality=5) Reoptimizing at t=1008 (cardinality=7) Reoptimizing at t=1134 (cardinality=6) Reoptimizing at t=1260 (cardinality=6) Reoptimizing at t=1386 (cardinality=5) Backtesting portfolio 'Hub-TE based design'... Reoptimizing at t=504 (cardinality=6) Reoptimizing at t=630 (cardinality=5) Reoptimizing at t=756 (cardinality=6) Reoptimizing at t=882 (cardinality=5) Reoptimizing at t=1008 (cardinality=7) Reoptimizing at t=1134 (cardinality=5) Reoptimizing at t=1260 (cardinality=6) Reoptimizing at t=1386 (cardinality=5) Backtesting portfolio 'TE_1 based design'... Reoptimizing at t=504 (cardinality=34) Reoptimizing at t=630 (cardinality=31) Reoptimizing at t=756 (cardinality=37) Reoptimizing at t=882 (cardinality=34) Reoptimizing at t=1008 (cardinality=35) Reoptimizing at t=1134 (cardinality=32) Reoptimizing at t=1260 (cardinality=41) Reoptimizing at t=1386 (cardinality=30) Backtesting portfolio 'DR based design'... Reoptimizing at t=504 (cardinality=4) Reoptimizing at t=630 (cardinality=5) Reoptimizing at t=756 (cardinality=5) Reoptimizing at t=882 (cardinality=3) Reoptimizing at t=1008 (cardinality=4) Reoptimizing at t=1134 (cardinality=4) Reoptimizing at t=1260 (cardinality=3) Reoptimizing at t=1386 (cardinality=5)
plot_prices(returns, benchmark=SP500_index_2015to2020)
Automatic sparsity control¶
The sparse index tracking formulation includes a regularization term $\lambda \|\w\|_0$ in the objective or as a constraint $\|\w\|_0 \le k$, where the hyper-parameter $\lambda$ or $k$ controls the sparsity level. Adjusting this hyper-parameter allows achieving different points on the error versus sparsity trade-off curve.
In practice, the goal is to choose a proper operating point on this trade-off curve without computing the entire curve. The false discovery rate (FDR), which is the probability of wrongly including a variable, is a key quantity for this decision. Controlling the FDR is the ideal way to decide whether to include a variable.
The T-Rex method, based on "dummy" variables, provides a practical approach for FDR control in sparse index tracking. Instead of tuning the sparsity hyper-parameter, T-Rex automatically selects the assets to include by controlling the FDR, avoiding the selection of irrelevant assets that are highly correlated with already selected ones.
The T-Rex method is efficiently implemented in the R package TRexSelector (and under development in Python as trexselector) based on the paper:
Machkour, J., Tien, S., Palomar, D. P., and Muma, M. (2022). TRexSelector: T-Rex selector: High-dimensional variable selection & FDR control.
We now backest the tracking portfolios obtained from the sparse penalized regression formulation with the FDR-controlling T-Rex method:
Machkour, J., Palomar, D. P., and Muma, M. (2025). FDR-controlled portfolio optimization for sparse financial index tracking. In Proceedings of the IEEE international conference on acoustics, speech, and signal processing (ICASSP). Hyderabad, India.
#
# T-Rex index tracking in Pyhon under development.
#
# See R version in: https://portfoliooptimizationbook.com/R-code/R-index-tracking.html
#
Index Tracking via skfolio¶
In the previous sections we used a custom backtest() function.
Here we replicate the same experiments using the skfolio library, which provides a scikit-learn–compatible backtesting framework via WalkForward and cross_val_predict.
Libraries and helpers¶
We import the necessary skfolio components and define two helpers:
Portfolio_via_CVXPY: adapted from Chapter 7 to also pass the benchmark returnsyto the custom function, so it can solve tracking problems.batch_cross_val_predict: a convenience wrapper (from Chapter 7) that runscross_val_predicton a list of optimizers with timing information.
import time
from datetime import timedelta
from skfolio import Population
from skfolio.optimization import ConvexOptimization, MeanRisk, ObjectiveFunction
from skfolio.model_selection import cross_val_predict, WalkForward
from skfolio.preprocessing import prices_to_returns
from skfolio.measures import RiskMeasure
# For cardinality constraints (mixed-integer programming):
# pip install "cvxpy[scip]"
The class Portfolio_via_CVXPY wraps an arbitrary CVXPY-based portfolio
design function so that it can be used within skfolio's backtesting
infrastructure. Compared with the version in Chapter 7, cvxpy_fun
receives both the asset returns X and the benchmark returns y, which
is required for index-tracking formulations.
class Portfolio_via_CVXPY(ConvexOptimization):
"""Portfolio based on a custom CVXPY function (supports benchmark y)."""
def __init__(self, cvxpy_fun=None, **kwargs):
super().__init__(**kwargs)
self.cvxpy_fun = cvxpy_fun
def fit(self, X, y=None):
if self.cvxpy_fun is None:
raise ValueError("cvxpy_fun must be provided at initialization")
weights = self.cvxpy_fun(X, y)
self.weights_ = np.asarray(weights)
return self
The helper batch_cross_val_predict iterates over a list of optimizers,
calling cross_val_predict on each one and printing elapsed times.
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"Starting batch backtesting with {len(optimizers)} portfolio designs...")
print(f"Cross-validation: {n_splits} splits")
print("=" * 70)
for i, optim in enumerate(optimizers, 1):
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"[{i}/{len(optimizers)}] Backtesting '{name}'...")
start_time = time.time()
try:
bt = cross_val_predict(
optim, X, cv=cv,
portfolio_params=getattr(optim, "portfolio_params", {}),
**kwargs,
)
elapsed_str = str(timedelta(seconds=int(time.time() - start_time)))
bt_list.append(bt)
print(f" Completed '{name}' in {elapsed_str}")
except Exception as e:
elapsed_str = str(timedelta(seconds=int(time.time() - start_time)))
print(f" Failed '{name}' after {elapsed_str}: {str(e)}")
raise
total_elapsed_str = str(timedelta(seconds=int(time.time() - total_start_time)))
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
Tracking error minimization (built-in skfolio)¶
skfolio's MeanRisk class natively supports a tracking error constraint
via the parameter max_tracking_error, defined as the RMSE (root mean
square error) of the portfolio returns relative to a benchmark (see the
skfolio tracking error example).
Here we create a minimum-variance long-only portfolio constrained to have a tracking error of at most 0.30% versus the S&P 500 index. Unlike the sparse MM formulations in the previous sections, this approach does not enforce sparsity — it may use all available assets.
We start by converting prices to returns via prices_to_returns, which
handles both asset and benchmark prices.
# Convert prices to returns (both assets and benchmark)
X, y = prices_to_returns(SP500_stocks_2015to2020, SP500_index_2015to2020)
print(f"Asset returns: {X.shape[0]} observations × {X.shape[1]} assets")
print(f"Benchmark returns: {y.shape[0]} observations")
Asset returns: 1439 observations × 471 assets Benchmark returns: 1439 observations
The MeanRisk model below minimizes portfolio variance subject to the
constraint that the tracking error (RMSE relative to the S&P 500)
does not exceed 0.30%. The default settings enforce long-only weights
(min_weights=0) and a fully-invested budget (sum(w) = 1).
portf_min_var_TE = MeanRisk(
objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.VARIANCE,
max_tracking_error=0.003,
portfolio_params=dict(name="Min-Variance with TE constraint"),
)
Quick sanity check: fit on a training slice and inspect the solution.
T_trn = 2 * 252
portf_min_var_TE.fit(X.iloc[:T_trn], y.iloc[:T_trn])
w = portf_min_var_TE.weights_
print(f"Sum of weights: {w.sum():.4f}")
print(f"Cardinality (non-zero weights): {np.sum(w > 1e-5)}")
print(f"Min weight: {w.min():.6f}")
Sum of weights: 1.0000 Cardinality (non-zero weights): 78 Min weight: 0.000000
Sparse index tracking (via Portfolio_via_CVXPY)¶
The functions sparse_tracking_via_MM and compute_alpha defined earlier
in the notebook solve the sparsity-penalized tracking problem via the MM
algorithm. We now write thin adapter functions that match the
cvxpy_fun(X, y) signature expected by Portfolio_via_CVXPY, where X
is a DataFrame of asset returns and y is a Series of benchmark returns
(both provided automatically by skfolio during the walk-forward backtest).
We consider two variants:
- Fixed portfolio: optimizes over plain returns — the weight vector $\mathbf{w}$ is assumed constant between rebalancing dates.
- Time-varying portfolio: optimizes over $\alpha$-transformed returns, accounting for the natural drift of the portfolio between rebalancing dates.
def tracking_wfixed(X, y, lambda_val=5e-7):
"""Sparse index tracking assuming a fixed portfolio (adapter for skfolio)."""
r = y.squeeze()
return sparse_tracking_via_MM(X, r, lambda_val=lambda_val, max_iter=10)
def tracking_wt(X, y, lambda_val=5e-7):
"""Sparse index tracking assuming a time-varying portfolio (adapter for skfolio)."""
r = y.squeeze()
alpha = compute_alpha(X, r)
w0 = sparse_tracking_via_MM(alpha["X_tilde"], r, lambda_val=lambda_val, max_iter=10)
w = w0 * alpha["last_alpha_lagged"]
w_sum = w.sum()
if w_sum > 0:
w = w / w_sum
return w
Wrap each adapter function in a Portfolio_via_CVXPY estimator so that
skfolio's cross_val_predict can call them during the walk-forward
backtest.
portf_sparse_fixed = Portfolio_via_CVXPY(
cvxpy_fun=tracking_wfixed,
portfolio_params=dict(name="Sparse tracking (fixed w)"),
)
portf_sparse_tv = Portfolio_via_CVXPY(
cvxpy_fun=tracking_wt,
portfolio_params=dict(name="Sparse tracking (time-varying w)"),
)
Quick sanity check on the training slice (same X, y, T_trn from the
previous subsection).
portf_sparse_fixed.fit(X.iloc[:T_trn], y.iloc[:T_trn])
w_fixed = portf_sparse_fixed.weights_
print("=== Fixed portfolio ===")
print(f" Sum of weights: {w_fixed.sum():.4f}")
print(f" Cardinality: {np.sum(w_fixed > 1e-5)}")
portf_sparse_tv.fit(X.iloc[:T_trn], y.iloc[:T_trn])
w_tv = portf_sparse_tv.weights_
print("\n=== Time-varying portfolio ===")
print(f" Sum of weights: {w_tv.sum():.4f}")
print(f" Cardinality: {np.sum(w_tv > 1e-5)}")
=== Fixed portfolio === Sum of weights: 1.0000 Cardinality: 22 === Time-varying portfolio === Sum of weights: 1.0000 Cardinality: 25
Backtest and comparison¶
We now run a walk-forward backtest on all three portfolios using the same
schedule as the original backtest() call:
- Training window:
T_trn = 2 × 252trading days (~2 years). - Test/rebalance window:
6 × 21trading days (~6 months).
At each fold, the model is refit on the training data and evaluated on the subsequent 6-month out-of-sample window.
Note: skfolio's
cross_val_predictcomputes out-of-sample returns using the fixed optimized weights throughout each test fold (i.e., no intra-fold weight drift). This matches the "fixed portfolio" assumption. In contrast, the custombacktest()function earlier in this notebook drifts weights daily with realized returns, so results will differ slightly.
all_formulations = [
portf_min_var_TE,
portf_sparse_fixed,
portf_sparse_tv,
]
cv = WalkForward(train_size=T_trn, test_size=6 * 21, purged_size=1)
bt_list = batch_cross_val_predict(
all_formulations,
X=X,
cv=cv,
y=y,
)
bt_population = Population(bt_list)
Starting batch backtesting with 3 portfolio designs... Cross-validation: 7 splits ====================================================================== [1/3] Backtesting 'Min-Variance with TE constraint'... Completed 'Min-Variance with TE constraint' in 0:00:11 [2/3] Backtesting 'Sparse tracking (fixed w)'... Completed 'Sparse tracking (fixed w)' in 0:00:09 [3/3] Backtesting 'Sparse tracking (time-varying w)'... Completed 'Sparse tracking (time-varying w)' in 0:00:10 ====================================================================== Batch backtesting completed! Total time: 0:00:30 Successfully processed 3 portfolios
Summary statistics¶
bt_population.summary()
| Min-Variance with TE constraint | Sparse tracking (fixed w) | Sparse tracking (time-varying w) | |
|---|---|---|---|
| Mean | 0.045% | 0.056% | 0.059% |
| Annualized Mean | 11.46% | 14.20% | 14.88% |
| Variance | 0.016% | 0.018% | 0.018% |
| Annualized Variance | 3.94% | 4.43% | 4.57% |
| Semi-Variance | 0.0087% | 0.0098% | 0.0098% |
| Annualized Semi-Variance | 2.20% | 2.47% | 2.47% |
| Standard Deviation | 1.25% | 1.33% | 1.35% |
| Annualized Standard Deviation | 19.85% | 21.05% | 21.38% |
| Semi-Deviation | 0.93% | 0.99% | 0.99% |
| Annualized Semi-Deviation | 14.84% | 15.73% | 15.72% |
| Mean Absolute Deviation | 0.65% | 0.75% | 0.75% |
| CVaR at 95% | 3.19% | 3.48% | 3.46% |
| EVaR at 95% | 6.75% | 6.70% | 6.70% |
| Worst Realization | 11.39% | 11.48% | 11.48% |
| CDaR at 95% | 20.91% | 19.03% | 18.64% |
| MAX Drawdown | 41.43% | 38.02% | 35.84% |
| Average Drawdown | 2.92% | 3.35% | 3.31% |
| EDaR at 95% | 28.69% | 26.16% | 24.80% |
| First Lower Partial Moment | 0.32% | 0.38% | 0.38% |
| Ulcer Index | 0.060 | 0.060 | 0.058 |
| Gini Mean Difference | 1.04% | 1.19% | 1.19% |
| Value at Risk at 95% | 1.56% | 1.90% | 1.91% |
| Drawdown at Risk at 95% | 12.71% | 11.74% | 12.28% |
| Entropic Risk Measure at 95% | 3.00 | 3.00 | 3.00 |
| Fourth Central Moment | 0.000067% | 0.000062% | 0.000070% |
| Fourth Lower Partial Moment | 0.000043% | 0.000040% | 0.000040% |
| Skew | -89.13% | -77.23% | -41.83% |
| Kurtosis | 2732.17% | 2000.00% | 2135.79% |
| Sharpe Ratio | 0.036 | 0.042 | 0.044 |
| Annualized Sharpe Ratio | 0.58 | 0.67 | 0.70 |
| Sortino Ratio | 0.049 | 0.057 | 0.060 |
| Annualized Sortino Ratio | 0.77 | 0.90 | 0.95 |
| Mean Absolute Deviation Ratio | 0.070 | 0.075 | 0.079 |
| First Lower Partial Moment Ratio | 0.14 | 0.15 | 0.16 |
| Value at Risk Ratio at 95% | 0.029 | 0.030 | 0.031 |
| CVaR Ratio at 95% | 0.014 | 0.016 | 0.017 |
| Entropic Risk Measure Ratio at 95% | 0.00015 | 0.00019 | 0.00020 |
| EVaR Ratio at 95% | 0.0067 | 0.0084 | 0.0088 |
| Worst Realization Ratio | 0.0040 | 0.0049 | 0.0051 |
| Drawdown at Risk Ratio at 95% | 0.0036 | 0.0048 | 0.0048 |
| CDaR Ratio at 95% | 0.0022 | 0.0030 | 0.0032 |
| Calmar Ratio | 0.0011 | 0.0015 | 0.0016 |
| Average Drawdown Ratio | 0.016 | 0.017 | 0.018 |
| EDaR Ratio at 95% | 0.0016 | 0.0022 | 0.0024 |
| Ulcer Index Ratio | 0.0076 | 0.0095 | 0.010 |
| Gini Mean Difference Ratio | 0.044 | 0.047 | 0.050 |
| Avg nb of Assets per Portfolio | 471.0 | 471.0 | 471.0 |
| Number of Portfolios | 7 | 7 | 7 |
| Number of Failed Portfolios | 0 | 0 | 0 |
| Number of Fallback Portfolios | 0 | 0 | 0 |
Cumulative returns¶
We plot the cumulative returns of each portfolio and overlay the S&P 500 benchmark (dashed black line) for visual comparison.
fig = bt_population.plot_cumulative_returns()
# Overlay benchmark cumulative returns
existing_x = fig.data[0].x
bench_oos = y.loc[bt_population[0].observations].squeeze()
bench_cum = (1 + bench_oos).cumprod() - 1
fig.add_scatter(
x=existing_x,
y=bench_cum.values,
name="S&P 500",
line=dict(color="black", dash="dash", width=1.5),
)
fig.update_layout(
title="Cumulative Returns (out-of-sample)",
yaxis_title="Growth of $1",
legend=dict(x=0.02, y=0.98),
)
fig.show()
Tracking error¶
We compute the mean squared tracking error (consistent with the
least-squares objective of sparse_tracking_via_MM) for each portfolio
over the out-of-sample period:
results = []
for bt in bt_population:
oos_ret = bt.returns # numpy array
oos_obs = bt.observations # date array
bench_ret = y.loc[oos_obs].values
excess = oos_ret - bench_ret
te_mse = (excess ** 2).mean()
te_rmse = np.sqrt(te_mse)
results.append({
"Portfolio": bt.name,
"TE (MSE)": f"{te_mse:.2e}",
"TE (daily RMSE)": f"{te_rmse:.4%}",
"TE (annualized RMSE)": f"{te_rmse * np.sqrt(252):.2%}",
"Cardinality": int(np.sum(bt[0].weights > 1e-5)),
})
pd.DataFrame(results).set_index("Portfolio")
| TE (MSE) | TE (daily RMSE) | TE (annualized RMSE) | Cardinality | |
|---|---|---|---|---|
| Portfolio | ||||
| Min-Variance with TE constraint | 3.31e-04 | 1.8186% | 28.87% | 78 |
| Sparse tracking (fixed w) | 3.50e-04 | 1.8716% | 29.71% | 22 |
| Sparse tracking (time-varying w) | 3.56e-04 | 1.8862% | 29.94% | 25 |
Native skfolio tracking approaches¶
skfolio provides three built-in approaches for tracking error optimization:
- Return-based tracking error constraint (
max_tracking_error): constrains the tracking error while optimizing another objective (e.g., minimize CVaR). - Weight-based target (
target_weights): minimizes tracking error by finding weights close to a target allocation. - Return-based target (
BenchmarkTracker): minimizes tracking risk directly on excess returns (portfolio minus benchmark).
All three support sparsity via the cardinality parameter, which limits the number of
invested assets using a mixed-integer solver (e.g., SCIP).
from skfolio.optimization import BenchmarkTracker
# --- Approach 1: Return-based TE constraint (min CVaR s.t. TE ≤ 0.3%) ---
portf_te_constraint = MeanRisk(
objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.CVAR,
max_tracking_error=0.003,
portfolio_params=dict(name="Min CVaR (TE ≤ 0.3%)"),
)
# --- Approach 2: Weight-based target (track equal-weighted portfolio) ---
n_assets = X.shape[1]
target_w = np.ones(n_assets) / n_assets
portf_target_weights = MeanRisk(
objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.STANDARD_DEVIATION,
target_weights=target_w,
portfolio_params=dict(name="Track EW target (weight-based)"),
)
# --- Approach 3: BenchmarkTracker (minimize TE on excess returns) ---
portf_benchmark_tracker = BenchmarkTracker(
risk_measure=RiskMeasure.STANDARD_DEVIATION,
portfolio_params=dict(name="BenchmarkTracker (dense)"),
)
# --- Approach 3b: BenchmarkTracker with cardinality for sparsity ---
portf_benchmark_tracker_sparse = BenchmarkTracker(
risk_measure=RiskMeasure.STANDARD_DEVIATION,
cardinality=20, # at most 20 assets
solver="SCIP", # MIP solver needed for cardinality
solver_params=dict(scip_params={"limits/time": 60}), # 60s per solve
portfolio_params=dict(name="BenchmarkTracker (K=20)"),
)
native_formulations = [
portf_te_constraint,
portf_target_weights,
portf_benchmark_tracker,
portf_benchmark_tracker_sparse,
]
cv = WalkForward(train_size=T_trn, test_size=6 * 21, purged_size=1)
bt_native = batch_cross_val_predict(
native_formulations,
X=X,
cv=cv,
y=y,
)
bt_native_pop = Population(bt_native)
Starting batch backtesting with 4 portfolio designs... Cross-validation: 7 splits ====================================================================== [1/4] Backtesting 'Min CVaR (TE ≤ 0.3%)'... Completed 'Min CVaR (TE ≤ 0.3%)' in 0:00:21 [2/4] Backtesting 'Track EW target (weight-based)'... Completed 'Track EW target (weight-based)' in 0:00:01 [3/4] Backtesting 'BenchmarkTracker (dense)'... Completed 'BenchmarkTracker (dense)' in 0:00:02 [4/4] Backtesting 'BenchmarkTracker (K=20)'... Completed 'BenchmarkTracker (K=20)' in 0:07:02 ====================================================================== Batch backtesting completed! Total time: 0:07:27 Successfully processed 4 portfolios
bt_native_pop.summary()
| Min CVaR (TE ≤ 0.3%) | Track EW target (weight-based) | BenchmarkTracker (dense) | BenchmarkTracker (K=20) | |
|---|---|---|---|---|
| Mean | 0.047% | 0.049% | 0.054% | 0.049% |
| Annualized Mean | 11.90% | 12.33% | 13.59% | 12.42% |
| Variance | 0.016% | 0.020% | 0.018% | 0.018% |
| Annualized Variance | 4.07% | 5.05% | 4.57% | 4.66% |
| Semi-Variance | 0.0091% | 0.011% | 0.0100% | 0.010% |
| Annualized Semi-Variance | 2.29% | 2.82% | 2.51% | 2.57% |
| Standard Deviation | 1.27% | 1.42% | 1.35% | 1.36% |
| Annualized Standard Deviation | 20.17% | 22.48% | 21.37% | 21.59% |
| Semi-Deviation | 0.95% | 1.06% | 1.00% | 1.01% |
| Annualized Semi-Deviation | 15.13% | 16.79% | 15.85% | 16.05% |
| Mean Absolute Deviation | 0.68% | 0.78% | 0.73% | 0.76% |
| CVaR at 95% | 3.21% | 3.64% | 3.48% | 3.51% |
| EVaR at 95% | 6.97% | 7.33% | 6.93% | 6.86% |
| Worst Realization | 11.86% | 12.46% | 11.84% | 11.50% |
| CDaR at 95% | 19.60% | 24.59% | 20.33% | 21.43% |
| MAX Drawdown | 40.35% | 45.55% | 39.21% | 40.11% |
| Average Drawdown | 3.24% | 3.65% | 3.34% | 3.42% |
| EDaR at 95% | 28.49% | 32.15% | 27.34% | 28.10% |
| First Lower Partial Moment | 0.34% | 0.39% | 0.37% | 0.38% |
| Ulcer Index | 0.059 | 0.071 | 0.061 | 0.064 |
| Gini Mean Difference | 1.07% | 1.23% | 1.17% | 1.20% |
| Value at Risk at 95% | 1.71% | 2.03% | 1.91% | 1.87% |
| Drawdown at Risk at 95% | 10.78% | 16.43% | 12.55% | 14.07% |
| Entropic Risk Measure at 95% | 3.00 | 3.00 | 3.00 | 3.00 |
| Fourth Central Moment | 0.000075% | 0.000094% | 0.000076% | 0.000073% |
| Fourth Lower Partial Moment | 0.000047% | 0.000059% | 0.000046% | 0.000045% |
| Skew | -89.81% | -76.49% | -62.63% | -67.16% |
| Kurtosis | 2875.33% | 2343.76% | 2313.63% | 2128.60% |
| Sharpe Ratio | 0.037 | 0.035 | 0.040 | 0.036 |
| Annualized Sharpe Ratio | 0.59 | 0.55 | 0.64 | 0.58 |
| Sortino Ratio | 0.050 | 0.046 | 0.054 | 0.049 |
| Annualized Sortino Ratio | 0.79 | 0.73 | 0.86 | 0.77 |
| Mean Absolute Deviation Ratio | 0.070 | 0.063 | 0.073 | 0.065 |
| First Lower Partial Moment Ratio | 0.14 | 0.13 | 0.15 | 0.13 |
| Value at Risk Ratio at 95% | 0.028 | 0.024 | 0.028 | 0.026 |
| CVaR Ratio at 95% | 0.015 | 0.013 | 0.015 | 0.014 |
| Entropic Risk Measure Ratio at 95% | 0.00016 | 0.00016 | 0.00018 | 0.00016 |
| EVaR Ratio at 95% | 0.0068 | 0.0067 | 0.0078 | 0.0072 |
| Worst Realization Ratio | 0.0040 | 0.0039 | 0.0046 | 0.0043 |
| Drawdown at Risk Ratio at 95% | 0.0044 | 0.0030 | 0.0043 | 0.0035 |
| CDaR Ratio at 95% | 0.0024 | 0.0020 | 0.0027 | 0.0023 |
| Calmar Ratio | 0.0012 | 0.0011 | 0.0014 | 0.0012 |
| Average Drawdown Ratio | 0.015 | 0.013 | 0.016 | 0.014 |
| EDaR Ratio at 95% | 0.0017 | 0.0015 | 0.0020 | 0.0018 |
| Ulcer Index Ratio | 0.0080 | 0.0069 | 0.0088 | 0.0077 |
| Gini Mean Difference Ratio | 0.044 | 0.040 | 0.046 | 0.041 |
| Avg nb of Assets per Portfolio | 471.0 | 471.0 | 471.0 | 471.0 |
| Number of Portfolios | 7 | 7 | 7 | 7 |
| Number of Failed Portfolios | 0 | 0 | 0 | 0 |
| Number of Fallback Portfolios | 0 | 0 | 0 | 0 |
fig = bt_native_pop.plot_cumulative_returns()
existing_x = fig.data[0].x
bench_oos = y.loc[bt_native_pop[0].observations].squeeze()
bench_cum = (1 + bench_oos).cumprod() - 1
fig.add_scatter(
x=existing_x,
y=bench_cum.values,
name="S&P 500",
line=dict(color="black", dash="dash", width=1.5),
)
fig.update_layout(
title="Cumulative Returns – Native skfolio Tracking (out-of-sample)",
yaxis_title="Growth of $1",
legend=dict(x=0.02, y=0.98),
)
fig.show()
results = []
for bt in bt_native_pop:
oos_ret = bt.returns
oos_obs = bt.observations
bench_ret = y.loc[oos_obs].values
excess = oos_ret - bench_ret
te_mse = (excess ** 2).mean()
te_rmse = np.sqrt(te_mse)
card = int(np.sum(bt[0].weights > 1e-5))
results.append({
"Portfolio": bt.name,
"TE (MSE)": f"{te_mse:.2e}",
"TE (daily RMSE)": f"{te_rmse:.4%}",
"TE (annualized RMSE)": f"{te_rmse * np.sqrt(252):.2%}",
"Cardinality": card,
})
pd.DataFrame(results).set_index("Portfolio")
| TE (MSE) | TE (daily RMSE) | TE (annualized RMSE) | Cardinality | |
|---|---|---|---|---|
| Portfolio | ||||
| Min CVaR (TE ≤ 0.3%) | 3.36e-04 | 1.8328% | 29.10% | 59 |
| Track EW target (weight-based) | 3.75e-04 | 1.9364% | 30.74% | 471 |
| BenchmarkTracker (dense) | 3.56e-04 | 1.8857% | 29.93% | 284 |
| BenchmarkTracker (K=20) | 3.59e-04 | 1.8957% | 30.09% | 20 |