Coverage for src / jquantstats / _portfolio_turnover.py: 100%
36 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-07 14:36 +0000
1"""Turnover analytics mixin for Portfolio."""
3from __future__ import annotations
5import contextlib
6from typing import TYPE_CHECKING
8import polars as pl
11class PortfolioTurnoverMixin:
12 """Mixin providing turnover analytics for Portfolio."""
14 if TYPE_CHECKING:
15 cashposition: pl.DataFrame
16 aum: float
17 _turnover_cache: pl.DataFrame | None
19 @property
20 def turnover(self) -> pl.DataFrame:
21 """Daily one-way portfolio turnover as a fraction of AUM.
23 Computes the sum of absolute position changes across all assets for
24 each period, normalised by AUM. The first row is always zero because
25 there is no prior position to form a difference against.
27 The result is cached after first access so repeated calls are O(1).
29 Note:
30 Caching is not thread-safe. Concurrent access from multiple
31 threads may trigger redundant computation, but will never produce
32 incorrect results because each thread stores the same deterministic
33 value.
35 Returns:
36 pl.DataFrame: Frame with an optional ``'date'`` column and a
37 ``'turnover'`` column (dimensionless fraction of AUM).
39 Examples:
40 >>> from jquantstats.portfolio import Portfolio
41 >>> import polars as pl
42 >>> from datetime import date
43 >>> _d = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3)]
44 >>> prices = pl.DataFrame({"date": _d, "A": [100.0, 110.0, 121.0]})
45 >>> pos = pl.DataFrame({"date": prices["date"], "A": [1000.0, 1200.0, 900.0]})
46 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e5)
47 >>> pf.turnover["turnover"].to_list()
48 [0.0, 0.002, 0.003]
49 """
50 cache = getattr(self, "_turnover_cache", None)
51 if cache is not None:
52 return cache
54 assets = [c for c in self.cashposition.columns if c != "date" and self.cashposition[c].dtype.is_numeric()]
55 daily_abs_chg = (
56 pl.sum_horizontal(pl.col(c).diff().abs().fill_null(0.0).fill_nan(0.0) for c in assets) / self.aum
57 ).alias("turnover")
58 cols: list[str | pl.Expr] = []
59 if "date" in self.cashposition.columns:
60 cols.append("date")
61 cols.append(daily_abs_chg)
62 result = self.cashposition.select(cols)
64 with contextlib.suppress(AttributeError, TypeError):
65 object.__setattr__(self, "_turnover_cache", result)
66 return result
68 @property
69 def turnover_weekly(self) -> pl.DataFrame:
70 """Weekly aggregated one-way portfolio turnover as a fraction of AUM.
72 When a ``'date'`` column is present, sums the daily turnover within
73 each calendar week (Monday-based ``group_by_dynamic``). Without a
74 date column, a rolling 5-period sum with ``min_samples=5`` is returned
75 (the first four rows will be ``null``).
77 Returns:
78 pl.DataFrame: Frame with an optional ``'date'`` column (week
79 start) and a ``'turnover'`` column (fraction of AUM, summed over
80 the week).
81 """
82 daily = self.turnover
83 if "date" not in daily.columns or not daily["date"].dtype.is_temporal():
84 return daily.with_columns(pl.col("turnover").rolling_sum(window_size=5, min_samples=5))
85 return daily.group_by_dynamic("date", every="1w").agg(pl.col("turnover").sum()).sort("date")
87 def turnover_summary(self) -> pl.DataFrame:
88 """Return a summary DataFrame of turnover statistics.
90 Computes three metrics from the daily turnover series:
92 - ``mean_daily_turnover``: mean of daily one-way turnover (fraction
93 of AUM).
94 - ``mean_weekly_turnover``: mean of weekly-aggregated turnover
95 (fraction of AUM).
96 - ``turnover_std``: standard deviation of daily turnover (fraction of
97 AUM); complements the mean to detect regime switches.
99 Returns:
100 pl.DataFrame: One row per metric with columns ``'metric'`` and
101 ``'value'``.
103 Examples:
104 >>> from jquantstats.portfolio import Portfolio
105 >>> import polars as pl
106 >>> from datetime import date, timedelta
107 >>> import numpy as np
108 >>> start = date(2020, 1, 1)
109 >>> dates = pl.date_range(start=start, end=start + timedelta(days=9), interval="1d", eager=True)
110 >>> prices = pl.DataFrame({"date": dates, "A": pl.Series(np.ones(10) * 100.0)})
111 >>> pos = pl.DataFrame({"date": dates, "A": pl.Series([float(i) * 100 for i in range(10)])})
112 >>> pf = Portfolio(prices=prices, cashposition=pos, aum=1e4)
113 >>> summary = pf.turnover_summary()
114 >>> list(summary["metric"])
115 ['mean_daily_turnover', 'mean_weekly_turnover', 'turnover_std']
116 """
117 daily_col = self.turnover["turnover"]
118 _mean = daily_col.mean()
119 mean_daily = float(_mean) if isinstance(_mean, (int, float)) else 0.0
120 _std = daily_col.std()
121 std_daily = float(_std) if isinstance(_std, (int, float)) else 0.0
122 weekly_col = self.turnover_weekly["turnover"].drop_nulls()
123 _weekly_mean = weekly_col.mean()
124 mean_weekly = (
125 float(_weekly_mean) if weekly_col.len() > 0 and isinstance(_weekly_mean, (int, float)) else float("nan")
126 )
127 return pl.DataFrame(
128 {
129 "metric": ["mean_daily_turnover", "mean_weekly_turnover", "turnover_std"],
130 "value": [mean_daily, mean_weekly, std_daily],
131 }
132 )