Created
July 20, 2026 14:50
-
-
Save USMortality/7d4196c81f10d6250c9cc77dbab4bb21 to your computer and use it in GitHub Desktop.
Oil Price & Trend
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env Rscript | |
| # Crude Oil (WTI) - Log-Trend & Super-Cycle Analysis | |
| # Charts: 1_ Price+Trend, 2_ Deviation+Supertrend, 3_ Normalized | |
| # Data: FRED DCOILWTICO daily spot price, monthly last observation | |
| library(tidyverse) | |
| library(ggrepel) | |
| library(zoo) | |
| sf <- 1.5 | |
| width <- 600 * sf | |
| height <- 335 * sf | |
| options(vsc.dev.args = list(width = width, height = height, res = 72 * sf)) | |
| LABEL <- "Crude Oil WTI Spot Price" | |
| PREFIX <- "chart_crude_oil_wti" | |
| START <- as.Date("1986-01-01") | |
| FRED_SERIES <- "DCOILWTICO" | |
| FRED_URL <- paste0("https://fred.stlouisfed.org/graph/fredgraph.csv?id=", FRED_SERIES) | |
| LOCAL_CSV <- paste0(FRED_SERIES, ".csv") | |
| RED <- "#C62828"; ORANGE <- "#E65100"; AMBER <- "#F9A825" | |
| GREEN <- "#2E7D32"; BLUE <- "#1565C0"; GRAY <- "#757575"; TEAL <- "#00695C" | |
| SUPER_CYCLE_YEARS_DEFAULT <- 12 | |
| LOESS_SPAN_MULT <- 1.8 | |
| LOESS_MIN_SPAN <- 0.18 | |
| LOESS_MAX_SPAN <- 0.98 | |
| EDGE_DAMPEN_YEARS <- 4 | |
| EDGE_MIN_WEIGHT <- 0.35 | |
| Z_BAND <- 1.96 | |
| if (file.exists(LOCAL_CSV)) { | |
| cat("-> Reading local FRED series", LOCAL_CSV, "...\n") | |
| raw <- read_csv(LOCAL_CSV, col_types = cols(.default = "c"), show_col_types = FALSE) | |
| } else { | |
| cat("-> Downloading FRED series", FRED_SERIES, "...\n") | |
| raw <- read_csv(FRED_URL, col_types = cols(.default = "c"), show_col_types = FALSE) | |
| } | |
| df <- raw |> | |
| rename(date = observation_date, close = all_of(FRED_SERIES)) |> | |
| mutate( | |
| date = as.Date(date), | |
| close = suppressWarnings(as.numeric(close)) | |
| ) |> | |
| filter(date >= START, !is.na(close), close > 0) |> | |
| mutate(month = as.yearmon(date)) |> | |
| group_by(month) |> | |
| slice_max(date, n = 1, with_ties = FALSE) |> | |
| ungroup() |> | |
| transmute(date = as.Date(month), close) |> | |
| arrange(date) | |
| if (nrow(df) < 60) { | |
| stop("Not enough usable price history after parsing FRED data.") | |
| } | |
| cat(" Final:", nrow(df), "months,", format(min(df$date)), "to", format(max(df$date)), "\n") | |
| df <- df |> mutate(date_num = as.numeric(date), log_close = log(close)) | |
| fit <- lm(log_close ~ date_num, data = df) | |
| cagr <- (exp(coef(fit)[2] * 365.25) - 1) * 100 | |
| df <- df |> | |
| mutate( | |
| trend = exp(predict(fit, newdata = pick(everything()))), | |
| pct_dev = (close - trend) / trend * 100 | |
| ) | |
| cat(" Trend CAGR:", round(cagr, 2), "%/yr\n") | |
| data_years <- as.numeric(difftime(max(df$date), min(df$date), units = "days")) / 365.25 | |
| cycle_years <- if (data_years >= 20) SUPER_CYCLE_YEARS_DEFAULT else max(5, round(data_years * 0.35)) | |
| k_months <- as.integer(cycle_years * 12) | |
| n <- nrow(df) | |
| span <- min(LOESS_MAX_SPAN, max(LOESS_MIN_SPAN, LOESS_SPAN_MULT * k_months / n)) | |
| t_idx <- seq_len(n) | |
| edge_n <- min(as.integer(EDGE_DAMPEN_YEARS * 12), floor(n / 2)) | |
| w <- rep(1, n) | |
| if (edge_n >= 2) { | |
| ramp <- seq(EDGE_MIN_WEIGHT, 1, length.out = edge_n) | |
| w[seq_len(edge_n)] <- ramp | |
| w[(n - edge_n + 1):n] <- rev(ramp) | |
| } | |
| lo <- loess(df$pct_dev ~ t_idx, span = span, degree = 1, | |
| family = "symmetric", weights = w, | |
| control = loess.control(surface = "direct")) | |
| df$supertrend <- as.numeric(predict(lo, data.frame(t_idx = t_idx))) | |
| df$norm_dev <- df$pct_dev - df$supertrend | |
| cat(" Supertrend:", cycle_years, "yr cycle, span =", round(span, 3), "\n") | |
| decade_labels <- function(dates, values, threshold = 60) { | |
| tbl <- tibble(date = dates, val = values) |> | |
| mutate(decade = floor(lubridate::year(date) / 10) * 10) |> | |
| group_by(decade) |> | |
| summarize( | |
| max_d = date[which.max(val)], max_v = max(val), | |
| min_d = date[which.min(val)], min_v = min(val), | |
| .groups = "drop" | |
| ) | |
| bind_rows( | |
| tbl |> filter(abs(max_v) >= threshold) |> transmute(date = max_d, val = max_v), | |
| tbl |> filter(abs(min_v) >= threshold) |> transmute(date = min_d, val = min_v) | |
| ) |> | |
| distinct(date, .keep_all = TRUE) | |
| } | |
| last_str <- format(max(df$date), "%b %Y") | |
| cur <- tail(df, 1) | |
| cat("-> Chart 1: Price + Trend\n") | |
| extrema1 <- bind_rows( | |
| decade_labels(df$date, df$pct_dev, 65), | |
| tibble(date = cur$date, val = cur$pct_dev) | |
| ) |> | |
| distinct(date, .keep_all = TRUE) |> | |
| left_join(df |> select(date, close), by = "date") | |
| p1 <- ggplot(df, aes(x = date)) + | |
| geom_ribbon(aes(ymin = pmin(close, trend), ymax = trend), fill = GREEN, alpha = 0.15) + | |
| geom_ribbon(aes(ymin = trend, ymax = pmin(pmax(close, trend), trend * 1.75)), fill = AMBER, alpha = 0.12) + | |
| geom_ribbon(aes(ymin = trend * 1.75, ymax = pmax(close, trend * 1.75)), fill = RED, alpha = 0.18) + | |
| geom_line(aes(y = close), color = BLUE, linewidth = 0.5) + | |
| geom_line(aes(y = trend), color = "black", linetype = "dashed", linewidth = 0.5) + | |
| geom_point(data = extrema1, aes(y = close), color = ORANGE, size = 2) + | |
| geom_text_repel(data = extrema1, aes(y = close, label = sprintf("%+.0f%%", val)), | |
| color = BLUE, size = 2.8, max.overlaps = 15, seed = 42) + | |
| scale_x_date(date_breaks = "5 year", date_labels = "%Y") + | |
| scale_y_continuous(trans = "log2", labels = scales::label_dollar()) + | |
| labs( | |
| title = paste0(LABEL, " (Log Scale)"), | |
| subtitle = paste0("Since ", format(min(df$date), "%Y"), " | Log-trend CAGR ~ ", round(cagr, 1), "%/yr"), | |
| x = NULL, y = "USD per barrel", | |
| caption = "Source: FRED DCOILWTICO. Monthly values use the last daily observation in each month." | |
| ) + | |
| theme_bw() + | |
| theme(axis.text.x = element_text(angle = 30, hjust = 1), legend.position = "none") | |
| ggsave(paste0(PREFIX, "_1_price.png"), p1, | |
| width = width / 72 / sf, height = height / 72 / sf, dpi = 72 * sf) | |
| cat("-> Chart 2: Deviation + Supertrend\n") | |
| lbl2 <- bind_rows( | |
| decade_labels(df$date, df$pct_dev, 65), | |
| tibble(date = cur$date, val = cur$pct_dev) | |
| ) |> | |
| distinct(date, .keep_all = TRUE) | |
| p2 <- ggplot(df, aes(date, pct_dev)) + | |
| geom_ribbon(aes(ymin = pmin(pct_dev, 0), ymax = 0), fill = GREEN, alpha = 0.15) + | |
| geom_ribbon(aes(ymin = 0, ymax = pmin(pmax(pct_dev, 0), 75)), fill = AMBER, alpha = 0.12) + | |
| geom_ribbon(aes(ymin = 75, ymax = pmax(pct_dev, 75)), fill = RED, alpha = 0.18) + | |
| geom_hline(yintercept = 0, linetype = "dashed", linewidth = 0.4) + | |
| geom_line(color = BLUE, linewidth = 0.4, alpha = 0.6) + | |
| geom_line(aes(y = supertrend), color = TEAL, linewidth = 0.8) + | |
| geom_point(data = lbl2, aes(y = val), color = ORANGE, size = 1.8) + | |
| geom_text_repel( | |
| data = lbl2 |> mutate(lbl = if_else(date == max(date), paste0(last_str, ": ", sprintf("%+.0f%%", val)), sprintf("%+.0f%%", val))), | |
| aes(y = val, label = lbl), | |
| color = BLUE, size = 2.8, fontface = "bold", | |
| nudge_y = 5, box.padding = 0.5, max.overlaps = 15, seed = 42 | |
| ) + | |
| scale_x_date(date_breaks = "5 year", date_labels = "%Y") + | |
| scale_y_continuous(labels = \(x) paste0(x, "%")) + | |
| labs( | |
| title = paste0(LABEL, ": Deviation from Log-Trend + Supertrend"), | |
| subtitle = paste0("Trend CAGR ~ ", round(cagr, 1), "%/yr | Teal = ", cycle_years, "yr supertrend"), | |
| x = NULL, y = "Deviation from trend (%)", | |
| caption = "Source: FRED DCOILWTICO. Supertrend: robust LOESS with edge down-weighting." | |
| ) + | |
| theme_bw() + | |
| theme(axis.text.x = element_text(angle = 30, hjust = 1), legend.position = "none") | |
| ggsave(paste0(PREFIX, "_2_deviation.png"), p2, | |
| width = width / 72 / sf, height = height / 72 / sf, dpi = 72 * sf) | |
| cat("-> Chart 3: Double-Normalized\n") | |
| norm_sd <- sd(df$norm_dev, na.rm = TRUE) | |
| band <- Z_BAND * norm_sd | |
| lbl3 <- bind_rows( | |
| decade_labels(df$date, df$norm_dev, 45), | |
| tibble(date = cur$date, val = cur$norm_dev) | |
| ) |> | |
| distinct(date, .keep_all = TRUE) | |
| p3 <- ggplot(df, aes(date, norm_dev)) + | |
| geom_ribbon(aes(ymin = pmin(norm_dev, 0), ymax = 0), fill = GREEN, alpha = 0.15) + | |
| geom_ribbon(aes(ymin = 0, ymax = pmin(pmax(norm_dev, 0), band)), fill = AMBER, alpha = 0.12) + | |
| geom_ribbon(aes(ymin = band, ymax = pmax(norm_dev, band)), fill = RED, alpha = 0.18) + | |
| geom_hline(yintercept = 0, linetype = "dashed", linewidth = 0.4) + | |
| geom_hline(yintercept = c(-band, band), color = GRAY, linetype = "dotted", linewidth = 0.35) + | |
| geom_line(color = BLUE, linewidth = 0.5) + | |
| geom_point(data = lbl3, aes(y = val), color = ORANGE, size = 1.8) + | |
| geom_text_repel( | |
| data = lbl3 |> mutate(lbl = if_else(date == max(date), paste0(last_str, ": ", sprintf("%+.0f%%", val)), sprintf("%+.0f%%", val))), | |
| aes(y = val, label = lbl), | |
| color = BLUE, size = 2.8, fontface = "bold", | |
| nudge_y = 3, box.padding = 0.4, max.overlaps = 20, seed = 42 | |
| ) + | |
| scale_x_date(date_breaks = "5 year", date_labels = "%Y") + | |
| scale_y_continuous(labels = \(x) paste0(x, "%")) + | |
| labs( | |
| title = paste0(LABEL, ": Double-Normalized (Log + Supertrend)"), | |
| subtitle = paste0("Deviation minus ", cycle_years, "yr supertrend | Dotted = +/-", Z_BAND, " SD"), | |
| x = NULL, y = "Normalized deviation (%)", | |
| caption = "Source: FRED DCOILWTICO. Cyclical over/undervaluation after removing secular trend." | |
| ) + | |
| theme_bw() + | |
| theme(axis.text.x = element_text(angle = 30, hjust = 1), legend.position = "none") | |
| ggsave(paste0(PREFIX, "_3_normalized.png"), p3, | |
| width = width / 72 / sf, height = height / 72 / sf, dpi = 72 * sf) | |
| cat("\nSaved:", paste0(PREFIX, c("_1_price.png", "_2_deviation.png", "_3_normalized.png")), "\n") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment