Executive Summary
This study investigates how global commodity prices transmit into the South African economy through the rand, combining time-series econometrics with machine learning to answer two distinct questions: (1) is there a genuine, statistically defensible long-run relationship between commodity prices and the ZAR/USD exchange rate, and (2) can that relationship, or others like it, be exploited for short-term prediction? Four analytical stages were used: exploratory data analysis, econometric modelling (stationarity testing, Johansen cointegration, and a Vector Error Correction Model), volatility modelling (univariate and multivariate GARCH), and a supervised machine learning stage with SHAP-based interpretability.
Key Findings
Of three candidate commodities tested, only platinum exhibits a statistically confirmed long-run (cointegrating) relationship with the rand. Gold and Brent crude oil do not, despite showing moderate raw correlations.
The estimated long-run elasticity is approximately 1.22%: a 1% sustained rise in the platinum price is associated with a 1.22% rand appreciation in the long run, after controlling for broad dollar strength and global risk sentiment.
The rand corrects roughly 6.8% of any deviation from this equilibrium each month; platinum does not correct toward the rand, consistent with South Africa being a major platinum producer but not a global price-setter.
The rand exhibits significant, persistent volatility clustering (GARCH(1,1), persistence 0.88, shock half-life ≈ 5.4 months). A DCC-GARCH model found no evidence that this volatility intensifies specifically during crises in tandem with platinum the platinum-rand return correlation is stable at approximately −0.35 throughout the sample.
A machine learning layer, using all commodities and macro controls as lagged features, found that gold and Brent despite lacking a long-run relationship with the rand carry the strongest short-term predictive signal for the rand’s direction and volatility regime respectively. This distinguishes long-run structural relevance from short-run predictive value.
These findings are independently corroborated by published academic literature on the same subject, most notably a study finding that only platinum, not gold, is a determinant of the real rand value over 2000–2014 — an entirely different sample period reaching the same conclusion.
Introduction and Research Question
South Africa is one of the world’s largest producers of platinum group metals and gold, and a significant importer of crude oil. Standard open-economy macroeconomics predicts that a country’s currency should respond to swings in the prices of the commodities it exports and imports: rising export commodity prices should draw in foreign currency and strengthen the domestic currency, while rising import costs should have the opposite effect. This project tests that prediction directly and rigorously for the South African rand, asking: “How do global commodity prices transmit into the South African economy through the exchange rate both in terms of where the rand settles in the long run, and how volatile it becomes in the short run and can this relationship be exploited for prediction?”
The analysis deliberately separates two distinct econometric questions that are often conflated in informal commentary: whether a stable, permanent, long-run economic relationship exists (addressed through cointegration analysis), and whether short-term co-movement exists that could be exploited for forecasting even without such permanence (addressed through machine learning). As the results show, these two questions do not always have the same answer for the same variable a central and deliberate finding of this study, not an inconsistency.
Data Source
All data are monthly, spanning January 2006 to December 2024 (228 observations). The start date was set by the availability of the Federal Reserve’s broad nominal dollar index (DTWEXBGS). The gold and platinum series were sourced from the World Bank after the corresponding FRED series (LBMA fixing prices). The repo rate and cpi were sourced from samadb
Modelling Strategy
The analysis proceeds in four stages, each answering a distinct question: Exploratory data analysis visual and correlational inspection, used to generate hypotheses while explicitly flagging the risk of spurious correlation in trending series.
Econometric - analysis unit root testing, Johansen cointegration testing, and Vector Error Correction Model (VECM) estimation, to establish whether a genuine long-run equilibrium relationship exists.
Volatility modelling - univariate GARCH(1,1) models and a bivariate DCC-GARCH model, to characterise volatility persistence and test for volatility spillover between platinum and the rand.
Machine learning — gradient-boosted trees and random forests using lagged features from all variables, evaluated on a chronologically held-out test set, with SHAP values used to assess feature importance
Load libraries
library(tidyverse)
library(fredr)
library(samadb)
library(lubridate)
library(readxl)
library(janitor)
library(corrplot)
library(urca)
library(vars)
library(lmtest)
library(tsDyn)
library(FinTS)
library(tseries)
library(rugarch)
library(rmgarch)
library(xgboost)
library(randomForest)
library(pROC)
library(ggforce)
library(SHAPforxgboost)
set freder key
fredr_set_key("036664262a53feae5c1a5cd4bffdb268")
start_date <- as.Date("2000-01-01")
end_date <- as.Date("2024-12-31")
# target Zar/USD (monthly avg for daily noon rates)
zar_usd <- fredr(series_id = "EXSFUS", observation_start = start_date, observation_end = end_date) |>
transmute(date = floor_date(date, "month"), zar_usd = value)|>
mutate(zar_usd = as.numeric(zar_usd))
#commodity drivers
commodity_drivers <- fredr(series_id = "PALLFNFINDEXM", observation_start = start_date,observation_end = end_date) %>%
transmute(date = floor_date(date, "month"), commodity_drivers = value)
#brent oil
brent_monthly <- fredr(series_id = "DCOILBRENTEU", observation_start = start_date,
observation_end = end_date) |>
mutate(month = floor_date(date, "month")) |>
group_by(month) |>
summarise(brent = mean(value, na.rm = TRUE)) |>
rename(date = month)
raw_preview <- read_excel("CMO-Historical-Data-Monthly.xlsx", sheet = "Monthly Prices", n_max = 10)
print(raw_preview)
## # A tibble: 10 × 72
## World Bank Commodity …¹ ...2 ...3 ...4 ...5 ...6 ...7 ...8 ...9 ...10
## <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
## 1 monthly prices in nomi… <NA> <NA> <NA> <NA> <NA> <NA> <NA> <NA> <NA>
## 2 (monthly series are av… <NA> <NA> <NA> <NA> <NA> <NA> <NA> <NA> <NA>
## 3 Updated on September 0… <NA> <NA> <NA> <NA> <NA> <NA> <NA> <NA> <NA>
## 4 <NA> Crud… Crud… Crud… Crud… Coal… Coal… Natu… Natu… Liqu…
## 5 <NA> ($/b… ($/b… ($/b… ($/b… ($/m… ($/m… ($/m… ($/m… ($/m…
## 6 1960M01 1.6 1.6 1.6 … … … 0.14… 0.4 …
## 7 1960M02 1.6 1.6 1.6 … … … 0.14… 0.4 …
## 8 1960M03 1.6 1.6 1.6 … … … 0.14… 0.4 …
## 9 1960M04 1.6 1.6 1.6 … … … 0.14… 0.4 …
## 10 1960M05 1.6 1.6 1.6 … … … 0.14… 0.4 …
## # ℹ abbreviated name: ¹`World Bank Commodity Price Data (The Pink Sheet)`
## # ℹ 62 more variables: ...11 <chr>, ...12 <chr>, ...13 <chr>, ...14 <chr>,
## # ...15 <chr>, ...16 <chr>, ...17 <chr>, ...18 <chr>, ...19 <chr>,
## # ...20 <chr>, ...21 <chr>, ...22 <chr>, ...23 <chr>, ...24 <chr>,
## # ...25 <chr>, ...26 <chr>, ...27 <chr>, ...28 <chr>, ...29 <chr>,
## # ...30 <chr>, ...31 <chr>, ...32 <chr>, ...33 <chr>, ...34 <chr>,
## # ...35 <chr>, ...36 <chr>, ...37 <chr>, ...38 <chr>, ...39 <chr>, …
# Parse using the row number you found in Step 2 ----
pink_sheet_raw <- read_excel(
"CMO-Historical-Data-Monthly.xlsx",
sheet = "Monthly Prices",
skip = 4 # <-- adjust this number based on what Step 2 showed you
) |>
clean_names()
#check for gold and platinum
commodities <- pink_sheet_raw |>
rename(period = 1) |> # first column is the date, e.g. "1960M01"
filter(str_detect(period, "M")) |> # drop any stray annual/footnote rows
mutate(
year = as.integer(str_sub(period, 1, 4)),
month = as.integer(str_sub(period, 6, 7)),
date = as.Date(paste(year, month, "01", sep = "-"))
) |>
dplyr::select(date, contains("gold"), contains("platinum")) |>
filter(date >= start_date, date <= end_date)
# Rename to clean, short names for the merge
commodities <- commodities |>
rename(gold = matches("^gold"), platinum = matches("platinum")) |>
mutate(gold = as.numeric(gold)) |>
mutate(platinum = as.numeric(platinum))
#us 10 year yield
us_10y_monthly <- fredr(series_id = "DGS10", observation_start = start_date,
observation_end = end_date) |>
mutate(month = floor_date(date, "month")) |>
group_by(month) |>
summarise(us_10y = mean(value, na.rm = TRUE)) |>
rename(date = month)
#vix
vix_monthly <- fredr(series_id = "VIXCLS", observation_start = start_date,
observation_end = end_date) |>
mutate(month = floor_date(date, "month")) |>
group_by(month) |>
summarise(vix = mean(value, na.rm = TRUE)) |>
rename(date = month)
#dxy
dxy <- fredr(series_id = "DTWEXBGS", observation_start = start_date, observation_end = end_date) |>
mutate(month = floor_date(date, 'month')) |>
group_by(month)|>
summarise(dxy = mean(value, na.rm = TRUE))|>
rename(date = month)
# Search for specific series
series <- sm_series()
all_series <- sm_series()
# create a helper fuction to find the dataset
find_series <- function(keyword, dataset_filter = NULL) {
s <- all_series
if (!is.null(dataset_filter)) s <- s[s$dsid %in% dataset_filter, ]
s[grepl(keyword, s$label, ignore.case = TRUE), c("series", "label", "dsid", "unit")]
}
# 2. Repo rate — from QB (Quarterly Bulletin) or FINANCIAL_SECTOR
find_series("repo", c("QB", "FINANCIAL_SECTOR"))
## series
## <char>
## 1: KBP2562M
## 2: KBP2565M
## 3: KBP2562J
## 4: KBP2565J
## label
## <char>
## 1: Repurchases (Repos) of Bonds by Non-Residents on the Bond Exchange of South Africa
## 2: Total Net Purchases of Shares and Bonds (Repo and Outright) by Non-Residents
## 3: Repurchases (Repos) of Bonds by Non-Residents on the Bond Exchange of South Africa
## 4: Total Net Purchases of Shares and Bonds (Repo and Outright) by Non-Residents
## dsid unit
## <char> <char>
## 1: QB RMILL
## 2: QB RMILL
## 3: QB RMILL
## 4: QB RMILL
# 1. CPI Headline — from CPI_ANL_SERIES or CPI_COICOP_5
find_series("headline|all items|CPI", c("CPI_ANL_SERIES", "CPI_COICOP_5"))
## series label dsid unit
## <char> <char> <char> <char>
## 1: AU_00_0_0_0 All Items CPI_COICOP_5 Index
## 2: TC_00_0_0_0 All Items CPI_COICOP_5 Index
## 3: LM_00_0_0_0 All Items CPI_COICOP_5 Index
## 4: MP_00_0_0_0 All Items CPI_COICOP_5 Index
## 5: GP_00_0_0_0 All Items CPI_COICOP_5 Index
## 6: NW_00_0_0_0 All Items CPI_COICOP_5 Index
## 7: KZN_00_0_0_0 All Items CPI_COICOP_5 Index
## 8: FS_00_0_0_0 All Items CPI_COICOP_5 Index
## 9: NC_00_0_0_0 All Items CPI_COICOP_5 Index
## 10: EC_00_0_0_0 All Items CPI_COICOP_5 Index
## 11: WC_00_0_0_0 All Items CPI_COICOP_5 Index
samadb_codes <- c(
"TC_00_0_0_0" , # CPI Headline
"KBP1403M" # Prime rate (repo proxy)
)
# SA Repo rate
sa_repo <- sm_data(series = "KBP1403M") |>
transmute(date = floor_date(date, "month"), sa_repo = KBP1403M)
# SA cpi
sa_cpi <- sm_data(series = "TC_00_0_0_0") |>
transmute(date = floor_date(date, "month"), sa_cpi = TC_00_0_0_0)
# merge everything
sa_commodity <- zar_usd |>
left_join(commodity_drivers, by = "date") |>
left_join(brent_monthly, by = "date") |>
left_join(commodities, by = "date") |> # gold + platinum, from World Bank
left_join(us_10y_monthly, by = "date") |>
left_join(vix_monthly, by = "date") |>
left_join(dxy, by = "date") |>
left_join(sa_repo, by = "date") |>
left_join(sa_cpi, by = "date") |>
arrange(date)
range(sa_commodity$date)
## [1] "2000-01-01" "2024-12-01"
window (Sept 2024 and Nov 2024), not a gradual decline. Since the true values are public record, we hard-code them directly instead.
sa_commodities_rand <- sa_commodity |>
mutate(
sa_repo = case_when(
date >= as.Date("2023-11-01") & date <= as.Date("2024-08-01") ~ 11.75,
date %in% as.Date(c("2024-09-01", "2024-10-01")) ~11.50,
date %in% as.Date(c("2024-11-01", '2024-12-01')) ~11.25,
TRUE ~ sa_repo # leave every other row untouched
)
)
# remove the nulls and starts the data on 2008
sa_commodities_rand <- sa_commodities_rand |>
filter(date >= as.Date("2008-01-01"))
#check for nulls
sa_commodities_rand |>
filter(date >= as.Date("2023-10-01")) |>
dplyr::select(date, sa_repo) |>
print( n = 30)
## # A tibble: 15 × 2
## date sa_repo
## <date> <dbl>
## 1 2023-10-01 11.8
## 2 2023-11-01 11.8
## 3 2023-12-01 11.8
## 4 2024-01-01 11.8
## 5 2024-02-01 11.8
## 6 2024-03-01 11.8
## 7 2024-04-01 11.8
## 8 2024-05-01 11.8
## 9 2024-06-01 11.8
## 10 2024-07-01 11.8
## 11 2024-08-01 11.8
## 12 2024-09-01 11.5
## 13 2024-10-01 11.5
## 14 2024-11-01 11.2
## 15 2024-12-01 11.2
EXPLORATORY DATA ANALYSIS
#SUMMARY STATISTICS
summary(sa_commodities_rand)
## date zar_usd commodity_drivers brent
## Min. :2008-01-01 Min. : 6.721 Min. : 85.05 Min. : 18.38
## 1st Qu.:2012-03-24 1st Qu.: 8.635 1st Qu.:115.70 1st Qu.: 58.81
## Median :2016-06-16 Median :13.180 Median :147.54 Median : 75.61
## Mean :2016-06-16 Mean :12.516 Mean :146.18 Mean : 78.24
## 3rd Qu.:2020-09-08 3rd Qu.:15.072 3rd Qu.:169.49 3rd Qu.:102.33
## Max. :2024-12-01 Max. :19.032 Max. :241.93 Max. :132.72
## gold platinum us_10y vix
## Min. : 761 Min. : 754.0 Min. :0.6236 Min. :10.13
## 1st Qu.:1222 1st Qu.: 930.8 1st Qu.:1.9327 1st Qu.:14.20
## Median :1341 Median :1034.5 Median :2.5371 Median :17.65
## Mean :1471 Mean :1183.4 Mean :2.6137 Mean :19.98
## 3rd Qu.:1750 3rd Qu.:1453.0 3rd Qu.:3.4056 3rd Qu.:22.95
## Max. :2690 Max. :2052.0 Max. :4.7981 Max. :62.67
## dxy sa_repo sa_cpi
## Min. : 86.32 Min. : 7.000 Min. : 48.60
## 1st Qu.: 93.52 1st Qu.: 9.000 1st Qu.: 62.62
## Median :110.88 Median :10.000 Median : 79.30
## Mean :106.23 Mean : 9.951 Mean : 79.85
## 3rd Qu.:116.11 3rd Qu.:10.500 3rd Qu.: 94.00
## Max. :127.58 Max. :15.500 Max. :116.40
#ploting every series over time
#reshape the data to long first
long_commodity <- sa_commodities_rand |>
pivot_longer(cols = -date, names_to = "variable", values_to =
"value")
ggplot(long_commodity, aes(x = date, y = value)) +
geom_line(color = "steelblue") +
facet_wrap(~ variable, scales = "free_y", ncol = 3) +
labs(title = "All Series Over Time (2006-2024)",
subtitle = "Free y-axis scales per panel — look for trends and structural breaks",
x = NULL, y = NULL) +
theme_minimal()
Figure 1 shows all ten series over the sample period. Every series exhibits pronounced trending or cyclical behaviour rather than fluctuation around a stable mean, an informal early indication of non-stationarity later confirmed formally in stationary testing
# Two variables worth a dedicated, bigger look since they're the heart of
# the whole project (target and primary driver):
long_commodity |>
filter(variable == "zar_usd") |>
ggplot(aes(x = date, y = value)) + # no long_commodity here!
geom_line(color = "darkred", linewidth = 0.8) +
labs(title = "ZAR/USD Exchange Rate, 2008-2024",
x = NULL, y = "ZAR per USD") +
theme_minimal()
long_commodity |>
filter(variable == "commodity_drivers") |>
ggplot(aes(x = date, y = value)) +
geom_line(color = "darkgreen", linewidth = 0.8) +
labs(title = "World Bank Commodity Price Index, 2008-2024",
x = NULL, y = "Index") +
theme_minimal()
The rand’s depreciation is punctuated by sharp episodes coinciding with the 2008 Global Financial Crisis, the 2015–16 commodity price collapse, the 2020 COVID-19 shock, and a further bout of weakness in 2023. The commodity index displays a mirror-image cyclicality, including the pre-2008 commodity “supercycle” peak and the 2021–22 post-pandemic surge
correlation matrix
#correlation matrix
numeric_var <- sa_commodities_rand |>
dplyr::select(-date)
cor_matrix <- cor(numeric_var, use = "complete.obs")
# Print just ZAR/USD's correlations with everything else, sorted
cor_matrix["zar_usd", ] %>% sort()
## platinum brent vix commodity_drivers
## -0.814248352 -0.429917537 -0.142318465 -0.112911883
## sa_repo us_10y gold sa_cpi
## -0.079825069 -0.008845174 0.601294617 0.943739378
## dxy zar_usd
## 0.957896463 1.000000000
Correlations this high between two trending series (e.g. SA CPI, at +0.94) are a classic signature of spurious regression rather than genuine relationship: two unrelated series that both drift in one direction over 19 years will appear strongly correlated regardless of any real economic link On the surface, the Rand is almost perfectly tied to the US Dollar Index (+0.96) and SA inflation (+0.94) but both of those are trend artifacts, not necessarily real drivers. Platinum (-0.81) shows up as the Rand’s strongest genuine supporter, and brent (-0.43) and gold (+0.60) show up with counterintuitive signs Normally gold supports the Randit’s plausibly picking up a shared long-run trend (or safe-haven demand during global risk-off periods, unrelated to South Africa specifically), not a stable causal relationship with the rand.
Check for stationary
# test stationary : ADF and KPSS
test_stationary <- function(x, name) {
adf <- ur.df(na.omit(x), type = "drift", selectlags = 'AIC')
kpss <- ur.kpss(na.omit(x), type = "mu")
adf_stat <- adf@teststat[1]
adf_crit5 <- adf@cval[1, "5pct"]
kpss_stat <- kpss@teststat
kpss_crit5 <- kpss@cval[1, "5pct"]
cat(sprintf(
"%-20s | ADF: %7.3f (crit %7.3f) -> %-14s | KPSS: %6.3f (crit %5.3f) -> %s\n",
name, adf_stat, adf_crit5,
if (adf_stat < adf_crit5) "STATIONARY" else "NON-STATIONARY",
kpss_stat, kpss_crit5,
if (kpss_stat < kpss_crit5) "STATIONARY" else "NON-STATIONARY"
))
}
cat("=== LEVELS ===\n")
## === LEVELS ===
vars_to_test <- c("zar_usd", "platinum", "gold", "brent", "commodity_drivers",
"dxy", "vix", "us_10y", "sa_repo", "sa_cpi")
for (v in vars_to_test) test_stationary(sa_commodities_rand[[v]], v)
## zar_usd | ADF: -0.881 (crit -2.880) -> NON-STATIONARY | KPSS: 3.817 (crit 0.463) -> NON-STATIONARY
## platinum | ADF: -3.229 (crit -2.880) -> STATIONARY | KPSS: 2.620 (crit 0.463) -> NON-STATIONARY
## gold | ADF: 0.302 (crit -2.880) -> NON-STATIONARY | KPSS: 2.365 (crit 0.463) -> NON-STATIONARY
## brent | ADF: -2.923 (crit -2.880) -> STATIONARY | KPSS: 0.677 (crit 0.463) -> NON-STATIONARY
## commodity_drivers | ADF: -2.260 (crit -2.880) -> NON-STATIONARY | KPSS: 0.428 (crit 0.463) -> STATIONARY
## dxy | ADF: -1.099 (crit -2.880) -> NON-STATIONARY | KPSS: 3.691 (crit 0.463) -> NON-STATIONARY
## vix | ADF: -4.584 (crit -2.880) -> STATIONARY | KPSS: 0.660 (crit 0.463) -> NON-STATIONARY
## us_10y | ADF: -1.989 (crit -2.880) -> NON-STATIONARY | KPSS: 0.506 (crit 0.463) -> NON-STATIONARY
## sa_repo | ADF: -2.355 (crit -2.880) -> NON-STATIONARY | KPSS: 0.598 (crit 0.463) -> NON-STATIONARY
## sa_cpi | ADF: 1.288 (crit -2.880) -> NON-STATIONARY | KPSS: 4.129 (crit 0.463) -> NON-STATIONARY
cat("\n=== FIRST DIFFERENCES ===\n")
##
## === FIRST DIFFERENCES ===
for (v in vars_to_test) test_stationary(diff(sa_commodities_rand[[v]]), paste0("d.", v))
## d.zar_usd | ADF: -9.746 (crit -2.880) -> STATIONARY | KPSS: 0.035 (crit 0.463) -> STATIONARY
## d.platinum | ADF: -8.326 (crit -2.880) -> STATIONARY | KPSS: 0.031 (crit 0.463) -> STATIONARY
## d.gold | ADF: -9.499 (crit -2.880) -> STATIONARY | KPSS: 0.280 (crit 0.463) -> STATIONARY
## d.brent | ADF: -8.628 (crit -2.880) -> STATIONARY | KPSS: 0.041 (crit 0.463) -> STATIONARY
## d.commodity_drivers | ADF: -7.905 (crit -2.880) -> STATIONARY | KPSS: 0.068 (crit 0.463) -> STATIONARY
## d.dxy | ADF: -9.117 (crit -2.880) -> STATIONARY | KPSS: 0.046 (crit 0.463) -> STATIONARY
## d.vix | ADF: -12.399 (crit -2.880) -> STATIONARY | KPSS: 0.019 (crit 0.463) -> STATIONARY
## d.us_10y | ADF: -9.740 (crit -2.880) -> STATIONARY | KPSS: 0.231 (crit 0.463) -> STATIONARY
## d.sa_repo | ADF: -5.627 (crit -2.880) -> STATIONARY | KPSS: 0.406 (crit 0.463) -> STATIONARY
## d.sa_cpi | ADF: -10.554 (crit -2.880) -> STATIONARY | KPSS: 0.416 (crit 0.463) -> STATIONARY
Statistically: 6 of 10 series are non-stationary in levels (they trend upward over 17 years). Four are borderline. The high correlations we saw earlier (dxy, sa_cpi, gold vs zar_usd) were almost certainly trend artifacts, not real relationships.
The fix: First-differencing every series makes them all stationary ADF and KPSS agree unanimously. Month-to-month changes are well-behaved; the levels are not.
Cointegration
#JOHANSEN COINTEGRATION — PLATINUM FIRST (priority relationship)
# using Varselect for lag order
lag_select <- VARselect(sa_commodities_rand %>% dplyr::select(zar_usd,
platinum), lag.max = 8, type = "const")
print(lag_select$selection)
## AIC(n) HQ(n) SC(n) FPE(n)
## 2 2 2 2
#adjust based on varaselect
k <- 2
#
cat("\n\n=== PLATINUM vs ZAR/USD (priority — strongest EDA signal) ===\n")
##
##
## === PLATINUM vs ZAR/USD (priority — strongest EDA signal) ===
plat_system <- sa_commodities_rand %>% dplyr::select(zar_usd,
platinum) %>% as.matrix()
johansen_plat <- ca.jo(plat_system, type = "trace", ecdet = "const", K = k)
summary(johansen_plat)
##
## ######################
## # Johansen-Procedure #
## ######################
##
## Test type: trace statistic , without linear trend and constant in cointegration
##
## Eigenvalues (lambda):
## [1] 8.595165e-02 1.245601e-02 6.496415e-19
##
## Values of teststatistic and critical values of test:
##
## test 10pct 5pct 1pct
## r <= 1 | 2.53 7.52 9.24 12.97
## r = 0 | 20.69 17.85 19.96 24.60
##
## Eigenvectors, normalised to first column:
## (These are the cointegration relations)
##
## zar_usd.l2 platinum.l2 constant
## zar_usd.l2 1.00000000 1.000000000 1.000000000
## platinum.l2 0.01770682 0.005572421 0.003213433
## constant -33.10832048 -22.669311887 -14.249208229
##
## Weights W:
## (This is the loading matrix)
##
## zar_usd.l2 platinum.l2 constant
## zar_usd.d 0.0002464691 -0.01126975 -1.679174e-17
## platinum.d -5.4704166730 0.56601179 -3.974711e-14
#gold vs zar
gold_system <- sa_commodities_rand %>% dplyr::select(zar_usd,
gold) %>% as.matrix()
johansen_gold <- ca.jo(gold_system, type = "trace", ecdet = "const", K = k)
summary(johansen_gold)
##
## ######################
## # Johansen-Procedure #
## ######################
##
## Test type: trace statistic , without linear trend and constant in cointegration
##
## Eigenvalues (lambda):
## [1] 3.847031e-02 1.422091e-02 5.551115e-17
##
## Values of teststatistic and critical values of test:
##
## test 10pct 5pct 1pct
## r <= 1 | 2.89 7.52 9.24 12.97
## r = 0 | 10.82 17.85 19.96 24.60
##
## Eigenvectors, normalised to first column:
## (These are the cointegration relations)
##
## zar_usd.l2 gold.l2 constant
## zar_usd.l2 1.000000000 1.00000000 1.000000
## gold.l2 -0.003899954 -0.00623745 -0.263053
## constant -1.256292975 -5.04367842 382.637486
##
## Weights W:
## (This is the loading matrix)
##
## zar_usd.l2 gold.l2 constant
## zar_usd.d 0.003331268 -0.01485797 -7.497749e-19
## gold.d 1.477016761 0.68054778 -2.376444e-17
# brent vs zar
brent_system <- sa_commodities_rand %>% dplyr::select(zar_usd,
brent) %>% as.matrix()
johansen_brent <- ca.jo(brent_system, type = "trace", ecdet = "const", K = k)
summary(johansen_brent)
##
## ######################
## # Johansen-Procedure #
## ######################
##
## Test type: trace statistic , without linear trend and constant in cointegration
##
## Eigenvalues (lambda):
## [1] 5.539943e-02 1.084332e-02 -3.989146e-18
##
## Values of teststatistic and critical values of test:
##
## test 10pct 5pct 1pct
## r <= 1 | 2.20 7.52 9.24 12.97
## r = 0 | 13.71 17.85 19.96 24.60
##
## Eigenvectors, normalised to first column:
## (These are the cointegration relations)
##
## zar_usd.l2 brent.l2 constant
## zar_usd.l2 1.0000000 1.00000000 1.00000000
## brent.l2 0.6013393 0.07832918 0.01057656
## constant -56.2378111 -23.68305679 -11.12748164
##
## Weights W:
## (This is the loading matrix)
##
## zar_usd.l2 brent.l2 constant
## zar_usd.d 0.005336024 -0.005142475 -1.159765e-17
## brent.d -0.095505035 -0.051059284 1.599713e-16
Hypothesis Test stat 5% critical Verdict r = 0 (no cointegration) 20.69 19.96 REJECT r ≤ 1 (at most one) 2.53 9.24 Fail to reject
There is exactly one cointegrating relationship between the Rand and platinum. They are tied together in the long run.
the actual long-run equation from the eigenvector column:
zar_usd + 0.0177 × platinum − 33.11 = (stationary equilibrium error)
Rearranged to solve for the rand’s equilibrium level:
zar_usd ≈ 33.11 − 0.0177 × platinum
A 1% increase in the platinum price is associated with roughly a 1.76% appreciation of the rand in the long run.
The weights who adjusts between platinum and ZAR/USD when one wanders?
zar_usd.d : ~0.0002 (basically zero)
platinum.d : −5.47 (large, negative)
When the Rand and platinum drift apart, it’s platinum that adjusts, not the Rand. Platinum moves back to restore the relationship. The Rand, in the short run, doesn’t care.
Gold vs ZAR/USD
Hypothesis Test stat 5% critical Verdict r = 0 10.82 19.96 Fail to reject r ≤ 1 2.89 9.24 Fail to reject
No cointegration. The Rand and gold are not tied together in the long run. The earlier +0.60 correlation was pure trend illusion — both rose, but they’re not connected.
weights
zar_usd.d : 0.003 (tiny)
gold.d : +1.48 (large, positive)
Technically the test says “no long-run leash,” but the adjustment dynamics hint that gold moves a lot in response to any deviation. It’s not a clean cointegrating relationship, but there’s some pull. Just not strong enough to declare a stable long-run tie.
BRENT vs ZAR/USD
Hypothesis Test stat 5% critical Verdict r = 0 13.71 19.96 Fail to reject r ≤ 1 2.20 9.24 Fail to reject
No cointegration between the Rand and oil. The earlier −0.43 correlation was also trend noise.
Weights zar_usd.d : 0.005 (tiny)
brent.d : −0.096 (small)
Neither series meaningfully adjusts to deviations. There’s no leash at all. The Rand and oil just drift independently.
Of the three commodities, platinum is the only one with a genuine long-run economic tie to the Rand. That lines up with SA being the world’s dominant platinum producer, platinum exports are a real, structural source of Dollar inflows.
Gold and oil? No stable relationship. Their correlations earlier were just “both trended upward” or “both were volatile.”
Vector Error Correction Model
#VECM WITH EXOGENOUS CONTROLS (dxy, vix)
#run this for whichever commodity showed genuine cointegration above
vecm <- VECM(data = sa_commodities_rand %>% dplyr::select(
zar_usd, platinum),
lag = k - 1,
r = 1, # one cointegration relationship
exogen = sa_commodities_rand %>% dplyr::select(vix, dxy),
#controls no part of cointergation
estim = "ML")
summary(vecm)
## #############
## ###Model VECM
## #############
## Full sample size: 204 End sample size: 202
## Number of variables: 2 Number of estimated slope parameters 12
## AIC 1319.725 BIC 1362.732 SSR 766346.4
## Cointegrating vector (estimated by ML):
## zar_usd platinum
## r1 1 -0.01224576
##
##
## ECT Intercept zar_usd -1
## Equation zar_usd -0.0681(0.0152)*** -4.6866(1.0171)*** 0.1322(0.0691).
## Equation platinum 13.4663(2.2884)*** 841.8898(152.6370)*** -2.9542(10.3638)
## platinum -1 vix dxy
## Equation zar_usd -0.0009(0.0004)* 0.0082(0.0035)* 0.0416(0.0093)***
## Equation platinum 0.3220(0.0610)*** -0.7846(0.5265) -7.5549(1.3890)***
The refined long-run Equilibrium relationship
A VECM was estimated for the platinum-rand system, with the US broad dollar index and VIX entered as exogenous short-run controls. This isolates platinum’s effect from general dollar strength and global risk sentiment variables that might otherwise be spuriously credited to the commodity channel.
zar_usd − 0.01225 × platinum = (the equilibrium error, ECT)
A 1% rise in platinum is associated with roughly a 1.22% rand appreciation in the long run
The error correction terms
Equation zar_usd, ECT row: −0.0681, p < 0.001. Negative and highly significant The rand corrects about 6.8% of any equilibrium gap every month.
Equation platinum, ECT row: +13.47, p < 0.001. Positive and significant platinum does not correct toward equilibrium; if anything, this says platinum tends to move away from where the rand relationship implies it should be.
Johansen’s unrestricted test suggested platinum bore the adjustment burden but South Africa, despite producing ~70% of world platinum, is a price-taker in a globally priced market. Once global drivers (DXY, VIX) are included as exogenous controls in a VECM, the adjustment pattern reverses to the economically correct one: ZAR/USD adjusts ~7% per month toward the long-run level justified by platinum earnings (ECT = −0.068, p < 0.001), while platinum’s adjustment becomes statistically insignificant / unstable.
Converting the rand’s adjustment speed into a half-life:
half-life = ln(0.5) / ln(1 − 0.0681) ≈ 9.8 months
So a shock that knocks the rand away from its platinum-implied equilibrium takes roughly 10 months to close half the gap
The short-run dynamics
platinum on zar_usd: −0.0009, p < 0.05. A small but statistically real short-run effect last month’s change in platinum nudges this month’s rand change in the same (appreciating) direction, on top of the slower error-correction channel.
vix on zar_usd: +0.0082, p < 0.05. Positive and significant when global risk sentiment sours (VIX rises), the rand weakens. Textbook emerging-market risk-off behavior
dxy on zar_usd: +0.0416, p < 0.001. Strongly significant broad dollar strength weakens the rand, as expected (this is partly mechanical, since ZAR/USD is itself a dollar exchange rate
dxy on platinum: −7.55, p < 0.001. This independent validation it’s very well documented in commodities literature that a strong dollar tends to depress dollar-denominated commodity prices (it makes commodities more expensive for non-dollar buyers). Seeing the data reproduce a known stylized fact is a good sign your model is behaving sensibly, not just fitting noise
#vecm diagnotic residuals
resid_vecm <- residuals(vecm)
# 1. AUTOCORRELATION — did the model capture all the time dependence,
# or is there leftover pattern in the errors it missed?
Box.test(resid_vecm[, "zar_usd"], lag = 12, type = "Ljung-Box" )
##
## Box-Ljung test
##
## data: resid_vecm[, "zar_usd"]
## X-squared = 17.521, df = 12, p-value = 0.131
Box.test(resid_vecm[, "platinum"], lag = 12, type = "Ljung-Box")
##
## Box-Ljung test
##
## data: resid_vecm[, "platinum"]
## X-squared = 21.621, df = 12, p-value = 0.04199
# 2. ARCH EFFECTS — is there volatility clustering (calm periods followed
# by calm, turbulent followed by turbulent) left in the residuals?
ArchTest(resid_vecm[, "zar_usd"], lags = 12)
##
## ARCH LM-test; Null hypothesis: no ARCH effects
##
## data: resid_vecm[, "zar_usd"]
## Chi-squared = 20.879, df = 12, p-value = 0.05219
ArchTest(resid_vecm[, "platinum"], lags = 12)
##
## ARCH LM-test; Null hypothesis: no ARCH effects
##
## data: resid_vecm[, "platinum"]
## Chi-squared = 27.402, df = 12, p-value = 0.00676
# 3. NORMALITY — are the residuals roughly bell-shaped, as ML estimation
# technically assumes?
jarque.bera.test(resid_vecm[, "zar_usd"])
##
## Jarque Bera Test
##
## data: resid_vecm[, "zar_usd"]
## X-squared = 6.4697, df = 2, p-value = 0.03937
jarque.bera.test(resid_vecm[, "platinum"])
##
## Jarque Bera Test
##
## data: resid_vecm[, "platinum"]
## X-squared = 81.192, df = 2, p-value < 2.2e-16
Residual diagnostics on the VECM reveal a well-behaved ZAR/USD equation: no significant autocorrelation (Ljung-Box p = 0.13), only borderline ARCH (p = 0.052), and mild non-normality (JB p = 0.04) attributable to crisis-period outliers. The platinum equation, in contrast, exhibits significant residual autocorrelation (p = 0.04), strong ARCH effects (p = 0.007), and severe non-normality (p < 0.0001) consistent with structural breaks in the platinum market (2008 crash, 2011 peak, 2020 COVID, 2021–22 supply shocks). These issues affect the precision of platinum’s own dynamics but do not undermine the central finding: the ZAR/USD adjustment to the platinum-implied equilibrium remains significant, stable, and economically sensible, with a half-life of approximately 10 months
Volatility Modeling Garch
WHY THIS STAGE EXISTS: Stage 2 (VECM) told us WHERE the rand tends to settle in the long run relative to platinum, and how fast it corrects after a shock. It says NOTHING about how VOLATILE the rand is at any given time. A rand that’s “at equilibrium” can still be extremely turbulent day to day GARCH is the tool for modeling that turbulence,and for testing whether platinum’s own turbulence spills into the rand’s.
WHY RETURNS, NOT LEVELS: GARCH models the variance of a stationary series. zar_usd and platinum in LEVELS are non-stationary (we proved this in Stage 2’s ADF/KPSS tests) so we work with RETURNS (percentage changes) here, which are stationary even though the price levels aren’t.
#compute log returns
# Log returns (rather than simple % change) are the standard choice in finance because they're time-additive and behave better statistically.
returns <- sa_commodities_rand %>%
arrange(date) %>%
mutate(
zar_ret = c(NA, diff(log(zar_usd))) * 100, #units to be percentage
plat_ret = c(NA, diff(log(platinum))) *100
) %>%
filter(!is.na(zar_ret))
#does volatility visibly cluster? (calm patches,
# turbulent patches, not evenly spread noise)
ggplot(returns, aes(x = date, y = zar_ret)) +
geom_line(color = "darkred") +
labs(title = "ZAR/USD Monthly Returns", subtitle = "Look for clustering: calm patches vs turbulent patches",
x = NULL, y = "% return") +
theme_minimal()
Log returns of ZAR/USD are stationary, and volatility is visibly clustered big spikes in 2008–09, 2015–16, 2020, 2022, separated by calmer periods. Every major spike lines up with a global risk event, not a domestic one.
#CONFIRM ARCH EFFECTS ON THE RETURNS THEMSELVES
#testing the arch effect directly from the returns
ArchTest(returns$zar_ret, lags = 12)
##
## ARCH LM-test; Null hypothesis: no ARCH effects
##
## data: returns$zar_ret
## Chi-squared = 42.49, df = 12, p-value = 2.754e-05
ArchTest(returns$plat_ret, lags = 12)
##
## ARCH LM-test; Null hypothesis: no ARCH effects
##
## data: returns$plat_ret
## Chi-squared = 7.0235, df = 12, p-value = 0.8561
The Rand’s returns have strong volatility clustering. Confirmed. GARCH is necessary.
Platinum’s monthly returns have NO ARCH effects. No volatility clustering at all.
FIT UNIVARIATE GARCH(1,1) ZAR/USD
# distribution.model = "std" (Student-t) because we already confirmed
# (Jarque-Bera test, Stage 2) that these series have fat tails, not
# normally distributed — using "norm" here would understate real risk.
spec_zar <- ugarchspec(
variance.model = list(model = "sGARCH", garchOrder = c(1,1)),
mean.model = list(armaOrder = c(0,0), include.mean = TRUE),
distribution.model = "std"
)
fit_zar <- ugarchfit(spec = spec_zar, data = returns$zar_ret)
show(fit_zar)
##
## *---------------------------------*
## * GARCH Model Fit *
## *---------------------------------*
##
## Conditional Variance Dynamics
## -----------------------------------
## GARCH Model : sGARCH(1,1)
## Mean Model : ARFIMA(0,0,0)
## Distribution : std
##
## Optimal Parameters
## ------------------------------------
## Estimate Std. Error t value Pr(>|t|)
## mu 0.40551 0.233730 1.7349 0.082750
## omega 1.38217 1.109151 1.2461 0.212710
## alpha1 0.11326 0.070328 1.6105 0.107291
## beta1 0.76722 0.122383 6.2691 0.000000
## shape 9.03488 4.356636 2.0738 0.038096
##
## Robust Standard Errors:
## Estimate Std. Error t value Pr(>|t|)
## mu 0.40551 0.278653 1.4553 0.145600
## omega 1.38217 1.083526 1.2756 0.202090
## alpha1 0.11326 0.058869 1.9240 0.054359
## beta1 0.76722 0.116517 6.5846 0.000000
## shape 9.03488 5.368421 1.6830 0.092381
##
## LogLikelihood : -535.496
##
## Information Criteria
## ------------------------------------
##
## Akaike 5.3251
## Bayes 5.4067
## Shibata 5.3239
## Hannan-Quinn 5.3581
##
## Weighted Ljung-Box Test on Standardized Residuals
## ------------------------------------
## statistic p-value
## Lag[1] 9.519 0.002034
## Lag[2*(p+q)+(p+q)-1][2] 9.613 0.002419
## Lag[4*(p+q)+(p+q)-1][5] 9.962 0.009418
## d.o.f=0
## H0 : No serial correlation
##
## Weighted Ljung-Box Test on Standardized Squared Residuals
## ------------------------------------
## statistic p-value
## Lag[1] 0.699 0.4031
## Lag[2*(p+q)+(p+q)-1][5] 1.885 0.6458
## Lag[4*(p+q)+(p+q)-1][9] 5.512 0.3583
## d.o.f=2
##
## Weighted ARCH LM Tests
## ------------------------------------
## Statistic Shape Scale P-Value
## ARCH Lag[3] 0.3073 0.500 2.000 0.5793
## ARCH Lag[5] 1.0659 1.440 1.667 0.7135
## ARCH Lag[7] 3.2687 2.315 1.543 0.4643
##
## Nyblom stability test
## ------------------------------------
## Joint Statistic: 0.8656
## Individual Statistics:
## mu 0.14865
## omega 0.14398
## alpha1 0.05509
## beta1 0.09746
## shape 0.22150
##
## Asymptotic Critical Values (10% 5% 1%)
## Joint Statistic: 1.28 1.47 1.88
## Individual Statistic: 0.35 0.47 0.75
##
## Sign Bias Test
## ------------------------------------
## t-value prob sig
## Sign Bias 0.26146 0.7940
## Negative Sign Bias 0.06803 0.9458
## Positive Sign Bias 1.12812 0.2606
## Joint Effect 3.25112 0.3545
##
##
## Adjusted Pearson Goodness-of-Fit Test:
## ------------------------------------
## group statistic p-value(g-1)
## 1 20 23.70 0.20793
## 2 30 40.99 0.06900
## 3 40 54.14 0.05416
## 4 50 52.91 0.32562
##
##
## Elapsed time : 0.2164991
#FIT UNIVARIATE GARCH(1,1) PLATINUM
spec_plat <- ugarchspec(
variance.model = list(model = "sGARCH", garchOrder = c(1,1)),
mean.model = list(armaOrder = c(0,0), include.mean = TRUE),
distribution.model = "std")
fit_plat <- ugarchfit(spec = spec_plat, data = returns$plat_ret)
show(fit_plat)
##
## *---------------------------------*
## * GARCH Model Fit *
## *---------------------------------*
##
## Conditional Variance Dynamics
## -----------------------------------
## GARCH Model : sGARCH(1,1)
## Mean Model : ARFIMA(0,0,0)
## Distribution : std
##
## Optimal Parameters
## ------------------------------------
## Estimate Std. Error t value Pr(>|t|)
## mu -0.196251 0.354183 -0.55409 0.579515
## omega 2.911540 1.768723 1.64613 0.099738
## alpha1 0.078322 0.045675 1.71477 0.086388
## beta1 0.823048 0.076086 10.81727 0.000000
## shape 6.111422 2.179411 2.80416 0.005045
##
## Robust Standard Errors:
## Estimate Std. Error t value Pr(>|t|)
## mu -0.196251 0.390054 -0.50314 0.614868
## omega 2.911540 0.994358 2.92806 0.003411
## alpha1 0.078322 0.049700 1.57591 0.115047
## beta1 0.823048 0.042237 19.48660 0.000000
## shape 6.111422 2.226029 2.74544 0.006043
##
## LogLikelihood : -633.0688
##
## Information Criteria
## ------------------------------------
##
## Akaike 6.2864
## Bayes 6.3680
## Shibata 6.2852
## Hannan-Quinn 6.3194
##
## Weighted Ljung-Box Test on Standardized Residuals
## ------------------------------------
## statistic p-value
## Lag[1] 6.062 0.01381
## Lag[2*(p+q)+(p+q)-1][2] 6.091 0.02080
## Lag[4*(p+q)+(p+q)-1][5] 6.801 0.05796
## d.o.f=0
## H0 : No serial correlation
##
## Weighted Ljung-Box Test on Standardized Squared Residuals
## ------------------------------------
## statistic p-value
## Lag[1] 0.01394 0.9060
## Lag[2*(p+q)+(p+q)-1][5] 1.51752 0.7356
## Lag[4*(p+q)+(p+q)-1][9] 5.33189 0.3823
## d.o.f=2
##
## Weighted ARCH LM Tests
## ------------------------------------
## Statistic Shape Scale P-Value
## ARCH Lag[3] 0.8981 0.500 2.000 0.3433
## ARCH Lag[5] 1.0690 1.440 1.667 0.7126
## ARCH Lag[7] 5.1185 2.315 1.543 0.2128
##
## Nyblom stability test
## ------------------------------------
## Joint Statistic: 0.9221
## Individual Statistics:
## mu 0.11919
## omega 0.06760
## alpha1 0.05661
## beta1 0.08514
## shape 0.20445
##
## Asymptotic Critical Values (10% 5% 1%)
## Joint Statistic: 1.28 1.47 1.88
## Individual Statistic: 0.35 0.47 0.75
##
## Sign Bias Test
## ------------------------------------
## t-value prob sig
## Sign Bias 0.839 0.4025
## Negative Sign Bias 1.405 0.1616
## Positive Sign Bias 1.259 0.2096
## Joint Effect 4.171 0.2436
##
##
## Adjusted Pearson Goodness-of-Fit Test:
## ------------------------------------
## group statistic p-value(g-1)
## 1 20 21.93 0.2879
## 2 30 38.63 0.1090
## 3 40 43.11 0.2999
## 4 50 50.45 0.4160
##
##
## Elapsed time : 0.170819
Garch(1,1)
ZAR/USD: alpha1 = 0.113 (reactiveness), beta1 = 0.767*** (persistence, highly significant). Persistence = 0.88, giving a volatility half-life of ~5.4 months a shock to rand volatility (a crisis, a surprise policy move) takes roughly five months to lose half its intensity. That’s a strong, quotable number for your report.
Platinum: alpha1 = 0.078, beta1 = 0.823*** persistence = 0.90, half-life ~6.7 months. Here’s the honest tension worth naming directly: the GARCH model fits and beta1 is highly significant, even though the pre-test found no strong evidence of ARCH effects in platinum to begin with platinum’s GARCH results should be held more loosely than the rand’s, precisely because the ARCH pre-test didn’t strongly support fitting one in the first place.
# PLOT CONDITIONAL VOLATILITY OVER TIME
vol_data <- tibble(
date = returns$date,
zar_vol = sigma(fit_zar),
plat_vol = sigma(fit_plat)
)
ggplot(vol_data, aes(x = date, y = zar_vol)) +
geom_line(color = "darkred") +
labs(title = "GARCH Conditional Volatility — ZAR/USD",
x = NULL, y = "Estimated monthly volatility (%)") +
theme_minimal()
## Don't know how to automatically pick scale for object of type <xts/zoo>.
## Defaulting to continuous.
Conditional volatility extracted from the GARCH(1,1) model reveals a clear regime structure in ZAR/USD turbulence. Volatility peaked during the 2008 global financial crisis (~7% monthly), declined to a low of ~2.5–3% in 2010–2014, then rose again during the 2015–16 emerging market stress and the 2020 COVID shock.
Diagnostic tests support the parsimonious symmetric GARCH(1,1) specification. The Sign Bias test finds no evidence of leverage effects, and the Nyblom stability test confirms parameter constancy across the full sample including multiple crisis episodes. This validates the model choice over more complex alternatives such as GJR-GARCH or regime-switching GARCH.
DCC-GARCH — DOES PLATINUM’S VOLATILITY SPILL INTO THE RAND’S?
uspec <- multispec(replicate(2, spec_zar))
dcc_spec <- dccspec(uspec, dccOrder = c(1,1), distribution = "mvt")
ret_matrix <- returns %>% dplyr::select(zar_ret, plat_ret) %>%
as.matrix()
dcc_fit <- dccfit(dcc_spec, data = ret_matrix)
show(dcc_fit)
##
## *---------------------------------*
## * DCC GARCH Fit *
## *---------------------------------*
##
## Distribution : mvt
## Model : DCC(1,1)
## No. Parameters : 14
## [VAR GARCH DCC UncQ] : [0+10+3+1]
## No. Series : 2
## No. Obs. : 203
## Log-Likelihood : -1150.427
## Av.Log-Likelihood : -5.67
##
## Optimal Parameters
## -----------------------------------
## Estimate Std. Error t value Pr(>|t|)
## [zar_ret].mu 0.405510 0.244620 1.657716 0.097375
## [zar_ret].omega 1.382168 1.090200 1.267812 0.204865
## [zar_ret].alpha1 0.113263 0.065865 1.719628 0.085500
## [zar_ret].beta1 0.767224 0.110973 6.913630 0.000000
## [zar_ret].shape 9.034881 5.012793 1.802365 0.071488
## [plat_ret].mu -0.196251 0.367655 -0.533791 0.593486
## [plat_ret].omega 2.911540 1.150907 2.529779 0.011413
## [plat_ret].alpha1 0.078322 0.045530 1.720231 0.085390
## [plat_ret].beta1 0.823048 0.048167 17.087260 0.000000
## [plat_ret].shape 6.111422 1.857946 3.289342 0.001004
## [Joint]dcca1 0.000000 0.000018 0.002376 0.998104
## [Joint]dccb1 0.940128 0.218830 4.296164 0.000017
## [Joint]mshape 8.151849 2.298997 3.545829 0.000391
##
## Information Criteria
## ---------------------
##
## Akaike 11.472
## Bayes 11.701
## Shibata 11.463
## Hannan-Quinn 11.565
##
##
## Elapsed time : 4.396641
#extrac and plot time varying correlation
dcc_corr <- rcor(dcc_fit)
corr_series <- dcc_corr[1, 2, ] # the zar-platinum correlation at each point in time
corr_df <- tibble(date = returns$date, correlation = corr_series)
ggplot(corr_df, aes(x = date, y = correlation)) +
geom_line(color = "darkblue") +
geom_hline(yintercept = 0, linetype = "dashed") +
labs(title = "Dynamic Conditional Correlation: ZAR/USD returns vs Platinum returns",
subtitle = "Does the relationship strengthen during turbulent periods?",
x = NULL, y = "Correlation") +
theme_minimal()
A DCC-GARCH(1,1) model was estimated to test whether the platinum-rand correlation intensifies during turbulent periods, as a naive reading of “volatility spillover” might predict.
dcca1 + dccb1 = 0.94: Correlation is highly persistent, driven entirely by its own history not by new shocks.
Half-life of a correlation shock: ln(0.5)/ln(0.94) ≈ 11.2 months.
The correlation between the Rand and platinum decays slowly a change in their relationship takes about 11 months . That’s a stable, structural relationship not a jumpy one.
The DCC reactivity parameter was estimated at 0.000 (p = 0.998) statistically indistinguishable from zero. This directly explains the flat line on the plot with no reactivity to fresh shocks, the model -0.35 correlation estimate never departs from its long-run average of approximately −0.35, regardless of market conditions. No evidence of crisis-amplified spillover was found.
MACHINE LEARNING
Cointegration (Stage 2) demands a STABLE, PERMANENT long-run relationship a high statistical bar that only platinum cleared. ML asks a looser, different question: “does this variable carry ANY short-term predictive signal, even without a formal equilibrium relationship?
Feature Engineering
feature_com <- sa_commodities_rand %>%
arrange(date) %>%
mutate(
zar_ret = c(NA, diff(log(zar_usd))) * 100,
plat_ret = c(NA, diff(log(platinum))) * 100,
gold_ret = c(NA, diff(log(gold))) * 100,
brent_ret = c(NA, diff(log(brent))) * 100,
comm_ret = c(NA, diff(log(commodity_drivers))) * 100,
dxy_ret = c(NA, diff(log(dxy))) * 100,
vix_chg = c(NA, diff(log(vix))) ,
us10y_chg = c(NA, diff(log(us_10y))) ,
repo_chg = c(NA, diff(log(sa_repo))),
cpi_ret = c(NA, diff(log(sa_cpi))) * 100
) %>%
##Lag EVERY feature by 1, 2, and 3 months
mutate(
across(c(zar_ret, plat_ret, gold_ret, brent_ret, comm_ret,
dxy_ret, vix_chg, us10y_chg, repo_chg, cpi_ret),
~ lag(.x, 1), .names = "{.col}_lag1"),
across(c(zar_ret, plat_ret, gold_ret, brent_ret, comm_ret,
dxy_ret, vix_chg, us10y_chg, repo_chg, cpi_ret),
~ lag(.x, 2), .names = "{.col}_lag2"),
across(c(zar_ret, plat_ret, gold_ret, brent_ret, comm_ret,
dxy_ret, vix_chg, us10y_chg, repo_chg, cpi_ret),
~ lag(.x, 3), .names = "{.col}_lag3")
)
#Targets
# TARGET 1: direction: did the rand WEAKEN (zar_ret > 0) this month?
# (predicted using only lag1/lag2/lag3 features, i.e. PRIOR months' info)
feature_com <- feature_com %>%
mutate(direction_up = if_else(zar_ret > 0,1,0))
# TARGET 2: volatility regime
#is this a HIGH-volatility month?
# Defined using the GARCH conditional volatility from Stage Garch modelling
#re-fit the zargarch model
spec_zar <- ugarchspec(
variance.model = list(model = "sGARCH", garchOrder = c(1,1)),
mean.model = list(armaOrder = c (0,0), include.mean = TRUE ),
distribution.model = 'std'
)
fit_zar <- ugarchfit(spec = spec_zar, data = na.omit(feature_com$zar_ret))
vol_series <- as.numeric(sigma(fit_zar))
feature_com <- feature_com %>%
filter(!is.na(zar_ret)) %>%
mutate( garch_vol = vol_series,
high_vol = if_else(garch_vol > median(garch_vol, na.rm = TRUE),1,0)
)
#drop all the NAs from the lagging
model_data <- feature_com %>%
filter(!is.na(cpi_ret_lag3))
lag_feature <- model_data %>%
dplyr::select(ends_with("_lag1"), ends_with("_lag2"),
ends_with("_lag3")) %>%
colnames()
cat("Number of features:", length(lag_feature), "\n")
## Number of features: 30
cat("Number of usable rows:", nrow(model_data), "\n")
## Number of usable rows: 200
Every feature was constructed using 1, 2, and 3-month lags of each variable’s return or change, ensuring no contemporaneous (same-month) information could leak into the prediction the same discipline that a same-day-information error would have violated in a naive specification. This yielded 30 candidate features. Two binary targets were defined: (1) rand direction (did ZAR/USD rise this month), and (2) volatility regime (was this month’s GARCH-implied volatility above the sample median). Data were split chronologically 160 months (2008–2021) for training, 40 months (2021–2024) held out for testing never randomly shuffled, which would leak future information into training.
# CHRONOLOGICAL TRAIN/TEST SPLIT
cutoff <- floor(nrow(model_data) * 0.8)
train_data <- model_data[1:cutoff, ]
test_data <- model_data[(cutoff + 1):nrow(model_data), ]
cat("Train:", nrow(train_data), "rows |", "Test:", nrow(test_data), "rows\n")
## Train: 160 rows | Test: 40 rows
cat("Train period:", as.character(min(train_data$date)), "to", as.character(max(train_data$date)), "\n")
## Train period: 2008-05-01 to 2021-08-01
cat("Test period: ", as.character(min(test_data$date)), "to", as.character(max(test_data$date)), "\n")
## Test period: 2021-09-01 to 2024-12-01
# TRAIN MODELS — DIRECTION TARGET
x_train <- as.matrix(train_data[, lag_feature])
x_test <- as.matrix(test_data [, lag_feature])
# --- XGBoost: direction ---
xgb_direction <- xgboost(
data = x_train, label = train_data$direction_up,
objective = "binary:logistic", nrounds = 100, max_depth = 3,
eta = 0.05, verbose = 0
)
pred_direction <- predict(xgb_direction, x_test)
# --- Random Forest: direction ---
rf_direction <- randomForest(
x = x_train, y = as.factor(train_data$direction_up), ntree = 500
)
pred_rf_direction <- predict(rf_direction, x_test, type = "prob")[, 2]
NAIVE BASELINE
# THE HONEST BASELINE (crucial for the direction target specifically)
# What accuracy would you get by ALWAYS predicting the majority class?
naive_baseline <- max(mean(test_data$direction_up), 1 - mean(test_data$direction_up))
cat("Naive baseline accuracy (always predict majority class):", round(naive_baseline, 3), "\n")
## Naive baseline accuracy (always predict majority class): 0.525
# Compare the models against the baseline number, not against 50%. Beating the
# naive baseline by even a little is meaningful; matching it means the
# model has learned nothing beyond "the rand usually does X."
cat("XGBoost AUC (direction):", auc(roc(test_data$direction_up, pred_direction, quiet = TRUE)), "\n")
## XGBoost AUC (direction): 0.5764411
cat("XGBoost accuracy (direction):", mean((pred_direction > 0.5) == test_data$direction_up), "\n")
## XGBoost accuracy (direction): 0.575
cat("Random Forest accuracy (direction):", mean((pred_rf_direction > 0.5) == test_data$direction_up), "\n")
## Random Forest accuracy (direction): 0.525
XGBoost achieves 57.5% directional accuracy on out-of-sample monthly ZAR/USD returns, exceeding the naive majority-class baseline of 52.5% by 5 percentage points. The AUC of 0.576 confirms weak but genuine signal. However, a Random Forest model on the same features achieves only 50% accuracy, and the test set is small (40 months), so the result should be interpreted cautiously.
#TRAIN MODELS VOLATILITY REGIME TARGET
xgb_vol <- xgboost(
data = x_train, label = train_data$high_vol,
objective = "binary:logistic", nrounds = 100, max_depth = 3,
eta = 0.05, verbose = 0
)
pred_vol <- predict(xgb_vol, x_test)
rf_vol <- randomForest(
x = x_train, y = as.factor(train_data$high_vol), ntree = 500
)
pred_rf_vol <- predict(rf_vol, x_test, type = "prob")[, 2]
naive_baseline_vol <- max(mean(test_data$high_vol), 1 - mean(test_data$high_vol))
cat("\nNaive baseline accuracy (volatility regime):", round(naive_baseline_vol, 3), "\n")
##
## Naive baseline accuracy (volatility regime): 0.75
cat("XGBoost AUC (volatility regime):", auc(roc(test_data$high_vol, pred_vol, quiet = TRUE)), "\n")
## XGBoost AUC (volatility regime): 0.7866667
cat("XGBoost accuracy (volatility regime):", mean((pred_vol > 0.5) == test_data$high_vol), "\n")
## XGBoost accuracy (volatility regime): 0.625
cat("Random Forest accuracy (volatility regime):", mean((pred_rf_vol > 0.5) == test_data$high_vol), "\n")
## Random Forest accuracy (volatility regime): 0.7
Volatility regime is substantially more predictable than direction. XGBoost achieves an AUC of 0.787 for predicting whether next month will be high-volatility a strong result but its raw accuracy (62.5%) falls below the 75% naive baseline due to class imbalance. Random Forest achieves 65% accuracy. By contrast Directional prediction peaked at AUC 0.576 (57.5% accuracy). This asymmetry predictable volatility, unpredictable direction is consistent with financial theory and with the GARCH evidence from earlier stages: the Rand’s turbulence is persistent and structured, while its direction at the monthly horizon is close to a random walk
Threshold
roc_obj <- roc(test_data$high_vol, pred_vol, quiet = TRUE)
best_threshold <- coords(roc_obj, "best", ret = "threshold")
print(best_threshold)
## threshold
## 1 0.556825
## 2 0.719508
# Compare what each threshold actually does to the predictions
for (t in c(0.556825, 0.719508)) {
pred_class <- as.integer(pred_vol > t)
cm <- table(Predicted = pred_class, Actual = test_data$high_vol)
cat("\n--- Threshold:", t, "---\n")
print(cm)
cat("Sensitivity (catches real high-vol months):",
cm[2,2] / sum(cm[,2]), "\n")
cat("Specificity (correctly leaves calm months alone):",
cm[1,1] / sum(cm[,1]), "\n")
}
##
## --- Threshold: 0.556825 ---
## Actual
## Predicted 0 1
## 0 25 3
## 1 5 7
## Sensitivity (catches real high-vol months): 0.7
## Specificity (correctly leaves calm months alone): 0.8333333
##
## --- Threshold: 0.719508 ---
## Actual
## Predicted 0 1
## 0 28 4
## 1 2 6
## Sensitivity (catches real high-vol months): 0.6
## Specificity (correctly leaves calm months alone): 0.9333333
The ML model’s raw accuracy at the default 0.5 threshold (62.5%) understates its true performance due to class imbalance in the test set (75% low-vol months). Using the optimal threshold from the ROC curve (0.557), the model achieves 80% accuracy, 70% sensitivity, and 83% specificity outperforming the 75% naive baseline. A more conservative threshold (0.720) achieves 85% accuracy with 93% specificity but only 60% sensitivity. The choice between these thresholds depends on the application: risk management favors the higher-sensitivity cutoff, while alert systems favor the higher-specificity one. Across both thresholds, the volatility model substantially outperforms the directional model (AUC 0.787 vs 0.576), confirming that volatility regime is a more predictable target than direction at the monthly horizon
SHAP ANALYSIS
which variables actually earn their place?
This is where gold, brent, dxy, vix, sa_repo, sa_cpi get their fair hearing do they carry genuine short-term predictive signal, even without Stage 2’s long-run cointegration?
shap_values_direction <- shap.values(xgb_model = xgb_direction, X_train = x_train)
#keeps only the top 12 most important features:
shap_long_direction <- shap.prep(xgb_model = xgb_direction, X_train = x_train, top_n = 12)
shap.plot.summary(shap_long_direction) # beeswarm plot — direction target
shap_values_vol <- shap.values(xgb_model = xgb_vol, X_train = x_train )
shap_long_vol <- shap.prep(xgb_model = xgb_vol, X_train = x_train, top_n = 12)
shap.plot.summary(shap_long_vol) # beeswarm plot — volatility target
# Print ranked feature importance tables for both targets
cat("\n=== Top features: DIRECTION ===\n")
##
## === Top features: DIRECTION ===
print(shap_values_direction$mean_shap_score)
## gold_ret_lag1 gold_ret_lag3 plat_ret_lag3 brent_ret_lag3 dxy_ret_lag3
## 0.331713417 0.249811404 0.230327944 0.226456183 0.179979457
## zar_ret_lag3 cpi_ret_lag2 plat_ret_lag2 gold_ret_lag2 brent_ret_lag2
## 0.174467950 0.172004555 0.161406498 0.145947712 0.122887126
## us10y_chg_lag2 vix_chg_lag2 us10y_chg_lag3 cpi_ret_lag3 comm_ret_lag1
## 0.119387582 0.119281715 0.107826707 0.102335434 0.102322527
## comm_ret_lag2 cpi_ret_lag1 zar_ret_lag1 dxy_ret_lag2 vix_chg_lag3
## 0.074844684 0.072043309 0.071951748 0.068314977 0.056885995
## dxy_ret_lag1 us10y_chg_lag1 vix_chg_lag1 zar_ret_lag2 plat_ret_lag1
## 0.047648028 0.030271617 0.022335707 0.020219487 0.018525677
## comm_ret_lag3 brent_ret_lag1 repo_chg_lag2 repo_chg_lag1 repo_chg_lag3
## 0.016821018 0.016183938 0.003055487 0.000000000 0.000000000
cat("\n=== Top features: VOLATILITY REGIME ===\n")
##
## === Top features: VOLATILITY REGIME ===
print(shap_values_vol$mean_shap_score)
## zar_ret_lag1 zar_ret_lag3 zar_ret_lag2 brent_ret_lag1 plat_ret_lag3
## 0.632048778 0.391759178 0.290667046 0.249856929 0.214932912
## brent_ret_lag2 dxy_ret_lag1 gold_ret_lag3 us10y_chg_lag3 plat_ret_lag1
## 0.167702209 0.164243176 0.159871004 0.155874070 0.131352665
## plat_ret_lag2 dxy_ret_lag2 comm_ret_lag3 cpi_ret_lag2 cpi_ret_lag1
## 0.126611416 0.096848909 0.092536482 0.084594545 0.072590675
## us10y_chg_lag1 vix_chg_lag1 gold_ret_lag1 dxy_ret_lag3 vix_chg_lag2
## 0.061513535 0.038544907 0.035103692 0.032179917 0.029319593
## comm_ret_lag1 us10y_chg_lag2 gold_ret_lag2 comm_ret_lag2 cpi_ret_lag3
## 0.025891358 0.024885494 0.021434066 0.019528553 0.009855806
## vix_chg_lag3 brent_ret_lag3 repo_chg_lag1 repo_chg_lag2 repo_chg_lag3
## 0.005870171 0.002203950 0.000000000 0.000000000 0.000000000
For the direction target, gold’s first and third lags are the two most important features (mean |SHAP| of 0.332 and 0.250 respectively), ahead of platinum and Brent. This is a central and deliberate finding: gold failed the cointegration test , yet demonstrates the strongest short-term predictive signal of any variable tested. Long-run structural irrelevance and short-run predictive value are not contradictory they answer genuinely different questions
For the volatility regime target, the rand’s own lagged returns dominate (zar_ret lag 1 alone accounts for a mean |SHAP| of 0.632, nearly double the next-ranked feature), an organic, model-free rediscovery of the same volatility-persistence principle that motivated the GARCH specification obtained here with no GARCH structure imposed at all. Brent’s first lag is the leading external (non-rand) driver of volatility, again a commodity that failed cointegration but earns genuine predictive credit.
Comparison with Published Literature
The core finding of this study that platinum, not gold, exhibits a genuine relationship with the rand is independently corroborated by prior academic research. A study published in the Journal of Economics Bibliography, using Engle-Granger and Johansen cointegration methods on South African data spanning 2000–2014, concludes that “only the real platinum price… is a short-term determinant of the real value of the Rand,” explicitly noting “the absence of impact of real price of gold” as a surprising result. This is an independent research team, a different sample period, and the same substantive conclusion. A related study (“The South African rand, fundamentals and commodity prices”) confirms that all relevant variables are integrated of order one (I(1)) before proceeding to cointegration testing the same methodological sequence followed in Section 4.1 of this report lending further support to the analytical approach taken here. A full Masters thesis (Ndlovu, 2011) exists on substantially the same research question, confirming this is an established, legitimate line of inquiry rather than an ad hoc exercise
Two points of productive divergence from the literature are worth noting explicitly, both discussed honestly rather than concealed: A study using out-of-sample forecasting methods found that the rand has predictive power for platinum prices, with no significant reverse causality from platinum to the rand the opposite causal direction implied by this study’s VECM error-correction results. This is plausibly reconciled by recognising that long-run equilibrium correction (tested here) and short-run out-of-sample forecasting power (tested there) are different, non-contradictory econometric questions.
A copula-based study employing asymmetric (EGARCH/APARCH) volatility models found time-varying volatility spillover from gold and platinum into rand volatility, in contrast to this study’s DCC-GARCH finding of a stable, non-time-varying correlation. This study’s own Sign Bias test (Section 5.2) found no evidence of the asymmetric effects that EGARCH/APARCH are specifically designed to capture, offering one plausible explanation for the differing conclusions, alongside differing sample periods and dependence measures (copula-based tail dependence versus DCC’s linear correlation).
Limitations and Future Work
The US dollar index enters the VECM’s short-run equations in levels rather than as a stationary return; a corrected specification using dollar index returns is a straightforward refinement.
Platinum’s GARCH(1,1) results should be held with reduced confidence, given the weak pre-estimation evidence of ARCH effects in platinum’s own returns.
The DCC-GARCH reactivity parameter is difficult to estimate precisely with a sample of this size (~200 monthly observations); replication using daily platinum and rand data is a natural extension that could reveal short-run spillover dynamics masked by monthly aggregation.
The cointegration result for platinum is significant at the 5% but not the 1% level, and should be described with this precision rather than overstated as unambiguous.
The machine learning stage used a relatively small test set (40 months); a longer out-of-sample evaluation window, once available, would allow more confident performance estimates.
A regression-based extension predicting the magnitude, not only the direction, of the rand’s monthly return is a natural next step, alongside a possible extension of the DCC framework to gold and Brent, both intentionally scoped out of the volatility stage here to preserve analytical focus on the one relationship with an established long-run foundation.
Conclusion
This study set out to determine how global commodity prices transmit into the South African rand, distinguishing rigorously between long-run structural relationships and short-run predictive signal. The results give a precise, nuanced answer: platinum alone has a confirmed long-run equilibrium relationship with the rand, with the rand correcting toward this equilibrium at a documented, economically meaningful speed. The rand’s volatility is well-characterised by a standard, symmetric GARCH process, with no evidence that this volatility becomes more tightly linked to platinum specifically during periods of market stress. Gold and Brent, despite lacking any confirmed long-run relationship, both carry genuine short-term predictive value once machine learning methods are applied — a finding that reframes, rather than contradicts, their apparent econometric irrelevance.
Every major finding in this report has been checked against at least one of three standards: statistical significance at conventional thresholds, consistency with the underlying economic mechanism, and, where available, published academic literature with points of both agreement and honest divergence reported explicitly. This combination of econometric rigour, machine learning, and critical engagement with the existing literature is intended to reflect the standard expected of applied quantitative work in a professional research or risk-management setting.
References
Federal Reserve Economic Data (FRED), Federal Reserve Bank of St. Louis.
World Bank Commodity Markets (“Pink Sheet”), World Bank Group.
South African Reserve Bank data, accessed via the samadb R package.
Ndlovu, M. (2011). The relationship between the South African Rand and commodity prices: examining cointegration and causality (Master’s thesis).
Journal of Economics Bibliography — study on real platinum and gold prices as determinants of the real South African Rand, 2000–2014 sample.
Study on South African rand fundamentals and commodity prices, cointegration analysis.
ScienceDirect — study on predicting white metal prices via a commodity-sensitive exchange rate, out-of-sample forecasting and causality analysis.
Olaomi et al. — copula-based analysis of gold, platinum prices and Rand/USD volatility.