1. Introduction

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.


2. Data Import

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…

Interpretation

This section imports the datasets and checks the basic structure and formats before cleaning and merging.


3. Data 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)

Interpretation

Mortgage rates were averaged to quarterly frequency to match the HPI data. The merged dataset includes aligned timestamps for analysis.


4. Create Growth and Change Variables

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, …

Interpretation

These transformations help remove long-term trends and make the data appropriate for time-series analysis.


5. Descriptive Time-Series Plots

Mortgage Rates

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()

HPI Rebased to 100

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()

Growth Series as Percent

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()

Faceted Comparison Plot

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()

Interpretation

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.


6. Correlations and Lag Structure

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")

Clean Summary Table

tibble(
  Metric = c("Same-Period Correlation","Lag 1","Lag 2","Lag 3"),
  Value  = c(cor_same, cor_lag1, cor_lag2, cor_lag3)
)

Interpretation

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.


7. Scatterplot

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()


8. Regression Models (1, 2, 3)

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

12. Combined Model Summary Table

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|)"]
  )
)

9. PACF and ADF Tests

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()

Clean ADF Summary Table

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)
)

10. Subsample Analysis

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")
  )
)


13. Residual Diagnostics

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()

14. ACF of Levels vs Growth

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()

15. Rolling Averages

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")


16. Exporting Plots

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)

17. Conclusion

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.