Chapter 6 · Time Series Models for Trading and Risk
Section 6.2 · Chapter 6 · Learning Statistics with Python
Time Series Models for Trading and Risk
Prof. Xuhu Wan
ISOM, HKUST Business School · 2026 Edition
A forecast is only as good as the baseline it beats. You will score the laziest forecast of all and discover that “predict zero” beats it; then watch the AIC-winning ARIMA lose money out of sample. The pivot: what a modern ML pipeline inherits from time series — stationary features, lags without leakage, walk-forward validation — and what a gradient-boosting model honestly delivers on one series.
With actual \(y_t\) and forecast \(\hat y_t\) over \(n\) periods:
\[\text{MAE} = \tfrac1n\sum|y_t - \hat y_t|, \quad \text{RMSE} = \sqrt{\tfrac1n\sum (y_t-\hat y_t)^2}, \quad \text{MAPE} = \tfrac{100}{n}\sum\left|\tfrac{y_t-\hat y_t}{y_t}\right|,\]
\[\text{DA} = \tfrac1n\sum \mathbf 1\{\text{sign}(y_t) = \text{sign}(\hat y_t)\}.\]
Which metric is unusable for forecasting minute-by-minute price changes?
Target Y is the next minute’s price change; the forecast Yhat is the current change.
388 forecasts: MAE 0.1157 dollars, RMSE 0.1694, directional accuracy 0.466 — worse than a coin. Minute changes are slightly negatively autocorrelated (bid–ask bounce), so copying the last change points you the wrong way.
Forecast zero for every minute. The RMSE of the zero forecast is just the std of Y around zero. Is it above or below the naive RMSE of 0.1694?
0.1057
0.1057 vs 0.1694. Copying the last change adds the variance of two changes; predicting the mean (≈ 0) adds none. Any forecast you build must beat 0.1057, not the naive number. That zero forecast is the first baseline in every ML pipeline too.
\(\hat y_{t+1} = \tfrac{1}{40}\sum_{i=0}^{39} \Delta P_{t-i}\) — .rolling(40).mean(). Predict: does averaging out the bounce beat the zero forecast (0.1057)?
On the common 349 rows: SMA RMSE 0.0993, zero 0.0986, naive 0.1428. The SMA crushes naive and ties the zero forecast; its only edge is a direction call (DA 0.521) that a zero forecast cannot make. And compare only on the same rows — the naive RMSE moved from 0.1694 to 0.1428 just by dropping 40 warm-up minutes.
\(s_t = \alpha\,x_t + (1-\alpha)\,s_{t-1}\), \(s_1 = x_1\). As \(\alpha \to 1\) it is the naive forecast; as \(\alpha \to 0\) the long-run mean.
x = [10, 20, 30], α = 0.3, adjust=False. \(s_1 = 10\), \(s_2 = 0.3·20 + 0.7·10\), \(s_3 = 0.3·30 + 0.7·s_2\). What prints?
[10.0, 13.0, 18.1]
EWMA(0.3): RMSE 0.1075, DA 0.496 — between naive and SMA, because α = 0.3 remembers only ~2 minutes and inherits the bounce. The window is the model — and you tuned it by eye. Now let a likelihood choose.
Fit ARIMA(2,0,1) — the AIC winner — on AAPL log returns 2020–2023, then feed 2024 through res.apply(new_data) for one-step-ahead forecasts without refitting. Trade the sign.
DA 0.480, RMSE 0.01451 — worse than forecasting the training mean (0.01421); Sharpe -0.20 against 1.16 for holding. The AIC winner in-sample is a loser out of sample. This is the result that moved mean forecasting off the classical desk.
ML did not replace time series; it sits on top of it. Four things every return-prediction model in Chapter 4 quietly assumed:
| Inherited discipline | From | Failure if skipped |
|---|---|---|
| Stationary features — returns, ratios, differences, never price levels | §6.1 | tree splits on a level that never recurs; the model memorises 2021 |
| Lag to avoid leakage — every feature at \(t\) uses data to \(t-1\) | .shift(1) |
in-sample accuracy 0.9, live accuracy 0.5 |
| Walk-forward validation — expanding window, refit per period | §6.2 | random K-fold lets the future train the past |
| A baseline that must be beaten — zero forecast, ARIMA(0,0,0) | §6.1–5.2 | a Sharpe with nothing to compare it to |
Tip
The ML model is the last step of a time-series pipeline. Most of the value is in the first three rows.
Target is y = r (today’s return). A colleague builds X[“std21”] = r.rolling(21).std() with no shift. What is wrong?
Eleven features, all stationary (returns, rolling moments, a ratio), all shifted. The first fold trains on 2016–2017 and tests on 2018; each later fold expands the training window by a year. That is the walk-forward split.
Same spec as Chapter 4: HistGradientBoostingRegressor(max_depth=2, learning_rate=0.05, max_iter=200, min_samples_leaf=50). Refit every January on all data to date; predict the year; trade long/flat on the sign.
Honest numbers: DA 0.494, RMSE 0.01276 — worse than the zero forecast (0.01247), correlation -0.013; the rule’s Sharpe 0.31 against 0.57 for holding the index, in the market 59 % of days. No year’s DA clears 0.54. The same walk-forward discipline that exposed ARIMA exposes the GBM. ML did not win by forecasting one series from its own past — it won by moving to the cross-section and to richer features (Ch. 3.5). The pipeline is the inheritance; the alpha is elsewhere.
In Colab
import torch, torch.nn as nn
class LSTMReg(nn.Module):
def __init__(self, n_feat, hidden=32):
super().__init__(); self.lstm = nn.LSTM(n_feat, hidden, batch_first=True); self.head = nn.Linear(hidden, 1)
def forward(self, x): out, _ = self.lstm(x); return self.head(out[:, -1])
model = LSTMReg(n_feat=11); opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(20): # X_seq: (batch, 21 days, 11 features), y: next-day return
opt.zero_grad(); loss = ((model(X_seq) - y).pow(2)).mean(); loss.backward(); opt.step()Your LSTM beats the GBM by 4 % accuracy in-sample. What do you check first?
\(X\) Granger-causes \(Y\) if lags of \(X\) improve the forecast of \(Y\) beyond \(Y\)’s own lags — an F-test on the \(X\) coefficients in \(Y\)’s equation (a VAR is \(k\) such regressions on shared lags). Forecastability, not mechanism.
p_nvda_to_spy is done. Compute p_spy_to_nvda — the p-value that SPY’s past helps forecast NVDA — using granger_p with 2 lags.
NVDA → SPY 0.6835, SPY → NVDA 0.1257. Neither direction at 5 %: in daily returns the lead–lag is priced away within the day. On macro data (Stock–Watson, FRED-MD) Granger tests are informative; on liquid daily prices they mostly confirm efficiency.
.shift(1) before anything else, expanding-window walk-forward, and a baseline to beat.Next: §6.3 — stop forecasting a direction; trade a stationary relationship instead.
Prof. Xuhu Wan · HKUST ISOM · Learning Statistics with Python