Markets: A Rigorous Course

Exercise · m06b_costs.py

What a fund costs: fee drag, direct versus regular SIPs, transaction charges, ETF versus index fund

Exercise stubs. Replace each raise NotImplementedError with your code, then run the tests in ../tests. See notes.md 6B.3: Proposition 6B.3.1 on fee drag for a lump sum (6B.7) with its bounds (6B.8), Proposition 6B.3.2 for a SIP (6B.9), Worked examples 6B.3 (direct versus regular over 20 years) and 6B.4 (stamp duty, exit load and STT on one transaction), and costs outside the TER; and 6B.9.4, Proposition 6B.9.2 (the ETF-versus-index-fund crossover horizon, (6B.27)).

Conventions#

  • r : gross annual return of the portfolio (before the expense ratio), a fraction.
  • c : annual cost (expense ratio), a fraction. One year multiplies wealth by 1 + r − c.
  • Monthly SIPs: the monthly growth factor is g = (1 + r − c)^(1/12); an instalment is invested at the start of each month and the corpus is valued at the end of the last month.
  • One-off costs k (exit load, stamp duty, the bid–ask spread and brokerage on an ETF round trip) are fractions of the amount invested.

Tests (8)

  • test_worked_example_6b3_lump_sum
  • test_bounds_hold_generally
  • test_sip_corpus_closed_form_matches_loop
  • test_worked_example_6b3_sip
  • test_worked_example_6b4_one_transaction
  • test_trading_cost_outside_ter
  • test_crossover_worked_example_6b13
  • test_crossover_by_brute_force

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

not run yet

Not saved: sign up to keep your code
"""M06B · What a fund costs: fee drag, direct versus regular SIPs, transaction charges, ETF versus index fund.

Exercise stubs. Replace each ``raise NotImplementedError`` with your code, then run the tests in
../tests. See notes.md §6B.3: Proposition 6B.3.1 on fee drag for a lump sum (6B.7) with its bounds
(6B.8), Proposition 6B.3.2 for a SIP (6B.9), Worked examples 6B.3 (direct versus regular over 20 years)
and 6B.4 (stamp duty, exit load and STT on one transaction), and costs outside the TER; and §6B.9.4,
Proposition 6B.9.2 (the ETF-versus-index-fund crossover horizon, (6B.27)).

Conventions
-----------
* ``r``  : gross annual return of the portfolio (before the expense ratio), a fraction.
* ``c``  : annual cost (expense ratio), a fraction. One year multiplies wealth by 1 + r − c.
* Monthly SIPs: the monthly growth factor is g = (1 + r − c)^(1/12); an instalment is invested at the
  start of each month and the corpus is valued at the end of the last month.
* One-off costs ``k`` (exit load, stamp duty, the bid–ask spread and brokerage on an ETF round trip)
  are fractions of the amount invested.
"""

from __future__ import annotations

import math


def lump_sum_value(amount: float, r: float, c: float, years: float) -> float:
    """Terminal value of a lump sum: amount · (1 + r − c)^T."""
    raise NotImplementedError


def terminal_wealth_ratio(r: float, c1: float, c2: float, years: float) -> float:
    """ρ_T = ((1 + r − c1)/(1 + r − c2))^T (6B.7): terminal wealth with cost c1 over wealth with cost c2."""
    raise NotImplementedError


def fee_drag_bounds(r: float, c1: float, c2: float, years: float) -> tuple[float, float]:
    """Bounds (6B.8) for c1 > c2, Δc = c1 − c2:
    (exp(−T Δc/(1 + r − c1)), exp(−T Δc/(1 + r − c2)))."""
    raise NotImplementedError


def fee_drag_continuous(c1: float, c2: float, years: float) -> float:
    """The continuous-time ratio exp(−Δc T), exact when wealth is W0 e^{(μ − c)T}. Applied to annual
    compounding it overstates the drag, because it ignores the 1 + r in (6B.8)."""
    raise NotImplementedError


def sip_corpus(monthly: float, r: float, c: float, years: int) -> float:
    """Corpus after ``years`` of instalments ``monthly`` at the start of each month:
    FV = P Σ_{m=1}^{n} g^m = P g (g^n − 1)/(g − 1), g = (1 + r − c)^(1/12), n = 12 · years (P n if g = 1)."""
    raise NotImplementedError


def sip_wealth_ratio(r: float, c1: float, c2: float, years: int) -> float:
    """FV_1/FV_2 for the same SIP under costs c1 and c2 (6B.9). For c1 > c2 it lies strictly between
    γ^n and γ, with γ = g_1/g_2 the monthly ratio and n = 12 · years (Proposition 6B.3.2)."""
    raise NotImplementedError


def sip_cost_in_rupees(monthly: float, r: float, c_direct: float, c_regular: float, years: int) -> float:
    """What the regular plan costs a SIP investor in rupees: FV(direct) − FV(regular)."""
    raise NotImplementedError


def units_allotted(amount: float, nav: float, stamp_duty: float = 0.00005) -> float:
    """Units allotted on a purchase after stamp duty: amount (1 − stamp_duty)/NAV (§6B.3.3)."""
    raise NotImplementedError


def redemption_proceeds(units: float, nav: float, exit_load: float = 0.0, stt: float = 0.0) -> float:
    """Proceeds of a redemption (§6B.3.3, Worked example 6B.4): gross value G = units · NAV, exit load
    exit_load · G (credited to the scheme), STT on the net redemption value G (1 − exit_load):
        proceeds = G (1 − exit_load)(1 − stt)."""
    raise NotImplementedError


def portfolio_trading_cost(turnover: float, one_way_cost: float) -> float:
    """Annual trading cost outside the TER: about 2 τ κ for turnover τ and one-way cost κ (§6B.3.3)."""
    raise NotImplementedError


def crossover_horizon(k_etf: float, c_etf: float, k_fund: float, c_fund: float, r: float | None = None) -> float:
    """Crossover horizon of Proposition 6B.9.2 (6B.27), with k_E = k_etf, c_E = c_etf, k_I = k_fund,
    c_I = c_fund:

    * with ``r``: T* = ln[(1 − k_I)/(1 − k_E)] / ln[(1 + r − c_E)/(1 + r − c_I)];
    * ``r is None``: the approximation T* ≈ (k_E − k_I)/(c_I − c_E).
    Return 0.0 if the ETF is no dearer up front and no dearer to hold (k_E <= k_I and c_E <= c_I);
    otherwise math.inf if c_E >= c_I (it never catches up). Never negative.
    """
    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.