Prof. Marc Los Huertos • Pomona College • Environmental Analysis
Why Oil Prices? Architecture Data Sources Feature Engineering Models Ensemble Spot Anchoring CA Gasoline Diagnostics Iterative Improvement
What this module covers. This tutorial walks through a complete, production-grade oil price forecasting system built in R. The system fetches live data from three public APIs, engineers dozens of features, fits three different forecasting models, combines them into a weighted ensemble, and generates 9-month Brent crude and California retail gasoline forecasts with confidence intervals. More importantly, it demonstrates a diagnostic-driven development workflow where each new version of the model is motivated by specific failures identified in the previous version’s diagnostic report. The code is real, the data are live, and every design decision is explained in the context of the problem it solves.
Oil prices sit at the intersection of energy policy, environmental regulation, and economic behavior. When crude oil rises, gasoline follows with a lag. Gasoline prices affect driving behavior, vehicle purchase decisions, public transit ridership, and political support for clean energy alternatives. For environmental scientists and policy analysts, understanding the trajectory of energy prices is not a finance exercise – it is a tool for anticipating changes in emissions, transportation patterns, and the economic viability of renewable alternatives.
California is a particularly interesting case. The state’s gasoline prices are consistently the highest in the continental US, driven by a combination of refinery concentration, boutique fuel blends, carbon cap-and-trade costs, and state excise taxes. The spread between Brent crude (the global benchmark) and California retail gasoline is itself an environmental indicator: it reflects refinery capacity, regulatory stringency, and seasonal demand patterns.
This tutorial builds a forecasting system that models both the global crude price and the California-specific markup, giving you a complete pipeline from barrel to pump.
📌 Why not just read the futures curve? The WTI futures curve gives the market’s consensus forward price. It is a useful baseline, and we use it as one. But futures prices reflect risk premia and hedging demand alongside expectations. A model that incorporates momentum, sentiment, macro indicators, and seasonal patterns can, in principle, add value at short horizons where these signals carry information the curve does not. The key word is can. Whether your model actually beats the random walk (today’s price as the forecast) is an empirical question, and this tutorial treats it as one.
The forecasting system is organized as a 16-script pipeline
orchestrated by a single main file. Each script reads objects from the
global environment left by prior scripts and writes its own outputs
back. This is not the most elegant architecture (a formal R package with
function arguments would be cleaner), but it has a practical virtue: you
can source() any individual script in the console to re-run
just that step after making changes.
The orchestrator is intentionally minimal. It source()s
each step in order and nothing else:
source("00_packages.R")
source("01_config.R")
source("02_market_data.R")
source("03_features.R")
source("04_news_sentiment.R")
source("05_assemble.R")
source("06_train_test_split.R")
source("07_models_brent.R")
source("08_gam_gasoline.R")
source("09_counterfactual.R")
source("10_accuracy.R")
source("11_plots.R")
source("12_export.R")
source("13_diagnostic_report.R")
source("14_compact_diagnostic.R")
📌 Why separate scripts instead of functions? In a production system, each script would be a function in an R package, with explicit inputs and outputs. The script-based approach used here trades architectural purity for transparency: every intermediate object lives in the global environment where you can inspect it interactively. For a teaching context, this is a reasonable trade-off.
The system pulls from three public APIs, each requiring a free API
key stored in ~/.Renviron:
| Source | Data | R Method | Key Variable |
|---|---|---|---|
| Yahoo Finance | Brent (BZ=F), WTI (CL=F), 12-month WTI
curve |
quantmod::getSymbols() |
EIA_KEY (not needed) |
| EIA | Weekly CA retail gasoline | httr::GET() to API v2 |
EIA_KEY |
| FRED | USD index, initial claims, 10Y-2Y yield spread | fredr::fredr() |
FRED_API_KEY |
01_config.R)All parameters are set in one place. This is the entire configuration for the system – dates, tickers, API keys, and output paths:
EIA_API_KEY <- Sys.getenv("EIA_KEY")
FRED_API_KEY <- Sys.getenv("FRED_API_KEY")
BRENT_TICKER <- "BZ=F"
WTI_TICKER <- "CL=F"
LOOKBACK_DAYS <- 540 # 18 months of training data
FORECAST_HORIZON <- 270 # 9 months ahead
DATE_END <- Sys.Date()
DATE_START <- DATE_END - LOOKBACK_DAYS
⚠️ API keys must be in ~/.Renviron Never hard-code
API keys in scripts. Add them to ~/.Renviron as
EIA_KEY=your_key_here (one per line, no quotes). Restart R
after editing. The Sys.getenv() calls will pick them up
automatically. This keeps keys out of version control.
The WTI futures curve gives the market’s forward expectation for oil prices across 12 contract months. The system fetches each contract individually from Yahoo Finance and computes curve metrics:
WTI_CURVE_TICKERS <- c(
"CLM26.NYM", "CLN26.NYM", "CLQ26.NYM", "CLU26.NYM",
"CLV26.NYM", "CLX26.NYM", "CLZ26.NYM", "CLF27.NYM",
"CLG27.NYM", "CLH27.NYM", "CLJ27.NYM", "CLK27.NYM"
)
The curve slope (front-month vs. back-month, annualized) tells you whether the market is in contango (upward-sloping, future prices higher than spot) or backwardation (downward-sloping, future prices below spot). This distinction matters for forecasting: a model that predicts rising prices in a steeply backwardated market is fighting the market’s consensus.
Raw price data is not directly useful for forecasting. The feature engineering step transforms prices into signals that capture momentum, volatility, market structure, and seasonal patterns.
03_features.R)| Feature | Formula | What It Captures |
|---|---|---|
ret_1d, ret_5d, ret_21d |
Log returns over 1, 5, 21 days | Short/medium momentum |
momentum_63d |
close / lag(close, 63) - 1 |
Quarterly trend strength |
macd |
12-day EMA minus 26-day EMA | Trend direction and acceleration |
rsi_14 |
Relative Strength Index (14-day) | Overbought/oversold signal |
bb_width |
Bollinger Band width (20-day, 2 SD) | Current volatility regime |
vol_ratio |
5-day SD / 21-day SD | Volatility expansion/contraction |
price_vs_ma63 |
close / MA(63) - 1 |
Deviation from medium-term mean |
Oil demand has strong seasonal patterns. The system creates continuous indices rather than binary flags:
add_seasonal_demand <- function(df) {
df |> mutate(
# Peaks mid-summer (day ~172), zero in winter
driving_season_idx = pmax(0, sin(2 * pi * (yday(date) - 80) / 365)),
# Peaks mid-winter, zero in summer
heating_demand_idx = pmax(0, -cos(2 * pi * yday(date) / 365)),
# Binary: spring + fall turnaround windows
refinery_maint = as.integer(month(date) %in% c(3, 4, 9, 10)),
summer_peak = as.integer(month(date) %in% 5:8),
winter_heat = as.integer(month(date) %in% c(11, 12, 1, 2))
)
}
📌 Why continuous indices instead of dummies? A
binary summer = TRUE/FALSE variable treats May 1 and August
15 identically. A sinusoidal index peaks in mid-summer and tapers
smoothly into the shoulder months, giving the model a richer signal. The
pmax(0, …) clips negative values so the index is zero
during the off-season rather than negative.
04_news_sentiment.R)The system scrapes RSS feeds from major energy news sources, scores each article using keyword-based topic classification, and computes daily sentiment features across five channels:
| Channel | Routes To | Examples |
|---|---|---|
sent_supply_risk |
Brent models | Geopolitical disruption, pipeline attacks |
sent_opec_policy |
Brent models | OPEC+ cuts, quota compliance |
sent_macro_demand |
Brent models | Recession fears, GDP revisions |
sent_refinery |
CA spread model | CA refinery outages, maintenance |
sent_ca_regulatory |
CA spread model | CA carbon policy, fuel standards |
A critical design choice: negative news enters the model quickly (0-2 day lag) while positive news enters slowly (5-21 day rolling average). This reflects the empirical asymmetry in how oil markets respond to shocks – supply disruptions spike prices within hours, while diplomatic resolutions take weeks to filter through.
Each model brings a different structural assumption to the forecast.
ARIMA (AutoRegressive Integrated Moving Average) with exogenous
regressors is the classical time-series workhorse. The model is fit on
log-transformed Brent prices with d=1 (first differencing
enforced) and the technical indicators as external regressors:
brent_ts <- ts(log(train$brent_close), frequency = 5)
arima_model <- auto.arima(
brent_ts,
xreg = xreg_train, # scaled feature matrix
d = 1, # force first differencing
stepwise = TRUE,
approximation = TRUE,
max.p = 5, max.q = 3,
max.P = 1, max.Q = 1,
ic = "aicc"
)
Strengths: Captures short-term autocorrelation structure (today’s price predicts tomorrow’s). Ljung-Box test on residuals tells you if the model has captured all available serial correlation.
Weaknesses: Assumes the autocorrelation structure is stable over the entire training window. In regime-shifting markets, this is often false.
Facebook’s Prophet decomposes the series into trend, weekly seasonality, yearly seasonality, and external regressors. It handles missing data and holidays automatically:
m_prophet <- prophet(
seasonality.mode = "multiplicative",
daily.seasonality = FALSE,
weekly.seasonality = TRUE,
yearly.seasonality = TRUE,
changepoint.prior.scale = 0.15 # controls trend flexibility
)
Strengths: Handles structural breaks (changepoints) more gracefully than ARIMA. Multiplicative seasonality is appropriate for percentage-based price moves.
Weaknesses: Not designed for financial time series. The weekly “seasonality” in oil prices is really a day-of-week effect, not a true seasonal cycle.
Least Absolute Shrinkage and Selection Operator regression performs automatic feature selection by penalizing coefficient magnitudes toward zero. Features that do not improve out-of-sample prediction are dropped entirely:
en_spec <- linear_reg(penalty = tune(), mixture = tune()) |>
set_engine("glmnet")
# Time-series cross-validation (rolling origin)
ts_folds <- rolling_origin(
train,
initial = round(nrow(train) * 0.7),
assess = 10,
cumulative = FALSE,
skip = 9
)
Strengths: Handles many correlated features without manual selection. The regularization path reveals which features carry genuine predictive power versus noise.
Weaknesses: Linear model – cannot capture nonlinear interactions unless you engineer them explicitly.
The three models are combined using inverse-RMSE weights computed from expanding-window cross-validation. In v17, a bias penalty was added: models that consistently under- or over-predict have their effective RMSE inflated, reducing their weight in the ensemble.
# Directional bias: fraction of over-predictions per model
over_pct_vec <- c(
arima = mean(xw_all$arima_pred > xw_all$actual, na.rm = TRUE),
prophet = mean(xw_all$prophet_pred > xw_all$actual, na.rm = TRUE),
lasso = mean(xw_all$lasso_pred > xw_all$actual, na.rm = TRUE)
)
# Bias penalty: 1.0 (balanced) to 2.0 (completely one-sided)
bias_penalty_vec <- 1 + 2 * abs(over_pct_vec - 0.5)
# Penalized RMSE for weight computation
penalized_rmse_vec <- rmse_vec * bias_penalty_vec
# Inverse-penalized-RMSE weights
wts <- (1 / penalized_rmse_vec) / sum(1 / penalized_rmse_vec)
Bias penalty formula
penalty = 1 + 2 *
|over_pct - 0.50|
A perfectly balanced model (50% over,
50% under) gets penalty = 1.0. A completely one-sided model (0% or 100%)
gets penalty = 2.0, doubling its effective RMSE and halving its ensemble
weight.
Equal weighting (1/3 each) is a strong baseline that often beats complex weighting schemes. The bias-penalized approach adds value when models have asymmetric errors – if ARIMA consistently under-predicts by $5 while LASSO is unbiased, giving them equal weight bakes in a -$1.67 bias.
The most persistent diagnostic finding across versions was systematic
under-prediction: over_pct=0% (the model under-predicted
every single backtest day). The root cause is that the ensemble averages
three models trained on 18 months of history, which includes periods at
lower prices. In a rising market, this historical anchoring creates a
downward drag.
The fix blends the raw ensemble forecast with a “no-change from spot” baseline. The blend weight decays exponentially over the forecast horizon using a calibrated half-life:
# Alpha = 1 at day 0 (pure spot), decays toward 0 (pure model)
fc_alpha <- 0.5 ^ (fc_days_out / best_halflife)
ensemble_future <- fc_alpha * spot_current +
(1 - fc_alpha) * ensemble_future
The half-life is calibrated by grid search over the expanding-window backtest, choosing the value that minimizes RMSE. A 15-day half-life means the model’s own signal dominates by about day 30.
When the ensemble forecasts a direction that contradicts a steep futures curve (e.g., predicting a rally in a -22% backwardated market), the forecast is blended with the curve-implied price path at longer horizons:
if (contradicts_curve) {
curve_implied <- spot_current * (1 + curve_slope * (fc_days_out / 365))
curve_weight <- pmin(0.60, pmax(0, (fc_days_out - 30) / 240 * 0.60))
ensemble_future <- (1 - curve_weight) * ensemble_future +
curve_weight * curve_implied
}
The weight ramps from 0 at day 30 to a maximum of 60% at 9 months. This prevents the model from predicting a 10% rally when the forward curve prices in a 22% decline.
If the backtest residuals show strong autocorrelation (ACF lag-1 > 0.3), there is persistent structure the ensemble has not captured. An AR(1) model fit on the residual series projects a decaying correction onto the forecast:
residuals_bt <- test$brent_close - ensemble_test
resid_ar <- arima(residuals_bt, order = c(1, 0, 0))
resid_forecast <- predict(resid_ar, n.ahead = length(future_dates))$pred
# Cap at +/- 2 SD to prevent runaway extrapolation
max_correction <- 2 * sd(residuals_bt)
resid_forecast <- pmax(-max_correction, pmin(max_correction, resid_forecast))
ensemble_future <- ensemble_future + resid_forecast
📌 The order of corrections matters The pipeline applies corrections in this sequence: (1) raw ensemble, (2) spot anchoring with half-life decay, (3) curve-consistent blending, (4) AR(1) residual correction. Each layer addresses a specific failure mode identified in diagnostic reports. The order ensures that structural corrections (spot anchoring) are applied before fine-tuning (residual correction).
Converting a Brent crude forecast to a California retail gasoline price requires modeling the spread – the markup between crude and pump price. This markup reflects refining costs, distribution, taxes, seasonal blend requirements, and CA-specific regulatory costs.
A Generalized Additive Model (GAM) captures the nonlinear relationship between Brent and the spread:
spread_gam <- mgcv::gam(
spread ~ s(dl_brent_lag, k = 8) + # nonlinear pass-through
s(driving_season_idx, k = 5) + # seasonal cycle
lag_spread + # autoregressive component
refinery_stress + # CA refinery outage intensity
sent_refinery + # refinery sentiment
sent_ca_regulatory + # CA regulatory sentiment
summer_peak + winter_heat,
data = spread_df,
method = "REML"
)
In v17, the GAM achieved in-sample adjusted R-squared of 0.860 but
holdout R-squared of -15.049 – worse than predicting the mean. The GAM
was fitting noise in the training data despite reduced basis dimensions
(k=8, k=5).
The v18 fix adds automatic holdout validation: after fitting the GAM, hold out the last 15% of training data and check R-squared. If negative, fall back to a simpler OLS model:
gam_val_n <- min(30, floor(nrow(spread_df) * 0.15))
gam_val_df <- tail(spread_df, gam_val_n)
gam_train_df <- head(spread_df, nrow(spread_df) - gam_val_n)
gam_val_model <- mgcv::gam(/* same formula */, data = gam_train_df)
gam_val_pred <- predict(gam_val_model, newdata = gam_val_df)
gam_val_r2 <- 1 - var(gam_val_df$spread - gam_val_pred) /
var(gam_val_df$spread)
if (gam_val_r2 < 0) {
# Fall back to OLS
spread_gam <- lm(
spread ~ dl_brent_lag + driving_season_idx + lag_spread +
refinery_stress + summer_peak + winter_heat + quarter,
data = spread_df
)
}
⚠️ Negative R-squared is possible and meaningful R-squared below zero means your model is worse than a horizontal line at the mean. This is common when a flexible model (GAM, polynomial regression, neural network) is evaluated on genuinely out-of-sample data. Always evaluate on a holdout, never on training data alone.
The most important lesson of this project is not any single modeling technique – it is the workflow of using diagnostics to drive improvements. Each version of the model produces a compact diagnostic report that encodes pass/fail flags for key metrics:
OIL v17 | 2026-05-26 | train=351 | bt=20 | xw_folds=5
SKILL: RW=FIX (-147.8%) | Mean=OK (63.5%) | Trend=OK (4.4%)
XW_SKILL: vs_RW=-30.7% dir_acc=89.4%(OK)
BRENT: RMSE=$13.2 MAPE=11.4% R2=0.035
BIAS: over_pct=0% err_acf1=0.656 err_trend=-0.585$/d verdict=BIASED
| Metric | What It Means | Passing |
|---|---|---|
SKILL vs RW |
Does the model beat “today’s price = tomorrow’s price”? | > 0% |
dir_acc |
Fraction of days the model correctly predicts price direction | > 50% |
over_pct |
Fraction of backtest days the model over-predicts | Near 50% |
err_acf1 |
Lag-1 autocorrelation of errors | < 0.30 |
err_trend |
Linear trend in errors over time | Near 0 |
Every version’s header documents the specific diagnostic finding that motivated each change:
| Version | Diagnostic Finding | Fix |
|---|---|---|
| v08 | over_pct=0%, raw 1M forecast $11.50 below spot | Spot-anchoring correction |
| v16 | Single 30-day holdout dominated by one regime | Expanding-window backtest |
| v17 | All models biased in same direction | Bias-penalized ensemble weights |
| v18 | 6M forecast contradicts -22% backwardated curve | Curve-consistent blending |
| v18 | err_acf1=0.656, persistent error structure | AR(1) residual correction |
| v18 | GAM holdout R2=-15.049 | OLS fallback gate |
A subtle but important issue: what values do features take in the
forecast horizon? During training, the model sees
momentum_63d at its actual realized value. During
forecasting, you do not know what momentum will be next month.
Earlier versions set momentum to zero in the forecast horizon – a safe default that creates a discontinuity at the forecast boundary. In a strong trend regime, this means the LASSO’s positive momentum coefficients suddenly contribute nothing, dragging the forecast toward the mean.
The v18 fix calibrates an AR(1) decay rate from the feature’s own autocorrelation in the training data:
mom_series <- master$momentum_63d[!is.na(master$momentum_63d)]
phi_mom <- max(0.50, min(0.95,
cor(mom_series[-1], mom_series[-length(mom_series)],
use = "complete.obs")))
# Exponential decay from last observed value
future_frame$momentum_63d <- last_tech("momentum_63d") *
phi_mom ^ seq_along(future_dates)
If the training-period autocorrelation of momentum is 0.85, the
feature decays with a half-life of about 4.3 days
(log(0.5)/log(0.85)), which is realistic: momentum persists
for a few days and then fades. The bounds (0.50 to 0.95) prevent the
decay from being either instantaneous or essentially permanent.
📌 Try it: check your feature autocorrelations For
any feature you project into the forecast horizon, compute its lag-1
autocorrelation in the training data: cor(x[-1],
x[-length(x)]). If it is high (> 0.8), the feature is
persistent and an AR(1) projection is reasonable. If it is low (<
0.3), the feature is noisy and should be set to its unconditional mean,
not projected forward.
The v18 diagnostic report will reveal whether the four fixes addressed the core problems. Based on the improvement roadmap, the likely next priorities are:
Regime detection. Classify the market state (high-volatility, contango, backwardation, normal) and train separate ensemble weights per regime. A hidden Markov model with 2-3 states would allow soft transitions rather than hard cutoffs.
Meta-ensemble. Replace static inverse-RMSE weights with a meta-learner (ridge regression or gradient boosting) that takes the three model predictions plus regime features as inputs. This only adds value after the individual models are unbiased – stacking biased models just learns to be biased more efficiently.
SHAP values for interpretability. Use Shapley values to decompose each day’s forecast into feature contributions. This transforms the model from a black box into a narrative: “Today’s forecast is $2.30 above yesterday’s because momentum contributed +$1.80 and sentiment contributed +$0.50.”