Spaces:
Sleeping
Sleeping
File size: 9,997 Bytes
379bf78 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 | """Exposure-at-default (EAD) profiles: contractual amortisation + revolver CCF.
Rung-1 EAD component of the IFRS 9 ECL engine. Produces, per loan, the
deterministic exposure path EAD_t that multiplies the PD/LGD term structure in
ECL = sum_t S(t-1) * lambda_t * LGD_t * EAD_t * (1 + EIR)^-t
(tests/fixtures/compute_ecl.py section 3 -- the golden convention)
TIMING CONVENTION (aligned with engine/hazard.py's pd_term_structure)
----------------------------------------------------------------------
Projected periods are indexed t = 1..horizon, where t = 1 is the first
quarter AFTER the snapshot. EAD_t is the CONTRACTUAL principal balance
ENTERING period t, i.e. the snapshot balance after t-1 scheduled quarterly
payments. This mirrors the compute_ecl section-3 fixture exactly ("the
exposure at risk in year t is the balance after t-1 repayments"): default in
period t crystallises against the balance outstanding at the start of that
period, before the period's own instalment. Consequently EAD_1 equals the
snapshot balance, the path is monotone non-increasing, and EAD_t = 0 for
every t beyond the remaining term.
CRITICAL -- NO PREPAYMENT DOUBLE COUNTING
------------------------------------------
EAD_t here is the CONTRACTUAL amortisation balance and is deliberately NOT
scaled by prepayment probabilities. The ECL survival weight S(t-1) produced
by engine/hazard.py is the competing-risk survival
S(t) = prod_k (1 - lambda_default_k - lambda_prepay_k): it ALREADY removes
the prepaid fraction of the book. Scaling EAD_t by prepayment survival as
well would count prepayment twice and understate lifetime ECL. Any reviewer
checking this module: the contractual path is a requirement, not an
oversight.
AMORTISATION CONVENTION
------------------------
Level-payment (annuity) amortisation with QUARTERLY compounding of the
nominal annual note rate quoted in percent (the panel's interest_rate_time):
r_q = annual_rate / 100 / 4 (nominal/4 quarterly rate)
B_k = B_0 * ((1+r_q)^n - (1+r_q)^k) / ((1+r_q)^n - 1), k = 0..n
EAD_t = B_{t-1}, EAD_t = 0 for t > n
with n = remaining term in quarters. The closed form is the standard
annuity balance identity (equivalent to the recursion
B_k = B_{k-1} * (1+r_q) - payment with the level payment
A = B_0 * r_q / (1 - (1+r_q)^-n)); it guarantees B_n = 0 exactly
(terminal balance ~0 at maturity) and monotone decline (no
negative-amortisation products exist in this book).
DOCUMENTED FALLBACKS / SIMPLIFICATIONS
---------------------------------------
* rate <= 0, NaN, or so small that 1 + rate/400 rounds to 1 in float64
-> STRAIGHT-LINE fallback B_k = B_0 * (1 - k/n).
In the built panel interest_rate_time > 0 on every row (zero-coded rates
were dropped in the panel waterfall), so the fallback is defensive; the
orig_rate_missing flag concerns the ORIGINATION-rate snapshot only -- the
current note rate that drives amortisation is populated for those loans.
* remaining term floored at 1 quarter: loans at/past contractual maturity
(mat_time <= time; 82 panel rows) are treated as fully due within one
quarter -- EAD_1 = current balance, zero thereafter.
* The remaining term is taken as mat_time - time, i.e. the ORIGINAL
contractual maturity; payment-holiday / modification reprofiling is out of
scope at this rung (no such data exists in the panel).
* Balloon / interest-only structures are not modelled: every term loan is
assumed level-pay to zero at maturity. The panel carries no amortisation-
type field, so this is the disciplined default for US fixed-rate
mortgages.
* Revolvers: the DCR mortgage book contains none; ccf_ead exists for engine
completeness and reproduces the compute_ecl section-12 golden fixture
EAD = drawn + CCF * (limit - drawn) (5, 20, 0.6 -> 14.0).
Everything here is deterministic: no randomness, no I/O, no network.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
#: nominal percent -> quarterly decimal rate divisor (100 * 4)
RATE_DIVISOR = 400.0
#: columns ead_matrix requires on the snapshot frame
SNAPSHOT_COLS = ["id", "time", "mat_time", "balance_time",
"interest_rate_time"]
def _ead_paths(balance: np.ndarray, annual_rate_pct: np.ndarray,
remaining_quarters: np.ndarray, horizon: int) -> np.ndarray:
"""Vectorised core: (m,) inputs -> (m, horizon) matrix of EAD_t, t=1..horizon.
EAD_t = start-of-period-t contractual balance = B_{t-1} (module
docstring). Annuity closed form where the rate is usable, straight-line
fallback otherwise; remaining term floored at 1 quarter.
"""
balance = np.atleast_1d(np.asarray(balance, dtype=float))
rate = np.atleast_1d(np.asarray(annual_rate_pct, dtype=float))
n = np.atleast_1d(np.asarray(remaining_quarters, dtype=float))
if not (balance.shape == rate.shape == n.shape):
raise ValueError("balance, rate and remaining term must align")
if np.any(balance < 0):
raise ValueError("negative balances are not valid exposures")
if horizon < 1:
raise ValueError("horizon must be >= 1")
n = np.maximum(np.floor(n), 1.0)[:, None] # floor at 1 quarter
# payments already made when period t starts: k = t-1, capped at n so the
# closed form returns exactly 0 beyond maturity.
k = np.minimum(np.arange(horizon, dtype=float)[None, :], n)
r_q = rate / RATE_DIVISOR
# usable only when 1 + r_q is representably > 1 in float64: a denormal-
# tiny positive rate would make (1+r_q)^n - 1 == 0 and the annuity form
# 0/0 -> NaN, so such rates take the straight-line fallback as well
# (for finite r_q the condition subsumes r_q > 0).
use_annuity = np.isfinite(r_q) & (1.0 + r_q > 1.0)
r_safe = np.where(use_annuity, r_q, 0.01)[:, None] # dummy in dead branch
grow_n = (1.0 + r_safe) ** n
frac_annuity = (grow_n - (1.0 + r_safe) ** k) / (grow_n - 1.0)
frac_straight = 1.0 - k / n
frac = np.where(use_annuity[:, None], frac_annuity, frac_straight)
# pure float-noise guard; both branches are analytically within [0, 1]
frac = np.clip(frac, 0.0, 1.0)
return balance[:, None] * frac
def ead_profile(balance: float, annual_rate: float,
remaining_quarters: int, horizon: int) -> np.ndarray:
"""Contractual level-payment EAD path for a single term loan.
Parameters
----------
balance : current outstanding principal (the snapshot balance_time).
annual_rate : nominal annual note rate in PERCENT (interest_rate_time
units, e.g. 6.5 = 6.5%), compounded quarterly (r_q = rate/400).
NaN, <= 0, or degenerately tiny (1 + r_q rounds to 1 in float64)
triggers the documented straight-line fallback.
remaining_quarters : contractual quarters to maturity, floored at 1.
horizon : number of projected quarters t = 1..horizon.
Returns
-------
np.ndarray of shape (horizon,): EAD_t = contractual balance entering
period t (after t-1 level payments); EAD_1 = balance, monotone
non-increasing, exactly 0 for t > remaining_quarters.
CONTRACTUAL PATH ONLY -- deliberately NOT prepayment-scaled: the ECL
survival S(t-1) from engine/hazard.py is already the competing-risk
survival including prepayment, so scaling EAD by prepayment as well
would double count (module docstring, CRITICAL section).
"""
return _ead_paths(
np.array([balance]), np.array([annual_rate]),
np.array([remaining_quarters]), horizon,
)[0]
def ead_matrix(panel_snapshot_df: pd.DataFrame, horizon: int) -> pd.DataFrame:
"""Per-loan contractual EAD paths from a one-quarter panel snapshot.
Parameters
----------
panel_snapshot_df : one row per loan (a single-quarter slice of
data/processed/panel.parquet) carrying SNAPSHOT_COLS: id, time,
mat_time, balance_time, interest_rate_time. Remaining term is
mat_time - time, floored at 1 quarter.
horizon : number of projected quarters t = 1..horizon.
Returns
-------
pd.DataFrame indexed by loan id with integer columns 1..horizon;
cell (i, t) = EAD_t for loan i under the module's contractual
level-payment convention (NOT prepayment-scaled -- see the CRITICAL
double-counting section of the module docstring).
"""
missing = [c for c in SNAPSHOT_COLS if c not in panel_snapshot_df.columns]
if missing:
raise KeyError(f"snapshot frame is missing required columns: {missing}")
ids = panel_snapshot_df["id"].to_numpy()
if len(np.unique(ids)) != len(ids):
raise ValueError("snapshot has duplicate loan ids -- pass one row "
"per loan (a single-quarter slice)")
remaining = (panel_snapshot_df["mat_time"].to_numpy(dtype=float)
- panel_snapshot_df["time"].to_numpy(dtype=float))
paths = _ead_paths(
panel_snapshot_df["balance_time"].to_numpy(dtype=float),
panel_snapshot_df["interest_rate_time"].to_numpy(dtype=float),
remaining, horizon,
)
return pd.DataFrame(paths, index=pd.Index(ids, name="id"),
columns=pd.RangeIndex(1, horizon + 1, name="period"))
def ccf_ead(drawn: float, limit: float, ccf: float) -> float:
"""Revolver EAD via the credit-conversion factor.
EAD = drawn + CCF * max(limit - drawn, 0)
Reproduces the compute_ecl section-12 golden fixture: drawn 5m, limit
20m, CCF 0.6 -> 5 + 0.6 * 15 = 14.0m (2.8x drawn). The undrawn headroom
is floored at 0 so an overlimit facility contributes its drawn balance
only; CCF is not clamped (regulatory CCFs can exceed 1 for facilities
that are drawn down further past limit-breach). Engine-completeness
only: the DCR mortgage panel contains no revolvers.
"""
if drawn < 0:
raise ValueError("drawn balance must be >= 0")
if ccf < 0:
raise ValueError("CCF must be >= 0")
return float(drawn + ccf * max(limit - drawn, 0.0))
|