Spaces:
Sleeping
Sleeping
Day 4: runtime file set (engine, agent, API, UI source, panel, DFAST scenarios, model cache, exhibits)
379bf78 verified | """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)) | |