Skip to content

Commit f36ff34

Browse files
chilango74claude
andcommitted
feat!: tracking error is the standard deviation of return differences (#97)
BREAKING CHANGE: `tracking_error()` returns different values and no longer accepts a `method` argument. `okama` implemented two formulas behind `method`, and the default was the uncentered root-mean-square of the monthly return differences. That quantity is not the tracking error: leaving the differences uncentered folds the systematic lag behind the benchmark — the tracking difference — into the dispersion measure, so a fund that trails by a steady amount every month is reported as badly tracking even when the gap never moves. Tracking error is the sample standard deviation of the differences around their mean (CFA Level II, 2019, V6, eq. 8; CFA Level I, 2025, V9 Portfolio Management, footnote 3), which is what all three entry points now compute — and the only thing they compute: helpers.Index.tracking_error(ror) AssetList.tracking_error(rolling_window=None) Portfolio.tracking_error(benchmark, rolling_window=None) The relation between the two is exact: TE_rms² = (N-1)/N · TE_std² + mean(d)², so the RMS variant is not a different estimator of the same thing but a mixture of two measures the curriculum deliberately keeps apart. It is removed rather than kept as an option — callers who passed `method="std"` keep their numbers by dropping the argument. Docstrings now state the formula, the relation to tracking difference and the CFA reference. `pyproject.toml` is deliberately left at 2.3.1: the version bump belongs to the release workflow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUmxQQKrKZRAi1qKUternL
1 parent d41e051 commit f36ff34

7 files changed

Lines changed: 105 additions & 113 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **Breaking.** `tracking_error()` now returns the sample standard deviation of the
13+
return differences around their mean (Bessel's correction), annualized by
14+
`sqrt(12)` — the tracking error as defined by the CFA curriculum (CFA Level II,
15+
2019, V6, eq. 8; CFA Level I, 2025, V9 Portfolio Management, footnote 3). The
16+
previous default was the uncentered root-mean-square of the differences, which
17+
folded the systematic lag behind the benchmark (the tracking difference) into the
18+
result: `TE_rms² = (N-1)/N · TE_std² + mean(d)²`. The same symbols over the same
19+
period therefore return a different number than in 2.3.1 and earlier — usually
20+
lower, since the lag term drops out, though marginally higher for a fund whose lag
21+
is smaller than `TE_std / sqrt(N)`, where the uncentered formula's division by `N`
22+
instead of `N-1` dominates. Affects `helpers.Index.tracking_error`,
23+
`AssetList.tracking_error` and `Portfolio.tracking_error` (#97).
24+
25+
### Removed
26+
27+
- **Breaking.** The `method` parameter of `helpers.Index.tracking_error`,
28+
`AssetList.tracking_error` and `Portfolio.tracking_error`. Tracking error now has a
29+
single definition, so `method="rms"` and `method="std"` (both added in 2.2.2) are
30+
gone — passing `method=` raises `TypeError`. Code that asked for `method="std"`
31+
keeps its values by simply dropping the argument; code that relied on the `"rms"`
32+
values has to compute them itself, as the mixture of tracking difference and
33+
tracking error that it is.
34+
1035
### Fixed
1136

1237
- `Portfolio.assets_weights` (the symbol → weight mapping) was built once in the

okama/asset_list.py

Lines changed: 14 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
from typing import Literal # noqa: I001
2-
from functools import partial
3-
4-
import numpy as np
1+
import numpy as np # noqa: I001
52
import pandas as pd
63

74
from okama.common.helpers.helpers import check_rolling_window
@@ -1397,40 +1394,29 @@ def tracking_difference_annual(self) -> pd.DataFrame:
13971394
result.index = result.index.asfreq("Y")
13981395
return result
13991396

1400-
def tracking_error(
1401-
self,
1402-
rolling_window: int | None = None,
1403-
method: Literal["rms", "std"] = "rms",
1404-
) -> pd.DataFrame:
1397+
def tracking_error(self, rolling_window: int | None = None) -> pd.DataFrame:
14051398
"""
14061399
Calculate tracking error time series for the rate of return of assets.
14071400
1408-
Tracking error is an ex-post measure of how closely the assets follow the benchmark.
1409-
It is computed from the realized monthly return differences between each asset and
1410-
the benchmark, and is annualized (multiplied by sqrt(12)). Tracking error values
1401+
Tracking error is an ex-post measure of how closely the assets follow the benchmark:
1402+
the sample standard deviation of the realized monthly return differences between
1403+
each asset and the benchmark, taken around their mean and corrected for bias
1404+
(Bessel's correction), annualized by multiplying by sqrt(12). Tracking error values
14111405
are decimal fractions: 0.05 corresponds to 5% annualized.
14121406
14131407
Benchmark should be in the first position of the symbols list in AssetList parameters.
14141408
1415-
Two formulas are available (`method` parameter):
1416-
1417-
- "rms" (default): root-mean-square of the return differences. The differences are
1418-
not centered around their mean, hence the systematic lag between an asset and
1419-
the benchmark (tracking difference) is included in the result.
1420-
- "std": sample standard deviation of the return differences with Bessel's
1421-
correction — the classic tracking error definition (Hwang & Satchell,
1422-
"Tracking Error: Ex-Ante versus Ex-Post Measures", 2001, eq. 2) measuring
1423-
the pure volatility of deviations from the benchmark. The first point of the
1424-
expanding time series is dropped (a single observation has no standard deviation).
1409+
Because the differences are centered, a systematic lag behind the benchmark does not
1410+
inflate the result: how far an asset falls behind is measured by
1411+
`tracking_difference`, how unstable that gap is — by tracking error (CFA Level II,
1412+
2019, V6, eq. 8). The first point of the expanding time series is dropped (a single
1413+
observation has no standard deviation).
14251414
14261415
Parameters
14271416
----------
14281417
rolling_window : int or None, default None
14291418
Size of the moving window in months. Must be at least 12 months.
14301419
If None calculate expanding tracking error.
1431-
method : {"rms", "std"}, default "rms"
1432-
Tracking error formula: "rms" for the uncentered root-mean-square of return
1433-
differences, "std" for the centered sample standard deviation.
14341420
14351421
Returns
14361422
-------
@@ -1447,18 +1433,18 @@ def tracking_error(
14471433
14481434
To calculate rolling tracking error set `rolling_window` to a number of months (moving window size):
14491435
1450-
>>> x.tracking_error(rolling_window=12 * 5, method="std").plot()
1436+
>>> x.tracking_error(rolling_window=12 * 5).plot()
14511437
>>> plt.show()
14521438
"""
14531439
if rolling_window:
14541440
return helpers.Index.rolling_fn(
14551441
df=self.assets_ror,
14561442
window=rolling_window,
1457-
fn=partial(helpers.Index.tracking_error, method=method),
1443+
fn=helpers.Index.tracking_error,
14581444
window_below_year=False, # small windows below 12 months are not allowed
14591445
)
14601446
else:
1461-
return helpers.Index.tracking_error(self.assets_ror, method=method)
1447+
return helpers.Index.tracking_error(self.assets_ror)
14621448

14631449
def index_corr(self, rolling_window: int | None = None) -> pd.DataFrame:
14641450
"""

okama/common/helpers/helpers.py

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -656,25 +656,23 @@ def tracking_difference_annualized(tracking_diff: pd.DataFrame) -> pd.DataFrame:
656656
return diff.iloc[settings._MONTHS_PER_YEAR - 1 :] # returns for the first 11 months can't be annualized
657657

658658
@staticmethod
659-
def tracking_error(ror: pd.DataFrame, method: str = "rms") -> pd.DataFrame:
659+
def tracking_error(ror: pd.DataFrame) -> pd.DataFrame:
660660
"""
661661
Return expanding tracking error time series for a rate of return time series.
662662
663663
Assets are compared with the index or another benchmark.
664664
Index should be in the first position (first column).
665665
666666
Tracking error is an ex-post measure: it is computed from the realized (historical)
667-
monthly return differences `d` between each asset and the benchmark.
668-
Two formulas are available:
669-
670-
- "rms" (default): expanding root-mean-square of the differences, sqrt(mean(d²)).
671-
The differences are not centered around their mean, hence the systematic lag
672-
between an asset and the benchmark (tracking difference) is included in the result.
673-
- "std": expanding sample standard deviation of the differences with Bessel's
674-
correction, sqrt(sum((d - mean(d))²) / (n - 1)) — the classic tracking error
675-
definition (Hwang & Satchell, "Tracking Error: Ex-Ante versus Ex-Post Measures",
676-
2001, eq. 2) measuring the pure volatility of deviations. The first expanding
677-
point is dropped (a single observation has no standard deviation).
667+
monthly return differences `d` between each asset and the benchmark as the expanding
668+
sample standard deviation of those differences with Bessel's correction,
669+
sqrt(sum((d - mean(d))²) / (n - 1)).
670+
671+
The differences are centered around their mean, so a systematic lag behind the
672+
benchmark does not inflate the result: how far an asset falls behind is tracking
673+
difference (`Index.tracking_difference`), how unstable that gap is, is tracking
674+
error (CFA Level II, 2019, V6, eq. 8). The first expanding point is dropped
675+
(a single observation has no standard deviation).
678676
679677
The result is annualized for monthly time series (multiplied by sqrt(12)).
680678
"""
@@ -684,14 +682,8 @@ def tracking_error(ror: pd.DataFrame, method: str = "rms") -> pd.DataFrame:
684682
raise ShortPeriodLengthError("Tracking Error is not defined for time periods < 1 year")
685683
difference = ror.subtract(ror.iloc[:, 0], axis=0)
686684
difference = difference.drop(difference.columns[0], axis=1) # drop the first column (stock index data)
687-
if method == "rms":
688-
cumsum = difference.pow(2, axis=0).cumsum()
689-
tracking_error = cumsum.divide((1.0 + np.arange(ror.shape[0])), axis=0).pow(0.5, axis=0)
690-
elif method == "std":
691-
tracking_error = difference.expanding().std().dropna(how="all")
692-
else:
693-
raise ValueError(f"method must be 'rms' or 'std', got '{method}'.")
694-
return tracking_error * np.sqrt(12)
685+
tracking_error = difference.expanding().std().dropna(how="all")
686+
return tracking_error * np.sqrt(settings._MONTHS_PER_YEAR)
695687

696688
@staticmethod
697689
def expanding_cov_cor(ror: pd.DataFrame, fn: str) -> pd.DataFrame:

okama/portfolios/core.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,26 +1661,21 @@ def tracking_error(
16611661
self,
16621662
benchmark: str | type,
16631663
rolling_window: int | None = None,
1664-
method: Literal["rms", "std"] = "rms",
16651664
) -> pd.Series:
16661665
"""
16671666
Calculate ex-post tracking error time series of the portfolio against a benchmark.
16681667
16691668
Tracking error is an ex-post (backward-looking) measure of how closely the portfolio
1670-
follows the benchmark. It is computed from the realized monthly return differences
1671-
between the portfolio and the benchmark, and is annualized (multiplied by sqrt(12)).
1669+
follows the benchmark: the sample standard deviation of the realized monthly return
1670+
differences between the portfolio and the benchmark, taken around their mean and
1671+
corrected for bias (Bessel's correction), annualized by multiplying by sqrt(12).
16721672
Tracking error values are decimal fractions: 0.05 corresponds to 5% annualized.
16731673
1674-
Two formulas are available (`method` parameter):
1675-
1676-
- "rms" (default): root-mean-square of the return differences. The differences are
1677-
not centered around their mean, hence the systematic lag between the portfolio
1678-
and the benchmark (tracking difference) is included in the result.
1679-
- "std": sample standard deviation of the return differences with Bessel's
1680-
correction — the classic tracking error definition (Hwang & Satchell,
1681-
"Tracking Error: Ex-Ante versus Ex-Post Measures", 2001, eq. 2) measuring
1682-
the pure volatility of deviations from the benchmark. The first point of the
1683-
expanding time series is dropped (a single observation has no standard deviation).
1674+
Because the differences are centered, a systematic lag behind the benchmark does not
1675+
inflate the result: how far the portfolio falls behind is measured by tracking
1676+
difference, how unstable that gap is — by tracking error (CFA Level II, 2019, V6,
1677+
eq. 8). The first point of the expanding time series is dropped (a single
1678+
observation has no standard deviation).
16841679
16851680
The benchmark rate of return is converted to the portfolio base currency, and the
16861681
time period is limited to the intersection of the portfolio and benchmark
@@ -1694,9 +1689,6 @@ def tracking_error(
16941689
rolling_window : int or None, default None
16951690
Size of the moving window in months. Must be at least 12 months.
16961691
If None calculate expanding tracking error.
1697-
method : {"rms", "std"}, default "rms"
1698-
Tracking error formula: "rms" for the uncentered root-mean-square of return
1699-
differences, "std" for the centered sample standard deviation.
17001692
17011693
Returns
17021694
-------
@@ -1714,11 +1706,11 @@ def tracking_error(
17141706
17151707
To calculate rolling tracking error set `rolling_window` to a number of months (moving window size):
17161708
1717-
>>> pf.tracking_error(benchmark="SP500TR.INDX", rolling_window=24, method="std").plot()
1709+
>>> pf.tracking_error(benchmark="SP500TR.INDX", rolling_window=24).plot()
17181710
>>> plt.show()
17191711
"""
17201712
al = AssetList([benchmark, self], ccy=self.currency, inflation=False)
1721-
tracking_error = al.tracking_error(rolling_window=rolling_window, method=method)
1713+
tracking_error = al.tracking_error(rolling_window=rolling_window)
17221714
return tracking_error[self.symbol]
17231715

17241716
@property

tests/asset_list/test_asset_list.py

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -381,33 +381,29 @@ def test_tracking_difference_annualized_and_annual(synthetic_env2):
381381
assert tdan.shape[0] >= 1
382382

383383

384-
def test_tracking_error_std_method(synthetic_env):
385-
"""method='std' returns the centered sample std (ddof=1) of return differences, annualized."""
384+
def test_tracking_error_is_centered_sample_std(synthetic_env):
385+
"""Tracking error is the centered sample std (ddof=1) of return differences, annualized."""
386386
al = ok.AssetList(["IDX.US", "A.US", "B.US"], ccy="USD", inflation=False)
387-
te = al.tracking_error(method="std")
387+
te = al.tracking_error()
388388
assert isinstance(te, pd.DataFrame)
389389
assert list(te.columns) == ["A.US", "B.US"]
390390
d = al.assets_ror["A.US"] - al.assets_ror["IDX.US"]
391391
assert te["A.US"].iloc[-1] == pytest.approx(d.std(ddof=1) * np.sqrt(12))
392-
# The first expanding point is dropped for the std method
392+
# The first expanding point is dropped (a single observation has no standard deviation)
393393
assert len(te) == len(al.assets_ror) - 1
394394

395395

396-
def test_tracking_error_rms_default_unchanged(synthetic_env):
397-
"""Calling without arguments equals method='rms' and reproduces the legacy formula."""
396+
def test_tracking_error_does_not_accept_a_method_argument(synthetic_env):
397+
"""The `method` switch is gone: tracking error has a single definition."""
398398
al = ok.AssetList(["IDX.US", "A.US", "B.US"], ccy="USD", inflation=False)
399-
d = al.assets_ror["A.US"] - al.assets_ror["IDX.US"]
400-
expected_last = np.sqrt((d**2).sum() / len(d)) * np.sqrt(12)
401-
te_default = al.tracking_error()
402-
te_rms = al.tracking_error(method="rms")
403-
pd.testing.assert_frame_equal(te_default, te_rms)
404-
assert te_default["A.US"].iloc[-1] == pytest.approx(expected_last)
399+
with pytest.raises(TypeError):
400+
al.tracking_error(method="rms")
405401

406402

407-
def test_tracking_error_rolling_with_std_method(synthetic_env):
408-
"""Rolling tracking error supports method='std' (window >= 12 months)."""
403+
def test_tracking_error_rolling(synthetic_env):
404+
"""Rolling tracking error requires a window of at least 12 months."""
409405
al = ok.AssetList(["IDX.US", "A.US", "B.US"], ccy="USD", inflation=False)
410-
te = al.tracking_error(rolling_window=12, method="std")
406+
te = al.tracking_error(rolling_window=12)
411407
assert isinstance(te, pd.DataFrame)
412408
assert list(te.columns) == ["A.US", "B.US"]
413409
assert len(te) > 0

tests/portfolio/test_portfolio.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -415,12 +415,12 @@ def test_tracking_error_matches_asset_list_workaround(pf_ab_monthly):
415415
pd.testing.assert_series_equal(te, expected)
416416

417417

418-
def test_tracking_error_std_matches_manual_computation(pf_ab_monthly, synthetic_env):
419-
"""method='std' equals the centered std (ddof=1) of portfolio-vs-benchmark differences."""
420-
te = pf_ab_monthly.tracking_error(benchmark="IDX.US", method="std")
418+
def test_tracking_error_matches_manual_computation(pf_ab_monthly, synthetic_env):
419+
"""Tracking error equals the centered std (ddof=1) of portfolio-vs-benchmark differences."""
420+
te = pf_ab_monthly.tracking_error(benchmark="IDX.US")
421421
diff = pf_ab_monthly.ror - synthetic_env["series"]["IDX.US"]
422422
assert te.iloc[-1] == pytest.approx(diff.std(ddof=1) * np.sqrt(12))
423-
# The first expanding point is dropped for the std method
423+
# The first expanding point is dropped (a single observation has no standard deviation)
424424
assert len(te) == len(diff) - 1
425425

426426

@@ -448,13 +448,14 @@ def test_tracking_error_with_portfolio_benchmark(pf_ab_monthly, synthetic_env):
448448
assert isinstance(te, pd.Series)
449449
assert te.name == pf_ab_monthly.symbol
450450
diff = pf_ab_monthly.ror - bench_pf.ror
451-
expected_last = np.sqrt((diff**2).sum() / len(diff)) * np.sqrt(12)
451+
expected_last = diff.std(ddof=1) * np.sqrt(12)
452452
assert te.iloc[-1] == pytest.approx(expected_last)
453453

454454

455-
def test_tracking_error_invalid_method_raises(pf_ab_monthly):
456-
with pytest.raises(ValueError, match="method"):
457-
pf_ab_monthly.tracking_error(benchmark="IDX.US", method="mad")
455+
def test_tracking_error_does_not_accept_a_method_argument(pf_ab_monthly):
456+
"""The `method` switch is gone: tracking error has a single definition."""
457+
with pytest.raises(TypeError):
458+
pf_ab_monthly.tracking_error(benchmark="IDX.US", method="rms")
458459

459460

460461
def test_table_adds_local_name_when_present(synthetic_env):

0 commit comments

Comments
 (0)