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
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-06 04:52 +0000
1"""Multi-panel performance dashboard figure builder."""
3from __future__ import annotations
5import plotly.graph_objects as go
6import polars as pl
7from plotly.subplots import make_subplots
9from ._styling import _apply_base_layout, _hex_to_rgba, _ticker_colors
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.
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.
19 Returns:
20 A Plotly Figure containing cumulative returns, drawdowns, and monthly returns panels.
22 """
23 # Get the date column name from the first column of the DataFrame
24 date_col = returns.columns[0]
26 # Get the tickers (all columns except the date column)
27 tickers = [col for col in returns.columns if col != date_col]
29 # Calculate cumulative returns (prices)
30 prices = returns.with_columns([((1 + pl.col(ticker)).cum_prod()).alias(f"{ticker}_price") for ticker in tickers])
32 colors = _ticker_colors(tickers)
33 colors.update({f"{ticker}_light": _hex_to_rgba(colors[ticker]) for ticker in tickers})
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])
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 )
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)
55 _apply_base_layout(fig, f"{' vs '.join(tickers)} Performance Dashboard", height=1200)
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%")
61 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
62 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
64 if log_scale:
65 fig.update_yaxes(type="log", row=1, col=1)
67 return fig
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 )
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()
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 )
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()
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]
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 )