Coverage for src/jquantstats/_plots/_data/_periodic.py: 100%

80 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Periodic-return bar charts and the monthly-return heatmap.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING 

6 

7import plotly.graph_objects as go 

8import polars as pl 

9 

10from ._styling import _apply_base_layout, _bar_colors, _ticker_colors, _yearly_bar_colors 

11 

12if TYPE_CHECKING: 

13 from jquantstats._protocol import DataLike 

14 

15 

16def _period_agg_exprs(tickers: list[str], compounded: bool) -> list[pl.Expr]: 

17 """Per-ticker aggregation expressions for a period bucket. 

18 

19 Args: 

20 tickers: Asset column names to aggregate. 

21 compounded: Compound returns within the bucket when True, sum them 

22 when False. 

23 

24 Returns: 

25 One aliased expression per ticker. 

26 """ 

27 if compounded: 

28 return [((1.0 + pl.col(t)).product() - 1.0).alias(t) for t in tickers] 

29 return [pl.col(t).sum().alias(t) for t in tickers] 

30 

31 

32def _monthly_heatmap_matrix( 

33 monthly: pl.DataFrame, years: list[int] 

34) -> tuple[list[list[float | None]], list[list[str]]]: 

35 """Build the year-by-month value and label grids for the monthly heatmap. 

36 

37 Args: 

38 monthly: Aggregated frame with ``_year``, ``_month`` and ``ret`` columns. 

39 years: Sorted unique years, defining the row order of the output grids. 

40 

41 Returns: 

42 A ``(z, text)`` tuple: ``z`` holds returns scaled to percent (``None`` 

43 for missing cells) and ``text`` the formatted per-cell labels. 

44 

45 """ 

46 year_idx = {y: i for i, y in enumerate(years)} 

47 z: list[list[float | None]] = [[None] * 12 for _ in years] 

48 text: list[list[str]] = [[""] * 12 for _ in years] 

49 for row in monthly.iter_rows(named=True): 

50 yi = year_idx[row["_year"]] 

51 mi = row["_month"] - 1 

52 val = row["ret"] 

53 z[yi][mi] = val * 100 if val is not None else None 

54 text[yi][mi] = f"{val:.1%}" if val is not None else "" 

55 return z, text 

56 

57 

58class _PeriodicPlotsMixin: 

59 """Daily/monthly/yearly bar charts and the monthly heatmap for :class:`DataPlots`.""" 

60 

61 __slots__ = () 

62 

63 _data: DataLike 

64 

65 def daily_returns(self, title: str = "Daily Returns") -> go.Figure: 

66 """Daily returns as a bar chart. 

67 

68 Each bar is coloured green for positive returns and red for negative 

69 returns. When multiple assets are present each asset gets its own 

70 trace in the palette colour with opacity used for positive/negative 

71 differentiation. 

72 

73 Args: 

74 title: Chart title. Defaults to ``"Daily Returns"``. 

75 

76 Returns: 

77 go.Figure: Interactive Plotly bar chart. 

78 

79 """ 

80 df = self._data.all 

81 date_col = df.columns[0] 

82 tickers = [c for c in df.columns if c != date_col] 

83 colors = _ticker_colors(tickers) 

84 single = len(tickers) == 1 

85 

86 fig = go.Figure() 

87 for ticker in tickers: 

88 values = df[ticker].to_list() 

89 bar_colors = _bar_colors(values, colors[ticker], single_asset=single) 

90 

91 fig.add_trace( 

92 go.Bar( 

93 x=df[date_col], 

94 y=df[ticker], 

95 name=ticker, 

96 marker={"color": bar_colors, "line": {"width": 0}}, 

97 opacity=0.85, 

98 hovertemplate=f"{ticker}: %{{y:.2%}}", 

99 ) 

100 ) 

101 

102 _apply_base_layout(fig, title) 

103 fig.update_yaxes(title_text="Return", tickformat=".1%") 

104 return fig 

105 

106 def yearly_returns(self, title: str = "Yearly Returns", compounded: bool = True) -> go.Figure: 

107 """Annual compounded (or summed) returns as a grouped bar chart. 

108 

109 Args: 

110 title: Chart title. Defaults to ``"Yearly Returns"``. 

111 compounded: Compound returns within each year. Defaults to True. 

112 

113 Returns: 

114 go.Figure: Interactive Plotly grouped bar chart. 

115 

116 """ 

117 df = self._data.all 

118 date_col = df.columns[0] 

119 tickers = [c for c in df.columns if c != date_col] 

120 colors = _ticker_colors(tickers) 

121 

122 agg_exprs = _period_agg_exprs(tickers, compounded) 

123 yearly = ( 

124 df.with_columns(pl.col(date_col).dt.year().alias("_year")).group_by("_year").agg(agg_exprs).sort("_year") 

125 ) 

126 

127 fig = go.Figure() 

128 for ticker in tickers: 

129 bar_colors = _yearly_bar_colors(yearly[ticker].to_list(), colors[ticker]) 

130 fig.add_trace( 

131 go.Bar( 

132 x=yearly["_year"], 

133 y=yearly[ticker], 

134 name=ticker, 

135 marker={"color": bar_colors, "line": {"width": 0}}, 

136 opacity=0.85, 

137 hovertemplate=f"{ticker}: %{{y:.2%}}", 

138 ) 

139 ) 

140 

141 _apply_base_layout(fig, title, with_range_selector=False) 

142 fig.update_layout(barmode="group", xaxis_title="Year") 

143 fig.update_yaxes(title_text="Annual Return", tickformat=".1%") 

144 return fig 

145 

146 def monthly_returns(self, title: str = "Monthly Returns", compounded: bool = True) -> go.Figure: 

147 """Monthly compounded (or summed) returns as a bar chart. 

148 

149 Args: 

150 title: Chart title. Defaults to ``"Monthly Returns"``. 

151 compounded: Compound returns within each month. Defaults to True. 

152 

153 Returns: 

154 go.Figure: Interactive Plotly bar chart. 

155 

156 """ 

157 df = self._data.all 

158 date_col = df.columns[0] 

159 tickers = [c for c in df.columns if c != date_col] 

160 colors = _ticker_colors(tickers) 

161 single = len(tickers) == 1 

162 

163 monthly = df.group_by_dynamic( 

164 index_column=date_col, every="1mo", period="1mo", closed="right", label="right" 

165 ).agg(_period_agg_exprs(tickers, compounded)) 

166 

167 fig = go.Figure() 

168 for ticker in tickers: 

169 values = monthly[ticker].to_list() 

170 bar_colors = _bar_colors(values, colors[ticker], single_asset=single) 

171 

172 fig.add_trace( 

173 go.Bar( 

174 x=monthly[date_col], 

175 y=monthly[ticker], 

176 name=ticker, 

177 marker={"color": bar_colors, "line": {"width": 0}}, 

178 opacity=0.85, 

179 hovertemplate=f"{ticker}: %{{y:.2%}}", 

180 ) 

181 ) 

182 

183 _apply_base_layout(fig, title) 

184 fig.update_yaxes(title_text="Monthly Return", tickformat=".1%") 

185 return fig 

186 

187 def monthly_heatmap( 

188 self, 

189 title: str = "Monthly Returns Heatmap", 

190 compounded: bool = True, 

191 asset: str | None = None, 

192 ) -> go.Figure: 

193 """Monthly returns calendar heatmap (year x month). 

194 

195 One heatmap is produced per call for a single asset. Green cells 

196 indicate positive months; red cells indicate negative months. 

197 

198 Args: 

199 title: Chart title. Defaults to ``"Monthly Returns Heatmap"``. 

200 compounded: Compound intra-month returns. Defaults to True. 

201 asset: Asset column name to display. Defaults to the first 

202 non-date column in the dataset. 

203 

204 Returns: 

205 go.Figure: Interactive Plotly heatmap. 

206 

207 """ 

208 df = self._data.all 

209 date_col = df.columns[0] 

210 tickers = [c for c in df.columns if c != date_col] 

211 col = asset if asset in tickers else tickers[0] 

212 

213 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] 

214 

215 agg_expr = ((1.0 + pl.col(col)).product() - 1.0).alias("ret") if compounded else pl.col(col).sum().alias("ret") 

216 monthly = ( 

217 df.with_columns( 

218 [ 

219 pl.col(date_col).dt.year().alias("_year"), 

220 pl.col(date_col).dt.month().alias("_month"), 

221 ] 

222 ) 

223 .group_by(["_year", "_month"]) 

224 .agg(agg_expr.alias("ret")) 

225 .sort(["_year", "_month"]) 

226 ) 

227 

228 years = sorted(monthly["_year"].unique().to_list()) 

229 z, text = _monthly_heatmap_matrix(monthly, years) 

230 

231 fig = go.Figure( 

232 go.Heatmap( 

233 x=month_names, 

234 y=[str(y) for y in years], 

235 z=z, 

236 text=text, 

237 texttemplate="%{text}", 

238 colorscale=[[0, "#d62728"], [0.5, "#ffffff"], [1, "#2ca02c"]], 

239 zmid=0, 

240 showscale=True, 

241 colorbar={"title": "Return (%)"}, 

242 hovertemplate="<b>%{y} %{x}</b><br>Return: %{text}<extra></extra>", 

243 ) 

244 ) 

245 

246 fig.update_layout( 

247 title=f"{title}{col}", 

248 height=max(300, 40 * len(years) + 100), 

249 plot_bgcolor="white", 

250 xaxis={"side": "top"}, 

251 ) 

252 return fig