Time-Series Models for Financial Data › Implementation: Analyzing S&P 500 Returns
Quantitative FinanceAll chapters Time-Series Models for Financial Data › Implementation: Analyzing S&P 500 ReturnsOne section · Sections in this chapter9 / 13 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's apply these concepts to real financial data, analyzing daily S&P 500 returns.
import numpy as np
np.random.seed(42)Simulating Financial Data
We'll work with a simulated dataset that mimics S&P 500 characteristics, then show how to identify and fit ARIMA models.
import numpy as np
import pandas as pd
# Simulate a price series with realistic characteristics
n_days = 1000
mu_daily = 0.0003 # ~7.5% annual return
sigma_daily = 0.012 # ~19% annual volatility
# Generate returns with a modest AR(1) structure for an identifiable example
returns = np.zeros(n_days)
phi = 0.25
for t in range(1, n_days):
returns[t] = (
mu_daily
+ phi * (returns[t - 1] - mu_daily)
+ sigma_daily * np.random.randn()
)
# Generate price series
prices = 100 * np.exp(np.cumsum(returns))
# Create date index
dates = pd.date_range(start="2020-01-01", periods=n_days, freq="B")
price_series = pd.Series(prices, index=dates, name="Price")
return_series = pd.Series(returns, index=dates, name="Returns")Visualizing Prices vs. Returns


The price series shows a clear upward trend, violating the constant mean requirement for stationarity. Returns, however, fluctuate around a stable mean near zero, making them suitable for ARMA modeling.
Computing ACF and PACF
def compute_acf(series, max_lag=20):
"""Compute sample autocorrelation function."""
n = len(series)
mean = series.mean()
var = series.var()
acf = np.zeros(max_lag + 1)
for k in range(max_lag + 1):
cov = (
np.sum((series[k:] - mean) * (series[:-k] - mean)) / n
if k > 0
else var
)
acf[k] = cov / var
return acf
def compute_pacf(series, max_lag=20):
"""Compute sample partial autocorrelation using Durbin-Levinson."""
acf = compute_acf(series, max_lag)
pacf = np.zeros(max_lag + 1)
pacf[0] = 1.0
pacf[1] = acf[1]
phi = np.zeros((max_lag + 1, max_lag + 1))
phi[1, 1] = acf[1]
for k in range(2, max_lag + 1):
num = acf[k] - sum(phi[k - 1, j] * acf[k - j] for j in range(1, k))
den = 1 - sum(phi[k - 1, j] * acf[j] for j in range(1, k))
phi[k, k] = num / den
for j in range(1, k):
phi[k, j] = phi[k - 1, j] - phi[k, k] * phi[k - 1, k - j]
pacf[k] = phi[k, k]
return pacfmax_lag = 20
acf_returns = compute_acf(returns, max_lag)
pacf_returns = compute_pacf(returns, max_lag)
# Confidence bounds (approximate 95% under null of white noise)
conf_bound = 1.96 / np.sqrt(n_days)

The ACF shows the lag-1 autocorrelation is significant, with subsequent lags decaying. The PACF shows a significant spike at lag 1 followed by values within the confidence bands. This pattern, with PACF cutting off after lag 1 and ACF decaying, strongly suggests an AR(1) model.
Estimating an AR(1) Model
We'll estimate the AR(1) parameters using ordinary least squares, which is consistent for stationary AR models.
# Prepare data for AR(1) estimation
Y = returns[1:] # Y_t
Y_lag = returns[:-1] # Y_{t-1}
# OLS estimation: Y_t = c + phi * Y_{t-1} + epsilon_t
X = np.column_stack([np.ones(len(Y_lag)), Y_lag])
beta = np.linalg.lstsq(X, Y, rcond=None)[0]
c_hat, phi_hat = beta
# Compute residuals
Y_hat = X @ beta
residuals = Y - Y_hat
sigma_hat = np.std(residuals)
# Compute standard errors
XtX_inv = np.linalg.inv(X.T @ X)
var_beta = sigma_hat**2 * XtX_inv
se_c = np.sqrt(var_beta[0, 0])
se_phi = np.sqrt(var_beta[1, 1])AR(1) Model Estimation Results ======================================== Constant (c): 0.000454 (SE: 0.000372) AR(1) coef (φ): 0.2433 (SE: 0.0307) Residual std: 0.011748 Implied mean: 0.000600 Stationarity: Yes (|φ| = 0.2433)
The estimated AR(1) coefficient is close to our simulated value of 0.25. The model is stationary since .
Diagnostic Checking
from scipy import stats
# Ljung-Box test for residual autocorrelation
def ljung_box_test(residuals, max_lag=10):
"""Compute Ljung-Box Q statistic."""
n = len(residuals)
acf_resid = compute_acf(residuals, max_lag)
Q = (
n
* (n + 2)
* sum(acf_resid[k] ** 2 / (n - k) for k in range(1, max_lag + 1))
)
p_value = 1 - stats.chi2.cdf(Q, df=max_lag)
return Q, p_value
Q_stat, p_val = ljung_box_test(residuals)
acf_residuals = compute_acf(residuals, max_lag)Residual Diagnostics ======================================== Ljung-Box Q(10): 7.45 p-value: 0.6828 Residual mean: 0.000000 Residual std: 0.011748 Skewness: 0.1188 Kurtosis: 0.0656
The Ljung-Box test checks whether residual autocorrelations are collectively significant. A high p-value suggests the residuals behave like white noise, indicating an adequate model.


Forecasting
With the estimated model, we can generate forecasts with confidence intervals.
# Generate forecasts
forecast_horizon = 20
Y_T = returns[-1] # Last observed return
mu_unconditional = c_hat / (1 - phi_hat)
forecasts = np.zeros(forecast_horizon)
forecast_var = np.zeros(forecast_horizon)
for h in range(forecast_horizon):
# Point forecast
forecasts[h] = mu_unconditional + (phi_hat ** (h + 1)) * (
Y_T - mu_unconditional
)
# Forecast variance
forecast_var[h] = (
sigma_hat**2 * (1 - phi_hat ** (2 * (h + 1))) / (1 - phi_hat**2)
)
# 95% confidence intervals
lower_95 = forecasts - 1.96 * np.sqrt(forecast_var)
upper_95 = forecasts + 1.96 * np.sqrt(forecast_var)
# 80% confidence intervals
lower_80 = forecasts - 1.28 * np.sqrt(forecast_var)
upper_80 = forecasts + 1.28 * np.sqrt(forecast_var)
The forecasts converge quickly to the unconditional mean because the AR(1) coefficient is small. The confidence intervals widen initially but stabilize as they approach the unconditional variance.
Using statsmodels for ARIMA
In practice, we use established libraries for ARIMA estimation. The statsmodels package provides the ARIMA tools used below.
from statsmodels.tsa.stattools import adfuller
# Augmented Dickey-Fuller test for stationarity
adf_result = adfuller(returns)Augmented Dickey-Fuller Test for Returns ============================================= ADF Statistic: -24.6312 p-value: 0.000000 Critical values: 1%: -3.4369 5%: -2.8644 10%: -2.5683 Conclusion: Stationary (reject unit root)
The ADF test strongly rejects the null hypothesis of a unit root, confirming that returns are stationary and suitable for ARMA modeling without differencing.
# Fit ARIMA models with different specifications
from statsmodels.tsa.arima.model import ARIMA
models = {}
for p in range(3):
for q in range(3):
if p == 0 and q == 0:
continue # Skip trivial model
try:
model = ARIMA(returns, order=(p, 0, q))
fitted = model.fit()
models[(p, 0, q)] = {
"aic": fitted.aic,
"bic": fitted.bic,
"model": fitted,
}
except:
pass # Skip failed fits
# Sort models by BIC for comparison
sorted_models = sorted(models.items(), key=lambda x: x[1]["bic"])Model Comparison (ARIMA) ================================================== Model AIC BIC -------------------------------------------------- ARIMA(1,0,0) -6045.13 -6030.41 ARIMA(0,0,1) -6041.88 -6027.15 ARIMA(1,0,1) -6043.13 -6023.50 ARIMA(2,0,0) -6043.13 -6023.50 ARIMA(0,0,2) -6042.45 -6022.82 ARIMA(2,0,1) -6041.13 -6016.59 ARIMA(1,0,2) -6039.96 -6015.42 ARIMA(2,0,2) -6039.59 -6010.14
Information criteria help select the best model balancing fit and parsimony. The BIC, with its heavier penalty for complexity, often selects simpler models appropriate for financial data.
# Fit best model (AR(1) based on BIC)
if sorted_models:
best_order = sorted_models[0][0]
best_model = models[best_order]["model"]
else:
# Fallback to AR(1) if no models were successfully fit
from statsmodels.tsa.arima.model import ARIMA
model = ARIMA(returns, order=(1, 0, 0))
best_model = model.fit()
best_order = (1, 0, 0)
Best Model: ARIMA(1, 0, 0)
==================================================
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
const 0.0006 0.000 1.217 0.223 -0.000 0.002
ar.L1 0.2433 0.032 7.723 0.000 0.182 0.305
sigma2 0.0001 6.08e-06 22.679 0.000 0.000 0.000
==============================================================================The model summary confirms that the AR(1) coefficient is statistically significant and close to the true value of 0.25. The low p-values indicate strong evidence against zero coefficients.
ARIMA for Price Series
When working with prices directly, we need ARIMA with . This models returns (first differences) using ARMA.
# Fit ARIMA(1,1,0) to prices, which is equivalent to AR(1) on returns
price_model = ARIMA(prices, order=(1, 1, 0))
price_fitted = price_model.fit()
# Generate price forecasts
price_forecast = price_fitted.get_forecast(steps=20)
price_pred = price_forecast.predicted_mean
price_ci = price_forecast.conf_int(alpha=0.05)
The confidence interval for price forecasts widens dramatically compared to return forecasts. This reflects the accumulation of return forecast errors: uncertainty compounds over time when predicting integrated series.