
How to Test for Stationarity in Time Series Using ADF and KPSS in Python
How to Test for Stationarity in Time Series Using ADF and KPSS in Python
Time series work in quantitative finance often begins with an assumption that is easy to state but hard to satisfy: the future will behave enough like the past to make a model useful. Stationarity is the formal version of that assumption. When a price series is non-stationary, a forecasting model may learn patterns that are accidental, unstable, or purely a function of the sample window.
This guide walks through two complementary stationarity tests—the Augmented Dickey-Fuller (ADF) test and the Kwiatkowski-Phillips-Schmidt-Shin (KPSS) test—and shows how to run them in Python with statsmodels. You will leave with a practical decision framework, code you can reuse, and a clear view of why this step belongs before feature engineering in any forecasting pipeline.
Why Stationarity Matters Before You Model
Many forecasting and machine-learning models assume that the joint statistical distribution of a series remains constant over time. That does not always mean the series must be perfectly normal or independent; it means the underlying data-generating process should not be drifting in ways that make training observations misleading.
If an asset price series has a time-varying mean, a model may learn to chase a trend that does not continue out of sample. If it has time-varying variance, a model may treat calm periods as more representative than volatile ones, or vice versa. The result is often a signal that looks strong in backtesting but fails when evaluated on unseen data.
Stationarity testing is therefore a diagnostic tool. It helps you decide whether the raw series is suitable for modelling or whether you should transform it first. In a competitive forecasting setting, skipping this step is one of the fastest ways to build an overfit signal.
Stationarity in Asset Price Series: The Core Problem
What is Stationarity in Plain Terms?
A strictly stationary series has a complete distribution that is unchanged when the series is shifted in time. In practice, analysts usually work with weak stationarity, which requires three conditions:
- Constant Mean: The mean of the series does not drift over time.
- Constant Variance: The volatility of the series is stable.
- Stable Autocovariance: The relationship between two observations depends only on the time gap separating them, not on where in the series those observations occur.
Weak stationarity does not require returns to be independent or normally distributed. It only requires that the broad statistical shape of the series remains stable enough for a model to learn from history.
Why Raw Prices Fail the Stationarity Test
Raw equity, foreign exchange, or crypto prices usually fail the stationarity test because they trend. A price that starts near 100 and ends near 300 has a time-varying mean. Its variance can also grow as the price level rises, creating heteroscedasticity. That makes raw prices unsuitable for models assuming a stable mean and variance.
A standard transformation is to take the natural logarithm of the price and then compute first differences:
import numpy as np
log_price = np.log(price)
returns = log_price.diff().dropna()
This log-differencing produces a series of continuously compounded returns. Returns are usually much closer to stationary than prices because the transformation removes the trend and expresses changes in proportional terms.
Meet the Two Complementary Tests: ADF and KPSS
Augmented Dickey-Fuller (ADF) Test Explained
The ADF test starts from the null hypothesis that the series has a unit root, meaning it is non-stationary. The test fits a regression that includes lagged differences of the series to account for serial correlation. If the test statistic is sufficiently negative, you reject the null and conclude that the series is stationary.
In Python, statsmodels.tsa.stattools.adfuller implements the ADF test. A small p-value, typically below 0.05, provides evidence against the unit root hypothesis.
Kwiatkowski-Phillips-Schmidt-Shin (KPSS) Test Explained
The KPSS test flips the logic. Its null hypothesis is that the series is stationary around a deterministic level or trend. The test examines whether the series drifts too far from that level. If the test statistic is too large, you reject the null and conclude that the series is non-stationary.
In contrast to ADF, a high KPSS p-value is reassuring. If the p-value is above 0.05, you fail to reject the null of stationarity.
Why One Test is Not Enough
No single stationarity test has perfect power. ADF can fail to reject the unit root when the sample is small or the series is close to non-stationary. KPSS can reject a stationary series when the model is misspecified (such as an omitted trend).
Using both tests together creates a useful decision framework:
| ADF Result | KPSS Result | Interpretation |
|---|---|---|
| Reject unit root (p < 0.05) | Fail to reject stationarity (p > 0.05) | Consistent with stationarity |
| Fail to reject unit root (p > 0.05) | Reject stationarity (p < 0.05) | Consistent with non-stationarity |
| Reject unit root (p < 0.05) | Reject stationarity (p < 0.05) | Possible deterministic trend or heteroscedasticity |
| Fail to reject unit root (p > 0.05) | Fail to reject stationarity (p > 0.05) | Low power, sample size, or lag choice issue |
A robust dataset for many models should sit cleanly in the first row: ADF rejects its null, and KPSS fails to reject its null.
Step-by-Step: Running ADF and KPSS in Python
Loading and Preparing a Price Series
Let's create a reproducible synthetic random walk price series to test:
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
dates = pd.date_range('2020-01-01', periods=1000, freq='D')
base_returns = rng.normal(0.0002, 0.01, len(dates))
# Generate a trending random walk price series
price = pd.Series(100 * np.exp(np.cumsum(base_returns)), index=dates)
Apply the log transformation and compute first differences to get our returns:
log_price = np.log(price)
returns = log_price.diff().dropna()
ADF Implementation with statsmodels
Run the ADF test on the returns series:
from statsmodels.tsa.stattools import adfuller
# Run ADF test
adf_stat, adf_p, adf_lags, adf_obs, adf_crit, adf_ic = adfuller(
returns, autolag='AIC'
)
print(f'ADF statistic: {adf_stat:.3f}')
print(f'ADF p-value: {adf_p:.4f}')
autolag='AIC' lets the test choose the number of lagged differences automatically. If the p-value is below 0.05, we reject the unit root hypothesis.
KPSS Implementation with statsmodels
Run the KPSS test on the exact same returns series:
from statsmodels.tsa.stattools import kpss
# Run KPSS test
kpss_stat, kpss_p, kpss_lags, kpss_crit = kpss(
returns, regression='c', nlags='auto'
)
print(f'KPSS statistic: {kpss_stat:.3f}')
print(f'KPSS p-value: {kpss_p:.4f}')
The regression='c' option tests stationarity around a constant level. The nlags='auto' option chooses the lag truncation parameter automatically.
Interpreting the Results
If the output yields an ADF p-value < 0.05 and a KPSS p-value > 0.05, you have reasonable evidence that the transformed series is suitable for models that assume a stable mean and variance. If both tests reject, you may have a deterministic trend or time-varying volatility that simple log-differencing did not capture.
What If the Series Still Isn’t Stationary?
Log-differencing is often the first choice for financial prices because it yields proportional returns. However, it is not a universal solution.
- First-differencing without logs is useful when the series is already a rate, spread, or ratio where taking logs would distort the interpretation.
- Variance-stabilizing transforms (like a Box-Cox transform) can help with severe heteroscedasticity.
- Seasonal adjustments and detrending can remove deterministic calendar effects. However, be careful: if you remove a trend estimated on the full sample, you leak information from the future into your training data. See The 'Walk‑Forward' Test: The Only Backtest That Matters for guidance on avoiding this.
Stationarity Checks Inside the AlphaNova Workflow
Most quantitative research never leaves the lab. AlphaNova changes that by building a direct pipeline from academic-grade signal research to live market execution.
AlphaNova competitions supply obfuscated, tabular financial data across multiple assets. Participants submit a pure Python Predictor class, and submissions are evaluated out of sample. Before engineering features for that class, it is worth testing whether the target signals or transformed price data satisfy stationarity.
AlphaNova’s greedy quality selection process—built on proprietary methods in geometry and statistics—admits only genuinely uncorrelated, overfit-filtered signals. A signal built on non-stationary inputs often looks novel in-sample but is actually a weak proxy for a common drift or volatility effect. Clean, stationary inputs are a better foundation for producing the uncorrelated signals that our selection process rewards. (Read more in From Signals to Cities: Compression and the Geometry of Novelty).
From Clean Data to a Competitive Signal
A forecasting competition is not a demand for the best in-sample curve fit. It is a demand for a model that performs on data it has not seen. Stationarity testing is your first line of defense against the gap between in-sample performance and out-of-sample failure.
At AlphaNova, there are no entry fees, no token requirements, and no pay-to-play mechanics. AlphaNova is purely merit-based. Whether you are a PhD student, a data scientist, or an independent researcher, if your signal is good enough, it trades. Top-performing signals that survive our rigorous out-of-sample evaluation get integrated into a deep learning system that trades real capital, and outstanding contributors can earn profit sharing over time.
Start locally. Load a price series, run ADF and KPSS, and ensure your foundation is solid. AlphaNova provides a local runner so you can iterate on preprocessing safely.
When you are ready to put these ideas into practice, Join the latest AlphaNova competition.