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

70 statements  

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

1"""Cumulative-return and equity-curve line charts.""" 

2 

3from __future__ import annotations 

4 

5import math 

6from typing import TYPE_CHECKING 

7 

8import plotly.graph_objects as go 

9import polars as pl 

10 

11from ._styling import _apply_base_layout, _apply_figsize, _ticker_colors 

12 

13if TYPE_CHECKING: 

14 from jquantstats._protocol import DataLike 

15 

16 

17class _CumulativePlotsMixin: 

18 """Cumulative-return and equity-curve plots for :class:`DataPlots`.""" 

19 

20 __slots__ = () 

21 

22 _data: DataLike 

23 

24 def returns(self, title: str = "Cumulative Returns", log_scale: bool = False) -> go.Figure: 

25 """Cumulative compounded returns over time. 

26 

27 Plots ``(1 + r).cumprod()`` for every column in the dataset (including 

28 benchmark when present). 

29 

30 Args: 

31 title: Chart title. Defaults to ``"Cumulative Returns"``. 

32 log_scale: Use a logarithmic y-axis. Defaults to False. 

33 

34 Returns: 

35 go.Figure: Interactive Plotly line chart. 

36 

37 """ 

38 df = self._data.all 

39 date_col = df.columns[0] 

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

41 colors = _ticker_colors(tickers) 

42 

43 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers]) 

44 

45 fig = go.Figure() 

46 for ticker in tickers: 

47 fig.add_trace( 

48 go.Scatter( 

49 x=prices[date_col], 

50 y=prices[ticker], 

51 mode="lines", 

52 name=ticker, 

53 line={"color": colors[ticker], "width": 2}, 

54 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: %{{y:.2f}}x", 

55 ) 

56 ) 

57 

58 _apply_base_layout(fig, title) 

59 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

60 if log_scale: 

61 fig.update_yaxes(type="log") 

62 return fig 

63 

64 def compare(self, title: str = "Comparison vs Benchmark", figsize: tuple[int, int] | None = None) -> go.Figure: 

65 """Compare cumulative returns of each asset against the benchmark. 

66 

67 Args: 

68 title: Chart title. Defaults to ``"Comparison vs Benchmark"``. 

69 figsize: Optional ``(width, height)`` in pixels. 

70 

71 Returns: 

72 go.Figure: Interactive Plotly line chart. 

73 

74 Raises: 

75 AttributeError: If no benchmark data is available. 

76 

77 """ 

78 benchmark_df = getattr(self._data, "benchmark", None) 

79 if benchmark_df is None: 

80 raise AttributeError("compare() requires benchmark data to be set") # noqa: TRY003 

81 

82 df = self._data.all 

83 date_col = df.columns[0] 

84 assets = list(self._data.returns.columns) 

85 benchmarks = list(benchmark_df.columns) 

86 

87 series = assets + benchmarks 

88 colors = _ticker_colors(series) 

89 prices = df.with_columns([(1.0 + pl.col(col)).cum_prod().alias(col) for col in series]) 

90 

91 fig = go.Figure() 

92 for asset in assets: 

93 fig.add_trace( 

94 go.Scatter( 

95 x=prices[date_col], 

96 y=prices[asset], 

97 mode="lines", 

98 name=asset, 

99 line={"color": colors[asset], "width": 2}, 

100 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{asset}: %{{y:.2f}}x", 

101 ) 

102 ) 

103 for benchmark in benchmarks: 

104 fig.add_trace( 

105 go.Scatter( 

106 x=prices[date_col], 

107 y=prices[benchmark], 

108 mode="lines", 

109 name=benchmark, 

110 line={"color": colors[benchmark], "width": 2.5, "dash": "dash"}, 

111 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{benchmark}: %{{y:.2f}}x", 

112 ) 

113 ) 

114 

115 _apply_base_layout(fig, title) 

116 _apply_figsize(fig, figsize) 

117 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

118 return fig 

119 

120 def log_returns(self, title: str = "Log Returns", figsize: tuple[int, int] | None = None) -> go.Figure: 

121 """Cumulative log returns over time. 

122 

123 Plots ``log((1 + r).cumprod())`` — the natural log of the compounded 

124 growth factor — which linearises exponential growth and makes 

125 multi-asset comparisons on a common scale. 

126 

127 Args: 

128 title: Chart title. Defaults to ``"Log Returns"``. 

129 figsize: Optional ``(width, height)`` in pixels. 

130 

131 Returns: 

132 go.Figure: Interactive Plotly line chart. 

133 

134 """ 

135 df = self._data.all 

136 date_col = df.columns[0] 

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

138 colors = _ticker_colors(tickers) 

139 

140 log_prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().log(math.e).alias(t) for t in tickers]) 

141 

142 fig = go.Figure() 

143 for ticker in tickers: 

144 fig.add_trace( 

145 go.Scatter( 

146 x=log_prices[date_col], 

147 y=log_prices[ticker], 

148 mode="lines", 

149 name=ticker, 

150 line={"color": colors[ticker], "width": 2}, 

151 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: %{{y:.4f}}", 

152 ) 

153 ) 

154 

155 _apply_base_layout(fig, title) 

156 _apply_figsize(fig, figsize) 

157 fig.update_yaxes(title_text="Log Return") 

158 return fig 

159 

160 def earnings( 

161 self, 

162 start_balance: float = 1e5, 

163 title: str = "Portfolio Earnings", 

164 compounded: bool = True, 

165 ) -> go.Figure: 

166 """Dollar equity curve showing portfolio value over time. 

167 

168 Scales cumulative returns by *start_balance* so the y-axis reflects 

169 an absolute portfolio value rather than a dimensionless growth factor. 

170 

171 Args: 

172 start_balance: Starting portfolio value in currency units. 

173 Defaults to 100 000. 

174 title: Chart title. Defaults to ``"Portfolio Earnings"``. 

175 compounded: Use compounded returns (``cumprod``). When False uses 

176 cumulative sum. Defaults to True. 

177 

178 Returns: 

179 go.Figure: Interactive Plotly line chart. 

180 

181 """ 

182 df = self._data.all 

183 date_col = df.columns[0] 

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

185 colors = _ticker_colors(tickers) 

186 

187 if compounded: 

188 equity = df.with_columns([(start_balance * (1.0 + pl.col(t)).cum_prod()).alias(t) for t in tickers]) 

189 else: 

190 equity = df.with_columns([(start_balance * (1.0 + pl.col(t).cum_sum())).alias(t) for t in tickers]) 

191 

192 fig = go.Figure() 

193 for ticker in tickers: 

194 fig.add_trace( 

195 go.Scatter( 

196 x=equity[date_col], 

197 y=equity[ticker], 

198 mode="lines", 

199 name=ticker, 

200 line={"color": colors[ticker], "width": 2}, 

201 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{ticker}: $%{{y:,.0f}}", 

202 ) 

203 ) 

204 

205 _apply_base_layout(fig, title) 

206 fig.update_yaxes( 

207 title_text=f"Portfolio Value (starting ${start_balance:,.0f})", 

208 tickprefix="$", 

209 tickformat=",.0f", 

210 ) 

211 return fig