In this blog post, we will show how to use Python to simulate the amortization of a fully amortized loan, such as a mortgage or a car loan. We will derive the formula for calculating the monthly payments and the outstanding balance, and implement them in Python using Numba. We will also use the IPyWidgets library to create interactive widgets that will allow us to explore the effects of different loan parameters on the amortization schedule.

Amortization formulas

When you take out a loan, you typically agree to repay the loan in equal monthly installments over a fixed period of time. This type of loan is known as a fully amortized loan. We will use the following variables in our derivation:

$A$ : the principal amount borrowed
$M$ : the total number of monthly payments
$r$ : the annual interest rate
$i$ : the monthy interest rate
$P_m$ : the principal part of monthly payment $m$
$I_m$ : the interest part of monthly payment $m$
$T$ : the constant monthly payment
$B_m$ : the balance [principal still due] after the $m$-th payment

We have the following identities:

  • $i = r / 12$
  • $I_1 = i A$
  • $\sum_{m=1}^M P_m = A$
  • $B_M = 0$

$\forall \; 1 \leq m \leq M:$

  • $T = P_m + I_m$
  • $B_m =A - (P_1 + ... + P_m)$

$\forall \; 2 \leq m \leq M:$

  • $I_m = i B_{m-1}$
  • $B_m = B_{m-1} - P_m$

Since $A$, $r$ and $M$ are known, it is easy to compute $I_1$. However, we need to compute eather $T$ or $P_1$ in order to be able to perform the simulation. Here is the derivation of the $P_1$ formula.

It is easy to show by recursion that:

$$P_m=(T-iA)(1+i)^{m-1}$$

Indeed, we have $P_1=T-I_1=T-iA$, and:

$$ \begin{align*} P_{m+1} &= T - I_{m+1} \\ &= T - i B_m \\ &= T - i (B_{m-1} - P_m) \\ &= (T-I_m) + i P_m \\ &= P_m + i P_m \\ &= P_m (1+i) \end{align*} $$

By summing up all the $P_m$ terms, we get:

$$\sum_{m=1}^M P_m = A = (T-iA) \sum_{m=1}^M (1+i)^{m-1}$$

And so:

$$T= \frac{A}{\sum_{m=0}^{M-1} (1+i)^m} + i A$$

For $i \gt 0$, the denominator of the fraction can be simplified in the following way:

$$ \begin{align*} \sum_{m=0}^{M-1} (1+i)^m &= \frac{(1+i)-1}{i} \sum_{m=0}^{M-1} (1+i)^m \\ &= \frac{1}{i} \left( \sum_{m=1}^{M} (1+i)^m - \sum_{m=0}^{M-1} (1+i)^m \right) \\ &= \frac{1}{i} \left( (1+i)^M - 1 \right) \end{align*} $$

This leads to:

$$T = \frac{i A}{(1+i)^M - 1} + i A$$

And eventually:

$$P_1 = \frac{i A}{(1+i)^m - 1}$$

Imports

In the next section, we will implement these formulas in Python. First, let's import the necessary libraries:

import ipywidgets as widgets
import numpy as np
import pandas as pd
from ipywidgets import interact
from numba import jit

We are operating on Python version 3.11.5 and running on a Linux x86_64 machine.

ipywidgets             : 8.0.7
numpy                  : 1.24.4
pandas                 : 2.1.3
numba                  : 0.57.1

Implementing the Amortization Formulas in Python

@jit(nopython=True)
def _compute_amortized_loan_inner(a, r, mc):
    """
    a: amount, r: annual interest rate, mc: month count
    """
    P = np.empty(mc, dtype=np.float64)  # principal part
    I = np.empty(mc, dtype=np.float64)  # interest part
    B = np.empty(mc, dtype=np.float64)  # balance
    # init
    if r > 0.0:
        P[0] = r * a / (12.0 * ((1.0 + r / 12.0) ** mc - 1.0))
    else:
        P[0] = a / mc
    I[0] = r * a / 12.0
    t = I[0] + P[0]
    B[0] = a - P[0]
    # loop on months
    for m in range(1, mc):
        I[m] = r * B[m - 1] / 12.0
        P[m] = t - I[m]
        B[m] = B[m - 1] - P[m]
    return P, I, B, t

def compute_amortized_loan(amount=200_000, interest_rate_pc=4.0, period_m=180):
    """Compute the amortization schedule for a fully amortized loan.

    Parameters
    ----------
    amount : float
        The principal amount borrowed.
    interest_rate_pc : float
        The annual interest rate, in percentage points.
    period_m : int
        The total number of monthly payments.

    Returns
    -------
    df : pandas.DataFrame
        A DataFrame containing the amortization schedule, with one row for
        each monthly payment. The columns of the DataFrame are:

        * `principal`: the principal part of the monthly payment
        * `interest`: the interest part of the monthly payment
        * `balance`: the outstanding balance after the monthly payment
        * `total`: the total monthly payment
    """
    r = 0.01 * interest_rate_pc
    P, I, B, t = _compute_amortized_loan_inner(amount, r, period_m)
    df = pd.DataFrame(
        data={"principal": P, "interest": I, "balance": B},
        index=np.arange(1, period_m + 1),
    )
    df.rename_axis("month", inplace=True)
    df["total"] = t
    return df

Now we can use the compute_amortized_loan function to calculate the amortization schedule for a loan of $200,000 with an annual interest rate of 3.5% over a period of 180 months (15 years).

%%time
df = compute_amortized_loan(amount=200_000, interest_rate_pc=3.5, period_m=180)
CPU times: user 709 ms, sys: 1.31 s, total: 2.02 s
Wall time: 316 ms
df.head(3)
principalinterestbalancetotal
month
1846.431749583.333333199153.5682511429.765083
2848.900509580.864574198304.6677421429.765083
3851.376468578.388614197453.2912741429.765083
df.tail(3)
principalinterestbalancetotal
month
1781417.32726312.4378202.847068e+031429.765083
1791421.4611348.3039491.425607e+031429.765083
1801425.6070624.158021-5.029506e-101429.765083

One aspect not addressed in this simulation is the rounding of results to two decimal places, which could be a consideration for future refinements.

Plot

We will now create static and interactive visualizations of the amortization schedule.

Static

def plot_amortized_loan(amount=100_000, interest_rate_pc=3.5, period_m=180):
    df = compute_amortized_loan(amount, interest_rate_pc, period_m)
    ax = df[["principal", "interest"]].plot.area(
        stacked=True, alpha=0.6, figsize=(10, 6)
    )
    cost = (df.total - df.principal).sum()
    total = df.total.values[0]
    ax.legend(loc="center right")
    _ = ax.set_xlim(1, period_m)
    _ = plt.text(
        x=0.05 * period_m, y=0.15 * total, s=f"Monthly payment = {total:10.2f}"
    )
    _ = plt.text(
        x=0.05 * period_m,
        y=0.05 * total,
        s=f"Total cost = {cost:10.2f} ({100.*cost/amount:.2f} %)",
    )
    _ = ax.set(
        title=f"Fully amortized loan simulation\namount={amount:.0f}, rate={interest_rate_pc:.2f} %, months={period_m}",
        xlabel="Month",
        ylabel="Monthly payment",
    )
plot_amortized_loan(amount=110_000, interest_rate_pc=3.78, period_m=180)

static

Interactive

We will now use the IPyWidgets library to create interactive widgets that will allow us to explore the effects of different loan parameters on the amortization schedule.

_ = interact(
    plot_amortized_loan,
    amount=widgets.FloatSlider(
        value=200000,
        min=10000,
        max=250000,
        step=1000,
        description="Amount",
        continuous_update=False,
        readout_format=".0f",
    ),
    interest_rate_pc=widgets.FloatSlider(
        value=3.5,
        min=0.0,
        max=7.5,
        step=0.01,
        description="Rate (%)",
        continuous_update=False,
        readout_format=".2f",
    ),
    period_m=widgets.IntSlider(
        value=120,
        min=2,
        max=360,
        step=1,
        description="Months",
        continuous_update=False,
        readout_format="d",
    ),
)

ipywidget

Thanks to Numba, each adjustment made using a slider widget triggers a swift response, leading to an almost instantaneous update of the figure in the Jupyter notebook.

References

[1] Bret D. Whissel, A Derivation of Amortization, https://www.bretwhissel.net/amortization/amortize2col.pdf