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

53 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 18:44 +0000

1"""Plotting utilities for financial returns data.""" 

2 

3from __future__ import annotations 

4 

5import dataclasses 

6from typing import TYPE_CHECKING 

7 

8import plotly.express as px 

9import plotly.graph_objects as go 

10import polars as pl 

11from plotly.subplots import make_subplots 

12 

13if TYPE_CHECKING: 

14 from ._protocol import DataLike 

15 

16 

17def _plot_performance_dashboard(returns: pl.DataFrame, log_scale: bool = False) -> go.Figure: 

18 """Build a multi-panel performance dashboard figure for the given returns. 

19 

20 Args: 

21 returns: A Polars DataFrame with a date column followed by one column per asset. 

22 log_scale: Whether to use a logarithmic y-axis for cumulative returns. 

23 

24 Returns: 

25 A Plotly Figure containing cumulative returns, drawdowns, and monthly returns panels. 

26 

27 """ 

28 

29 def hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str: 

30 """Convert a hex colour string to an RGBA CSS string. 

31 

32 Args: 

33 hex_color: A hex colour string (with or without a leading ``#``). 

34 alpha: Opacity in the range [0, 1]. Defaults to 0.5. 

35 

36 Returns: 

37 An RGBA CSS string suitable for use in Plotly colour arguments. 

38 

39 """ 

40 hex_color = hex_color.lstrip("#") 

41 r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) 

42 return f"rgba({r}, {g}, {b}, {alpha})" 

43 

44 # Get the date column name from the first column of the DataFrame 

45 date_col = returns.columns[0] 

46 

47 # Get the tickers (all columns except the date column) 

48 tickers = [col for col in returns.columns if col != date_col] 

49 

50 # Calculate cumulative returns (prices) 

51 prices = returns.with_columns([((1 + pl.col(ticker)).cum_prod()).alias(f"{ticker}_price") for ticker in tickers]) 

52 

53 palette = px.colors.qualitative.Plotly 

54 colors = {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)} 

55 colors.update({f"{ticker}_light": hex_to_rgba(colors[ticker]) for ticker in tickers}) 

56 

57 # Resample to monthly returns 

58 monthly_returns = returns.group_by_dynamic( 

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

60 ).agg([((pl.col(ticker) + 1.0).product() - 1.0).alias(ticker) for ticker in tickers]) 

61 

62 # Create subplot grid with domain for stats table 

63 fig = make_subplots( 

64 rows=3, 

65 cols=1, 

66 shared_xaxes=True, 

67 row_heights=[0.5, 0.25, 0.25], 

68 subplot_titles=["Cumulative Returns", "Drawdowns", "Monthly Returns"], 

69 vertical_spacing=0.05, 

70 ) 

71 

72 # --- Row 1: Cumulative Returns 

73 for ticker in tickers: 

74 price_col = f"{ticker}_price" 

75 fig.add_trace( 

76 go.Scatter( 

77 x=prices[date_col], 

78 y=prices[price_col], 

79 mode="lines", 

80 name=ticker, 

81 legendgroup=ticker, 

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

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

84 showlegend=True, 

85 ), 

86 row=1, 

87 col=1, 

88 ) 

89 

90 # --- Row 2: Drawdowns 

91 for ticker in tickers: 

92 price_col = f"{ticker}_price" 

93 # Calculate drawdowns using polars 

94 price_series = prices[price_col] 

95 cummax = prices.select(pl.col(price_col).cum_max().alias("cummax")) 

96 dd_values = ((price_series - cummax["cummax"]) / cummax["cummax"]).to_list() 

97 

98 fig.add_trace( 

99 go.Scatter( 

100 x=prices[date_col], 

101 y=dd_values, 

102 mode="lines", 

103 fill="tozeroy", 

104 fillcolor=colors[f"{ticker}_light"], 

105 line={"color": colors[ticker], "width": 1}, 

106 name=ticker, 

107 legendgroup=ticker, 

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

109 showlegend=False, 

110 ), 

111 row=2, 

112 col=1, 

113 ) 

114 

115 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1) 

116 

117 # --- Row 3: Monthly Returns 

118 for ticker in tickers: 

119 # Get monthly returns values as a list for coloring 

120 monthly_values = monthly_returns[ticker].to_list() 

121 

122 # If there's only one ticker, use green for positive returns and red for negative returns 

123 if len(tickers) == 1: 

124 bar_colors = ["green" if val > 0 else "red" for val in monthly_values] 

125 else: 

126 bar_colors = [colors[ticker] if val > 0 else colors[f"{ticker}_light"] for val in monthly_values] 

127 

128 fig.add_trace( 

129 go.Bar( 

130 x=monthly_returns[date_col], 

131 y=monthly_returns[ticker], 

132 name=ticker, 

133 legendgroup=ticker, 

134 marker={ 

135 "color": bar_colors, 

136 "line": {"width": 0}, 

137 }, 

138 opacity=0.8, 

139 hovertemplate=f"{ticker} Monthly Return: %{{y:.2%}}", 

140 showlegend=False, 

141 ), 

142 row=3, 

143 col=1, 

144 ) 

145 

146 # Layout 

147 fig.update_layout( 

148 title=f"{' vs '.join(tickers)} Performance Dashboard", 

149 height=1200, 

150 hovermode="x unified", 

151 plot_bgcolor="white", 

152 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, 

153 xaxis={ 

154 "rangeselector": { 

155 "buttons": [ 

156 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"}, 

157 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"}, 

158 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"}, 

159 {"step": "year", "stepmode": "todate", "label": "YTD"}, 

160 {"step": "all", "label": "All"}, 

161 ] 

162 }, 

163 "rangeslider": {"visible": False}, 

164 "type": "date", 

165 }, 

166 ) 

167 

168 fig.update_yaxes(title_text="Cumulative Return", row=1, col=1, tickformat=".2f") 

169 fig.update_yaxes(title_text="Drawdown", row=2, col=1, tickformat=".0%") 

170 fig.update_yaxes(title_text="Monthly Return", row=3, col=1, tickformat=".0%") 

171 

172 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

173 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey") 

174 

175 if log_scale: 

176 fig.update_yaxes(type="log", row=1, col=1) 

177 

178 return fig 

179 

180 

181@dataclasses.dataclass(frozen=True) 

182class DataPlots: 

183 """Visualization tools for financial returns data. 

184 

185 This class provides methods for creating various plots and visualizations 

186 of financial returns data, including: 

187 

188 - Returns bar charts 

189 - Portfolio performance snapshots 

190 - Monthly returns heatmaps 

191 

192 The class is designed to work with the _Data class and uses Plotly 

193 for creating interactive visualizations. 

194 

195 Attributes: 

196 data: The _Data object containing returns and benchmark data to visualize. 

197 

198 """ 

199 

200 data: DataLike 

201 

202 def __repr__(self) -> str: 

203 """Return a string representation of the DataPlots object.""" 

204 return f"DataPlots(assets={self.data.assets})" 

205 

206 def plot_snapshot(self, title: str = "Portfolio Summary", log_scale: bool = False) -> go.Figure: 

207 """Create a comprehensive dashboard with multiple plots for portfolio analysis. 

208 

209 This function generates a three-panel plot showing: 

210 1. Cumulative returns over time 

211 2. Drawdowns over time 

212 3. Daily returns over time 

213 

214 This provides a complete visual summary of portfolio performance. 

215 

216 Args: 

217 title (str, optional): Title of the plot. Defaults to "Portfolio Summary". 

218 compounded (bool, optional): Whether to use compounded returns. Defaults to True. 

219 log_scale (bool, optional): Whether to use logarithmic scale for cumulative returns. 

220 Defaults to False. 

221 

222 Returns: 

223 go.Figure: A Plotly figure object containing the dashboard. 

224 

225 Example: 

226 >>> import polars as pl 

227 >>> from jquantstats import Data 

228 >>> # minimal demo dataset with a Date column and one asset 

229 >>> returns = pl.DataFrame({ 

230 ... "Date": ["2023-01-01", "2023-01-02", "2023-01-03"], 

231 ... "Asset": [0.01, -0.02, 0.03], 

232 ... }).with_columns(pl.col("Date").str.to_date()) 

233 >>> data = Data.from_returns(returns=returns) 

234 >>> fig = data.plots.plot_snapshot(title="My Portfolio Performance") 

235 >>> # Optional: display the interactive figure 

236 >>> fig.show() # doctest: +SKIP 

237 

238 """ 

239 fig = _plot_performance_dashboard(returns=self.data.all, log_scale=log_scale) 

240 return fig