Exercise · m06b_debt.py
Debt funds: duration and convexity of the NAV, portfolio yield, runs and segregated portfolios
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.