Markets: A Rigorous Course

Exercise · m06b_sip_skill.py

SIPs and manager skill: rupee-cost averaging, lump sum versus SIP, alpha, luck and Berk–Green

Exercise stubs. Replace each raise NotImplementedError with your code, then run the tests in ../tests. See notes.md 6B.6 (Proposition 6B.6.1: average cost is the harmonic mean (6B.14)–(6B.15); Proposition 6B.6.2: averaging is not an edge over a lump sum (6B.16); Worked examples 6B.8–6B.9) and 6B.7 (the measures (6B.17)–(6B.20), luck versus skill (6B.21), Proposition 6B.7.1: Berk–Green (6B.22)–(6B.23), Worked example 6B.10).

Conventions#

  • SIP: the same rupee amount is invested at each price in prices.
  • Lump sum versus SIP: wealth W is available at time 0. The lump sum invests it all at once for n periods. The SIP keeps the money in cash earning rf per period and invests W/n at times 0, 1, …, n − 1. The fund's gross return per period has mean 1 + mu, independent across periods.
  • Regressions: excess returns, OLS with an intercept and the usual homoskedastic standard errors, so under zero alpha with normal errors the alpha t-statistic is Student t with T − 2 degrees of freedom (approximately standard normal for long samples, as (6B.21) assumes).

Tests (9)

  • test_worked_example_6b8_harmonic_mean
  • test_averaging_gap_nonnegative_and_6b15
  • test_worked_example_6b9_lump_vs_sip
  • test_lump_vs_sip_monte_carlo
  • test_performance_measures
  • test_luck_6b21_and_false_discovery_share
  • test_zero_skill_funds_simulation
  • test_worked_example_6b10_berk_green
  • test_berk_green_flows_chase_performance_but_alpha_does_not_persist

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

not run yet

Not saved: sign up to keep your code
"""M06B · SIPs and manager skill: rupee-cost averaging, lump sum versus SIP, alpha, luck and Berk–Green.

Exercise stubs. Replace each ``raise NotImplementedError`` with your code, then run the tests in
../tests. See notes.md §6B.6 (Proposition 6B.6.1: average cost is the harmonic mean (6B.14)–(6B.15);
Proposition 6B.6.2: averaging is not an edge over a lump sum (6B.16); Worked examples 6B.8–6B.9) and
§6B.7 (the measures (6B.17)–(6B.20), luck versus skill (6B.21), Proposition 6B.7.1: Berk–Green
(6B.22)–(6B.23), Worked example 6B.10).

Conventions
-----------
* SIP: the same rupee amount is invested at each price in ``prices``.
* Lump sum versus SIP: wealth W is available at time 0. The lump sum invests it all at once for n
  periods. The SIP keeps the money in cash earning ``rf`` per period and invests W/n at times
  0, 1, …, n − 1. The fund's gross return per period has mean 1 + ``mu``, independent across periods.
* Regressions: excess returns, OLS with an intercept and the usual homoskedastic standard errors, so
  under zero alpha with normal errors the alpha t-statistic is Student t with T − 2 degrees of freedom
  (approximately standard normal for long samples, as (6B.21) assumes).
"""

from __future__ import annotations

import math

import numpy as np
from scipy import stats


# ------------------------------------------------------------------ §6B.6 rupee-cost averaging
def sip_units(prices, amount: float) -> np.ndarray:
    """Units bought at each price when ``amount`` rupees are invested each time: P/p_i."""
    raise NotImplementedError


def sip_average_cost(prices) -> float:
    """Average cost per unit of a SIP: n P / Σ(P/p_i) = n / Σ(1/p_i) = H(p), the harmonic mean (6B.14)."""
    raise NotImplementedError


def averaging_gap(prices) -> float:
    """A(p) − H(p) ≥ 0, arithmetic mean minus SIP average cost (6B.14), zero iff all prices are equal.
    For small dispersion, (6B.15) gives A − H ≈ A σ_δ² with σ_δ² = (1/n) Σ δ_i², p_i = A(1 + δ_i)."""
    raise NotImplementedError


def lump_sum_expected(wealth: float, n: int, mu: float) -> float:
    """E[W_L] = W (1 + mu)^n."""
    raise NotImplementedError


def sip_expected(wealth: float, n: int, mu: float, rf: float) -> float:
    """E[W_S] = (W/n) Σ_{j=0}^{n−1} (1 + rf)^j (1 + mu)^{n−j}: the tranche invested at time j sat in cash
    for j periods. E[W_L] − E[W_S] is (6B.16), positive when mu > rf."""
    raise NotImplementedError


def simulate_lump_vs_sip(wealth: float, n: int, mu: float, sigma: float, rf: float, n_paths: int,
                         seed: int = 0) -> tuple[np.ndarray, np.ndarray]:
    """Monte Carlo terminal wealth of the lump sum and the SIP. Gross returns are lognormal with mean
    1 + mu and standard deviation ``sigma``: log-variance s² = ln(1 + sigma²/(1 + mu)²), log-mean
    ln(1 + mu) − s²/2. Use np.random.default_rng(seed) and an (n_paths, n) array of gross returns.
    Return (lump, sip), each of shape (n_paths,)."""
    raise NotImplementedError


# ------------------------------------------------------------------ §6B.7 measures
def sharpe_ratio(returns, rf: float = 0.0, periods_per_year: int = 12) -> float:
    """Annualised Sharpe ratio (6B.17): mean(r − rf)/std(r − rf, ddof=1) · sqrt(periods_per_year)."""
    raise NotImplementedError


def jensen_alpha(fund_excess, market_excess) -> tuple[float, float, float]:
    """OLS of (6B.18), R_p − r_f = α + β (R_m − r_f) + ε. Return (alpha per period, beta, t-statistic of
    alpha) with se(α) = sqrt(s² [(XᵀX)⁻¹]₀₀), s² = residual sum of squares/(T − 2)."""
    raise NotImplementedError


def information_ratio(fund_returns, benchmark_returns, periods_per_year: int = 12) -> float:
    """IR = (mean active return · m)/TE with TE = σ(R_p − R_b) sqrt(m) (6B.19), σ with ddof=1."""
    raise NotImplementedError


def active_share(w_fund, w_bench) -> float:
    """AS = ½ Σ |w_p,i − w_b,i| over all securities (6B.20)."""
    raise NotImplementedError


# ------------------------------------------------------------------ §6B.7 luck versus skill
def alpha_tstats_zero_skill(n_funds: int, n_months: int, beta: float = 1.0, mu_m: float = 0.008,
                            sigma_m: float = 0.05, sigma_e: float = 0.02, seed: int = 0) -> np.ndarray:
    """Alpha t-statistics of ``n_funds`` funds with zero true alpha over ``n_months`` months.

    With rng = np.random.default_rng(seed), draw first the market series
    r_m = mu_m + sigma_m · rng.standard_normal(n_months), then the idiosyncratic returns
    ε = sigma_e · rng.standard_normal((n_months, n_funds)). Fund i earns beta · r_m + ε[:, i].
    Regress each fund on the market as in ``jensen_alpha`` (vectorise: one XᵀX for all funds) and return
    the n_funds t-statistics of alpha.
    """
    raise NotImplementedError


def expected_false_discoveries(n_funds: int, z: float, n_months: int | None = None, two_sided: bool = False,
                               dist: str = "normal") -> float:
    """Expected number of zero-alpha funds with t > z, treating the t-statistics as independent.

    * ``dist="normal"`` (the default, as in the notes): N (1 − Φ(z)) (6B.21); ``n_months`` is not used.
    * ``dist="t"``: the exact small-sample version N · P(t_{T−2} > z) with T = ``n_months`` (required).
    Twice that for |t| > z if ``two_sided``. Raise ValueError for an unknown dist or a missing n_months.
    """
    raise NotImplementedError


def false_discovery_share(n_skilled: int, mean_t_skilled: float, n_unskilled: int, z: float) -> tuple[float, float, float]:
    """Expected (TP, FP, FDR) when ``n_skilled`` funds have t-statistics ~ N(mean_t_skilled, 1) and
    ``n_unskilled`` have N(0, 1), with a one-sided threshold z (§6B.7.2):
    TP = n_s Φ(mean_t − z), FP = n_u (1 − Φ(z)), FDR = FP/(FP + TP)."""
    raise NotImplementedError


# ------------------------------------------------------------------ §6B.7.3 Berk–Green
def berk_green_size(phi: float, b: float, fee: float) -> float:
    """Equilibrium size q* = (φ − f)/b (6B.22) when gross alpha is φ − b q per rupee in expectation;
    0 if f ≥ φ. At q* investors' expected net alpha φ − b q* − f is zero."""
    raise NotImplementedError


def berk_green_update(phi: float, s2: float, sigma2: float, eta: float, b: float) -> tuple[float, float, float]:
    """One period of learning (Proposition 6B.7.1(b)): κ = s²/(s² + σ²), φ' = φ + κ η, and the flow
    q*' − q* = κ η / b (6B.23). Return (φ', posterior variance κ σ², flow)."""
    raise NotImplementedError


def berk_green_optimal_fee(phi: float, b: float) -> tuple[float, float, float]:
    """Revenue f q*(f) = f (φ − f)/b is maximised at f* = φ/2, with q = φ/(2b) and revenue φ²/(4b)
    (Proposition 6B.7.1(c)). Return (f*, q, revenue)."""
    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.