Bond Risk Measures: Duration, Convexity, and Immunization
Measure and manage bond interest rate risk using duration, convexity, and immunization. Topics include portfolio hedging and liability-driven investing.
Quantitative FinanceAll chapters Bond Risk Measures and ImmunizationFull chapter · Sections in this chapter1 / 13 ↓
Reading tools Definitions, links & notes
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Bond Risk Measures and Immunization
A bond portfolio can gain value when rates fall and still leave a pension fund less able to meet its obligations. The liabilities may rise by more than the assets. Managing that mismatch requires a clear choice of what to hedge: a bond's price, the dollar surplus over liabilities, or the funding ratio. These are different quantities, even when all use duration.
This chapter develops duration and convexity as first- and second-order price sensitivities, then uses them to construct and test a portfolio against a future liability. We begin with fixed, nonnegative cash flows and a common yield convention. The approximations describe nearby yield changes; they are not exact repricing rules for arbitrary rate moves.
Immunization matches asset present value and cash-flow timing to a liability under stated assumptions. In the single-liability example, matching Macaulay duration to the payment horizon removes first-order exposure to an immediate persistent change in one flat yield. It does not guarantee payment under every future rate path or remove default and liquidity risk.
Interest Rate Risk
Before developing formal risk measures, we establish the fundamental relationship between interest rates and bond prices. Understanding this relationship provides the intuition that underlies all duration and convexity calculations.
A bond represents a contractual promise to pay a stream of future cash flows. These cash flows consist of periodic coupon payments plus the return of principal at maturity. The question every bond investor faces is: what is this stream of future payments worth today? The answer depends critically on the discount rate applied to those payments.
For the fixed-cash-flow bonds considered here, price is the present value of promised payments. The first formula uses one yield per payment period, with positive discount base and nonnegative cash flows:
where:
- : the bond price
- : the cash flow at time (coupon payments and principal)
- : the yield to maturity (expressed as a decimal)
- : the total number of periods until maturity
For these fixed positive payments, a higher yield lowers price. A lower-coupon bond generally trades below an otherwise comparable higher-coupon bond when both are priced at the same yield. The coupon itself is contractual: an issuer does not automatically change an existing bond's payments when market yields move.
The magnitude of this price sensitivity depends on several bond characteristics, and understanding these relationships helps investors anticipate how their holdings will respond to rate changes:
- Maturity: A fixed payment due later has greater percentage yield sensitivity at the same yield convention. Longer coupon-bond maturity also increases duration in the examples below, but maturity alone is not a universal ordering rule across all coupon and yield combinations.
- Coupon rate: Holding maturity, yield and payment frequency fixed, lower coupons increase duration when there is a coupon date strictly before maturity. A zero-coupon bond has all its present-value weight at maturity, giving the greatest duration for those terms. Higher coupons shift present-value weight to earlier receipts when those dates exist. If all remaining payments arrive at maturity, changing the coupon does not change duration or create an earlier receipt.
- Current yield level: Price sensitivity is higher at lower yield levels due to the convex nature of the price-yield curve. Moving from a 2% yield to a 3% yield produces a larger percentage price change than moving from 8% to 9%, even though both represent a 100 basis point increase.
PythonIn [3]Expand
def _bond_terms(face_value, coupon_rate, years_to_maturity, yield_rate, frequency):
"""Validate the fixed, nonnegative-cash-flow, regular-payment model."""
from math import isclose, isfinite
from numbers import Real
values = (face_value, coupon_rate, years_to_maturity, yield_rate, frequency)
if any(
isinstance(x, bool) or not isinstance(x, Real) or not isfinite(x)
for x in values
):
raise ValueError("Bond inputs must be finite real numbers")
if face_value <= 0 or coupon_rate < 0 or years_to_maturity <= 0:
raise ValueError("Require positive face value and maturity, nonnegative coupon")
if frequency <= 0 or frequency != int(frequency):
raise ValueError("frequency must be a positive integer")
frequency = int(frequency)
raw_periods = years_to_maturity * frequency
periods = round(raw_periods)
if periods < 1 or not isclose(periods, raw_periods, rel_tol=0, abs_tol=1e-10):
raise ValueError("Maturity must lie on the regular payment grid")
periodic_yield = yield_rate / frequency
if 1 + periodic_yield <= 0:
raise ValueError("The periodic discount base must be positive")
return periods, face_value * coupon_rate / frequency, periodic_yield
def bond_price(face_value, coupon_rate, years_to_maturity, yield_rate, frequency=2):
"""
Calculate the price of a bond with periodic coupon payments.
Parameters:
- face_value: Par value of the bond
- coupon_rate: Annual coupon rate (decimal)
- years_to_maturity: Time to maturity in years
- yield_rate: Yield to maturity (decimal, annualized)
- frequency: Number of coupon payments per year
"""
from math import expm1, log1p
periods, coupon_payment, periodic_yield = _bond_terms(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency
)
# Present value of coupon payments
if periodic_yield == 0:
pv_coupons = coupon_payment * periods
else:
pv_coupons = (
coupon_payment * -expm1(-periods * log1p(periodic_yield)) / periodic_yield
)
# Present value of face value
pv_face = face_value / (1 + periodic_yield) ** periods
return pv_coupons + pv_faceLet's examine how a bond's price changes across different yield levels:
PythonIn [4]Expand
import numpy as np
# 10-year bond with 5% coupon, $1000 face value
face_value = 1000
coupon_rate = 0.05
maturity = 10
# Calculate prices across yield range
yields = np.linspace(0.01, 0.10, 50)
prices = [bond_price(face_value, coupon_rate, maturity, y) for y in yields]VisualizationOut [5]Expand

For the fixed nonnegative cash flows used here, the price-yield curve bends upward. From the same starting yield, a nonzero yield decrease gives a larger price gain than the loss caused by an equal increase, provided the discount base remains positive. Embedded options can change this convexity pattern.
Macaulay Duration
Macaulay duration is the weighted average time until a bond's cash flows are received. The weights are the present values of each cash flow as a proportion of the bond's total price.
Frederick Macaulay introduced duration in 1938 as a measure of the effective maturity of a bond. His insight was that a bond's stated maturity date tells only part of the story. A 10-year bond that pays substantial coupons behaves quite differently from a 10-year zero-coupon bond, even though both mature on the same date. Macaulay sought a single number that would capture the economic timing of all a bond's cash flows.
Rather than using only the final maturity date, duration accounts for all cash flows and their timing. The concept is analogous to finding the center of gravity of the cash flow stream. Just as a physical object balances at its center of mass, a bond's duration represents the "balance point" of its payments over time. For a bond paying cash flows at times :
where:
- : Macaulay duration in payment periods in this formula
- : the integer payment-period index; divide by the annual payment frequency to express duration in years
- : the cash flow at time
- : the yield to maturity per period (expressed as a decimal)
- : the bond price (sum of all present values)
- : the present value of cash flow , equal to
- : the total number of cash flow periods
Each term weights the time period by the present value of the cash flow received at that time. Dividing by the total price normalizes these weights to sum to one, so the result is a proper weighted average.
Macaulay duration summarizes the timing of present-value weights; it is not a payback date or a promise that accumulated coupons have recovered the purchase price. A zero-coupon bond has one receipt time, so its duration equals maturity. With positive earlier coupons, some present-value weight arrives sooner and the weighted mean is shorter than maturity.
For a zero-coupon bond, the calculation is trivial. The only cash flow is the face value at maturity, so Macaulay duration equals the time to maturity. With a positive coupon payment strictly before maturity, Macaulay duration is less than maturity because that earlier payment receives positive present-value weight. If all remaining cash flow arrives at maturity, duration equals maturity.
PythonIn [6]Expand
def macaulay_duration(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency=2
):
"""Calculate Macaulay duration of a bond."""
periods, coupon_payment, periodic_yield = _bond_terms(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency
)
# Calculate price
price = bond_price(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency
)
# Calculate weighted average time
weighted_time = 0
for t in range(1, periods + 1):
if t < periods:
cf = coupon_payment
else:
cf = coupon_payment + face_value
pv_cf = cf / (1 + periodic_yield) ** t
time_in_years = t / frequency
weighted_time += time_in_years * pv_cf
return weighted_time / priceLet's calculate duration for several bonds to see how bond characteristics affect it:
PythonIn [7]Expand
# Compare durations for different bonds
bonds = [
{"name": "5-year, 5% coupon", "maturity": 5, "coupon": 0.05},
{"name": "10-year, 5% coupon", "maturity": 10, "coupon": 0.05},
{"name": "10-year, 2% coupon", "maturity": 10, "coupon": 0.02},
{"name": "10-year, 8% coupon", "maturity": 10, "coupon": 0.08},
{"name": "30-year, 5% coupon", "maturity": 30, "coupon": 0.05},
]
yield_rate = 0.05 # 5% yield
print("Bond Characteristics and Macaulay Duration")
print("=" * 55)
print(f"{'Bond':<25} {'Maturity':>10} {'Duration':>12}")
print("-" * 55)
for bond in bonds:
mac_dur = macaulay_duration(1000, bond["coupon"], bond["maturity"], yield_rate)
print(f"{bond['name']:<25} {bond['maturity']:>10} {mac_dur:>12.2f} years")ConsoleOut [7]Expand
Bond Characteristics and Macaulay Duration ======================================================= Bond Maturity Duration ------------------------------------------------------- 5-year, 5% coupon 5 4.49 years 10-year, 5% coupon 10 7.99 years 10-year, 2% coupon 10 8.95 years 10-year, 8% coupon 10 7.39 years 30-year, 5% coupon 30 15.84 years
The results confirm our intuition: longer maturity increases duration, and lower coupons increase duration because less of the bond's value comes from near-term cash flows. Notice that the 10-year, 2% coupon bond has a duration of nearly 9 years, while the 10-year, 8% coupon bond has a duration closer to 7 years. The higher coupon payments shift the center of gravity of the cash flows earlier in time.
VisualizationOut [8]Expand

VisualizationOut [9]Expand

Modified Duration
While Macaulay duration measures weighted-average time, we need a measure that directly quantifies price sensitivity. Practitioners and risk managers care about when cash flows arrive and how much a bond's price will change when interest rates move. Modified duration provides this essential link between the time-weighted concept of Macaulay duration and the practical concern of price volatility.
Modified duration measures the percentage change in bond price for a one percentage point change in yield. It equals Macaulay duration divided by , where is the compounding frequency.
The derivation of modified duration comes from calculus, specifically from examining how the bond price function responds to small changes in yield. This mathematical derivation reveals that the connection between time-weighted cash flows and price sensitivity is not accidental. Rather, it emerges naturally from the structure of the present value formula.
Starting with the bond price formula,
Taking the derivative with respect to yield ,
This derivative is the dollar price sensitivity per unit change in decimal yield; multiply it by a small yield change to approximate the dollar price change. To express this in percentage terms, we divide both sides by the price :
where the last step recognizes that is exactly the definition of Macaulay duration.
The derivation above differentiates with respect to a per-period yield, so its duration is in periods. For nominal annual yield compounded times per year, differentiating introduces . With now in years, this gives modified duration with respect to the quoted annual decimal yield:
where:
- : modified duration
- : Macaulay duration
- : the annual yield to maturity
- : the number of compounding periods per year (e.g., for semi-annual)
The negative sign in the derivative confirms the inverse relationship between price and yield. Modified duration is expressed as a positive number with the understanding that price and yield move in opposite directions. When we use modified duration to estimate price changes, we explicitly include the negative sign.
The price change approximation using modified duration is:
or equivalently:
where:
- : the change in bond price
- : the current bond price
- : modified duration
- : the change in yield (expressed as a decimal, e.g., 0.01 for a 1% change)
The negative sign confirms that price and yield move in opposite directions: when yields rise (), prices fall (). This approximation is essentially a first-order Taylor expansion of the price function around the current yield. It uses the slope of the price-yield curve at the current point to estimate prices at nearby yields.
PythonIn [10]Expand
def modified_duration(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency=2
):
"""Calculate modified duration of a bond."""
mac_dur = macaulay_duration(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency
)
return mac_dur / (1 + yield_rate / frequency)PythonIn [11]Expand
# Calculate both durations for our 10-year, 5% coupon bond
face_value = 1000
coupon_rate = 0.05
maturity = 10
yield_rate = 0.05
mac_dur = macaulay_duration(face_value, coupon_rate, maturity, yield_rate)
mod_dur = modified_duration(face_value, coupon_rate, maturity, yield_rate)
price = bond_price(face_value, coupon_rate, maturity, yield_rate)
print("Bond: 10-year, 5% coupon at 5% yield")
print(f"Price: ${price:.2f}")
print(f"Macaulay Duration: {mac_dur:.4f} years")
print(f"Modified Duration: {mod_dur:.4f}")ConsoleOut [11]Expand
Bond: 10-year, 5% coupon at 5% yield Price: $1000.00 Macaulay Duration: 7.9894 years Modified Duration: 7.7946
The modified duration of 7.79 tells us that for each 1% (100 basis point) increase in yield, we expect the bond price to decrease by approximately 7.79%. Since the bond is priced at par ($1,000), this translates to roughly a $77.90 price change per 100 basis points of yield movement. This gives portfolio managers a quick way to assess interest rate exposure: multiply the modified duration by the expected yield change to estimate the percentage price impact.
Testing the Duration Approximation
We can verify that modified duration provides a reasonable approximation for small yield changes:
PythonIn [12]Expand
# Test duration approximation for various yield changes
yield_changes = [-0.02, -0.01, -0.005, 0.005, 0.01, 0.02]
print("Duration Approximation vs. Actual Price Change")
print("=" * 70)
print(f"{'Yield Change':>12} {'Actual Price':>14} {'Approx Price':>14} {'Error':>12}")
print("-" * 70)
for dy in yield_changes:
new_yield = yield_rate + dy
actual_price = bond_price(face_value, coupon_rate, maturity, new_yield)
# Duration approximation
approx_change = -mod_dur * price * dy
approx_price = price + approx_change
error = (approx_price - actual_price) / actual_price * 100
print(
f"{dy * 100:>+12.1f}% {actual_price:>14.2f} {approx_price:>14.2f} {error:>+12.3f}%"
)ConsoleOut [12]Expand
Duration Approximation vs. Actual Price Change
======================================================================
Yield Change Actual Price Approx Price Error
----------------------------------------------------------------------
-2.0% 1171.69 1155.89 -1.348%
-1.0% 1081.76 1077.95 -0.352%
-0.5% 1039.91 1038.97 -0.090%
+0.5% 961.93 961.03 -0.094%
+1.0% 925.61 922.05 -0.384%
+2.0% 857.88 844.11 -1.605%The approximation works well for small changes but introduces noticeable error for larger movements. This error arises because duration is a linear approximation of a curved (convex) relationship. When we use duration alone, we are essentially drawing a tangent line to the price-yield curve at the current point and using that straight line to estimate prices at other yields.
For a 2% yield increase, duration underestimates the new price and overstates the bond's loss. For a 2% yield decrease, it understates the bond's price gain. This systematic pattern motivates the need for convexity. The actual price is always higher than the linear approximation, regardless of whether rates rise or fall.
Convexity
Convexity is the second derivative of price with respect to yield, divided by price. It provides a second-order correction to the duration estimate; it is not simply the derivative of modified duration.
Duration provides a first-order approximation to price changes, analogous to using the slope of a curve to estimate nearby points. However, the price-yield relationship is not a straight line but a curve that bends upward. Convexity measures this curvature and allows us to improve our price estimates significantly.
The first derivative describes the slope of the price-yield curve; the second derivative describes how that slope changes. Modified duration is the negative first derivative divided by price, while convexity is the second derivative divided by price. Consequently, , rather than simply .
Convexity is derived from the second derivative of the price function with respect to yield. Starting from the first derivative:
Taking the derivative again,
With nonnegative cash flows and a positive discount base, the second-derivative terms are nonnegative; at least the principal term is positive. Thus the fixed-cash-flow price-yield curve is convex: its slope becomes less negative as yield increases.
Dividing by price to get the convexity measure,
where:
- : convexity
- : bond price
- : time period
- : cash flow at time
- : yield to maturity
- : total number of periods
For semi-annual compounding, the formula adjusts to:
where the factor of (equivalently for frequency ) arises from converting the period-based calculation to annual terms. This adjustment accounts for the fact that both the time units and the yield compounding are expressed in periods rather than years. Without this adjustment, the convexity value would be scaled incorrectly when used in the price approximation formula with yield changes expressed in annual terms.
PythonIn [13]Expand
def convexity(face_value, coupon_rate, years_to_maturity, yield_rate, frequency=2):
"""Calculate convexity of a bond."""
periods, coupon_payment, periodic_yield = _bond_terms(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency
)
price = bond_price(
face_value, coupon_rate, years_to_maturity, yield_rate, frequency
)
conv_sum = 0
for t in range(1, periods + 1):
if t < periods:
cf = coupon_payment
else:
cf = coupon_payment + face_value
pv_cf = cf / (1 + periodic_yield) ** (t + 2)
conv_sum += t * (t + 1) * pv_cf
# Adjust for frequency
return conv_sum / (price * frequency**2)PythonIn [14]Expand
conv = convexity(face_value, coupon_rate, maturity, yield_rate)
print("Bond: 10-year, 5% coupon at 5% yield")
print(f"Convexity: {conv:.4f}")ConsoleOut [14]Expand
Bond: 10-year, 5% coupon at 5% yield Convexity: 73.6287
Duration-Convexity Approximation
With both duration and convexity, we can form a second-order Taylor expansion for price changes. The Taylor series provides a systematic way to approximate a function near a point using its derivatives. By including the second derivative (convexity), we capture the curvature that the first-order approximation using duration alone misses.
The Taylor series expansion of price around the current yield is
Dividing by and substituting our definitions of duration and convexity,
where:
- : the percentage change in bond price
- : modified duration (first-order sensitivity)
- : convexity (second-order sensitivity)
- : change in yield (as a decimal)
For positive convexity, the second-order term is nonnegative because . It is positive for a nonzero yield change and zero at the expansion point. Fixed nonnegative cash flows with positive discount bases have positive convexity under the yield convention used here; embedded options can change that conclusion.
Intuitively, this reflects the asymmetric benefit of convexity. Bondholders gain more from falling rates than they lose from rising rates of the same magnitude. This asymmetry arises because the price-yield curve bends upward, being convex to the origin. Any movement away from the current yield produces a price that lies above the tangent line approximation. The convexity term approximates this difference to second order; higher-order terms remain.
PythonIn [15]Expand
# Compare approximations
print("Comparison: Duration Only vs. Duration + Convexity")
print("=" * 80)
print(
f"{'Yield Change':>12} {'Actual':>12} {'Dur Only':>12} {'Dur+Conv':>12} {'Dur Err':>10} {'D+C Err':>10}"
)
print("-" * 80)
for dy in yield_changes:
new_yield = yield_rate + dy
actual_price = bond_price(face_value, coupon_rate, maturity, new_yield)
# Duration-only approximation
dur_approx = price * (1 - mod_dur * dy)
# Duration + convexity approximation
dur_conv_approx = price * (1 - mod_dur * dy + 0.5 * conv * dy**2)
dur_error = (dur_approx - actual_price) / actual_price * 100
dc_error = (dur_conv_approx - actual_price) / actual_price * 100
print(
f"{dy * 100:>+12.1f}% {actual_price:>12.2f} {dur_approx:>12.2f} {dur_conv_approx:>12.2f} {dur_error:>+10.3f}% {dc_error:>+10.3f}%"
)ConsoleOut [15]Expand
Comparison: Duration Only vs. Duration + Convexity
================================================================================
Yield Change Actual Dur Only Dur+Conv Dur Err D+C Err
--------------------------------------------------------------------------------
-2.0% 1171.69 1155.89 1170.62 -1.348% -0.091%
-1.0% 1081.76 1077.95 1081.63 -0.352% -0.012%
-0.5% 1039.91 1038.97 1039.89 -0.090% -0.002%
+0.5% 961.93 961.03 961.95 -0.094% +0.002%
+1.0% 925.61 922.05 925.74 -0.384% +0.013%
+2.0% 857.88 844.11 858.83 -1.605% +0.112%Adding convexity dramatically improves the approximation. The duration-convexity model captures nearly all price movements even for substantial yield changes of 200 basis points. The remaining small errors arise from higher-order terms in the Taylor expansion that we have not included.
Visualizing Duration and Convexity
VisualizationOut [16]Expand

The visualization shows the duration approximation as a tangent line that diverges from the actual curve as we move away from the current yield. The straight red dashed line represents what duration alone predicts, touching the actual curve only at the current yield of 5%. The duration-convexity approximation, being a parabola (a quadratic function), tracks the actual curve much more closely across the entire yield range. The green dashed-dotted curve hugs the blue actual price curve, showing the value of including the second-order term.
VisualizationOut [17]Expand

Dollar Duration and DV01
Practitioners often work with dollar-denominated risk measures rather than percentage changes. This preference arises for practical reasons. When hedging a portfolio or calculating profit and loss, dollar amounts matter directly. Two common metrics translate duration into dollar terms.
Money duration (also called dollar duration) is modified duration multiplied by the position's market value. It is the magnitude of the first-order dollar sensitivity to a unit change in decimal yield. Multiply it by 0.01 for a one-percentage-point move:
where is modified duration and is the bond price.
This measure tells us the absolute dollar exposure to interest rate changes. A position with higher dollar duration has greater dollar risk, regardless of whether that position represents a large investment in low-duration bonds or a small investment in high-duration bonds.
DV01 (Dollar Value of a 01, also called "price value of a basis point" or PVBP) measures the dollar change for a one basis point (0.01%) change in yield:
The factor 0.0001 is a one-basis-point yield move expressed in decimal units: 1 bp = 0.01 percentage points = 0.0001.
Use the same decimal-yield unit as the derivative: a one-basis-point increase is , so the first-order dollar loss is . Traders use DV01 to compare dollar sensitivities to small yield movements.
These measures are particularly useful for hedging, as they express risk in dollar terms that can be directly compared across positions. If one unit has a DV01 of $50 and another has a DV01 of $25 to the same yield factor, two short units of the second offset the first-order exposure of one long unit of the first. This does not also match their convexity, curve-shape or credit exposures.
PythonIn [18]Expand
dollar_duration = mod_dur * price
dv01 = mod_dur * price * 0.0001
print("Bond: 10-year, 5% coupon at 5% yield")
print(f"Price: ${price:.2f}")
print(f"Modified Duration: {mod_dur:.4f}")
print(f"Dollar Duration: ${dollar_duration:.2f}")
print(f"DV01: ${dv01:.4f}")
print("\nInterpretation:")
print(
f" - A 1 percentage point yield increase causes ~${dollar_duration * 0.01:.2f} price decrease"
)
print(f" - A 1 bp yield increase causes ~${dv01:.4f} price decrease")ConsoleOut [18]Expand
Bond: 10-year, 5% coupon at 5% yield Price: $1000.00 Modified Duration: 7.7946 Dollar Duration: $7794.58 DV01: $0.7795 Interpretation: - A 1 percentage point yield increase causes ~$77.95 price decrease - A 1 bp yield increase causes ~$0.7795 price decrease
Portfolio Duration and Convexity
Individual bond risk measures become even more powerful when extended to portfolios. A portfolio manager holding dozens or hundreds of bonds needs a way to summarize the aggregate interest rate exposure. Fortunately, duration and convexity combine in a straightforward way: they are computed as weighted averages, where the weights are each bond's market value as a proportion of total portfolio value.
For a portfolio with bonds, each with market value and modified duration :
where:
- : the portfolio's modified duration
- : the weight of bond in the portfolio, calculated as
- : the market value of bond
- : the modified duration of bond
- : the number of bonds in the portfolio
This formula follows from the linearity of the price change approximation. If each bond's percentage price change is given by , then the portfolio's percentage change is the value-weighted average of the individual changes. The same weighting applies to convexity:
where is the convexity of bond .
This weighted-average approach allows portfolio managers to adjust interest rate exposure by changing the mix of bonds. With positive portfolio value and a positive-value addition, holding the other positions fixed, a bond whose modified duration exceeds the current portfolio duration raises it; one below the current portfolio duration lowers it. These comparisons use the same yield-change coordinate.
PythonIn [19]Expand
def portfolio_duration_convexity(bonds_data, yield_rate):
"""
Calculate portfolio duration and convexity.
bonds_data: list of dicts with 'face_value', 'coupon_rate', 'maturity', 'quantity'
"""
results = []
total_value = 0
for bond in bonds_data:
price_i = bond_price(
bond["face_value"],
bond["coupon_rate"],
bond["maturity"],
yield_rate,
)
value_i = price_i * bond["quantity"]
dur_i = modified_duration(
bond["face_value"],
bond["coupon_rate"],
bond["maturity"],
yield_rate,
)
conv_i = convexity(
bond["face_value"],
bond["coupon_rate"],
bond["maturity"],
yield_rate,
)
results.append(
{
"price": price_i,
"value": value_i,
"duration": dur_i,
"convexity": conv_i,
}
)
total_value += value_i
# Calculate weighted averages
port_duration = sum(r["value"] * r["duration"] for r in results) / total_value
port_convexity = sum(r["value"] * r["convexity"] for r in results) / total_value
return port_duration, port_convexity, total_value, resultsPythonIn [20]Expand
# Create a sample portfolio
portfolio = [
{
"face_value": 1000,
"coupon_rate": 0.03,
"maturity": 2,
"quantity": 100,
"name": "2-year, 3%",
},
{
"face_value": 1000,
"coupon_rate": 0.04,
"maturity": 5,
"quantity": 150,
"name": "5-year, 4%",
},
{
"face_value": 1000,
"coupon_rate": 0.05,
"maturity": 10,
"quantity": 100,
"name": "10-year, 5%",
},
{
"face_value": 1000,
"coupon_rate": 0.055,
"maturity": 30,
"quantity": 50,
"name": "30-year, 5.5%",
},
]
yield_rate = 0.05
port_dur, port_conv, total_val, details = portfolio_duration_convexity(
portfolio, yield_rate
)
print("Portfolio Composition")
print("=" * 75)
print(
f"{'Bond':<15} {'Price':>10} {'Quantity':>10} {'Value':>12} {'Duration':>10} {'Weight':>10}"
)
print("-" * 75)
for bond, det in zip(portfolio, details):
weight = det["value"] / total_val * 100
print(
f"{bond['name']:<15} ${det['price']:>9.2f} {bond['quantity']:>10} ${det['value']:>11,.2f} {det['duration']:>10.2f} {weight:>9.1f}%"
)
print("-" * 75)
print(f"{'Total':<15} {'':<10} {'':<10} ${total_val:>11,.2f}")
print(f"\nPortfolio Duration: {port_dur:.2f}")
print(f"Portfolio Convexity: {port_conv:.2f}")ConsoleOut [20]Expand
Portfolio Composition =========================================================================== Bond Price Quantity Value Duration Weight --------------------------------------------------------------------------- 2-year, 3% $ 962.38 100 $ 96,238.03 1.91 24.5% 5-year, 4% $ 956.24 150 $ 143,435.95 4.46 36.4% 10-year, 5% $ 1000.00 100 $ 100,000.00 7.79 25.4% 30-year, 5.5% $ 1077.27 50 $ 53,863.58 15.16 13.7% --------------------------------------------------------------------------- Total $ 393,537.56 Portfolio Duration: 6.15 Portfolio Convexity: 74.98
The portfolio's modified duration is approximately 6.15 years, the market-value-weighted average of the individual modified durations. The thirty-year bond has the longest modified duration, about 15.16 years, but represents only 13.69% of portfolio value. Portfolio convexity is about 74.98. For a small common yield change, these measures give the portfolio's first- and second-order price approximation; positive convexity does not prevent a loss when yields rise.
VisualizationsOut [21]Expand


Immunization
Immunization is a strategy that structures a bond portfolio to protect against interest rate risk by matching the portfolio's duration to a target investment horizon. The technique addresses a fundamental challenge facing investors with future obligations: how can one ensure that today's investments will meet tomorrow's needs, regardless of how interest rates move in the interim?
The core insight is that interest rate changes affect bond investors through two offsetting channels:
- Price effect. When rates rise, bond prices fall, creating a negative impact. An investor who needs to sell bonds before maturity will realize less than expected.
- Reinvestment effect. When rates rise, coupon payments can be reinvested at higher rates, creating a positive impact. An investor who reinvests coupons will earn more than expected on those reinvestments.
These two effects work in opposite directions. Rising rates hurt through lower prices but help through higher reinvestment income. Falling rates help through higher prices but hurt through lower reinvestment income. The question becomes: is there a holding period at which these effects exactly cancel?
At a horizon equal to Macaulay duration, the price and reinvestment effects cancel to first order in a small immediate persistent parallel yield change, assuming fixed cash flows and reinvestment at the same shocked yield. Finite shocks leave higher-order exposure. Matching one duration does not protect against arbitrary subsequent rate paths.
VisualizationOut [22]Expand

Single-Period Immunization
Consider an investor with a liability due in exactly years. To immunize against interest rate risk, two conditions must be satisfied:
- Duration matching. Set portfolio Macaulay duration in years equal to the liability horizon . Equivalently, match modified duration to under the same flat nominal annual yield and frequency.
- Present value matching. Ensure the portfolio's current market value equals the present value of the liability.
In the common-flat-yield fixed-cash-flow model, these conditions remove first-order terminal-value exposure to an immediate persistent parallel shift. Positive asset convexity relative to the single-payment liability controls the local second-order surplus response. These conditions do not establish immunity to all finite shocks or future rate paths.
PythonIn [23]Expand
def immunize_portfolio(liability_pv, target_duration, available_bonds, yield_rate):
"""Match a positive liability PV and its Macaulay horizon using two bonds."""
from math import isclose, isfinite
if not isfinite(liability_pv) or liability_pv <= 0:
raise ValueError("liability_pv must be positive and finite")
if not isfinite(target_duration) or target_duration <= 0:
raise ValueError("target_duration must be positive and finite")
if len(available_bonds) != 2:
raise ValueError("This simple immunization requires exactly 2 bonds")
durations = [
macaulay_duration(b["face_value"], b["coupon_rate"], b["maturity"], yield_rate)
for b in available_bonds
]
prices = [
bond_price(b["face_value"], b["coupon_rate"], b["maturity"], yield_rate)
for b in available_bonds
]
d1, d2 = durations
if isclose(d1, d2, rel_tol=1e-12, abs_tol=1e-12):
raise ValueError("Two distinct durations are required")
w1 = (target_duration - d2) / (d1 - d2)
w2 = 1 - w1
if min(w1, w2) < 0:
raise ValueError("The target horizon requires a short position")
return [w1, w2], durations, pricesPythonIn [24]Expand
# Immunization example: $1,000,000 liability due in 7 years
liability_amount = 1_000_000
liability_years = 7
yield_rate = 0.05
# Present value of liability
frequency = 2
liability_pv = liability_amount / (1 + yield_rate / frequency) ** (
frequency * liability_years
)
# Available bonds for immunization
immunization_bonds = [
{
"face_value": 1000,
"coupon_rate": 0.04,
"maturity": 5,
"name": "5-year, 4%",
},
{
"face_value": 1000,
"coupon_rate": 0.06,
"maturity": 15,
"name": "15-year, 6%",
},
]
weights, durations, prices = immunize_portfolio(
liability_pv, liability_years, immunization_bonds, yield_rate
)
print(f"Liability: ${liability_amount:,.0f} due in {liability_years} years")
print(f"Present Value of Liability: ${liability_pv:,.2f}")
print(f"Target Macaulay Duration: {liability_years} years")
print()
print("Immunization Portfolio:")
print("-" * 60)
for i, (bond, w, d, p) in enumerate(
zip(immunization_bonds, weights, durations, prices)
):
investment = liability_pv * w
num_bonds = investment / p
print(f"{bond['name']}:")
print(f" Duration: {d:.2f} years")
print(f" Weight: {w:.2%}")
print(f" Investment: ${investment:,.2f}")
print(f" Number of bonds: {num_bonds:.1f}")
print()
# Verify portfolio duration
port_duration = weights[0] * durations[0] + weights[1] * durations[1]
print(f"Portfolio Macaulay Duration: {port_duration:.2f} years")ConsoleOut [24]Expand
Liability: $1,000,000 due in 7 years Present Value of Liability: $707,727.20 Target Macaulay Duration: 7 years Immunization Portfolio: ------------------------------------------------------------ 5-year, 4%: Duration: 4.57 years Weight: 58.01% Investment: $410,560.62 Number of bonds: 429.3 15-year, 6%: Duration: 10.36 years Weight: 41.99% Investment: $297,166.58 Number of bonds: 269.0 Portfolio Macaulay Duration: 7.00 years
Testing Immunization Effectiveness
We can verify that our immunized portfolio performs well under different interest rate scenarios:
PythonIn [25]Expand
def simulate_immunization(
liability_amount,
liability_years,
bonds,
initial_weights,
initial_yield,
yield_shock,
):
"""Horizon value of fixed semiannual cash flows after a permanent flat shock.
Payments before the coupon-date horizon compound forward. Payments after
the horizon are discounted back, giving the residual bond's sale value.
Assume no default, frictionless reinvestment and fractional holdings.
"""
from math import isclose, isfinite
frequency = 2
if not isfinite(liability_amount) or liability_amount <= 0:
raise ValueError("liability_amount must be positive and finite")
if not isfinite(liability_years) or liability_years <= 0:
raise ValueError("liability_years must be positive and finite")
horizon_periods = round(liability_years * frequency)
if not isclose(
horizon_periods, liability_years * frequency, rel_tol=0, abs_tol=1e-10
):
raise ValueError("The horizon must lie on the semiannual payment grid")
if len(bonds) != len(initial_weights) or not bonds:
raise ValueError("Provide one weight per bond")
if any(not isfinite(w) or w < 0 for w in initial_weights):
raise ValueError("Weights must be finite and nonnegative")
if not isclose(sum(initial_weights), 1.0, rel_tol=0, abs_tol=1e-12):
raise ValueError("Weights must sum to one")
if not isfinite(initial_yield) or not isfinite(yield_shock):
raise ValueError("Yields must be finite")
initial_base = 1 + initial_yield / frequency
shocked_base = 1 + (initial_yield + yield_shock) / frequency
if min(initial_base, shocked_base) <= 0:
raise ValueError("Periodic discount bases must be positive")
initial_pv = liability_amount / initial_base**horizon_periods
terminal_value = 0.0
for bond, weight in zip(bonds, initial_weights):
old_price = bond_price(
bond["face_value"],
bond["coupon_rate"],
bond["maturity"],
initial_yield,
frequency,
)
quantity = initial_pv * weight / old_price
periods = round(bond["maturity"] * frequency)
if not isclose(periods, bond["maturity"] * frequency, rel_tol=0, abs_tol=1e-10):
raise ValueError("Bond maturity must lie on the payment grid")
coupon = bond["face_value"] * bond["coupon_rate"] / frequency
for period in range(1, periods + 1):
cash_flow = coupon + (bond["face_value"] if period == periods else 0)
terminal_value += (
quantity * cash_flow * shocked_base ** (horizon_periods - period)
)
return terminal_valuePythonIn [26]Expand
# Test immunization under different yield scenarios
yield_shocks = [-0.02, -0.01, 0, 0.01, 0.02]
print("Immunization Test: Terminal Values Under Different Yield Scenarios")
print("=" * 65)
print(f"{'Yield Shock':>12} {'New Yield':>12} {'Terminal Value':>16} {'vs Target':>12}")
print("-" * 65)
for shock in yield_shocks:
terminal = simulate_immunization(
liability_amount,
liability_years,
immunization_bonds,
weights,
yield_rate,
shock,
)
diff_pct = (terminal - liability_amount) / liability_amount * 100
print(
f"{shock * 100:>+12.1f}% {(yield_rate + shock) * 100:>12.1f}% ${terminal:>15,.0f} {diff_pct:>+11.2f}%"
)ConsoleOut [26]Expand
Immunization Test: Terminal Values Under Different Yield Scenarios
=================================================================
Yield Shock New Yield Terminal Value vs Target
-----------------------------------------------------------------
-2.0% 3.0% $ 1,003,967 +0.40%
-1.0% 4.0% $ 1,000,973 +0.10%
+0.0% 5.0% $ 1,000,000 +0.00%
+1.0% 6.0% $ 1,000,939 +0.09%
+2.0% 7.0% $ 1,003,693 +0.37%With consistent semiannual cash flows, the zero-shock terminal value equals the $1 million liability. Macaulay matching removes the first-order response at that point; the remaining finite-shock differences are higher-order exposure. This test assumes an immediate permanent parallel yield change, frictionless reinvestment, no default and fractional bond holdings. Rebalancing is needed to maintain a match as time and rates change.
VisualizationOut [27]Expand

Liability-Driven Investing (LDI)
Liability-Driven Investing extends immunization concepts to manage portfolios against complex liability streams. Rather than targeting a single future payment, LDI addresses situations where institutions must meet a series of obligations over time. The framework shifts the investment objective from maximizing returns to ensuring that assets will be sufficient to meet liabilities under various market conditions.
Pension funds provide a classic example. A defined-benefit pension fund must pay retirees monthly benefits potentially spanning decades. The present value of these liabilities depends on interest rates. When rates fall, the present value of fixed liability payments rises. Funding can weaken if liabilities rise faster than assets in percentage terms; whether a deficit develops also depends on the initial funding position and the size of the rate move. A fund that appeared adequately funded at high interest rates may suddenly face a deficit when rates decline, even if the actual benefit obligations have not changed.
Duration Gap
The duration gap measures the mismatch between asset duration and liability duration weighted by the inverse funding ratio:
where:
- : duration of the asset portfolio
- : duration of the liabilities
- : present value of assets
- : present value of liabilities
- : the inverse of the funding ratio
The duration gap measures first-order exposure of dollar surplus . Under a common small decimal yield change, . A positive gap means rising yields reduce dollar surplus; a negative gap means rising yields increase it. This concerns dollar surplus, not the percentage changes in asset and liability values.
A zero gap equates asset and liability money durations, so dollar surplus has zero first-order yield sensitivity. The funding ratio has a different derivative: . Its first-order sensitivity vanishes when the two modified durations are equal. For an overfunded fund, that is generally not the same condition as a zero surplus duration gap.
PythonIn [28]Expand
# Example: Pension fund LDI analysis
asset_value = 100_000_000 # $100 million in assets
liability_value = 95_000_000 # $95 million in liabilities (overfunded)
asset_duration = 6.5
liability_duration = 12.0
funding_ratio = asset_value / liability_value
duration_gap = asset_duration - (liability_value / asset_value) * liability_duration
print("Pension Fund LDI Analysis")
print("=" * 50)
print(f"Asset Value: ${asset_value:,.0f}")
print(f"Liability Value: ${liability_value:,.0f}")
print(f"Funding Ratio: {funding_ratio:.2%}")
print(f"Asset Duration: {asset_duration:.1f} years")
print(f"Liability Duration: {liability_duration:.1f} years")
print(f"Duration Gap: {duration_gap:.2f}")
print()
# Impact of 1% rate change
rate_change = 0.01
asset_change = -asset_duration * asset_value * rate_change
liability_change = -liability_duration * liability_value * rate_change
surplus_change = asset_change - liability_change
print("Impact of +1% Rate Change:")
print(f" Asset Value Change: ${asset_change:,.0f}")
print(f" Liability Value Change: ${liability_change:,.0f}")
print(f" Surplus Change: ${surplus_change:,.0f}")
print()
print(
f"New Funding Ratio: {(asset_value + asset_change) / (liability_value + liability_change):.2%}"
)ConsoleOut [28]Expand
Pension Fund LDI Analysis ================================================== Asset Value: $100,000,000 Liability Value: $95,000,000 Funding Ratio: 105.26% Asset Duration: 6.5 years Liability Duration: 12.0 years Duration Gap: -4.90 Impact of +1% Rate Change: Asset Value Change: $-6,500,000 Liability Value Change: $-11,400,000 Surplus Change: $4,900,000 New Funding Ratio: 111.84%
The negative duration gap (-4.9) indicates that liabilities are more rate-sensitive than assets. A 1% rate increase benefits the fund because liabilities fall more than assets, improving the funding ratio. Conversely, falling rates would damage the fund's position. This analysis highlights the interest rate risk that pension funds face when their asset duration does not match their liability duration.
LDI Strategies
Pension funds and insurers employ several LDI approaches to manage the duration gap and protect their funding status:
-
Cash flow matching: Structure bond portfolios so that coupon and principal payments exactly match liability payment dates in both timing and amount. This approach eliminates reinvestment risk entirely because each cash inflow is immediately used to fund an outflow. However, cash flow matching may be expensive or impossible to implement for very long-dated liabilities, as appropriate bonds may not exist or may trade at premium prices.
-
Duration matching: Match portfolio duration to liability duration, accepting some reinvestment risk in exchange for flexibility. This approach allows the use of a broader range of bonds and can be implemented at lower cost than perfect cash flow matching. The tradeoff is that the fund remains exposed to non-parallel yield curve shifts.
-
Key rate duration matching: Match sensitivity to specific points on the yield curve, and protects against non-parallel curve movements. Instead of a single duration number, key rate durations measure sensitivity to changes at specific maturities (such as the 2-year, 5-year, 10-year, and 30-year points). Matching key rate durations across assets and liabilities gives more stable protection but requires more sophisticated analysis and implementation.
-
Liability-driven benchmarking: Compare the portfolio's cash-flow-adjusted percentage return with the percentage change in a consistently valued liability benchmark. Asset returns above that benchmark indicate relative outperformance, even when both percentage changes are negative. Contributions and benefit payments must be handled consistently, and liability remeasurement must be distinguished from investment performance. This comparison is one performance measure, not a complete judgment of a plan's success.
PythonIn [29]Expand
def ldi_scenario_analysis(
asset_value,
liability_value,
asset_duration,
liability_duration,
yield_scenarios,
):
"""Analyze funding ratio under different yield scenarios."""
results = []
for dy in yield_scenarios:
new_assets = asset_value * (1 - asset_duration * dy)
new_liabilities = liability_value * (1 - liability_duration * dy)
new_surplus = new_assets - new_liabilities
new_funding_ratio = new_assets / new_liabilities
results.append(
{
"yield_change": dy,
"assets": new_assets,
"liabilities": new_liabilities,
"surplus": new_surplus,
"funding_ratio": new_funding_ratio,
}
)
return resultsVisualizationOut [30]Expand

The current portfolio's funding ratio improves when rates rise because its liability percentage sensitivity exceeds its asset sensitivity. The surplus-matched portfolio holds dollar surplus constant in this linear scenario model, but its funding ratio still changes. The added equal-duration series instead holds the funding ratio constant within the model. Neither construction removes convexity, credit, liquidity or general curve-shape risk, and the finite-shock curves use approximate rather than exactly repriced values.
Limitations and Practical Considerations
Duration and convexity are powerful tools, but they rest on assumptions that may not hold in practice. Understanding these limitations helps practitioners apply these measures appropriately and recognize when more sophisticated analysis is required.
Common-factor scenario limitation: An individual bond's own-YTM modified duration is a local price sensitivity; it does not assume that all benchmark-curve yields move together. The portfolio and immunization scenarios in this chapter use a common yield change, so a single matched scalar exposure does not describe arbitrary curve-shape changes. In reality, short rates and long rates often move differently. The curve can steepen, flatten, or twist in ways that a single scalar exposure cannot capture. For example, if short-term yields rise while long-term yields stay fixed on an initially upward-sloping curve, the long-minus-short spread narrows: the curve flattens over those maturities. A single common-factor duration approximation would not fully describe portfolio performance in such a scenario. Key rate durations address this limitation by measuring sensitivity to changes at specific maturities, such as the 2-year, 5-year, 10-year, and 30-year points. However, they add complexity to the analysis.
Instantaneous shift assumption: Duration is a local price derivative at a fixed valuation time. Calendar passage changes remaining cash-flow dates, and coupon payments can cause jumps in the measured duration. A previous asset-liability match can therefore drift and require rebalancing; duration need not decrease smoothly or monotonically along every coupon-bond path.
Yield level dependency: Both duration and convexity change as yields change. A portfolio immunized at 5% yields will have different risk characteristics at 3% yields. Large rate movements can materially alter a portfolio's risk profile. The duration and convexity calculated before a rate move may not accurately describe the portfolio's behavior after the move.
Credit and liquidity risks: Duration addresses interest rate risk only. Corporate bonds face additional risks from credit spread changes, defaults, and liquidity conditions that duration does not capture. Spread duration measures sensitivity to credit spread changes and should be considered alongside interest rate duration for corporate bond portfolios. Default and liquidity risks require separate analysis entirely.
Embedded options: Callable bonds and mortgage-backed securities contain embedded options that create negative convexity in certain yield ranges. When rates fall, callable bonds may be redeemed by the issuer, limiting the investor's upside. Standard duration measures can be misleading for these instruments because the cash flow pattern depends on the path of interest rates. Effective duration, which uses scenario-based revaluation to account for how embedded-option cash flows change with rates, is an appropriate sensitivity measure for these securities, alongside full scenario repricing.
Reinvestment assumptions: Immunization assumes coupons can be reinvested at prevailing rates. In practice, transaction costs, minimum investment sizes, and market liquidity affect actual reinvestment rates. An investor receiving a small coupon payment may not be able to reinvest at the quoted market rate due to bid-ask spreads or minimum trade sizes.
Despite these limitations, duration and convexity remain foundational risk measures. They provide a common language for discussing interest rate exposure, enable rapid approximate calculations for portfolio analysis, and form the building blocks for more sophisticated risk management systems. Practitioners who understand both the power and the limitations of these tools can apply them effectively while recognizing situations that require additional analysis.
Summary
This chapter developed the core quantitative tools for measuring and managing bond interest rate risk:
Duration measures a bond's sensitivity to interest rate changes. Macaulay duration represents the weighted average time to receive cash flows. Modified duration directly quantifies the percentage price change for a given yield change. For example, a bond with a modified duration of 7 will lose approximately 7% of its value for each 1% increase in yields.
Convexity adds the second-order price term omitted by duration. It improves the approximation in the worked examples, but finite-shock errors remain. For positive convexity the term is positive for a nonzero yield change and zero at the expansion point.
Portfolio measures aggregate individual bond durations and convexities using market-value weights. DV01 and dollar duration express risk in dollar terms useful for hedging.
Immunization against a single payment matches present value and Macaulay duration to the payment horizon under a consistent yield convention. It removes first-order exposure to the stated immediate persistent parallel shock in the fixed-cash-flow model, not every possible rate path or source of risk.
Liability-driven investing extends immunization concepts to manage complex streams of future obligations. The duration gap measures the mismatch between asset and liability sensitivities, guiding portfolio construction for pension funds and insurers.
Own-YTM duration and convexity are local price sensitivities, not assumptions that a benchmark yield curve moves in parallel. The common-yield portfolio and immunization scenarios here do not cover arbitrary curve-shape changes. These measures have other limitations, but they remain essential tools that every fixed income practitioner must master. More advanced techniques build upon this foundation by adding sensitivity to curve shape changes, credit spreads, and embedded options.
Key Parameters
The key parameters for bond risk measures and immunization are:
- Face Value: The contractual principal due at maturity. The examples use $1,000 per bond; denominations depend on the instrument.
- Coupon Rate: Annual coupon payment as a percentage of face value. At fixed maturity, yield and payment frequency, higher coupons shorten Macaulay duration when there is a coupon date strictly before maturity. If all remaining payments arrive at maturity, duration is unchanged.
- Years to Maturity: Time until principal is due. Moving cash flows later changes duration and convexity; compare the complete cash-flow schedules and yield conventions rather than relying on maturity alone.
- Yield to Maturity (y): The discount rate that equates the bond's price to the present value of its cash flows. For fixed nonnegative cash flows with at least one positive future payment and a positive discount base, higher yields reduce modified duration. Macaulay duration does not increase; it falls strictly when positive cash flows occur at more than one date.
- Frequency: Number of coupon payments per year. These examples use two, so both pricing and reinvestment use the corresponding semiannual periodic yield.
- Modified Duration: Measures percentage price sensitivity to yield changes. Used for hedging and risk management.
- Convexity: Measures the curvature of the price-yield relationship. Positive convexity benefits bondholders as it creates asymmetric price responses.
- Target Duration: For the single-liability flat-yield example, the horizon matched by portfolio Macaulay duration to remove first-order terminal-value yield exposure.
Quiz
Ready to test your understanding? Take this quick quiz to reinforce what you've learned about bond risk measures and immunization.
Bond Risk Measures and Immunization
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


Comments
Add to the conversation
A question, a correction, or an example from your own work.
No comments yet. Be the first to share your thoughts!