Preetomsorkar's picture
Day 4: runtime file set (engine, agent, API, UI source, panel, DFAST scenarios, model cache, exhibits)
379bf78 verified
Raw
History Blame Contribute Delete
10 kB
"""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))