
Expected loss and regulatory capital
Source:vignettes/articles/expected-loss-and-capital.Rmd
expected-loss-and-capital.RmdThis is the third of three articles on the IRB risk parameters. PD calibration and rating
grades builds the probability of default and LGD and EAD under IRB the loss
given default and the exposure at default, both starting from the
scorecard pipeline of Get
started. This article turns the three parameters into expected loss,
IRB risk weights, capital with the output floor and accounting ECL. It
works on the bundled scr_demo_portfolio, whose PD, LGD and
EAD columns are of the kind those models produce (grade PDs, pool LGDs,
exposures), rather than on the objects built there, so that the
portfolio can cover six asset classes at once.
The regulatory texts behind the parameter tables are listed in scr_irb_params().
Regulation changes and each supervisor interprets it: check the tables
against the texts in force before any regulatory use.
library(scorecraft)
library(data.table)
cfg <- scr_config(verbose = FALSE, nthread = 2)1. Expected and unexpected loss
The loss a portfolio produces in an average year is its
expected loss, EL = PD * LGD * EAD; it is
a cost of doing business and is covered by provisions and pricing. The
loss in a bad year exceeds that average, and the gap between a high
quantile of the loss distribution and its mean is the unexpected
loss that capital must absorb. The IRB risk-weight function
turns a through-the-cycle PD into the PD of a year at the 99.9% quantile
of a one-factor model, and charges capital for the difference between
the loss at that quantile and the expected loss. Every number that a
regime fixes in that calculation (PD and LGD floors, asset correlations,
the maturity rule, the confidence level, the standardized risk weights
that the output floor compares against) lives in a table returned by
scr_irb_params() and selected by a preset; nothing is
hard-coded in the functions that consume it, and an edited table is
recorded as such on every object built from it.
2. The parameter tables
scr_irb_params() returns every regime-specific number as
a table; the print summarizes them.
p <- scr_irb_params("bcb")
p
#> <scr_irb_params> framework: bcb
#> BCB Resolutions 303/2023 (IRB) and 229/2022 (standardized); values as tables, editable
#> PD floors: corporate 0.05% | bank 0.05% | sovereign none | retail_mortgage 0.05% | qrre_transactor 0.05% | qrre_revolver 0.10% | retail_other 0.05%
#> LGD floors (unsecured): corporate 25% | retail_mortgage n/a | qrre 50% | retail_other 30%
#> F-IRB LGD: senior_unsecured 75% | priority_claim 45% | subordinated 75% | secured_financial 0% | secured_receivables 20% | secured_real_estate 20% | secured_other 25%
#> CCF (standardized): uncond_cancellable 10% | commitment 40% | nif_ruf 50% | direct_substitute 100% | own-estimate floor 50% of the standardized value
#> correlation: corporate 0.12-0.24 (k=50) | mortgage 0.15 | QRRE 0.04 | other retail 0.03-0.16 (k=35) | FI x1.25 | SME adj 0.04 (BRL m 15-300)
#> confidence 0.999 | scaling factor 1 | M default 2.5 in [1, 5] | output floor 72.5% | SA risk weights: 26 rows
p$pd_floor
#> asset_class floor
#> <char> <num>
#> 1: corporate 5e-04
#> 2: bank 5e-04
#> 3: sovereign NA
#> 4: retail_mortgage 5e-04
#> 5: qrre_transactor 5e-04
#> 6: qrre_revolver 1e-03
#> 7: retail_other 5e-04There are three presets. "bcb" takes its values from BCB
Resolution 303/2023, "basel3_final" from the consolidated
Basel Framework, whose CRE31 chapter is the risk-weight function, and
"crr3" from the EU Capital Requirements Regulation as
amended in 2024; "crr3" currently carries the same tables
as "basel3_final". Between "bcb" and
"basel3_final" the visible differences are the supervisory
LGD table of the foundation approach (the "bcb" preset
carries 75% for a senior unsecured claim and 45% for a claim with
priority; the Basel table separates corporates, 40%, from financial
institutions, 45%) and the firm-size bounds of the SME correlation
adjustment. Each table is a default to be checked against the text in
force, not a citation.
p_b3 <- scr_irb_params("basel3_final")
rbind(cbind(framework = "bcb", p$lgd_firb), cbind(framework = "basel3_final", p_b3$lgd_firb))
#> framework claim lgd
#> <char> <char> <num>
#> 1: bcb senior_unsecured 0.75
#> 2: bcb priority_claim 0.45
#> 3: bcb subordinated 0.75
#> 4: bcb secured_financial 0.00
#> 5: bcb secured_receivables 0.20
#> 6: bcb secured_real_estate 0.20
#> 7: bcb secured_other 0.25
#> 8: basel3_final senior_unsecured_corporate 0.40
#> 9: basel3_final senior_unsecured_fi 0.45
#> 10: basel3_final subordinated 0.75
#> 11: basel3_final secured_financial 0.00
#> 12: basel3_final secured_receivables 0.20
#> 13: basel3_final secured_real_estate 0.20
#> 14: basel3_final secured_other 0.25
rbindlist(list(c(framework = "bcb", p$correlation$sme), c(framework = "basel3_final", p_b3$correlation$sme)))
#> framework lo hi adj unit
#> <char> <num> <num> <num> <char>
#> 1: bcb 15 300 0.04 BRL m
#> 2: basel3_final 5 50 0.04 EUR mAny cell can be edited and the object passed back through
params; the function that receives it flags
params_modified in its model card.
3. One exposure: scr_el() and
scr_irb_rw()
Expected loss is the primitive. Defaulted exposures replace
PD * LGD with the best estimate of expected loss,
ELBE.
scr_el(c(0.01, 0.02), 0.45, c(1000, 2000))
#> [1] 4.5 18.0
scr_el(0.02, 0.45, 1000, defaulted = TRUE, elbe = 0.6)
#> [1] 600scr_irb_rw() returns the intermediate quantities of the
risk-weight function, not only the answer: the PD and LGD after floors,
the maturity after clipping, the correlation r, the
maturity adjustment ma and the capital requirement
k. A corporate exposure at PD 1%, LGD 45% and maturity 2.5
years carries a risk weight of 92.32%.
scr_irb_rw(0.01, 0.45, m = 2.5, asset_class = "corporate", params = p)
#> pd_used lgd_used m r b ma k rw
#> <num> <num> <num> <num> <num> <num> <num> <num>
#> 1: 0.01 0.45 2.5 0.1927837 0.1374861 1.25981 0.07385344 0.923168
#> rwa
#> <num>
#> 1: 0.923168
rbind(
mortgage = scr_irb_rw(0.01, 0.20, asset_class = "retail_mortgage", params = p),
qrre = scr_irb_rw(0.02, 0.80, asset_class = "qrre_revolver", params = p),
retail_other = scr_irb_rw(0.02, 0.50, asset_class = "retail_other", params = p)
)[, .(pd_used, lgd_used, r, k, rw)]
#> pd_used lgd_used r k rw
#> <num> <num> <num> <num> <num>
#> 1: 0.01 0.2 0.15000000 0.02005295 0.2506619
#> 2: 0.02 0.8 0.04000000 0.04113480 0.5141850
#> 3: 0.02 0.5 0.09455609 0.05154350 0.6442938Retail classes have no maturity adjustment (b is
NA, ma is one), so for them K is
exactly LGD times the distance between the stressed and the
unconditional PD. scr_pd_stress() is that stressed PD, and
at q = 0.999 with the regulatory correlation it reproduces
k to machine precision.
o <- scr_irb_rw(0.02, 0.50, asset_class = "retail_other", params = p)
c(k = o$k, by_hand = 0.5 * (scr_pd_stress(0.02, o$r, 0.999) - 0.02))
#> k by_hand
#> 0.0515435 0.0515435
scr_pd_stress(0.02, rho = 0.15, q = c(0.5, 0.95, 0.99, 0.999))
#> [1] 0.01295348 0.06219237 0.10558734 0.17632894The shape of the function differs by asset class because the correlation does: fixed for mortgages (0.15) and revolving retail (0.04), decreasing in PD for corporates and other retail. Plotted at a common LGD of 45%:
grid <- exp(seq(log(1e-4), log(0.3), length.out = 80))
classes <- c("corporate", "retail_mortgage", "qrre_revolver", "retail_other")
curves <- lapply(classes, function(a) scr_irb_rw(grid, 0.45, asset_class = a, params = p)$rw)
plot(grid, curves[[1]], type = "n", log = "x", ylim = c(0, max(unlist(curves))),
xlab = "PD (log scale)", ylab = "risk weight", main = "IRB risk weight by PD, LGD 45%")
for (i in seq_along(classes)) lines(grid, curves[[i]], lwd = 2, col = i)
legend("topleft", classes, col = seq_along(classes), lwd = 2, bty = "n")
The flat start of every curve is the PD floor: below it the risk
weight no longer falls with the PD. The floors_hit
attribute counts the rows where each floor was binding, and
apply_floors = FALSE shows what the function would return
without them.
r_floored <- scr_irb_rw(grid, 0.45, asset_class = "retail_other", params = p)
attr(r_floored, "floors_hit")
#> floor n
#> <char> <int>
#> 1: pd_floor 16
#> 2: lgd_floor 0
#> 3: m_floor 0
#> 4: m_cap 0
r_raw <- scr_irb_rw(grid, 0.45, asset_class = "retail_other", params = p, apply_floors = FALSE)
data.table(pd = grid, rw_floored = r_floored$rw, rw_no_floor = r_raw$rw)[pd < 6e-4][c(1, 5, 10, 15)]
#> pd rw_floored rw_no_floor
#> <num> <num> <num>
#> 1: 0.0001000000 0.06629119 0.01834523
#> 2: 0.0001499881 0.06629119 0.02554640
#> 3: 0.0002489589 0.06629119 0.03839309
#> 4: 0.0004132365 0.06629119 0.05720678
# LGD floors: an unsecured own estimate of 10% is lifted to the floor of its class,
# except for the mortgage, whose 5% floor does not bind
scr_irb_rw(0.01, 0.10, asset_class = c("retail_other", "qrre_revolver", "corporate", "retail_mortgage"), params = p)$lgd_used
#> [1] 0.30 0.50 0.25 0.10
# secured by real estate, the retail floor is 10% instead of the unsecured 30%
scr_irb_rw(0.01, 0.05, asset_class = "retail_other", collateral = "real_estate", params = p)$lgd_used
#> [1] 0.14. The standardized comparison
The output floor compares the IRB result with a fraction of what the
standardized approach would require, so the same book needs a
standardized risk weight per exposure. scr_sa_rw() looks it
up in params$sa_rw: regulatory retail, mortgages by
loan-to-value band, corporates by rating bucket or the SME weight,
defaulted exposures by provision ratio.
scr_sa_rw(c("retail_other", "retail_mortgage", "retail_mortgage", "corporate", "corporate", "corporate_sme"),
ltv = c(NA, 0.55, 0.95, NA, NA, NA), rating = c(NA, NA, NA, "A+", NA, NA))
#> [1] 0.75 0.25 0.50 0.50 1.00 0.85
scr_sa_rw("retail_other", defaulted = TRUE, provision_ratio = c(0.1, 0.3))
#> [1] 1.5 1.0
p$sa_rw[asset_class == "retail_mortgage" & sub_class == "standard"]
#> asset_class sub_class ltv_lo ltv_hi rw
#> <char> <char> <num> <num> <num>
#> 1: retail_mortgage standard 0.0 0.5 0.20
#> 2: retail_mortgage standard 0.5 0.6 0.25
#> 3: retail_mortgage standard 0.6 0.8 0.30
#> 4: retail_mortgage standard 0.8 0.9 0.40
#> 5: retail_mortgage standard 0.9 1.0 0.50
#> 6: retail_mortgage standard 1.0 Inf 0.705. The portfolio: scr_capital()
scr_demo_portfolio holds 5,000 exposures in six segments
that map one to one onto the asset classes; PD is a grade PD and LGD a
pool value, so segment by grade is homogeneous in PD and LGD, while
maturity varies inside the corporate pools (section 6 comes back to
this). About 3% of the rows are in default with an elbe and
a provision. The whole book is run under the advanced approach
(capital_approach = "airb", the default) to show every
input at work; under the final Basel framework and CRR3 a book of large
corporates would be restricted to the foundation approach, shown at the
end of this section.
d <- scr_demo_portfolio
table(d$segment, d$asset_class)
#>
#> corporate corporate_sme qrre_revolver qrre_transactor
#> cards_revolver 0 0 800 0
#> cards_transactor 0 0 0 500
#> corporate_large 500 0 0 0
#> corporate_sme 0 700 0 0
#> mortgages 0 0 0 0
#> retail_loans 0 0 0 0
#>
#> retail_mortgage retail_other
#> cards_revolver 0 0
#> cards_transactor 0 0
#> corporate_large 0 0
#> corporate_sme 0 0
#> mortgages 1000 0
#> retail_loans 0 1500
cap <- scr_capital(d, segment = "segment", asset_class = "asset_class", m = "m",
defaulted = "defaulted", elbe = "elbe", provisions = "provision",
ltv = "ltv", rating = "rating", sales = "sales", transactor = "transactor",
grade = "grade", id = "id", config = cfg, keep_rows = TRUE)
cap
#> <scr_capital> bcb | airb | 5,000 exposures in 6 segments
#> EAD 1,940,402,792 | EL 31,028,477 (1.60%) | RWA IRB 1,214,315,257 | density 62.6% | capital (8.0%) 97,145,221
#> standardized RWA 1,553,528,212 | IRB/SA 0.782 | output floor 72.5%: not binding (headroom 88,007,303)
#> provisions 43,425,571 vs EL: shortfall 0 | excess 12,397,094 | tier 2 add-back 7,285,892 (cap 7,285,892)
#> floors: pd 194 rows, RWA 5,221,058 | lgd 0 rows, RWA 0 | m 0 rows, RWA 0 | HHI 0.00231 (n_eff 433, max share 1.19%)
#> top segments by RWA:
#> corporate_large n 500 EAD 1,451,911,238 RW 65.1% RWA 944,938,904 IRB/SA 0.77
#> corporate_sme n 700 EAD 302,516,540 RW 79.2% RWA 239,492,431 IRB/SA 0.92
#> mortgages n 1,000 EAD 165,305,905 RW 12.6% RWA 20,764,980 IRB/SA 0.37
#> retail_loans n 1,500 EAD 15,592,011 RW 44.5% RWA 6,935,236 IRB/SA 0.59
#> cards_revolver n 800 EAD 3,282,347 RW 54.2% RWA 1,777,936 IRB/SA 0.71
#> sensitivity: vasicek_q0.99 +95.8% | vasicek_q0.95 +55.7% | r_x1.25 +26.8%Totals and the reconciliation by segment
Every figure on the print is in cap$totals, and
cap$segments breaks it down: EAD-weighted inputs, the IRB
and standardized risk-weighted assets side by side, expected loss and
provisions.
with(cap$totals, data.table(ead, el, el_rate, rwa_irb, rwa_sa, irb_sa_ratio, density, capital))
#> ead el el_rate rwa_irb rwa_sa irb_sa_ratio density
#> <num> <num> <num> <num> <num> <num> <num>
#> 1: 1940402792 31028477 0.01599074 1214315257 1553528212 0.78165 0.6258058
#> capital
#> <num>
#> 1: 97145221
cap$segments[, .(segment, n, ead, pd_mean, lgd_mean, rw, rwa_irb, irb_sa_ratio, el, provisions, shortfall_excess)]
#> segment n ead pd_mean lgd_mean rw rwa_irb
#> <char> <int> <num> <num> <num> <num> <num>
#> 1: corporate_large 500 1451911238 0.03359715 0.40 0.6508242 944938904.4
#> 2: corporate_sme 700 302516540 0.06973651 0.42 0.7916672 239492431.3
#> 3: mortgages 1000 165305905 0.01792392 0.15 0.1256155 20764979.8
#> 4: retail_loans 1500 15592011 0.05400681 0.45 0.4447942 6935236.3
#> 5: cards_revolver 800 3282347 0.07501589 0.75 0.5416660 1777935.7
#> 6: cards_transactor 500 1794751 0.03000090 0.70 0.2260868 405769.5
#> irb_sa_ratio el provisions shortfall_excess
#> <num> <num> <num> <num>
#> 1: 0.7729808 20926478.3 30840652 9914173.739
#> 2: 0.9233544 9087003.5 10039600 952596.493
#> 3: 0.3672066 421450.8 1963358 1541907.188
#> 4: 0.5866356 374260.3 396848 22587.723
#> 5: 0.7118514 181708.6 142177 -39531.608
#> 6: 0.4907211 37575.5 42936 5360.503The irb_sa_ratio column says where the IRB approach
saves the most against the standardized one; mortgages sit far below the
72.5% line on their own, the large corporate book just above it and the
SME book well above, and the output floor is applied to the total, not
by segment. The total here is credit risk only: the regulatory floor
compares the whole of the risk-weighted assets, market and operational
risk included, with 72.5% of their standardized counterparts, so the
figure below is a credit-portfolio view of the floor, not the
bank’s.
Floors and the output-floor bridge
cap$floors measures each input floor by the rows it
binds, their EAD and the risk-weighted assets it adds. Here only the PD
floor bites (the two safest grades start below it on purpose), and the
bridge from the unfloored IRB figure to the reported one reads:
cap$floors
#> floor n_hit ead_hit delta_rwa
#> <char> <int> <num> <num>
#> 1: pd_floor 194 110745561 5221058
#> 2: lgd_floor 0 0 0
#> 3: m_floor 0 0 0
with(cap$totals, data.table(
step = c("rwa_irb_no_floors", "input_floors", "rwa_irb", "rwa_sa", "output_floor_rwa", "rwa_reported"),
value = c(rwa_irb_no_floors, rwa_irb - rwa_irb_no_floors, rwa_irb, rwa_sa, rwa_floor, rwa_reported)))
#> step value
#> <char> <num>
#> 1: rwa_irb_no_floors 1209094199
#> 2: input_floors 5221058
#> 3: rwa_irb 1214315257
#> 4: rwa_sa 1553528212
#> 5: output_floor_rwa 1126307953
#> 6: rwa_reported 1214315257
with(cap$totals, data.table(floor_binding, headroom))
#> floor_binding headroom
#> <lgcl> <num>
#> 1: FALSE 88007303rwa_reported is the larger of the IRB figure and
output_floor * rwa_sa; headroom is the
distance to the floor, negative when it binds.
Expected loss against provisions
Regulatory EL is compared with the provision stock. A shortfall is
deducted from common equity tier 1; an excess counts as tier 2 up to
0.6% of the IRB risk-weighted assets, which is why
tier2_addback can be smaller than excess. The
comparison is made on the totals, defaulted and performing exposures
together. The Basel text (CRE35) and the CRR compare the two separately,
so that an excess of specific provisions on defaulted exposures cannot
cover a shortfall on performing ones; where that distinction matters,
run the function on each part and combine the results.
with(cap$totals, data.table(el, provisions, shortfall, excess, tier2_cap, tier2_addback))
#> el provisions shortfall excess tier2_cap tier2_addback
#> <num> <num> <num> <num> <num> <num>
#> 1: 31028477 43425571 0 12397094 7285892 7285892Sensitivity
The grid re-runs the whole function under fixed shocks. The two
Vasicek rows stress every performing PD with
scr_pd_stress() at the exposure’s own correlation and feed
the stressed PD back into the function, so that the correlation follows
the PD; they are the closest the grid comes to a bad-year capital
figure.
cap$sensitivity
#> shock rwa delta delta_pct
#> <char> <num> <num> <num>
#> 1: base 1214315257 0 0.000000000
#> 2: pd_x1.10 1256949940 42634684 0.035110062
#> 3: pd_x1.25 1315374005 101058749 0.083222827
#> 4: pd_x1.50 1401061020 186745763 0.153786887
#> 5: lgd_plus_5pp 1380658748 166343491 0.136985424
#> 6: ead_x1.10 1335746783 121431526 0.100000000
#> 7: no_floor 1209094199 -5221058 -0.004299591
#> 8: r_x1.25 1540264244 325948987 0.268422047
#> 9: vasicek_q0.95 1890754479 676439222 0.557054042
#> 10: vasicek_q0.99 2377857720 1163542463 0.958188128The foundation approach
Under capital_approach = "firb" the LGD is the
supervisory value of the claim type, read from
params$lgd_firb through the claim column, no
LGD floor applies and the maturity is fixed at
params$m_default. The claim names belong to the preset.
Here the large corporates are run as senior unsecured claims under the
two presets and set beside the advanced run of the same rows.
big <- d[d$segment == "corporate_large", ]
cap_firb <- function(framework, claim) {
big$claim <- claim
scr_capital(big, segment = "segment", asset_class = "asset_class", m = "m",
defaulted = "defaulted", elbe = "elbe", sales = "sales", rating = "rating",
grade = "grade", claim = "claim", params = scr_irb_params(framework),
config = scr_config(verbose = FALSE, nthread = 2, framework = framework,
capital_approach = "firb"))$totals
}
airb <- cap$segments[segment == "corporate_large"]
f_bcb <- cap_firb("bcb", "senior_unsecured")
f_b3 <- cap_firb("basel3_final", "senior_unsecured_corporate")
data.table(run = c("airb, own LGD", "firb, bcb preset", "firb, basel3_final preset"),
rwa_irb = c(airb$rwa_irb, f_bcb$rwa_irb, f_b3$rwa_irb),
el = c(airb$el, f_bcb$el, f_b3$el))
#> run rwa_irb el
#> <char> <num> <num>
#> 1: airb, own LGD 944938904 20926478
#> 2: firb, bcb preset 1625968473 25912479
#> 3: firb, basel3_final preset 867183186 20926478The preset drives the result. With the 40% of the Basel table, equal
to the own LGD of this book, the foundation run is lower than the
advanced one because the maturity is fixed at 2.5 years where the book
averages three, and because the defaulted rows carry no
LGD - ELBE charge under the foundation approach. With the
75% the "bcb" preset carries for a senior unsecured claim,
the risk-weighted assets rise by about 70% and the expected loss by
about a quarter (less than in proportion, because the defaulted rows
keep their ELBE).
6. The same numbers in SQL
The production query does not need a normal quantile: the constants
of every pool (PD, LGD, correlation, K, RW,
all after floors) are computed in R and emitted as a
pool_params CTE, and the exposure table is joined to it on
segment and grade. Defaulted rows use ELBE * EAD and
K = max(0, LGD - ELBE) from their own columns.
sql <- scr_sql(cap, table = "portfolio", dialect = "duckdb")
cat(grep("^-- (CTE| |NOTE)", sql, value = TRUE), sep = "\n")
#> -- CTE pool_params: PD, LGD, correlation, K and RW per pool, computed in R
#> -- (floors applied; no normal quantile needed at run time).
#> -- CTE exposure_capital: el = pd * lgd * ead, rwa = 12.5 * k * ead per exposure;
#> -- rows with defaulted = 1 use ELBE * ead and K = max(0, LGD - ELBE).
#> -- NOTE: at least one pool is not homogeneous: its constants are EAD-weighted, so
#> -- EL and RWA are exact per pool and approximate per exposure. Pass `grade` for finer pools.
cat(tail(sql, 21), sep = "\n")
#> SELECT
#> e.id,
#> e.segment,
#> e.grade,
#> e.ead AS ead,
#> CASE WHEN e.defaulted = 1 THEN COALESCE(e.elbe, e.lgd) * e.ead ELSE p.pd * p.lgd * e.ead END AS el,
#> CASE WHEN e.defaulted = 1 THEN (CASE WHEN e.lgd - COALESCE(e.elbe, e.lgd) > 0 THEN e.lgd - COALESCE(e.elbe, e.lgd) ELSE 0 END) ELSE p.k END AS k
#> FROM portfolio e
#> JOIN pool_params p ON e.segment = p.segment AND e.grade = p.grade
#> )
#>
#> SELECT
#> id,
#> segment,
#> grade,
#> ead,
#> el,
#> k,
#> 12.5 * k AS rw,
#> 12.5 * k * ead AS rwa
#> FROM exposure_capital;The retail pools are homogeneous, so the query reproduces R row by row; the corporate pools carry a maturity that varies inside the pool, so their constants are EAD-weighted and the match is exact at pool level (the header of the SQL says so). Both facts can be checked on DuckDB.
con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbWriteTable(con, "portfolio", d)
got <- DBI::dbGetQuery(con, paste(sql, collapse = "\n"))
got <- got[match(d$id, got$id), ]
ex <- cap$exposures
retail <- !d$segment %in% c("corporate_large", "corporate_sme")
data.table(id = d$id, segment = d$segment, el_r = ex$el, el_sql = got$el, rwa_r = ex$rwa, rwa_sql = got$rwa)[retail][1:4]
#> id segment el_r el_sql rwa_r rwa_sql
#> <char> <char> <num> <num> <num> <num>
#> 1: E00001 retail_loans 18.65707 18.65707 2386.003 2386.003
#> 2: E00002 mortgages 256.42922 256.42922 36989.679 36989.679
#> 3: E00003 cards_revolver 99.56714 99.56714 2210.792 2210.792
#> 4: E00004 mortgages 68.02380 68.02380 12261.888 12261.888
c(el = all.equal(got$el[retail], ex$el[retail]), rwa = all.equal(got$rwa[retail], ex$rwa[retail]))
#> el rwa
#> TRUE TRUE
agg <- DBI::dbGetQuery(con, paste(scr_sql(cap, table = "portfolio", dialect = "duckdb", level = "portfolio"), collapse = "\n"))
agg <- agg[match(cap$segments$segment, agg$segment), ]
c(rwa = all.equal(agg$rwa, cap$segments$rwa_irb), el = all.equal(agg$el, cap$segments$el))
#> rwa el
#> TRUE TRUE
DBI::dbDisconnect(con, shutdown = TRUE)7. Accounting expected credit loss: scr_ecl()
scr_ecl() computes the discrete-time expected credit
loss: a survival-weighted sum of marginal monthly hazards times LGD and
EAD, discounted at the effective interest rate. With a flat hazard, no
discounting and no prepayment the sum collapses to the closed form
LGD * EAD * (1 - (1 - h)^T).
cfg_none <- scr_config(verbose = FALSE, nthread = 2, ecl_discount = "none")
h <- 0.01; lgd <- 0.4; ead <- 1000
e1 <- scr_ecl(h, lgd, ead, t_max = 36L, config = cfg_none)
c(ecl_12m = e1$totals$ecl_12m, closed_form = lgd * ead * (1 - (1 - h)^12),
ecl_life = e1$totals$ecl_life, closed_form = lgd * ead * (1 - (1 - h)^36))
#> ecl_12m closed_form ecl_life closed_form
#> 45.44605 45.44605 121.43471 121.43471On the portfolio, the annual grade PD becomes a flat monthly hazard,
run over a term that depends on the product: twenty years for mortgages,
the contractual maturity for corporates and three years for the other
retail lines. The hazard matrix is zero after the term of each row, so
the lifetime figure stops there. The stage is allocated by the rule: 90
days past due for stage 3; 30 days past due, or a 12-month PD at least
twice the one at origination, for stage 2. The 30-day backstop is the
rebuttable presumption of IFRS 9; the ratio of two is a common
convention, set by ecl_sicr_ratio, not a threshold the
standard prescribes. Three scenarios are weighted. The z
shock moves every monthly hazard through the one-factor model with the
correlation rho, given explicitly here because the default
(0.15) is only a placeholder; applied month by month, the shock raises
the 12-month PD more than the same z applied to the annual
PD would. lgd_add and ead_mult do what their
names say.
Two inputs are shortcuts that a real ECL model would not take. The PD
here is a regulatory grade PD, through-the-cycle and possibly carrying a
margin and a floor, and the LGD is a pool value that may include a
downturn component; IFRS 9 asks for unbiased, point-in-time,
forward-looking estimates, such as pd_be mapped to the
current state of the cycle (see the point-in-time section of the PD
article) and an LGD without the downturn and the margin. The mechanics
below are unchanged by that choice; the numbers are not.
hz <- 1 - (1 - d$pd)^(1 / 12)
term <- ifelse(d$segment == "mortgages", 240L,
ifelse(is.na(d$m), 36L, as.integer(round(12 * d$m))))
hz_term <- outer(hz, rep(1, max(term)))
hz_term[col(hz_term) > term] <- 0
ecl <- scr_ecl(hz_term, d$lgd, d$ead, eir = d$eir, dpd = d$dpd, pd_orig = d$pd_orig,
rho = 0.10, segment = d$segment, id = d$id,
scenarios = list(base = list(), downturn = list(z = -1, lgd_add = 0.05), upturn = list(z = 1)),
weights = c(0.5, 0.3, 0.2), config = cfg)
ecl
#> <scr_ecl> 5,000 exposures | ECL 31,966,351 | coverage 1.65% | 12-month horizon 12 of 240 months | discount: eir
#> stage rule: dpd >= 90 -> stage 3; dpd >= 30 or PD ratio >= 2 -> stage 2
#> stage 1 n 4,304 EAD 1,687,138,403 ECL 8,069,792 coverage 0.48%
#> stage 2 n 542 EAD 201,418,836 ECL 2,429,902 coverage 1.21%
#> stage 3 n 154 EAD 51,845,553 ECL 21,466,657 coverage 41.41%
#> 12-month 30,524,224 | lifetime 44,113,928 | scenarios: base 0.50, downturn 0.30, upturn 0.20
ecl$scenarios
#> scenario weight ecl_12m ecl_life ecl
#> <char> <num> <num> <num> <num>
#> 1: base 0.5 28846459 41358454 30157406
#> 2: downturn 0.3 38686456 61177845 41134697
#> 3: upturn 0.2 22475291 25406736 22736196
ecl$stages
#> Key: <stage>
#> stage n ead ecl_12m ecl_life ecl coverage
#> <int> <int> <num> <num> <num> <num> <num>
#> 1: 1 4304 1687138403 8069792.3 20217368 8069792 0.004783124
#> 2: 2 542 201418836 987774.7 2429902 2429902 0.012063925
#> 3: 3 154 51845553 21466657.4 21466657 21466657 0.414050119Accounting ECL and regulatory EL measure different things and are not
expected to agree: the ECL of stage 2 is a lifetime figure, the ECL of
stage 3 is LGD * EAD rather than ELBE * EAD,
and the scenario weights tilt the accounting number. Laying the two side
by side per segment is nevertheless the first table a reconciliation
asks for.
merge(cap$segments[, .(segment, ead, el_regulatory = el, provisions)],
ecl$segments[, .(segment, ecl_accounting = ecl, share_stage2, share_stage3)], by = "segment")[order(-ead)]
#> segment ead el_regulatory provisions ecl_accounting
#> <char> <num> <num> <num> <num>
#> 1: corporate_large 1451911238 20926478.3 30840652 21178194.33
#> 2: corporate_sme 302516540 9087003.5 10039600 9497790.04
#> 3: mortgages 165305905 421450.8 1963358 652546.02
#> 4: retail_loans 15592011 374260.3 396848 408339.48
#> 5: cards_revolver 3282347 181708.6 142177 190857.73
#> 6: cards_transactor 1794751 37575.5 42936 38623.76
#> share_stage2 share_stage3
#> <num> <num>
#> 1: 0.0960000 0.02600000
#> 2: 0.1114286 0.04000000
#> 3: 0.1020000 0.01000000
#> 4: 0.1153333 0.03466667
#> 5: 0.1150000 0.05250000
#> 6: 0.0980000 0.01800000
c(el_regulatory = cap$totals$el, ecl_accounting = ecl$totals$ecl, provisions = cap$totals$provisions)
#> el_regulatory ecl_accounting provisions
#> 31028477 31966351 434255718. Deliverables
scr_export() writes one workbook with the summary, the
configuration and every table of the object, plus the SQL file.
out <- file.path(tempdir(), "scorecraft-capital")
ex <- scr_export(cap, out, stamp = FALSE)
basename(unlist(ex$files))
#> [1] "capital_bcb.xlsx" "sql_capital_bcb.sql"
openxlsx::getSheetNames(ex$files$xlsx)
#> [1] "Capital_Summary" "Capital_Config"
#> [3] "Segments_Reconciliation" "Pools"
#> [5] "Floors_Impact" "Output_Floor_Bridge"
#> [7] "Sensitivity" "Concentration"
#> [9] "EL_vs_Provisions" "Exposures"
#> [11] "Model_Card" "Decision_Ledger"9. The ledger and the model card
Every choice the function made on the caller’s behalf is a row of the ledger: the preset and approach, where the inputs came from, how many rows each floor touched, whether the output floor binds, and the provision comparison. The model card carries the numbers a validator needs to reproduce the run, including whether the parameter tables were edited.
cap$ledger[, .(action, detail)]
#> action
#> <char>
#> 1: framework
#> 2: inputs
#> 3: floors
#> 4: output_floor
#> 5: provisions
#> detail
#> <char>
#> 1: bcb | approach airb | params preset
#> 2: pd: column | lgd: column | ead: column | asset_class: column asset_class
#> 3: pd floor on 194 rows, lgd floor on 0 rows, maturity clipped on 0 rows
#> 4: 72.5% of the standardized RWA: not binding
#> 5: EL 31028477 vs provisions 43425571: shortfall 0, excess 12397094, tier 2 add-back 7285892
mc <- cap$model_card
keys <- c("framework", "approach", "params_modified", "pd_source", "n_exposures", "n_defaulted",
"n_pools", "rwa_irb", "rwa_sa", "output_floor_binding", "density", "capital",
"shortfall", "excess", "scaling_factor", "confidence")
data.table(field = keys, value = vapply(mc[keys], function(v) format(v, digits = 6), character(1)))
#> field value
#> <char> <char>
#> 1: framework bcb
#> 2: approach airb
#> 3: params_modified FALSE
#> 4: pd_source column
#> 5: n_exposures 5000
#> 6: n_defaulted 154
#> 7: n_pools 59
#> 8: rwa_irb 1214315257
#> 9: rwa_sa 1553528212
#> 10: output_floor_binding FALSE
#> 11: density 0.625806
#> 12: capital 97145221
#> 13: shortfall 0
#> 14: excess 12397094
#> 15: scaling_factor 1
#> 16: confidence 0.999Two runs with the same framework, approach,
params_modified = FALSE and the same input sources are
comparable; a run with params_modified = TRUE is a
different regime and must say which cells changed.