Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions okama/portfolios/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
22 changes: 22 additions & 0 deletions tests/portfolio/test_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down