Oil Price Forecasting

Marc Los Huertos

EA030 · Compiled 2026-05-26

Why Oil Prices? Architecture Data Sources Feature Engineering Models Ensemble Spot Anchoring CA Gasoline Diagnostics Iterative Improvement

🎯 Learning Outcomes

  1. Explain why commodity price forecasting matters for environmental policy and energy planning.
  2. Design a modular R pipeline that separates configuration, data ingestion, feature engineering, modeling, and diagnostics into individual scripts.
  3. Fetch and clean market data from Yahoo Finance, the EIA API, and FRED using R.
  4. Engineer time-series features: technical indicators, momentum, seasonal indices, and news sentiment.
  5. Fit ARIMA-X, Prophet, and LASSO models and combine them into a bias-penalized inverse-RMSE ensemble.
  6. Diagnose forecast bias using expanding-window backtesting, directional accuracy, and residual autocorrelation.
  7. Apply spot-anchoring and curve-consistent blending to correct systematic under-prediction.
  8. Convert crude oil forecasts to retail gasoline prices using a GAM spread model with holdout validation.
  9. Build a compact diagnostic report that summarizes model health in a machine-readable format.
  10. Practice iterative, diagnostic-driven model improvement across multiple versions.

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.


1. Why Oil Prices Matter for Environmental Analysis

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.


2. System Architecture

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.

00
main.R
Orchestrator
00
packages.R
Library loading
01
config.R
API keys, dates, paths
02
market_data.R
Yahoo, EIA, FRED
03
features.R
Technical indicators
04
news_sentiment.R
RSS + topic scoring
05
assemble.R
Master data frame
06
train_test_split.R
Split + future frame
07
models_brent.R
ARIMA, Prophet, LASSO
08
gam_gasoline.R
Brent-to-pump spread
09
counterfactual.R
Feature ablation
10
accuracy.R
Metrics + baselines
11
plots.R
14 diagnostic PNGs
12
export.R
CSV, RDS, run log
13
diagnostic_report.R
Full text report
14
compact_diagnostic.R
Machine-readable triage

The main script

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.


3. Data Sources and Ingestion

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

Configuration (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.

Futures curve data

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.


4. Feature Engineering

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.

Technical indicators (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

Seasonal demand indices

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.

News sentiment (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.


5. The Three Forecasting Models

Each model brings a different structural assumption to the forecast.

5.1 ARIMA-X

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.

5.2 Prophet

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.

5.3 LASSO

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.


6. The Ensemble

Bias-penalized inverse-RMSE weighting

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.

Why not equal weights?

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.


7. Spot Anchoring and Curve Blending

The under-prediction problem

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.

Spot-anchoring correction

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.

Curve-consistent blending (v18)

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.

AR(1) residual correction (v18)

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).


8. California Gasoline: The Spread Model

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.

GAM with holdout validation

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"
)

The overfitting problem and OLS fallback (v18)

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.


9. Diagnostic-Driven Development

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

Reading the diagnostic

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

The improvement log

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

🔑 The diagnostic-driven workflow

  • Run the model and generate the diagnostic report.
  • Identify the worst metric – the one most clearly failing.
  • Trace the failure to a specific structural cause (not just “needs more data”).
  • Design a targeted fix that addresses that cause.
  • Document the fix in the version header with the diagnostic evidence.
  • Re-run and check whether the target metric improved without degrading others.

10. Forward Feature Realism (v18)

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.


11. Next Steps

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.”

🔑 Key takeaways