Stationarity in Time Series: Why It Matters for Trading Models

Stationarity is the assumption nearly every quant model makes and rarely verifies properly. The naive version — "use returns, run ADF, move on" — hides three things that decide whether a model survives deployment: unit-root tests have weak power and conflicting nulls, financial returns violate even weak stationarity in their variance, and the differencing that buys you stationarity destroys the memory that carried your signal. This article treats stationarity as a modeling decision with trade-offs, not a checkbox.

Weak stationarity and what breaks without it

A series is weakly (covariance) stationary if its mean is constant, its variance is finite and constant, and its autocovariance cov(xt, x{t-k}) depends only on the lag k, not on t. Strict stationarity requires the entire joint distribution to be time-invariant; we almost never have it and rarely need it. Weak stationarity is enough for the asymptotics behind OLS, ARMA, and most estimators to hold.

The reason this is non-negotiable is the spurious regression result (Granger and Newbold, 1974). Regress one independent random walk on another and the t-statistic on the slope diverges with sample size — you get arbitrarily "significant" coefficients and high R-squared between series with no relationship. The mechanism is that the errors are themselves I(1), so the standard errors are wrong by an order that grows with T. This is not a small-sample curiosity; it gets worse with more data.

import numpy as np
import statsmodels.api as sm

def spurious_rate(n=250, trials=2000, alpha=0.05):
    rej = 0
    for _ in range(trials):
        x = np.cumsum(np.random.randn(n))     # independent random walks
        y = np.cumsum(np.random.randn(n))
        t = sm.OLS(y, sm.add_constant(x)).fit().tvalues[1]
        if abs(t) > 1.96:
            rej += 1
    return rej / trials    # nominal 5%; you will see ~70-90%

print(f"Spurious 'significant' rate: {spurious_rate():.1%}")

You will see rejection rates far above the nominal 5% — often 70-90%. Any backtest built on price levels (regressions, ML features) inherits this pathology, and it looks like genuine, stable predictability until it evaporates live.

ADF: the null, the regression spec, and the power problem

The Augmented Dickey-Fuller test fits

dx_t = c + d*t + rho*x_{t-1} + sum_i psi_i * dx_{t-i} + e_t

and tests H0: rho = 0 (a unit root, non-stationary) against rho < 0. Three specification choices change the answer and are routinely botched:

  • Deterministic terms. Include a constant (regression="c") for a series with a

non-zero mean, a constant plus trend ("ct") for a trending series. Misspecifying these wrecks the test — testing a clearly trending series with no trend term will spuriously fail to reject.

  • Lag length. The augmentation lags psi_i soak up serial correlation. Too few

and the test is size-distorted; too many and it loses power. Use an information criterion (autolag="AIC"), not a fixed guess.

  • Power. This is the headline issue. The ADF test cannot distinguish a true unit

root from a stationary process whose AR root is near unity. For a process with rho implying a 60-day mean-reversion half-life, ADF on a year of data will fail to reject the unit root most of the time. Low power means false negatives exactly for the slow-reverting series quants want to trade.

Because ADF's null is non-stationarity, "failing to reject" is not evidence of a unit root — it is absence of evidence. Pair it with KPSS, whose null is the opposite (stationarity), to get a confirmatory read.

from statsmodels.tsa.stattools import adfuller, kpss

def stationarity_verdict(x, signif=0.05):
    adf_p = adfuller(x, autolag="AIC")[1]
    kpss_p = kpss(x, regression="c", nlags="auto")[1]
    adf_stat = adf_p < signif          # True => reject unit root (stationary)
    kpss_stat = kpss_p > signif        # True => fail to reject stationarity
    if adf_stat and kpss_stat:   return "stationary"
    if not adf_stat and not kpss_stat: return "non-stationary (unit root)"
    if adf_stat and not kpss_stat: return "difference-stationary? conflict"
    return "trend-stationary? conflict"
ADFKPSSReadingFix
rejectfail to rejectstationarynone
fail to rejectrejectunit rootdifference
rejectrejectconflictlikely trend-stationary; detrend
fail to rejectfail to rejectlow power bothget more data / inspect

Variance non-stationarity is the one people ignore

Returns pass mean-stationarity tests but flagrantly violate variance stationarity: volatility clusters and shifts with regime (the entire reason GARCH exists). A series with stable mean but heteroskedastic variance is not covariance-stationary, and models that assume homoskedastic errors will produce miscalibrated standard errors, wrong confidence intervals, and unstable volatility estimates. Testing the level for a unit root and declaring victory while ignoring the variance is the most common stationarity error among people who think they have avoided the common errors.

The memory cost of differencing

First-differencing reliably stationarizes a price series, but it is a blunt instrument: it removes essentially all long-memory information. The level often is the signal (overbought/oversold, distance from equilibrium), and full differencing throws it away — converting a predictable, mean-reverting feature into near-noise. This is the trade-off at the heart of feature construction for ML.

Fractional differentiation (López de Prado) resolves the tension by differencing to a fractional order d in (0, 1): enough to pass a stationarity test, little enough to retain memory. The operator is the binomial expansion of (1 - L)^d:

import numpy as np
import pandas as pd

def frac_diff_weights(d, thresh=1e-4):
    w = [1.0]
    k = 1
    while abs(w[-1]) > thresh:
        w.append(-w[-1] * (d - k + 1) / k)
        k += 1
    return np.array(w[::-1])

def frac_diff(series, d, thresh=1e-4):
    w = frac_diff_weights(d, thresh)
    width = len(w)
    out = series.rolling(width).apply(lambda x: np.dot(w, x), raw=True)
    return out.dropna()

The practical recipe: search for the minimum d that makes ADF reject, which maximizes retained memory subject to passing the test. See fractional differentiation for the full treatment. Empirically, equity prices often stationarize at d around 0.3-0.5 while retaining substantial autocorrelation that d = 1 would have destroyed — directly useful for feature engineering.

Look-ahead, regimes, and the train/serve gap

Two deployment traps specific to stationarity work:

  1. Transformation leakage. Any statistic used to transform the series — a

detrending regression, a normalization mean/variance, a fitted d — must be estimated on training data and applied causally. Computing a z-score with the full-sample mean is look-ahead bias dressed as preprocessing, and it makes in-sample backtests glow.

  1. Stationarity is not permanent. Markets shift regimes; a series stationary in

one regime is not in another. A relationship that passed ADF last year can be a trending mess this year. Re-test on a rolling basis and consider explicit regime detection rather than assuming a single stationary world. The ML corollary is covariate shift: if the feature distribution drifts between training and live, even a "stationary" feature degrades, which is why purged cross-validation and walk-forward evaluation matter.

Structural breaks masquerade as unit roots

A subtle and expensive confound: a stationary series with a structural break in its mean looks non-stationary to the ADF test. Perron's result is that the standard ADF test has dramatically reduced power against a trend-stationary series that experiences a one-time level or trend shift — it spuriously fails to reject the unit root because the break inflates the apparent persistence. In trading terms, a spread that was mean-reverting around one level and then jumped to a new level (a recapitalization, an index change) will both (a) fail your stationarity test and (b) be genuinely tradeable within each regime. Conversely, fitting a single stationary model across a break produces parameter estimates that are an average of two regimes and predict neither.

The defensive tools are break tests and rolling diagnostics rather than a single full- sample test:

import numpy as np
import pandas as pd

def rolling_adf(series, window=252, step=21):
    from statsmodels.tsa.stattools import adfuller
    out = {}
    for end in range(window, len(series), step):
        win = series.iloc[end - window:end].dropna()
        out[series.index[end - 1]] = adfuller(win, autolag="AIC")[1]
    return pd.Series(out)        # watch for the p-value regime-flipping over time

A CUSUM test on regression residuals, or a Bai-Perron multiple-break test, formalizes this; the practical version is simply running the rolling ADF above and treating a p-value that flips from "stationary" to "non-stationary" as evidence of a break rather than a property of the whole series.

A disciplined workflow

  • Plot the series with rolling mean and rolling standard deviation first; visible

drift or fanning variance is non-stationarity you can see before any test.

  • Default to log returns as the modeling unit, but do not stop there — check the

variance for clustering, not just the mean.

  • Run ADF and KPSS jointly with correct deterministic terms and AIC-selected lags;

treat conflicts as information about the type of non-stationarity.

  • When the level carries signal, reach for fractional differentiation and pick the

smallest d that passes, rather than reflexively taking first differences.

  • Fit every transformation on training data only, persist it, and apply it causally

to live data to avoid train/serve mismatch.

  • Re-test as regimes change; stationarity is a local, not global, property of

markets.

Honest limits

Stationarity tests answer a narrow question — is there a unit root in the conditional mean under these maintained assumptions — and they answer it with low power against the near-unit-root, slow-reverting processes that are most tradeable. They say nothing about variance dynamics, structural breaks, or nonlinearity. Use them as a gate and a diagnostic, not as proof. The deeper skill is knowing which transformation preserves the signal you care about: returns for risk, fractional differencing for memory-bearing features, cointegrating combinations for spreads. Stationarity is what keeps a model from learning a pattern that disappears the moment you go live — but only if you respect that it is a local property you must keep re-earning.

#stationarity #time series #ADF test #returns #quantitative trading