Exercise · m06b_sip_skill.py
SIPs and manager skill: rupee-cost averaging, lump sum versus SIP, alpha, luck and Berk–Green
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.