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:


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:

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 returns y to the custom function, so it can solve tracking problems.
  • batch_cross_val_predict: a convenience wrapper (from Chapter 7) that runs cross_val_predict on 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 × 252 trading days (~2 years).
  • Test/rebalance window: 6 × 21 trading 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_predict computes 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 custom backtest() 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:

$$\mathrm{TE} = \frac{1}{T}\sum_{t=1}^{T}(r_{p,t} - r_{b,t})^2.$$
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:

  1. Return-based tracking error constraint (max_tracking_error): constrains the tracking error while optimizing another objective (e.g., minimize CVaR).
  2. Weight-based target (target_weights): minimizes tracking error by finding weights close to a target allocation.
  3. 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