Coverage for src/jquantstats/_plots/_data/_styling.py: 100%
55 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"""Shared styling and figure-layout helpers for the data plots."""
3from __future__ import annotations
5from typing import Any
7import plotly.express as px
8import plotly.graph_objects as go
11def _hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str:
12 """Convert a hex colour string to an RGBA CSS string.
14 Args:
15 hex_color: A hex colour string (with or without a leading ``#``).
16 alpha: Opacity in the range [0, 1]. Defaults to 0.5.
18 Returns:
19 An RGBA CSS string suitable for use in Plotly colour arguments.
21 """
22 hex_color = hex_color.lstrip("#")
23 r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
24 return f"rgba({r}, {g}, {b}, {alpha})"
27def _ticker_colors(tickers: list[str]) -> dict[str, str]:
28 """Map ticker names to Plotly qualitative palette colours.
30 Args:
31 tickers: Ordered list of ticker / column names.
33 Returns:
34 dict mapping each ticker to a hex colour string.
36 """
37 palette = px.colors.qualitative.Plotly
38 return {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)}
41def _date_range_selector() -> dict[str, Any]:
42 """Return a standard Plotly date range-selector configuration.
44 Returns:
45 A dict suitable for ``xaxis.rangeselector``.
47 """
48 return {
49 "buttons": [
50 {"count": 6, "label": "6m", "step": "month", "stepmode": "backward"},
51 {"count": 1, "label": "1y", "step": "year", "stepmode": "backward"},
52 {"count": 3, "label": "3y", "step": "year", "stepmode": "backward"},
53 {"step": "year", "stepmode": "todate", "label": "YTD"},
54 {"step": "all", "label": "All"},
55 ]
56 }
59def _apply_base_layout(
60 fig: go.Figure,
61 title: str,
62 height: int = 600,
63 with_range_selector: bool = True,
64) -> go.Figure:
65 """Apply the standard jquantstats Plotly layout to a figure.
67 Sets white background, light-grey grid, horizontal legend, and an
68 optional date range-selector on the primary x-axis.
70 Args:
71 fig: The Plotly figure to style in-place.
72 title: Chart title.
73 height: Figure height in pixels. Defaults to 600.
74 with_range_selector: Attach a date range-selector to ``xaxis``.
75 Defaults to True.
77 Returns:
78 The same figure, mutated in-place and returned for chaining.
80 """
81 layout_kw: dict[str, Any] = {
82 "title": title,
83 "height": height,
84 "hovermode": "x unified",
85 "plot_bgcolor": "white",
86 "legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
87 }
88 if with_range_selector:
89 layout_kw["xaxis"] = {
90 "rangeselector": _date_range_selector(),
91 "rangeslider": {"visible": False},
92 "type": "date",
93 }
94 fig.update_layout(**layout_kw)
95 fig.update_xaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
96 fig.update_yaxes(showgrid=True, gridwidth=0.5, gridcolor="lightgrey")
97 return fig
100def _apply_figsize(fig: go.Figure, figsize: tuple[int, int] | None) -> go.Figure:
101 """Apply optional ``(width, height)`` figure size to Plotly layout."""
102 if figsize is not None:
103 fig.update_layout(width=figsize[0], height=figsize[1])
104 return fig
107def _bar_colors(values: list[float | None], positive_color: str, single_asset: bool = False) -> list[str]:
108 """Return the shared positive/negative bar colors for a series of values."""
109 if single_asset:
110 return ["#2ca02c" if v is not None and v > 0 else "#d62728" for v in values]
111 negative_color = _hex_to_rgba(positive_color, alpha=0.4)
112 return [positive_color if v is not None and v > 0 else negative_color for v in values]
115def _yearly_bar_colors(values: list[float | None], positive_color: str) -> list[str]:
116 """Bar colors for the yearly-returns chart.
118 Deliberately distinct from `_bar_colors`: the yearly chart treats a flat
119 zero year as positive (``>= 0``) and fades negatives to alpha 0.5 rather
120 than 0.4, so the two cannot share an implementation without changing what
121 is rendered.
123 Args:
124 values: The per-year return values; ``None`` counts as negative.
125 positive_color: The asset's base color.
127 Returns:
128 One color string per value.
129 """
130 negative_color = _hex_to_rgba(positive_color, 0.5)
131 return [positive_color if v is not None and v >= 0 else negative_color for v in values]
134def _compute_drawdown_periods(prices: list[float], n: int) -> list[dict[str, Any]]:
135 """Identify the top *n* drawdown periods from a cumulative price series.
137 Args:
138 prices: Cumulative price (NAV) values as a plain Python list.
139 n: Maximum number of drawdown periods to return.
141 Returns:
142 List of dicts with keys ``start_idx``, ``end_idx``, ``valley_idx``,
143 ``max_drawdown`` (fraction ≤ 0), sorted by severity (worst first).
145 """
146 length = len(prices)
147 hwm: list[float] = [0.0] * length
148 hwm[0] = prices[0]
149 for i in range(1, length):
150 hwm[i] = max(hwm[i - 1], prices[i])
152 in_dd = [prices[i] < hwm[i] for i in range(length)]
153 periods: list[dict[str, Any]] = []
154 i = 0
155 while i < length:
156 if not in_dd[i]:
157 i += 1
158 continue
159 start = i
160 while i < length and in_dd[i]:
161 i += 1
162 end = i - 1
163 valley = start + min(range(end - start + 1), key=lambda k: prices[start + k])
164 max_dd = (prices[valley] - hwm[valley]) / hwm[valley]
165 periods.append({"start_idx": start, "end_idx": end, "valley_idx": valley, "max_drawdown": max_dd})
167 periods.sort(key=lambda p: p["max_drawdown"])
168 return periods[:n]