diff --git a/okama/portfolios/core.py b/okama/portfolios/core.py index f7d0e44..047121a 100644 --- a/okama/portfolios/core.py +++ b/okama/portfolios/core.py @@ -1150,6 +1150,52 @@ def risk_annual(self) -> pd.Series: mean_return_ts = self.ror.expanding().mean() return helpers.Float.annualize_risk(risk_ts, mean_return_ts).iloc[1:] + def get_ex_ante_tracking_error(self, benchmark: Portfolio) -> float: + """ + Calculate annualized ex-ante Tracking Error against a benchmark portfolio. + + Ex-ante Tracking Error is estimated from planned active weights: + + ``TE = sqrt((a - b)' * Sigma * (a - b))`` + + where ``a`` is the portfolio weight vector, ``b`` is the benchmark + weight vector, and ``Sigma`` is the annualized sample covariance matrix + of historical monthly asset returns. + + The benchmark must be another ``Portfolio`` over the same assets. Per + Hwang & Satchell (2001), ex-ante Tracking Error can systematically + underestimate ex-post Tracking Error because realized portfolio weights + may drift between rebalancings. + + Parameters + ---------- + benchmark : Portfolio + Benchmark portfolio with the same assets and base currency. + + Returns + ------- + float + Annualized ex-ante Tracking Error. + """ + if not isinstance(benchmark, Portfolio): + raise TypeError("benchmark must be a Portfolio instance.") + if set(self.symbols) != set(benchmark.symbols): + raise ValueError("benchmark portfolio must have the same assets as the portfolio.") + if self.currency != benchmark.currency: + raise ValueError("benchmark portfolio must have the same base currency as the portfolio.") + + common_index = self.assets_ror.index.intersection(benchmark.assets_ror.index) + if len(common_index) < 2: + raise ValueError("At least two overlapping monthly return observations are required.") + + asset_returns = self.assets_ror.loc[common_index, self.symbols] + portfolio_weights = pd.Series(self.weights, index=self.symbols) + benchmark_weights = pd.Series(benchmark.weights, index=benchmark.symbols).reindex(self.symbols) + active_weights = portfolio_weights - benchmark_weights + annual_cov = asset_returns.cov() * settings._MONTHS_PER_YEAR + variance = float(active_weights.T @ annual_cov @ active_weights) + return float(np.sqrt(max(variance, 0.0))) + @property def semideviation_monthly(self) -> float: """ diff --git a/tests/portfolio/test_portfolio.py b/tests/portfolio/test_portfolio.py index 560592e..49c9ad3 100644 --- a/tests/portfolio/test_portfolio.py +++ b/tests/portfolio/test_portfolio.py @@ -4,6 +4,7 @@ from urllib.parse import parse_qs, urlparse import okama as ok +from okama import settings # Note: These tests use the global synthetic_env fixture defined in tests/conftest.py # which patches asset loading and the currency Asset to avoid any external API calls. @@ -125,6 +126,27 @@ def test_mean_return_and_risk_consistency(pf_ab_monthly): assert pytest.approx(rk_a, rel=1e-6, abs=1e-10) == expected_rk_a +def test_ex_ante_tracking_error_uses_annualized_active_weight_covariance(synthetic_env): + pf = ok.Portfolio(["A.US", "B.US", "IDX.US"], weights=[0.50, 0.30, 0.20], ccy="USD", inflation=False) + benchmark = ok.Portfolio(["IDX.US", "A.US", "B.US"], weights=[0.60, 0.20, 0.20], ccy="USD", inflation=False) + + active_weights = pd.Series(pf.weights, index=pf.symbols) - pd.Series( + benchmark.weights, index=benchmark.symbols + ).reindex(pf.symbols) + annual_cov = pf.assets_ror.cov() * settings._MONTHS_PER_YEAR + expected = np.sqrt(active_weights.T @ annual_cov @ active_weights) + + assert pytest.approx(pf.get_ex_ante_tracking_error(benchmark), rel=1e-12) == expected + + +def test_ex_ante_tracking_error_requires_same_asset_universe(synthetic_env): + pf = ok.Portfolio(["A.US", "B.US"], ccy="USD", inflation=False) + benchmark = ok.Portfolio(["A.US", "IDX.US"], ccy="USD", inflation=False) + + with pytest.raises(ValueError, match="same assets"): + pf.get_ex_ante_tracking_error(benchmark) + + def test_semideviation_and_tail_risks(pf_three_monthly): sd_m = pf_three_monthly.semideviation_monthly sd_a = pf_three_monthly.semideviation_annual