Coverage for src/jquantstats/_plots/_data/_distribution.py: 100%
43 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"""Return-distribution charts (histogram and per-period box plots)."""
3from __future__ import annotations
5from typing import TYPE_CHECKING
7import plotly.graph_objects as go
8import polars as pl
9from plotly.subplots import make_subplots
11from ._styling import _apply_base_layout, _ticker_colors
13if TYPE_CHECKING:
14 from jquantstats._protocol import DataLike
17class _DistributionPlotsMixin:
18 """Return-distribution plots for :class:`DataPlots`."""
20 __slots__ = ()
22 _data: DataLike
24 def histogram(self, title: str = "Returns Distribution", bins: int = 50) -> go.Figure:
25 """Overlaid return histograms, one per series.
27 Each asset (and the benchmark, when present) is drawn as a
28 semi-transparent histogram on shared axes, so the distributions can be
29 compared directly — a fat-tailed asset against a tightly peaked
30 benchmark, for instance.
32 Args:
33 title: Chart title. Defaults to ``"Returns Distribution"``.
34 bins: Number of histogram bins. Defaults to 50.
36 Returns:
37 go.Figure: Interactive Plotly histogram figure.
39 """
40 df = self._data.all
41 date_col = df.columns[0]
42 tickers = [c for c in df.columns if c != date_col]
43 colors = _ticker_colors(tickers)
45 fig = go.Figure()
46 for ticker in tickers:
47 values = df[ticker].drop_nulls().to_list()
48 fig.add_trace(
49 go.Histogram(
50 x=values,
51 name=ticker,
52 nbinsx=bins,
53 marker_color=colors[ticker],
54 opacity=0.6,
55 hovertemplate=f"{ticker}: %{{x:.2%}}<extra></extra>",
56 )
57 )
59 _apply_base_layout(fig, title, with_range_selector=False)
60 fig.update_layout(barmode="overlay")
61 fig.update_xaxes(title_text="Return", tickformat=".1%")
62 fig.update_yaxes(title_text="Count")
63 return fig
65 def distribution(
66 self,
67 title: str = "Return Distribution by Period",
68 compounded: bool = True,
69 ) -> go.Figure:
70 """Return distributions across daily, weekly, monthly, quarterly and yearly periods.
72 Renders a box plot for each aggregation period so the user can compare
73 how the distribution widens as the holding period lengthens. One
74 subplot column is produced per asset.
76 Args:
77 title: Chart title. Defaults to ``"Return Distribution by Period"``.
78 compounded: Compound returns within each period. Defaults to True.
80 Returns:
81 go.Figure: Interactive Plotly figure with one subplot per asset.
83 """
84 df = self._data.all
85 date_col = df.columns[0]
86 tickers = [c for c in df.columns if c != date_col]
87 colors = _ticker_colors(tickers)
89 periods = [
90 ("Daily", None),
91 ("Weekly", "1w"),
92 ("Monthly", "1mo"),
93 ("Quarterly", "3mo"),
94 ("Yearly", "1y"),
95 ]
97 n_assets = len(tickers)
98 fig = make_subplots(
99 rows=1,
100 cols=n_assets,
101 subplot_titles=tickers,
102 shared_yaxes=True,
103 )
105 for col_idx, ticker in enumerate(tickers, start=1):
106 for period_name, trunc in periods:
107 if trunc is None:
108 values = df[ticker].drop_nulls().to_list()
109 else:
110 agg_expr = (
111 ((1.0 + pl.col(ticker)).product() - 1.0).alias("ret")
112 if compounded
113 else pl.col(ticker).sum().alias("ret")
114 )
115 agg_df = (
116 df.with_columns(pl.col(date_col).dt.truncate(trunc).alias("_period"))
117 .group_by("_period")
118 .agg(agg_expr)
119 )
120 values = agg_df["ret"].drop_nulls().to_list()
122 fig.add_trace(
123 go.Box(
124 y=values,
125 name=period_name,
126 marker_color=colors[ticker],
127 showlegend=(col_idx == 1),
128 legendgroup=period_name,
129 boxpoints="outliers",
130 hovertemplate=f"{period_name}: %{{y:.2%}}<extra></extra>",
131 ),
132 row=1,
133 col=col_idx,
134 )
136 fig.update_layout(
137 title=title,
138 height=500,
139 plot_bgcolor="white",
140 legend={"orientation": "h", "yanchor": "bottom", "y": 1.05, "xanchor": "right", "x": 1},
141 )
142 fig.update_yaxes(tickformat=".1%", showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
143 fig.update_xaxes(showgrid=False)
144 return fig