Alphanume

Insights

Computing Bond Duration and Convexity in Python

Alphanume Team · June 5, 2026

Sensitivities from first principles.

Bond duration python implementations tend to reach straight for a library. That works until the bond has irregular cash flows, an embedded option, or a schedule your library refuses to parse — then you need to understand what you are computing. This tutorial builds macaulay_duration, modified_duration, and convexity from the cash-flow vector up, verifies each against a finite-difference reprice, and ends with the %dP approximation formula you will use every day. If you need the underlying price routine, see pricing a bond in Python first.

What bond duration actually measures

Macaulay duration is the present-value-weighted average time to receive each cash flow. Imagine every coupon and the final principal sitting on a balance beam; Macaulay duration is the fulcrum point measured in years. Formally, for a bond with cash flows Ct at times t (in years), yield y (annual), and compounding frequency freq:

MacD = ∑(t × PV(Ct)) / ∑PV(Ct)

The denominator is just the dirty price, so MacD is a price-weighted average maturity. A zero-coupon bond has MacD equal to its maturity because the only cash flow arrives at the end. A coupon bond's MacD is always shorter than maturity because some cash flows arrive earlier and pull the fulcrum forward.

Modified duration converts MacD into a price sensitivity. If the yield moves by a small amount dy, the percentage change in price is approximately −ModDur × dy. The exact relationship is ModDur = MacD / (1 + y / freq). For a bond priced to a 6% annual yield compounded semi-annually, every Macaulay year shrinks by a factor of 1 / 1.03 before becoming a sensitivity.

For a fuller conceptual treatment of how the two measures relate, see the post on bond duration vs convexity.

Setting up the cash-flow vector

Every calculation below works on two parallel NumPy arrays: times, holding the time in years to each cash flow, and cashflows, holding the dollar amount. This representation handles bullets, amortizers, and zero coupons equally — you just change what you pass in.

import numpy as np


def make_cashflows(face, coupon_rate, freq, maturity_years):
    """
    Return (times, cashflows) arrays for a standard bullet bond.

    Parameters
    ----------
    face         : float  — par / face value (e.g. 1000)
    coupon_rate  : float  — annual coupon rate (e.g. 0.05 for 5%)
    freq         : int    — coupons per year (1 = annual, 2 = semi-annual)
    maturity_years : float — years to maturity
    """
    n = int(round(maturity_years * freq))
    times = np.arange(1, n + 1) / freq          # e.g. [0.5, 1.0, ..., T]
    coupon = face * coupon_rate / freq
    cashflows = np.full(n, coupon)
    cashflows[-1] += face                        # principal at maturity
    return times, cashflows


def price_bond(times, cashflows, ytm, freq):
    """Price a bond given parallel time/cashflow arrays and a YTM."""
    discount = (1 + ytm / freq) ** (times * freq)
    return np.sum(cashflows / discount)

make_cashflows returns plain NumPy arrays, so every downstream function stays dependency-free. price_bond uses the same periodic discounting you would do by hand — the exponent times * freq converts fractional years back to periods, which is the correct convention for yield-to-maturity arithmetic. If you want to explore the pricing side interactively, the bond pricing calculator covers the same formula.

Macaulay duration, modified duration, and convexity

With the cash-flow arrays in hand, all three sensitivities are one-liners over NumPy arrays. Grouping them together keeps the discount-factor computation in one place.

def macaulay_duration(times, cashflows, ytm, freq):
    """PV-weighted average time to cash flows, in years."""
    discount = (1 + ytm / freq) ** (times * freq)
    pv = cashflows / discount
    return np.sum(times * pv) / np.sum(pv)


def modified_duration(times, cashflows, ytm, freq):
    """First-order price sensitivity: -dP/P per unit change in yield."""
    mac = macaulay_duration(times, cashflows, ytm, freq)
    return mac / (1 + ytm / freq)


def convexity(times, cashflows, ytm, freq):
    """
    Second-order price sensitivity (per unit yield-squared).

    Uses the standard closed-form second derivative of the PV sum:
        C = sum[ t*(t + 1/freq) * PV(CF_t) ] / ( P * (1+y/freq)^2 )
    """
    discount = (1 + ytm / freq) ** (times * freq)
    pv = cashflows / discount
    p = np.sum(pv)
    period_factor = times * (times + 1.0 / freq)
    return np.sum(period_factor * pv) / (p * (1 + ytm / freq) ** 2)

The convexity formula deserves a brief note. The second derivative of the present value of a single cash flow C / (1 + y/m)t*m with respect to y is C * t*(t + 1/m) / (1 + y/m)t*m+2. Sum over all flows, divide by price, and you have the convexity in years2. The extra (1 + ytm / freq)**2 in the denominator comes from that same second-derivative algebra — do not drop it.

The price-change approximation

Modified duration and convexity appear together in the second-order Taylor approximation of the percentage price change for a yield shift dy:

%dP ≈ −ModDur × dy + 0.5 × Convexity × dy2

The first term is linear and dominates for small moves; the convexity term adds the curvature correction that becomes material for larger shifts or for bonds with significant optionality. Dollar duration (DV01) is just ModDur × P / 10,000 — the dollar change per one basis point, which is the unit traders quote. Once you have modified duration and price, DV01 is trivial.

def price_change_approx(mod_dur, conv, price, dy):
    """
    Approximate the new price after a parallel yield shift of dy.

    Returns (approx_new_price, pct_change)
    """
    pct = -mod_dur * dy + 0.5 * conv * dy ** 2
    return price * (1 + pct), pct


def dv01(mod_dur, price):
    """Dollar value of one basis point (price change per 1 bp shift)."""
    return mod_dur * price / 10_000

Numerical verification via finite differences

Analytic formulas are easy to mis-implement — a sign error or a missing frequency factor can survive for months. A finite-difference check is the fastest way to validate. Reprice at y + h and y − h for a small h, then compute the centred difference. If your analytic modified duration matches the numerical estimate to several significant figures, the formula is correct.

def numerical_modified_duration(times, cashflows, ytm, freq, h=1e-5):
    """
    Estimate modified duration via centred finite differences.
    Matches the analytic formula when h is small (1e-4 to 1e-6 works well).
    """
    p_up   = price_bond(times, cashflows, ytm + h, freq)
    p_down = price_bond(times, cashflows, ytm - h, freq)
    p_mid  = price_bond(times, cashflows, ytm,     freq)
    return (p_down - p_up) / (2 * h * p_mid)


# --- Worked example ---
face, coupon_rate, freq, mat = 1000, 0.05, 2, 10   # 5% semi-annual, 10 yr
ytm = 0.06                                          # 6% YTM

times, cfs = make_cashflows(face, coupon_rate, freq, mat)
p   = price_bond(times, cfs, ytm, freq)
mac = macaulay_duration(times, cfs, ytm, freq)
mod = modified_duration(times, cfs, ytm, freq)
cvx = convexity(times, cfs, ytm, freq)
num = numerical_modified_duration(times, cfs, ytm, freq)

print(f"Price             : {p:>10.4f}")
print(f"Macaulay duration : {mac:>10.6f} years")
print(f"Modified duration : {mod:>10.6f}")
print(f"Numerical mod dur : {num:>10.6f}")   # should match to ~6 sig figs
print(f"Convexity         : {cvx:>10.6f}")
print(f"DV01              : {dv01(mod, p):>10.4f}")

dy = 0.01                                           # 100 bp shock
new_p, pct = price_change_approx(mod, cvx, p, dy)
exact_p = price_bond(times, cfs, ytm + dy, freq)
print(f"\n+100 bp shock")
print(f"Approx new price  : {new_p:>10.4f}")
print(f"Exact new price   : {exact_p:>10.4f}")
print(f"Approx error      : {abs(new_p - exact_p):>10.4f}")

Running this you will see the analytic and numerical modified durations agree to roughly six decimal places, which is as close as double precision allows for h near 1e-5. The approximation error on a 100 bp shock is a few cents on a $1,000 bond — small enough for most hedging purposes, and useful for building intuition about where the linear and quadratic terms each contribute.

A note on DV01 and dollar duration

Modified duration is a percentage sensitivity; DV01 converts it to dollars. For a $10 million notional position in the 10-year bond above, a 1 bp rise in yield costs roughly ModDur × $10,000,000 × 0.0001 dollars — that is the DV01 scaled to position size. Traders often quote DV01 directly because it lets you add sensitivities across instruments with different face values without converting everything to percentage terms first. Dollar duration is simply DV01 × 10,000 — the price change per 100 bp — which equals ModDur × Price in dollar terms.

Convexity matters most when you are long optionality (callable bonds, MBS) or when you are hedging a large rate move. A portfolio that is duration-neutral but convexity-positive benefits when rates move sharply in either direction. Constructing those trades correctly requires computing convexity the same way for every instrument in the book — which is exactly why building the function yourself, and verifying it against the finite-difference estimate, is worth doing once.