Turbulence Index: theory, implementation, and an honest backtest in Python

  • August 1, 2026
  • 6 min read

Luigi Piva — ALGOSWORKSAI LTD

This article is the complete, technical version of an idea I introduced elsewhere: how to measure market stress with the Mahalanobis distance, how to implement it in Python without fooling yourself, and what to expect when you use it. It contains working code and the methodological discipline that separates a reliable backtest from one that merely believes itself.

1. The theory in five lines

The Turbulence Index (Kritzman & Li, 2010) measures how statistically unusual a basket’s returns today are relative to their history, accounting for both magnitude and correlation structure:

d_t=1/n (r_t-μ)^T Σ^(-1) (r_t-μ)

where r_t  is the return vector at time t , μ  the historical mean vector, Σ  the historical covariance matrix,  n the number of assets. It’s the squared Mahalanobis distance, normalized.

The Σ^(-1)  term is the heart of it: it penalizes movements that violate the usual correlation structure. Two assets rising together when they’re normally uncorrelated produce more “turbulence” than two assets moving in their customary way, even at equal magnitude.

2. Implementation, with the trap to avoid

The number-one error in backtests of this kind is look-ahead bias: computing μ  and Σ  over the whole sample, including data in the future relative to the evaluation point. It produces magnificent and completely false results.

The correct discipline: at each time t , estimate μ  and Σ  only on a window of past data.

import numpy as np
import pandas as pd


def turbulence_index(returns, lookback=104, min_periods=52):
    “””
    Rolling Turbulence Index (Kritzman & Li 2010).
    mu, Sigma estimated on a trailing window ENDING at t-1: no look-ahead.

    returns : DataFrame (rows = periods, columns = assets)
    lookback : window width (weeks)
    “””
    R = returns.values
    T, n = R.shape
    d = np.full(T, np.nan)
    for t in range(min_periods, T):
        start = max(0, t – lookback)
        win = R[start:t]                 # PAST data only, excludes t
        if win.shape[0] < min_periods:
            continue
        mu = win.mean(axis=0)
        Sigma = np.cov(win, rowvar=False)
        diff = R[t] – mu
        # solve is more numerically stable than inv() when Sigma is ill-conditioned
        d[t] = diff @ np.linalg.solve(Sigma, diff) / n
    return pd.Series(d, index=returns.index, name=”turbulence”)

Two technical details that matter:

np.linalg.solve instead of np.linalg.inv. Solving the linear system Σ =diff is numerically more stable than explicitly inverting Σ , especially when assets are highly correlated and the matrix is ill-conditioned — which is precisely during crises, when we need it most.

The window R[start:t] excludes t. It seems trivial but it’s the line that makes the test honest. Today’s return doesn’t enter the estimation of the parameters we use to judge today.

3. The data

Seven global asset classes via ETFs, weekly returns:

TICKERS = {
    “US_Stocks”:”SPY”, “NonUS_Stocks”:”EFA”, “US_Bonds”:”AGG”,
    “NonUS_Bonds”:”BNDX”, “US_RealEstate”:”VNQ”,
    “NonUS_RealEstate”:”RWX”, “Commodities”:”DBC”,
}

import yfinance as yf
px = yf.download(list(TICKERS.values()), start=”2000-01-01″,
                 auto_adjust=True, progress=False)[“Close”]
px = px.rename(columns={v:k for k,v in TICKERS.items()})[list(TICKERS.keys())]
weekly = px.resample(“W-FRI”).last()
returns = weekly.pct_change().dropna(how=”any”)

A practical warning on data: two of these ETFs (BNDX and RWX) were only listed in 2013 and 2010. With dropna(how=”any”) the full 7-asset series cannot begin before 2013, regardless of the start parameter. This is the kind of constraint to verify before drawing conclusions about the period covered, a first_valid_index() per column reveals the bottleneck immediately.

4. Verifying the empirical properties

Before using it, an indicator must be verified. Kritzman & Li claim three properties. I check all of them.

Spikes during stress. The highest-turbulence weeks should coincide with identifiable crises. On real data, the peaks land on March 2020 (COVID), autumn 2022, the known stress moments. ✓

Persistence. The autocorrelation of turbulence should be positive: after a spike, it stays elevated.

td = turb.dropna()
for lag in [1, 2, 4, 8]:
    print(f”lag {lag}: {td.autocorr(lag):.3f}”)

This property is what justifies the operational use: if turbulence persists, reacting when you see it makes sense because it will continue over the coming weeks.

Expected value ≈ 1. For elliptically distributed returns, the expected value of the normalized turbulence is about 1. A strong deviation signals fat tails or misspecification. A useful sanity check.

5. Use: scaling exposure

The paper’s application: reduce equity exposure when turbulence rises. The method that works and I’ll return to this in detail in a second article, is quintile scaling, which stays fully invested in normal regimes and cuts only in the tail:

def quintile_exposure(pctl):
    “””Discrete exposure per quintile of historical turbulence.”””
    def f(p):
        if np.isnan(p): return 1.0
        if p < 0.40: return 1.0     # calm regime: full
        if p < 0.60: return 0.75
        if p < 0.80: return 0.50
        return 0.0                  # turbulent tail: out
    return pctl.apply(f)

# EXPANDING percentile (past history only) -> no look-ahead here either
turb_pctl = turb.expanding(min_periods=104).apply(
    lambda x: (x.iloc[-1] >= x).mean(), raw=False)
exposure = quintile_exposure(turb_pctl).shift(1)   # T+1 lag: decide today, act tomorrow

The double anti-look-ahead guard: expanding percentile (never future) and one-period shift (tomorrow’s position is based on today’s signal).

6. The result

On SPY, with cash (SHY) as the complement, net of transaction costs:

 

CAGR

Sharpe

Max DD

Avg Exp

Buy & Hold

13.9%

0.89

−31.8%

100%

Turbulence-adjusted

11.3%

0.95

−18.4%

83%

The Sharpe improves (0.95 vs 0.89) and the drawdown nearly halves, maintaining 83% average exposure. On a SPY buy & hold, a benchmark famously hard to beat on Sharpe, this is a solid result.

7. The final honesty

Kritzman & Li reported much stronger Sharpe improvements (up to ~2.20) on other instruments and periods. My 0.95 on SPY doesn’t reproduce them: part of the gap is the period (mine starts in 2013 due to ETF data constraints, missing the post-crisis years 2009–2012), part is the instrument, part is perhaps favorable optimization in the original setup.

And above all: every number here is in-sample in the broad sense, we chose the calibration knowing the data. Serious validation is walk-forward out-of-sample. These results are exploratory and encouraging, not a strategy ready for capital.

But the starting question: can you improve a SPY buy & hold by measuring the stress regime?  has an answer: yes, modestly, if you calibrate with judgment. And the path to verifying it rigorously is clear.

ALGOSWORKSAI LTD develops and validates systematic strategies for institutional allocators. The code is illustrative. Nothing in this article constitutes investment advice.

Get the monthly Market Regime Note

Regimes, volatility and correlations across major futures markets — with the code behind the charts. Free.

Subscribe →