Markets: A Rigorous Course

Exercise · m06b_debt.py

Debt funds: duration and convexity of the NAV, portfolio yield, runs and segregated portfolios

Exercise stubs. Replace each raise NotImplementedError with your code, then run the tests in ../tests. See notes.md 6B.10: Proposition 6B.10.1 (the NAV response (6B.28) and the horizon return (6B.29), Worked example 6B.14), side pockets, and Proposition 6B.10.2 (dilution when the NAV ignores liquidation costs (6B.30), the redemption threshold (6B.31), Worked example 6B.15); swing pricing is Proposition 6B.2.2. Bond maths as in M11 · 11.2–11.3.

Conventions#

  • A bond is a list of cash flows cfs (₹) at times times (years). Yields are annual, compounded freq times a year: P(y) = Σ CF_j (1 + y/freq)^(−freq t_j).
  • Run model: a fund with one unit (NAV nav = v) holds a fraction cash_frac of its assets in cash and the rest in securities that sell at a proportional cost k below their marked value: to raise R it sells securities worth R/(1 − k). Redeemers are paid v(1 − swing) per unit; cash pays first.

Tests (10)

  • test_par_bond_and_zero
  • test_duration_and_convexity_match_finite_differences
  • test_second_order_nav_change_6b28
  • test_worked_example_6b14
  • test_portfolio_yield_and_duration
  • test_worked_example_6b15_dilution_and_threshold
  • test_dilution_increasing_convex_and_matches_ledger
  • test_swing_pricing_removes_dilution
  • test_sequential_run_conserves_value_and_pays_early_redeemers_more
  • test_segregation

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

not run yet

Not saved: sign up to keep your code
"""M06B · Debt funds: duration and convexity of the NAV, portfolio yield, runs and segregated portfolios.

Exercise stubs. Replace each ``raise NotImplementedError`` with your code, then run the tests in
../tests. See notes.md §6B.10: Proposition 6B.10.1 (the NAV response (6B.28) and the horizon return
(6B.29), Worked example 6B.14), side pockets, and Proposition 6B.10.2 (dilution when the NAV ignores
liquidation costs (6B.30), the redemption threshold (6B.31), Worked example 6B.15); swing pricing is
Proposition 6B.2.2. Bond maths as in M11 §11.2–§11.3.

Conventions
-----------
* A bond is a list of cash flows ``cfs`` (₹) at times ``times`` (years). Yields are annual, compounded
  ``freq`` times a year: P(y) = Σ CF_j (1 + y/freq)^(−freq t_j).
* Run model: a fund with one unit (NAV ``nav`` = v) holds a fraction ``cash_frac`` of its assets in cash
  and the rest in securities that sell at a proportional cost k below their marked value: to raise R it
  sells securities worth R/(1 − k). Redeemers are paid v(1 − swing) per unit; cash pays first.
"""

from __future__ import annotations

import numpy as np
from scipy.optimize import brentq


def bond_price(cfs, times, y: float, freq: int = 1) -> float:
    """P = Σ CF_j (1 + y/freq)^(−freq t_j)."""
    raise NotImplementedError


def macaulay_duration(cfs, times, y: float, freq: int = 1) -> float:
    """D_mac = Σ t_j PV_j / P."""
    raise NotImplementedError


def modified_duration(cfs, times, y: float, freq: int = 1) -> float:
    """D = D_mac/(1 + y/freq) = −(1/P) dP/dy."""
    raise NotImplementedError


def convexity(cfs, times, y: float, freq: int = 1) -> float:
    """C = (1/P) d²P/dy² = (1/P) Σ CF_j t_j (t_j + 1/freq)(1 + y/freq)^(−freq t_j − 2)."""
    raise NotImplementedError


def portfolio_average(market_values, values) -> float:
    """Value-weighted average Σ w_j x_j, w_j = MV_j/Σ MV: D_p and C_p in (6B.28), or the 'portfolio YTM'
    that factsheets show (an approximation to the true portfolio yield)."""
    raise NotImplementedError


def portfolio_irr(bonds, holdings, prices, freq: int = 1) -> float:
    """Portfolio YTM as the notes define it: the internal rate of return of the combined cash flows, the
    y with Σ_i holdings_i P_i(y) = Σ_i holdings_i prices_i. ``bonds`` is a list of (cfs, times). Solve
    with brentq on [−0.5, 1]."""
    raise NotImplementedError


def nav_change_approx(d_mod: float, conv: float, dy: float) -> float:
    """ΔNAV/NAV ≈ −D_p Δy + ½ C_p (Δy)² (6B.28)."""
    raise NotImplementedError


def horizon_return(ytm: float, ter: float, d_mod: float, conv: float, dy: float, h: float = 1.0) -> float:
    """R_h ≈ (YTM − c) h − D_p Δy + ½ C_p (Δy)² (6B.29), with the shift Δy at the start of the horizon."""
    raise NotImplementedError


def breakeven_yield_rise(ytm: float, ter: float, d_mod: float) -> float:
    """Yield rise that wipes out one year's carry, ignoring convexity: Δy* = (YTM − c)/D_p."""
    raise NotImplementedError


def dilution(rho: float, k: float) -> float:
    """D(ρ) = ρ k / ((1 − k)(1 − ρ)) (6B.30): the stayers' loss as a fraction of their value when a
    fraction ρ of units is redeemed at the fair NAV and the fund holds no cash. Raise ValueError unless
    0 ≤ ρ < 1 − k."""
    raise NotImplementedError


def run_threshold(b: float, k: float) -> float:
    """ρ* = b (1 − k) / (k + b (1 − k)) (6B.31): an investor whose benefit from staying is b prefers to
    redeem when she expects more than ρ* of the units to be redeemed."""
    raise NotImplementedError


def stayer_nav_after_redemptions(nav: float, rho: float, k: float, cash_frac: float = 0.0,
                                 swing: float = 0.0) -> float:
    """NAV per unit of the stayers after a fraction ρ of the single unit is redeemed.

    Redeemers get p = nav (1 − swing) each, R = ρ p in total. Cash (cash_frac · nav) pays first; the
    shortfall S = max(0, R − cash_frac · nav) is raised by selling securities worth S/(1 − k), a loss of
    S k/(1 − k). Return (nav − R − S k/(1 − k))/(1 − ρ). With no cash and no swing this is
    nav (1 − D(ρ)) from (6B.30); with swing = k and no cash it is nav. Raise ValueError unless 0 ≤ ρ < 1.
    """
    raise NotImplementedError


def sequential_run(nav: float, tranches, k: float, cash_frac: float = 0.0) -> tuple[np.ndarray, float]:
    """Redemptions arrive in tranches (fractions of the original single unit, summing to < 1). Each
    tranche is paid the NAV v = (cash + marked securities)/units struck just before it: the NAV marks the
    remaining securities at full value (it ignores the cost of selling them) but reflects losses already
    realised. The payment u · v comes from cash first; any shortfall S is raised by selling securities of
    marked value S/(1 − k).

    Return (payout per unit to each tranche as an array, final NAV of the stayers). Payouts fall from
    tranche to tranche once cash runs out: early redeemers are paid more.
    """
    raise NotImplementedError


def segregate(total_assets: float, impaired_value: float, units: float) -> tuple[float, float]:
    """Segregated portfolio (side pocket, §6B.10.1): main NAV (V − X)/N and segregated NAV X/N, where X is
    the impaired paper's current value; each holder on the record date gets one segregated unit per unit."""
    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.