This project includes stress testing, credit risk modeling, and capital impact analysis using macroeconomic scenarios on a loan portfolio.
2025-06-15
This project implements a stress testing framework combining macroeconomic scenario simulation, credit risk modeling, and capital impact analysis, using real-world French economic data. The analysis demonstrates how adverse economic conditions affect a loan portfolio’s expected losses and regulatory capital adequacy.
GDP, Unemployment, Inflation: Quarterly data from Eurostat, covering France.
Data cleaning includes calculating GDP growth rates, quarterly unemployment rates, and inflation.
Historical macroeconomic data is used to fit a Vector Autoregression (VAR) model for scenario simulation.
# if not installed:
# install.packages(c("tidyverse", "vars", "eurostat", "zoo", "dplyr"))
library(tidyverse)
library(vars)
library(eurostat)
library(zoo)
library(dplyr)
gdp_shock_size <- 2
unemp_shock_size <- 1
infl_shock_size <- 1.5
n_quarters <- 8
n_credits <- 1000
CET1_initial <- 1000
RWA <- 10000
# GDP
# search_eurostat("gdp france quarterly")
gdp_raw <- get_eurostat("namq_10_gdp", filters = list(
geo = "FR",
na_item = "B1GQ",
unit = "CLV10_MEUR"
))
# Unemployment
unemp_raw <- get_eurostat("une_rt_q", filters = list(
geo = "FR",
sex = "T",
age = "Y20-64",
unit = "PC_ACT"
))
# Inflation
inflation_raw <- get_eurostat("prc_hicp_midx", filters = list(
geo = "FR",
coicop = "CP00",
unit = "I15"
))
# GDP : growth rate
gdp_q <- gdp_raw %>%
arrange(time) %>%
mutate(
time = as.yearqtr(time),
gdp_growth = 100 * (values / lag(values) - 1)) %>%
filter(!is.na(gdp_growth)) %>%
dplyr::select(time, gdp_growth)
gdp_q_unique <- gdp_q %>%
group_by(time) %>%
summarise(gdp_growth = mean(gdp_growth, na.rm = TRUE))
# Unemployment rate
unemp_q <- unemp_raw %>%
dplyr::select(time, unemployment_rate = values) %>%
arrange(time) %>%
filter(!is.na(unemployment_rate)) %>%
mutate(time = as.yearqtr(time))
unemp_q_unique <- unemp_q %>%
group_by(time) %>%
summarise(unemployment = mean(unemployment_rate, na.rm = TRUE))
# Inflation
# Convert monthly index to quarterly average index
inflation_q <- inflation_raw %>%
mutate(quarter = as.yearqtr(time)) %>%
group_by(quarter) %>%
summarise(avg_index = mean(values, na.rm = TRUE)) %>%
arrange(quarter) %>%
# Compute quarter-on-quarter inflation rate in %
mutate(inflation = 100 * (avg_index / lag(avg_index) - 1)) %>%
filter(!is.na(inflation))
inflation_q <- inflation_q %>%
rename(time = quarter) %>%
dplyr::select(time, inflation)
# Merge
macro_data <- gdp_q_unique %>%
inner_join(unemp_q_unique, by = "time") %>%
inner_join(inflation_q, by = "time") %>%
arrange(time)
# For later: Assuming that initial GDP level is the last observed GDP level from the original data
last_gdp_level <- as.numeric(tail(gdp_raw$values, 1))
macro_data <- macro_data %>%
mutate(GDP_level = last_gdp_level * cumprod(1 + gdp_growth / 100))
A VAR model on GDP growth, unemployment, and inflation to capture dynamic relationships and forecast future quarters.
# Time series matix
macro_ts <- macro_data %>%
dplyr::select(gdp_growth, unemployment, inflation) %>%
as.matrix()
# Fit model
var_model <- VAR(macro_ts, p = 1, type = "const")
summary(var_model)
##
## VAR Estimation Results:
## =========================
## Endogenous variables: gdp_growth, unemployment, inflation
## Deterministic variables: const
## Sample size: 88
## Log Likelihood: -199.379
## Roots of the characteristic polynomial:
## 0.9622 0.2606 0.215
## Call:
## VAR(y = macro_ts, p = 1, type = "const")
##
##
## Estimation results for equation gdp_growth:
## ===========================================
## gdp_growth = gdp_growth.l1 + unemployment.l1 + inflation.l1 + const
##
## Estimate Std. Error t value Pr(>|t|)
## gdp_growth.l1 -0.26162 0.10647 -2.457 0.0161 *
## unemployment.l1 0.05025 0.13628 0.369 0.7132
## inflation.l1 0.23012 0.22693 1.014 0.3135
## const -0.31933 1.18925 -0.269 0.7890
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
##
## Residual standard error: 1.12 on 84 degrees of freedom
## Multiple R-Squared: 0.0733, Adjusted R-squared: 0.0402
## F-statistic: 2.215 on 3 and 84 DF, p-value: 0.09238
##
##
## Estimation results for equation unemployment:
## =============================================
## unemployment = gdp_growth.l1 + unemployment.l1 + inflation.l1 + const
##
## Estimate Std. Error t value Pr(>|t|)
## gdp_growth.l1 -0.09941 0.02713 -3.665 0.000433 ***
## unemployment.l1 0.94586 0.03472 27.242 < 2e-16 ***
## inflation.l1 -0.10446 0.05782 -1.807 0.074374 .
## const 0.50552 0.30300 1.668 0.098964 .
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
##
## Residual standard error: 0.2854 on 84 degrees of freedom
## Multiple R-Squared: 0.919, Adjusted R-squared: 0.9161
## F-statistic: 317.8 on 3 and 84 DF, p-value: < 2.2e-16
##
##
## Estimation results for equation inflation:
## ==========================================
## inflation = gdp_growth.l1 + unemployment.l1 + inflation.l1 + const
##
## Estimate Std. Error t value Pr(>|t|)
## gdp_growth.l1 0.01651 0.05250 0.314 0.7540
## unemployment.l1 -0.12143 0.06720 -1.807 0.0744 .
## inflation.l1 0.23233 0.11190 2.076 0.0409 *
## const 1.36310 0.58644 2.324 0.0225 *
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
##
## Residual standard error: 0.5524 on 84 degrees of freedom
## Multiple R-Squared: 0.1345, Adjusted R-squared: 0.1035
## F-statistic: 4.35 on 3 and 84 DF, p-value: 0.006728
##
##
##
## Covariance matrix of residuals:
## gdp_growth unemployment inflation
## gdp_growth 1.25469 0.13320 0.03814
## unemployment 0.13320 0.08145 -0.05403
## inflation 0.03814 -0.05403 0.30510
##
## Correlation matrix of residuals:
## gdp_growth unemployment inflation
## gdp_growth 1.00000 0.4167 0.06165
## unemployment 0.41667 1.0000 -0.34276
## inflation 0.06165 -0.3428 1.00000
Generate baseline and stressed macroeconomic paths. The stress scenario applies potential shocks to GDP (-2%), unemployment (+1%), and inflation (+1.5%), decreasing exponentially over time.
# Baseline:
set.seed(123)
sim_base <- predict(var_model, n.ahead = n_quarters)
gdp_sim <- sim_base$fcst$gdp_growth[,1]
unemp_sim <- sim_base$fcst$unemployment[,1]
infl_sim <- sim_base$fcst$inflation[,1]
sim_data <- tibble(
quarter = 1:n_quarters,
scenario = "baseline",
gdp_growth = gdp_sim,
unemployment = unemp_sim,
inflation = infl_sim
)
sim_data <- sim_data %>%
group_by(scenario) %>%
mutate(GDP_level = last_gdp_level * cumprod(1 + gdp_growth / 100)) %>%
ungroup()
# Stressed:
gdp_stress <- numeric(n_quarters)
unemp_stress <- numeric(n_quarters)
infl_stress <- numeric(n_quarters)
# Apply shocks at quarter 1
for (i in 1:n_quarters) {
gdp_stress[i] <- gdp_sim[i] - gdp_shock_size * exp(-0.3 * (i - 1))
unemp_stress[i] <- unemp_sim[i] + unemp_shock_size * exp(-0.3 * (i - 1))
infl_stress[i] <- infl_sim[i] + infl_shock_size * exp(-0.3 * (i - 1))
}
sim_data_stress <- tibble(
quarter = 1:n_quarters,
scenario = "stress",
gdp_growth = gdp_stress,
unemployment = unemp_stress,
inflation = infl_stress
)
sim_data_stress <- sim_data_stress %>%
group_by(scenario) %>%
mutate(GDP_level = last_gdp_level * cumprod(1 + gdp_growth / 100)) %>%
ungroup()
# Merge
sim_all <- bind_rows(sim_data, sim_data_stress)
sim_all %>%
pivot_longer(cols = c(gdp_growth, unemployment, inflation), names_to = "variable", values_to = "value") %>%
ggplot(aes(x = quarter, y = value, color = scenario)) +
geom_line(size = 1) +
facet_wrap(~variable, scales = "free_y") +
theme_minimal() +
scale_color_brewer(palette = "Set1") +
labs(title = "VAR Scenario Simulation: Baseline vs Stressed", y = "", x = "Quarter")
The stressed scenario results in a deep, prolonged GDP contraction, rapid disinflation, and a persistent rise in unemployment compared to baseline. This highlights significant macroeconomic risks and potential credit deterioration in adverse conditions.
Simulation of a synthetic loan portfolio with Exposure at Default (EAD), Loss Given Default (LGD), and sector assignments.
set.seed(123) # ensure the random numbers are reproducible
portfolio <- tibble(
id = 1:n_credits,
sector = sample(c("Industry", "Services", "Construction"), n_credits, replace = TRUE), #randomly assign a sector to each loan
EAD = runif(n_credits, 100000, 1000000), #exposure at default
LGD = runif(n_credits, 0.2, 0.5) #loss given default
)
Note: The Probability of Default model is based on synthetic training data for illustrative purposes. Real-world calibration would require actual default histories.
set.seed(456)
gdp_growth_train <- rnorm(40, mean = 0.5, sd = 1)
macro_data_pd <- tibble(
quarter = 1:40,
GDP = gdp_growth_train,
unemployment = 8 + cumsum(rnorm(40, 0, 0.2)),
inflation = 1.5 + cumsum(rnorm(40, 0, 0.1)),
default_rate = 0.02 + 0.005 * (0.5 - GDP) + 0.01 * (unemployment - 8) + rnorm(40, 0, 0.002)
)
A Probability of Default (PD) model linking macroeconomic variables to default rates using logistic regression.
# Probability of default (PD) as a function of macro variables
macro_data_pd <- macro_data_pd %>%
mutate(default_flag = ifelse(default_rate > median(default_rate), 1, 0))
# 1 if default rate above median, 0 otherwise
model_pd <- glm(default_flag ~ GDP + unemployment + inflation, data = macro_data_pd, family = binomial)
# fits a generalized linear model
sim_all <- sim_all %>%
mutate(GDP = gdp_growth)
sim_all <- sim_all %>%
mutate(
PD_sim = predict(
model_pd,
newdata = tibble(GDP = GDP, unemployment = unemployment, inflation = inflation),
type = "response"
),
)
Calculation of expected losses by sector and quarter, adjusting PD by sector risk premiums.
portfolio_expanded <- sim_all %>%
dplyr::select(scenario, quarter, PD_sim) %>%
crossing(portfolio) %>%
mutate(
sector_adj = case_when(
sector == "Industry" ~ 0.01,
sector == "Services" ~ 0.005,
sector == "Construction" ~ 0.02,
TRUE ~ 0
),
PD_sim = pmin(PD_sim + sector_adj, 0.5), # makes sure PD does not exceed 50%
Expected_Loss = PD_sim * LGD * EAD
)
# Losses by quarter
losses_by_sector_quarter <- portfolio_expanded %>%
group_by(scenario, quarter, sector) %>%
summarise(Total_Loss = sum(Expected_Loss)/1e6, .groups = "drop")
ggplot(losses_by_sector_quarter, aes(x = quarter, y = Total_Loss, color = sector)) +
geom_line(size = 1) +
facet_wrap(~scenario) +
theme_minimal() +
scale_color_brewer(palette = "Set1") +
labs(title = "Expected Losses Over Time by Sector",
x = "Quarter", y = "Expected Loss (Million €)")
This highlights the importance of capital buffers to absorb losses during severe downturns and the systemic nature of macro shocks.
Assessment of the impact of stressed losses on CET1 capital and capital adequacy ratios, assuming initial capital and risk-weighted assets.
final_losses_stress <- losses_by_sector_quarter %>%
filter(scenario == "stress") %>%
summarise(total = sum(Total_Loss)) %>%
pull(total)
CET1_final <- CET1_initial - final_losses_stress
CET1_ratio <- CET1_final / RWA
CET1_ratio_pct <- CET1_ratio * 100
print(paste0("Total expected stress loss: ", round(final_losses_stress,1), "M€"))
## [1] "Total expected stress loss: 241.3M€"
if (final_losses_stress > CET1_initial) {
warning("⚠️ Losses exceed CET1 — check calibration.")
}
print(paste0("Final CET1: ", round(CET1_final, 1), "M€"))
## [1] "Final CET1: 758.7M€"
print(paste0("CET1 ratio under stress: ", round(CET1_ratio_pct, 2), " %"))
## [1] "CET1 ratio under stress: 7.59 %"
capital_buffer_pct <- (CET1_final / CET1_initial) * 100
print(paste0("Capital buffer remaining under stress: ", round(capital_buffer_pct, 2), "%"))
## [1] "Capital buffer remaining under stress: 75.87%"
print(paste0("Capital buffer consumed under stress: ", round(100 - capital_buffer_pct, 2), "%"))
## [1] "Capital buffer consumed under stress: 24.13%"
Although losses are significant, the CET1 ratio stays above regulatory minimums, and most of the capital buffer remains intact. This suggests a solid capital position and the ability to absorb severe macroeconomic shocks without breaching regulatory thresholds.