Time Series Analysis of U.S. Daily Wheat, Corn, and Soybean Futures Prices
Author
Anthony Le
Published
December 5, 2024
I. Introduction
The agricultural commodities market is integral to global trade and economic stability, providing food, feed, and raw materials for a diverse range of industries. However, prices in this sector are highly volatile due to weather-dependent production, geopolitical events, and macroeconomic fluctuations. Futures markets for commodities like wheat, corn, and soybeans provide a vital platform for buyers and sellers to trade standardized contracts for future delivery, primarily facilitating investment opportunities for speculators and hedgers against price volatility.
This document is the full companion code notebook to the written report (report/paper.pdf): it walks through data acquisition and wrangling, descriptive and spectral analysis, and the full modeling pipeline — function-of-time/ARIMA models, univariate GARCH, multivariate (DCC) GARCH, and ARIMAX models — for daily wheat, corn, and soybean futures prices from January 2000 through early December 2024. Each commodity’s futures contract represents 5,000 bushels, with prices quoted in U.S. cents per bushel in the raw data (converted to dollars below).
II. Data Import & Initial Exploration
The code below imports the data for three commodity series with information on prices and volumes of trades since Jan 3, 2000 to Dec 5, 2024. Initial wrangling includes replacing N/A’s values in volumes of each futures with linear approximation.
Show the code
# Data Importswheat <-read.csv("data/wheat_futures.csv")corn <-read.csv("data/corn_futures.csv")soy <-read.csv("data/soybean_futures.csv")wheatvol <-read.csv("data/wheat_volume.csv")soyvol <-read.csv("data/soybean_volume.csv")# Fill in N/A's for wheat and soy volumemerged_data <-merge(wheat, wheatvol, by ="Date", all.x =TRUE)merged_data$Vol.[is.na(merged_data$Vol.)] <- merged_data$Volume[is.na(merged_data$Vol.)]wheat <- merged_data[, !names(merged_data) %in%"Volume"]merged_data <-merge(soy, soyvol, by ="Date", all.x =TRUE)merged_data$Vol.[is.na(merged_data$Vol.)] <- merged_data$Volume[is.na(merged_data$Vol.)]soy <- merged_data[, !names(merged_data) %in%"Volume"]# Wranglingwheat <- wheat %>%mutate(Date =as.Date(wheat$Date))corn <- corn %>%mutate(Date =as.Date(corn$Date))soy <- soy %>%mutate(Date =as.Date(soy$Date))total <-merge(wheat,corn,by="Date")%>%rename('Wheat'='Price.x','wheat_vol'='Vol..x','Corn'='Price.y','corn_vol'='Vol..y')total <-merge(total,soy,by="Date")%>%rename('Soy'='Price','soy_vol'='Vol.') %>%mutate(wheat_vol =na.approx(wheat_vol))%>%mutate(corn_vol =na.approx(corn_vol))%>%mutate(soy_vol =na.approx(soy_vol))%>%mutate("Month"=month(Date)) %>%mutate("Year"=year(Date)) %>%mutate(Wheat = Wheat/100)%>%mutate(Corn = Corn/100)%>%mutate(Soy = Soy/100)
Potential Biases & Assumptions
The data is sourced from the Chicago Board of Trade (CBOT), one of several exchanges (alongside Euronext, KCBT, and MGEX) where these commodities trade, so it represents a sample rather than the full population of futures activity and may not capture dynamics specific to other exchanges. Trading volume figures are also prone to reporting gaps — handled here via linear interpolation — which should be kept in mind when interpreting volume-based results.
The price and volume are plotted below.
Show the code
# Plot the time seriesprice_plot <-ggplot(total, aes(x = Date)) +geom_line(aes(y = Wheat, color ="Wheat")) +geom_line(aes(y = Corn, color ="Corn"))+geom_line(aes(y = Soy, color ="Soybeans")) +labs(title ="Wheat, Corn, and Soybeans Futures Prices",y ="Price ($)",x ="Year") +scale_color_manual(values =c("Wheat"="coral", "Corn"="cornflowerblue","Soybeans"="brown1")) +theme_light()volume_plot <-ggplot(total, aes(x = Date)) +geom_line(aes(y = wheat_vol, color ="Wheat"), linewidth = .15) +geom_line(aes(y = corn_vol, color ="Corn"), linewidth = .15) +geom_line(aes(y = soy_vol, color ="Soybeans"), linewidth = .15) +labs(title ="Wheat, Corn, and Soybeans Futures Volumes",y ="Volume",x ="Year") +scale_color_manual(values =c("Wheat"="coral", "Corn"="cornflowerblue","Soybeans"="brown1")) +scale_y_continuous(labels = scales::label_number(scale_cut =cut_short_scale())) +theme_light()price_plot / volume_plot
As seen below, there does not seem to be any monthly seasonality in the prices of these derivatives.
Show the code
p <-ggplot(total, aes(x=Month, alpha = Year)) +geom_line(aes(y = Wheat, color ="Wheat")) +geom_line(aes(y = Corn, color ="Corn"))+geom_line(aes(y = Soy, color ="Soybeans"))+labs(x ="Month",y ="Price ($)",title ="Wheat, Corn, and Soybeans Futures Prices by Month") +theme_minimal() +scale_color_manual(values =c("Wheat"="coral", "Corn"="cornflowerblue", "Soybeans"="brown1"))+scale_x_continuous(breaks=c(2,4, 6, 8,10,12)) +transition_states(Year)animate(p, renderer =gifski_renderer())
Now we create time series objects for each commodity’s futures prices and plot them along with corresponding ACF/PACF plots. The most recent 47 trading days (Oct 1 – Dec 5, 2024) are held out as a test set for cross-validation later on.
When trying to decompose the series, it appears that there is no seasonality that the computer can recognize in either the original or differenced series — decompose()/stl() error out with “time series has no or fewer than 2 periods”, which is expected for a derivatives market rather than a raw seasonal commodity series, so those calls are left commented out below.
Show the code
# "time series has no or less than 2 periods"# plot(stl(wheat.ts))# plot(stl(corn.ts))# plot(stl(soy.ts))# plot(stl(wheat.r))# plot(stl(corn.r))# plot(stl(soy.r))
Applying Ksmooth, we can try to pick up some overall trend in the data instead.
In this section, we explore the spectrums of each series (differenced for stationarity) and their corresponding daily returns as well.
Show the code
par(mfrow=c(3,1))mvspec(diff(wheat.ts)[-1], log ="y", main ="Spectrum of Differenced Wheat Futures (Log)", col = col.set[1], lwd =2)mvspec(diff(corn.ts)[-1], log ="y", main ="Spectrum of Differenced Corn Futures (Log)", col = col.set[2], lwd =2)mvspec(diff(soy.ts)[-1], log ="y", main ="Spectrum of Differenced Soybeans Futures (Log)", col = col.set[3], lwd =2)
Bootstrapping the spectral density tells us that there are no significant frequencies.
Daily returns data is produced using the dailyReturn function in quantmod. Each series is stationary, independent, and random. Notably, auto.arima() suggests ARIMA(0,0,0) with zero mean for all of them — i.e., no exploitable structure in the mean of daily returns, which is exactly what motivates modeling their variance with GARCH below.
Show the code
# Daily returns# One way to calculate the returns is to use diff(log(data)), which is commented below.# xts_plots(diff(log(wheat.ts))[-1],"Returns on Wheat Futures",25)# However, for simplicity, we can also use dailyReturns() from "quantmod".wheat.r <-dailyReturn(wheat.ts)xts_plots(wheat.r,"Returns on Wheat Futures",25)
Show the code
xts_plots(wheat.r^2,"Squared Returns on Wheat Futures",25)
Show the code
corn.r <-dailyReturn(corn.ts)xts_plots(corn.r,"Returns on Corn Futures",25)
Show the code
xts_plots(corn.r^2,"Squared Returns on Corn Futures",25)
Show the code
soy.r <-dailyReturn(soy.ts)xts_plots(soy.r,"Returns on Soybeans Futures",25)
Show the code
xts_plots(soy.r^2,"Squared Returns on Soybeans Futures",25)
Show the code
# charts.RollingPerformance(R = wheat.r)summary(auto.arima(wheat.r) )# ARIMA(0,0,0) with zero mean
Series: wheat.r
ARIMA(0,0,0) with zero mean
sigma^2 = 0.0004225: log likelihood = 15452.65
AIC=-30903.3 AICc=-30903.3 BIC=-30896.56
Training set error measures:
ME RMSE MAE MPE MAPE MASE ACF1
Training set 0.0003469623 0.0205549 0.01519133 100 100 0.6987919 0.004276192
Show the code
summary(auto.arima(corn.r) )# ARIMA(0,0,0) with zero mean
Series: corn.r
ARIMA(0,0,0) with zero mean
sigma^2 = 0.0003071: log likelihood = 16452.73
AIC=-32903.46 AICc=-32903.46 BIC=-32896.71
Training set error measures:
ME RMSE MAE MPE MAPE MASE ACF1
Training set 0.0002679425 0.01752312 0.01248567 100 100 0.6999619 0.01777746
Show the code
summary(auto.arima(soy.r)) # ARIMA(0,0,0) with zero mean
Series: soy.r
ARIMA(0,0,0) with zero mean
sigma^2 = 0.0002496: log likelihood = 17102.16
AIC=-34202.33 AICc=-34202.33 BIC=-34195.58
Training set error measures:
ME RMSE MAE MPE MAPE MASE ACF1
Training set 0.0002595717 0.01579816 0.01104248 100 100 0.6878481 -0.01606062
Show the code
par(mfrow=c(3,1))wheat.r.spec <-mvspec(wheat.r, log ="y", main ="Spectrum of Returns on Wheat Futures (Log)", col = col.set[1], lwd =1)corn.r.spec <-mvspec(corn.r, log ="y", main ="Spectrum of Returns on Corn Futures (Log)", col = col.set[2], lwd =1)soy.r.spec <-mvspec(soy.r, log ="y", main ="Spectrum of Returns Soybeans Futures (Log)", col = col.set[3], lwd =1)
IV. ARIMA Models
Wheat Futures
By manually fitting ARIMA models guided by ACF/PACF signals and using auto.arima(), we were not able to find a model with reasonably independent and normal residuals. The “best” model by AIC is ARIMA(1,1,3), suggested by auto.arima().
Show the code
wheat_auto <-auto.arima(wheat.ts) # ARIMA(1,1,3)summary(wheat_auto) # AIC=-7404.54 - best
Series: wheat.ts
ARIMA(1,1,3)
Coefficients:
ar1 ma1 ma2 ma3
0.4723 -0.4298 -0.0275 -0.0485
s.e. 0.1455 0.1456 0.0148 0.0135
sigma^2 = 0.01794: log likelihood = 3707.27
AIC=-7404.54 AICc=-7404.53 BIC=-7370.83
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0005738023 0.1338992 0.0858726 -0.007552508 1.520633 1.002313
ACF1
Training set 0.0001096424
Show the code
checkresiduals(wheat_auto)
Ljung-Box test
data: Residuals from ARIMA(1,1,3)
Q* = 7.0809, df = 6, p-value = 0.3134
Model df: 4. Total lags used: 10
Series: wheat.ts
ARIMA(1,0,0) with non-zero mean
Coefficients:
ar1 mean
0.9975 5.3236
s.e. 0.0009 0.6314
sigma^2 = 0.01801: log likelihood = 3692.06
AIC=-7378.13 AICc=-7378.12 BIC=-7357.9
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0005057067 0.1341914 0.08572483 -0.04281015 1.519925 1.000588
ACF1
Training set 0.04524726
Show the code
checkresiduals(wheat_100)
Ljung-Box test
data: Residuals from ARIMA(1,0,0) with non-zero mean
Q* = 42.023, df = 9, p-value = 3.255e-06
Model df: 1. Total lags used: 10
Series: wheat.ts
ARIMA(0,1,0)
sigma^2 = 0.01803: log likelihood = 3690.18
AIC=-7378.37 AICc=-7378.37 BIC=-7371.62
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0005373345 0.134265 0.08566116 -0.007185998 1.515948 0.999845
ACF1
Training set 0.04421788
Show the code
checkresiduals(wheat_010)
Ljung-Box test
data: Residuals from ARIMA(0,1,0)
Q* = 42.801, df = 10, p-value = 5.398e-06
Model df: 0. Total lags used: 10
Series: wheat.ts
ARIMA(1,1,0)
Coefficients:
ar1
0.0442
s.e. 0.0126
sigma^2 = 0.018: log likelihood = 3696.32
AIC=-7388.64 AICc=-7388.63 BIC=-7375.15
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0005138701 0.1341336 0.08587807 -0.006859185 1.52051 1.002377
ACF1
Training set 0.0004391689
Show the code
checkresiduals(wheat_110)
Ljung-Box test
data: Residuals from ARIMA(1,1,0)
Q* = 29.377, df = 9, p-value = 0.0005596
Model df: 1. Total lags used: 10
The same thing happens for corn futures — we can’t find a model with desirable residuals, and the best-fit model is again the one suggested by auto.arima(): ARIMA(0,1,3).
Show the code
corn_auto <-auto.arima(corn.ts) # ARIMA(0,1,3)summary(corn_auto) # AIC=44553.38 - Best
Series: corn.ts
ARIMA(0,1,3)
Coefficients:
ma1 ma2 ma3
0.0224 -0.0336 0.0306
s.e. 0.0126 0.0128 0.0124
sigma^2 = 0.006979: log likelihood = 6665.26
AIC=-13322.51 AICc=-13322.5 BIC=-13295.54
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0003396775 0.08351438 0.05337484 -0.003958796 1.250147 1.000411
ACF1
Training set -0.0003501423
Show the code
checkresiduals(corn_auto)
Ljung-Box test
data: Residuals from ARIMA(0,1,3)
Q* = 6.7363, df = 7, p-value = 0.4568
Model df: 3. Total lags used: 10
Series: corn.ts
ARIMA(0,1,0)
sigma^2 = 0.006994: log likelihood = 6657.13
AIC=-13312.25 AICc=-13312.25 BIC=-13305.51
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0003461912 0.08362282 0.05334472 -0.004067362 1.249022 0.9998467
ACF1
Training set 0.01987493
Show the code
checkresiduals(corn_010)
Ljung-Box test
data: Residuals from ARIMA(0,1,0)
Q* = 22.91, df = 10, p-value = 0.01108
Model df: 0. Total lags used: 10
Series: corn.ts
ARIMA(2,1,0)
Coefficients:
ar1 ar2
0.0205 -0.0333
s.e. 0.0126 0.0126
sigma^2 = 0.006986: log likelihood = 6661.84
AIC=-13317.67 AICc=-13317.67 BIC=-13297.44
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0003502006 0.08355998 0.05338484 -0.004110672 1.249921 1.000599
ACF1
Training set 0.001095628
Show the code
checkresiduals(corn_210)
Ljung-Box test
data: Residuals from ARIMA(2,1,0)
Q* = 13.726, df = 8, p-value = 0.08919
Model df: 2. Total lags used: 10
For soybean futures, auto.arima() suggests ARIMA(0,1,0) — effectively a random walk — and we cannot find any better fit.
Show the code
soy_auto <-auto.arima(soy.ts) # ARIMA(0,1,0)summary(soy_auto) # AIC=44553.38 - Best
Series: soy.ts
ARIMA(0,1,0)
sigma^2 = 0.03026: log likelihood = 2067.51
AIC=-4133.02 AICc=-4133.02 BIC=-4126.28
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0009589221 0.1739521 0.1116886 0.0007420328 1.106765 0.999847
ACF1
Training set -0.01909734
Show the code
checkresiduals(soy_auto)
Ljung-Box test
data: Residuals from ARIMA(0,1,0)
Q* = 25.398, df = 10, p-value = 0.00464
Model df: 0. Total lags used: 10
Series: soy.ts
ARIMA(0,1,1)
Coefficients:
ma1
-0.0193
s.e. 0.0127
sigma^2 = 0.03026: log likelihood = 2068.67
AIC=-4133.33 AICc=-4133.33 BIC=-4119.85
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.0009780841 0.17392 0.1116465 0.0007637643 1.106114 0.9994705
ACF1
Training set 0.0001024806
Show the code
checkresiduals(soy_110)
Ljung-Box test
data: Residuals from ARIMA(0,1,1)
Q* = 23.742, df = 9, p-value = 0.004729
Model df: 1. Total lags used: 10
V. GARCH Models
Wheat Futures
We fit ARMA(0,0)+GARCH(1,1) and ARMA(1,1)+GARCH(1,1). Running garch_compare() to search over conditional error distributions points to a skewed Student-t (“sstd”). This differs from the ARMA(1,3)+GARCH(1,1) suggested by garchAuto()’s own AIC-based search, but the simpler ARMA(0,0)+GARCH(1,1) specification is preferred here since it aligns with the returns analysis above (auto.arima() suggesting ARIMA(0,0,0) for the mean).
Show the code
# Returns of Wheatwheat.garch_auto =garchAuto(wheat.r, cores=8, trace=TRUE)summary(wheat.garch_auto)
35.71326 0.001944128
Ljung-Box Test R^2 Q(20) 42.52310 0.002362458
LM Arch Test R TR^2 27.50155 0.006539122
Information Criterion Statistics:
AIC BIC SIC HQIC
-5.097382 -5.085546 -5.097388 -5.093280
*---------------------------------*
* GARCH Model Fit *
*---------------------------------*
Conditional Variance Dynamics
-----------------------------------
GARCH Model : sGARCH(1,1)
Mean Model : ARFIMA(1,0,1)
Distribution : sstd
Convergence Problem:
Solver Message:
Ljung-Box test
data: Residuals
Q* = 3.7335, df = 10, p-value = 0.9586
Model df: 0. Total lags used: 10
Show the code
acf(wheat.garch1@fit$residuals, lag.max=100)
Show the code
pacf(wheat.garch1@fit$residuals, lag.max=100)
This plot shows the relationship between the squared residuals and the model’s fitted conditional variance — the two tracking each other is a sanity check that the GARCH variance equation is picking up the volatility clustering visible in the data.
Show the code
plot((wheat.garch1@fit$residuals)^2, type ="l",ylab="Squared Residuals")lines(wheat.garch1@fit$var , col ="green")
Corn and Soybean Futures
We repeat the same process for returns on corn and soybean futures. The best model for each series is again ARMA(0,0)+GARCH(1,1).
Show the code
# Returns of Corncorn_auto =garchAuto(corn.r, cores=8, trace=TRUE)summary(corn_auto)
R^2 Q(10) 7.240327 0.7025785
Ljung-Box Test R^2 Q(15) 10.758165 0.7695417
Ljung-Box Test R^2 Q(20) 18.182881 0.5753623
LM Arch Test R TR^2 10.036841 0.6127285
Information Criterion Statistics:
AIC BIC SIC HQIC
-5.473799 -5.458736 -5.473809 -5.468579
Ljung-Box test
data: Residuals
Q* = 20.965, df = 10, p-value = 0.02134
Model df: 0. Total lags used: 10
Show the code
checkresiduals(soy.garch1@fit)
Ljung-Box test
data: Residuals
Q* = 20.965, df = 10, p-value = 0.02134
Model df: 0. Total lags used: 10
Show the code
acf(soy.garch1@fit$residuals, lag.max=100)
Show the code
pacf(soy.garch1@fit$residuals, lag.max=100)
VI. GARCH Forecasting & Cross-Validation
Using the GARCH models developed above, we forecast into the test window (Oct 1 – Dec 5, 2024) held out earlier. That window is 47 trading days, but ts_ts() below regularizes each irregular trading-day return series onto a full calendar-day scale (inserting NAs for weekends/holidays) — so the actual comparison series ends up 66 points long, not 47. The forecast horizon (test_horizon) is therefore set to the calendar-day span of the test window, not its row count, so the predicted and actual series line up one-to-one for the R² calculation below.
Show the code
test_horizon <-as.numeric(diff(range(testdata$Date))) +1# Forecast Wheatwheat.for <-ugarchforecast(wheat.garch1, n.ahead = test_horizon)wheat.test <-ts_ts(dailyReturn(xts(testdata$Wheat,testdata$Date)))wheat.for.returns <- wheat.for@forecast$seriesForwheat_r_t <-c(tail(ts_ts(wheat.r),150),rep(NA,test_horizon))wheat_r_f <-c(rep(NA,150),wheat.for.returns)wheat_r_a <-c(rep(NA,150),wheat.test)# Forecast Corncorn.for <-ugarchforecast(corn.garch1, n.ahead = test_horizon)corn.test <-ts_ts(dailyReturn(xts(testdata$Corn,testdata$Date)))corn.for.returns <- corn.for@forecast$seriesForcorn_r_t <-c(tail(ts_ts(corn.r),150),rep(NA,test_horizon))corn_r_f <-c(rep(NA,150),corn.for.returns)corn_r_a <-c(rep(NA,150),corn.test)# Forecast Soybeanssoy.for <-ugarchforecast(soy.garch1, n.ahead = test_horizon)soy.test <-ts_ts(dailyReturn(xts(testdata$Soy,testdata$Date)))soy.for.returns <- soy.for@forecast$seriesForsoy_r_t <-c(tail(ts_ts(soy.r),150),rep(NA,test_horizon))soy_r_f <-c(rep(NA,150),soy.for.returns)soy_r_a <-c(rep(NA,150),soy.test)# Plottingpar(mfrow=c(3,1))plot(wheat_r_t, type ="l", main ="Forecasted Wheat Futures Returns")lines(wheat_r_f, col ="orange")lines(wheat_r_a, col ="green")plot(corn_r_t, type ="l", main ="Forecasted Corn Futures Returns")lines(corn_r_f, col ="orange")lines(corn_r_a, col ="green")plot(soy_r_t, type ="l", main ="Forecasted Soybeans Futures Returns")lines(soy_r_f, col ="orange")lines(soy_r_a, col ="green")
Unfortunately, all three models yield negative R-Squared on this held-out window, meaning the forecasted values perform worse than simply predicting the mean return.
A DCC (dynamic conditional correlation) multivariate GARCH model is fit across all three return series to estimate how the correlation between each pair of commodities evolves over time, rather than assuming it is constant.
Show the code
rX <-data.frame(wheat.r, corn.r, soy.r)names(rX)[1] <-"wheat.r"names(rX)[2] <-"corn.r"names(rX)[3] <-"soy.r"# DCC (MVN)uspec.n =multispec(replicate(3, ugarchspec(mean.model =list(armaOrder =c(0,0)))))multf =multifit(uspec.n, rX)spec1 =dccspec(uspec = uspec.n, dccOrder =c(1, 1), distribution ='mvnorm')fit1 =dccfit(spec1, data = rX, fit.control =list(eval.se =TRUE), fit = multf)cov1 =rcov(fit1) # extracts the covariance matrixcor1 =rcor(fit1) # extracts the correlation matrixpar(mfrow=c(3,1))plot(as.xts(cor1[1,2,]),main="Wheat and Corn")plot(as.xts(cor1[1,3,]),main="Wheat and Soybeans")plot(as.xts(cor1[2,3,]),main="Corn and Soybeans")
The forecast correlation is shown below, using the same test_horizon as the GARCH forecasts above for consistency.
Show the code
dccf1 <-dccforecast(fit1, n.ahead = test_horizon)Rf <- dccf1@mforecast$Rcorf_WC <- Rf[[1]][1,2,]corf_WS <- Rf[[1]][1,3,]corf_CS <- Rf[[1]][2,3,]par(mfrow=c(3,1))c_WC <-c(tail(cor1[1,2,],150),rep(NA,test_horizon))cf_WC <-c(rep(NA,150),corf_WC)plot(c_WC,type ="l",main="Correlation between Wheat and Corn Futures")lines(cf_WC,type ="l", col ="orange")c_WS <-c(tail(cor1[1,3,],150),rep(NA,test_horizon))cf_WS <-c(rep(NA,150),corf_WS)plot(c_WS,type ="l",main="Correlation between Wheat and Soybeans Futures")lines(cf_WS,type ="l", col ="orange")c_CS <-c(tail(cor1[2,3,],150),rep(NA,test_horizon))cf_CS <-c(rep(NA,150),corf_CS)plot(c_CS,type ="l",main="Correlation between Corn and Soybeans Futures")lines(cf_CS,type ="l", col ="orange")
VIII. ARIMAX Models
Based on the correlation analysis above, wheat is most strongly correlated with corn (and vice versa), while soybeans are also most strongly correlated with corn. Each commodity’s ARIMA model is therefore extended with its most-correlated peer as an exogenous regressor (xreg). Note this uses TSA::arima() explicitly rather than forecast::Arima(), since TSA::arima() is the version that supports xreg the way used here.
From the analysis above, a few key findings emerge:
No seasonality: neither decompose()/stl(), spectral analysis, nor its bootstrapped confidence intervals turn up significant periodicity in any of the three series — consistent with futures prices tracking macroeconomic conditions rather than a fixed production calendar.
ARIMA: the best-fitting models (ARIMA(1,1,3) for wheat, ARIMA(0,1,3) for corn, ARIMA(0,1,0) — a random walk — for soybeans) all have residuals that are stationary but not independent or normal, so their forecast intervals should not be trusted.
GARCH: ARMA(0,0)+GARCH(1,1) fits all three return series well in-sample, with residuals close to independent and normal. Out-of-sample, however, all three forecasts produced negative R² against the actual Oct–Dec 2024 returns — worse than predicting the mean — so these models were not used for further forecasting.
Multivariate GARCH: the DCC model shows persistent, time-varying correlation across all three commodities, strongest between wheat and corn.
ARIMAX: adding the most-correlated peer commodity as an exogenous regressor produces statistically significant coefficients, but residuals still fail independence/normality checks, so the same forecasting caveats apply.
Taken together, the value of this analysis is less about producing a deployable price forecast and more about demonstrating the discipline of the model-selection process itself — including the willingness to conclude that a model class (GARCH, here) isn’t reliable enough to forecast with, rather than reporting an unvalidated result.