Insights
How to Price a Bond in Python
Alphanume Team · June 5, 2026
Discounting cash flows with numpy.
A bond is nothing more than a promise — pay the holder a coupon every period, then return the face value at maturity. Bond pricing python reduces that promise to a single number: the present value of every future cash flow, each discounted at the yield to maturity. That definition is clean, but the mechanics trip people up the first time: semi-annual compounding, the difference between clean and dirty price, and inverting the pricing formula to solve for yield. This post builds each piece from scratch using only numpy and scipy, so you can see exactly what the textbook formula looks like in code. When you want a quick answer without writing a line, the bond pricing calculator covers the same logic interactively.
How bond pricing works
The price of a bond is the sum of its discounted cash flows. For a bond that pays a semi-annual coupon, there are n = years * freq periods. Each period delivers a coupon of face * coupon_rate / freq. At maturity — period n — the face value is returned on top of the final coupon. The discount rate applied to period t is (1 + ytm / freq) ** t, where ytm is the annual yield to maturity expressed as a decimal.
Written as a sum:
P = ∑<sup>n</sup><sub>t=1</sub> C / (1 + y/f)<sup>t</sup> + F / (1 + y/f)<sup>n</sup>
where C is the periodic coupon, y is the annual yield, f is the payment frequency, and F is the face value. That final term is just the face value discounted back from maturity — it is not a separate formula, just the last cash flow being larger than the others.
Building the cash-flow vector with numpy
numpy makes this vectorized and readable. Construct an array of period indices, fill every element with the coupon payment, then add the face value to the last element. Divide by the discount factors — also a numpy array — and sum.
import numpy as np
def price_bond(face, coupon_rate, years, ytm, freq=2):
"""
Price a fixed-rate bond.
Parameters
----------
face : float — par / face value (e.g. 1000)
coupon_rate : float — annual coupon rate as a decimal (e.g. 0.05)
years : float — years to maturity
ytm : float — annual yield to maturity as a decimal (e.g. 0.04)
freq : int — coupon payments per year (default 2 = semi-annual)
Returns
-------
float — full (dirty) price of the bond
"""
n = int(years * freq)
coupon = face * coupon_rate / freq
periods = np.arange(1, n + 1) # [1, 2, ..., n]
cash_flows = np.full(n, coupon)
cash_flows[-1] += face # add principal at maturity
discount_factors = (1 + ytm / freq) ** periods
return float(np.sum(cash_flows / discount_factors))
np.arange(1, n + 1) produces the period indices starting at 1, which is exactly what we need — discounting by period 0 would divide by 1 and leave cash flows undiscounted. np.full fills the array with the periodic coupon, and the final in-place addition handles the principal repayment without a separate term.
Worked example — par, premium, and discount
Take a 5% annual coupon bond, 1,000 face value, three years to maturity, priced at four different yields. The relationship between coupon rate and yield determines whether the bond trades at par, a premium, or a discount — and the code makes that logic tangible.
face = 1_000
coupon_rate = 0.05
years = 3
scenarios = {
"at par (ytm = coupon)": 0.05,
"premium (ytm < coupon)": 0.03,
"discount (ytm > coupon)": 0.07,
"worked example (ytm = 4%)": 0.04,
}
for label, ytm in scenarios.items():
p = price_bond(face, coupon_rate, years, ytm)
print(f"{label:40s} price = {p:,.4f}")
Running that produces:
# at par (ytm = coupon) price = 1,000.0000
# premium (ytm < coupon) price = 1,056.5765
# discount (ytm > coupon) price = 947.5089
# worked example (ytm = 4%) price = 1,027.7509
At 4% yield the bond prices above par because investors are willing to pay a premium for a coupon stream that exceeds current market rates. When yield equals the coupon rate, the discounting and the cash flows exactly cancel and the bond trades at face value — a useful sanity check. For a deeper walk-through of the intuition behind each case, see how to price a bond.
Solving for yield to maturity
Market prices are observable; yields are not. Given a quoted price, you need to find the yield that makes price_bond(..., ytm=y) equal to that price. There is no closed-form solution — the equation is a polynomial of degree n — so we invert it numerically. scipy.optimize.brentq is ideal: it finds a root on a bracketed interval and converges reliably without requiring a derivative.
from scipy.optimize import brentq
def ytm_from_price(price, face, coupon_rate, years, freq=2):
"""
Solve for the yield to maturity given a market price.
Parameters
----------
price : float — observed market (dirty) price
face : float — par / face value
coupon_rate : float — annual coupon rate as a decimal
years : float — years to maturity
freq : int — coupon payments per year
Returns
-------
float — annual yield to maturity as a decimal
"""
objective = lambda y: price_bond(face, coupon_rate, years, y, freq) - price
# search between 0 bp and 100% — wide enough for any realistic bond
return brentq(objective, 1e-6, 10.0, xtol=1e-10)
# verify round-trip: price at 4% ytm, then recover the yield
p = price_bond(1_000, 0.05, 3, 0.04) # 1027.7509
y = ytm_from_price(p, 1_000, 0.05, 3)
print(f"Market price: {p:.4f} => YTM: {y:.6f}") # should print 0.040000
The lambda objective is the pricing error as a function of yield. brentq narrows the bracket until the error is below xtol. The round-trip test confirms the inversion is accurate: pricing at 4% and recovering the yield should return exactly 0.04.
Clean price, dirty price, and accrued interest
The price computed above is the dirty price — also called the full price or invoice price. It is what you actually pay. Between coupon dates, the dirty price includes interest that has accrued since the last coupon payment. Bond markets conventionally quote the clean price, which strips accrued interest out so the price does not jump discontinuously every time a coupon is paid.
The relationship is:
dirty price = clean price + accrued interest
Accrued interest for a semi-annual bond is face * coupon_rate / 2 * (days_since_last_coupon / days_in_coupon_period). For bonds priced exactly on a coupon date — as in all examples above — accrued interest is zero and dirty price equals clean price. In production code, you would compute the accrued stub and subtract it from the dirty price before quoting. For the purposes of computing bond duration in Python, the dirty price is the correct denominator in the duration formula.
Putting it together
Two functions — price_bond and ytm_from_price — cover the core of fixed-income analytics. price_bond is fully vectorized: you can pass a numpy array of yields to generate a price-yield curve in a single call. ytm_from_price wraps that in a root-solver that converges in under a millisecond for any realistic bond. From here the natural extensions are duration and convexity — sensitivity of price to changes in yield — and spread analysis, where the yield to maturity is decomposed into a risk-free rate plus a credit spread. The same discounted-cash-flow skeleton underlies all of them.