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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 18:44 +0000
1"""Plotting utilities for financial returns data."""
3from __future__ import annotations
5import dataclasses
6from typing import TYPE_CHECKING
8import plotly.express as px
9import plotly.graph_objects as go
10import polars as pl
11from plotly.subplots import make_subplots
13if TYPE_CHECKING:
14 from ._protocol import DataLike
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.
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.
24 Returns:
25 A Plotly Figure containing cumulative returns, drawdowns, and monthly returns panels.
27 """
29 def hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str:
30 """Convert a hex colour string to an RGBA CSS string.
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.
36 Returns:
37 An RGBA CSS string suitable for use in Plotly colour arguments.
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})"
44 # Get the date column name from the first column of the DataFrame
45 date_col = returns.columns[0]
47 # Get the tickers (all columns except the date column)
48 tickers = [col for col in returns.columns if col != date_col]
50 # Calculate cumulative returns (prices)
51 prices = returns.with_columns([((1 + pl.col(ticker)).cum_prod()).alias(f"{ticker}_price") for ticker in tickers])
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})
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])
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 )
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 )
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()
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 )
115 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1)
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()
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]
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 )
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 )
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%")
172 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
173 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
175 if log_scale:
176 fig.update_yaxes(type="log", row=1, col=1)
178 return fig
181@dataclasses.dataclass(frozen=True)
182class DataPlots:
183 """Visualization tools for financial returns data.
185 This class provides methods for creating various plots and visualizations
186 of financial returns data, including:
188 - Returns bar charts
189 - Portfolio performance snapshots
190 - Monthly returns heatmaps
192 The class is designed to work with the _Data class and uses Plotly
193 for creating interactive visualizations.
195 Attributes:
196 data: The _Data object containing returns and benchmark data to visualize.
198 """
200 data: DataLike
202 def __repr__(self) -> str:
203 """Return a string representation of the DataPlots object."""
204 return f"DataPlots(assets={self.data.assets})"
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.
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
214 This provides a complete visual summary of portfolio performance.
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.
222 Returns:
223 go.Figure: A Plotly figure object containing the dashboard.
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
238 """
239 fig = _plot_performance_dashboard(returns=self.data.all, log_scale=log_scale)
240 return fig