Coverage for src/jquantstats/_stats/_periodic.py: 100%
84 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Period-bucketed reporting tables for financial returns data.
3Tabular, period-grouped views of returns: the monthly-returns pivot, the
4inlier/outlier distribution across calendar frequencies, the benchmark
5comparison table, and the worst-N-periods list.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING, cast
12import polars as pl
14from ..exceptions import NoBenchmarkError
16if TYPE_CHECKING:
17 from ..data import Data
19# ── Periodic reporting mixin ──────────────────────────────────────────────────
22class _PeriodicReportingMixin:
23 """Mixin providing period-bucketed reporting tables.
25 Covers: monthly-returns pivot table, distribution across calendar
26 frequencies (daily…yearly), benchmark comparison table, and worst-N periods.
27 """
29 _data: Data
30 all: pl.DataFrame
32 if TYPE_CHECKING:
33 from .._protocol import DataLike
35 data: DataLike
37 def monthly_returns(self, eoy: bool = True, compounded: bool = True) -> dict[str, pl.DataFrame]:
38 """Calculate monthly returns in a pivot-table format.
40 Groups returns by calendar month and year, producing a DataFrame with
41 years as rows and months (JAN-DEC) as columns, plus an optional EOY
42 column with the full-year compounded return.
44 Args:
45 eoy (bool): Include an EOY column with the annual compounded return.
46 Defaults to True.
47 compounded (bool): Compound returns within each period. Defaults to True.
49 Returns:
50 dict[str, pl.DataFrame]: Per-asset pivot tables with columns
51 ``year``, ``JAN`` … ``DEC``, and optionally ``EOY``.
53 """
54 all_df = self.all
55 date_col_name = self._data.date_col[0]
56 month_names = {
57 1: "JAN",
58 2: "FEB",
59 3: "MAR",
60 4: "APR",
61 5: "MAY",
62 6: "JUN",
63 7: "JUL",
64 8: "AUG",
65 9: "SEP",
66 10: "OCT",
67 11: "NOV",
68 12: "DEC",
69 }
70 month_order = list(month_names.values())
72 result: dict[str, pl.DataFrame] = {}
73 for col, series in self._data.items():
74 df = pl.DataFrame({"date": all_df[date_col_name], "ret": series}).drop_nulls()
75 df = df.with_columns(
76 [
77 pl.col("date").dt.year().alias("year"),
78 pl.col("date").dt.month().alias("month_num"),
79 ]
80 )
82 agg_expr = ((1.0 + pl.col("ret")).product() - 1.0) if compounded else pl.col("ret").sum()
83 monthly = (
84 df.group_by(["year", "month_num"])
85 .agg(agg_expr.alias("ret"))
86 .with_columns(
87 pl.col("month_num")
88 .replace_strict(
89 list(month_names.keys()),
90 list(month_names.values()),
91 return_dtype=pl.String,
92 )
93 .alias("month_name")
94 )
95 .sort(["year", "month_num"])
96 )
98 pivoted = monthly.pivot(on="month_name", index="year", values="ret", aggregate_function="first")
99 for m in month_order:
100 if m not in pivoted.columns:
101 pivoted = pivoted.with_columns(pl.lit(0.0).alias(m))
102 pivoted = (
103 pivoted.select(["year", *month_order])
104 .fill_null(0.0)
105 .with_columns(pl.col("year").cast(pl.Int32))
106 .sort("year")
107 )
109 if eoy:
110 eoy_agg = (
111 df.group_by("year")
112 .agg(agg_expr.alias("EOY"))
113 .with_columns(pl.col("year").cast(pl.Int32))
114 .sort("year")
115 )
116 pivoted = pivoted.join(eoy_agg, on="year").sort("year")
118 result[col] = pivoted
119 return result
121 def distribution(self, compounded: bool = True) -> dict[str, dict[str, dict[str, list[float]]]]:
122 """Analyse return distributions across daily, weekly, monthly, quarterly, and yearly periods.
124 For each period, splits values into inliers and outliers using the
125 IQR method (1.5 * IQR beyond Q1/Q3).
127 Args:
128 compounded (bool): Compound returns within each period. Defaults to True.
130 Returns:
131 dict: Nested dict ``{asset: {period: {"values": [...], "outliers": [...]}}}``
132 where period is one of ``"Daily"``, ``"Weekly"``, ``"Monthly"``,
133 ``"Quarterly"``, ``"Yearly"``.
135 """
136 all_df = self.all
137 date_col_name = self._data.date_col[0]
139 def _agg(df: pl.DataFrame, group_col: str) -> pl.Series:
140 """Aggregate returns within each group using product or sum."""
141 expr = ((1.0 + pl.col("ret")).product() - 1.0) if compounded else pl.col("ret").sum()
142 return df.group_by(group_col).agg(expr.alias("ret"))["ret"]
144 def _iqr_split(s: pl.Series) -> dict[str, list[float]]:
145 """Split series into inliers and outliers using the IQR method."""
146 q1 = cast(float, s.quantile(0.25))
147 q3 = cast(float, s.quantile(0.75))
148 iqr = q3 - q1
149 mask = (s >= q1 - 1.5 * iqr) & (s <= q3 + 1.5 * iqr)
150 return {"values": s.filter(mask).to_list(), "outliers": s.filter(~mask).to_list()}
152 result: dict[str, dict[str, dict[str, list[float]]]] = {}
153 for col, series in self._data.items():
154 df = pl.DataFrame({"date": all_df[date_col_name], "ret": series}).drop_nulls()
155 df = df.with_columns(
156 [
157 pl.col("date").dt.truncate("1w").alias("week"),
158 pl.col("date").dt.truncate("1mo").alias("month"),
159 pl.col("date").dt.truncate("3mo").alias("quarter"),
160 pl.col("date").dt.truncate("1y").alias("year"),
161 ]
162 )
163 result[col] = {
164 "Daily": _iqr_split(df["ret"]),
165 "Weekly": _iqr_split(_agg(df, "week")),
166 "Monthly": _iqr_split(_agg(df, "month")),
167 "Quarterly": _iqr_split(_agg(df, "quarter")),
168 "Yearly": _iqr_split(_agg(df, "year")),
169 }
170 return result
172 def compare(
173 self,
174 aggregate: str | None = None,
175 compounded: bool = True,
176 round_vals: int | None = None,
177 ) -> dict[str, pl.DataFrame]:
178 """Compare each asset's returns against the benchmark.
180 Aligns returns and benchmark by date, multiplies by 100 (percentage),
181 then computes a ``Multiplier`` (Returns / Benchmark) and ``Won``
182 indicator (``"+"`` when the asset outperformed, ``"-"`` otherwise).
184 Args:
185 aggregate (str | None): Pandas-style resample frequency for
186 period aggregation (e.g. ``"ME"``, ``"QE"``, ``"YE"``).
187 ``None`` returns daily rows. Defaults to None.
188 compounded (bool): Compound returns when aggregating. Defaults to True.
189 round_vals (int | None): Decimal places to round. Defaults to None.
191 Returns:
192 dict[str, pl.DataFrame]: Per-asset DataFrames with columns
193 ``Benchmark``, ``Returns``, ``Multiplier``, ``Won``.
195 Raises:
196 AttributeError: If no benchmark data is attached.
198 """
199 if self._data.benchmark is None:
200 raise NoBenchmarkError
202 all_df = self.all
203 date_col_name = self._data.date_col[0]
204 bench_col = self._data.benchmark.columns[0]
206 _freq_map = {"ME": "1mo", "QE": "3mo", "YE": "1y", "W": "1w"}
208 def _agg_series(df: pl.DataFrame, period_col: str, val_col: str) -> pl.DataFrame:
209 """Aggregate a value column grouped by period using product or sum."""
210 expr = ((1.0 + pl.col(val_col)).product() - 1.0) if compounded else pl.col(val_col).sum()
211 return df.group_by(period_col).agg(expr.alias(val_col)).sort(period_col)
213 result: dict[str, pl.DataFrame] = {}
214 for col in self._data.returns.columns:
215 df = all_df.select(
216 [
217 pl.col(date_col_name),
218 pl.col(col).alias("ret"),
219 pl.col(bench_col).alias("bench"),
220 ]
221 )
223 if aggregate is not None and aggregate in _freq_map:
224 trunc = _freq_map[aggregate]
225 df = df.with_columns(pl.col(date_col_name).dt.truncate(trunc).alias("period"))
226 ret_agg = _agg_series(df.drop_nulls(subset=["ret"]), "period", "ret")
227 bench_agg = _agg_series(df.drop_nulls(subset=["bench"]), "period", "bench")
228 df = ret_agg.join(bench_agg, on="period", how="full", coalesce=True).sort("period")
229 ret_col, bench_col_name, _date_alias = "ret", "bench", "period"
230 else:
231 ret_col, bench_col_name, _date_alias = "ret", "bench", date_col_name
233 ret_pct = (df[ret_col] * 100).alias("Returns")
234 bench_pct = (df[bench_col_name] * 100).alias("Benchmark")
235 out = pl.DataFrame(
236 {
237 "Benchmark": bench_pct,
238 "Returns": ret_pct,
239 }
240 )
241 out = out.with_columns(
242 [
243 (pl.col("Returns") / pl.col("Benchmark").replace(0.0, None)).alias("Multiplier"),
244 pl.when(pl.col("Returns") >= pl.col("Benchmark"))
245 .then(pl.lit("+"))
246 .otherwise(pl.lit("-"))
247 .alias("Won"),
248 ]
249 )
251 if round_vals is not None:
252 out = out.with_columns(
253 [
254 pl.col("Benchmark").round(round_vals),
255 pl.col("Returns").round(round_vals),
256 pl.col("Multiplier").round(round_vals),
257 ]
258 )
260 result[col] = out
261 return result
263 def worst_n_periods(self, n: int = 5) -> dict[str, list[float | None]]:
264 """Return the N worst return periods per asset.
266 If a series has fewer than ``n`` non-null observations the list is
267 padded with ``None`` on the right.
269 Args:
270 n: Number of worst periods to return. Defaults to 5.
272 Returns:
273 dict[str, list[float | None]]: Sorted worst returns per asset.
274 """
275 result: dict[str, list[float | None]] = {}
276 for col, series in self._data.items():
277 nonnull = series.drop_nulls()
278 worst: list[float | None] = nonnull.sort(descending=False).head(n).to_list()
279 while len(worst) < n:
280 worst.append(None)
281 result[col] = worst
282 return result