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

44 statements  

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

1"""Multi-panel performance dashboard figure builder.""" 

2 

3from __future__ import annotations 

4 

5import plotly.graph_objects as go 

6import polars as pl 

7from plotly.subplots import make_subplots 

8 

9from ._styling import _apply_base_layout, _hex_to_rgba, _ticker_colors 

10 

11 

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

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

14 

15 Args: 

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

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

18 

19 Returns: 

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

21 

22 """ 

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

24 date_col = returns.columns[0] 

25 

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

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

28 

29 # Calculate cumulative returns (prices) 

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

31 

32 colors = _ticker_colors(tickers) 

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

34 

35 # Resample to monthly returns 

36 monthly_returns = returns.group_by_dynamic( 

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

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

39 

40 # Create subplot grid with domain for stats table 

41 fig = make_subplots( 

42 rows=3, 

43 cols=1, 

44 shared_xaxes=True, 

45 row_heights=[0.5, 0.25, 0.25], 

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

47 vertical_spacing=0.05, 

48 ) 

49 

50 _add_cumulative_traces(fig, prices, date_col, tickers, colors) 

51 _add_drawdown_traces(fig, prices, date_col, tickers, colors) 

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

53 _add_monthly_traces(fig, monthly_returns, date_col, tickers, colors) 

54 

55 _apply_base_layout(fig, f"{' vs '.join(tickers)} Performance Dashboard", height=1200) 

56 

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

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

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

60 

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

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

63 

64 if log_scale: 

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

66 

67 return fig 

68 

69 

70def _add_cumulative_traces( 

71 fig: go.Figure, 

72 prices: pl.DataFrame, 

73 date_col: str, 

74 tickers: list[str], 

75 colors: dict[str, str], 

76) -> None: 

77 """Add the row-1 cumulative-return line traces (one per ticker).""" 

78 for ticker in tickers: 

79 price_col = f"{ticker}_price" 

80 fig.add_trace( 

81 go.Scatter( 

82 x=prices[date_col], 

83 y=prices[price_col], 

84 mode="lines", 

85 name=ticker, 

86 legendgroup=ticker, 

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

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

89 showlegend=True, 

90 ), 

91 row=1, 

92 col=1, 

93 ) 

94 

95 

96def _add_drawdown_traces( 

97 fig: go.Figure, 

98 prices: pl.DataFrame, 

99 date_col: str, 

100 tickers: list[str], 

101 colors: dict[str, str], 

102) -> None: 

103 """Add the row-2 drawdown area traces (one per ticker).""" 

104 for ticker in tickers: 

105 price_col = f"{ticker}_price" 

106 # Calculate drawdowns using polars 

107 price_series = prices[price_col] 

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

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

110 

111 fig.add_trace( 

112 go.Scatter( 

113 x=prices[date_col], 

114 y=dd_values, 

115 mode="lines", 

116 fill="tozeroy", 

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

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

119 name=ticker, 

120 legendgroup=ticker, 

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

122 showlegend=False, 

123 ), 

124 row=2, 

125 col=1, 

126 ) 

127 

128 

129def _add_monthly_traces( 

130 fig: go.Figure, 

131 monthly_returns: pl.DataFrame, 

132 date_col: str, 

133 tickers: list[str], 

134 colors: dict[str, str], 

135) -> None: 

136 """Add the row-3 monthly-return bar traces (one per ticker).""" 

137 for ticker in tickers: 

138 # Get monthly returns values as a list for coloring 

139 monthly_values = monthly_returns[ticker].to_list() 

140 

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

142 if len(tickers) == 1: 

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

144 else: 

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

146 

147 fig.add_trace( 

148 go.Bar( 

149 x=monthly_returns[date_col], 

150 y=monthly_returns[ticker], 

151 name=ticker, 

152 legendgroup=ticker, 

153 marker={ 

154 "color": bar_colors, 

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

156 }, 

157 opacity=0.8, 

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

159 showlegend=False, 

160 ), 

161 row=3, 

162 col=1, 

163 )