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

1"""Return-distribution charts (histogram and per-period box plots).""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING 

6 

7import plotly.graph_objects as go 

8import polars as pl 

9from plotly.subplots import make_subplots 

10 

11from ._styling import _apply_base_layout, _ticker_colors 

12 

13if TYPE_CHECKING: 

14 from jquantstats._protocol import DataLike 

15 

16 

17class _DistributionPlotsMixin: 

18 """Return-distribution plots for :class:`DataPlots`.""" 

19 

20 __slots__ = () 

21 

22 _data: DataLike 

23 

24 def histogram(self, title: str = "Returns Distribution", bins: int = 50) -> go.Figure: 

25 """Overlaid return histograms, one per series. 

26 

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. 

31 

32 Args: 

33 title: Chart title. Defaults to ``"Returns Distribution"``. 

34 bins: Number of histogram bins. Defaults to 50. 

35 

36 Returns: 

37 go.Figure: Interactive Plotly histogram figure. 

38 

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) 

44 

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 ) 

58 

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 

64 

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. 

71 

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. 

75 

76 Args: 

77 title: Chart title. Defaults to ``"Return Distribution by Period"``. 

78 compounded: Compound returns within each period. Defaults to True. 

79 

80 Returns: 

81 go.Figure: Interactive Plotly figure with one subplot per asset. 

82 

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) 

88 

89 periods = [ 

90 ("Daily", None), 

91 ("Weekly", "1w"), 

92 ("Monthly", "1mo"), 

93 ("Quarterly", "3mo"), 

94 ("Yearly", "1y"), 

95 ] 

96 

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 ) 

104 

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() 

121 

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 ) 

135 

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