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))