Skip to content

Commit f5927d1

Browse files
feat: add 4 new modules (Risk Parity, PCA, Bootstrap, Information Ratio) for v2.6.0
- Portfolio Management - Risk Parity: inverse-vol, ERC, custom risk budgeting - Quantitative Methods - Principal Component Analysis: PCA from scratch, yield-curve decomposition - Quantitative Methods - Bootstrap: i.i.d./block/stationary resampling, confidence intervals - Finance - Information Ratio: active return, tracking error, IR, appraisal ratio - Each module fully commented with the why behind every formula + per-module README - Add 21 unit tests (all passing); fix test import paths to current folder names - Update docs_builder to categorize Information Ratio; regenerate z_docs/mkdocs (105 modules) - Refresh README to v2.6.0 with new directory entries and stats Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 82a5541 commit f5927d1

25 files changed

Lines changed: 2836 additions & 449 deletions
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Information Ratio & Active Management Metrics
2+
3+
When a portfolio is judged **against a benchmark**, what matters is how much it beat the benchmark by — and how *reliably*. These are the core metrics of active management: active return, tracking error, Information Ratio, and the appraisal ratio.
4+
5+
## Functions
6+
7+
| Function | Description |
8+
|---|---|
9+
| `active_returns(portfolio, benchmark)` | Period-by-period excess over benchmark |
10+
| `tracking_error(portfolio, benchmark, periods)` | Annualised volatility of active returns |
11+
| `information_ratio(portfolio, benchmark, periods)` | Active return per unit of tracking error |
12+
| `appraisal_ratio(portfolio, benchmark, periods)` | Jensen's alpha ÷ residual vol, via CAPM regression |
13+
14+
## Key Concepts
15+
16+
- **Active return**: `r_portfolio − r_benchmark`. The value the manager added (or lost) versus simply holding the benchmark.
17+
- **Tracking error**: the standard deviation of active returns, annualised. It is the *risk* of deviating from the benchmark.
18+
- **Information Ratio (IR)**: `annualised active return / tracking error`. The benchmark-relative cousin of the Sharpe ratio. IR > 0.5 is good; > 1.0 is excellent and rare over long horizons.
19+
- **Appraisal ratio**: from `r_p = α + β·r_b + ε`, it is annualised `α / σ(ε)` — skill (alpha) per unit of idiosyncratic risk, isolating stock-picking from market exposure.
20+
21+
## Example
22+
23+
```python
24+
import numpy as np
25+
from information_ratio import information_ratio, tracking_error, appraisal_ratio
26+
27+
bench = np.random.default_rng(0).normal(0.0004, 0.011, 504)
28+
port = 0.0002 + 1.05 * bench + np.random.default_rng(1).normal(0, 0.004, 504)
29+
30+
print(tracking_error(port, bench)) # annualised TE
31+
print(information_ratio(port, bench)) # IR
32+
print(appraisal_ratio(port, bench)) # alpha, beta, residual vol, appraisal ratio
33+
```
34+
35+
## Practical Notes
36+
37+
- Annualisation uses `mean × periods` for return and `std × √periods` for volatility, so the IR scales by `√periods`.
38+
- Use `periods=252` for daily data, `52` for weekly, `12` for monthly.
39+
- A high IR from a tiny tracking error can be statistically fragile — pair it with the [[bootstrap]] module to put a confidence interval around it.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Information Ratio & Active Management Metrics
3+
----------------------------------------------
4+
When a portfolio is run *against a benchmark*, the questions that matter are not
5+
"how much did we return?" but "how much did we beat the benchmark by, and how
6+
reliably?". This module covers the core metrics of active management:
7+
8+
- Active return — portfolio return minus benchmark return.
9+
- Tracking error — volatility of the active return (the risk taken to deviate
10+
from the benchmark).
11+
- Information Ratio — active return per unit of tracking error. The active-
12+
management analogue of the Sharpe ratio.
13+
- Appraisal ratio — Jensen's alpha divided by the volatility of regression
14+
residuals (idiosyncratic risk), via a CAPM-style fit.
15+
16+
These are the numbers used to judge a long-only active manager or a market-
17+
neutral overlay. A higher Information Ratio means more consistent outperformance
18+
per unit of benchmark-relative risk.
19+
"""
20+
21+
import numpy as np
22+
23+
# Trading days per year — used to annualise daily statistics.
24+
TRADING_DAYS = 252
25+
26+
27+
def active_returns(portfolio: np.ndarray, benchmark: np.ndarray) -> np.ndarray:
28+
"""
29+
Active (excess-over-benchmark) returns, period by period.
30+
31+
Args:
32+
portfolio: Array of portfolio returns.
33+
benchmark: Array of benchmark returns (same length).
34+
35+
Returns:
36+
np.ndarray: portfolio - benchmark, element-wise.
37+
"""
38+
portfolio = np.asarray(portfolio, dtype=float)
39+
benchmark = np.asarray(benchmark, dtype=float)
40+
if portfolio.shape != benchmark.shape:
41+
raise ValueError("portfolio and benchmark must be the same length")
42+
return portfolio - benchmark
43+
44+
45+
def tracking_error(portfolio: np.ndarray, benchmark: np.ndarray, periods: int = TRADING_DAYS) -> float:
46+
"""
47+
Annualised tracking error: the standard deviation of active returns.
48+
49+
Args:
50+
portfolio: Portfolio returns.
51+
benchmark: Benchmark returns.
52+
periods: Periods per year for annualisation (252 daily, 12 monthly).
53+
54+
Returns:
55+
float: Annualised tracking error.
56+
"""
57+
active = active_returns(portfolio, benchmark)
58+
# Volatility scales with the square root of time.
59+
return float(active.std(ddof=1) * np.sqrt(periods))
60+
61+
62+
def information_ratio(portfolio: np.ndarray, benchmark: np.ndarray, periods: int = TRADING_DAYS) -> float:
63+
"""
64+
Information Ratio = annualised active return / annualised tracking error.
65+
66+
Interpreted like a Sharpe ratio but relative to a benchmark. IR > 0.5 is
67+
good, > 1.0 is excellent and rare over long horizons.
68+
69+
Args:
70+
portfolio: Portfolio returns.
71+
benchmark: Benchmark returns.
72+
periods: Periods per year for annualisation.
73+
74+
Returns:
75+
float: Information Ratio (0.0 if tracking error is zero).
76+
"""
77+
active = active_returns(portfolio, benchmark)
78+
te = active.std(ddof=1)
79+
if te == 0:
80+
return 0.0
81+
# Annualise numerator (mean × periods) and denominator (std × sqrt(periods));
82+
# the ratio simplifies to mean/std × sqrt(periods).
83+
return float(active.mean() / te * np.sqrt(periods))
84+
85+
86+
def appraisal_ratio(portfolio: np.ndarray, benchmark: np.ndarray, periods: int = TRADING_DAYS) -> dict:
87+
"""
88+
Appraisal ratio via a CAPM-style regression of portfolio on benchmark.
89+
90+
Fits r_p = alpha + beta * r_b + epsilon by ordinary least squares. The
91+
appraisal ratio is alpha divided by the volatility of the residuals
92+
(idiosyncratic risk) — it measures stock-picking skill net of market beta.
93+
94+
Args:
95+
portfolio: Portfolio returns.
96+
benchmark: Benchmark returns.
97+
periods: Periods per year for annualisation.
98+
99+
Returns:
100+
dict with annualised "alpha", "beta", "residual_vol", and
101+
"appraisal_ratio".
102+
"""
103+
r_p = np.asarray(portfolio, dtype=float)
104+
r_b = np.asarray(benchmark, dtype=float)
105+
# Design matrix [1, r_b] for the intercept (alpha) and slope (beta).
106+
X = np.column_stack([np.ones_like(r_b), r_b])
107+
coef, *_ = np.linalg.lstsq(X, r_p, rcond=None)
108+
alpha, beta = coef
109+
residuals = r_p - X @ coef
110+
resid_vol = residuals.std(ddof=1)
111+
112+
ann_alpha = alpha * periods
113+
ann_resid_vol = resid_vol * np.sqrt(periods)
114+
ratio = ann_alpha / ann_resid_vol if ann_resid_vol > 0 else 0.0
115+
return {
116+
"alpha": float(ann_alpha),
117+
"beta": float(beta),
118+
"residual_vol": float(ann_resid_vol),
119+
"appraisal_ratio": float(ratio),
120+
}
121+
122+
123+
if __name__ == "__main__":
124+
print("Information Ratio & Active Management Metrics")
125+
print("=" * 46)
126+
127+
rng = np.random.default_rng(11)
128+
# Benchmark: broad market. Portfolio: benchmark beta of ~1.05 plus a small
129+
# consistent alpha and some idiosyncratic noise.
130+
bench = rng.normal(0.0004, 0.011, TRADING_DAYS * 2)
131+
port = 0.0002 + 1.05 * bench + rng.normal(0, 0.004, len(bench))
132+
133+
te = tracking_error(port, bench)
134+
ir = information_ratio(port, bench)
135+
print(f"\nAnnualised tracking error : {te:.2%}")
136+
print(f"Information Ratio : {ir:.3f}")
137+
138+
ap = appraisal_ratio(port, bench)
139+
print("\nCAPM-style regression:")
140+
print(f" Annualised alpha : {ap['alpha']:+.2%}")
141+
print(f" Beta : {ap['beta']:.3f}")
142+
print(f" Residual vol : {ap['residual_vol']:.2%}")
143+
print(f" Appraisal ratio : {ap['appraisal_ratio']:.3f}")
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Risk Parity Portfolio Construction
2+
3+
Risk parity builds a portfolio where **every asset contributes the same amount of risk** to the total — not the same amount of capital. A naive 60/40 stock/bond portfolio is ~90% *equity risk* despite being only 60% equity *capital*; risk parity fixes that imbalance.
4+
5+
## Functions
6+
7+
| Function | Description |
8+
|---|---|
9+
| `portfolio_volatility(weights, cov)` | Portfolio standard deviation `sqrt(wᵀΣw)` |
10+
| `risk_contributions(weights, cov)` | Component risk contribution per asset (sums to total vol) |
11+
| `inverse_volatility_weights(cov)` | Naive risk parity — closed-form, ignores correlations |
12+
| `risk_parity_weights(cov, budget=None)` | Equal Risk Contribution (ERC) or custom risk budget, solved numerically |
13+
14+
## Key Concepts
15+
16+
- **Marginal risk contribution**: `MRC = (Σw) / σ_p` — how much portfolio volatility changes per unit of weight.
17+
- **Component risk contribution**: `w ⊙ MRC`. By Euler's theorem these sum *exactly* to the portfolio volatility.
18+
- **Equal Risk Contribution (ERC)**: choose weights so every component contribution is equal. No closed form in general → solved by minimising the squared deviation of fractional contributions from the target budget.
19+
- **Risk budgeting**: ERC generalised — set an arbitrary target risk share per asset (e.g. 50% equity / 30% bond / 20% cash).
20+
21+
## Example
22+
23+
```python
24+
import numpy as np
25+
from risk_parity import risk_parity_weights, risk_contributions
26+
27+
vols = np.array([0.20, 0.10, 0.04])
28+
corr = np.array([[1.0, 0.3, 0.05], [0.3, 1.0, 0.15], [0.05, 0.15, 1.0]])
29+
cov = np.outer(vols, vols) * corr
30+
31+
w = risk_parity_weights(cov) # ERC weights
32+
rc = risk_contributions(w, cov)
33+
print(rc / rc.sum()) # ≈ equal risk shares
34+
```
35+
36+
## Practical Notes
37+
38+
- Inverse-volatility weighting is a fast, robust approximation and is exact only when assets are uncorrelated.
39+
- Risk parity portfolios are often **levered up** to reach a target volatility, since they tend to be bond-heavy and low-vol.
40+
- The optimiser starts from inverse-vol weights, which are close to the ERC solution, so SLSQP converges quickly and reliably.

0 commit comments

Comments
 (0)