Memory in prices: autocorrelation and stationarity
Worked examples
Read the claim, then run it and check the machine agrees. One at a time — nothing here is taken on trust.
import numpy as np
import qadata
def acf(v, lag):
v = np.asarray(v, dtype=float)
return float(np.corrcoef(v[:-lag], v[lag:])[0, 1])
prices = qadata.trending_prices().to_numpy()
rets = prices[1:] / prices[:-1] - 1.0
ou = qadata.ou_series().to_numpy()
print("lag : prices returns OU spread")
for k in (1, 2, 5, 10):
print(f"{k:3d} : {acf(prices, k):+.4f} {acf(rets, k):+.4f} {acf(ou, k):+.4f}")
phi = 0.5 ** (1 / 10)
print("planted OU curve :", " ".join(f"{phi**k:+.4f}" for k in (1, 2, 5, 10)))
Three fingerprints: prices cling to 0.99+ at every lag (a level with a permanent memory), returns sit within noise of zero from lag 1 (−0.012 — the market barely remembers yesterday), and the OU spread decays geometrically right along its planted φᵏ curve — 0.65 at lag 5, 0.39 at lag 10 against a theoretical 0.71 and 0.50. Fading memory is the only kind you can trade; m04 harvests exactly this decay.
import numpy as np
import qadata
def ar1_phi(v):
y, xl = v[1:], v[:-1]
X = np.column_stack([np.ones(len(xl)), xl])
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
return float(beta[1])
x = qadata.ou_series().to_numpy() # planted half-life: 10 days
phi_hat = ar1_phi(x)
print(f"phi_hat = {phi_hat:.5f} (planted {0.5 ** (1/10):.5f})")
print(f"half-life estimate: {-np.log(2) / np.log(phi_hat):.2f} days (planted 10)")
hl = np.array([-np.log(2) / np.log(ar1_phi(qadata.ou_series(seed=s).to_numpy()))
for s in range(200)])
lo, mid, hi = np.percentile(hl, [5, 50, 95])
print(f"200 seeds: 5th {lo:.2f} median {mid:.2f} 95th {hi:.2f}")
print(f"share of estimates below the true 10: {(hl < 10).mean():.0%}")
One lstsq call recovers φ̂ = 0.92196, half-life 8.53 — against a planted 10. The Monte Carlo is the honest part: across 200 identically-generated worlds the estimate ranges 7.9 to 12.0, and 58% come in low (the classic small-sample downward bias of AR coefficients plus noise). When m04-2 sizes trades off a fitted half-life, this spread is the difference between a parameter and a superstition.
import numpy as np
import qadata
DF_CRIT_5PCT = -2.86 # published DF critical value (5%, with constant)
def df_tstat(v):
v = np.asarray(v, dtype=float)
dy, xl = np.diff(v), v[:-1]
X = np.column_stack([np.ones(len(xl)), xl])
beta, *_ = np.linalg.lstsq(X, dy, rcond=None)
resid = dy - X @ beta
s2 = resid @ resid / (len(dy) - 2)
se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[1, 1])
return float(beta[1] / se)
prices = qadata.trending_prices().to_numpy()
series = {
"OU spread (planted stationary)": qadata.ou_series().to_numpy(),
"random walk (planted unit root)": np.cumsum(np.random.default_rng(21).standard_normal(1500)),
"prices": prices,
"returns": prices[1:] / prices[:-1] - 1.0,
}
for name, v in series.items():
t = df_tstat(v)
verdict = "stationary" if t < DF_CRIT_5PCT else "cannot reject unit root"
print(f"{name:32s}: t = {t:+7.2f} -> {verdict}")
Twelve lines replace the statsmodels ritual: regress Δx on lagged x, divide the slope by its standard error. The verdicts line up with what we planted — OU at −7.79 (deeply stationary), random walk at −1.01 (no evidence against the unit root), and the punchline pair: prices −1.26, returns −27.78. One subtlety worth respecting: −2.86 is not the Gaussian −1.96, because under the null the t-statistic follows Dickey and Fuller's distribution, not Student's.
Check the concept
One question at a time. Unsure? Revisit the lecture, then answer.
The challenge
Pass the quiz to unlock the challenge — your code will still be waiting here.