#| label: setup
#| include: false
required_pkgs <- c("tidyverse","lubridate","httr","jsonlite","fredr","scales","cowplot", "dplyr")
inst <- rownames(installed.packages()); miss <- setdiff(required_pkgs, inst)
if (length(miss) > 0) install.packages(miss, dependencies = TRUE)
invisible(lapply(required_pkgs, library, character.only = TRUE))
fred_key <- Sys.getenv("FRED_API_KEY")
if (identical(fred_key, "")) stop("FRED_API_KEY not detected during render.")
fredr::fredr_set_key(fred_key)
# Optional BLS key (improves limits/reliability)
bls_key <- Sys.getenv("BLS_API_KEY")
# #| label: test-env
# Sys.getenv("FRED_API_KEY")
# Sys.getenv("BLS_API_KEY")DATA_608_Story_02
Disclaimer:
The main focus of this assignment will be to select the right visuals that will render the substance of our data.
The data utilized are accessed through available APIs and ran from 2001-03-01 through 2026-02-01.
Introduction:
The relationship between inflation and unemployment is one of the most studied topics in macroeconomics. Traditionally, economists have described their interaction through the Phillips Curve, which shows a negative relationship between the two: when inflation rises, unemployment tends to fall, and when inflation falls, unemployment tends to rise. However, this relationship is more complex than it first appears. The Federal Reserve’s mandate from Congress is to control inflation and to maintain low unemployment. These seem to be contradictory objectives.
Has the FED been able to fulfill the mandate given to it by Congress?
Considering the following data for the last 25 years:
The Consumer Price Index (CPI) (Bureau of Labor Statistics)
The FED Funds Rate (FRED) (Federal Reserve Board)
Unemployment Rate (Bureau of Labor Statistics),
we will answer the question above.
- Loading Packages and Libraries
- API Keys set-up
# ----------------------
# 1) Configuration
# ----------------------
# Time window: last 25 years to the most recent complete month
end_date <- floor_date(Sys.Date(), unit = "month")
start_date <- end_date %m-% months(25 * 12 - 1)
start_year <- year(start_date)
end_year <- year(end_date)
# API Keys
# FRED API KEY
fred_key <- Sys.getenv("FRED_API_KEY")
if (identical(fred_key, "")) {
stop("Please set a FRED API key in your environment as FRED_API_KEY. Get one free at https://fredaccount.stlouisfed.org/apikey")
}
fredr_set_key(fred_key)
# BLS API KEY
bls_key <- Sys.getenv("BLS_API_KEY")
bls_has_key <- !identical(bls_key, "")
# Series IDs
series_cpi_sa <- "CUSR0000SA0" # CPI-U All items, SA
series_unrate <- "LNS14000000" # Unemployment rate, SA
series_fedfunds <- "FEDFUNDS" # Effective Fed Funds Rate
series_usrec <- "USREC" # NBER Recession Indicator (monthly)
# ----------------------
# 2) BLS API fetch
# ----------------------
bls_fetch <- function(series_id,
start_year,
end_year,
bls_key = NULL,
chunk_years = 10) {
stopifnot(is.character(series_id), length(series_id) == 1L)
stopifnot(is.numeric(start_year), is.numeric(end_year), start_year <= end_year)
url <- "https://api.bls.gov/publicAPI/v2/timeseries/data/"
years <- seq(start_year, end_year)
blocks <- split(years, ceiling(seq_along(years) / chunk_years))
out_list <- vector("list", length(blocks))
for (i in seq_along(blocks)) {
yrng <- range(blocks[[i]])
body <- list(
seriesid = list(series_id), # array, even for a single series
startyear = as.character(yrng[1]),
endyear = as.character(yrng[2])
)
if (!is.null(bls_key) && nzchar(bls_key)) {
body$registrationkey <- bls_key # lowercase per v2
}
res <- httr::POST(
url,
body = body,
encode = "json",
httr::add_headers(`Content-Type` = "application/json")
)
httr::stop_for_status(res)
raw_txt <- httr::content(res, as = "text", encoding = "UTF-8")
# IMPORTANT: no automatic simplification; we’ll walk the list safely
js <- jsonlite::fromJSON(raw_txt, simplifyVector = FALSE)
# Check status & surface message
if (is.null(js$status) || js$status != "REQUEST_SUCCEEDED") {
msg <- if (!is.null(js$message)) paste(unlist(js$message), collapse = "; ") else "Unknown error"
stop("BLS API request failed: ", msg)
}
# Locate the 'series' node robustly (Results$series OR Results[[1]]$series)
series_nodes <- NULL
if (!is.null(js$Results$series)) {
series_nodes <- js$Results$series
} else if (is.list(js$Results) && length(js$Results) >= 1L && !is.null(js$Results[[1]]$series)) {
series_nodes <- js$Results[[1]]$series
} else {
stop("Unexpected BLS JSON structure: cannot find Results$series.")
}
# Build a tidy tibble from the nested list
df <- purrr::map_dfr(series_nodes, function(s) {
sid <- s$seriesID
rows <- s$data
# rows is a list of named lists: year, period, periodName, value, footnotes ...
purrr::map_dfr(rows, function(r) {
tibble::tibble(
series_id = sid,
year = as.integer(r$year),
period = r$period,
value = as.numeric(r$value)
)
})
}) |>
dplyr::filter(stringr::str_detect(period, "^M\\d{2}$")) |>
dplyr::mutate(
month = as.integer(stringr::str_remove(period, "M")),
date = lubridate::ymd(sprintf("%04d-%02d-01", year, month))
) |>
dplyr::select(series_id, year, month, date, value) |>
dplyr::arrange(date)
out_list[[i]] <- df
Sys.sleep(0.2) # polite pause to avoid rate limits
}
dplyr::bind_rows(out_list) |>
dplyr::distinct(date, series_id, .keep_all = TRUE) |>
dplyr::arrange(date)
}- Data Download
# 3a. CPI (BLS)
cpi_raw <-
function(series_id,
start_year,
end_year,
bls_key = NULL,
chunk_years = 10) {
stopifnot(is.character(series_id), length(series_id) == 1L)
stopifnot(is.numeric(start_year), is.numeric(end_year), start_year <= end_year)
url <- "https://api.bls.gov/publicAPI/v2/timeseries/data/"
years <- seq(start_year, end_year)
blocks <- split(years, ceiling(seq_along(years) / chunk_years))
out_list <- vector("list", length(blocks))
for (i in seq_along(blocks)) {
yrng <- range(blocks[[i]])
body <- list(
seriesid = list(series_id), # MUST be an array, even for one series
startyear = as.character(yrng[1]),
endyear = as.character(yrng[2])
)
if (!is.null(bls_key) && nzchar(bls_key)) {
body$registrationkey <- bls_key # lower-case name per v2
}
res <- httr::POST(
url,
body = body,
encode = "json",
httr::add_headers(`Content-Type` = "application/json")
)
httr::stop_for_status(res)
raw_txt <- httr::content(res, as = "text", encoding = "UTF-8")
js <- jsonlite::fromJSON(raw_txt)
if (!identical(js$status, "REQUEST_SUCCEEDED")) {
msg <- if (!is.null(js$message)) paste(js$message, collapse = "; ") else "Unknown error"
stop("BLS API request failed: ", msg)
}
df <- purrr::map_dfr(js$Results$series, function(s) {
tibble::as_tibble(s$data) |> dplyr::mutate(series_id = s$seriesID)
}) |>
dplyr::filter(stringr::str_detect(period, "^M\\d{2}$")) |>
dplyr::transmute(
series_id,
year = as.integer(year),
month = as.integer(stringr::str_remove(period, "M")),
date = lubridate::ymd(sprintf("%04d-%02d-01", year, month)),
value = as.numeric(value)
) |>
dplyr::arrange(date)
out_list[[i]] <- df
Sys.sleep(0.2) # polite pause
}
dplyr::bind_rows(out_list) |> dplyr::arrange(date)
}
# 3b. Unemployment rate (BLS)
unrate_raw <-
function(series_id,
start_year,
end_year,
bls_key = NULL,
chunk_years = 10) {
stopifnot(is.character(series_id), length(series_id) == 1L)
stopifnot(is.numeric(start_year), is.numeric(end_year), start_year <= end_year)
url <- "https://api.bls.gov/publicAPI/v2/timeseries/data/"
years <- seq(start_year, end_year)
blocks <- split(years, ceiling(seq_along(years) / chunk_years))
out_list <- vector("list", length(blocks))
for (i in seq_along(blocks)) {
yrng <- range(blocks[[i]])
body <- list(
seriesid = list(series_id), # array, even for single series
startyear = as.character(yrng[1]),
endyear = as.character(yrng[2])
)
if (!is.null(bls_key) && nzchar(bls_key)) {
body$registrationkey <- bls_key # lowercase per v2 docs
}
res <- httr::POST(
url,
body = body,
encode = "json",
httr::add_headers(`Content-Type` = "application/json")
)
httr::stop_for_status(res)
raw_txt <- httr::content(res, as = "text", encoding = "UTF-8")
js <- jsonlite::fromJSON(raw_txt)
if (!identical(js$status, "REQUEST_SUCCEEDED")) {
msg <- if (!is.null(js$message)) paste(js$message, collapse = "; ") else "Unknown error"
stop("BLS API request failed: ", msg)
}
df <- purrr::map_dfr(js$Results$series, function(s) {
tibble::as_tibble(s$data) |>
dplyr::mutate(series_id = s$seriesID)
}) |>
dplyr::filter(stringr::str_detect(period, "^M\\d{2}$")) |>
dplyr::transmute(
series_id,
year = as.integer(year),
month = as.integer(stringr::str_remove(period, "M")),
date = lubridate::ymd(sprintf("%04d-%02d-01", year, month)),
value = as.numeric(value)
) |>
dplyr::arrange(date)
out_list[[i]] <- df
Sys.sleep(0.2) # polite pause to avoid rate limits
}
dplyr::bind_rows(out_list) |>
dplyr::arrange(date)
}
# 3c. Fed funds & Recession indicator (FRED)
ffr <- fredr(
series_id = series_fedfunds,
observation_start = start_date,
observation_end = end_date,
frequency = "m"
) |>
transmute(date = date, fedfunds = value)
usrec <- fredr(
series_id = series_usrec,
observation_start = start_date,
observation_end = end_date,
frequency = "m"
) |>
transmute(date = date, recession = as.integer(value))- Transform and Join Data
bls_key <- Sys.getenv("BLS_API_KEY")
chunk_years <- if (identical(bls_key, "")) 10 else 20
cpi_raw <- bls_fetch(
series_id = "CUSR0000SA0",
start_year = start_year,
end_year = end_year,
bls_key = if (identical(bls_key, "")) NULL else bls_key,
chunk_years = chunk_years
)
cpi <- cpi_raw |>
select(date, cpi_index = value) |>
# Year-over-year inflation (%)
arrange(date) |>
mutate(inflation_yoy = 100 * ((cpi_index / lag(cpi_index, 12)) - 1)) |>
filter(date >= start_date, date <= end_date)
# Years
end_date <- floor_date(Sys.Date(), "month")
start_date <- end_date %m-% months(25*12 - 1)
start_year <- year(start_date)
end_year <- year(end_date)
# Keys and chunking
bls_key <- Sys.getenv("BLS_API_KEY")
chunk_years <- if (nzchar(bls_key)) 20 else 10
# === Re-pull Unemployment (U-3, SA) ===
unrate_raw <- bls_fetch(
series_id = "LNS14000000",
start_year = start_year,
end_year = end_year,
bls_key = if (nzchar(bls_key)) bls_key else NULL,
chunk_years = chunk_years
)
# Build the tidy series (use explicit dplyr:: to avoid masking)
unrate <- unrate_raw |>
select(date, unrate = value) |>
filter(date >= start_date, date <= end_date)
macro <- cpi |>
left_join(unrate, by = "date") |>
left_join(ffr, by = "date") |>
left_join(usrec, by = "date") |>
arrange(date)
# For recession shading, get start/end intervals where recession==1
recession_ranges <- macro |>
mutate(flag = recession == 1,
grp = cumsum(coalesce(flag & !lag(flag, default = FALSE), FALSE))) |>
filter(flag) |>
group_by(grp) |>
summarise(start = min(date), end = max(date), .groups = "drop") |>
filter(!is.infinite(start), !is.infinite(end))- Visualization
library(ggplot2)
# Theme helper
theme_clean <- function() {
theme_minimal(base_size = 12) +
theme(
panel.grid.minor = element_blank(),
plot.title.position = "plot",
plot.caption = element_text(color = "grey40")
)
}
# 5a) Faceted time series: Inflation (YoY), Fed Funds, Unemployment
df_long <- macro |>
transmute(
date,
`Inflation (CPI YoY, %)` = inflation_yoy,
`Fed Funds Rate (%)` = fedfunds,
`Unemployment Rate (%)` = unrate
) |>
pivot_longer(-date, names_to = "metric", values_to = "value")
p_facets <- ggplot(df_long, aes(date, value)) +
# Recession shading
geom_rect(
data = recession_ranges,
inherit.aes = FALSE,
aes(xmin = start, xmax = end, ymin = -Inf, ymax = Inf),
fill = "grey85", alpha = 0.6
) +
geom_line(color = "#2C3E50", linewidth = 0.7) +
facet_wrap(~ metric, ncol = 1, scales = "free_y") +
scale_x_date(date_breaks = "5 years", date_labels = "%Y") +
labs(
title = "Inflation, Fed Funds, and Unemployment — Last 25 Years",
subtitle = "Shaded areas indicate NBER recessions (USREC).",
x = NULL, y = NULL,
caption = "Sources: BLS Public Data API (CPI, Unemployment); FRED API (Effective Fed Funds, USREC)"
) +
theme_clean()
# 5b) Lead/Lag analysis:
# Does the unemployment rate tend to rise AFTER the Fed raises rates?
# We'll compare monthly change in Fed Funds vs Unemployment lags/leads.
macro_lags <- macro |>
arrange(date) |>
mutate(
d_fedfunds = fedfunds - lag(fedfunds), # monthly change in FFR
# create unemployment shifted LAGGED forward (i.e., unemployment later)
unrate_lead_6 = lead(unrate, 6),
unrate_lead_12 = lead(unrate, 12),
unrate_lead_18 = lead(unrate, 18)
)
# Correlation table for various leads
corr_tbl <- tibble(
horizon = c("same month", "unemp +6m", "unemp +12m", "unemp +18m"),
corr = c(
cor(macro_lags$d_fedfunds, macro_lags$unrate, use = "pairwise.complete.obs"),
cor(macro_lags$d_fedfunds, macro_lags$unrate_lead_6, use = "pairwise.complete.obs"),
cor(macro_lags$d_fedfunds, macro_lags$unrate_lead_12, use = "pairwise.complete.obs"),
cor(macro_lags$d_fedfunds, macro_lags$unrate_lead_18, use = "pairwise.complete.obs")
)
)
p_corrbars <- ggplot(corr_tbl, aes(x = horizon, y = corr)) +
geom_col(fill = "#1F78B4") +
geom_hline(yintercept = 0, color = "grey40") +
coord_cartesian(ylim = c(-1, 1)) +
labs(
title = "Correlation: Monthly Change in Fed Funds vs Unemployment (with Leads)",
subtitle = "Positive bars mean rate hikes are associated with higher unemployment at that horizon.",
x = NULL, y = "Correlation"
) +
theme_clean()
# 5c) Scatter: Fed Funds now vs Unemployment 12 months later
scatter_df <- macro_lags |>
select(date, fedfunds, unrate_lead_12) |>
filter(!is.na(fedfunds), !is.na(unrate_lead_12))
p_scatter <- ggplot(scatter_df, aes(fedfunds, unrate_lead_12)) +
geom_point(alpha = 0.6, color = "#2E86AB") +
geom_smooth(method = "lm", se = TRUE, color = "#C0392B") +
labs(
title = "Fed Funds Today vs Unemployment 12 Months Later",
subtitle = "A simple lens for the lagged growth/employment trade-off.",
x = "Effective Fed Funds Rate (%)",
y = "Unemployment Rate in 12 Months (%)"
) +
theme_clean()
# Combine lead/lag visuals
p_leadlag <- plot_grid(p_corrbars, p_scatter, ncol = 1, rel_heights = c(1, 1.1))
# Print the plots
print(p_facets)Comment:
A review of the plot highlights three major economic disruptions: the dot‑com downturn of 2000–2001, the 2008 financial crisis, and the COVID‑19 recession in 2020. Outside of these shocks, both inflation and unemployment generally move along a relatively steady and predictable path. However, during each of these major events, the data reveal a clear inverse relationship between unemployment and inflation, reflecting the typical economic stress associated with recessions. Although neither unemployment nor inflation is directly controlled by the Federal Reserve, the Fed Funds Rate; the main monetary policy instrument used to influence economic conditions, shows a consistent downward adjustment during these episodes. This pattern suggests the Federal Reserve’s deliberate efforts to stabilize the economy by encouraging borrowing, spending, and investment during periods of distress. In this sense, the observed policy actions indicate that the Fed has actively used its primary tool, the Fed Funds Rate, to support its congressional mandate of promoting maximum employment and stable prices.
print(p_leadlag)Comment:
The negative bars in the monthly Fed Funds Rate–unemployment comparison suggest that rate hikes are not linked to higher unemployment. This implies that the two variables are not strongly correlated.
Conclusion:
The lack of a clear correlation between the federal funds rate and unemployment suggests that the Federal Reserve is actively working to balance both objectives of its congressional mandate. The opposite would suggest a “natural phenomenon”.