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

51 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 02:21 +0000

1from __future__ import annotations 

2 

3import dataclasses 

4from typing import TYPE_CHECKING 

5 

6import plotly.express as px 

7import plotly.graph_objects as go 

8import polars as pl 

9from plotly.subplots import make_subplots 

10 

11if TYPE_CHECKING: 

12 from ._data import Data 

13 

14 

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

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

17 hex_color = hex_color.lstrip("#") 

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

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

20 

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

22 date_col = returns.columns[0] 

23 

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

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

26 

27 # Calculate cumulative returns (prices) 

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

29 

30 palette = px.colors.qualitative.Plotly 

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

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

33 

34 # Resample to monthly returns 

35 monthly_returns = returns.group_by_dynamic( 

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

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

38 

39 # Create subplot grid with domain for stats table 

40 fig = make_subplots( 

41 rows=3, 

42 cols=1, 

43 shared_xaxes=True, 

44 row_heights=[0.5, 0.25, 0.25], 

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

46 vertical_spacing=0.05, 

47 ) 

48 

49 # --- Row 1: Cumulative Returns 

50 for ticker in tickers: 

51 price_col = f"{ticker}_price" 

52 fig.add_trace( 

53 go.Scatter( 

54 x=prices[date_col], 

55 y=prices[price_col], 

56 mode="lines", 

57 name=ticker, 

58 legendgroup=ticker, 

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

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

61 showlegend=True, 

62 ), 

63 row=1, 

64 col=1, 

65 ) 

66 

67 # --- Row 2: Drawdowns 

68 for ticker in tickers: 

69 price_col = f"{ticker}_price" 

70 # Calculate drawdowns using polars 

71 price_series = prices[price_col] 

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

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

74 

75 fig.add_trace( 

76 go.Scatter( 

77 x=prices[date_col], 

78 y=dd_values, 

79 mode="lines", 

80 fill="tozeroy", 

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

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

83 name=ticker, 

84 legendgroup=ticker, 

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

86 showlegend=False, 

87 ), 

88 row=2, 

89 col=1, 

90 ) 

91 

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

93 

94 # --- Row 3: Monthly Returns 

95 for ticker in tickers: 

96 # Get monthly returns values as a list for coloring 

97 monthly_values = monthly_returns[ticker].to_list() 

98 

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

100 if len(tickers) == 1: 

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

102 else: 

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

104 

105 fig.add_trace( 

106 go.Bar( 

107 x=monthly_returns[date_col], 

108 y=monthly_returns[ticker], 

109 name=ticker, 

110 legendgroup=ticker, 

111 marker={ 

112 "color": bar_colors, 

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

114 }, 

115 opacity=0.8, 

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

117 showlegend=False, 

118 ), 

119 row=3, 

120 col=1, 

121 ) 

122 

123 # Layout 

124 fig.update_layout( 

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

126 height=1200, 

127 hovermode="x unified", 

128 plot_bgcolor="white", 

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

130 xaxis={ 

131 "rangeselector": { 

132 "buttons": [ 

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

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

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

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

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

138 ] 

139 }, 

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

141 "type": "date", 

142 }, 

143 ) 

144 

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

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

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

148 

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

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

151 

152 if log_scale: 

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

154 

155 return fig 

156 

157 

158@dataclasses.dataclass(frozen=True) 

159class Plots: 

160 """Visualization tools for financial returns data. 

161 

162 This class provides methods for creating various plots and visualizations 

163 of financial returns data, including: 

164 

165 - Returns bar charts 

166 - Portfolio performance snapshots 

167 - Monthly returns heatmaps 

168 

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

170 for creating interactive visualizations. 

171 

172 Attributes: 

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

174 

175 """ 

176 

177 data: Data 

178 

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

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

181 

182 This function generates a three-panel plot showing: 

183 1. Cumulative returns over time 

184 2. Drawdowns over time 

185 3. Daily returns over time 

186 

187 This provides a complete visual summary of portfolio performance. 

188 

189 Args: 

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

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

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

193 Defaults to False. 

194 

195 Returns: 

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

197 

198 Example: 

199 >>> import polars as pl 

200 >>> from jquantstats.api import build_data 

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

202 >>> returns = pl.DataFrame({ 

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

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

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

206 >>> data = build_data(returns=returns) 

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

208 >>> # Optional: display the interactive figure 

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

210 

211 """ 

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

213 return fig