Cryptocurrency Volatility: A Complete Time Series Pipeline
What You Will Accomplish
- Load real-world financial data and prepare it for time series analysis
- Answer three research questions using ARIMA, ARCH, and GARCH
- Produce publication-ready model outputs, forecasts, and diagnostics
- Use this module as a template for your own stock/crypto/forex analysis
Setup & Required Libraries
pip install pandas numpy statsmodels pmdarima arch matplotlib scipy
Dataset: 📥 Download btcusd_daily.csv — 301 daily BTCUSD candles (Aug 2025 – Jun 2026). Right-click and "Save link as" to download. To use your own data, see Section 6.
In This Module
1. Research Setup — Three Questions, One Dataset
This hands-on module is structured as a research investigation. Instead of learning techniques in isolation, you will answer three specific research questions about Bitcoin's price behaviour. Each question is answered by a specific statistical model:
| Research Question | Model | What We Test |
|---|---|---|
| RQ1: Can past Bitcoin returns predict future returns? | ARIMA | Market efficiency — do returns contain predictable patterns? |
| RQ2: Does yesterday's volatility predict today's volatility? | ARCH | Volatility clustering — do large moves cluster together? |
| RQ3: How persistent is Bitcoin volatility after a crash? | GARCH | Risk management — how many days until elevated risk fades? |
The Dataset
We analyse BTCUSD daily closing prices — 301 trading days from August 2025 to June 2026. Bitcoin dropped from ~$111,000 to ~$64,000 during this period, providing ample volatility for our models to capture.
2. Data Loading and Exploratory Analysis
Step 1: Load and Prepare the Data
# =====================================================================
# HANDS-ON: ARIMA + ARCH + GARCH Research Pipeline
# Dataset: BTCUSD Daily (301 trading days, Aug 2025 – Jun 2026)
# =====================================================================
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
plt.style.use('seaborn-v0_8-darkgrid')
# ── Load CSV ──────────────────────────────────────────────────────
# TO USE YOUR OWN DATA: Change this file path
df = pd.read_csv('../../assets/datasets/btcusd_daily.csv', parse_dates=['date'])
df = df.set_index('date').sort_index()
# Display basic information
print(f"Observations: {len(df)}")
print(f"Period: {df.index[0].strftime('%Y-%m-%d')} to {df.index[-1].strftime('%Y-%m-%d')}")
print(f"Columns: {list(df.columns)}")
print(f"\nFirst 3 rows:")
print(df.head(3))
Observations: 301
Period: 2025-08-27 to 2026-06-23
Columns: ['open', 'high', 'low', 'close', 'volume']
First 3 rows:
open high low close volume
date
2025-08-27 111720.5 112581.0 110327.0 111206.5 4971592.0
2025-08-28 111200.0 113440.5 110822.0 112519.0 4301404.0
2025-08-29 112519.0 112597.5 107426.0 108329.0 5922968.0
Step 2: Compute Daily Returns
We work with log returns (continuously compounded) rather than raw prices. Returns are stationary — prices are not. The formula: r_t = ln(P_t / P_{t-1}) × 100
# ── Compute returns ───────────────────────────────────────────────
# TO USE YOUR OWN DATA: Change 'close' to your price column name
df['return'] = np.log(df['close'] / df['close'].shift(1)) * 100
ret = df['return'].dropna() # Remove first row (NaN)
print(f"Return observations: {len(ret)}")
print(f"Mean daily return: {ret.mean():.4f}%")
print(f"Std daily return: {ret.std():.4f}%")
print(f"Min return: {ret.min():.4f}%")
print(f"Max return: {ret.max():.4f}%")
print(f"Skewness: {ret.skew():.4f}")
print(f"Excess Kurtosis: {ret.kurtosis():.4f}")
Return observations: 300 Mean daily return: -0.1570% Std daily return: 2.3543% Min return: -12.4257% Max return: 9.0721% Skewness: -0.2776 Excess Kurtosis: 6.2263
Step 3: Visual Inspection — Price, Returns, and Volatility Clustering
# ── Plot price and returns ────────────────────────────────────────
fig, axes = plt.subplots(3, 1, figsize=(14, 10))
# Panel 1: BTC Price
axes[0].plot(df.index, df['close'], linewidth=1.2, color='#f7931a')
axes[0].set_title('BTCUSD Closing Price', fontweight='bold', fontsize=13)
axes[0].set_ylabel('Price (USD)')
# Panel 2: Daily Returns
axes[1].plot(ret.index, ret.values, linewidth=0.5, color='steelblue')
axes[1].axhline(y=0, color='gray', linestyle='--', linewidth=0.5)
axes[1].set_title('Daily Log Returns (%)', fontweight='bold', fontsize=13)
axes[1].set_ylabel('Return (%)')
# Panel 3: Squared Returns (proxy for volatility)
axes[2].fill_between(ret.index, ret.values**2, alpha=0.6, color='red')
axes[2].set_title('Squared Returns — Volatility Proxy', fontweight='bold', fontsize=13)
axes[2].set_ylabel('Squared Return')
axes[2].set_xlabel('Date')
plt.tight_layout()
plt.savefig('../../assets/images/hands-on-btc-exploration.png', dpi=150, bbox_inches='tight')
plt.show()
What We Observe
- Price panel: Bitcoin declined from ~$111K to ~$64K — a ~42% drawdown over 10 months
- Returns panel: No obvious trend or seasonality — returns oscillate around zero, suggesting stationarity
- Squared returns panel: Clear clusters of high volatility (large squared returns bunched together). This is the volatility clustering that ARCH/GARCH is designed to model
- Negative skewness (-0.28): More large negative days than large positive days — typical for equities and crypto
- High kurtosis (6.23): Extreme events occur far more often than a normal distribution predicts. The worst day was -12.4% — a 5+ sigma event under normality
These observations motivate our three research questions. The returns look unpredictable (RQ1 → ARIMA), but their magnitude clearly clusters (RQ2 → ARCH), and the clusters appear persistent (RQ3 → GARCH). Let us now test each question formally.
3. RQ1: Can Past Bitcoin Returns Predict Future Returns?
The Research Question
Hypothesis: Bitcoin daily returns contain predictable patterns (momentum, mean-reversion, or cycles).
Null Hypothesis: Returns follow a random walk — they are unpredictable (efficient market).
Test: Fit an ARIMA model. If auto_arima selects ARIMA(0,0,0), the best prediction is just the historical mean — returns are white noise, and we cannot reject market efficiency.
Step 1: Stationarity Test (ADF)
ARIMA requires the series to be stationary. Returns should already be stationary (unlike prices). We confirm this with the ADF test:
from statsmodels.tsa.stattools import adfuller
# ── ADF Test on Returns ───────────────────────────────────────────
adf_stat, adf_p, _, _, cv, _ = adfuller(ret)
print(f"ADF Statistic: {adf_stat:.4f}")
print(f"p-value: {adf_p:.6f}")
print(f"5% Critical Value: {cv['5%']:.4f}")
print(f"Conclusion: {'STATIONARY ✓' if adf_p < 0.05 else 'NON-STATIONARY — difference needed'}")
ADF Statistic: -17.8514 p-value: 0.000000 5% Critical Value: -2.8713 Conclusion: STATIONARY ✓
The ADF test decisively rejects the unit root null. Here is how we reached that conclusion:
| Step | What We Check | Our Value | Threshold | Decision |
|---|---|---|---|---|
| 1 | Is the ADF statistic more negative than the critical value? | -17.85 | < -2.87 | Yes ✓ — the statistic is far below the critical value |
| 2 | Is the p-value below 0.05? | 0.000000 | < 0.05 | Yes ✓ — we reject H₀ (unit root) |
The logic: H₀ = "returns are non-stationary (have a unit root)." The ADF statistic (-17.85) is far below the 5% critical value (-2.87). If returns truly had a unit root, we would expect the ADF statistic to be near zero or positive. A value of -17.85 is extremely unlikely under H₀ — the p-value is essentially zero. Therefore, we reject H₀ and conclude returns are stationary. No differencing needed — d = 0.
Step 2: Identify p and q Using ACF/PACF
Before running auto_arima, it is important to understand what p and q actually mean and how the ACF and PACF plots help identify them manually. Even though we will use automatic selection, understanding the logic prevents blind trust in the algorithm.
What Are p, d, and q?
| Parameter | Stands For | What It Controls | How to Identify It |
|---|---|---|---|
| p | AR order (Autoregressive) | How many past values of the series itself predict today. AR(1) means Y_t depends on Y_{t-1}. | PACF plot — the PACF cuts off after lag p (becomes zero for lags > p) |
| d | Differencing order (Integrated) | How many times you need to difference the data to make it stationary. d=0 means already stationary. | ADF test — if p < 0.05, the series is stationary, so d=0 (we already confirmed this) |
| q | MA order (Moving Average) | How many past shocks (errors) affect today. MA(1) means today's value depends on yesterday's shock ε_{t-1}. | ACF plot — the ACF cuts off after lag q (becomes zero for lags > q) |
How to Read the ACF and PACF Plots
Each vertical bar represents the correlation at that lag. The blue shaded band is the 95% confidence interval — bars that extend outside the blue band are statistically significant (p < 0.05). Bars inside the blue band are not different from zero.
The identification rules:
- For AR(p): The PACF shows significant spikes at lags 1 through p, then drops to zero (inside the blue band) for all lags > p. Count the last significant PACF spike — that is your p.
- For MA(q): The ACF shows significant spikes at lags 1 through q, then drops to zero (inside the blue band) for all lags > q. Count the last significant ACF spike — that is your q.
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
# ── ACF and PACF of returns ───────────────────────────────────────
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
plot_acf(ret, lags=20, ax=axes[0])
axes[0].set_title('ACF of BTC Returns — Identifies q (MA order)', fontweight='bold')
plot_pacf(ret, lags=20, ax=axes[1], method='ywm')
axes[1].set_title('PACF of BTC Returns — Identifies p (AR order)', fontweight='bold')
plt.tight_layout()
plt.savefig('../../assets/images/hands-on-acf-pacf.png', dpi=150, bbox_inches='tight')
plt.show()
What We See in BTC Returns
| Plot | What We Look For | What We Actually See | Conclusion |
|---|---|---|---|
| ACF (left) | Significant spikes → suggests MA terms (q > 0) | Nearly all bars are inside the blue band. At most 1-2 bars barely touch the edge. | q = 0 — no significant MA structure |
| PACF (right) | Significant spikes → suggests AR terms (p > 0) | Nearly all bars are inside the blue band. No clear cutoff pattern. | p = 0 — no significant AR structure |
Both plots suggest the same thing: Bitcoin daily returns are white noise. There is no significant autocorrelation at any lag. If we were identifying p and q manually, we would choose p = 0, q = 0 — meaning ARIMA(0,0,0), which is just Y_t = constant + ε_t (the mean plus random noise). But let us confirm this with the formal automatic selection procedure.
Step 3: Automatic ARIMA Selection — Let the Algorithm Search
How auto_arima works: It fits dozens of candidate ARIMA(p,d,q) models — trying different combinations of p (0 to max_p), d (fixed at 0 for stationary data), and q (0 to max_q) — and computes the AIC for each one. It then selects the model with the lowest AIC. This automates what we just did manually with the ACF/PACF plots, but more rigorously: instead of eyeballing the plots, it fits every candidate and compares their AIC values.
from pmdarima import auto_arima
from statsmodels.tsa.arima.model import ARIMA
# ── Train-test split (last 30 days for forecast evaluation) ───────
train = ret.iloc[:-30] # First 270 days
test = ret.iloc[-30:] # Last 30 days (holdout)
# ── Auto-select best ARIMA ────────────────────────────────────────
# Searches p=0..5, q=0..5, d=0 (already stationary)
# For each candidate, fits the model and computes AIC
# Returns the order with the LOWEST AIC
auto_model = auto_arima(train, seasonal=False, trace=False,
stepwise=True, max_p=5, max_q=5, d=0,
information_criterion='aic')
print(f"Best ARIMA order: {auto_model.order}")
print(f"AIC: {auto_model.aic():.2f}")
Best ARIMA order: (0, 0, 0) AIC: 1233.57
auto_arima confirms what the ACF/PACF plots suggested: ARIMA(0,0,0) is the best model. No AR terms (p=0), no differencing (d=0), no MA terms (q=0). The model is simply:
Y_t = c + ε_t
| Symbol | Pronounced | Name | What It Represents in This Model |
|---|---|---|---|
| Y_t | "Y sub t" | The dependent variable at time t | Bitcoin's daily return (%) on day t — what we are trying to predict |
| c | "c" (the constant) | The intercept / constant term | The historical mean return (-0.11% per day). Since the model has no AR or MA terms, every day's prediction is simply this number. It is the "best guess" when you have no other information. |
| ε_t | "epsilon sub t" | The error term / residual / white noise | The random, unpredictable component on day t. White noise means each ε_t is independent of all others — yesterday's error tells you nothing about today's error. It has mean zero and constant variance (σ² = 5.59). This term represents everything about Bitcoin returns that the model cannot predict. |
In plain English: Tomorrow's Bitcoin return = the historical average return + random noise. The model says you cannot do better than guessing the average, and even the average is not reliably different from zero.
This means Bitcoin daily returns have no predictable structure whatsoever. The best forecast of tomorrow's return is simply the average of all past returns — and even that average is not statistically different from zero.
# ── Fit the best model ──────────────────────────────────────────── arima_model = ARIMA(train, order=auto_model.order).fit() print(f"\n─── ARIMA{auto_model.order} Model Summary ───") print(arima_model.summary())
Best ARIMA order: (0, 0, 0)
AIC: 1233.57
─── ARIMA(0,0,0) Model Summary ───
SARIMAX Results
==============================================================================
Dep. Variable: return No. Observations: 270
Model: ARIMA Log Likelihood -615.508
==============================================================================
coef std err z P>|z| [0.025 0.975]
------------------------------------------------------------------------------
const -0.1077 0.145 -0.745 0.456 -0.391 0.176
sigma2 5.5925 0.234 23.923 0.000 5.134 6.051
===================================================================================
Ljung-Box (L1) (Q): 1.49 Jarque-Bera (JB): 486.83
Prob(Q): 0.22 Prob(JB): 0.00
===================================================================================
Recall the hypotheses we set up:
- Research Hypothesis (H₁): Bitcoin daily returns contain predictable patterns — past returns help predict future returns.
- Null Hypothesis (H₀): Bitcoin daily returns are unpredictable — they follow a random walk (efficient market).
The Evidence — Five Pieces of Statistical Proof:
| # | Test / Evidence | Result | What It Means | Supports |
|---|---|---|---|---|
| 1 | ADF Test | stat = -17.85, p ≈ 0.000 | Returns are stationary — no differencing needed. This is expected for any financial return series. | Pre-condition met ✓ |
| 2 | ACF / PACF Plots | All bars inside blue band | No significant autocorrelation at any lag. Visually, the series looks like white noise — each day's return is unrelated to any previous day's return. | H₀ (no predictability) |
| 3 | auto_arima | Selected ARIMA(0,0,0) | The algorithm searched p=0..5 and q=0..5 and found that adding ANY AR or MA term makes the model worse (higher AIC). The best model uses zero lags — the data has no memory. | H₀ (no predictability) |
| 4 | Constant term (c) | c = -0.11%, p = 0.456 | The average daily return is -0.11%, but the p-value (0.456) is far above 0.05. This means the average is not statistically different from zero. Even the simplest prediction — "tomorrow will be average" — is unreliable because the average itself is just noise. | H₀ (no predictability) |
| 5 | Ljung-Box Test | Q(1) = 1.49, p = 0.22 | Tests whether the model residuals still contain autocorrelation. p > 0.05 means the residuals are white noise — the model has successfully extracted all structure from the data. But since our model is just the mean, "all structure" = nothing. | H₀ (no predictability) |
The Verdict — Step by Step:
- auto_arima selected p = 0, q = 0: Adding any AR or MA lag to the model increases AIC — the data penalises us for trying to find patterns that do not exist.
- The constant (c = -0.11%) is not significant (p = 0.456): We fail to reject the null hypothesis that the true mean return is zero. The -0.11% we observe could easily be sampling variation around zero.
- Residuals are white noise (Ljung-Box p = 0.22): After removing the mean, nothing remains — no cycles, no momentum, no mean-reversion. Just noise.
▶ Final Answer: NO — Bitcoin daily returns cannot be predicted from their own past.
We fail to reject H₀. All five pieces of evidence point in the same direction: Bitcoin daily returns are a random walk. The best statistical model of tomorrow's return is zero (or the historical mean of -0.11%, which is itself indistinguishable from zero). Any apparent patterns in the price chart are illusions — the data contains no exploitable predictability.
This finding is consistent with the Efficient Market Hypothesis (EMH): in an efficient market, past price information is already reflected in the current price, and technical analysis based on historical patterns cannot consistently generate excess returns.
Step 4: Forecast from ARIMA(0,0,0)
An ARIMA(0,0,0) forecast is straightforward: every future prediction equals the historical mean, with prediction intervals based on the residual standard deviation. Let us produce a 30-day forecast and evaluate it against the held-out test data:
# ── 30-day forecast ───────────────────────────────────────────────
forecast_result = arima_model.get_forecast(steps=30)
forecast_mean = forecast_result.predicted_mean
forecast_ci = forecast_result.conf_int(alpha=0.05)
# Calculate forecast accuracy on test set
from sklearn.metrics import mean_squared_error, mean_absolute_error
rmse = np.sqrt(mean_squared_error(test, forecast_mean))
mae = mean_absolute_error(test, forecast_mean)
print(f"─── 30-Day Forecast Evaluation ───")
print(f"RMSE: {rmse:.4f}%")
print(f"MAE: {mae:.4f}%")
print(f"(The forecast is flat at {forecast_mean[0]:.4f}% — the historical mean)")
print(f"Test set mean: {test.mean():.4f}%")
─── 30-Day Forecast Evaluation ─── RMSE: 2.2530% MAE: 1.6923% (The forecast is flat at -0.1077% — the historical mean) Test set mean: -0.0534%
The RMSE of 2.25% is essentially the standard deviation of Bitcoin returns — the model predicts "average" every day, and the errors are just the natural daily volatility. An RMSE close to the return standard deviation (2.35%) confirms that the model has no predictive power beyond the mean. The flat forecast is the honest answer: you cannot predict Bitcoin's direction.
4. RQ2: Does Yesterday's Volatility Predict Today's Volatility?
The Research Question
Hypothesis: Bitcoin exhibits volatility clustering — large price moves tend to be followed by more large moves.
Null Hypothesis: Volatility is constant over time (homoscedastic — variance never changes).
Test: ARCH-LM test on squared returns. If p < 0.05, volatility clustering exists. Then fit ARCH(1) to estimate how much of yesterday's shock carries into today's variance.
Step 1: Formal ARCH-LM Test
import statsmodels.api as sm
from scipy.stats import chi2
# ── ARCH-LM Test: Regress squared returns on 10 lags ──────────────
sq_ret = ret.values ** 2
y = sq_ret[10:] # Dependent: squared returns from t=11 onward
X = np.column_stack([sq_ret[10-i-1 : len(sq_ret)-i-1] for i in range(10)])
X = sm.add_constant(X)
arch_lm_model = sm.OLS(y, X).fit()
n = len(y)
lm_stat = n * arch_lm_model.rsquared
lm_pval = 1 - chi2.cdf(lm_stat, 10)
print(f"─── ARCH-LM Test (10 lags) ───")
print(f"LM Statistic: {lm_stat:.1f}")
print(f"p-value: {lm_pval:.6f}")
print(f"Conclusion: {'ARCH EFFECTS PRESENT — volatility is predictable' if lm_pval < 0.05 else 'No ARCH effects'}")
─── ARCH-LM Test (10 lags) ─── LM Statistic: 54.2 p-value: 0.000000 Conclusion: ARCH EFFECTS PRESENT — volatility is predictable
What the ARCH-LM Test Actually Tells Us
The ARCH-LM result (LM = 54.2, p < 0.000001) confirms three interconnected facts. Here they are, explained from the ground up:
1. "Squared returns are autocorrelated" — What does this mean?
Take Bitcoin's daily returns: +2.3%, -1.5%, +0.8%, -4.2%, +3.1%, -3.8%... Now square them: 5.29, 2.25, 0.64, 17.64, 9.61, 14.44... Notice how large squared values (17.64) tend to be near other large values (9.61, 14.44), and small squared values (0.64, 2.25) tend to be near other small values. The squared returns are correlated with their own past — a large squared return today makes a large squared return tomorrow more likely. This is autocorrelation of squared returns. It means the magnitude of moves clusters, even though the direction does not.
2. Why this contradicts OLS
Standard OLS regression assumes homoscedasticity — the error variance is constant across all observations. In Module 1, we tested this with Breusch-Pagan. If variance is constant, then knowing yesterday's squared return tells you nothing about today's variance. But the ARCH-LM test proves it DOES. The OLS assumption is violated. This means OLS standard errors, t-statistics, and p-values are all unreliable for this data. You need models (ARCH/GARCH) that explicitly allow variance to change over time.
3. "Bitcoin's risk is time-varying and predictable" — What does this mean practically?
On Monday, you can look at last week's price movements and estimate whether this week will be calm or turbulent. If last week had ±4% daily swings, this week is likely to also have large swings. If last week was quiet (±1%), this week is likely quiet too. You cannot predict direction (RQ1 proved that), but you CAN predict risk level. This is the difference between predicting a coin flip's outcome (impossible) and predicting whether the coin is being flipped in a hurricane or a calm room (possible, and useful).
Step 2: Fit ARCH(1) Model
ARCH(1) models the conditional variance as: σ²_t = ω + α · ε²_{t-1}. The parameter α tells us what fraction of yesterday's squared shock transmits to today's variance.
from arch import arch_model
# ── ARCH(1) ───────────────────────────────────────────────────────
arch_res = arch_model(ret, vol='ARCH', p=1).fit(disp=False)
print("─── ARCH(1) Model ───")
print(arch_res.summary())
# Extract key parameters
omega = arch_res.params['omega']
alpha = arch_res.params['alpha[1]']
print(f"\n─── ARCH(1) Key Parameters ───")
print(f"ω (omega) = {omega:.4f} — baseline daily variance")
print(f"α (alpha) = {alpha:.4f} — shock transmission rate")
print(f"Baseline daily volatility (σ) = {np.sqrt(omega):.4f}%")
print(f"If yesterday's shock was ±3%: today's σ² = {omega:.4f} + {alpha:.4f}×9 = {omega + alpha*9:.4f}")
print(f" → today's σ = {np.sqrt(omega + alpha*9):.4f}%")
─── ARCH(1) Model ───
Constant Mean - ARCH Model Results
==============================================================================
Dep. Variable: return R-squared: 0.000
Mean Model: Constant Mean Adj. R-squared: 0.000
Vol Model: ARCH Log-Likelihood: -670.385
Distribution: Normal AIC: 1346.77
==============================================================================
coef std err t P>|t| 95.0% Conf. Int.
------------------------------------------------------------------------------
mu -0.0177 0.190 -0.093 0.926 [ -0.389, 0.354]
omega 4.3219 0.607 7.123 1.057e-12 [ 3.133, 5.511]
alpha[1] 0.1954 0.204 0.958 0.338 [ -0.204, 0.595]
==============================================================================
─── ARCH(1) Key Parameters ───
ω (omega) = 4.3219 — baseline daily variance
α (alpha) = 0.1954 — shock transmission rate
Baseline daily volatility (σ) = 2.0789%
If yesterday's shock was ±3%: today's σ² = 4.3219 + 0.1954×9 = 6.0805
→ today's σ = 2.4659%
Recall the hypotheses:
- Research Hypothesis (H₁): Bitcoin exhibits volatility clustering — large moves are followed by more large moves (of either sign). Yesterday's volatility contains information about today's volatility.
- Null Hypothesis (H₀): Volatility is constant over time (homoscedastic). Yesterday's volatility tells you nothing about today's.
The Evidence:
| # | Test / Evidence | Result | What It Means | Supports |
|---|---|---|---|---|
| 1 | ARCH-LM Test | LM = 54.2, p ≈ 0.000000 | Squared returns are strongly autocorrelated. We reject H₀ — volatility is NOT constant. Large squared returns tend to be followed by large squared returns. Volatility clustering definitively exists. | H₁ (clustering exists) |
| 2 | ARCH(1) α coefficient | α = 0.195, p = 0.338 | α measures how much of yesterday's squared shock feeds into today's variance. α = 0.195 means ~20% transmission. But p = 0.338 > 0.05 — this estimate is not statistically significant. A single lag of squared shocks is not enough to reliably capture the volatility dynamics. | Inconclusive — model is too simple |
| 3 | ARCH(1) ω coefficient | ω = 4.32, p ≈ 0.000 | The baseline variance is strongly significant — there IS a meaningful average level of volatility. Daily baseline σ = √4.32 = 2.08%. | Pre-condition met ✓ |
| 4 | ARCH(1) Model Fit | AIC = 1346.77 | We keep this AIC as a benchmark. If GARCH (which adds β) achieves a meaningfully lower AIC, then persistence matters beyond individual shocks. | Benchmark |
The Verdict — Step by Step:
- ARCH-LM rejects H₀ (constant variance): The evidence is overwhelming (p ≈ 0). Bitcoin volatility is time-varying and predictable — large moves cluster together. This answers the core question: yes, yesterday's volatility does predict today's.
- But ARCH(1) alone is inadequate: The α coefficient (0.195) is not significant (p = 0.338). This does NOT contradict the ARCH-LM finding — it means the volatility dynamics are more complex than a single lag can capture. The clustering exists, but we need a better model to quantify it properly.
- This motivates GARCH: The significant ω (baseline variance) combined with the insignificant α (shock transmission alone) suggests that volatility persistence — yesterday's variance carrying over to today — is the missing piece. GARCH adds exactly this through the β·σ²_{t-1} term.
▶ Final Answer: YES — Bitcoin volatility clusters. Yesterday's volatility predicts today's.
We reject H₀ based on the ARCH-LM test. Volatility is not constant — calm days tend to follow calm days, and turbulent days tend to follow turbulent days. However, ARCH(1) alone cannot adequately model this clustering (α is not significant). This points us to GARCH, which adds the persistence term needed to properly capture the dynamics. The answer to "does volatility cluster?" is a clear yes. The answer to "how much?" requires GARCH.
5. RQ3: How Persistent Is Bitcoin Volatility After a Crash?
The Research Question
Hypothesis: Bitcoin volatility has long memory — after a crash, elevated risk persists for days or weeks, not hours.
Test: Fit GARCH(1,1): σ²_t = ω + α·ε²_{t-1} + β·σ²_{t-1}. Compute persistence α+β and half-life.
Step 1: Fit GARCH(1,1)
# ── GARCH(1,1) ────────────────────────────────────────────────────
garch_res = arch_model(ret, vol='GARCH', p=1, q=1).fit(disp=False)
print("─── GARCH(1,1) Model ───")
print(garch_res.summary())
# Extract key parameters
gparams = garch_res.params
omega_g = gparams['omega']
alpha_g = gparams['alpha[1]']
beta_g = gparams['beta[1]']
persistence = alpha_g + beta_g
half_life = np.log(0.5) / np.log(persistence)
print(f"\n─── GARCH(1,1) Key Parameters ───")
print(f"ω = {omega_g:.4f} (baseline daily variance)")
print(f"α = {alpha_g:.4f} (shock transmission)")
print(f"β = {beta_g:.4f} (variance persistence)")
print(f"α+β = {persistence:.4f} (total persistence)")
print(f"Half-life = {half_life:.1f} days ({half_life/7:.1f} weeks)")
─── GARCH(1,1) Model ───
Constant Mean - GARCH Model Results
==============================================================================
Dep. Variable: return R-squared: 0.000
Model: GARCH Log-Likelihood: -663.900
Vol Model: GARCH AIC: 1335.80
Distribution: Normal BIC: 1350.61
==============================================================================
coef std err t P>|t| 95.0% Conf. Int.
------------------------------------------------------------------------------
mu -0.0234 0.126 -0.186 0.852 [ -0.270, 0.223]
omega 0.6489 0.864 0.751 0.452 [ -1.044, 2.342]
alpha[1] 0.1612 0.105 1.541 0.123 [-4.381e-02, 0.366]
beta[1] 0.7264 0.229 3.176 1.492e-03 [ 0.278, 1.175]
==============================================================================
─── GARCH(1,1) Key Parameters ───
ω = 0.6489 (baseline daily variance)
α = 0.1612 (shock transmission)
β = 0.7264 (variance persistence)
α+β = 0.8876 (total persistence)
Half-life = 5.8 days (0.8 weeks)
Step 2: Model Comparison — ARCH(1) vs GARCH(1,1)
| Criterion | ARCH(1) | GARCH(1,1) | Winner |
|---|---|---|---|
| AIC | 1346.77 | 1335.80 | GARCH (lower by 11 points) |
| Log-Likelihood | -670.39 | -663.90 | GARCH (higher is better) |
| Significant β | N/A | Yes (p=0.001) | GARCH captures persistence |
| Parameters | 3 | 4 | GARCH adds 1 parameter |
Recall the hypothesis:
- Research Hypothesis (H₁): Bitcoin volatility has long memory — after a crash, elevated risk persists for days or weeks, not hours. The persistence parameter α+β should be close to 1.0, and the half-life of a shock should be measured in days, not hours.
- Null Hypothesis (H₀): Volatility shocks dissipate quickly — α+β is small, and the half-life is short (1-2 days at most).
The Evidence:
| # | Test / Evidence | Result | What It Means | Supports |
|---|---|---|---|---|
| 1 | GARCH β coefficient | β = 0.726, p = 0.001 | 73% of yesterday's conditional variance carries over to today. This is the persistence engine — it is large AND statistically significant. Without any new shock, variance decays by only 27% per day. | H₁ (persistence exists) |
| 2 | GARCH α coefficient | α = 0.161, p = 0.123 | Individual shock transmission is weak — only 16% of yesterday's squared shock feeds into today's variance, and it is borderline significant. Volatility dynamics are driven by β (persistence), not α (shocks). | β dominates α — persistence over shocks |
| 3 | Persistence (α+β) | α+β = 0.888 | Total persistence is 0.888. This is below 1.0 (stationary — shocks eventually decay) but close enough to 1.0 that the decay is slow. Compare: α+β=0.5 would mean half-life of 1 day; α+β=0.99 would mean half-life of 69 days. | H₁ (moderate persistence) |
| 4 | Half-life | ln(0.5)/ln(0.888) = 5.8 days | After a volatility shock, half the excess volatility dissipates in ~6 trading days. In plain terms: after a crash, BTC remains unusually volatile for about a week. | H₁ (multi-day persistence) |
| 5 | GARCH vs ARCH AIC | 1335.8 vs 1346.8 (Δ=11) | GARCH(1,1) decisively outperforms ARCH(1). The β term earns its place — the data strongly prefers a model with variance persistence. An AIC difference of 11 points is substantial evidence. | GARCH is the right model |
The Verdict — Step by Step:
- β dominates α: β (0.726, p=0.001) is large and significant; α (0.161, p=0.123) is small and borderline. This means volatility is driven primarily by its own history — once volatility becomes elevated, it stays elevated through inertia, not because new large shocks keep arriving.
- α+β = 0.888 is moderate persistence: It is far enough below 1.0 to be comfortably stationary (no unit root concerns), but high enough that shocks decay over a week, not overnight. This is typical for daily financial data.
- Half-life = 5.8 trading days: After a crash causes volatility to spike, it takes ~6 trading days (~1.2 calendar weeks) for half the excess to dissipate. After 12 days: 25% remains. After 18 days: 12.5% remains.
▶ Final Answer: Bitcoin volatility shocks persist for approximately 6 trading days (~1 week).
We reject H₀ (quick dissipation). Volatility does not return to normal the day after a crash. The GARCH(1,1) model shows that elevated risk decays slowly, with a half-life of ~6 trading days. For risk management, this means: after a 5% down day, position sizes should remain reduced for about a week. Stop-losses set at "normal" volatility levels will be hit more frequently during this period. Option implied volatility will remain elevated for several days. The market needs approximately one week to "calm down" after a large shock.
6. Research Summary and Using Your Own Data
What We Found — Three Research Questions Answered
| RQ | Question | Answer | Model | Key Statistic |
|---|---|---|---|---|
| RQ1 | Can past returns predict future returns? | No — returns are white noise | ARIMA(0,0,0) | Ljung-Box p=0.22, const p=0.456 |
| RQ2 | Does volatility cluster? | Yes — ARCH-LM confirms | ARCH-LM test | LM=54.2, p<0.000001 |
| RQ3 | How persistent is volatility? | ~6 days half-life | GARCH(1,1) | α+β=0.888, β=0.726 (p=0.001) |
Research Methods Paragraph (Copy-Paste Ready)
The following is a template methods section suitable for a research paper or thesis:
METHODS — Time Series Analysis of Cryptocurrency Returns
We analyse daily Bitcoin (BTCUSD) closing prices from August 2025 to
June 2026 (N = 301 trading days). Log returns are computed as
r_t = ln(P_t / P_{t-1}) × 100.
Return Predictability: An augmented Dickey-Fuller test confirms returns
are stationary (ADF = -17.85, p < 0.001). Automatic ARIMA selection via
AIC minimisation identifies ARIMA(0,0,0) as the best-fitting model,
indicating that Bitcoin daily returns follow a white noise process
(Ljung-Box Q(1) = 1.49, p = 0.22). The constant term is not
significantly different from zero (μ = -0.11, p = 0.456), consistent
with the efficient market hypothesis.
Volatility Clustering: An ARCH-LM test on squared returns strongly
rejects the null of constant variance (LM = 54.2, p < 0.001),
confirming significant volatility clustering.
Volatility Persistence: A GARCH(1,1) model reveals moderate volatility
persistence (α+β = 0.888). The GARCH β coefficient (0.726, p = 0.001)
dominates the ARCH α coefficient (0.161, p = 0.123), indicating that
volatility is driven primarily by its own history rather than by
individual shocks. The half-life of a volatility shock is approximately
5.8 trading days. GARCH(1,1) outperforms ARCH(1) on both AIC
(1335.80 vs 1346.77) and log-likelihood (-663.90 vs -670.39),
confirming that the persistence term substantially improves model fit.
All models were estimated in Python using statsmodels and the arch
library.
Using Your Own Data — Template Guide
This entire module is designed as a reusable template. To analyse your own stock, crypto, forex, or any financial time series, change only these lines:
| Line to Change | BTC Example | Your Data |
|---|---|---|
| CSV file path | '../../assets/datasets/btcusd_daily.csv' | Path to your CSV file |
| Price column | 'close' | Your price column name (e.g., 'Close', 'Adj Close', 'Price') |
| Date column | 'date' | Your date column name (e.g., 'Date', 'Timestamp') |
Your CSV must have:
- A date column (any format that pandas can parse)
- A price column (closing price — the code computes returns automatically)
- At least 100 observations for reliable ARIMA/GARCH estimation
- Rows sorted by date (oldest first — the code sorts automatically)
Test Your Understanding
30 questions — covers all three research questions, ARIMA, ARCH, GARCH, data preparation, and interpretation.
Why do we compute log returns instead of using raw prices for ARIMA modelling?
auto_arima selected ARIMA(0,0,0) for Bitcoin returns. What does this imply?
The ADF test on Bitcoin returns gave p ≈ 0.000. What does this mean?
Ljung-Box test on ARIMA residuals with p=0.22 means the residuals are:
ARIMA const = -0.11% with p=0.456 means:
What does the ARCH-LM test examine?
ARCH-LM gave LM=54.2, p<0.000001. The correct conclusion:
In σ²_t = ω + α·ε²_{t-1}, what does α represent?
ARCH α=0.195 with p=0.338. What does this tell us?
What does the β parameter in GARCH(1,1) capture that ARCH(1) misses?
GARCH β=0.726(p=0.001) and α=0.161(p=0.123). What does this imply?
GARCH half-life of ~6 days means:
If α+β were exactly 1.0, what problem occurs?
Why is R-squared = 0.000 in both ARCH and GARCH output?
GARCH AIC (1335.80) vs ARCH AIC (1346.77). What does this mean?
ARIMA(0,0,0) forecast is flat. Why?
Excess kurtosis of 6.23 indicates:
Which library provides arch_model()?
What does auto_arima() do?
ARIMA and ARCH/GARCH answer different questions. Which is correct?
To use this template with your own data, what must change?
Why split data into training (270) and test (30)?
Why is GARCH estimated by MLE, not OLS?
conditional_volatility from a fitted GARCH model returns:
Negative skewness (-0.28) of Bitcoin returns means:
During a market crash, GARCH conditional volatility:
A reported α+β=0.99 should concern you because:
Correct order of steps in our pipeline:
ARIMA RMSE=2.25% vs return std=2.35% means:
The single most important takeaway from this module: