Coverage for src/jquantstats/_plots/_specs/_distribution.py: 100%
30 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-23 04:11 +0000
1"""Spec builders for the return-distribution charts.
3`histogram_spec` overlays the whole return distribution of each series on one
4pair of axes. `distribution_spec` instead asks how the distribution *widens* as
5the holding period lengthens, which needs a panel per asset.
6"""
8from __future__ import annotations
10from typing import TYPE_CHECKING
12import polars as pl
14from .._spec import Axis, BoxSeries, FigureSpec, HistogramSeries, HoverSpec, Panel
15from .._style import ticker_colors
17if TYPE_CHECKING:
18 from jquantstats._protocol import DataLike
20__all__ = ["distribution_spec", "histogram_spec"]
22# Overlaid distributions have to be seen through one another.
23_OVERLAY_OPACITY = 0.6
25# Holding periods, shortest first, so the widening reads left to right.
26# None means "no aggregation": the raw per-observation returns.
27_PERIODS: tuple[tuple[str, str | None], ...] = (
28 ("Daily", None),
29 ("Weekly", "1w"),
30 ("Monthly", "1mo"),
31 ("Quarterly", "3mo"),
32 ("Yearly", "1y"),
33)
35_PANEL_HEIGHT_PX = 500
38def _split_columns(frame: pl.DataFrame) -> tuple[str, list[str]]:
39 """Separate the date column from the value columns.
41 Args:
42 frame: A frame whose first column is the date axis.
44 Returns:
45 tuple[str, list[str]]: The date column name and every other column.
47 """
48 date_col = frame.columns[0]
49 return date_col, [c for c in frame.columns if c != date_col]
52def histogram_spec(data: DataLike, title: str, bins: int) -> FigureSpec:
53 """Describe the overlaid return-distribution histogram.
55 Args:
56 data: The dataset to plot.
57 title: Chart title.
58 bins: Number of histogram bins.
60 Returns:
61 FigureSpec: One translucent histogram per column, on shared axes.
63 """
64 df = data.all
65 _, tickers = _split_columns(df)
66 colors = ticker_colors(tickers)
68 panel = Panel(
69 histograms=tuple(
70 HistogramSeries(
71 name=ticker,
72 values=df[ticker].drop_nulls().to_list(),
73 color=colors[ticker],
74 bins=bins,
75 opacity=_OVERLAY_OPACITY,
76 hover=HoverSpec(
77 label=ticker,
78 value_format="percent2",
79 date_header=False,
80 axis="x",
81 hide_extra=True,
82 ),
83 )
84 for ticker in tickers
85 ),
86 xaxis=Axis(title="Return", tick_format="percent1"),
87 yaxis=Axis(title="Count"),
88 )
89 # No range selector: the x-axis is return magnitude, not time.
90 return FigureSpec(title=title, panels=(panel,), date_range_selector=False, bar_mode="overlay")
93def _period_values(df: pl.DataFrame, date_col: str, ticker: str, trunc: str | None, compounded: bool) -> list[float]:
94 """Aggregate one ticker's returns into buckets of a given length.
96 Args:
97 df: The combined index/returns frame.
98 date_col: Name of the date column.
99 ticker: Column to aggregate.
100 trunc: Polars duration to bucket by, or None for no aggregation.
101 compounded: Compound returns within each bucket.
103 Returns:
104 list[float]: One value per bucket, nulls dropped.
106 """
107 if trunc is None:
108 return df[ticker].drop_nulls().to_list()
110 agg = ((1.0 + pl.col(ticker)).product() - 1.0) if compounded else pl.col(ticker).sum()
111 bucketed = (
112 df.with_columns(pl.col(date_col).dt.truncate(trunc).alias("_period")).group_by("_period").agg(agg.alias("ret"))
113 )
114 return bucketed["ret"].drop_nulls().to_list()
117def distribution_spec(data: DataLike, title: str, compounded: bool) -> FigureSpec:
118 """Describe the by-holding-period distribution chart.
120 One panel per asset, each holding a box per period, so the widening of the
121 distribution with holding length can be compared across assets.
123 Args:
124 data: The dataset to plot.
125 title: Chart title.
126 compounded: Compound returns within each period.
128 Returns:
129 FigureSpec: One side-by-side panel per asset, sharing a vertical scale.
131 """
132 df = data.all
133 date_col, tickers = _split_columns(df)
134 colors = ticker_colors(tickers)
136 panels = tuple(
137 Panel(
138 boxes=tuple(
139 BoxSeries(
140 name=period_name,
141 values=_period_values(df, date_col, ticker, trunc, compounded),
142 color=colors[ticker],
143 # The same periods repeat in every panel, so only the
144 # first names them; the groups tie them together.
145 show_legend=(index == 0),
146 legend_group=period_name,
147 hover=HoverSpec(
148 label=period_name,
149 value_format="percent2",
150 date_header=False,
151 hide_extra=True,
152 ),
153 )
154 for period_name, trunc in _PERIODS
155 ),
156 title=ticker,
157 yaxis=Axis(tick_format="percent1"),
158 )
159 for index, ticker in enumerate(tickers)
160 )
162 return FigureSpec(
163 title=title,
164 panels=panels,
165 height=_PANEL_HEIGHT_PX,
166 chrome="panels",
167 arrangement="side_by_side",
168 shared_y=True,
169 )