In this project, I look at how changes in U.S. mortgage rates relate to national housing price growth. The main goal is to see whether increases in mortgage rates slow down short-run home price growth, and whether those effects happen right away or with a delay.
I use mortgage data from Freddie Mac’s Primary Mortgage Market Survey and the FHFA House Price Index (HPI). The steps include importing and cleaning the data, transforming variables into growth rates, creating time-series plots, running correlations and regression models, and using autocorrelation tools (ACF, PACF, ADF tests) to evaluate time-series properties.
mort_file <- "data/mortgage_rates.csv"
hpi_file <- "data/house_price_index.csv"
mort_raw <- readr::read_csv(mort_file)
hpi_raw <- readr::read_csv(hpi_file)
glimpse(mort_raw)
## Rows: 2,854
## Columns: 2
## $ observation_date <date> 1971-04-02, 1971-04-09, 1971-04-16, 1971-04-23, 1971…
## $ MORTGAGE30US <dbl> 7.33, 7.31, 7.31, 7.31, 7.29, 7.38, 7.42, 7.44, 7.46,…
glimpse(hpi_raw)
## Rows: 203
## Columns: 2
## $ observation_date <date> 1975-01-01, 1975-04-01, 1975-07-01, 1975-10-01, 1976…
## $ USSTHPI <dbl> 59.99, 60.92, 61.38, 62.24, 62.89, 65.54, 66.58, 67.2…
This section imports the datasets and checks the basic structure and formats before cleaning and merging.
mort <- mort_raw %>%
rename(DATE = observation_date, MORTGAGE_RATE = MORTGAGE30US) %>%
mutate(
DATE = as.Date(DATE),
QUARTER = floor_date(DATE, "quarter")
) %>%
group_by(QUARTER) %>%
summarise(MORTGAGE_RATE = mean(MORTGAGE_RATE, na.rm = TRUE), .groups = "drop") %>%
rename(DATE = QUARTER)
hpi <- hpi_raw %>%
rename(DATE = observation_date, HPI = USSTHPI) %>%
mutate(DATE = as.Date(DATE))
df <- inner_join(mort, hpi, by = "DATE") %>%
arrange(DATE)
summary(df)
## DATE MORTGAGE_RATE HPI
## Min. :1975-01-01 Min. : 2.761 Min. : 59.99
## 1st Qu.:1987-08-16 1st Qu.: 5.036 1st Qu.:146.66
## Median :2000-04-01 Median : 7.050 Median :233.61
## Mean :2000-03-31 Mean : 7.677 Mean :270.25
## 3rd Qu.:2012-11-16 3rd Qu.: 9.512 3rd Qu.:360.81
## Max. :2025-07-01 Max. :17.736 Max. :706.04
glimpse(df)
## Rows: 203
## Columns: 3
## $ DATE <date> 1975-01-01, 1975-04-01, 1975-07-01, 1975-10-01, 1976-01…
## $ MORTGAGE_RATE <dbl> 9.168462, 8.875385, 8.983846, 9.160769, 8.873077, 8.7769…
## $ HPI <dbl> 59.99, 60.92, 61.38, 62.24, 62.89, 65.54, 66.58, 67.27, …
head(df)
Mortgage rates were averaged to quarterly frequency to match the HPI data. The merged dataset includes aligned timestamps for analysis.
df_ts <- df %>%
arrange(DATE) %>%
mutate(
mort_change = MORTGAGE_RATE - lag(MORTGAGE_RATE),
mort_pct_change = (MORTGAGE_RATE / lag(MORTGAGE_RATE)) - 1,
hpi_growth = (HPI / lag(HPI)) - 1,
hpi_log_growth = log(HPI) - log(lag(HPI))
)
summary(df_ts)
## DATE MORTGAGE_RATE HPI mort_change
## Min. :1975-01-01 Min. : 2.761 Min. : 59.99 Min. :-2.18868
## 1st Qu.:1987-08-16 1st Qu.: 5.036 1st Qu.:146.66 1st Qu.:-0.27641
## Median :2000-04-01 Median : 7.050 Median :233.61 Median :-0.05223
## Mean :2000-03-31 Mean : 7.677 Mean :270.25 Mean :-0.01288
## 3rd Qu.:2012-11-16 3rd Qu.: 9.512 3rd Qu.:360.81 3rd Qu.: 0.19942
## Max. :2025-07-01 Max. :17.736 Max. :706.04 Max. : 1.58308
## NA's :1
## mort_pct_change hpi_growth hpi_log_growth
## Min. :-0.1349244 Min. :-0.031484 Min. :-0.031991
## 1st Qu.:-0.0407873 1st Qu.: 0.007312 1st Qu.: 0.007286
## Median :-0.0086763 Median : 0.012324 Median : 0.012249
## Mean : 0.0003006 Mean : 0.012374 Mean : 0.012205
## 3rd Qu.: 0.0318702 3rd Qu.: 0.017518 3rd Qu.: 0.017366
## Max. : 0.3777420 Max. : 0.064750 Max. : 0.062740
## NA's :1 NA's :1 NA's :1
head(df_ts)
glimpse(df)
## Rows: 203
## Columns: 3
## $ DATE <date> 1975-01-01, 1975-04-01, 1975-07-01, 1975-10-01, 1976-01…
## $ MORTGAGE_RATE <dbl> 9.168462, 8.875385, 8.983846, 9.160769, 8.873077, 8.7769…
## $ HPI <dbl> 59.99, 60.92, 61.38, 62.24, 62.89, 65.54, 66.58, 67.27, …
These transformations help remove long-term trends and make the data appropriate for time-series analysis.
ggplot(df_ts, aes(DATE, MORTGAGE_RATE)) +
geom_line(linewidth = 0.7) +
labs(title="30-Year Fixed Mortgage Rate Over Time",
x="Date", y="Mortgage Rate (%)") +
theme_minimal()
df_ts <- df_ts %>%
mutate(HPI_rebased = HPI / first(na.omit(HPI)) * 100)
ggplot(df_ts, aes(DATE, HPI_rebased)) +
geom_line(linewidth = 0.7) +
labs(title="FHFA U.S. House Price Index (Rebased to 100)",
x="Date", y="Index (100 = first quarter)") +
theme_minimal()
ggplot(df_ts, aes(DATE, hpi_growth * 100)) +
geom_hline(yintercept = 0, linetype = "dashed") +
geom_line(linewidth = 0.7) +
labs(title="Quarterly HPI Growth Rate", x="Date", y="HPI Growth (%)") +
theme_minimal()
df_ts_long <- df_ts %>%
transmute(
DATE,
`Mortgage Rate Change (Percentage Points)` = mort_change,
`HPI Growth (%)` = hpi_growth * 100
) %>%
pivot_longer(cols = -DATE, names_to = "Series", values_to = "Value")
ggplot(df_ts_long, aes(DATE, Value)) +
geom_hline(yintercept = 0, linetype="dashed") +
geom_line(linewidth=0.6) +
facet_wrap(~ Series, ncol = 1, scales = "free_y") +
labs(title="Mortgage Rate Changes and HPI Growth Over Time",
x="Date", y=NULL) +
theme_minimal()
Mortgage rates trend downward for decades, rebound recently, while HPI steadily rises. Growth is much more volatile. The panel plot shows no obvious timing relationship between rate changes and HPI growth.
df_ts <- df_ts %>%
mutate(
mort_change_lag1 = lag(mort_change, 1),
mort_change_lag2 = lag(mort_change, 2),
mort_change_lag3 = lag(mort_change, 3)
)
df_corr <- df_ts %>% filter(!is.na(hpi_growth), !is.na(mort_change))
cor_same <- cor(df_corr$mort_change, df_corr$hpi_growth)
cor_lag1 <- cor(df_ts$mort_change_lag1, df_ts$hpi_growth, use="complete.obs")
cor_lag2 <- cor(df_ts$mort_change_lag2, df_ts$hpi_growth, use="complete.obs")
cor_lag3 <- cor(df_ts$mort_change_lag3, df_ts$hpi_growth, use="complete.obs")
tibble(
Metric = c("Same-Period Correlation","Lag 1","Lag 2","Lag 3"),
Value = c(cor_same, cor_lag1, cor_lag2, cor_lag3)
)
Even though all correlations were generally small, the lag-3 value (around 0.07) stood out as the largest. Since it was noticeably higher than the other lags, I tested it in a separate regression model (Model 4) to make sure there wasn’t a delayed effect hiding in the data.
ggplot(df_corr, aes(mort_change, hpi_growth)) +
geom_point(alpha=0.5) +
labs(title="Mortgage Rate Changes vs Housing Price Growth",
x="Mortgage Rate Change", y="HPI Growth") +
theme_minimal()
df_ts <- df_ts %>% mutate(hpi_growth_lag1 = lag(hpi_growth,1))
model1 <- lm(hpi_growth ~ mort_change, data=df_ts)
model2 <- lm(hpi_growth ~ mort_change + hpi_growth_lag1,
data=df_ts %>% filter(!is.na(hpi_growth_lag1)))
model3 <- lm(hpi_growth ~ mort_change_lag1 + hpi_growth_lag1,
data=df_ts %>% filter(!is.na(hpi_growth_lag1)))
model4 <- lm(hpi_growth ~ mort_change_lag3, data = df_ts)
summary(model1)
##
## Call:
## lm(formula = hpi_growth ~ mort_change, data = df_ts)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.043750 -0.005185 -0.000081 0.005114 0.053010
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.0123681 0.0009735 12.705 <2e-16 ***
## mort_change -0.0004350 0.0020174 -0.216 0.829
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.01383 on 200 degrees of freedom
## (1 observation deleted due to missingness)
## Multiple R-squared: 0.0002325, Adjusted R-squared: -0.004766
## F-statistic: 0.0465 on 1 and 200 DF, p-value: 0.8295
summary(model2)
##
## Call:
## lm(formula = hpi_growth ~ mort_change + hpi_growth_lag1, data = df_ts %>%
## filter(!is.na(hpi_growth_lag1)))
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.038270 -0.005075 -0.000711 0.004721 0.044112
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.004228 0.001045 4.046 7.45e-05 ***
## mort_change -0.005088 0.001630 -3.121 0.00207 **
## hpi_growth_lag1 0.650123 0.057145 11.377 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.01081 on 198 degrees of freedom
## Multiple R-squared: 0.3954, Adjusted R-squared: 0.3893
## F-statistic: 64.75 on 2 and 198 DF, p-value: < 2.2e-16
summary(model3)
##
## Call:
## lm(formula = hpi_growth ~ mort_change_lag1 + hpi_growth_lag1,
## data = df_ts %>% filter(!is.na(hpi_growth_lag1)))
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.034846 -0.005019 -0.000897 0.004894 0.049338
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.0048479 0.0010502 4.616 7.02e-06 ***
## mort_change_lag1 0.0008751 0.0016146 0.542 0.588
## hpi_growth_lag1 0.6057231 0.0566127 10.699 < 2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.01106 on 198 degrees of freedom
## Multiple R-squared: 0.3666, Adjusted R-squared: 0.3602
## F-statistic: 57.31 on 2 and 198 DF, p-value: < 2.2e-16
summary(model4)
##
## Call:
## lm(formula = hpi_growth ~ mort_change_lag3, data = df_ts)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.043804 -0.005234 -0.000045 0.005268 0.052396
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.0123762 0.0009879 12.528 <2e-16 ***
## mort_change_lag3 0.0001722 0.0020339 0.085 0.933
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.01393 on 197 degrees of freedom
## (4 observations deleted due to missingness)
## Multiple R-squared: 3.637e-05, Adjusted R-squared: -0.00504
## F-statistic: 0.007165 on 1 and 197 DF, p-value: 0.9326
tibble(
Model = c("Model 1","Model 2","Model 3","Model 4"),
R_Squared = c(summary(model1)$r.squared,
summary(model2)$r.squared,
summary(model3)$r.squared,
summary(model4)$r.squared),
Adj_R_Sq = c(summary(model1)$adj.r.squared,
summary(model2)$adj.r.squared,
summary(model3)$adj.r.squared,
summary(model4)$adj.r.squared),
Mort_Coefficient = c(
coef(model1)["mort_change"],
coef(model2)["mort_change"],
coef(model3)["mort_change_lag1"],
coef(model4)["mort_change_lag3"]
),
Mort_P_Value = c(
summary(model1)$coefficients["mort_change","Pr(>|t|)"],
summary(model2)$coefficients["mort_change","Pr(>|t|)"],
summary(model3)$coefficients["mort_change_lag1","Pr(>|t|)"],
summary(model4)$coefficients["mort_change_lag3","Pr(>|t|)"]
)
)
hpi_growth_ts <- na.omit(df_ts$hpi_growth)
mort_change_ts <- na.omit(df_ts$mort_change)
par(mfrow=c(2,2))
acf(hpi_growth_ts, main="ACF: HPI Growth")
pacf(hpi_growth_ts, main="PACF: HPI Growth")
acf(mort_change_ts, main="ACF: Mortgage Rate Change")
pacf(mort_change_ts, main="PACF: Mortgage Rate Change")
par(mfrow=c(1,1))
adf_hpi <- adf.test(hpi_growth_ts)
adf_mort <- adf.test(mort_change_ts)
dir.create("figures", showWarnings = FALSE)
# ACF & PACF for HPI Growth
png("figures/acf_pacf_hpi_growth.png", width = 1600, height = 1000, res = 200)
par(mfrow = c(1, 2))
acf(hpi_growth_ts, main = "ACF: HPI Growth")
pacf(hpi_growth_ts, main = "PACF: HPI Growth")
par(mfrow = c(1, 1))
dev.off()
# ACF & PACF for Mortgage Rate Change
png("figures/acf_pacf_mort_change.png", width = 1600, height = 1000, res = 200)
par(mfrow = c(1, 2))
acf(mort_change_ts, main = "ACF: Mortgage Rate Change")
pacf(mort_change_ts, main = "PACF: Mortgage Rate Change")
par(mfrow = c(1, 1))
dev.off()
tibble(
Series = c("HPI Growth","Mortgage Rate Change"),
ADF_Statistic = c(adf_hpi$statistic, adf_mort$statistic),
P_Value = c(adf_hpi$p.value, adf_mort$p.value)
)
df_pre2000 <- df_ts %>% filter(DATE < as.Date("2000-01-01"))
df_post2000 <- df_ts %>% filter(DATE >= as.Date("2000-01-01"))
tibble(
Period = c("Pre-2000","Post-2000"),
Correlation = c(
cor(df_pre2000$mort_change, df_pre2000$hpi_growth, use="complete.obs"),
cor(df_post2000$mort_change, df_post2000$hpi_growth, use="complete.obs")
)
)
res1 <- resid(model1)
res2 <- resid(model2)
res3 <- resid(model3)
par(mfrow=c(3,2))
acf(res1, main="ACF Residuals: Model 1"); pacf(res1, main="PACF: Model 1")
acf(res2, main="ACF Residuals: Model 2"); pacf(res2, main="PACF: Model 2")
acf(res3, main="ACF Residuals: Model 3"); pacf(res3, main="PACF: Model 3")
par(mfrow=c(1,1))
# Create figures folder if it doesn't exist
dir.create("figures", showWarnings = FALSE)
png("figures/residual_acf_pacf_models_1_3.png",
width = 1800, height = 1400, res = 200)
par(mfrow = c(3, 2))
acf(res1, main = "ACF Residuals: Model 1")
pacf(res1, main = "PACF Residuals: Model 1")
acf(res2, main = "ACF Residuals: Model 2")
pacf(res2, main = "PACF Residuals: Model 2")
acf(res3, main = "ACF Residuals: Model 3")
pacf(res3, main = "PACF Residuals: Model 3")
par(mfrow = c(1, 1))
dev.off()
par(mfrow=c(1,2))
acf(df_ts$HPI, main="ACF: HPI Level")
acf(na.omit(df_ts$hpi_growth), main="ACF: HPI Growth")
par(mfrow=c(1,1))
dir.create("figures", showWarnings = FALSE)
png("figures/acf_hpi_level_vs_growth.png",
width = 1600, height = 900, res = 200)
par(mfrow = c(1, 2))
acf(df_ts$HPI, main = "ACF: HPI Level")
acf(na.omit(df_ts$hpi_growth), main = "ACF: HPI Growth")
par(mfrow = c(1, 1))
dev.off()
df_smooth <- df_ts %>%
mutate(
mort_ma4 = rollmean(MORTGAGE_RATE, 4, fill=NA, align="right"),
hpi_growth_ma4 = rollmean(hpi_growth, 4, fill=NA, align="right")
)
ggplot(df_smooth, aes(DATE)) +
geom_line(aes(y=MORTGAGE_RATE), alpha=0.4) +
geom_line(aes(y=mort_ma4)) +
labs(title="4-Quarter Rolling Average: Mortgage Rate")
ggplot(df_smooth, aes(DATE)) +
geom_line(aes(y=hpi_growth), alpha=0.4) +
geom_line(aes(y=hpi_growth_ma4)) +
labs(title="4-Quarter Rolling Average: HPI Growth")
dir.create("figures", showWarnings = FALSE)
# Mortgage rate plot
p_mort <- ggplot(df_ts, aes(DATE, MORTGAGE_RATE)) +
geom_line() +
labs(
title = "Mortgage Rates",
x = "Date",
y = "Rate (%)"
)
# HPI growth plot
p_hpi <- ggplot(df_ts, aes(DATE, hpi_growth)) +
geom_line() +
labs(
title = "HPI Growth",
x = "Date",
y = "Growth Rate"
)
ggsave("figures/mortgage_rate_plot.png", p_mort, width = 7, height = 5, dpi = 200)
ggsave("figures/hpi_growth_plot.png", p_hpi, width = 7, height = 5, dpi = 200)
Across correlations, regressions, PACF analysis, ADF stationarity tests, and residual diagnostics, the results consistently show that mortgage rate changes have very weak short-run effects on national housing price growth. The strongest predictor of current HPI growth is lagged HPI growth, not mortgage rate changes. Overall, housing prices tend to follow internal momentum and broader economic factors rather than reacting strongly to quarter-to-quarter rate movements.