Quant Research Guide
Time Series Analysis
Financial data is time series. Almost every quant research task — return prediction, risk modeling, pairs trading, volatility forecasting — requires understanding stationarity, autocorrelation structure, and the right model class for each problem. Non-stationarity is the most common modeling failure in practice.
Core Theory
- 1Stationarity (weak/covariance): a process is weakly stationary if E[Xₜ] = μ (constant), Var(Xₜ) = σ² (constant), and Cov(Xₜ, Xₜ₋ₖ) = γ(k) depends only on lag k, not t. Stock prices are non-stationary; log returns are approximately stationary. Always test before modeling.
- 2ADF and KPSS tests: ADF null is unit root (non-stationary) — reject to support stationarity. KPSS null is stationarity — reject to support non-stationarity. Run both: ADF rejects + KPSS fails to reject = strong evidence for stationarity. Conflicting results suggest near-integration.
- 3ACF and PACF identification: ACF plots Corr(Xₜ, Xₜ₋ₖ). PACF removes effects of intermediate lags. AR(p): PACF cuts off at lag p, ACF decays. MA(q): ACF cuts off at lag q, PACF decays. ARMA: both decay. Memorize this table — it appears in every time-series interview.
- 4ARIMA(p,d,q): d = differences needed for stationarity; p = AR order from PACF cutoff; q = MA order from ACF cutoff. Use AIC/BIC for model selection among candidates. Check residuals are white noise with the Ljung-Box test — if not, model is misspecified.
- 5GARCH(1,1) for volatility clustering: financial returns have time-varying variance. GARCH(1,1): σ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁. Stationarity requires α + β < 1. Long-run variance = ω/(1-α-β). Half-life of shock = -ln(2)/ln(α+β). For equities, α+β ≈ 0.97 → shocks persist ~23 days.
- 6Cointegration: two I(1) series Xₜ and Yₜ are cointegrated if Yₜ - βXₜ is I(0). Engle-Granger test: (1) regress Y on X by OLS, (2) ADF test the residuals. Pairs trading exploits cointegration: the spread mean-reverts, generating entry and exit signals.
- 7Spurious regression: regressing two independent random walks gives high R² and significant t-statistics by chance. Always test for cointegration before interpreting regressions between non-stationary series. This is Granger & Newbold (1974) — a famous result every quant must know.
- 8Newey-West HAC standard errors: when OLS residuals are serially correlated, conventional SEs are too small. Newey-West SEs are consistent under both heteroskedasticity and autocorrelation. Always use them for time-series predictive regressions.
Key Formulas
| 1 | import numpy as np |
| 2 | import pandas as pd |
| 3 | from scipy import stats |
| 4 | from statsmodels.tsa.stattools import adfuller, coint |
| 5 | from statsmodels.stats.diagnostic import acorr_ljungbox |
| 6 | |
| 7 | np.random.seed(42) |
| 8 | T = 756 # 3 years of daily data |
| 9 | |
| 10 | # --- Simulate returns with GARCH(1,1) volatility clustering --- |
| 11 | omega, alpha_g, beta_g = 0.00001, 0.08, 0.90 # persistence = 0.98 |
| 12 | returns = np.zeros(T) |
| 13 | sigma2 = np.zeros(T) |
| 14 | sigma2[0] = omega / (1 - alpha_g - beta_g) # unconditional variance |
| 15 | |
| 16 | for t in range(1, T): |
| 17 | sigma2[t] = omega + alpha_g * returns[t-1]**2 + beta_g * sigma2[t-1] |
| 18 | returns[t] = np.sqrt(sigma2[t]) * np.random.randn() |
| 19 | |
| 20 | ret = pd.Series(returns) |
| 21 | |
| 22 | # --- ADF test: are returns stationary? --- |
| 23 | adf_stat, adf_p, *_ = adfuller(ret, autolag='AIC') |
| 24 | print(f"ADF on returns: stat={adf_stat:.3f}, p={adf_p:.4f} -> {'Stationary ✓' if adf_p < 0.05 else 'Non-stationary'}") |
| 25 | |
| 26 | # --- Ljung-Box on r² detects ARCH effects --- |
| 27 | lb = acorr_ljungbox(ret**2, lags=[10], return_df=True) |
| 28 | print(f"Ljung-Box r^2: p={lb['lb_pvalue'].values[0]:.4f} -> {'ARCH effects ✓' if lb['lb_pvalue'].values[0] < 0.05 else 'No ARCH'}") |
| 29 | |
| 30 | # --- Rolling realized volatility (GARCH proxy) --- |
| 31 | roll_vol = ret.rolling(21).std() * np.sqrt(252) |
| 32 | print(f"Ann. rolling vol: mean={roll_vol.mean():.3f}, max={roll_vol.max():.3f}") |
| 33 | |
| 34 | # --- GARCH half-life --- |
| 35 | half_life = -np.log(2) / np.log(alpha_g + beta_g) |
| 36 | print(f"GARCH half-life: {half_life:.1f} trading days") |
| 37 | |
| 38 | # --- Cointegration + Pairs Trading --- |
| 39 | X = np.cumsum(np.random.randn(T) * 0.01) # I(1) random walk |
| 40 | Y = 2.0 * X + np.random.randn(T) * 0.02 # cointegrated: Y = 2X + noise |
| 41 | |
| 42 | t_stat, p_val, _ = coint(Y, X) |
| 43 | print(f"\nCointegration p: {p_val:.4f} -> {'Cointegrated ✓' if p_val < 0.05 else 'Not cointegrated'}") |
| 44 | |
| 45 | # Dynamic OLS hedge ratio + z-score signal |
| 46 | beta_hat = np.polyfit(X, Y, 1)[0] |
| 47 | spread = Y - beta_hat * X |
| 48 | z_score = (spread - spread.mean()) / spread.std() |
| 49 | |
| 50 | long_entry = (z_score < -2).sum() |
| 51 | short_entry = (z_score > 2).sum() |
| 52 | print(f"Long entries: {long_entry}, Short entries: {short_entry}") |
| 53 | print(f"Mean reversion speed: spread AR(1) = {pd.Series(spread).autocorr(lag=1):.3f}") |
Full time-series workflow: ADF stationarity testing, Ljung-Box detection of ARCH effects in squared returns, rolling realized volatility as a GARCH proxy, computing the GARCH shock half-life, Engle-Granger cointegration test, and building the pairs trading z-score signal with entry/exit logic.
Worked Interview Problems
3 problemsProblem
A researcher regresses SPY prices on VIX levels (daily closes, 5 years) and finds R² = 0.72, t-stat = 31.4. Is this meaningful?
Solution
Test both series for stationarity. SPY prices: random walk, ADF p ≈ 0.85 (non-stationary). VIX: highly persistent, ADF p ≈ 0.12 (borderline non-stationary).
Both series are I(1) or near-I(1). Regressing two non-stationary series is spurious regression — Granger & Newbold (1974) showed R² and t-stats are inflated by shared trend, not a real relationship.
Fix option 1: test for cointegration with Engle-Granger. If cointegrated, use an Error Correction Model (ECM).
Fix option 2: work with stationary variables — regress SPY daily log-returns on ΔVIX (daily change). Now R² ≈ 0.18, t ≈ -12.4 — still significant but honest.
Lesson: always ADF-test before running any time-series regression. High R² between levels means nothing without a cointegration test first.
Answer
No. Both series are I(1) so the regression is spurious. The R²=0.72 and t=31.4 are artifacts of shared trending. Use first differences or test for cointegration.
Common Mistakes
Regressing two I(1) series without testing cointegration first — produces spurious regression with misleadingly high R² and t-stats. Always run ADF on both variables before any time-series regression.
Confusing ADF and KPSS null hypotheses. ADF null = unit root (non-stationary). KPSS null = stationary. Getting this backward signals a fundamental gap to interviewers.
Selecting ARIMA order only from ACF/PACF without checking AIC. The plots give a starting point but model comparison with information criteria is mandatory. BIC penalizes complexity more and selects sparser models.
Fitting ARIMA to raw price levels. Prices are I(1). Set d=1 or (better) take log returns first, then fit ARMA(p,q) on the stationary series.
Forgetting to account for volatility clustering when computing signal significance. Time-varying variance makes OLS standard errors inconsistent. Always use White or Newey-West standard errors for predictive regressions on returns.
Using a static OLS hedge ratio for pairs trading. The hedge ratio β drifts over time. Use rolling OLS (60-90 day window) or a Kalman filter to track the dynamic relationship.