Coverage for src / basanos / analytics / _plots.py: 100%
172 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
1"""Plotting utilities for portfolio analytics using Plotly.
3This module defines the Plots facade which renders common portfolio visuals
4such as snapshots, lagged performance curves, smoothed-holdings curves, and
5lead/lag information ratio bar charts. Designed for notebook use.
7Examples:
8 >>> import dataclasses
9 >>> from basanos.analytics._plots import Plots
10 >>> dataclasses.is_dataclass(Plots)
11 True
12"""
14from __future__ import annotations
16import dataclasses
17from typing import TYPE_CHECKING
19import plotly.express as px
20import plotly.graph_objects as go
21import plotly.io as pio
22import polars as pl
23from plotly.subplots import make_subplots
25if TYPE_CHECKING:
26 # Import the local Portfolio type for type checking and documentation tools.
27 from .portfolio import Portfolio
29# Ensure Plotly works with Marimo (set after imports to satisfy linters)
30pio.renderers.default = "plotly_mimetype"
33@dataclasses.dataclass(frozen=True)
34class Plots:
35 """Facade for portfolio plots built with Plotly.
37 Provides convenience methods to visualize portfolio performance and
38 diagnostics directly from a Portfolio instance (e.g., snapshot charts,
39 lagged performance, smoothed holdings, and lead/lag IR).
40 """
42 portfolio: Portfolio
44 def lead_lag_ir_plot(self, start: int = -10, end: int = 19) -> go.Figure:
45 """Plot Sharpe ratio (IR) across lead/lag variants of the portfolio.
47 Builds portfolios with cash positions lagged from ``start`` to ``end``
48 (inclusive) and plots a bar chart of the Sharpe ratio for each lag.
49 Positive lags delay weights; negative lags lead them.
51 Args:
52 start: First lag to include (default: -10).
53 end: Last lag to include (default: +19).
55 Returns:
56 A Plotly Figure with one bar per lag labeled by the lag value.
57 """
58 if not isinstance(start, int) or not isinstance(end, int):
59 raise TypeError
60 if start > end:
61 start, end = end, start
63 lags = list(range(start, end + 1))
65 x_vals: list[int] = []
66 y_vals: list[float] = []
68 for n in lags:
69 pf = self.portfolio if n == 0 else self.portfolio.lag(n)
70 # Compute Sharpe on the portfolio's returns series
71 sharpe_val = pf.stats.sharpe().get("returns", float("nan"))
72 # Ensure a float (Stats returns mapping asset->value)
73 y_vals.append(float(sharpe_val) if sharpe_val is not None else float("nan"))
74 x_vals.append(n)
76 fig = go.Figure(
77 data=[
78 go.Bar(x=x_vals, y=y_vals, name="Sharpe by lag", marker_color="#1f77b4"),
79 ]
80 )
81 fig.update_layout(
82 title="Lead/Lag Information Ratio (Sharpe) by Lag",
83 xaxis_title="Lag (steps)",
84 yaxis_title="Sharpe ratio",
85 plot_bgcolor="white",
86 hovermode="x",
87 )
88 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
89 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
90 return fig
92 def snapshot(self, log_scale: bool = False) -> go.Figure:
93 """Return a snapshot dashboard of NAV and drawdown.
95 Args:
96 log_scale (bool, optional): If True, display NAV on a log scale. Defaults to False.
98 Returns:
99 plotly.graph_objects.Figure: A Figure with accumulated NAV (including tilt/timing)
100 and drawdown shaded area, equipped with a range selector.
101 """
102 # Create subplot grid with domain for stats table
103 fig = make_subplots(
104 rows=2,
105 cols=1,
106 shared_xaxes=True,
107 row_heights=[0.66, 0.33],
108 subplot_titles=["Accumulated Profit", "Drawdown"],
109 vertical_spacing=0.05,
110 )
112 # --- Row 1: Cumulative Returns
113 fig.add_trace(
114 go.Scatter(
115 x=self.portfolio.nav_accumulated["date"],
116 y=self.portfolio.nav_accumulated["NAV_accumulated"],
117 mode="lines",
118 name="NAV",
119 showlegend=False,
120 ),
121 row=1,
122 col=1,
123 )
125 fig.add_trace(
126 go.Scatter(
127 x=self.portfolio.tilt.nav_accumulated["date"],
128 y=self.portfolio.tilt.nav_accumulated["NAV_accumulated"],
129 mode="lines",
130 name="Tilt",
131 showlegend=False,
132 ),
133 row=1,
134 col=1,
135 )
137 fig.add_trace(
138 go.Scatter(
139 x=self.portfolio.timing.nav_accumulated["date"],
140 y=self.portfolio.timing.nav_accumulated["NAV_accumulated"],
141 mode="lines",
142 name="Timing",
143 showlegend=False,
144 ),
145 row=1,
146 col=1,
147 )
149 fig.add_trace(
150 go.Scatter(
151 x=self.portfolio.drawdown["date"],
152 y=self.portfolio.drawdown["drawdown_pct"],
153 mode="lines",
154 fill="tozeroy",
155 name="Drawdown",
156 showlegend=False,
157 ),
158 row=2,
159 col=1,
160 )
162 fig.add_hline(y=0, line_width=1, line_color="gray", row=2, col=1)
164 # Layout
165 fig.update_layout(
166 title="Performance Dashboard",
167 height=1200,
168 hovermode="x unified",
169 plot_bgcolor="white",
170 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
171 xaxis={
172 "rangeselector": {
173 "buttons": [
174 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
175 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
176 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
177 {"step": "year", "stepmode": "todate", "label": "YTD"},
178 {"step": "all", "label": "All"},
179 ]
180 },
181 "rangeslider": {"visible": False},
182 "type": "date",
183 },
184 )
186 fig.update_yaxes(title_text="NAV (accumulated)", row=1, col=1, tickformat=".2s")
187 fig.update_yaxes(title_text="Drawdown", row=2, col=1, tickformat=".0%")
189 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
190 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
192 if log_scale:
193 fig.update_yaxes(type="log", row=1, col=1)
194 # Ensure the first y-axis is explicitly set for environments
195 # where subplot updates may not propagate to layout alias.
196 if hasattr(fig.layout, "yaxis"):
197 fig.layout.yaxis.type = "log"
199 return fig
201 def lagged_performance_plot(self, lags: list[int] | None = None, log_scale: bool = False) -> go.Figure:
202 """Plot NAV_accumulated for multiple lagged portfolios.
204 Creates a Plotly figure with one line per lag value showing the
205 accumulated NAV series for the portfolio with cash positions
206 shifted by that lag. By default, lags [0, 1, 2, 3, 4] are used.
208 Args:
209 lags: A list of integer lags to apply; defaults to [0, 1, 2, 3, 4].
210 log_scale: If True, set the primary y-axis to logarithmic scale.
212 Returns:
213 A Plotly Figure containing one trace per requested lag.
214 """
215 if lags is None:
216 lags = [0, 1, 2, 3, 4]
217 if not isinstance(lags, list) or not all(isinstance(x, int) for x in lags):
218 raise TypeError
220 fig = go.Figure()
221 for lag in lags:
222 pf = self.portfolio if lag == 0 else self.portfolio.lag(lag)
223 nav = pf.nav_accumulated
224 fig.add_trace(
225 go.Scatter(
226 x=nav["date"],
227 y=nav["NAV_accumulated"],
228 mode="lines",
229 name=f"lag {lag}",
230 line={"width": 1},
231 )
232 )
234 fig.update_layout(
235 title="NAV accumulated by lag",
236 hovermode="x unified",
237 plot_bgcolor="white",
238 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
239 xaxis={
240 "rangeselector": {
241 "buttons": [
242 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
243 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
244 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
245 {"step": "year", "stepmode": "todate", "label": "YTD"},
246 {"step": "all", "label": "All"},
247 ]
248 },
249 "rangeslider": {"visible": False},
250 "type": "date",
251 },
252 )
253 fig.update_yaxes(title_text="NAV (accumulated)")
254 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
255 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
257 if log_scale:
258 fig.update_yaxes(type="log")
259 if hasattr(fig.layout, "yaxis"):
260 fig.layout.yaxis.type = "log"
262 return fig
264 def rolling_sharpe_plot(self, window: int = 63) -> go.Figure:
265 """Plot rolling annualised Sharpe ratio over time.
267 Computes the rolling Sharpe for each asset column using the given
268 window and renders one line per asset.
270 Args:
271 window: Rolling-window size in periods. Defaults to 63.
273 Returns:
274 A Plotly Figure with one trace per asset.
276 Raises:
277 ValueError: If ``window`` is not a positive integer.
278 """
279 if not isinstance(window, int) or window <= 0:
280 raise ValueError
282 rolling = self.portfolio.stats.rolling_sharpe(window=window)
284 fig = go.Figure()
285 date_col = rolling["date"] if "date" in rolling.columns else None
286 for col in rolling.columns:
287 if col == "date":
288 continue
289 fig.add_trace(
290 go.Scatter(
291 x=date_col,
292 y=rolling[col],
293 mode="lines",
294 name=col,
295 line={"width": 1},
296 )
297 )
299 fig.add_hline(y=0, line_width=1, line_dash="dash", line_color="gray")
301 fig.update_layout(
302 title=f"Rolling Sharpe Ratio ({window}-period window)",
303 hovermode="x unified",
304 plot_bgcolor="white",
305 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
306 xaxis={
307 "rangeselector": {
308 "buttons": [
309 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
310 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
311 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
312 {"step": "year", "stepmode": "todate", "label": "YTD"},
313 {"step": "all", "label": "All"},
314 ]
315 },
316 "rangeslider": {"visible": False},
317 "type": "date",
318 },
319 )
320 fig.update_yaxes(title_text="Sharpe ratio")
321 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
322 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
323 return fig
325 def rolling_volatility_plot(self, window: int = 63) -> go.Figure:
326 """Plot rolling annualised volatility over time.
328 Computes the rolling volatility for each asset column using the given
329 window and renders one line per asset.
331 Args:
332 window: Rolling-window size in periods. Defaults to 63.
334 Returns:
335 A Plotly Figure with one trace per asset.
337 Raises:
338 ValueError: If ``window`` is not a positive integer.
339 """
340 if not isinstance(window, int) or window <= 0:
341 raise ValueError
343 rolling = self.portfolio.stats.rolling_volatility(window=window)
345 fig = go.Figure()
346 date_col = rolling["date"] if "date" in rolling.columns else None
347 for col in rolling.columns:
348 if col == "date":
349 continue
350 fig.add_trace(
351 go.Scatter(
352 x=date_col,
353 y=rolling[col],
354 mode="lines",
355 name=col,
356 line={"width": 1},
357 )
358 )
360 fig.update_layout(
361 title=f"Rolling Volatility ({window}-period window)",
362 hovermode="x unified",
363 plot_bgcolor="white",
364 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
365 xaxis={
366 "rangeselector": {
367 "buttons": [
368 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
369 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
370 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
371 {"step": "year", "stepmode": "todate", "label": "YTD"},
372 {"step": "all", "label": "All"},
373 ]
374 },
375 "rangeslider": {"visible": False},
376 "type": "date",
377 },
378 )
379 fig.update_yaxes(title_text="Annualised volatility")
380 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
381 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
382 return fig
384 def annual_sharpe_plot(self) -> go.Figure:
385 """Plot annualised Sharpe ratio broken down by calendar year.
387 Computes the Sharpe ratio for each calendar year from the portfolio
388 returns and renders a grouped bar chart with one bar per year per
389 asset.
391 Returns:
392 A Plotly Figure with one bar group per asset.
393 """
394 breakdown = self.portfolio.stats.annual_breakdown()
396 # Extract the sharpe row for each year
397 sharpe_rows = breakdown.filter(pl.col("metric") == "sharpe")
398 asset_cols = [c for c in sharpe_rows.columns if c not in ("year", "metric")]
400 fig = go.Figure()
401 for asset in asset_cols:
402 fig.add_trace(
403 go.Bar(
404 x=sharpe_rows["year"],
405 y=sharpe_rows[asset],
406 name=asset,
407 )
408 )
410 fig.add_hline(y=0, line_width=1, line_color="gray")
412 fig.update_layout(
413 title="Annual Sharpe Ratio by Year",
414 barmode="group",
415 hovermode="x unified",
416 plot_bgcolor="white",
417 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
418 )
419 fig.update_yaxes(title_text="Sharpe ratio")
420 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey", title_text="Year")
421 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
422 return fig
424 def correlation_heatmap(
425 self,
426 frame: pl.DataFrame | None = None,
427 name: str = "portfolio",
428 title: str = "Correlation heatmap",
429 ) -> go.Figure:
430 """Plot a correlation heatmap for assets and the portfolio.
432 If ``frame`` is None, uses the portfolio's prices. The portfolio's
433 profit series is appended under ``name`` before computing the
434 correlation matrix.
436 Args:
437 frame: Optional Polars DataFrame with at least the asset price
438 columns. If omitted, uses ``self.portfolio.prices``.
439 name: Column name under which to include the portfolio profit.
440 title: Plot title.
442 Returns:
443 A Plotly Figure rendering the correlation matrix as a heatmap.
444 """
445 if frame is None:
446 frame = self.portfolio.prices
448 corr = self.portfolio.correlation(frame, name=name)
450 # Create an interactive heatmap
451 fig = px.imshow(
452 corr,
453 x=corr.columns,
454 y=corr.columns,
455 text_auto=".2f", # show correlation values
456 color_continuous_scale="RdBu_r", # red-blue diverging colormap
457 zmin=-1,
458 zmax=1, # correlation range
459 title=title,
460 )
462 # Adjust layout
463 fig.update_layout(
464 xaxis_title="", yaxis_title="", width=700, height=600, coloraxis_colorbar={"title": "Correlation"}
465 )
467 return fig
469 def monthly_returns_heatmap(self) -> go.Figure:
470 """Plot a monthly returns calendar heatmap.
472 Groups portfolio returns by calendar year and month, then renders a
473 Plotly heatmap with months on the x-axis and years on the y-axis.
474 Green cells indicate positive months; red cells indicate negative
475 months. Cell text shows the percentage return for that month.
477 Returns:
478 A Plotly Figure with a calendar heatmap of monthly returns.
480 Raises:
481 ValueError: If the portfolio has no ``date`` column.
482 """
483 monthly = self.portfolio.monthly
485 years = monthly["year"].unique().sort().to_list()
486 month_names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
488 z: list[list[float | None]] = []
489 text: list[list[str]] = []
490 for year in years:
491 year_data = monthly.filter(pl.col("year") == year)
492 year_row: list[float | None] = []
493 year_text: list[str] = []
494 for m in range(1, 13):
495 month_data = year_data.filter(pl.col("month") == m)
496 if month_data.is_empty():
497 year_row.append(None)
498 year_text.append("")
499 else:
500 ret = float(month_data["returns"][0])
501 year_row.append(ret * 100.0)
502 year_text.append(f"{ret * 100.0:.1f}%")
503 z.append(year_row)
504 text.append(year_text)
506 fig = go.Figure(
507 data=go.Heatmap(
508 z=z,
509 x=month_names,
510 y=[str(y) for y in years],
511 text=text,
512 texttemplate="%{text}",
513 colorscale="RdYlGn",
514 zmid=0,
515 colorbar={"title": "Return (%)"},
516 hovertemplate="<b>%{y} %{x}</b><br>Return: %{text}<extra></extra>",
517 )
518 )
520 fig.update_layout(
521 title="Monthly Returns Heatmap",
522 xaxis_title="Month",
523 yaxis_title="Year",
524 plot_bgcolor="white",
525 yaxis={"type": "category"},
526 )
528 return fig
530 def smoothed_holdings_performance_plot(
531 self,
532 windows: list[int] | None = None,
533 log_scale: bool = False,
534 ) -> go.Figure:
535 """Plot NAV_accumulated for smoothed-holding portfolios.
537 Builds portfolios with cash positions smoothed by a trailing rolling
538 mean over the previous ``n`` steps (window size n+1) for n in
539 ``windows`` (defaults to [0, 1, 2, 3, 4]) and plots their
540 accumulated NAV curves.
542 Args:
543 windows: List of non-negative integers specifying smoothing steps
544 to include; defaults to [0, 1, 2, 3, 4].
545 log_scale: If True, set the primary y-axis to logarithmic scale.
547 Returns:
548 A Plotly Figure containing one line per requested smoothing level.
549 """
550 if windows is None:
551 windows = [0, 1, 2, 3, 4]
552 if not isinstance(windows, list) or not all(isinstance(x, int) and x >= 0 for x in windows):
553 raise TypeError
555 fig = go.Figure()
556 for n in windows:
557 pf = self.portfolio if n == 0 else self.portfolio.smoothed_holding(n)
558 nav = pf.nav_accumulated
559 fig.add_trace(
560 go.Scatter(
561 x=nav["date"],
562 y=nav["NAV_accumulated"],
563 mode="lines",
564 name=f"smooth {n}",
565 line={"width": 1},
566 )
567 )
569 fig.update_layout(
570 title="NAV accumulated by smoothed holdings",
571 hovermode="x unified",
572 plot_bgcolor="white",
573 legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
574 xaxis={
575 "rangeselector": {
576 "buttons": [
577 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
578 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
579 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
580 {"step": "year", "stepmode": "todate", "label": "YTD"},
581 {"step": "all", "label": "All"},
582 ]
583 },
584 "rangeslider": {"visible": False},
585 "type": "date",
586 },
587 )
588 fig.update_yaxes(title_text="NAV (accumulated)")
589 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
590 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
592 if log_scale:
593 fig.update_yaxes(type="log")
594 if hasattr(fig.layout, "yaxis"):
595 fig.layout.yaxis.type = "log"
597 return fig
599 def trading_cost_impact_plot(self, max_bps: int = 20) -> go.Figure:
600 """Plot the Sharpe ratio as a function of one-way trading costs.
602 Evaluates the portfolio's annualised Sharpe ratio at each integer
603 cost level from 0 up to ``max_bps`` basis points and renders the
604 result as a line chart. The zero-cost Sharpe is shown as a
605 reference horizontal line so that the reader can quickly gauge
606 at what cost level the strategy's edge is eroded.
608 Args:
609 max_bps: Maximum one-way trading cost to evaluate, in basis
610 points. Defaults to 20.
612 Returns:
613 A Plotly Figure with one line trace showing Sharpe vs. cost.
615 Raises:
616 ValueError: If ``max_bps`` is not a positive integer.
617 """
618 impact = self.portfolio.trading_cost_impact(max_bps=max_bps)
620 cost_vals = impact["cost_bps"].to_list()
621 sharpe_vals = impact["sharpe"].to_list()
623 # Baseline Sharpe at zero cost
624 baseline = float(sharpe_vals[0]) if sharpe_vals and sharpe_vals[0] is not None else float("nan")
626 fig = go.Figure()
627 fig.add_trace(
628 go.Scatter(
629 x=cost_vals,
630 y=sharpe_vals,
631 mode="lines+markers",
632 name="Sharpe (cost-adjusted)",
633 marker={"size": 6},
634 line={"width": 2, "color": "#1f77b4"},
635 )
636 )
637 if baseline == baseline: # only add when baseline is finite (NaN != NaN)
638 fig.add_hline(
639 y=baseline,
640 line_width=1,
641 line_dash="dash",
642 line_color="gray",
643 annotation_text="0 bps baseline",
644 annotation_position="top right",
645 )
647 fig.update_layout(
648 title=f"Trading Cost Impact on Sharpe Ratio (0\u2013{max_bps} bps)",
649 hovermode="x unified",
650 plot_bgcolor="white",
651 )
652 fig.update_xaxes(
653 title_text="One-way cost (basis points)",
654 showgrid=True,
655 gridwidth=0.5,
656 gridcolor="lightgrey",
657 dtick=1,
658 )
659 fig.update_yaxes(
660 title_text="Annualised Sharpe ratio",
661 showgrid=True,
662 gridwidth=0.5,
663 gridcolor="lightgrey",
664 )
665 return fig