Skip to content

How to Diagnose Stationarity in Prices and Returns

Original question

Can you explain the difference between stationarity and non-stationarity in financial time series with practical Python examples?
A weakly stationary time series has a constant mean, a constant finite variance, and a covariance between observations that depends only on their time separation. A random walk in log price is non-stationary because the uncertainty in its level accumulates. Its one-period log returns are stationary in the simplest model with independent, identically distributed shocks.
Imagine opening a chart at a randomly chosen point. If the series is stationary, the model gives you the same unconditional mean and variance wherever you begin. Recent observations can still change your forecast. An autoregressive series might have a constant long-run mean while a high observation today makes a high observation tomorrow more likely. Likewise, a stationary volatility model can assign different conditional variances to quiet and turbulent weeks.

Financial prices can wander far from their starting levels. Measuring the change over one day resets the comparison to a common horizon. This often makes returns a better starting point for analysis, but returns can still undergo structural changes in their unconditional mean or variance.

Take a difference and track what happens to variance

Let PtP_t denote log price and suppose Pt=Pt−1+εtP_t=P_{t-1}+\varepsilon_t, with P0=0P_0=0 and independent shocks of mean zero and variance σ2>0\sigma^2>0. Expanding the recursion gives

Pt=∑j=1tεj,Var⁡(Pt)=tσ2,Cov⁡(Ps,Pt)=σ2min⁡(s,t).P_t=\sum_{j=1}^t\varepsilon_j, \qquad \operatorname{Var}(P_t)=t\sigma^2, \qquad \operatorname{Cov}(P_s,P_t)=\sigma^2\min(s,t).
The level's variance grows with tt, and the covariance at a fixed lag depends on the starting date. In contrast, ΔPt=εt\Delta P_t=\varepsilon_t has variance σ2\sigma^2 and zero covariance at every nonzero lag. A unit-root series whose first difference is stationary is called integrated of order one, or I(1)I(1).
A process can also be non-stationary because of a deterministic trend. If Pt=βt+utP_t=\beta t+u_t with stationary utu_t, subtracting the trend recovers utu_t. The distinction matters for forecasting because a shock to a random walk shifts its future expected level permanently, while a transitory shock around a trend can fade.

For the covariance condition, compare observations one step apart early and late in the random walk. P11=P10+ε11P_{11}=P_{10}+\varepsilon_{11}, and the new shock is independent of P10P_{10}, so their covariance is Var⁡(P10)=10σ2\operatorname{Var}(P_{10})=10\sigma^2. The covariance of P50P_{50} and P51P_{51} is 50σ250\sigma^2, despite the same one-step lag. ADF and KPSS can help examine a sample, but their different null hypotheses make agreement more informative than a single p-value. A break or an overly short sample can make both tests hard to interpret.

Stationarity does not require every conditional forecast of variance to be constant. A stationary return process can have a stable unconditional variance while today's large shock raises tomorrow's conditional variance. That distinction is important if the price level looks non-stationary and the returns show clusters of large moves. Taking log differences addresses the changing scale of the level under a random-walk model. It need not remove predictable changes in the size of the returns. Check both the returns and their squares before deciding what to model.

Twenty days versus eighty days

Suppose daily log returns have zero mean and a standard deviation of 1%. The random-walk model assigns a standard deviation of 20×1%≈4.47%\sqrt{20}\times1\%\approx4.47\% to cumulative log return after 20 trading days. After 80 days it gives about 8.94%. The daily log return has a 1% standard deviation at both dates. To see the difference in data, plot log prices and first differences, inspect their autocorrelations, and compare several calendar windows for changes.

Test the simulated levels and changes

import matplotlib.pyplot as plt import numpy as np from statsmodels.tsa.stattools import adfuller rng = np.random.default_rng(42) daily_log_return = rng.normal(0, 0.01, size=500) log_price = np.r_[0, daily_log_return.cumsum()] for label, values in [("log price", log_price), ("log return", np.diff(log_price))]: statistic, p_value, *_ = adfuller(values, regression="c", autolag="AIC") print(label, f"ADF statistic={statistic:.2f}, p={p_value:.3f}") fig, axes = plt.subplots(2, 1, sharex=True) axes[0].plot(log_price) axes[0].set_ylabel("Log price") axes[1].plot(np.arange(1, len(log_price)), np.diff(log_price)) axes[1].set_ylabel("Log return") plt.show()
The augmented Dickey–Fuller test uses a unit root as its null. With real series, select the deterministic terms and lag choices deliberately. KPSS tests a stationarity null and can help distinguish ambiguous cases.

References and next steps