Skip to contents

This is the second of three articles on the IRB risk parameters. The first, PD calibration and rating grades, builds the probability of default from a scorecard (see Get started for the scorecard itself); the third, Expected loss and regulatory capital, combines PD, LGD and EAD into expected loss, capital and accounting ECL.

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.

The two IRB parameters after the PD follow the discipline of the scorecard pipeline: a reference data set built with a funnel that names every rule it applied, drivers binned on training cohorts and revalidated with frozen bins on a hold-out, pools, a downturn and a floor that land in a ledger with a reason, an R scoring function and a SQL query that agree number for number, a validation battery with traffic lights and a workbook. Part A walks the loss given default (LGD) on the bundled default events and cash flows; part B walks the exposure at default (EAD) on the bundled facility snapshots. Both use the configuration below: two threads, 200 bootstrap replicates for the intervals, and a lower minimum of defaults per CCF bin because the EAD panel is small.

library(scorecraft)
cfg <- scr_config(verbose = FALSE, nthread = 2, n_boot = 200, ccf_min_defaults = 20)
scr_verbose(FALSE)

Part A: loss given default

What a workout LGD is

A workout LGD is the economic loss of a default event over the exposure at the default date: the exposure, less the recoveries, plus the direct costs, the drawings after default and the indirect costs allocated to the event, every cash flow discounted to the default date at the reference rate in force at that date plus an add-on. A cure (the facility returns to performing) is not a zero loss by decree: the balance outstanding at the cure date enters as a recovery on that date, so the cure carries its costs and the discount effect. Two defaults of one facility closer than a cure window are one event. An incomplete workout (still open at the observation date) receives the expected further recovery read from the recovery profile of the closed defaults of the same product; an open event older than the maximum workout period is closed with no further recovery. The long-run average (LRA) is the default-weighted mean over the events; the downturn LGD is the value appropriate for an economic downturn, never below the LRA; and an own estimate is subject to an input floor by asset class and collateral. The "type1" and "type3" labels of the downturn methods below follow the EBA guidelines on downturn LGD estimation (EBA/GL/2019/03): type 1 is the impact observed in the downturn periods, type 3 the long-run average plus an add-on of 15 percentage points; the "bcb" parameter preset is the Brazilian text (BCB Resolution 303/2023).

The reference data set: scr_workout()

scr_demo_lgd holds 900 default events on three products, with the drivers a workout model reads; scr_demo_lgd_cashflows is the long table of their post-default cash flows; scr_demo_rates the monthly reference rate. scr_workout() discounts, merges, extrapolates and bounds, and returns the reference data set (RDS) with one row per event.

wo <- scr_workout(scr_demo_lgd, scr_demo_lgd_cashflows, rates = scr_demo_rates, config = cfg)
wo
#> <scr_workout> 885 default events | 8 calendar years | observation date 2026-06-28
#>   cure rate 37.5% | incomplete 18.1% | mean discount rate 14.37%
#>   long-run average LGD: 40.5% default-weighted | 29.9% exposure-weighted | raw mean 40.5%
#>   auto         n 203   cure 36.5%   LRA 34.4%   (ew 35.9%)
#>   mortgage     n 237   cure 46.8%   LRA 26.3%   (ew 26.7%)
#>   unsecured    n 445   cure 33.0%   LRA 50.8%   (ew 51.3%)
#>   funnel MULTIPLE_DEFAULT_MERGED      15
#>   funnel OPEN_BEYOND_T_MAX_CLOSED     2
#>   funnel INCOMPLETE_EXTRAPOLATED      160
#>   funnel LGD_ABOVE_ONE                3

The funnel is the first deliverable: the thirty second defaults of the demo were fifteen pairs closer than the nine-month cure window, merged into their first spell, plus fifteen pairs far enough apart to stay two events; two open events older than sixty months were closed with no further recovery; 160 open events younger than that received an expected further recovery; three events lost more than the exposure (costs on a facility with no recovery) and were kept above one because lgd_cap_at_one is off.

wo$funnel[, .(rule, n, action)]
#>                        rule     n
#>                      <char> <int>
#> 1:             NOT_IN_SCOPE     0
#> 2:  MULTIPLE_DEFAULT_MERGED    15
#> 3: CASHFLOW_WITHOUT_DEFAULT     0
#> 4: OPEN_BEYOND_T_MAX_CLOSED     2
#> 5:  INCOMPLETE_EXTRAPOLATED   160
#> 6:     NEGATIVE_LGD_FLOORED     0
#> 7:            LGD_ABOVE_ONE     3
#>                                                                                        action
#>                                                                                        <char>
#> 1:             excluded: EAD <= 0, missing default date or default after the observation date
#> 2:                          merged into the earlier default of the facility (window 9 months)
#> 3:                                          cash-flow rows dropped: no default event in scope
#> 4:                                open for 60 months or more: closed with no further recovery
#> 5: open workouts younger than t_max: expected further recovery added from the product profile
#> 6:                                                  floored at 0 in lgd_real (raw value kept)
#> 7:                                                      kept above 1 (lgd_cap_at_one = FALSE)

The long-run average is reported default-weighted, the regulatory estimate, and exposure-weighted. They differ by ten points here because the mortgages carry the exposure and the low LGD. The share of incomplete workouts is the share of rows whose loss is partly an extrapolation.

wo$summary$by_product
#>      product     n cure_rate       lra    lra_ew share_incomplete
#>       <char> <int>     <num>     <num>     <num>            <num>
#> 1:      auto   203 0.3645320 0.3443811 0.3585787       0.02463054
#> 2:  mortgage   237 0.4683544 0.2626056 0.2665364       0.13080169
#> 3: unsecured   445 0.3303371 0.5076497 0.5129146       0.27865169
wo$summary$share_incomplete
#> [1] 0.180791

The recovery profile is the cumulative discounted recovery rate of the closed defaults by product and month in default; it is what the extrapolation reads (rho_tau at the current age, rho_t_max at the end), and what the in-default grid will read later.

wo$recovery_profile[month %in% c(6, 12, 24, 36, 60)]
#>       product month n_closed cum_recovery
#>        <char> <int>    <int>        <num>
#>  1:      auto     6      124   0.31759377
#>  2:      auto    12      124   0.54601720
#>  3:      auto    24      124   0.55029906
#>  4:      auto    36      124   0.55029906
#>  5:      auto    60      124   0.55029906
#>  6:  mortgage     6       95   0.02839870
#>  7:  mortgage    12       95   0.07399733
#>  8:  mortgage    24       95   0.46060628
#>  9:  mortgage    36       95   0.61970602
#> 10:  mortgage    60       95   0.62294590
#> 11: unsecured     6      174   0.23982228
#> 12: unsecured    12      174   0.30647093
#> 13: unsecured    24      174   0.33761553
#> 14: unsecured    36      174   0.34409119
#> 15: unsecured    60      174   0.34474541
#> 16:       all     6      393   0.10283559
#> 17:       all    12      393   0.18669388
#> 18:       all    24      393   0.46896001
#> 19:       all    36      393   0.58427160
#> 20:       all    60      393   0.58666059
#>       product month n_closed cum_recovery
#>        <char> <int>    <int>        <num>
head(wo$extrapolation[, .(default_id, product, months_in_default, rho_tau, rho_t_max, expected_further)], 4)
#>    default_id   product months_in_default   rho_tau rho_t_max expected_further
#>        <char>    <char>             <int>     <num>     <num>            <num>
#> 1:      D0001 unsecured                15 0.3079344 0.3447454       218.854993
#> 2:      D0003  mortgage                24 0.4606063 0.6229459     19987.125213
#> 3:      D0018 unsecured                35 0.3427772 0.3447454         4.182786
#> 4:      D0028 unsecured                23 0.3273125 0.3447454       113.773474

Binning a bounded target: scr_bin_continuous()

The optimal-binning engine of the scorecard accepts binary targets only. LGD and CCF are continuous and bounded, so the severity stage and the CCF pools use a supervised binner of their own: candidate cut points at quantiles, greedy merges of the adjacent pair whose merge loses the least between-bin sum of squares until the target number of bins and the size floors hold, then pool-adjacent violators on the bin means when monotonicity is required. The result has exactly the shape of the engine’s object: the woe slot carries the bin mean of the target and the iv slot the bin’s share of the between-bin sum of squares, so the total is the eta-squared of the driver and the same SQL emitter reproduces the bin statistic in every dialect. Here it runs once, on the worst delinquency before default, over the non-cured events, with the 2024 and later defaults as the hold-out.

nc <- wo$rds[is_cure == FALSE]
cb <- scr_bin_continuous(nc, "lgd_real", "prior_dpd_max",
                         train_idx = which(nc$year < 2024), holdout_idx = which(nc$year >= 2024))
cb
#> <scr_cbins> 1 driver(s) binned against 'lgd_real' (bin statistic: mean)
#>   prior_dpd_max            numerical   3 bins | eta2 0.136 | increasing | hold-out eta2 0.077, PSI 0.016 (stable)
cb$fit$results$prior_dpd_max$bin
#> [1] "(-Inf;30.000000]"      "(30.000000;60.000000]" "(60.000000;+Inf]"
cb$fit$results$prior_dpd_max$woe
#> [1] 0.5624174 0.6556032 0.7320278

The hold-out revalidation recomputes the bin means with the bins frozen, reports the PSI of the bin shares with the sample-size-adjusted critical value, and flags UNSTABLE_HOLDOUT when the hold-out means break the training order.

cb$holdout[, .(bin, n_train, mean_train, n_holdout, mean_holdout)]
#>                      bin n_train mean_train n_holdout mean_holdout
#>                   <char>   <int>      <num>     <int>        <num>
#> 1:      (-Inf;30.000000]     225  0.5624174        89    0.5791883
#> 2: (30.000000;60.000000]      84  0.6556032        40    0.6655246
#> 3:      (60.000000;+Inf]      75  0.7320278        40    0.6913565
cb$summary[, .(feature, eta2, eta2_holdout, psi, psi_flag, holdout_ok)]
#>          feature      eta2 eta2_holdout        psi psi_flag holdout_ok
#>           <char>     <num>        <num>      <num>   <char>     <lgcl>
#> 1: prior_dpd_max 0.1356892   0.07655161 0.01569249   stable       TRUE

The two-stage model: scr_lgd()

scr_lgd() fits LGD = P(cure) LGD_cure + (1 - P(cure)) E[LGD | no cure]. The cure stage is a binary model on is_cure with the scorecard machinery: optimal binning on the training cohorts, WOE, hold-out revalidation, a logistic regression on the WOE columns with the sign check. The severity stage bins the same drivers against the realized LGD of the non-cures with the continuous binner and fits a fractional logit on the bin means. The split is by cohort of default: the last 30% of the default dates are the hold-out.

drv <- c("product", "ltv", "prior_dpd_max", "months_on_book", "region")
m <- scr_lgd(wo, drivers = drv, config = cfg)
m
#> <scr_lgd> 885 defaults | train 620 / hold-out 265 (cohort split after 2024-01-01) | cure rate 37.5%
#>   cure stage: prior_dpd_max, months_on_book, region | severity stage (fractional_logit): product, prior_dpd_max, months_on_book | LGD of a cure 4.5%
#>   train    n 620   RMSE 0.2731  R2 0.241  Spearman 0.473  gAUC 0.672 [0.645, 0.694]  LCR 0.403
#>   holdout  n 265   RMSE 0.2655  R2 0.275  Spearman 0.536  gAUC 0.693 [0.655, 0.728]  LCR 0.460
#>   pools 3 | downturn type1 (provisional) | floor not applied
#>   pool   n     pred        LRA     LRA ew   MoC C    LGD DT   floor    final
#>   1    274    0.276     27.5%     23.1%   0.023     44.8%   0.000     44.8%
#>   2    170    0.415     40.0%     38.0%   0.038     58.8%   0.000     58.8%
#>   3    176    0.554     59.3%     42.8%   0.043     78.6%   0.000     78.6%

Each stage keeps its own audit trail. In the cure stage, a driver enters the regression only if its training IV clears the minimum, its frozen bins hold on the hold-out and its coefficient is positive.

m$cure$bins[, .(iv = sum(iv), iv_holdout = iv_holdout[1], holdout_ok = holdout_ok[1],
                selected = selected[1]), by = variable]
#>          variable         iv iv_holdout holdout_ok selected
#>            <char>      <num>      <num>     <lgcl>   <lgcl>
#> 1:        product 0.07245340 0.03531391      FALSE    FALSE
#> 2:            ltv 0.02874538 0.01015355      FALSE    FALSE
#> 3:  prior_dpd_max 0.12697029 0.68239837       TRUE     TRUE
#> 4: months_on_book 0.06033029 0.09515877       TRUE     TRUE
#> 5:         region 0.03748065 0.14064382       TRUE     TRUE
m$cure$sign_check
#> Index: <action>
#>          variable     coef action reason
#>            <char>    <num> <char> <char>
#> 1: months_on_book 1.218919   kept     OK
#> 2:  prior_dpd_max 1.128097   kept     OK
#> 3:         region 1.240103   kept     OK

In the severity stage, ltv is removed by the sign check. Its bin means fall with the loan-to-value because the ratio is zero for unsecured facilities, which are the ones that lose most: the driver is the product effect in disguise, and once product is in the regression its coefficient turns negative.

m$severity$bins[, .(variable, bin, count, mean, mean_holdout, selected)]
#>           variable              bin count      mean mean_holdout selected
#>             <char>           <char> <int>     <num>        <num>   <lgcl>
#>  1:            ltv  (-Inf;0.479429]   220 0.7156562    0.7094439    FALSE
#>  2:            ltv  (0.479429;+Inf]   165 0.4828760    0.5173144    FALSE
#>  3: months_on_book (-Inf;27.000000]   207 0.6510963    0.6393596     TRUE
#>  4: months_on_book (27.000000;+Inf]   178 0.5749547    0.6104969     TRUE
#>  5:  prior_dpd_max (-Inf;30.000000]   225 0.5624174    0.5791883     TRUE
#>  6:  prior_dpd_max (30.000000;+Inf]   160 0.6910935    0.6786501     TRUE
#>  7:        product  mortgage%;%auto   175 0.4748150    0.5071735     TRUE
#>  8:        product        unsecured   210 0.7334583    0.7339459     TRUE
#>  9:         region            south   101 0.5719386    0.6365571    FALSE
#> 10:         region   central%;%west   118 0.6012325    0.6065112    FALSE
#> 11:         region     east%;%north   166 0.6530582    0.6369108    FALSE
m$severity$sign_check
#> Index: <action>
#>          variable      coef  action        reason
#>            <char>     <num>  <char>        <char>
#> 1: months_on_book  3.329868    kept            OK
#> 2:  prior_dpd_max  4.969159    kept            OK
#> 3:        product  4.431850    kept            OK
#> 4:            ltv -2.075296 removed SIGN_REVERSED

Metrics on both samples come with a bootstrap interval on Somers’ D (the generalized AUC is (D + 1) / 2) and on the loss capture ratio.

m$metrics[, .(sample, n, rmse, r2, spearman, gauc, gauc_lo, gauc_hi, lcr)]
#>     sample     n      rmse        r2  spearman      gauc   gauc_lo   gauc_hi
#>     <char> <int>     <num>     <num>     <num>     <num>     <num>     <num>
#> 1:   train   620 0.2730699 0.2411368 0.4725775 0.6721247 0.6449645 0.6938869
#> 2: holdout   265 0.2655118 0.2751904 0.5359311 0.6925386 0.6553274 0.7278851
#>          lcr
#>        <num>
#> 1: 0.4030191
#> 2: 0.4603287

The pools cut the training predictions into quantile bands, merge the bands with fewer than lgd_min_defaults_bin defaults into the neighbor with the closer long-run average, then merge adjacent bands whose averages break the increasing order. With 620 training defaults and a minimum of 100 per pool, the seven target bands collapse to three. Per pool: the default-weighted LRA, the exposure-weighted one, and the category-C margin of conservatism, a one-sided 95% t interval on the mean. The downturn column is provisional at this point: the configured method is "type1", which needs periods, so until scr_lgd_downturn() runs each pool carries the type-3 value (long-run average plus 15 percentage points) as a placeholder, and the floor is zero until scr_lgd_floor() runs.

m$pools[, .(pool, pred_lo, pred_hi, n, lra, lra_ew, se, moc_c, lgd_dt, floor, lgd_final)]
#>     pool   pred_lo   pred_hi     n       lra    lra_ew         se      moc_c
#>    <int>     <num>     <num> <int>     <num>     <num>      <num>      <num>
#> 1:     1      -Inf 0.3769045   274 0.2749072 0.2306159 0.01382310 0.02281440
#> 2:     2 0.3769045 0.4526564   170 0.4002597 0.3802176 0.02289441 0.03786551
#> 3:     3 0.4526564       Inf   176 0.5926373 0.4275209 0.02595678 0.04292232
#>       lgd_dt floor lgd_final
#>        <num> <num>     <num>
#> 1: 0.4477216     0 0.4477216
#> 2: 0.5881253     0 0.5881253
#> 3: 0.7855596     0 0.7855596

Downturn, floor and the in-default grid

scr_lgd_downturn() quantifies the downturn per pool from user-supplied periods. Under "type1" the downturn value is the realized LGD of the training defaults whose default date falls in the periods (a pool with fewer than ten such defaults falls back to the add-on); the reference value, the mean of the two worst calendar years of the pool’s training defaults, is reported as a challenger, not a bound. Both use the training rows only, so the hold-out stays independent for validation. The downturn LGD used for capital is min(1, max(LRA + MoC, DT + MoC)). The cap at one is the package’s choice; the EBA guidelines cap the type-3 estimate at 105%, so a pool that loses more than its exposure in a downturn needs a decision the cap does not take. The reference rate of the demo rises above thirteen percent in 2022 and 2023, which is the reason recorded.

m <- scr_lgd_downturn(m, periods = data.frame(start = as.Date("2022-01-01"), end = as.Date("2023-12-31")),
                      reason = "reference rate above 13% in 2022-2023")
m$downturn$table[, .(pool, n, lra, moc_c, n_downturn, dt_observed, reference_value, method_used, lgd_dt, impact, below_reference)]
#>     pool     n       lra      moc_c n_downturn dt_observed reference_value
#>    <int> <int>     <num>      <num>      <int>       <num>           <num>
#> 1:     1   274 0.2749072 0.02281440        117   0.2923210       0.3071022
#> 2:     2   170 0.4002597 0.03786551         73   0.4681236       0.4750833
#> 3:     3   176 0.5926373 0.04292232         79   0.6312294       0.6358949
#>    method_used    lgd_dt     impact below_reference
#>         <char>     <num>      <num>          <lgcl>
#> 1:       type1 0.3151354 0.01741372           FALSE
#> 2:       type1 0.5059891 0.06786385           FALSE
#> 3:       type1 0.6741517 0.03859206           FALSE

No pool sits below its reference value: in every pool the downturn LGD, margin included, is at least the mean of the two worst training years. Had one fallen below, the flag would not change the estimate; it would be there for the reviewer to answer.

scr_lgd_floor() reads the input floor from the parameter table of the framework. The tables are numbers, editable and printable; an edit is detected and recorded as params_modified in the ledger. The floor of a partly secured pool blends the unsecured and the collateral floors with the secured share: 0.30 * 0.6 + 0.10 * 0.4 = 0.22 here.

p <- scr_irb_params("bcb")
p$lgd_floor
#>        asset_class unsecured financial receivables real_estate other_physical
#>             <char>     <num>     <num>       <num>       <num>          <num>
#> 1:       corporate      0.25         0         0.1        0.10           0.15
#> 2: retail_mortgage        NA        NA          NA        0.05             NA
#> 3:            qrre      0.50        NA          NA          NA             NA
#> 4:    retail_other      0.30         0         0.1        0.10           0.15
m <- scr_lgd_floor(m, params = p, asset_class = "retail_other", secured_share = 0.4)
m$floors$table
#>     pool     n    lgd_dt floor_unsecured floor_secured secured_share floor
#>    <int> <int>     <num>           <num>         <num>         <num> <num>
#> 1:     1   274 0.3151354             0.3           0.1           0.4  0.22
#> 2:     2   170 0.5059891             0.3           0.1           0.4  0.22
#> 3:     3   176 0.6741517             0.3           0.1           0.4  0.22
#>    lgd_final binding
#>        <num>  <lgcl>
#> 1: 0.3151354   FALSE
#> 2: 0.5059891   FALSE
#> 3: 0.6741517   FALSE
m
#> <scr_lgd> 885 defaults | train 620 / hold-out 265 (cohort split after 2024-01-01) | cure rate 37.5%
#>   cure stage: prior_dpd_max, months_on_book, region | severity stage (fractional_logit): product, prior_dpd_max, months_on_book | LGD of a cure 4.5%
#>   train    n 620   RMSE 0.2731  R2 0.241  Spearman 0.473  gAUC 0.672 [0.645, 0.694]  LCR 0.403
#>   holdout  n 265   RMSE 0.2655  R2 0.275  Spearman 0.536  gAUC 0.693 [0.655, 0.728]  LCR 0.460
#>   pools 3 | downturn type1 (final) | floor retail_other, binding in 0.0% of the defaults
#>   pool   n     pred        LRA     LRA ew   MoC C    LGD DT   floor    final
#>   1    274    0.276     27.5%     23.1%   0.023     31.5%   0.220     31.5%
#>   2    170    0.415     40.0%     38.0%   0.038     50.6%   0.220     50.6%
#>   3    176    0.554     59.3%     42.8%   0.043     67.4%   0.220     67.4%

The floor binds in none of the three pools on this data, and that fact is what the ledger records. scr_elbe() derives the in-default grid: for every pool and every age since default, the expected loss best estimate (ELBE) is the mean realized LGD of the training defaults still in workout at that age, and the in-default LGD adds the unexpected-loss increment (LGD_DT - LRA) scaled by the share of the recoveries still to come, read from the recovery profile. At age zero the ELBE equals the LRA and the in-default LGD equals the downturn LGD; the consistency table checks exactly that. The jump at six months is the cures leaving the set of open events.

e <- scr_elbe(m)
e
#> <scr_elbe> 3 pools x 5 reference ages (months since default: 0, 6, 12, 24, 36) | t_max 60
#>   consistency at tau = 0: ELBE equals the LRA and the in-default LGD equals the downturn LGD
#>   in-default LGD by pool and age
#>   pool       m0      m6     m12     m24     m36
#>   1       31.5%   50.9%   50.1%   51.5%   54.4%
#>   2       50.6%   71.2%   70.8%   67.6%   69.4%
#>   3       67.4%   84.8%   84.9%   81.8%   82.7%
e$consistency
#>     pool       lra    lgd_dt    elbe_0 lgd_in_default_0     ok
#>    <int>     <num>     <num>     <num>            <num> <lgcl>
#> 1:     1 0.2749072 0.3151354 0.2749072        0.3151354   TRUE
#> 2:     2 0.4002597 0.5059891 0.4002597        0.5059891   TRUE
#> 3:     3 0.5926373 0.6741517 0.5926373        0.6741517   TRUE

Production: scr_apply() and scr_sql()

scr_apply() scores new rows with the frozen bins of both stages and returns the cure probability, the severity, the predicted LGD, the pool and the pool parameters.

new <- head(scr_demo_lgd, 5)
ap <- scr_apply(m, new, what = "all")
ap
#>       p_cure  severity  lgd_pred  pool   lgd_lra    lgd_dt lgd_final
#>        <num>     <num>     <num> <int>     <num>     <num>     <num>
#> 1: 0.3838348 0.7822633 0.4991442     3 0.5926373 0.6741517 0.6741517
#> 2: 0.3157596 0.4370276 0.3131328     1 0.2749072 0.3151354 0.3151354
#> 3: 0.1881175 0.5331106 0.4412239     2 0.4002597 0.5059891 0.5059891
#> 4: 0.2283024 0.5331106 0.4215954     2 0.4002597 0.5059891 0.5059891
#> 5: 0.4774548 0.7095159 0.3920757     2 0.4002597 0.5059891 0.5059891

scr_sql() emits the same computation for the database: the bin statistics of both stages, the two logits, the predicted LGD, the pool CASE on the pool boundaries (snapped to the midpoint between adjacent predictions, so that floating-point noise cannot flip a pool between R and SQL), the pool parameters and the floored result.

sql <- scr_sql(m, table = "prd.defaults", dialect = "duckdb")
sql_lines <- unlist(strsplit(sql, "\n", fixed = TRUE))
cat(grep("AS pool$|AS lgd_dt,$|AS lgd_floor$|AS lgd_final$", sql_lines, value = TRUE), sep = "\n")
#>     CASE WHEN lgd_pred <= 0.37690454385294925 THEN 1 WHEN lgd_pred <= 0.45265644452998088 THEN 2 ELSE 3 END AS pool
#>     CASE pool WHEN 1 THEN 0.31513536760296268 WHEN 2 THEN 0.50598910528854668 WHEN 3 THEN 0.67415168383886248 ELSE 0.67415168383886248 END AS lgd_dt,
#>     CASE pool WHEN 1 THEN 0.22 WHEN 2 THEN 0.22 WHEN 3 THEN 0.22 ELSE 0.22 END AS lgd_floor
#>     GREATEST(lgd_dt, lgd_floor) AS lgd_final

The query runs in DuckDB on the same five rows and reproduces scr_apply().

con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbWriteTable(con, "defaults_t", new)
got <- DBI::dbGetQuery(con, paste(scr_sql(m, table = "defaults_t", dialect = "duckdb"), collapse = "\n"))
DBI::dbDisconnect(con, shutdown = TRUE)
got[, c("lgd_pred", "pool", "lgd_dt", "lgd_floor", "lgd_final")]
#>    lgd_pred pool    lgd_dt lgd_floor lgd_final
#> 1 0.4991442    3 0.6741517      0.22 0.6741517
#> 2 0.3131328    1 0.3151354      0.22 0.3151354
#> 3 0.4412239    2 0.5059891      0.22 0.5059891
#> 4 0.4215954    2 0.5059891      0.22 0.5059891
#> 5 0.3920757    2 0.5059891      0.22 0.5059891
identical(as.integer(got$pool), ap$pool)
#> [1] TRUE
all.equal(got$lgd_final, ap$lgd_final)
#> [1] TRUE

Validation and export

scr_lgd_validate() runs on the hold-out (or on new data) against the training reference: calibration per pool and for the portfolio (one-sided t-test where under-estimation is the failure, loss shortfall, coverage of the realized mean by the downturn LGD), discrimination against the training gAUC, stability of the pool and driver-bin distributions, and the homogeneity within and heterogeneity between pools. The lights use the p-value thresholds of pd_lights and the fixed PSI thresholds.

v <- scr_lgd_validate(m)
v
#> <scr_lgd_validation> sample holdout | n 265
#>   calibration: realized 41.7% vs estimate 39.1% | t 1.33 p 0.092 [green] | loss shortfall -1.1% | downturn covers: TRUE
#>   discrimination: gAUC 0.693 [0.655, 0.728] vs initial 0.672 (S -1.10, p 0.865) [green] | Spearman 0.536 | LCR 0.460
#>   stability: pool PSI 0.0056 (stable; adjusted stable) | drivers: prior_dpd_max_cure 0.009, months_on_book_cure 0.001, region_cure 0.029, product_sev 0.001, prior_dpd_max_sev 0.001, months_on_book_sev 0.002
#>   calibration_portfolio_t        green  
#>   calibration_pools_t            amber  
#>   loss_shortfall                 amber  
#>   downturn_coverage              green  
#>   gauc_vs_initial                green  
#>   psi_pools                      green  
#>   psi_drivers                    green  
#>   homogeneity_within_pools       red    
#>   heterogeneity_between_pools    green
v$calibration[, .(pool, n, lgd_est, real_mean, real_ew, p, light, dt_covers)]
#>     pool     n   lgd_est real_mean   real_ew          p  light dt_covers
#>    <int> <int>     <num>     <num>     <num>      <num> <char>    <lgcl>
#> 1:     1   127 0.2749072 0.2720949 0.2688960 0.55023471  green      TRUE
#> 2:     2    68 0.4002597 0.4463789 0.4701465 0.10555718  green      TRUE
#> 3:     3    70 0.5926373 0.6495472 0.5502930 0.04033446  amber      TRUE

The red light on homogeneity is the price of three pools: a Welch test between the two halves of each pool, split at its median prediction, finds that the halves still differ, so the pools are not yet homogeneous. That is what the battery is for; the fix is more defaults or a lower minimum per pool, and either is a ledger row. Two ambers sit beside it: one pool realizes a little more than it estimates (the one-sided t-test at p = 0.04), and the loss shortfall says the portfolio estimate is slightly below the realized loss; dt_covers is TRUE in every pool, which is what the downturn add-on and the margin are there for.

scr_export() writes one workbook with the RDS funnel and summaries, the recovery profile, the bins and coefficients of both stages, the sign checks, the pools, the downturn and its periods, the floors, the in-default grid, the validation blocks, the model card and the ledger, plus the SQL file.

out <- file.path(tempdir(), "scorecraft-lgd-ead")
basename(unlist(scr_export(m, out, stamp = FALSE, validation = v, elbe = e)$files))
#> [1] "lgd_model.xlsx"    "sql_lgd_model.sql"

Part B: exposure at default

Realized conversion factors

Write R for the reference date, D for the default date, E for the drawn amount and L for the limit. The realized credit conversion factor of a default event is the share of the undrawn amount at the reference date that was drawn by the default date, CCF = (E_D - E_R) / (L_R - E_R), and the realized EAD is the drawn amount at the default date, never capped at the limit. The reference date is one horizon before the default (twelve months here under the fixed approach; the start of the calendar cohort under the cohort approach). The CCF is only meaningful while the undrawn part is material: when the utilization at the reference date is at or above ccf_u_star, when nothing is undrawn, or when the facility is over its limit, the denominator is small or negative and the row is routed to the limit factor LF = E_D / L_R instead of being dropped; a facility with no usable limit uses the exposure factor E_D / E_R. Two floors apply at different places: the realized CCF is floored at zero for the averages (a facility that repaid before default; the raw value is kept), and the applied CCF of an own estimate is floored at a fraction of the standardized CCF, one half of ccf_sa_ccf here, while the predicted EAD is never below the drawn amount. The default ccf_sa_ccf = 0.40 is the standardized CCF of a commitment; unconditionally cancelable retail lines, which include most credit cards, carry 10% under the standardized approach, which would put the floor at 0.05. Set ccf_sa_ccf to the standardized CCF of the product being modeled.

The reference data set: scr_ead_data()

scr_demo_ead is a panel of 1,200 revolving facilities over thirty months with a 0/1 default flag; every run of ones opens a default event at its first month. scr_ead_data() finds the reference snapshot of every event, computes the realized measure and applies the funnel rules.

ed <- scr_ead_data(scr_demo_ead, facility_id = "facility_id", obligor_id = "obligor_id",
                   date_col = "ref_date", limit = "limit", drawn = "drawn", defaulted = "defaulted",
                   drivers = c("product", "months_on_book", "dpd"), config = cfg)
ed
#> <scr_ead_data> 197 rows from 197 default events | 1,200 facilities x 34,811 snapshots
#>   horizon: fixed (12 months) | measure: auto (u* = 0.95) | reference dates 2023-01-01 to 2024-06-01 (1.4 years)
#>   ULF: LRA simple 0.3248 | exposure-weighted 0.2889 | n = 189
#>   LF rows 4.1% | above one 7.4% | negative (raw) 23.8% | fast defaults 19.8%
#>   funnel:
#>     FAST_DEFAULT             kept              25 (12.7%)
#>     OVER_LIMIT_AT_REF        routed_to_lf       7 (3.6%)
#>     NEGATIVE_CCF_FLOORED     floored           45 (22.8%)
#>     CCF_ABOVE_ONE            kept              14 (7.1%)
#>     OK                       kept             106 (53.8%)
#>   note: fewer than five years of reference dates; the average is not a long-run one yet

The funnel keeps everything it can and names what it did: a facility younger than the horizon is kept from its first snapshot and flagged FAST_DEFAULT; an over-limit facility is routed to the limit factor; a negative realized CCF (the facility repaid before defaulting) is floored at zero; a CCF above one (a limit raised or a drawing beyond the limit) is kept as observed because no cap is configured.

ed$funnel
#>                    rule       action     n      share
#>                  <char>       <char> <int>      <num>
#> 1:         FAST_DEFAULT         kept    25 0.12690355
#> 2:    OVER_LIMIT_AT_REF routed_to_lf     7 0.03553299
#> 3: NEGATIVE_CCF_FLOORED      floored    45 0.22842640
#> 4:        CCF_ABOVE_ONE         kept    14 0.07106599
#> 5:                   OK         kept   106 0.53807107
head(ed$rds[, .(facility_id, ref_date, default_date, utilisation_ref, ead_realised, measure, ccf_raw, ccf, rule)])
#>    facility_id   ref_date default_date utilisation_ref ead_realised measure
#>         <char>     <Date>       <Date>           <num>        <num>  <char>
#> 1:       F0031 2023-01-01   2023-08-01          0.5200          400     ulf
#> 2:       F0062 2023-01-01   2023-11-01          0.4600          220     ulf
#> 3:       F0093 2023-01-01   2023-07-01          0.6485        14020     ulf
#> 4:       F0103 2023-01-01   2023-12-01          0.3400          140     ulf
#> 5:       F0108 2023-01-01   2024-01-01          0.1300          360     ulf
#> 6:       F0109 2023-01-01   2023-08-01          0.3650         7920     ulf
#>        ccf_raw       ccf                 rule
#>          <num>     <num>               <char>
#> 1: -0.25000000 0.0000000 NEGATIVE_CCF_FLOORED
#> 2: -0.03703704 0.0000000 NEGATIVE_CCF_FLOORED
#> 3:  0.14935989 0.1493599         FAST_DEFAULT
#> 4: -0.09090909 0.0000000 NEGATIVE_CCF_FLOORED
#> 5:  0.26436782 0.2643678                   OK
#> 6:  0.46456693 0.4645669         FAST_DEFAULT

The summary gives the simple and the exposure-weighted averages by cohort of reference date and by measure, with a total row. The large lines draw a smaller share of their undrawn amount, so the exposure-weighted CCF sits below the simple one. Two cohorts and 1.4 years of reference dates is not a long run yet, and the print says so.

ed$summary
#>    cohort measure     n ccf_simple    ccf_ew ead_realised share_above_one
#>    <char>  <char> <int>      <num>     <num>        <num>           <num>
#> 1:   2023      lf     7  0.8726786 0.8623423        47860      0.57142857
#> 2:   2023     ulf   146  0.3007913 0.2631065       574650      0.07534247
#> 3:   2024      lf     1  1.0400000 1.0400000          520      1.00000000
#> 4:   2024     ulf    43  0.4063661 0.3498382       214470      0.06976744
#> 5:    ALL      lf     8  0.8935938 0.8639286        48380      0.62500000
#> 6:    ALL     ulf   189  0.3248110 0.2888929       789120      0.07407407

Pools and downturn: scr_ead() and scr_ead_downturn()

scr_ead() splits by reference date (the most recent dates are the hold-out), bins every candidate driver against the realized CCF on the training rows with the continuous binner, revalidates the frozen bins on the hold-out, and admits a driver only when it passes four named rules: enough defaults in every bin, separation of the bin means (an F-test), the training order preserved on the hold-out, and a stable bin distribution. The cells of the cross of the admitted drivers are ordered by predicted CCF and merged into pools with at least ccf_min_defaults each; rows in the limit-factor measure form their own pool LF.

m_ead <- scr_ead(ed, drivers = c("utilisation_ref", "product", "months_on_book", "dpd"), config = cfg)
m_ead
#> <scr_ead> 2 pool(s) + LF from 197 reference rows | fixed horizon (12 months) | measure auto
#>   split by reference date: train 126 | hold-out 71 (from 2023-11-01) | drivers admitted: utilisation_ref
#>   floor 0.2000 (= 0.5 x SA-CCF 0.4) | MoC alpha 0.05 | downturn none
#>   pool  meas       n      lra   lra_ew      moc   ccf_dt    final    floor  applied
#>   P1    ulf       98   0.2857   0.2459   0.0603   0.2857   0.3459   0.2000   0.3459
#>   P2    ulf       23   0.4963   0.3785   0.1301   0.4963   0.6264   0.2000   0.6264
#>   LF    lf         5   0.8884   0.8726   0.1411   0.8884   1.0295      row   1.0295
#>   train    n   126 | RMSE 0.3628 | MAE 0.2884 | gAUC 0.5582 [0.5112, 0.6016] | EAD adequacy 0.8485 | CEAR 0.1181
#>   holdout  n    71 | RMSE 0.4035 | MAE 0.2728 | gAUC 0.5708 [0.5027, 0.6398] | EAD adequacy 0.7905 | CEAR 0.4077
m_ead$drivers[, .(feature, n_bins, eta2, direction, p_anova, eta2_holdout, psi_flag, admitted, reason)]
#>            feature n_bins         eta2       direction    p_anova eta2_holdout
#>             <char>  <int>        <num>          <char>      <num>        <num>
#> 1: utilisation_ref      2 4.934431e-02      decreasing 0.01433303 3.634314e-02
#> 2:         product      3 2.890852e-02 ordered_by_mean 0.17715296 5.268658e-02
#> 3:  months_on_book      4 8.125227e-02      decreasing 0.01893577 7.602372e-02
#> 4:             dpd      1 2.225959e-32            none         NA 1.830249e-32
#>    psi_flag admitted        reason
#>      <char>   <lgcl>        <char>
#> 1:   stable     TRUE            OK
#> 2:   stable    FALSE NO_SEPARATION
#> 3: moderate    FALSE NOT_MONOTONIC
#> 4:   stable    FALSE NO_SEPARATION

Only the utilization at the reference date is admitted: the product does not separate the means, the months on book reverse their order on the hold-out and the days past due collapse to one bin. Two pools follow the two utilization bins, the lower utilization drawing the larger share of its undrawn amount. Per pool: the long-run average, moc_est, a one-sided normal estimation-error margin at ccf_moc_alpha; ccf_final = max(lra, ccf_dt) + moc_est; the floor 0.5 * 0.40 = 0.20; and ccf_applied = max(ccf_final, ccf_floor). The LF pool has no scalar floor because its floor depends on the utilization of the row.

m_ead$pools[, .(pool, measure, n, lra, lra_ew, se, moc_est, ccf_dt, ccf_final, ccf_floor, ccf_applied, floor_binding)]
#>      pool measure     n       lra    lra_ew         se    moc_est    ccf_dt
#>    <char>  <char> <int>     <num>     <num>      <num>      <num>     <num>
#> 1:     P1     ulf    98 0.2856519 0.2458889 0.03663230 0.06025477 0.2856519
#> 2:     P2     ulf    23 0.4962957 0.3785191 0.07912126 0.13014290 0.4962957
#> 3:     LF      lf     5 0.8884167 0.8726214 0.08576962 0.14107847 0.8884167
#>    ccf_final ccf_floor ccf_applied floor_binding
#>        <num>     <num>       <num>        <lgcl>
#> 1: 0.3459067       0.2   0.3459067         FALSE
#> 2: 0.6264386       0.2   0.6264386         FALSE
#> 3: 1.0294951        NA   1.0294951         FALSE

scr_ead_downturn() takes the periods and a mandatory reason. Under "type1" the downturn value of a pool is max(lra, observed), the default-weighted realized CCF of the events whose default date falls in the periods; the applied CCF is recomputed and the ledger records periods, method and reason. The observed value is computed on the training rows only, like the long-run average, so the hold-out validation below stays independent of the downturn component. No minimum count is imposed, so a pool with few training events in the periods (the LF pool here, with four) should be read with care.

The period chosen covers the first three quarters of 2024, when the realized CCFs of the training rows run above their long-run average in every pool; the table shows the observed value above lra in each.

m_ead <- scr_ead_downturn(m_ead, periods = data.frame(start = as.Date("2024-01-01"), end = as.Date("2024-09-30")),
                          reason = "realised CCFs above their average in 2024 Q1-Q3")
m_ead$downturn$table
#>      pool       lra n_downturn dt_observed  dt_type3    ccf_dt ccf_final
#>    <char>     <num>      <int>       <num>     <num>     <num>     <num>
#> 1:     P1 0.2856519         59   0.3253274 0.4356519 0.3253274 0.3855822
#> 2:     P2 0.4962957         19   0.5274588 0.6462957 0.5274588 0.6576017
#> 3:     LF 0.8884167          4   0.9255208 1.0384167 0.9255208 1.0665993
#>    ccf_applied
#>          <num>
#> 1:   0.3855822
#> 2:   0.6576017
#> 3:   1.0665993

Production, validation and export

scr_apply() needs the limit, the drawn amount and the raw drivers of the admitted set; the utilization is derived. It returns the pool, the measure, the applied CCF, the model EAD, the floor EAD (drawn + 0.20 * undrawn), the predicted EAD as the greatest of the drawn amount, the model and the floor, and whether the floor is the binding term. The five rows below include an undrawn facility and an over-limit one, which goes to LF: its model EAD, the applied limit factor times the limit, only just exceeds the drawn amount.

new_ead <- scr_demo_ead[scr_demo_ead$ref_date == as.Date("2025-06-01") &
                        scr_demo_ead$facility_id %in% c("F0001", "F0002", "F0004", "F0186", "F0615"), ]
new_ead[, c("facility_id", "product", "limit", "drawn")]
#>       facility_id product limit drawn
#> 30          F0001    card 12000  7070
#> 60          F0002    card  3000   320
#> 120         F0004    line 62500 29660
#> 5434        F0186    card  8000     0
#> 17907       F0615    card  3500  3680
ap_ead <- scr_apply(m_ead, new_ead)
ap_ead
#>      pool measure utilisation undrawn ccf_applied ead_model ead_floor
#>    <char>  <char>       <num>   <num>       <num>     <num>     <num>
#> 1:     P1     ulf   0.5891667    4930   0.3855822  8970.920      8056
#> 2:     P2     ulf   0.1066667    2680   0.6576017  2082.373       856
#> 3:     P1     ulf   0.4745600   32840   0.3855822 42322.519     36228
#> 4:     P2     ulf   0.0000000    8000   0.6576017  5260.814      1600
#> 5:     LF      lf   1.0514286       0   1.0665993  3733.098      3680
#>    ead_predicted ead_floor_binding
#>            <num>            <lgcl>
#> 1:      8970.920             FALSE
#> 2:      2082.373             FALSE
#> 3:     42322.519             FALSE
#> 4:      5260.814             FALSE
#> 5:      3733.098             FALSE

The floor never binds here because every applied CCF is above 0.20; the column is there for the pool where it would. The SQL has four blocks: utilization and undrawn amount, the driver bin index from the frozen cut points, the pool from the cells with the LF branch, and the applied CCF with the predicted EAD as a GREATEST of the three terms.

sql_ead <- scr_sql(m_ead, table = "prd.facilities", dialect = "duckdb")
sql_ead_lines <- unlist(strsplit(sql_ead, "\n", fixed = TRUE))
cat(grep("AS pool$", sql_ead_lines, value = TRUE), sep = "\n")
#>     CASE WHEN undrawn <= 0 OR utilisation >= 0.95 THEN 'LF' WHEN (utilisation_idx = 1) THEN 'P2' WHEN (utilisation_idx = 2) THEN 'P1' ELSE 'P2' END AS pool
cat(tail(sql_ead_lines, 10), sep = "\n")
#> )
#> SELECT
#>     limit_amt, drawn_amt, utilisation, undrawn, pool, ccf_applied,
#>     CASE WHEN pool = 'LF' THEN GREATEST(drawn_amt, ccf_applied * limit_amt, drawn_amt + 0.2 * undrawn) ELSE GREATEST(drawn_amt, drawn_amt + ccf_applied * undrawn, drawn_amt + 0.2 * undrawn) END AS ead_predicted
#> FROM (
#>   SELECT
#>     *,
#>     CASE pool WHEN 'P1' THEN 0.38558218069556494 WHEN 'P2' THEN 0.6576016928597912 WHEN 'LF' THEN 1.066599304475079 ELSE NULL END AS ccf_applied
#>   FROM pool_ead
#> ) ead;
con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbWriteTable(con, "facilities_t", new_ead)
got_ead <- DBI::dbGetQuery(con, paste(scr_sql(m_ead, table = "facilities_t", dialect = "duckdb"), collapse = "\n"))
DBI::dbDisconnect(con, shutdown = TRUE)
got_ead[, c("pool", "ccf_applied", "ead_predicted")]
#>   pool ccf_applied ead_predicted
#> 1   P1   0.3855822      8970.920
#> 2   P2   0.6576017      2082.373
#> 3   P1   0.3855822     42322.519
#> 4   P2   0.6576017      5260.814
#> 5   LF   1.0665993      3733.098
identical(got_ead$pool, ap_ead$pool)
#> [1] TRUE
all.equal(got_ead$ead_predicted, ap_ead$ead_predicted)
#> [1] TRUE

scr_ead_validate() compares realized and predicted on the hold-out per pool and in total: the one-sided t-test of realized above predicted, the EAD adequacy ratio (realized over predicted EAD), the gAUC against the development value, the back-test by cohort and the stability of the pool and bin distributions. The numeric limits of the lights are a convention of the package and the summary says so in its last column.

v_ead <- scr_ead_validate(m_ead)
v_ead
#> <scr_ead_validation> 71 rows (holdout) | overall light: GREEN
#>   pool        n  realised predicted        t        p  light  adequacy  light
#>   P1         50    0.2763    0.3856   -1.832   0.9635 green     0.7709 green 
#>   P2         18    0.4536    0.6576   -2.346   0.9843 green     0.7600 green 
#>   LF          3    0.9022    1.0666   -1.193   0.8224 green     0.7167 green 
#>   TOTAL      71    0.3232    0.4576   -2.680   0.9954 green     0.7674 green 
#>   gAUC 0.5708 [0.5027, 0.6398] vs development 0.5582 (p 0.6133) | Spearman 0.2580 | CEAR 0.4077
#>   stability: pool PSI 0.0308 (stable) | utilisation_ref PSI 0.0319 (stable)
#>   lights: calibration_t_total green | ead_adequacy_total green | gauc_vs_development green | pool_psi green
v_ead$summary[, .(test, statistic, p, light)]
#>                   test   statistic         p  light
#>                 <char>       <num>     <num> <char>
#> 1: calibration_t_total -2.67996285 0.9953712  green
#> 2:  ead_adequacy_total  0.76736324        NA  green
#> 3: gauc_vs_development -0.28794667 0.6133062  green
#> 4:            pool_psi  0.03078423        NA  green
v_ead$backtest[, .(cohort, n, realised, predicted, p, adequacy, light_adequacy)]
#>    cohort     n  realised predicted         p  adequacy light_adequacy
#>    <char> <int>     <num>     <num>     <num>     <num>         <char>
#> 1:   2023    27 0.1802738 0.4399861 0.9999977 0.6231908          green
#> 2:   2024    44 0.4063661 0.4678206 0.7995910 0.8390325          green

Every calibration light is green because the estimate sits above the realized values on the hold-out: the margin and the downturn push the applied CCF up, and an adequacy ratio below one means the predicted EAD covers the realized one. The discrimination light is green only because the hold-out gAUC does not fall below the development value; the level itself is weak, 0.57 with an interval that reaches down to 0.50, so the single admitted driver separates the pools little better than chance. On a real portfolio that would be the first finding of the review. The workbook carries the funnel, the summaries, the driver bins and the admission table, the pools and the cells, the downturn and the margin, the hold-out metrics, the validation blocks, the model card and the ledger, next to the SQL file.

basename(unlist(scr_export(m_ead, out, stamp = FALSE, validation = v_ead)$files))
#> [1] "ead_ccf.xlsx"    "sql_ead_ccf.sql"

What both ledgers recorded

Neither parameter can be reconstructed from its pool table alone; the ledger is the part of the object that says why the numbers are what they are. The LGD ledger starts in the workout (discounting, cure treatment, merged defaults, extrapolation, cost allocation, bounds), continues with the split, the note of each stage and the driver the sign check removed, the pool merges, the provisional downturn and the final one with its periods and reason, and the floor with the framework, the asset class and the secured share.

m$ledger[, .(action, detail, reason)]
#>                  action
#>                  <char>
#>  1:         discounting
#>  2:      cure_treatment
#>  3:   multiple_defaults
#>  4: incomplete_workouts
#>  5:     cost_allocation
#>  6:              bounds
#>  7:               split
#>  8:          cure_stage
#>  9:      severity_stage
#> 10: severity_sign_check
#> 11:          pool_merge
#> 12:            downturn
#> 13:            downturn
#> 14:               floor
#>                                                                                                                 detail
#>                                                                                                                 <char>
#>  1:                                    reference rate at default + add-on 5.00%, monthly compounding over whole months
#>  2: outstanding at the cure date (ead plus drawings, net of cash recovered) as an artificial recovery on the cure date
#>  3:                                                       15 event(s) merged: gap below 9 months or overlapping spells
#>  4:              160 open event(s) extrapolated from the product recovery profile (lambda = 1); 2 closed at t_max = 60
#>  5:                                                                                  indirect costs 0 allocated by ead
#>  6:                                                                            floor at zero: TRUE | cap at one: FALSE
#>  7:                                                                   cohort split, hold-out 30.0%, cut-off 2024-01-01
#>  8:                                                                                                                 OK
#>  9:                                                                                               fractional_logit: OK
#> 10:                                                                                                                ltv
#> 11:                                          4 band(s) below 100 defaults merged into the neighbor with the closer LRA
#> 12:                                 provisional: type 3 add-on 15.0%; run scr_lgd_downturn() with the downturn periods
#> 13:                                            type1; add-on 15.0%; periods 2022-01-01 to 2023-12-31; mean impact 4.1%
#> 14:         bcb retail_other: unsecured 30.0%, real_estate 10.0%, secured share 40.0%; binding in 0.0% of the defaults
#>                                    reason
#>                                    <char>
#>  1:                                      
#>  2:                                      
#>  3:                                      
#>  4:                                      
#>  5:                                      
#>  6:                                      
#>  7:                                      
#>  8:                                      
#>  9:                                      
#> 10:            SIGN_REVERSED_OR_TOO_LARGE
#> 11:                          MIN_DEFAULTS
#> 12:                      DOWNTURN_PENDING
#> 13: reference rate above 13% in 2022-2023
#> 14:

The EAD ledger has one row per step: how the reference data set was built (horizon, measure rule, floors, where post-default drawings are booked, the default level), how the pools were fitted (the admitted drivers, the margin, the floor arithmetic) and the downturn with its periods and reason.

m_ead$ledger[, .(step, action, detail, reason)]
#>              step action
#>            <char> <char>
#> 1: reference_data  build
#> 2:          pools    fit
#> 3:       downturn  type1
#>                                                                                                                          detail
#>                                                                                                                          <char>
#> 1: horizon fixed (12 months); measure auto with u* = 0.95; floor 0; cap NA; post-default drawings in lgd; default level obligor
#> 2:                                            drivers admitted: utilisation_ref; 2 pools; MoC alpha 0.05; floor 0.5 x 0.4 = 0.2
#> 3:  periods: 2024-01-01 to 2024-09-30; 82 training reference rows in the periods; add-on 0.15; applied CCF now 0.3856 to 1.0666
#>                                             reason
#>                                             <char>
#> 1:                                   configuration
#> 2:                                   configuration
#> 3: realised CCFs above their average in 2024 Q1-Q3

Both ledgers travel unchanged into the Decision_Ledger sheet of the workbook, and the SQL header of each model states the stages, the downturn status and the floor it was generated with.