Last active
February 16, 2026 18:07
-
-
Save USMortality/5eefabc516e2fa836ee7f5b20e88f51a to your computer and use it in GitHub Desktop.
Ex-US Equity (World ex-US / VXUS) — Log-Trend & Super-Cycle Analysis | ERN historical data from 1971 + Yahoo Finance
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 | |
| # ═══════════════════════════════════════════════════════════════ | |
| # Ex-US Equity (World ex-US / VXUS) — Log-Trend & Super-Cycle | |
| # Charts: 1_ Price+Trend, 2_ Deviation+Supertrend, 3_ Normalized | |
| # Data: ERN SWR Toolbox (1871–2025) + Yahoo Finance (recent) | |
| # ═══════════════════════════════════════════════════════════════ | |
| library(tidyverse) | |
| library(quantmod) | |
| library(ggrepel) | |
| sf <- 1.5 | |
| width <- 600 * sf | |
| height <- 335 * sf | |
| options(vsc.dev.args = list(width = width, height = height, res = 72 * sf)) | |
| # ── Configuration ───────────────────────────────────────────── | |
| LABEL <- "Stocks World ex-USA (VXUS)" | |
| PREFIX <- "chart_exus" | |
| START <- as.Date("1871-01-01") | |
| # ERN SWR Toolbox — column 10 = World ex-US Equities index | |
| ERN_SHEET_ID <- "1QGrMm6XSGWBVLI8I_DOAeJV5whoCnSdmaR8toQB2Jz8" | |
| ERN_COL <- 10 | |
| YAHOO_TICKER <- "VXUS" # adjusted close for recent supplement | |
| RED <- "#C62828"; ORANGE <- "#E65100"; AMBER <- "#F9A825" | |
| GREEN <- "#2E7D32"; BLUE <- "#1565C0"; GRAY <- "#757575"; TEAL <- "#00695C" | |
| # Supertrend | |
| SUPER_CYCLE_YEARS_DEFAULT <- 40 | |
| LOESS_SPAN_MULT <- 1.8 | |
| LOESS_MIN_SPAN <- 0.12 | |
| LOESS_MAX_SPAN <- 0.98 | |
| EDGE_DAMPEN_YEARS <- 12 | |
| EDGE_MIN_WEIGHT <- 0.35 | |
| Z_BAND <- 1.96 | |
| # ── Helper: parse ERN CSV numbers (comma thousands) ────────── | |
| parse_num <- function(x) suppressWarnings(as.numeric(gsub(",", "", x))) | |
| # ── Download ERN historical data ───────────────────────────── | |
| cat("→ Downloading ERN SWR Toolbox (Asset Returns)...\n") | |
| ern_url <- paste0( | |
| "https://docs.google.com/spreadsheets/d/", ERN_SHEET_ID, | |
| "/gviz/tq?tqx=out:csv&sheet=Asset+Returns" | |
| ) | |
| ern_raw <- read_csv(ern_url, skip = 2, col_names = FALSE, | |
| col_types = cols(.default = "c"), show_col_types = FALSE) | |
| ern_df <- tibble( | |
| month = parse_num(ern_raw[[1]]), | |
| year = parse_num(ern_raw[[2]]), | |
| close = parse_num(ern_raw[[ERN_COL]]) | |
| ) |> | |
| filter(!is.na(month), !is.na(year), !is.na(close), close > 0) |> | |
| mutate(date = as.Date(sprintf("%04d-%02d-01", | |
| as.integer(year), as.integer(month)))) |> | |
| filter(date >= START) |> | |
| transmute(date, close) |> | |
| arrange(date) | |
| cat(" ERN data:", nrow(ern_df), "months,", | |
| format(min(ern_df$date)), "to", format(max(ern_df$date)), "\n") | |
| # ── Supplement with recent Yahoo Finance data ───────────────── | |
| cat("→ Supplementing with Yahoo Finance (", YAHOO_TICKER, ")...\n") | |
| last_ern <- tail(ern_df, 1) | |
| tryCatch({ | |
| raw <- getSymbols(YAHOO_TICKER, from = last_ern$date - 60, | |
| to = Sys.Date(), auto.assign = FALSE) | |
| yahoo_m <- tibble( | |
| date = as.Date(index(raw)), | |
| close = as.numeric(Ad(raw)) | |
| ) |> | |
| filter(!is.na(close), close > 0) |> | |
| mutate(ym = floor_date(date, "month")) |> | |
| group_by(ym) |> slice_tail(n = 1) |> ungroup() |> | |
| transmute(date = ym, close) |> arrange(date) | |
| overlap <- yahoo_m |> filter(date == last_ern$date) | |
| if (nrow(overlap) == 0) { | |
| overlap <- yahoo_m |> | |
| mutate(diff = abs(as.numeric(date - last_ern$date))) |> | |
| filter(diff <= 45) |> arrange(diff) |> slice(1) |> select(-diff) | |
| } | |
| if (nrow(overlap) == 1) { | |
| scale_f <- last_ern$close / overlap$close | |
| yahoo_new <- yahoo_m |> | |
| filter(date > last_ern$date) |> | |
| mutate(close = close * scale_f) | |
| if (nrow(yahoo_new) > 0) { | |
| ern_df <- bind_rows(ern_df, yahoo_new) | |
| cat(" Added", nrow(yahoo_new), "months from Yahoo.\n") | |
| } | |
| } | |
| }, error = \(e) cat(" Yahoo supplement failed:", e$message, "\n")) | |
| df <- ern_df |> arrange(date) | |
| cat(" Final:", nrow(df), "months,", | |
| format(min(df$date)), "to", format(max(df$date)), "\n") | |
| # ── Log-linear trend ───────────────────────────────────────── | |
| 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") | |
| # ── Supertrend (edge-adaptive LOESS) ───────────────────────── | |
| data_years <- as.numeric(difftime(max(df$date), min(df$date), | |
| units = "days")) / 365.25 | |
| cycle_years <- if (data_years >= 50) { | |
| SUPER_CYCLE_YEARS_DEFAULT | |
| } else { | |
| max(8, round(data_years * 0.4)) | |
| } | |
| 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") | |
| # ── Label helper ────────────────────────────────────────────── | |
| decade_labels <- function(dates, values, threshold = 20) { | |
| tbl <- tibble(date = dates, val = values) |> | |
| mutate(decade = floor(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) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # Chart 1_: Price (log scale) with Log-Trend | |
| # ═══════════════════════════════════════════════════════════════ | |
| cat("→ Chart 1: Price + Trend\n") | |
| extrema1 <- bind_rows( | |
| decade_labels(df$date, df$pct_dev, 20), | |
| 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.6)), | |
| fill = AMBER, alpha = 0.12) + | |
| geom_ribbon(aes(ymin = trend * 1.6, | |
| ymax = pmax(close, trend * 1.6)), | |
| 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(x = date, y = close), | |
| color = ORANGE, size = 2) + | |
| geom_text_repel(data = extrema1, | |
| aes(x = date, y = close, label = sprintf("%+.0f%%", val)), | |
| color = BLUE, size = 2.8, nudge_y = 0.15, | |
| max.overlaps = 15, seed = 42) + | |
| scale_x_date(date_breaks = "10 year", date_labels = "%Y") + | |
| scale_y_continuous(trans = "log2", | |
| labels = scales::label_number(scale_cut = scales::cut_short_scale())) + | |
| labs( | |
| title = paste0(LABEL, " (Log Scale)"), | |
| subtitle = paste0("Since ", format(min(df$date), "%Y"), | |
| " | Trend CAGR ~ ", round(cagr, 1), "%/yr"), | |
| x = NULL, y = "Index Level", | |
| caption = "Source: ERN SWR Toolbox + Yahoo Finance (VXUS)" | |
| ) + | |
| 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) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # Chart 2_: Deviation from Log-Trend + Supertrend | |
| # ═══════════════════════════════════════════════════════════════ | |
| cat("→ Chart 2: Deviation + Supertrend\n") | |
| lbl2 <- bind_rows( | |
| decade_labels(df$date, df$pct_dev, 20), | |
| 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), 60)), | |
| fill = AMBER, alpha = 0.12) + | |
| geom_ribbon(aes(ymin = 60, ymax = pmax(pct_dev, 60)), | |
| 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 = "10 year", date_labels = "%Y") + | |
| scale_y_continuous(labels = \(x) paste0(x, "%"), | |
| breaks = seq(-60, 120, 20)) + | |
| labs( | |
| title = paste0(LABEL, ": Deviation from Log-Trend + Supertrend"), | |
| subtitle = paste0( | |
| "Trend CAGR ~ ", round(cagr, 1), "%/yr | Teal = ", | |
| cycle_years, "yr supertrend (edge-adaptive LOESS)"), | |
| x = NULL, y = "Deviation from trend (%)", | |
| caption = "Source: ERN SWR Toolbox + Yahoo Finance (VXUS). 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) | |
| # ═══════════════════════════════════════════════════════════════ | |
| # Chart 3_: Double-Normalized (deviation minus supertrend) | |
| # ═══════════════════════════════════════════════════════════════ | |
| 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, 15), | |
| 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: ERN SWR Toolbox + Yahoo Finance (VXUS). 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("\n✓ Saved:", 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