A complete end-to-end implementation of Non-Maturity Deposit (NMD) Models for IRRBB Measurement, behavioral analysis, and Asset-Liability Management (ALM) — built in Python across 10 sequential Jupyter notebooks.
Non-Maturity Deposits (NMD) are bank deposits with no contractual maturity date e.g., savings accounts, current accounts, and demand deposits. Customers can withdraw at any time, yet in practice most balances remain stable for years. This creates a fundamental challenge for banks:
- Liquidity risk (ILAAP): If customers suddenly withdraw, the bank must have enough liquid assets to pay them.
- Interest rate risk (IRRBB): The bank invests deposits at market rates but pays customers a lower "sticky" deposit rate. When interest rates change, both the value of investments and the cost of funding shift — but not at the same speed.
Under BCBS 368 (Interest Rate Risk in the Banking Book, April 2016), banks are required to model NMD Behavior explicitly, quantify how sensitive their economic value is to interest rate movements, and report this exposure to regulators. This repository implements the complete NMD Modeling framework from raw data to IRRBB Disclosure tables with consideration of ILAAP integrated.
The project is built as 10 sequential Jupyter notebooks (Notebook 01 - Notebook 10), each responsible for one modeling layer. Outputs from upstream notebooks flow as inputs into downstream ones via serialized model objects (.pkl).
Synthetic Data → Behavioral Models → Rate Models → Valuation → Hedging → IRRBB Report
NB01 NB02–NB03 NB04–NB05 NB06–NB07 NB08–NB09 NB10
- Fully reproducible — Every result can be traced back to Notebook 01's random seed.
- BCBS 368 Compliant — EVE Discount method, regulatory caps, and scenario shocks follow the standard.
- No OOP — All logic implemented as standalone functions with full docstrings.
- Modular — Each notebook can be modified or extended independently.
nmd_model/
├── model/ #Trainned model and parameters (pkl.)
│ ├── hazard_rate.pkl
│ ├── ci_model.pkl
│ ├── hp_model.pkl
│ ├── gbm_model.pkl
│ ├── ddm_model.pkl
│ ├── ddy_model.pkl
│ ├── beta_model.pkl
│ ├── threshold_model.pkl
│ ├── jvd_model.pkl
│ ├── threshold_model.pkl
│ ├── runoff_model.pkl
│ ├── replicating_weights.pkl
│ ├── yield_curve.pkl
│ └── dynamics_model.pkl
├── notebooks/
│ ├── 01_data_generation.ipynb
│ ├── 02_survival_decay.ipynb
│ ├── 03_stable_nonstable.ipynb
│ ├── 04_deposit_rate_model.ipynb
│ ├── 05_deposit_decay.ipynb
│ ├── 06_economic_theory.ipynb
│ ├── 07_nmd_floor.ipynb
│ ├── 08_structural_hedge.ipynb
│ ├── 09_wealth_allocation.ipynb
│ └── 10_irrbb_integration.ipynb
├── src/
│ ├── data_generator.py
│ ├── survival_analysis.py
│ ├── stable_nonstable_model.py
│ ├── deposit_rate_model.py
│ ├── deposit_decay_model.py
│ ├── economic_theory.py
│ ├── nmd_floor.py
│ ├── caterpillar.py
│ ├── wealth_allocation.py
│ ├── reporting.py
│ └── plot_function.py
├── data/
│ ├── processed
│ └── raw/
| └── └── nmd_data.parquet
├── requirements.txt
└── README.md
Each notebook exports key outputs via pickle for downstream use. The diagram below shows the complete dependency chain.
NB01: Data Generation
└─► nmd_data.parquet
│
├─► NB02: Survival Decay
│ └─► hazard_rate.pkl ──────────────────────────────► NB05
│
├─► NB03: Stable/Non-Stable
│ └─► stable_pct (CI method) ──────────────────────► NB05
│
├─► NB04: Deposit Rate Model
│ └─► γ=0.2072, α=0.0008, β₂=0.0797 ─────────────► NB05, NB06
│
├─► NB05: Deposit Decay
│ └─► core_balance=3,823 MB, WAL=2.85Y ───────────► NB06, NB08, NB09, NB10
│ └─► IRRBB repricing buckets ─────────────────────► NB10
│
├─► NB06: Economic Theory (EVE)
│ └─► EVE=7,870 MB, ΔEVE=−1,062 MB ─────────────────► NB10
│
├─► NB07: NMD Floor
│ └─► Floor K=0%: 21 MB, K=d₀: 50 MB ──────────────► NB10
│
├─► NB08: Structural Hedge
│ └─► 5Y/5 tranches, NII=186 MB ────────────────────► NB09, NB10
│
└─► NB09: Wealth Allocation
└─► w_cat=85%, NII=172 MB, LCR=173%, NSFR=135% ──► NB10
└─► Asset repricing profile ────────────────────► NB10
NB10: IRRBB Integration
└─► 6 output tables:
1. NMD Model Summary
2. EVE Sensitivity Table
3. NII Sensitivity Table
4. Repricing Gap
5. Net Repricing Gap (Asset − Liability)
6. LCR / NSFR
Purpose: Generate 150 months of realistic NMD Data capturing the joint dynamics of market rate, deposit rate, deposit balance, and CDS spread. The real NMD Data is proprietary and varies significantly across institutions. Synthetic data allows full reproducibility and enables stress scenario testing by simply changing seed parameters.
The four variables are simulated jointly with realistic correlations
| Variable | Model |
|---|---|
Market rate r_t |
AR(1) on changes |
Deposit rate d_t |
Asymmetric error correction |
Balance D_t |
Log-linear with macro drivers |
CDS spread cs_t |
Log AR(1) |
Output: data/raw/nmd_data.parquet — 150 rows × 4 columns
Purpose: Measure how quickly deposit balances run off each month using cohort analysis. The aggregated balance series hides vintage effects. Accounts opened during a low rate environment behave differently from those opened during rate hikes. Cohort analysis separates these effects precisely.
Method — Discrete Hazard Rate:
- Build a balance matrix
B(t, c)wheret= observation month,c= cohort (origination month) - Compute monthly runoff rate per cohort:
RR(t, c) = 1 − B(t, c) / B(t−1, c) - Average across all surviving cohorts at time
t:h(t) = mean(RR(t, c))
Why not Cox Regression? Cox PH is designed for binary events (closed vs. open). NMD has partial runoff that balance declines gradually with no single exit event. Discrete hazard rates capture this continuous erosion.
Stressed runoff — two approaches:
| Approach | Method | Use Case |
|---|---|---|
| Percentile | P95 of h(t) |
ILAAP Base stress floor |
| MEV Regression | h(t) = α + β₁·r_repo + β₂·unemployment + ε |
Macro scenario projection |
Output:
hazard_rate.pkl— monthly hazard rates for first 24 months- Average base hazard ≈ 3.65% per month
Purpose: Decompose total balance into core and non-core (volatile) portions, subject to BCBS 368 regulatory caps. This decomposition matters because only the stable portion can be invested long term. Investing volatile balances in long duration assets creates liquidity risk.
OLS regression of balance on time trend, using the lower 95% CI as the core floor:
stable_pct = mean(lower_CI) / mean(balance)
Decomposes balance into trend and cycle using λ = 1,600. The cycle component is non-core.
Simulates worst-case paths using historical log-return volatility. Balances below the P5 quantile are non-core.
Maximum historical MoM or YoY decline percentage is treated as the non-corefraction.
BCBS 368 Regulatory Caps:
| Segment | Max Core % | Max WAL |
|---|---|---|
| Retail Transactional | 90% | 5.0Y |
| Retail Non-transactional | 70% | 4.5Y |
| Wholesale | 50% | 4.0Y |
Output: HP Method stable_pct = 96.36% on the synthetic data.
Purpose: Model how deposit rates respond to changes in market rates considering both in the short run and long run. The pass-through speed determines how quickly the bank's funding cost increases when rates rise. Slow pass-through means sticky deposit rates that wider spreads for longer where creating implicit duration risk.
d = α + β·r → β = 0.2072
A 100bps rise in market rates produces only a 20.72bps rise in deposit rates in the long run.
Banks raise deposit rates slowly (λ⁺ = 0.15) but cut them quickly (λ⁻ = 0.45) — protecting margins on the way down. Also estimates bank spread α = 0.0008.
Δd_t = β₀ + β₁·t + β₂·Δr_t → β₂ = 0.0797
Only ~7.97% of any rate change passes through to deposit rates in the same month.
Output:
| Parameter | Value | Meaning |
|---|---|---|
| γ (gamma) | 0.2072 | Long-run pass-through |
| α (alpha) | 0.0008 | Bank spread |
| β₂ (beta_2) | 0.0797 | Short-term pass-through |
Purpose: Build a forward 60-month(s) runoff portfolio profile of core deposits and producing by replicating portfolio for the IRRBB Repricing gap distribution.
Core Balance Formula:
Core Balance = Total Balance × stable_pct × (1 − β)
= 5,039 MB × 96.36% × (1 − 0.2072) = 3,850 MB
stable_pct: Behavioural stability (does not exhibit volatile withdrawal patterns)(1 − β): Repricing stickiness (does not reprice immediately when market rates change)
Maximise WAL(seed)
s.t. WAL ≤ 5.0Y # BCBS 368 cap
Core_remaining ≤ 90% # BCBS 368 capSolved by scipy.optimize.differential_evolution with tolerance 1e-8.
Minimise std(X·w − margin − deposit_rate)
s.t. Σwᵢ = 1, WAL ≤ 5Y, 0 ≤ wᵢ ≤ 50%
Output: Weight Average Life (WAL) = 2.85 years
MIT · Built for learning purposes




















