6. Stationarity, Dickey-Fuller and cointegration
Level 8: Time Series - Stationarity
Data are stationary when the parameters of the data generating process do not change over time. Without formulas: the mean of the series at time t is an expected value that may depend on t, and the variance is the expected value of the squared deviation from that mean, and it may also depend on t. A series is stationary in mean if that mean is constant over time, and stationary in variance if the variance is constant over time.
Series are stationary if they have no trend and no seasonal effects. Statistics computed on them are consistent over time, such as the mean or the variance of the observations. When a series is stationary it is easier to model. Statistical modeling methods assume or require stationarity to be effective; many statistical tests, in the small print of their assumptions, require the data being analyzed to be stationary. A stationary series is straightforward to forecast because we can assume its future statistical properties will be the same as, or proportional to, the current ones.
How to check for stationarity. There are three routes: look at the plots and check visually for obvious trends or seasonality; review the descriptive statistics by subperiod or over random partitions and look for significant differences; and apply formal statistical tests.
A synthetic experiment makes it clear:
def generate_datapoint(params):
mu, sigma = params[0], params[1]
return np.random.normal(mu, sigma)
T = 100
A = pd.Series(index=range(T), dtype=float) # mean 0, sigma 1: stationary
for t in range(T):
A[t] = generate_datapoint((0, 1))
B = pd.Series(index=range(T), dtype=float) # the mean grows with time
for t in range(T):
B[t] = generate_datapoint((t * 0.1, 1))
C = pd.Series(index=range(T), dtype=float) # the variance grows with time
for t in range(T):
C[t] = generate_datapoint((0, t * 1))
D = pd.Series(index=range(T), dtype=float) # the mean oscillates cyclically
for t in range(T):
D[t] = generate_datapoint((np.sin(t), 1))Series A comes from a stationary process with fixed parameters; B and C are generated with parameters that change over time. If you draw the mean of B as a horizontal line on top of the series itself, you will see the problem: the computed mean shows the average of all the data to date, but it is useless for predicting any future state. Comparing it with any specific moment makes no sense, because it is an amalgam of different states at different times. It is a simple, clear example of why non-stationarity ruins the analysis; in practice, far subtler problems arise. Series D illustrates this: a cyclical movement of the mean is very hard to tell apart from random noise, and with noisy data and limited samples it can be difficult to determine whether a series is stationary and whether a drift is noise or part of a trend.
Augmented Dickey-Fuller (ADF) test. This is a statistical unit root test. The intuition is that it determines how strongly a series is defined by a trend. It uses an autoregressive model and optimizes an information criterion over different lag values. The null hypothesis is that the series has a unit root, that is, that it is NOT stationary (it has some time-dependent structure). The alternative hypothesis, rejecting the null, is that the series is stationary.
X = df.values # prices
result = adfuller(X)
print('ADF Statistic: %f' % result[0])
print('p-value: %f' % result[1])
print('Critical Values:')
for key, value in result[4].items():
print('\t%s: %.3f' % (key, value))
X = Re # returns
result = adfuller(X)On the returns we obtain a statistic of -14.536258, smaller than the 5% critical value of -2.862. This indicates that we can reject the null hypothesis at a significance level below 5% (a low probability that the result is a statistical fluke). Rejecting the null means that the process has no unit root and therefore that the series is stationary, or has no time-dependent structure. On the prices, by contrast, the statistic stays well above the critical values and the p-value is high: the null cannot be rejected.
Cointegration. A linear combination of several series is a new series built by summing each series multiplied by a real coefficient. If every series in a set is I(1), that is, they each need one differencing to become stationary, and there exists some linear combination of them that is I(0), that is, stationary without differencing, we say the set is cointegrated.
The intuition: for some linear combination of the series, the result has no appreciable autocovariance and is essentially noise. This is useful in cases such as pairs trading, where we look for two assets whose prices are cointegrated. Since the linear combination of their prices is noise, we can bet that the relationship reverts to its mean and place trades accordingly.
N = 100
X1 = np.cumsum(np.random.normal(0, 1, N)) # integrated stationary noise: I(1)
X1 = pd.Series(X1); X1.name = 'X1'
X2 = X1 + np.random.normal(0, 1, N) # X1 plus stationary noise
X2.name = 'X2'
Z = X2 - X1 # the linear combination: stationary by design
Z.name = 'Z'
plt.plot(Z); plt.show()Since X2 is an I(1) series plus stationary noise, it is still I(1). To show cointegration we need to find a linear combination of X1 and X2 that is stationary: subtracting gives us exactly the noise with which X2 was built.