Skip to content

Commit 1bf2ba9

Browse files
committed
Show both downturn severities in the parameter stress
The SBA data holds one macro downturn (2008) but supports a two-level stress ladder read off realised cohorts: - adverse = crisis cohort pooled (2006-08): EL rate 15.3% (3.1x) - severe = single worst vintage, auto-detected (2007 peak): EL 17.6% (3.6x) credit_risk_parameters_stress now reports TTC + adverse + severe with multipliers; report §10d, README §4 and the stress chart (3-bar ladder) updated. README §4 also notes the two stress lenses in the repo: this parameter stress (whole-book baseline) vs the limit-linked charge-off stress in §9 (seasoned baseline). Tests updated (33 pass).
1 parent 5f33a41 commit 1bf2ba9

8 files changed

Lines changed: 116 additions & 80 deletions

File tree

README.md

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -143,26 +143,37 @@ cost, opex and a capital charge. It varies ~9× across sectors:
143143

144144
## 4. Stress test — downturn PD / LGD / EL
145145

146-
The 2006–08 financial-crisis vintages the data already contains are a **realised
147-
downturn scenario**. Both **PD and LGD rise**, so EL rises multiplicatively —
148-
the downturn-PD / downturn-LGD inputs for stressed pricing and capital
146+
The SBA data (FY2000–2019) contains **one** macro downturn — the 2008 financial
147+
crisis — but it supports a **two-level stress ladder** read straight off the
148+
realised cohorts (both are fully seasoned, so near-final):
149+
150+
- **Adverse** = the crisis cohort pooled (2006–08)
151+
- **Severe** = the single worst vintage, the peak of the crisis (**2007**, auto-detected)
152+
153+
Both **PD and LGD rise**, so EL rises multiplicatively
149154
([09_…stress.csv](outputs/tables/09_credit_risk_parameters_stress.csv)):
150155

151-
| Parameter | Through-the-cycle | Crisis / downturn (2006–08) | Stress multiplier |
152-
|---|---:|---:|---:|
153-
| PD — default rate (obligor-weighted) | 12.2% | 25.9% | **2.1×** |
154-
| PD — default rate (exposure-weighted) | 7.7% | 21.9% | **2.8×** |
155-
| LGD — loss given default | 64.0% | 70.0% | **1.1×** |
156-
| EAD — avg exposure per loan | $264,769 | $154,065 | 0.6× |
157-
| **EL rate — expected loss / exposure** | **4.9%** | **15.3%** | **3.1×** |
158-
159-
![Parameter stress — downturn vs through-the-cycle](outputs/charts/parameters_stress.png)
160-
161-
The headline: **expected loss roughly triples (4.9% → 15.3%)** in a downturn,
162-
driven mostly by PD doubling with LGD adding ~9% on top. (EAD *falls* — crisis
163-
cohorts were smaller loans — so it is not a stress driver; exposure is fixed at
164-
approval.) This feeds the limit-linked charge-off stress in report §9 and
165-
[08_stress_scenario.csv](outputs/tables/08_stress_scenario.csv).
156+
| Parameter | Through-the-cycle | Adverse (2006–08) | × | Severe (2007) | × |
157+
|---|---:|---:|---:|---:|---:|
158+
| PD — default rate (obligor-weighted) | 12.2% | 25.9% | **2.1×** | 28.8% | **2.4×** |
159+
| PD — default rate (exposure-weighted) | 7.7% | 21.9% | **2.8×** | 24.4% | **3.2×** |
160+
| LGD — loss given default | 64.0% | 70.0% | **1.1×** | 71.9% | **1.1×** |
161+
| EAD — avg exposure per loan | $264,769 | $154,065 | 0.6× | $142,500 | 0.5× |
162+
| **EL rate — expected loss / exposure** | **4.9%** | **15.3%** | **3.1×** | **17.6%** | **3.6×** |
163+
164+
![Parameter stress ladder — adverse & severe vs through-the-cycle](outputs/charts/parameters_stress.png)
165+
166+
The headline: **expected loss roughly triples** in a downturn (4.9% → 15.3%
167+
adverse → 17.6% severe), driven mostly by PD doubling with LGD adding ~9–12% on
168+
top. (EAD *falls* — crisis cohorts were smaller loans — so it is not a stress
169+
driver; exposure is fixed at approval.)
170+
171+
> **Two stress views in the repo, by design.** This *parameter* stress (PD/LGD/EAD/EL,
172+
> whole-book TTC baseline) sits alongside the limit-linked **charge-off-rate
173+
> stress** in report §9 / [08_stress_scenario.csv](outputs/tables/08_stress_scenario.csv),
174+
> which uses a *seasoned-only* baseline (13.5%) so it can be re-tested against the
175+
> risk-appetite charge-off limit. Same crisis, two lenses — pricing/ECL inputs
176+
> here, appetite-limit breach there.
166177
167178
---
168179

5.63 KB
Loading

outputs/reports/report.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -255,15 +255,15 @@ _Realised, **through-the-cycle** parameters read off the FY2000-2019 outcome dat
255255
| Collateral | Secured | 436,969 | 8.8% | 62.9% | $410,653 | 3.4% |
256256
| Collateral | Unsecured | 650,050 | 14.5% | 64.8% | $166,705 | 7.5% |
257257

258-
**10d. Parameter stress test — through-the-cycle vs the downturn (2006, 2007, 2008).** The financial-crisis vintages are a realised, data-grounded downturn: **both PD and LGD rise**, so EL rises multiplicatively. These are the downturn-PD / downturn-LGD inputs for stressed pricing and capital:
258+
**10d. Parameter stress test — the two downturn severities in the data.** The SBA data holds one macro downturn (the 2008 crisis) but supports a two-level stress ladder read off realised cohorts: **adverse** = the crisis cohort pooled (2006, 2007, 2008); **severe** = the single worst vintage (2007, the peak). PD and LGD both rise, so EL rises multiplicatively downturn-PD / downturn-LGD inputs for stressed pricing and capital:
259259

260-
| Parameter | Through-the-cycle | Crisis / downturn | Stress multiplier |
261-
|---|---|---|---|
262-
| PD — default rate (obligor-weighted) | 12.2% | 25.9% | 2.13x |
263-
| PD — default rate (exposure-weighted) | 7.7% | 21.9% | 2.85x |
264-
| LGD — loss given default | 64.0% | 70.0% | 1.09x |
265-
| EAD — avg exposure per loan ($) | $264,769 | $154,065 | 0.58x |
266-
| EL rate — expected loss / exposure | 4.9% | 15.3% | 3.11x |
260+
| Parameter | Through-the-cycle | Adverse (2006, 2007, 2008) | × | Severe (2007) | × |
261+
|---|---|---|---|---|---|
262+
| PD — default rate (obligor-weighted) | 12.2% | 25.9% | 2.13x | 28.8% | 2.36x |
263+
| PD — default rate (exposure-weighted) | 7.7% | 21.9% | 2.85x | 24.4% | 3.18x |
264+
| LGD — loss given default | 64.0% | 70.0% | 1.09x | 71.9% | 1.12x |
265+
| EAD — avg exposure per loan ($) | $264,769 | $154,065 | 0.58x | $142,500 | 0.54x |
266+
| EL rate — expected loss / exposure | 4.9% | 15.3% | 3.11x | 17.6% | 3.57x |
267267

268268
## Notes — APS 330 / Pillar 3 & governance
269269

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
metric_key,parameter,through_the_cycle,crisis_downturn,stress_multiplier,crisis_vintages
2-
pd_count,PD — default rate (obligor-weighted),0.122,0.2594,2.13,"2006, 2007, 2008"
3-
pd_dollar,PD — default rate (exposure-weighted),0.0769,0.2188,2.85,"2006, 2007, 2008"
4-
lgd,LGD — loss given default,0.6401,0.6998,1.09,"2006, 2007, 2008"
5-
ead_avg,EAD — avg exposure per loan ($),264769.0,154065.0,0.58,"2006, 2007, 2008"
6-
el_rate,EL rate — expected loss / exposure,0.0492,0.1531,3.11,"2006, 2007, 2008"
1+
metric_key,parameter,through_the_cycle,adverse_downturn,adverse_multiplier,severe,severe_multiplier,adverse_vintages,severe_vintage
2+
pd_count,PD — default rate (obligor-weighted),0.122,0.2594,2.13,0.2882,2.36,"2006, 2007, 2008",2007
3+
pd_dollar,PD — default rate (exposure-weighted),0.0769,0.2188,2.85,0.2442,3.18,"2006, 2007, 2008",2007
4+
lgd,LGD — loss given default,0.6401,0.6998,1.09,0.719,1.12,"2006, 2007, 2008",2007
5+
ead_avg,EAD — avg exposure per loan ($),264769.0,154065.0,0.58,142500.0,0.54,"2006, 2007, 2008",2007
6+
el_rate,EL rate — expected loss / exposure,0.0492,0.1531,3.11,0.1756,3.57,"2006, 2007, 2008",2007

src/credit_parameters.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -136,22 +136,34 @@ def credit_risk_parameters_by_product(df: pd.DataFrame) -> pd.DataFrame:
136136

137137
def credit_risk_parameters_stress(df: pd.DataFrame,
138138
config: dict | None = None) -> pd.DataFrame:
139-
"""Stress the parameters: through-the-cycle book vs the 2006-08 downturn.
140-
141-
The financial-crisis vintages the data already contains are used as a
142-
realised, data-grounded downturn scenario. For each parameter we report the
143-
through-the-cycle (whole-book) value, the crisis-cohort value, and the
144-
implied **stress multiplier** (crisis / TTC). PD and LGD both rise in the
145-
downturn, so EL rises multiplicatively — a downturn-PD / downturn-LGD view
146-
for capital and limit-setting (the crisis vintages are fully seasoned, so
147-
these are near-final outcomes).
139+
"""Stress the parameters using the two downturn severities the data contains.
140+
141+
The SBA data (FY2000-2019) holds one macro downturn — the 2008 financial
142+
crisis — but it supports a two-level stress *ladder* read straight off the
143+
realised cohorts:
144+
145+
* **Adverse / downturn** — the crisis cohort pooled (config
146+
``stress.crisis_vintages``, 2006-08).
147+
* **Severe** — the single worst seasoned vintage (the peak of the crisis,
148+
auto-detected by charge-off rate — 2007).
149+
150+
For each parameter we report the through-the-cycle (whole-book) value and
151+
both stressed values with their multiplier vs TTC. PD and LGD both rise, so
152+
EL rises multiplicatively — the downturn-PD / downturn-LGD inputs for
153+
stressed pricing and capital. (Crisis vintages are fully seasoned, so these
154+
are near-final outcomes.)
148155
"""
149156
cfg = config or load_config()
150157
crisis_vintages = sorted(set(cfg["stress"]["crisis_vintages"]))
151158

152159
ttc = _parameter_frame(df, None, "ttc").iloc[0]
153-
crisis = _parameter_frame(
154-
df[df["vintage"].isin(crisis_vintages)], None, "crisis").iloc[0]
160+
downturn = _parameter_frame(
161+
df[df["vintage"].isin(crisis_vintages)], None, "downturn").iloc[0]
162+
163+
# Severe = the single worst seasoned vintage by charge-off rate (auto).
164+
seasoned = df[df["fully_seasoned"]]
165+
peak_vintage = int(seasoned.groupby("vintage")["is_default"].mean().idxmax())
166+
severe = _parameter_frame(df[df["vintage"] == peak_vintage], None, "severe").iloc[0]
155167

156168
specs = [
157169
("pd_count", "PD — default rate (obligor-weighted)"),
@@ -162,22 +174,24 @@ def credit_risk_parameters_stress(df: pd.DataFrame,
162174
]
163175
rows = []
164176
for key, label in specs:
165-
b, s = float(ttc[key]), float(crisis[key])
177+
b, d, s = float(ttc[key]), float(downturn[key]), float(severe[key])
166178
rows.append({
167179
"metric_key": key,
168180
"parameter": label,
169181
"through_the_cycle": round(b, 4),
170-
"crisis_downturn": round(s, 4),
171-
"stress_multiplier": round(s / b, 2) if b else float("nan"),
182+
"adverse_downturn": round(d, 4),
183+
"adverse_multiplier": round(d / b, 2) if b else float("nan"),
184+
"severe": round(s, 4),
185+
"severe_multiplier": round(s / b, 2) if b else float("nan"),
172186
})
173187
out = pd.DataFrame(rows)
174-
out["crisis_vintages"] = ", ".join(str(v) for v in crisis_vintages)
188+
out["adverse_vintages"] = ", ".join(str(v) for v in crisis_vintages)
189+
out["severe_vintage"] = peak_vintage
175190
_log.info(
176-
"Parameter stress (crisis %s): PD %.1f%%->%.1f%%, LGD %.1f%%->%.1f%%, "
177-
"EL %.1f%%->%.1f%%", crisis_vintages,
178-
ttc["pd_count"] * 100, crisis["pd_count"] * 100,
179-
ttc["lgd"] * 100, crisis["lgd"] * 100,
180-
ttc["el_rate"] * 100, crisis["el_rate"] * 100,
191+
"Parameter stress — EL rate: TTC %.1f%% -> adverse %.1f%% (%s) -> "
192+
"severe %.1f%% (%d peak)",
193+
ttc["el_rate"] * 100, downturn["el_rate"] * 100, crisis_vintages,
194+
severe["el_rate"] * 100, peak_vintage,
181195
)
182196
return out
183197

src/report.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -421,20 +421,23 @@ def build_markdown_report(
421421
a("")
422422

423423
if credit_params_stress is not None and not credit_params_stress.empty:
424-
vint = credit_params_stress["crisis_vintages"].iloc[0]
425-
a(f"**10d. Parameter stress test — through-the-cycle vs the downturn "
426-
f"({vint}).** The financial-crisis vintages are a realised, "
427-
"data-grounded downturn: **both PD and LGD rise**, so EL rises "
428-
"multiplicatively. These are the downturn-PD / downturn-LGD inputs "
429-
"for stressed pricing and capital:")
424+
adv = credit_params_stress["adverse_vintages"].iloc[0]
425+
sev = int(credit_params_stress["severe_vintage"].iloc[0])
426+
a("**10d. Parameter stress test — the two downturn severities in the "
427+
"data.** The SBA data holds one macro downturn (the 2008 crisis) "
428+
"but supports a two-level stress ladder read off realised cohorts: "
429+
f"**adverse** = the crisis cohort pooled ({adv}); **severe** = the "
430+
f"single worst vintage ({sev}, the peak). PD and LGD both rise, so "
431+
"EL rises multiplicatively — downturn-PD / downturn-LGD inputs for "
432+
"stressed pricing and capital:")
430433
a("")
431-
a("| Parameter | Through-the-cycle | Crisis / downturn | Stress multiplier |")
432-
a("|---|---|---|---|")
434+
a(f"| Parameter | Through-the-cycle | Adverse ({adv}) | × | Severe ({sev}) | × |")
435+
a("|---|---|---|---|---|---|")
433436
for _, r in credit_params_stress.iterrows():
434-
money = r["metric_key"] == "ead_avg"
435-
fmt = _fmt_money if money else _fmt_pct
437+
fmt = _fmt_money if r["metric_key"] == "ead_avg" else _fmt_pct
436438
a(f"| {r['parameter']} | {fmt(r['through_the_cycle'])} | "
437-
f"{fmt(r['crisis_downturn'])} | {r['stress_multiplier']:.2f}x |")
439+
f"{fmt(r['adverse_downturn'])} | {r['adverse_multiplier']:.2f}x | "
440+
f"{fmt(r['severe'])} | {r['severe_multiplier']:.2f}x |")
438441
a("")
439442

440443
a("## Notes — APS 330 / Pillar 3 & governance")

tests/test_pipeline.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,13 @@ def test_credit_parameters_by_structure(base):
329329
def test_credit_parameters_stress(base, cfg):
330330
out = cp.credit_risk_parameters_stress(base, config=cfg)
331331
assert {"pd_count", "lgd", "el_rate"} <= set(out["metric_key"])
332-
assert {"through_the_cycle", "crisis_downturn", "stress_multiplier"} <= set(out.columns)
333-
# Crisis cohorts default worse → PD and EL multipliers exceed 1 in the fixture.
334-
pd_row = out.set_index("metric_key").loc["pd_count"]
335-
assert pd_row["crisis_downturn"] > pd_row["through_the_cycle"]
336-
assert out.set_index("metric_key").loc["el_rate", "stress_multiplier"] > 1.0
332+
assert {"through_the_cycle", "adverse_downturn", "adverse_multiplier",
333+
"severe", "severe_multiplier"} <= set(out.columns)
334+
s = out.set_index("metric_key")
335+
# Crisis cohorts default worse than the book; severe (peak) >= adverse (pooled).
336+
assert s.loc["pd_count", "adverse_downturn"] > s.loc["pd_count", "through_the_cycle"]
337+
assert s.loc["el_rate", "adverse_multiplier"] > 1.0
338+
assert s.loc["el_rate", "severe"] >= s.loc["el_rate", "adverse_downturn"]
337339

338340

339341
# --------------------------------------------------------------------------- #

tools/make_figures.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -118,26 +118,32 @@ def _barh_labels(ax, values, fmt="{:.1f}%"):
118118
ax.grid(axis="y", alpha=0)
119119
save(fig, "el_by_sector.png")
120120

121-
# 6. Parameter stress — downturn vs through-the-cycle ------------------------
121+
# 6. Parameter stress — the two downturn severities in the data --------------
122122
st = pd.read_csv(TAB / "09_credit_risk_parameters_stress.csv").set_index("metric_key")
123-
vint = str(st["crisis_vintages"].iloc[0])
123+
adv_v = str(st["adverse_vintages"].iloc[0])
124+
sev_v = int(st["severe_vintage"].iloc[0])
124125
keys = ["pd_count", "lgd", "el_rate"]
125126
labels = ["PD\n(default rate)", "LGD\n(loss given default)", "EL rate\n(expected loss)"]
126127
ttc = [st.loc[k, "through_the_cycle"] * 100 for k in keys]
127-
cri = [st.loc[k, "crisis_downturn"] * 100 for k in keys]
128-
mult = [st.loc[k, "stress_multiplier"] for k in keys]
128+
adv = [st.loc[k, "adverse_downturn"] * 100 for k in keys]
129+
sev = [st.loc[k, "severe"] * 100 for k in keys]
130+
adv_m = [st.loc[k, "adverse_multiplier"] for k in keys]
131+
sev_m = [st.loc[k, "severe_multiplier"] for k in keys]
129132
x = np.arange(len(keys))
130-
w = 0.38
131-
fig, ax = plt.subplots(figsize=(8.8, 5.4))
132-
ax.bar(x - w / 2, ttc, w, label="through-the-cycle (FY2000–2019)", color=BLUE)
133-
b2 = ax.bar(x + w / 2, cri, w, label=f"crisis / downturn ({vint})", color=RED)
134-
for xi, c, m in zip(x, cri, mult):
135-
ax.text(xi + w / 2, c, f"{m:.1f}×", ha="center", va="bottom", fontsize=11, fontweight="bold")
133+
w = 0.27
134+
fig, ax = plt.subplots(figsize=(9.6, 5.6))
135+
ax.bar(x - w, ttc, w, label="through-the-cycle (FY2000–2019)", color=BLUE)
136+
ax.bar(x, adv, w, label=f"adverse — crisis cohort ({adv_v})", color="#ef8a62")
137+
ax.bar(x + w, sev, w, label=f"severe — peak vintage ({sev_v})", color=RED)
138+
for xi, v, m in zip(x, adv, adv_m):
139+
ax.text(xi, v, f"{m:.1f}×", ha="center", va="bottom", fontsize=9.5)
140+
for xi, v, m in zip(x, sev, sev_m):
141+
ax.text(xi + w, v, f"{m:.1f}×", ha="center", va="bottom", fontsize=9.5, fontweight="bold")
136142
ax.set_xticks(x)
137143
ax.set_xticklabels(labels)
138144
ax.set_ylabel("rate (%)")
139-
ax.set_title("Parameter stress testdownturn vs through-the-cycle")
140-
ax.legend(frameon=False)
145+
ax.set_title("Parameter stress ladderadverse & severe vs through-the-cycle")
146+
ax.legend(frameon=False, fontsize=10)
141147
ax.grid(axis="x", alpha=0)
142148
save(fig, "parameters_stress.png")
143149

0 commit comments

Comments
 (0)