|
| 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}") |
0 commit comments