Source code · Python

portfolio_risk.py

116 lines · Part of Credit risk lab · All code · Read only; the files run as published

"""Portfolio credit risk: expected loss, Basel IRB capital and a Monte Carloloss distribution for the whole card book, using the PDs from pd_model.py.     Expected loss (EL)  = PD x LGD x EAD, per account    EAD                 = drawn balance + CCF x undrawn limit    IRB capital (K)     = Basel II/III retail formula for qualifying revolving                          retail exposures, asset correlation R = 0.04    Monte Carlo         = one-factor Gaussian copula (the model behind the IRB                          formula), simulated with idiosyncratic default noise LGD and CCF are not in the data; they are stated assumptions with stress cases.The PD here is the model's probability of a missed payment in the followingmonth, which is a shorter horizon than the one-year PD Basel uses, so capitalfigures illustrate the method rather than a regulatory number."""import numpy as npimport pandas as pdfrom scipy.stats import norm from pd_model import STATUS_ORDER LGD = 0.80            # unsecured card balances: assumptionCCF = 0.50            # share of the undrawn limit drawn by default: assumptionR_QRRE = 0.04         # Basel asset correlation for qualifying revolving retailCONFIDENCE = 0.999    # Basel IRB confidence levelPD_FLOOR = 0.001      # Basel III floor for revolving retail PDSIMULATIONS = 20_000SEED = 7  def ead(features: pd.DataFrame, ccf: float = CCF) -> np.ndarray:    return features["drawn_balance"].to_numpy() + ccf * features["undrawn_limit"].to_numpy()  def irb_capital(pd_: np.ndarray, lgd: float, rho: float = R_QRRE, q: float = CONFIDENCE) -> np.ndarray:    """Capital per unit of EAD: loss at the q quantile of the systematic factor, less EL."""    conditional = norm.cdf((norm.ppf(pd_) + np.sqrt(rho) * norm.ppf(q)) / np.sqrt(1 - rho))    return lgd * (conditional - pd_)  def simulate_losses(pd_: np.ndarray, exposure: np.ndarray, lgd: float, rho: float,                    n: int = SIMULATIONS, seed: int = SEED, chunk: int = 250) -> np.ndarray:    """Portfolio loss in each scenario: an account defaults when its latent value    sqrt(rho) * Z + sqrt(1 - rho) * e falls below the PD threshold."""    rng = np.random.default_rng(seed)    threshold = norm.ppf(pd_).astype(np.float32)    loss_if_default = (lgd * exposure).astype(np.float32)    a, b = np.float32(np.sqrt(rho)), np.float32(np.sqrt(1 - rho))    losses = np.empty(n)    for start in range(0, n, chunk):        m = min(chunk, n - start)        z = rng.standard_normal((m, 1), dtype=np.float32)        e = rng.standard_normal((m, pd_.size), dtype=np.float32)        defaults = (a * z + b * e) < threshold        losses[start:start + m] = defaults @ loss_if_default    return losses  def tail(losses: np.ndarray, q: float) -> tuple[float, float]:    var = float(np.quantile(losses, q))    return var, float(losses[losses >= var].mean())  def scenario(name: str, pd_: np.ndarray, exposure: np.ndarray, lgd: float, rho: float, sims: int) -> dict:    el = float(np.sum(pd_ * lgd * exposure))    k = float(np.sum(irb_capital(pd_, lgd, rho) * exposure))    losses = simulate_losses(pd_, exposure, lgd, rho, n=sims)    var999, es999 = tail(losses, CONFIDENCE)    return {"scenario": name, "expected_loss": el, "irb_capital": k, "var_999": var999,            "unexpected_loss_999": var999 - float(losses.mean()), "es_999": es999,            "simulated_mean": float(losses.mean()), "losses": losses}  def run(features: pd.DataFrame, pd_model_values: np.ndarray) -> dict:    pd_ = np.clip(pd_model_values, PD_FLOOR, 0.9999)    exposure = ead(features)    total_ead = float(exposure.sum())    el_account = pd_ * LGD * exposure     base = scenario("Base", pd_, exposure, LGD, R_QRRE, SIMULATIONS)    stresses = [        scenario("PD x 1.5", np.clip(pd_ * 1.5, PD_FLOOR, 0.9999), exposure, LGD, R_QRRE, 10_000),        scenario("LGD 90%", pd_, exposure, 0.90, R_QRRE, 10_000),        scenario("CCF 100% (full limit drawn)", pd_, ead(features, 1.0), LGD, R_QRRE, 10_000),        scenario("Correlation 0.15", pd_, exposure, LGD, 0.15, 10_000),        scenario("Combined: PD x 1.5, LGD 90%, correlation 0.15",                 np.clip(pd_ * 1.5, PD_FLOOR, 0.9999), exposure, 0.90, 0.15, 10_000),    ]     # Loss distribution for the chart, as a share of total EAD.    share = base["losses"] / total_ead    counts, edges = np.histogram(share, bins=40)    histogram = [{"from": float(edges[i]), "to": float(edges[i + 1]), "share_of_sims": float(counts[i] / share.size)}                 for i in range(len(counts))]     # Where the expected loss sits: by most recent repayment status.    by_status = (pd.DataFrame({"segment": features["status_sep_group"], "ead": exposure, "el": el_account})                 .groupby("segment").sum().reindex(STATUS_ORDER))    concentration = [{"segment": seg, "share_of_ead": float(r["ead"] / total_ead),                      "share_of_el": float(r["el"] / el_account.sum())} for seg, r in by_status.iterrows()]     def strip(s: dict) -> dict:        return {k: v for k, v in s.items() if k != "losses"}     return {        "assumptions": {"lgd": LGD, "ccf": CCF, "correlation": R_QRRE, "confidence": CONFIDENCE,                        "pd_floor": PD_FLOOR, "simulations": SIMULATIONS, "currency": "NT$"},        "book": {"accounts": int(len(features)), "credit_limits": float(features["credit_limit"].sum()),                 "drawn": float(features["drawn_balance"].sum()), "ead": total_ead,                 "average_pd": float(pd_.mean()), "ead_weighted_pd": float(np.sum(pd_ * exposure) / total_ead)},        "base": strip(base),        "stress": [strip(s) for s in stresses],        "histogram": histogram,        "concentration": concentration,        "account_level": pd.DataFrame({"pd": pd_, "ead": exposure, "lgd": LGD}),    }