Skip to main content

Cent-accurate mortgage amortization for Python, validated against published CFPB and Fannie Mae examples

Project description

mortgagemath logo

mortgagemath

tests lint coverage PyPI Python 3.11+ License: MIT PyPI Downloads Documentation

Cent-accurate mortgage amortization for Python — validated against CFPB, Fannie Mae, and published real-world examples.

Documentation

Use Cases

  • Fintech / mortgage software
  • Loan calculators
  • Real-estate underwriting tools
  • Audit / reconciliation workflows
  • Financial educators
  • Anyone frustrated by 1-cent discrepancies

Installation

pip install mortgagemath

Requires Python 3.11+. Zero runtime dependencies -- only the standard library (decimal, dataclasses, enum).

To verify a fresh install reproduces the same reference values the test suite validates, run:

python -m mortgagemath

This recomputes a CFPB sample Closing Disclosure, the Goldstein §10.3 Example 1 carry-precision schedule, and the Fannie Mae §1103 Tier 2 SARM monthly payment plus balloon-at-term. Exits 0 if every value matches the published source exactly, 1 otherwise.

Quick Start

From Python

from decimal import Decimal
from mortgagemath import LoanParams, periodic_payment, amortization_schedule

loan = LoanParams(
    principal=Decimal("131250"),
    annual_rate=Decimal("4.25"),
    term_months=360,
)

pmt = periodic_payment(loan)         # Decimal("645.68")
sched = amortization_schedule(loan)
print(sched[104].principal)          # Decimal("260.27")
print(sched[104].interest)           # Decimal("385.41")

Returns a cent-accurate payment and lender-style amortization schedule.

Released v0.2.x called this function monthly_payment; that name is preserved as a permanent alias. The new periodic_payment name reads more clearly when non-monthly cadences (weekly, biweekly, quarterly) are used.

From the command line

A built-in CLI (registered as mortgagemath, also available as python -m mortgagemath) computes the same numbers without writing a script:

# Just the monthly payment
$ mortgagemath payment --principal 131250 --rate 4.25 --term-months 360
645.68

# Full amortization schedule (table by default; --format csv | json)
$ mortgagemath schedule --principal 131250 --rate 4.25 --term-months 360 | head -5
    #         Payment        Interest       Principal         Total Int           Balance
    0            0.00            0.00            0.00              0.00        131,250.00
    1          645.68          464.84          180.84            464.84        131,069.16
    2          645.68          464.20          181.48            929.04        130,887.68
    3          645.68          463.56          182.12          1,392.60        130,705.56

Pipe --format csv to a file or --format json to jq for downstream tooling. See mortgagemath --help and the per-subcommand help (mortgagemath schedule --help) for the full flag surface, including Canadian semi-annual compounding (--compounding), non-monthly payments (--payment-frequency), Actual/360 commercial loans (--day-count, --start-date), and ARMs (--rate-change EFFECTIVE_PMT:NEW_RATE).

Canadian and other non-monthly loans

Canadian Interest Act §6 mortgages and any other loan with a non-monthly compounding or payment frequency:

from mortgagemath import (
    LoanParams, Compounding, PaymentFrequency,
    PaymentRounding, periodic_payment,
)

# Canadian mortgage: $350,100 at j_2 = 4.9%, 3-year term on a
# 20-year amortization, monthly payments.
loan = LoanParams(
    principal=Decimal("350100"),
    annual_rate=Decimal("4.9"),
    term_months=36,
    amortization_period_months=240,
    compounding=Compounding.SEMI_ANNUAL,
    payment_frequency=PaymentFrequency.MONTHLY,
    payment_rounding=PaymentRounding.ROUND_HALF_UP,
    interest_rounding=PaymentRounding.ROUND_HALF_UP,
)
print(periodic_payment(loan))         # Decimal("2281.73") — Olivier §13.4

Adjustable-rate mortgages (ARMs)

Pass a rate_schedule of RateChange entries. Each declares the 1-indexed payment at which a new rate takes effect:

from mortgagemath import LoanParams, RateChange, PaymentRounding, amortization_schedule

# 5/1 ARM: $200,000 at 5.7%, term 30 years, with a single rate
# change at month 61 to 7.2%.  recast=True (the default) recomputes
# the level payment over the remaining 300 payments.
loan = LoanParams(
    principal=Decimal("200000"),
    annual_rate=Decimal("5.7"),
    term_months=360,
    payment_rounding=PaymentRounding.ROUND_HALF_UP,
    interest_rounding=PaymentRounding.ROUND_HALF_UP,
    rate_schedule=(
        RateChange(effective_payment_number=61, new_annual_rate=Decimal("7.2")),
    ),
)
sched = amortization_schedule(loan)
print(sched[60].payment)              # Decimal("1160.80") — initial level payment
print(sched[61].payment)              # Decimal("1334.16") — recast at month 61

Payment caps with negative amortization (since v0.5):

# ProEducate ARM payment-cap example: $65,000 at 10% Year 1, 12% Year 2,
# 7.5% annual payment cap.  At the year 2 reset the uncapped recast
# would be $667.30, but the cap clamps it to $613.20 (= $570.42 × 1.075).
# Monthly interest at 12% on the year-1 ending balance ($64,638.72) is
# $646.39, exceeding the capped $613.20 — so $33.19 of interest is
# capitalized into the balance each month (negative amortization).
loan = LoanParams(
    principal=Decimal("65000"),
    annual_rate=Decimal("10"),
    term_months=360,
    payment_rounding=PaymentRounding.ROUND_HALF_UP,
    interest_rounding=PaymentRounding.ROUND_HALF_UP,
    rate_schedule=(
        RateChange(
            effective_payment_number=13,
            new_annual_rate=Decimal("12"),
            payment_cap_factor=Decimal("1.075"),  # 7.5% annual cap
        ),
    ),
)
sched = amortization_schedule(loan)
print(sched[13].payment)              # Decimal("613.20") — cap binds
print(sched[13].principal)            # Decimal("-33.19") — neg-am

CLI examples for the harder loan types

# Canadian j_2 mortgage (semi-annual compounding per Interest Act §6)
$ mortgagemath payment --principal 300000 --rate 5 --term-months 300 \
    --compounding semi_annual \
    --payment-rounding ROUND_HALF_UP --interest-rounding ROUND_HALF_UP
1747.45

# 5/1 ARM with a rate change at month 61
$ mortgagemath schedule --principal 200000 --rate 5.7 --term-months 360 \
    --payment-rounding ROUND_HALF_UP --interest-rounding ROUND_HALF_UP \
    --rate-change 61:7.2 --format csv > arm-schedule.csv

# Fannie Mae §1103 SARM with balloon at term 120
$ mortgagemath schedule --principal 25000000 --rate 5.5 \
    --term-months 120 --amortization-period-months 360 \
    --day-count actual/360 --start-date 2018-12-01 \
    --payment-rounding ROUND_HALF_UP --interest-rounding ROUND_HALF_UP \
    --format json | jq '.[-1].balance'
"20885505.83"

mortgagemath --help and mortgagemath <subcommand> --help document the full flag surface. mortgagemath with no arguments runs the post-install self-check (also mortgagemath selfcheck).

If this solves a problem for you, please star the repo ⭐

Why This Package?

Most mortgage libraries compute formulas.

Few reproduce lender schedules.

That creates:

  • 1-cent discrepancies
  • incorrect principal / interest splits
  • reconciliation headaches
  • wrong final balances
  • audit friction

mortgagemath solves this with configurable lender rounding, exact payoff balances, and a robust validation suite to compare calculations against published CFPB regulatory examples, Fannie Mae publications, and financial literature.

Why Use mortgagemath?

Feature mortgagemath Typical Python packages
Decimal math Sometimes
Configurable lender rounding Rare
Exact zero final balance Rare
Published-source validation No
Balloon / commercial loans Rare
Full amortization schedule Mixed

Compared to Typical Alternatives

Many mortgage libraries rely on floats, limited rounding controls, or do not generate lender-style amortization schedules.

mortgagemath focuses on:

  • Decimal math end-to-end
  • Configurable lender rounding
  • Exact zero ending balances
  • Full amortization schedules
  • Published-source validation

Accuracy Validation

Validated against published examples from:

  • CFPB sample disclosures
  • Fannie Mae multifamily guides
  • Geltner et al., Commercial Real Estate Analysis (graduate CRE finance text)
  • OpenStax textbooks
  • LibreTexts examples
  • Boundary rounding test cases

See docs/accuracy.md for the full source table and cent-accurate results.

Reporting a discrepancy

Found a published example or a real lender statement that mortgagemath doesn't reproduce to the cent? Two paths:

  • Reporters / users — open an issue using the Mortgage example doesn't match template. It walks through the loan parameters and any per-row data the source publishes; the discrepancy then either lands in the validation suite (if reproducible) or in docs/future-work.md (if not). No need to run pytest locally.
  • Contributors who can run pytest locally — see Contributing Test Fixtures below for the direct PR path.

For unrelated bugs or feature requests, use the general Bug or feature request template.

Rounding Conventions

Banks round the monthly payment and each month's interest to the nearest cent, but the rounding convention varies by lender. mortgagemath makes this explicit and configurable:

from mortgagemath import LoanParams, PaymentRounding

loan = LoanParams(
    principal=Decimal("131250"),
    annual_rate=Decimal("4.25"),
    term_months=360,
    payment_rounding=PaymentRounding.ROUND_UP,       # default
    interest_rounding=PaymentRounding.ROUND_HALF_UP,  # default
)
Parameter Default Meaning
payment_rounding ROUND_UP Monthly payment rounded up to nearest cent (ceiling)
interest_rounding ROUND_HALF_UP Monthly interest rounded to nearest cent (standard)

Supported rounding modes for either field: ROUND_UP (ceiling), ROUND_HALF_UP, and ROUND_HALF_EVEN (banker's rounding). The defaults match the conventions used in the CFPB sample disclosures and most US residential mortgage servicers we have validated against. If your lender uses a different convention, you can override them per-loan.

Balance Tracking

Different sources propagate the running balance between schedule rows differently. mortgagemath supports both common conventions via the balance_tracking field on LoanParams:

Mode Algorithm Used by
BalanceTracking.ROUND_EACH (default) Each row rounds the balance to cents; next row's interest computes from the rounded balance Most US residential lenders; CFPB sample disclosures
BalanceTracking.CARRY_PRECISION Unrounded balance carried internally; per-row values rounded for display only Excel-default; graduate-level CRE finance textbooks (Geltner, LibreTexts, eCampus)

For long-horizon loans the two algorithms diverge by single-digit cents on each row, with the displayed total interest sometimes drifting by a few dollars over a 30-year term. Both modes preserve principal + interest == payment per row and land balance at exactly $0.00 on the final row of a fully-amortizing loan.

from mortgagemath import BalanceTracking, LoanParams, PaymentRounding

# A loan to be compared against a Geltner-style worked schedule
loan = LoanParams(
    principal=Decimal("1000000"),
    annual_rate=Decimal("12"),
    term_months=360,
    payment_rounding=PaymentRounding.ROUND_HALF_UP,
    interest_rounding=PaymentRounding.ROUND_HALF_UP,
    balance_tracking=BalanceTracking.CARRY_PRECISION,
)

ACTUAL_360 schedules always use carry-precision internally (day-counted interest accrual is only meaningful with full balance precision); the balance_tracking field is ignored in that case.

Day Count Conventions

US residential mortgages use 30/360 (each month is treated as 30 days, each year as 360); US commercial loans often use Actual/360 (interest accrues on the actual calendar days in each month, divided by 360).

from datetime import date
from mortgagemath import DayCount

residential = LoanParams(
    principal=Decimal("200000"),
    annual_rate=Decimal("6"),
    term_months=360,
    day_count=DayCount.THIRTY_360,   # default
)

commercial_balloon = LoanParams(             # 10-yr SARM on 30-yr amort
    principal=Decimal("25000000"),
    annual_rate=Decimal("5.5"),
    term_months=120,                          # 10 years of payments
    amortization_period_months=360,           # closed-form on 30-yr basis
    day_count=DayCount.ACTUAL_360,
    start_date=date(2018, 12, 1),
)

monthly_payment() works identically for both day-counts: the level monthly P&I is the standard closed-form annuity value. Fannie Mae's Multifamily Selling and Servicing Guide §1103 calls this the "calculated actual/360 fixed rate payment" and uses the same formula — no 365/360 rate bump (validated against the §1103 worked example: $25M / 5.5% / 30yr → $141,947.25).

amortization_schedule() produces different per-row figures by mode:

Mode Period interest Balance tracking
THIRTY_360 (default) balance × rate / 12 (constant) round-each-balance
ACTUAL_360 balance × rate × days_in_month / 360 (variable) full-precision internal, displayed values rounded to cents

ACTUAL_360 requires start_date (the issue date / first interest-accrual period). Period 1 covers the calendar month containing that date; the first payment is due on the same day-of-month one month later. This matches the Fannie Mae §1103 example: issue date 2018-12-01 → period 1 spans December 2018 (31 days) → first payment January 1, 2019. Validated against §1103's published aggregate principal over 120 payments ($4,114,494.17, equivalent to a balance of $20,885,505.83 at row 120).

For commercial loans where the term is shorter than the amortization period — e.g. a 10-year SARM on a 30-year amortization basis — set amortization_period_months to the longer value. The closed-form payment is computed against amortization_period_months; the schedule produces term_months rows, and the final row's balance is the balloon the borrower owes alongside the last regular payment. Per the Fannie Mae §1103 example, $25M / 5.5% / term_months = 120 / amortization_period_months = 360 / start_date = 2018-12-01 produces a $141,947.25 monthly P&I for 120 months and a balloon of $20,885,505.83 at term — both validated to the cent.

Schedule Guarantees

Every schedule produced by amortization_schedule() satisfies these invariants, enforced by the test suite across many loan configurations:

  • principal + interest == payment for every installment
  • Final balance is exactly Decimal("0.00")
  • Balance decreases monotonically
  • Sum of all principal payments equals the original loan amount
  • total_interest accumulates correctly across all payments

API Reference

LoanParams

Frozen dataclass defining a fixed-rate mortgage.

Field Type Default Description
principal Decimal required Original loan amount
annual_rate Decimal required Annual interest rate as percent (e.g. Decimal("4.25") for 4.25%)
term_months int required Loan term in months
day_count DayCount THIRTY_360 Day count convention
payment_rounding PaymentRounding ROUND_UP How to round the monthly payment
interest_rounding PaymentRounding ROUND_HALF_UP How to round each month's interest

monthly_payment(loan: LoanParams) -> Decimal

Calculate the monthly principal and interest payment.

amortization_schedule(loan: LoanParams) -> list[Installment]

Generate the full amortization schedule. Returns a list of Installment objects from payment 0 (initial state) through the final payment.

Installment

Frozen dataclass for a single payment.

Field Type Description
number int Payment number (0 = initial state)
payment Decimal Total payment amount
interest Decimal Interest portion
principal Decimal Principal portion
total_interest Decimal Cumulative interest paid through this payment
balance Decimal Remaining balance after this payment

Test Suite

The test suite has three layers:

  1. Payment unit tests -- edge cases, both day-count methods, rounding modes
  2. Schedule invariants -- structural properties verified across 8 different loan configurations ($50k--$500k, 3%--8.5%, 10--30yr terms)
  3. Authoritative-source fixtures -- paired TOML (loan parameters) + CSV (payment schedule) fixtures, auto-discovered by pytest. Each fixture declares its source.kind: regulatory examples (CFPB sample disclosures), open-licensed textbook worked examples, calculator output, synthetic boundary loans, or bank statements.

See tests/schedules/README.md for the full schema and the list of supported kind values.

Contributing Test Fixtures

This is the direct-PR path. If you can't run pytest locally, the Mortgage example doesn't match issue template is the easier route — paste the published values and a maintainer will land the fixture for you.

To add a verified loan as a PR, create two files in tests/schedules/:

example_30yr_50_150000.toml (loan parameters):

[loan]
principal = "150000.00"
annual_rate = "5.0"
term_months = 360
day_count = "30/360"
payment_rounding = "ROUND_UP"
interest_rounding = "ROUND_HALF_UP"

[source]
kind = "regulatory_example"
country = "US"
label = "30yr_conventional_5pct_150k"
url = "https://example.gov/path/to/published/example"

[expected]
monthly_payment = "805.24"

example_30yr_50_150000.csv (full or partial schedule):

payment,payment_amount,principal,interest,balance
1,805.24,180.24,625.00,149819.76
2,805.24,180.99,624.25,149638.77

Do not include property addresses, lender names, or other PII. For statement-kind fixtures, also include verified_by and verified_date.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

mortgagemath-0.6.0.tar.gz (1.0 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

mortgagemath-0.6.0-py3-none-any.whl (27.5 kB view details)

Uploaded Python 3

File details

Details for the file mortgagemath-0.6.0.tar.gz.

File metadata

  • Download URL: mortgagemath-0.6.0.tar.gz
  • Upload date:
  • Size: 1.0 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for mortgagemath-0.6.0.tar.gz
Algorithm Hash digest
SHA256 ecc98bdbee3006632e50bca183d36879b041c1198d48b803b27ded5b73dcbc68
MD5 7778023c4db945d4b7d81a68ee144b28
BLAKE2b-256 d7331c4dbb4f89013cfc25bdc59e0ccf07dbd1e9895fcbb35719c8ecee133b4f

See more details on using hashes here.

File details

Details for the file mortgagemath-0.6.0-py3-none-any.whl.

File metadata

  • Download URL: mortgagemath-0.6.0-py3-none-any.whl
  • Upload date:
  • Size: 27.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for mortgagemath-0.6.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b0699c97c7c301473059cbfd98ec3ec5614079f6a2f05bc4d53664fe5ee097ba
MD5 626e1b0cbeee04b54f6c6bc1933a7c09
BLAKE2b-256 0eb2c8f1e425e6152e8a1a1ee5b8a427fa183cef9c8972107aba97c32e491e14

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page