Learn Without Walls

Module 10: Interest Rates & Time Value of Money

Why a dollar today beats a dollar tomorrow, and the mathematics that proves it

Part II of 5 Module 10 of 22

1. The Time Value of Money: Why a Dollar Today Beats a Dollar Tomorrow

The most fundamental concept in all of finance is deceptively simple: a dollar today is worth more than a dollar in the future. This is not an opinion — it follows from three independent economic principles:

Stats Bridge

In statistics, we discount future observations when they carry more uncertainty. Exponential smoothing assigns exponentially declining weights to older observations: wt-k = α(1−α)k. The time value of money works the same way — future cash flows are discounted by (1+r)−k. Both are exponential decay functions, reflecting the idea that distant things (observations or cash flows) should count for less.

1.1 Present Value and Future Value

The two fundamental formulas relate present value (PV) and future value (FV):

FV = PV × (1 + r)n        PV = FV / (1 + r)n = FV × (1 + r)−n

where r is the interest rate per period and n is the number of periods.

Finance Term

Discount factor: The quantity d(n) = (1 + r)−n is called the discount factor. It tells you how much $1 received n periods from now is worth today. At r = 5%, the discount factor for 10 years is 0.6139 — a dollar in 10 years is worth about 61 cents today.

1.2 The Discount Factor Curve

Year Discount Factor (r=3%) Discount Factor (r=5%) Discount Factor (r=8%) Discount Factor (r=10%)
01.00001.00001.00001.0000
10.97090.95240.92590.9091
20.94260.90700.85730.8264
50.86260.78350.68060.6209
100.74410.61390.46320.3855
200.55370.37690.21450.1486
300.41200.23140.09940.0573
Pythonimport numpy as np
import matplotlib.pyplot as plt

def discount_factor(r, n):
    """Compute the discount factor for rate r and n periods."""
    return (1 + r) ** (-n)

def present_value(fv, r, n):
    """Compute present value of a future cash flow."""
    return fv * discount_factor(r, n)

def future_value(pv, r, n):
    """Compute future value of a present amount."""
    return pv * (1 + r) ** n

# ── Plot discount factor curves for different rates ───────
years = np.arange(0, 31)
rates = [0.02, 0.03, 0.05, 0.08, 0.10]
colors = ['#38a169', '#3182ce', '#1a365d', '#d69e2e', '#e53e3e']

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

# Discount factors
for r, color in zip(rates, colors):
    df_values = [discount_factor(r, n) for n in years]
    ax1.plot(years, df_values, color=color, linewidth=2,
             label=f'r = {r*100:.0f}%')

ax1.set_xlabel("Years")
ax1.set_ylabel("Discount Factor")
ax1.set_title("Discount Factor Curves")
ax1.legend()
ax1.grid(True, alpha=0.3)
ax1.set_ylim(0, 1.05)

# Future value of $1
for r, color in zip(rates, colors):
    fv_values = [future_value(1, r, n) for n in years]
    ax2.plot(years, fv_values, color=color, linewidth=2,
             label=f'r = {r*100:.0f}%')

ax2.set_xlabel("Years")
ax2.set_ylabel("Future Value of $1")
ax2.set_title("Growth of $1 at Different Rates")
ax2.legend()
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("time_value_of_money.png", dpi=150, bbox_inches='tight')
plt.show()

# Example calculations
print("Present Value Examples:")
print(f"  $1,000 in 5 years at 5%:  ${present_value(1000, 0.05, 5):>10.2f}")
print(f"  $1,000 in 10 years at 5%: ${present_value(1000, 0.05, 10):>10.2f}")
print(f"  $1,000 in 30 years at 5%: ${present_value(1000, 0.05, 30):>10.2f}")
print(f"\nFuture Value Examples:")
print(f"  $1,000 for 5 years at 5%:  ${future_value(1000, 0.05, 5):>10.2f}")
print(f"  $1,000 for 10 years at 5%: ${future_value(1000, 0.05, 10):>10.2f}")
print(f"  $1,000 for 30 years at 5%: ${future_value(1000, 0.05, 30):>10.2f}")

2. Compound Interest as Geometric Growth

Compound interest is the mechanism by which money grows exponentially. The key distinction is between simple interest (linear growth) and compound interest (geometric/exponential growth):

Simple:   FV = PV × (1 + r × n)        Compound:   FV = PV × (1 + r)n

2.1 The Frequency of Compounding

If interest is compounded m times per year at annual rate r, the future value after n years is:

FV = PV × (1 + r/m)m×n

As m → ∞, we get continuous compounding:

FV = PV × er×n        PV = FV × e−r×n
Stats Bridge

Continuously compounded returns are log returns: rcc = ln(Pt/Pt-1). This is why quantitative finance and time series analysis use log returns — they are additive over time (ln(PT/P0) = ∑ rt), and they connect directly to the geometric Brownian motion model: dS/S = μ dt + σ dW. The log return is the increment of this diffusion process.

Compounding Frequency m FV of $1,000 at 10% for 1 Year Effective Annual Rate
Annual1$1,100.0010.000%
Semi-annual2$1,102.5010.250%
Quarterly4$1,103.8110.381%
Monthly12$1,104.7110.471%
Daily365$1,105.1610.516%
Continuous$1,105.1710.517%

2.2 The Rule of 72

A practical approximation: the number of years to double your money is approximately 72/r, where r is the annual rate in percent.

ndouble ≈ 72 / (r × 100) = ln(2) / ln(1+r) ≈ 0.693 / r
Python# Compare simple vs compound interest over time
years = np.arange(0, 41)
principal = 10_000
rate = 0.07

simple_growth = principal * (1 + rate * years)
compound_growth = principal * (1 + rate) ** years
continuous_growth = principal * np.exp(rate * years)

fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(years, simple_growth / 1000, '--', color='#718096',
        linewidth=2, label='Simple Interest')
ax.plot(years, compound_growth / 1000, '-', color='#1a365d',
        linewidth=2.5, label='Annual Compounding')
ax.plot(years, continuous_growth / 1000, '-', color='#e53e3e',
        linewidth=2, label='Continuous Compounding')

# Annotate the gap
gap_year = 30
gap = compound_growth[gap_year] - simple_growth[gap_year]
ax.annotate(
    f'Compounding effect:\n${gap:,.0f} extra',
    xy=(gap_year, compound_growth[gap_year]/1000),
    xytext=(gap_year - 10, compound_growth[gap_year]/1000 + 10),
    fontsize=10, ha='center',
    arrowprops=dict(arrowstyle='->', color='#1a365d')
)

ax.set_xlabel("Years")
ax.set_ylabel("Portfolio Value ($thousands)")
ax.set_title(f"$10,000 at {rate*100:.0f}%: Simple vs Compound Interest")
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("compound_interest.png", dpi=150, bbox_inches='tight')
plt.show()

# Rule of 72 accuracy
print("Rule of 72 vs Exact Doubling Time:")
print(f"{'Rate':>6s} {'Rule of 72':>12s} {'Exact':>10s} {'Error':>8s}")
print(f"{'-'*38}")
for r in [0.02, 0.04, 0.06, 0.08, 0.10, 0.12, 0.15, 0.20]:
    rule72 = 72 / (r * 100)
    exact = np.log(2) / np.log(1 + r)
    err = abs(rule72 - exact) / exact * 100
    print(f"{r*100:>5.0f}% {rule72:>12.1f} {exact:>10.2f} {err:>7.1f}%")
Key Insight

Compound interest is the most powerful force in personal finance. At 7% annual return, $10,000 becomes $76,123 in 30 years — nearly 8x growth. At 10%, it becomes $174,494. The difference between 7% and 10% over 30 years is a factor of 2.3x. This is why seemingly small differences in fees (e.g., 0.03% vs 1.0% expense ratio) matter enormously over long horizons.

3. Net Present Value and Discounted Cash Flows

The net present value (NPV) of a stream of cash flows C0, C1, …, CN is the sum of their discounted values:

NPV = ∑t=0N Ct / (1 + r)t = C0 + C1/(1+r) + C2/(1+r)2 + …
Finance Term

NPV Rule: Invest if NPV > 0 (the project creates value). Reject if NPV < 0 (the project destroys value). This is the single most important decision rule in corporate finance.

3.1 Special Cash Flow Patterns

Annuity: Equal payments for n periods

PVannuity = C × [1 − (1+r)−n] / r

Perpetuity: Equal payments forever

PVperpetuity = C / r

Growing Perpetuity: Payments growing at rate g forever

PVgrowing perp. = C / (r − g)     (requires r > g)
Stats Bridge

The perpetuity formula PV = C/r is the financial version of the geometric series sum: ∑k=1 xk = x/(1−x) for |x| < 1. Setting x = 1/(1+r), the infinite sum ∑ C/(1+r)k = C/r. The growing perpetuity is the sum of a geometric series with ratio (1+g)/(1+r). These are convergent series because r > 0 (or r > g), guaranteeing the discount factor decays faster than cash flows grow.

Pythondef npv(cashflows, rate):
    """Compute NPV of a stream of cash flows."""
    return sum(cf / (1 + rate)**t for t, cf in enumerate(cashflows))

def pv_annuity(payment, rate, n_periods):
    """PV of an ordinary annuity."""
    return payment * (1 - (1 + rate)**(-n_periods)) / rate

def pv_perpetuity(payment, rate):
    """PV of a perpetuity."""
    return payment / rate

def pv_growing_perpetuity(payment, rate, growth):
    """PV of a growing perpetuity (Gordon Growth Model)."""
    assert rate > growth, "Rate must exceed growth for convergence"
    return payment / (rate - growth)

# ── Example: Mortgage calculation ─────────────────────────
home_price = 500_000
down_payment = 100_000
loan_amount = home_price - down_payment
annual_rate = 0.065
monthly_rate = annual_rate / 12
n_months = 30 * 12  # 30-year mortgage

# Monthly payment (solving annuity formula for C)
monthly_payment = loan_amount * monthly_rate / (1 - (1 + monthly_rate)**(-n_months))

# Total paid over life of loan
total_paid = monthly_payment * n_months
total_interest = total_paid - loan_amount

print(f"Mortgage Analysis:")
print(f"  Home price:       ${home_price:>12,.2f}")
print(f"  Down payment:     ${down_payment:>12,.2f}")
print(f"  Loan amount:      ${loan_amount:>12,.2f}")
print(f"  Rate:             {annual_rate*100:>11.2f}%")
print(f"  Term:             {'30 years':>12s}")
print(f"  Monthly payment:  ${monthly_payment:>12,.2f}")
print(f"  Total paid:       ${total_paid:>12,.2f}")
print(f"  Total interest:   ${total_interest:>12,.2f}")
print(f"  Interest/principal ratio: {total_interest/loan_amount:.2f}x")

# ── NPV of a project ─────────────────────────────────────
# Initial investment of $1M, then cash flows of $300K/yr for 5 years
project_cashflows = [-1_000_000, 300_000, 300_000, 300_000, 300_000, 300_000]

print(f"\nProject NPV at different discount rates:")
print(f"{'Rate':>6s} {'NPV':>14s} {'Decision':>10s}")
print(f"{'-'*32}")
for r in [0.05, 0.08, 0.10, 0.12, 0.15, 0.20]:
    project_npv = npv(project_cashflows, r)
    decision = "INVEST" if project_npv > 0 else "REJECT"
    print(f"{r*100:>5.0f}% ${project_npv:>13,.0f} {decision:>10s}")

4. The Yield Curve: The Term Structure of Interest Rates

Interest rates are not a single number. A 1-year Treasury bond, a 10-year Treasury bond, and a 30-year Treasury bond each offer different yields. The yield curve plots these yields as a function of maturity, giving us the term structure of interest rates.

Stats Bridge

The yield curve is a functional data object — a curve observed at discrete maturities that represents an underlying continuous function y(m). You can think of it as a nonparametric regression of yield on maturity. Methods from functional data analysis (B-spline smoothing, principal component decomposition of curve shapes) are directly applicable. Nelson-Siegel and Svensson models are parametric specifications commonly used to fit the curve.

Finance Term

Yield curve: The relationship between bond yields and their time to maturity. The “risk-free” yield curve is typically constructed from U.S. Treasury securities (T-bills, T-notes, T-bonds) because they are considered free of default risk.

4.1 Yield Curve Shapes

Shape Description Historical Signal
Normal (upward-sloping) Long rates > short rates Economy expanding normally; investors demand a term premium for longer maturities
Flat Long rates ≈ short rates Transition period; uncertainty about future growth
Inverted (downward-sloping) Long rates < short rates Markets expect rate cuts (economic slowdown); historically predicts recessions
Humped Mid-term rates highest Mixed expectations; possible transition to inversion
Key Insight

An inverted yield curve (specifically, when the 10-year yield falls below the 2-year yield) has preceded every U.S. recession since 1955, with only one false positive (1966). The lag between inversion and recession onset averages about 12–18 months. This is one of the most reliable economic indicators known, though the mechanism (is it causal or merely predictive?) remains debated.

Pythonimport numpy as np
import matplotlib.pyplot as plt

# ── Construct example yield curves ────────────────────────
maturities = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 20, 30])
mat_labels = ['3M', '6M', '1Y', '2Y', '3Y', '5Y', '7Y', '10Y', '20Y', '30Y']

# Normal yield curve (typical expansion)
normal_yields = np.array([4.50, 4.55, 4.50, 4.40, 4.35, 4.30,
                          4.35, 4.40, 4.60, 4.65])

# Inverted yield curve (pre-recession)
inverted_yields = np.array([5.35, 5.30, 5.10, 4.85, 4.65, 4.40,
                            4.30, 4.20, 4.30, 4.35])

# Flat yield curve (transition)
flat_yields = np.array([4.50, 4.50, 4.50, 4.48, 4.47, 4.45,
                        4.45, 4.48, 4.50, 4.52])

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(maturities, normal_yields, 'o-', color='#38a169',
        linewidth=2.5, markersize=6, label='Normal (Expansion)')
ax.plot(maturities, inverted_yields, 's-', color='#e53e3e',
        linewidth=2.5, markersize=6, label='Inverted (Pre-Recession)')
ax.plot(maturities, flat_yields, '^-', color='#d69e2e',
        linewidth=2.5, markersize=6, label='Flat (Transition)')

ax.set_xlabel("Maturity (Years)")
ax.set_ylabel("Yield (%)")
ax.set_title("Yield Curve Shapes")
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
ax.set_xticks(maturities)
ax.set_xticklabels(mat_labels, fontsize=8)

plt.tight_layout()
plt.savefig("yield_curves.png", dpi=150, bbox_inches='tight')
plt.show()

4.2 Spot Rates, Forward Rates, and the Bootstrap

The yield curve can be expressed in several equivalent forms:

(1 + sn)n = (1 + sk)k × (1 + fk,n)n−k

where sn is the n-year spot rate and fk,n is the forward rate from year k to year n.

Stats Bridge

Extracting spot rates from coupon bond prices is called bootstrapping the yield curve — a term that should ring a bell. In statistics, bootstrapping resamples data to estimate sampling distributions. In fixed income, bootstrapping recursively solves for zero-coupon rates from observed coupon bond prices. Different word, similar recursive/iterative logic.

Pythondef bootstrap_spot_rates(par_yields, maturities):
    """
    Bootstrap spot rates from par yields.
    Par yield = coupon rate at which a bond trades at par.
    """
    spot_rates = np.zeros(len(maturities))

    for i, (mat, par_y) in enumerate(zip(maturities, par_yields)):
        if i == 0:
            # First rate: no bootstrapping needed
            spot_rates[i] = par_y
        else:
            # Solve for spot rate at this maturity
            # Par bond: 100 = sum(c/(1+s_t)^t) + (100+c)/(1+s_n)^n
            coupon = par_y * 100  # annual coupon payment
            pv_coupons = sum(
                coupon / (1 + spot_rates[j])**maturities[j]
                for j in range(i)
            )
            # (100 + coupon) / (1 + s_n)^n = 100 - pv_coupons
            remaining = 100 - pv_coupons
            spot_rates[i] = ((100 + coupon) / remaining)**(1/mat) - 1

    return spot_rates

def forward_rate(spot_rates, maturities, t1_idx, t2_idx):
    """Compute the forward rate between two maturities."""
    s1 = spot_rates[t1_idx]
    s2 = spot_rates[t2_idx]
    t1 = maturities[t1_idx]
    t2 = maturities[t2_idx]
    return ((1 + s2)**t2 / (1 + s1)**t1)**(1/(t2 - t1)) - 1

# Example: bootstrap from par yields
par_yields_example = np.array([0.045, 0.046, 0.045, 0.044, 0.043,
                                0.043, 0.044, 0.044, 0.046, 0.047])
maturities_example = np.array([0.25, 0.5, 1, 2, 3, 5, 7, 10, 20, 30])

spot_rates = bootstrap_spot_rates(par_yields_example, maturities_example)

print("Bootstrapped Spot Rates:")
print(f"{'Maturity':>10s} {'Par Yield':>10s} {'Spot Rate':>10s}")
print(f"{'-'*32}")
for mat, par, spot in zip(maturities_example, par_yields_example, spot_rates):
    print(f"{mat:>10.2f} {par*100:>9.3f}% {spot*100:>9.3f}%")

# Compute some forward rates
print(f"\nImplied Forward Rates:")
pairs = [(2, 5), (3, 7), (5, 9)]  # indices into maturities array
for i1, i2 in pairs:
    fwd = forward_rate(spot_rates, maturities_example, i1, i2)
    m1, m2 = maturities_example[i1], maturities_example[i2]
    print(f"  f({m1:.0f}Y, {m2:.0f}Y) = {fwd*100:.3f}%")

5. Duration: The First Derivative of Bond Price w.r.t. Yield

When interest rates change, bond prices move in the opposite direction. Duration measures how sensitive a bond's price is to changes in yield. It is, precisely, the first derivative of the price-yield relationship, normalized by price.

Finance Term

Macaulay Duration: The weighted average time to receive a bond's cash flows, where the weights are the present values of each cash flow. Measured in years.
Modified Duration: Macaulay Duration / (1 + y). This directly measures the percentage price change per unit change in yield.

DMac = (1/P) ∑t=1N t ⋅ CFt / (1+y)t

DMod = DMac / (1 + y)

ΔP/P ≈ −DMod × Δy
Stats Bridge

Macaulay duration is a weighted mean — the expected value of the time variable T, where T is distributed according to the present-value weights of each cash flow. It's literally E[T] where T has the PV-weighted distribution. Modified duration is the elasticity of price with respect to (1+y), or equivalently, the first-order Taylor coefficient in the expansion of P(y) around the current yield.

5.1 Duration Intuition

Bond Type Approximate Duration Price Change if Rates Rise 1%
Money market / T-bill 0.25 years -0.25%
2-year Treasury note 1.9 years -1.9%
10-year Treasury note (4% coupon) 8.3 years -8.3%
30-year Treasury bond (4% coupon) 17.5 years -17.5%
30-year zero-coupon bond 30.0 years -30.0%
Pythondef bond_price(face, coupon_rate, ytm, n_years, freq=2):
    """
    Price a coupon bond.
    freq: coupon frequency (2 = semi-annual, 1 = annual)
    """
    periods = int(n_years * freq)
    coupon = face * coupon_rate / freq
    y = ytm / freq

    pv_coupons = sum(coupon / (1 + y)**t for t in range(1, periods + 1))
    pv_face = face / (1 + y)**periods

    return pv_coupons + pv_face

def macaulay_duration(face, coupon_rate, ytm, n_years, freq=2):
    """Compute Macaulay duration of a coupon bond."""
    periods = int(n_years * freq)
    coupon = face * coupon_rate / freq
    y = ytm / freq
    price = bond_price(face, coupon_rate, ytm, n_years, freq)

    weighted_sum = 0
    for t in range(1, periods + 1):
        if t < periods:
            cf = coupon
        else:
            cf = coupon + face
        weighted_sum += (t / freq) * cf / (1 + y)**t

    return weighted_sum / price

def modified_duration(face, coupon_rate, ytm, n_years, freq=2):
    """Modified duration = Macaulay duration / (1 + y/freq)."""
    mac_dur = macaulay_duration(face, coupon_rate, ytm, n_years, freq)
    return mac_dur / (1 + ytm / freq)

# ── Example: 10-year Treasury ─────────────────────────────
face = 1000
coupon = 0.04  # 4% coupon
ytm = 0.045    # 4.5% yield
maturity = 10

price = bond_price(face, coupon, ytm, maturity)
mac_dur = macaulay_duration(face, coupon, ytm, maturity)
mod_dur = modified_duration(face, coupon, ytm, maturity)

print(f"10-Year Bond Analysis (4% coupon, 4.5% YTM):")
print(f"  Price:              ${price:>10.2f}")
print(f"  Macaulay Duration:  {mac_dur:>10.4f} years")
print(f"  Modified Duration:  {mod_dur:>10.4f}")
print(f"  If yields rise 50bp: ~{-mod_dur*0.005*100:.2f}% price change")
print(f"                       ~${-mod_dur*0.005*price:.2f}")

# ── Duration for different coupons and maturities ─────────
print(f"\nDuration Table (Modified Duration, YTM = 4.5%):")
print(f"{'Maturity':>10s}", end='')
for c in [0.00, 0.02, 0.04, 0.06, 0.08]:
    print(f" {'c='+str(int(c*100))+'%':>8s}", end='')
print()
print(f"{'-'*52}")

for m in [1, 2, 3, 5, 7, 10, 20, 30]:
    print(f"{str(m)+'Y':>10s}", end='')
    for c in [0.00, 0.02, 0.04, 0.06, 0.08]:
        if c == 0 and m > 0:
            # Zero coupon: duration = maturity / (1 + y/freq)
            d = m / (1 + ytm/2)
        else:
            d = modified_duration(face, c, ytm, m)
        print(f" {d:>8.2f}", end='')
    print()
Common Pitfall

Duration is a linear approximation. It works well for small yield changes (< 50 basis points) but becomes inaccurate for large changes because the price-yield relationship is curved (convex), not linear. For large rate moves, you need the second-order correction: convexity.

6. Convexity: The Second Derivative (Curvature)

Convexity captures the curvature of the price-yield relationship. It is the second derivative of the bond price with respect to yield, normalized by price:

C = (1/P) ⋅ d2P/dy2 = (1/P) ∑t=1N t(t+1) ⋅ CFt / [(1+y)t+2 ⋅ freq2]

6.1 The Taylor Expansion of Bond Price

Combining duration and convexity gives us a second-order Taylor expansion of the price change:

ΔP/P ≈ −DMod ⋅ Δy + ½ ⋅ C ⋅ (Δy)2
Stats Bridge

This is exactly a second-order Taylor expansion of the function P(y) around the current yield y0. Duration is the first derivative (slope), convexity is the second derivative (curvature). If you've done a delta-method approximation for the variance of a transformed estimator, you've used the same Taylor expansion logic. The higher-order terms are called “Greeks” in options pricing — delta, gamma, theta — which are first, second, and time derivatives of the option price.

Pythondef convexity(face, coupon_rate, ytm, n_years, freq=2):
    """Compute convexity of a coupon bond."""
    periods = int(n_years * freq)
    coupon = face * coupon_rate / freq
    y = ytm / freq
    price = bond_price(face, coupon_rate, ytm, n_years, freq)

    conv_sum = 0
    for t in range(1, periods + 1):
        if t < periods:
            cf = coupon
        else:
            cf = coupon + face
        conv_sum += t * (t + 1) * cf / (1 + y)**(t + 2)

    return conv_sum / (price * freq**2)

def price_change_taylor(mod_dur, conv, delta_y):
    """
    Approximate price change using Taylor expansion.
    Returns (first-order, second-order) approximations.
    """
    first_order = -mod_dur * delta_y
    second_order = -mod_dur * delta_y + 0.5 * conv * delta_y**2
    return first_order, second_order

# ── Compute and compare approximations ────────────────────
face = 1000
coupon = 0.04
ytm = 0.045
maturity = 30  # 30-year bond for dramatic convexity

price_0 = bond_price(face, coupon, ytm, maturity)
mod_dur_val = modified_duration(face, coupon, ytm, maturity)
conv_val = convexity(face, coupon, ytm, maturity)

print(f"30-Year Bond (4% coupon, 4.5% YTM):")
print(f"  Price:              ${price_0:.2f}")
print(f"  Modified Duration:  {mod_dur_val:.4f}")
print(f"  Convexity:          {conv_val:.4f}")

# Compare approximations for different yield changes
yield_changes = [-0.02, -0.01, -0.005, 0.005, 0.01, 0.02]

print(f"\n{'Δy (bps)':>10s} {'Actual':>10s} {'Dur Only':>10s} "
      f"{'Dur+Conv':>10s} {'Dur Err':>10s} {'D+C Err':>10s}")
print(f"{'-'*62}")

for dy in yield_changes:
    # Actual price change
    actual_price = bond_price(face, coupon, ytm + dy, maturity)
    actual_pct = (actual_price - price_0) / price_0

    # Taylor approximations
    first, second = price_change_taylor(mod_dur_val, conv_val, dy)

    err1 = abs(first - actual_pct)
    err2 = abs(second - actual_pct)

    print(f"{dy*10000:>+10.0f} {actual_pct*100:>+10.2f}% {first*100:>+10.2f}% "
          f"{second*100:>+10.2f}% {err1*100:>10.3f}% {err2*100:>10.3f}%")

# ── Visualization: price-yield curve with Taylor approx ──
yields = np.linspace(0.01, 0.09, 200)
actual_prices = [bond_price(face, coupon, y, maturity) for y in yields]

# Taylor approximations centered at current yield
dy_range = yields - ytm
dur_approx = price_0 * (1 - mod_dur_val * dy_range)
dur_conv_approx = price_0 * (1 - mod_dur_val * dy_range
                             + 0.5 * conv_val * dy_range**2)

fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(yields*100, actual_prices, color='#1a365d',
        linewidth=2.5, label='Actual Price')
ax.plot(yields*100, dur_approx, '--', color='#e53e3e',
        linewidth=2, label='Duration Only (1st order)')
ax.plot(yields*100, dur_conv_approx, '--', color='#38a169',
        linewidth=2, label='Duration + Convexity (2nd order)')
ax.scatter([ytm*100], [price_0], s=100, color='black', zorder=10,
           label=f'Current (y={ytm*100}%)')

ax.set_xlabel("Yield (%)")
ax.set_ylabel("Bond Price ($)")
ax.set_title("Price-Yield Curve: Duration and Convexity Approximations\n"
             f"30-Year Bond, 4% Coupon")
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
ax.set_xlim(1, 9)

plt.tight_layout()
plt.savefig("duration_convexity.png", dpi=150, bbox_inches='tight')
plt.show()
Key Insight

Convexity is always positive for standard (option-free) bonds, and it is a desirable property. A bond with higher convexity benefits more when rates fall (price rises more) and suffers less when rates rise (price falls less) compared to a bond with the same duration but lower convexity. This asymmetry means investors are willing to pay a premium for convexity — it's like having a built-in insurance policy.

7. Building and Analyzing a Yield Curve in Python

Let's put it all together with a comprehensive yield curve analysis.

Pythonimport numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline
from scipy.optimize import minimize_scalar

# ── Current Treasury yields (example data) ────────────────
treasury_data = {
    '1M':  (1/12,  4.55),
    '3M':  (0.25,  4.50),
    '6M':  (0.5,   4.45),
    '1Y':  (1,     4.35),
    '2Y':  (2,     4.20),
    '3Y':  (3,     4.10),
    '5Y':  (5,     4.05),
    '7Y':  (7,     4.10),
    '10Y': (10,    4.15),
    '20Y': (20,    4.40),
    '30Y': (30,    4.45),
}

maturities = np.array([v[0] for v in treasury_data.values()])
yields = np.array([v[1] for v in treasury_data.values()]) / 100
labels = list(treasury_data.keys())

# ── Interpolate with cubic spline ─────────────────────────
cs = CubicSpline(maturities, yields)
mat_fine = np.linspace(maturities[0], maturities[-1], 500)
yield_fine = cs(mat_fine)

# ── Compute discount factors ──────────────────────────────
discount_factors = np.exp(-yields * maturities)  # continuous
discount_fine = np.exp(-yield_fine * mat_fine)

# ── Compute forward rates ─────────────────────────────────
# Instantaneous forward rate: f(t) = -d/dt [ln d(t)]
# = y(t) + t * y'(t)
yield_deriv = cs(mat_fine, 1)  # first derivative of yield curve
forward_fine = yield_fine + mat_fine * yield_deriv

# ── Comprehensive plot ────────────────────────────────────
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Panel 1: Yield curve
ax = axes[0, 0]
ax.plot(mat_fine, yield_fine * 100, color='#1a365d', linewidth=2)
ax.scatter(maturities, yields * 100, s=60, color='#e53e3e', zorder=5)
for m, y, l in zip(maturities, yields, labels):
    ax.annotate(l, (m, y*100), fontsize=7,
                textcoords="offset points", xytext=(0, 8), ha='center')
ax.set_xlabel("Maturity (Years)")
ax.set_ylabel("Yield (%)")
ax.set_title("Treasury Yield Curve")
ax.grid(True, alpha=0.3)

# Panel 2: Discount factor curve
ax = axes[0, 1]
ax.plot(mat_fine, discount_fine, color='#38a169', linewidth=2)
ax.scatter(maturities, discount_factors, s=40, color='#38a169', zorder=5)
ax.set_xlabel("Maturity (Years)")
ax.set_ylabel("Discount Factor")
ax.set_title("Discount Factor Curve")
ax.grid(True, alpha=0.3)

# Panel 3: Forward rate curve
ax = axes[1, 0]
ax.plot(mat_fine, forward_fine * 100, color='#d69e2e', linewidth=2,
        label='Forward rate')
ax.plot(mat_fine, yield_fine * 100, '--', color='#1a365d', linewidth=1.5,
        label='Spot rate', alpha=0.7)
ax.set_xlabel("Maturity (Years)")
ax.set_ylabel("Rate (%)")
ax.set_title("Spot vs Forward Rate Curves")
ax.legend()
ax.grid(True, alpha=0.3)

# Panel 4: Duration sensitivity
ax = axes[1, 1]
bond_mats = np.arange(1, 31)
durations = []
convexities = []
for m in bond_mats:
    ytm_m = float(cs(m))
    d = modified_duration(1000, 0.04, ytm_m, m)
    c = convexity(1000, 0.04, ytm_m, m)
    durations.append(d)
    convexities.append(c)

ax.plot(bond_mats, durations, 'o-', color='#1a365d',
        linewidth=2, markersize=4, label='Modified Duration')
ax_twin = ax.twinx()
ax_twin.plot(bond_mats, convexities, 's-', color='#e53e3e',
             linewidth=2, markersize=4, label='Convexity')
ax.set_xlabel("Bond Maturity (Years)")
ax.set_ylabel("Modified Duration (years)", color='#1a365d')
ax_twin.set_ylabel("Convexity", color='#e53e3e')
ax.set_title("Duration and Convexity vs Maturity\n(4% Coupon Bonds)")
ax.grid(True, alpha=0.3)

# Combined legend
lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax_twin.get_legend_handles_labels()
ax.legend(lines1 + lines2, labels1 + labels2, loc='upper left')

plt.tight_layout()
plt.savefig("yield_curve_analysis.png", dpi=150, bbox_inches='tight')
plt.show()

# ── Summary statistics ────────────────────────────────────
print("Yield Curve Summary:")
print(f"  Short end (3M):   {yields[1]*100:.2f}%")
print(f"  Long end (30Y):   {yields[-1]*100:.2f}%")
print(f"  Slope (10Y-2Y):   {(yields[8]-yields[4])*100:+.2f} bps")
print(f"  Slope (30Y-3M):   {(yields[-1]-yields[1])*100:+.2f} bps")

inverted = yields[8] < yields[4]
print(f"  10Y-2Y inverted?  {'YES - recession signal!' if inverted else 'No'}")

8. Real vs. Nominal Interest Rates

The nominal interest rate is the rate you observe in the market. The real interest rate adjusts for inflation — it measures the true increase in purchasing power.

(1 + rnominal) = (1 + rreal) × (1 + π)

Approximation:   rreal ≈ rnominal − π

where π is the inflation rate. The approximation is the Fisher equation.

Stats Bridge

The Fisher equation is a first-order approximation that drops the cross-term rreal×π. This is valid when both rates are small. At high inflation (like 50% or more in some countries), the approximation breaks down and you must use the exact formula. This is similar to how the delta method approximation for variance breaks down when the transformation is highly nonlinear.

8.1 TIPS: Treasury Inflation-Protected Securities

The difference between nominal Treasury yields and TIPS (inflation-protected) yields is called the breakeven inflation rate — it represents the market's expectation of future inflation.

Maturity Nominal Yield TIPS Yield (Real) Breakeven Inflation
5-Year4.05%1.80%2.25%
10-Year4.15%1.90%2.25%
30-Year4.45%2.10%2.35%
Key Insight

The breakeven inflation rate is the market's implied forecast of average inflation over the given horizon. When breakeven inflation is 2.25% for 10 years, the bond market is pricing in average annual inflation of 2.25% over the next decade. Comparing this to central bank targets (often 2%) and current inflation gives useful information about whether the market expects inflation to rise or fall.

9. Practical Applications for the Statistically Minded

9.1 Discounting in Statistical Decision Theory

The time value of money concept appears naturally in sequential decision problems. In multi-armed bandits with discounting, the objective is to maximize the expected discounted sum of rewards:

max E[∑t=0 γt Rt]

where γ = 1/(1+r) is the discount factor. The Gittins index, which solves this problem optimally, is essentially a present-value calculation of the expected future reward stream for each arm.

9.2 The Cost of Fees: A Present Value Analysis

Pythondef cost_of_fees(initial_investment, annual_return, years, fee_rate):
    """
    Compare wealth with and without fees over time.
    """
    net_return = annual_return - fee_rate
    wealth_no_fee = initial_investment * (1 + annual_return)**np.arange(years+1)
    wealth_with_fee = initial_investment * (1 + net_return)**np.arange(years+1)
    fee_cost = wealth_no_fee - wealth_with_fee
    return wealth_no_fee, wealth_with_fee, fee_cost

# Compare 0.03% (Vanguard) vs 1.0% (typical active fund) vs 2.0% (hedge fund)
years = 40
initial = 100_000
annual_return = 0.08

fee_rates = [0.0003, 0.005, 0.01, 0.02]
fee_labels = ['0.03% (Index)', '0.50% (Active)', '1.0% (Active)', '2.0% (Hedge Fund)']
colors = ['#38a169', '#3182ce', '#d69e2e', '#e53e3e']

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))

for fee, label, color in zip(fee_rates, fee_labels, colors):
    no_fee, with_fee, cost = cost_of_fees(initial, annual_return, years, fee)
    ax1.plot(range(years+1), with_fee/1000, color=color,
             linewidth=2, label=label)

ax1.set_xlabel("Years")
ax1.set_ylabel("Portfolio Value ($thousands)")
ax1.set_title(f"Growth of ${initial/1000:.0f}K at {annual_return*100:.0f}% "
              f"Gross Return")
ax1.legend(fontsize=9)
ax1.grid(True, alpha=0.3)

# Cumulative fee cost
for fee, label, color in zip(fee_rates, fee_labels, colors):
    no_fee, with_fee, cost = cost_of_fees(initial, annual_return, years, fee)
    ax2.plot(range(years+1), cost/1000, color=color,
             linewidth=2, label=label)

ax2.set_xlabel("Years")
ax2.set_ylabel("Cumulative Fee Cost ($thousands)")
ax2.set_title("Wealth Lost to Fees")
ax2.legend(fontsize=9)
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig("cost_of_fees.png", dpi=150, bbox_inches='tight')
plt.show()

# Summary table
print(f"Impact of Fees over {years} Years (${initial:,} at {annual_return*100}% gross)")
print(f"{'Fee':>20s} {'Final Wealth':>15s} {'Fee Cost':>12s} {'% Lost':>8s}")
print(f"{'-'*57}")
for fee, label in zip(fee_rates, fee_labels):
    no_fee, with_fee, cost = cost_of_fees(initial, annual_return, years, fee)
    pct_lost = cost[-1] / no_fee[-1] * 100
    print(f"{label:>20s} ${with_fee[-1]:>13,.0f} ${cost[-1]:>10,.0f} "
          f"{pct_lost:>7.1f}%")
Common Pitfall

A 1% annual fee sounds small, but over 40 years at 8% gross return, it consumes about 28% of your final wealth. A 2% fee consumes nearly half. The compound effect of fees is insidious because it's not just the fee itself — it's also the foregone compound growth on the fee payments. This is why low-cost index funds (0.03%) are so powerful: the difference between 0.03% and 1.0% over a career is hundreds of thousands of dollars.

10. Chapter Summary

The time value of money is the foundation upon which all of finance is built:

Stats Bridge

The mathematical toolkit for interest rates and time value of money is deeply connected to concepts you already know: geometric series (perpetuity formulas), exponential decay (discount factors), Taylor expansions (duration and convexity), functional data analysis (yield curves), and nonparametric curve estimation (spline interpolation of the term structure). The notation changes, but the mathematics is familiar territory.