Markets: A Rigorous Course

Exercise · m11_bonds.py

Bond pricing, yield to maturity, duration and convexity

Read notes.md 11.1–11.4 first. Run: cd exercises && .venv/bin/python -m pytest modules/M11-macro-rates-fx/tests/test_m11_bonds.py

Conventions (used throughout):

  • face value F, annual coupon rate c (decimal), m coupons per year, maturity T years with N = round(m*T) remaining coupon dates, yield y quoted as an annual rate compounded m times a year.
  • Valuation is on a coupon date (no accrued interest), so cash flows fall at times t_k = k/m, k = 1..N, and CF_k = F*c/m (+ F at k = N).
  • P(y) = sum_k CF_k * (1 + y/m)^(-m t_k) (eq. 11.2) Use numpy only. Do not use scipy's root finders in yield_to_maturity.

Tests (9)

  • test_par_bond_prices_at_par
  • test_zero_coupon_closed_form
  • test_ytm_round_trip
  • test_par_bond_duration_closed_form
  • test_modified_duration_is_minus_dlogP_dy
  • test_convexity_matches_second_derivative
  • test_second_order_beats_first_order_and_error_scaling
  • test_dv01
  • test_gordon_equity_duration

Theory: M11 notes. Run with Ctrl/⌘+Enter.

not run yet

Not saved: sign up to keep your code
"""M11 exercise: bond pricing, yield to maturity, duration and convexity.

Read notes.md §11.1–11.4 first. Run:
    cd exercises && .venv/bin/python -m pytest modules/M11-macro-rates-fx/tests/test_m11_bonds.py

Conventions (used throughout):
  * face value F, annual coupon rate c (decimal), m coupons per year,
    maturity T years with N = round(m*T) remaining coupon dates,
    yield y quoted as an annual rate compounded m times a year.
  * Valuation is on a coupon date (no accrued interest), so cash flows fall
    at times t_k = k/m, k = 1..N, and CF_k = F*c/m (+ F at k = N).
  * P(y) = sum_k CF_k * (1 + y/m)^(-m t_k)                      (eq. 11.2)
Use numpy only. Do not use scipy's root finders in yield_to_maturity.
"""

from __future__ import annotations

import numpy as np


def cash_flows(face: float, coupon_rate: float, years: float, freq: int = 2) -> tuple[np.ndarray, np.ndarray]:
    """Return (t, cf): numpy arrays of payment times t_k = k/freq (years) and
    cash flows CF_k = face*coupon_rate/freq, with face added to the last one.
    N = round(years*freq); raise ValueError if N < 1."""
    raise NotImplementedError


def bond_price(face: float, coupon_rate: float, ytm: float, years: float, freq: int = 2) -> float:
    """Price on a coupon date, eq. (11.2): sum_k CF_k (1 + ytm/freq)^(-freq t_k)."""
    raise NotImplementedError


def yield_to_maturity(
    price: float,
    face: float,
    coupon_rate: float,
    years: float,
    freq: int = 2,
    tol: float = 1e-12,
    max_iter: int = 200,
) -> float:
    """Solve P(y) = price for y (Proposition 11.1: P is strictly decreasing and
    convex in y, so the root is unique).

    Method: bracket [lo, hi] with P(lo) > price > P(hi), then iterate
    Newton steps y <- y - (P(y)-price)/P'(y) with P'(y) = -P(y) D_mod(y),
    falling back to bisection whenever Newton leaves the bracket.
    Must be accurate to 1e-10 in yield."""
    raise NotImplementedError


def macaulay_duration(face: float, coupon_rate: float, ytm: float, years: float, freq: int = 2) -> float:
    """D_mac = sum_k t_k PV(CF_k) / P, in years (eq. 11.4)."""
    raise NotImplementedError


def modified_duration(face: float, coupon_rate: float, ytm: float, years: float, freq: int = 2) -> float:
    """D_mod = -(1/P) dP/dy = D_mac / (1 + ytm/freq) (Proposition 11.2)."""
    raise NotImplementedError


def convexity(face: float, coupon_rate: float, ytm: float, years: float, freq: int = 2) -> float:
    """C = (1/P) d^2P/dy^2
         = (1/P) sum_k t_k (t_k + 1/freq) CF_k (1 + ytm/freq)^(-freq t_k - 2)   (eq. 11.8)."""
    raise NotImplementedError


def dv01(face: float, coupon_rate: float, ytm: float, years: float, freq: int = 2) -> float:
    """First-order price fall for a +1 bp yield move: P * D_mod * 1e-4 (a positive number)."""
    raise NotImplementedError


def approx_price_change(price: float, mod_dur: float, conv: float, dy: float, order: int = 2) -> float:
    """Taylor approximation, eq. (11.9):
        order=1: P * (-D_mod dy)
        order=2: P * (-D_mod dy + 0.5 C dy^2)
    Raise ValueError for any other order."""
    raise NotImplementedError


def gordon_price(d1: float, r: float, g: float) -> float:
    """Gordon growth value D1/(r - g). Raise ValueError unless r > g."""
    raise NotImplementedError


def gordon_equity_duration(r: float, g: float, kind: str = "modified") -> float:
    """Duration of a Gordon-growth equity (Proposition 11.5).
        kind='modified': -(1/P) dP/dr = 1/(r - g)
        kind='macaulay': (1 + r)/(r - g)  (annual discounting, first dividend at t=1)
    Raise ValueError unless r > g, or for an unknown kind."""
    raise NotImplementedError

No runs yet

The stub's functions raise NotImplementedError until you write them. Fill in one, press Run tests, and watch its tests turn green.

Reference solution

Available after your first run, with an account approved for code runs.

Create a free account

Reading is open to everyone. An account keeps your progress, answers, notes and code drafts, and gives you the study plan, the journals and the simulator. Running code needs an account approved for it.