This release is a pre-release and may not be stable for production use.
mortgagekit
A small Python package with mortgage math and modeling utilities used by the book. It serves as the companion computational package for the book (Mortgage Markets in the Age of AI) and the essay series (The Invisible System of Mortgages).
Core components reside across the following modules:
- src/mortgagekit/mortgage.py — Scheduled monthly payment, amortization schedule, and tracking of principal and interest balances.
- src/mortgagekit/prepayment.py — Conversions between annualized pre-payment speed (CPR), monthly pre-payment speed (SMM), and survival probability calculations.
- src/mortgagekit/rates.py — Continuous-compounding discounting, portfolio present value, DV01, and CV01 risk sensitivity analytics.
Installation
Install in editable mode for local development:
pip install -e .
Module Reference
1. Amortization and Payments
Located in src/mortgagekit/mortgage.py. Contains functions to compute fixed monthly payments, remaining balances, and multi-period scheduling.
Mortgage Analytical Formulas
For a self-amortizing fixed-rate mortgage with principal $B$, monthly interest rate $r_m = \text{annual_rate} / 12$, and total periods $N = \text{months}$, the fixed monthly payment $P$ is calculated as:
$$P = B \times \frac{r_m}{1 - (1 + r_m)^{-N}}$$
The remaining principal balance $B_k$ after the $k$-th payment period is given recursively by:
$$B_k = B_{k-1} \times (1 + r_m) - P$$
Mortgage Code Examples
from mortgagekit import mortgage_payment, mortgage_balance, mortgage_schedule
# Compute monthly payment on a $400,000, 30-year (360-month) loan at 6.5% interest rate
payment = mortgage_payment(balance=400_000, annual_rate=0.065, months=360)
print(f"Monthly Payment: ${payment:.2f}")
# Output: Monthly Payment: $2528.27
# Check remaining scheduled balance after 5 years (60 months)
balance_after_5y = mortgage_balance(balance=400_000, annual_rate=0.065, months=360, month=60)
print(f"Balance after 5 Years: ${balance_after_5y:.2f}")
# Generate full amortization schedules
schedule = mortgage_schedule(balance=400_000, annual_rate=0.065, months=360)
# Look at month 1
m1 = schedule[0]
print(f"Month {m1.month}: Payment={m1.payment:.2f}, Interest={m1.interest:.2f}, Principal={m1.principal:.2f}, Remaining Balance={m1.balance:.2f}")
2. Prepayment Conventions
Located in src/mortgagekit/prepayment.py. Helps map annualized pre-payments into month-over-month conditional rates.
Prepayment Analytical Formulas
The relationship between the Conditional Prepayment Rate ($CPR$) and Single Monthly Mortality ($SMM$) is configured via:
$$SMM = 1 - (1 - CPR)^{\frac{1}{12}}$$
$$CPR = 1 - (1 - SMM)^{12}$$
Under a flat prepayment speed of $CPR$, the survival probability $S(t)$ representing the probability that a loan balance remains active (has not prepaid) after $t$ months is:
$$S(t) = (1 - SMM)^t$$
Prepayment Code Examples
from mortgagekit import cpr_to_smm, smm_to_cpr, survival_probability
# Convert a 12% CPR to monthly SMM
smm_12 = cpr_to_smm(0.12)
print(f"12% CPR corresponds to {smm_12 * 100:.4f}% SMM")
# Output: 12% CPR corresponds to 1.0596% SMM
# Convert a monthly SMM of 1.5% back to annualized CPR
cpr_reconstructed = smm_to_cpr(0.015)
print(f"1.5% SMM corresponds to {cpr_reconstructed * 100:.4f}% CPR")
# Find the survival probability of the outstanding balance after 36 months at 8% CPR
survival_pct = survival_probability(cpr=0.08, months=36)
print(f"Remaining active balance ratio after 3 years: {survival_pct * 100:.2f}%")
3. Yields, Rates, and Risk Sensitivities
Located in src/mortgagekit/rates.py. Applies continuous-compounded discount factors, computes flat yield bond cash flows, and evaluates risk metric vectors.
Yield and Risk Analytical Formulas
We use continuous compounding as the standard mathematical baseline for valuation modeling. The discount factor $d(t)$ at maturity time $t$ under a continuous rate $r$ is:
$$d(t) = e^{-r t}$$
Let ${ (t_i, C_i) }_{i=1}^n$ be a set of discrete future cash flows. The total bond price $B(y)$ evaluated under flat continuous yield $y$ is:
$$B(y) = \sum_{i=1}^n C_i e^{-y t_i}$$
The risk sensitivity metric DV01 represents the dollar value of a one-basis-point (0.01%, or $10^{-4}$) decline in rates. Formally, it is proportional to the first derivative of price with respect to yield:
$$\text{DV01} = -\frac{dB}{dy} \times 10^{-4} = \left( \sum_{i=1}^n t_i C_i e^{-y t_i} \right) \times 10^{-4}$$
The risk sensitivity metric CV01 (Convexity Value of a Basis Point) represents the second-order sensitivity scaled to a one-basis-point yield shock:
$$\text{CV01} = \frac{1}{2} \frac{d^2B}{dy^2} \times (10^{-4})^2 = \frac{1}{2} \left( \sum_{i=1}^n t_i^2 C_i e^{-y t_i} \right) \times 10^{-8}$$
Yield and Risk Code Examples
from mortgagekit import discount_factor, bond_price, dv01, cv01
# Continuous discount factor for 2.5 years at 5.0% rate
df = discount_factor(rate=0.05, t=2.5)
print(f"Discount Factor: {df:.6f}")
# Define a series of expected cash flows: (Time in years, Expected Cashflow)
cashflows = [
(0.5, 12000.0),
(1.0, 12000.0),
(1.5, 12000.0),
(2.0, 112000.0)
]
# Price cashflows under a flat 4.5% yield
price = bond_price(cashflows, y=0.045)
print(f"Bond Present Value: ${price:.2f}")
# Compute DV01 and CV01 sensitivities under the 4.5% rate environment
dollar_value_01 = dv01(cashflows, y=0.045)
convexity_value_01 = cv01(cashflows, y=0.045)
print(f"DV01 Risk: ${dollar_value_01:.4f} per basis point drop")
print(f"CV01 Risk: ${convexity_value_01:.6f} curvature sensitivity")
The Pricing and Risk Engine
Sections 1–3 above are the building blocks. This section is the engine built on top of them: it projects what a pool pays, discounts it against a curve, and measures how the value moves when rates do.
The engine is pure standard library — no numpy, no scipy — so it installs anywhere and every number in it can be traced by hand.
Modules
| Module | What it holds |
|---|---|
| src/mortgagekit/curve.py | Curve — a continuously-compounded zero curve, linear between knots, flat outside them, with parallel shifts and single-knot bumps |
| src/mortgagekit/prepayment.py | FlatCPR, PSA, RefiSCurve — the speed assumptions |
| src/mortgagekit/cashflows.py | MortgagePool, pool_cashflows — scheduled principal, prepayments, and the servicing strip |
| src/mortgagekit/pricing.py | present value, WAL, static (Z-)spread, cash-flow yield |
| src/mortgagekit/risk.py | effective duration and convexity, DV01, key rate durations |
| src/mortgagekit/calculator.py | analyze — one call that returns the whole report |
A prepayment model is anything with smm(age, rate_shift)
The engine only ever asks a model one question: what fraction of the surviving balance prepays this month? That single interface is what lets the same pricing code run a flat CPR, a PSA ramp, or a rate-sensitive refinancing curve.
from mortgagekit import FlatCPR, PSA, RefiSCurve
FlatCPR(0.06) # 6% CPR forever
PSA(200) # 200 PSA: ramps to 12% CPR by month 30
RefiSCurve(wac=0.065, mortgage_rate=0.065) # speeds respond to the refi incentive
rate_shift is the parallel rate move the risk engine is currently applying.
Models that ignore it are static by definition — and that is exactly why they
cannot show you negative convexity.
Projecting and pricing a pool
from mortgagekit import Curve, MortgagePool, PSA, pool_cashflows, price, weighted_average_life
# A $1mm pool of 6.5% loans passing 6.0% through to the investor after servicing
pool = MortgagePool(balance=1_000_000, wac=0.065, wam=360, net_coupon=0.060)
curve = Curve.from_zeros([(0.25, 0.043), (2, 0.041), (5, 0.042), (10, 0.045), (30, 0.047)])
flows = pool_cashflows(pool, PSA(200))
print(f"WAL: {weighted_average_life(flows):.2f} years") # WAL: 7.73 years
print(f"Value: {price(pool, curve, PSA(200)):,.0f}") # Value: 1,096,766
Each month, in the order a servicer applies them: interest accrues on the opening balance at the net coupon; the scheduled payment is recomputed on the opening balance over the remaining term, giving scheduled principal; and the prepayment is that month's SMM applied to what is left after the scheduled payment. Principal always returns in full — prepayment changes when the money comes back, never how much.
The whole report in one call
from mortgagekit import analyze, RefiSCurve
report = analyze(pool, curve, RefiSCurve(wac=0.065, mortgage_rate=0.065), spread=0.0075)
print(report.summary())
Price 104.5184 per 100
WAL 6.43 years
Cash-flow yield 5.1078 % (monthly nominal)
Static spread 75.0 bp over the curve
Effective duration 3.83
Static duration 5.01
Duration given up 1.18 to the borrower's option
Effective convexity -363.02
Effective DV01 400.7424 per 1bp fall in rates
Final cash flow 360 months out
Key rate durations 0.25y +0.09 2y +0.79 5y +1.70 10y +2.06 30y +0.37
Why effective and static duration differ
A static duration differentiates the price of a fixed set of cash flows. An effective duration reprices the pool with the cash flows allowed to move. For a Treasury the two agree. For a mortgage they do not, and the gap between them is the borrower's option:
$$D_{\text{eff}} = \frac{P_{-} - P_{+}}{2 P_0 \Delta y} \qquad C_{\text{eff}} = \frac{P_{+} + P_{-} - 2 P_0}{P_0 (\Delta y)^2}$$
where $P_{\pm}$ are repriced after reprojecting the cash flows at the shocked rate level. Holding the cash flows still while shifting the curve is the mistake that makes a mortgage look positively convex.
Priced at a 75bp spread, a 6% pass-through against the same pool with the option switched off:
| Shock | CPR | With option | No option | Given up |
|---|---|---|---|---|
| −300bp | 43.7% | 111.329 | 126.114 | −14.785 |
| −100bp | 29.1% | 107.061 | 111.322 | −4.260 |
| 0 | 15.6% | 104.518 | 104.978 | −0.460 |
| +100bp | 8.6% | 98.849 | 99.219 | −0.370 |
| +300bp | 6.1% | 85.760 | 89.177 | −3.417 |
The position loses on both sides: it shortens into the rally and extends into the sell-off. That is what a convexity of −363 means, and why a duration-hedged mortgage book still bleeds.
Static spread is not an OAS
static_spread solves for the one number added to every zero rate that reprices
the pool, holding a single projected cash flow path fixed. That is a Z-spread. An
OAS asks what spread survives once the cash flows are allowed to change down
every rate path, which needs the simulation of Chapter 6. The engine is
deliberately deterministic and does not claim to compute one.
Key rate durations
from mortgagekit import key_rate_durations
for t, d in key_rate_durations(pool, curve, PSA(200)):
print(f"{t:>5}y {d:+.3f}")
Because the curve is linear in zero rate, bumping a single knot produces the triangular hump key rate durations are defined against. With the prepayment model held still they sum to the static duration — exactly in the limit, and at a finite shock up to an $O(\Delta y^2)$ convexity term, since summing finite differences is not the finite difference of the sum.
Command line
python -m mortgagekit --balance 1000000 --wac 6.5 --coupon 6.0 --refi 6.5 --spread 75
python -m mortgagekit --psa 200 --json --schedule 12
Rates are entered the way people say them — 6.5 means 6.5% — and the spread is
in basis points. --psa, --cpr and --refi select the prepayment model;
--json emits the report as JSON, and --schedule N prints the first N months
of projected cash flow.
Running Tests
To verify code correctness, you can run tests directly after installing development dependencies (if any are added) or via standard Python unittest.
PYTHONPATH=src python -m unittest discover -s tests
The engine's tests check results against closed forms and market conventions rather than against numbers this code once produced: that a zero prepayment speed reproduces the amortization schedule, that 100 PSA hits its published anchors, that principal returns in full at every speed, that premium pools lose and discount pools gain as speeds rise, that spread and yield solvers round-trip, and that key rate durations converge on the parallel shift as the shock shrinks.
Note: Code in this library contains typing definitions using standard PEP 484 type annotations and ships with the src/mortgagekit/py.typed marker, which enables automatic static analysis and autocompletion interfaces in modern IDE environments.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distributions
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file mortgagekit-0.0.1.dev0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: mortgagekit-0.0.1.dev0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 127.5 kB
- Tags: CPython 3.14, manylinux: glibc 2.17+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
190fc47e540579ded5a076badaf8ab9f676dc97637b6e59b24d29e2287258de5
|
|
| MD5 |
b7badacdde660c3c1d13f3967876380f
|
|
| BLAKE2b-256 |
c3f059848c7789558f12969efc15e43dcb11cf92c7479c95070461c0cca3db77
|
File details
Details for the file mortgagekit-0.0.1.dev0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: mortgagekit-0.0.1.dev0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 121.6 kB
- Tags: CPython 3.13, manylinux: glibc 2.17+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0374f47b1eb2fbd8d60ad47f004e0011fd5f67100e2f4e2168aa013af4ba0ffc
|
|
| MD5 |
7ff92ff0711ed3cfe52b5392aa4a52a1
|
|
| BLAKE2b-256 |
e3da07fc6fc27aa01f6f26c79e9e08fcd91a36e1dbf17c2442c288301b1300bf
|
File details
Details for the file mortgagekit-0.0.1.dev0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: mortgagekit-0.0.1.dev0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 121.9 kB
- Tags: CPython 3.12, manylinux: glibc 2.17+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
df39a6774757a4f5685c2ec764399299b8325ed4ab4c760d3e31e4f9b3326a3b
|
|
| MD5 |
e68e401122cca16397174f1e6c4cbae1
|
|
| BLAKE2b-256 |
4240f2a141dff28d89506a3025846d575df5c0f8348f280dcef3a15a2e50c7f8
|
File details
Details for the file mortgagekit-0.0.1.dev0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: mortgagekit-0.0.1.dev0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 125.9 kB
- Tags: CPython 3.11, manylinux: glibc 2.17+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1f7c5d6da84c24fb6f7ca07153d6f4ce7a102d11657982d23dfa7317b38c562a
|
|
| MD5 |
3ddf207caad4d71aa336b016f13b10b1
|
|
| BLAKE2b-256 |
f953c77dc6ccbbf5907f076d619aa38f737f8732093fb0eb4d14383c839f2657
|
File details
Details for the file mortgagekit-0.0.1.dev0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: mortgagekit-0.0.1.dev0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 95.8 kB
- Tags: CPython 3.10, manylinux: glibc 2.17+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
add2ad7064bf2cd867883e8ed346997630fcba766946f94b6d7d8e9fbca06046
|
|
| MD5 |
1634ecb54d3228e4f5e19a909258c2d0
|
|
| BLAKE2b-256 |
4405154eea1d92390e25d5c7b98a458eb4e3daa6f9d71b232938aa21426d374c
|
File details
Details for the file mortgagekit-0.0.1.dev0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl.
File metadata
- Download URL: mortgagekit-0.0.1.dev0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- Upload date:
- Size: 94.6 kB
- Tags: CPython 3.9, manylinux: glibc 2.17+ x86-64, manylinux: glibc 2.28+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
44b0577ed3dc62fb0d2739ce95512c0d2f83488b01a0f03b0dd7bc292e35c00c
|
|
| MD5 |
0066a67ee6b00a60d3f015bafe585d64
|
|
| BLAKE2b-256 |
2346dda363e2f67d6f9e9ebff7254e80df82469e6f54fada991a33ef987158f5
|