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

1"""Shared styling and figure-layout helpers for the data plots.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7import plotly.express as px 

8import plotly.graph_objects as go 

9 

10 

11def _hex_to_rgba(hex_color: str, alpha: float = 0.5) -> str: 

12 """Convert a hex colour string to an RGBA CSS string. 

13 

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. 

17 

18 Returns: 

19 An RGBA CSS string suitable for use in Plotly colour arguments. 

20 

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})" 

25 

26 

27def _ticker_colors(tickers: list[str]) -> dict[str, str]: 

28 """Map ticker names to Plotly qualitative palette colours. 

29 

30 Args: 

31 tickers: Ordered list of ticker / column names. 

32 

33 Returns: 

34 dict mapping each ticker to a hex colour string. 

35 

36 """ 

37 palette = px.colors.qualitative.Plotly 

38 return {ticker: palette[i % len(palette)] for i, ticker in enumerate(tickers)} 

39 

40 

41def _date_range_selector() -> dict[str, Any]: 

42 """Return a standard Plotly date range-selector configuration. 

43 

44 Returns: 

45 A dict suitable for ``xaxis.rangeselector``. 

46 

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 } 

57 

58 

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. 

66 

67 Sets white background, light-grey grid, horizontal legend, and an 

68 optional date range-selector on the primary x-axis. 

69 

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. 

76 

77 Returns: 

78 The same figure, mutated in-place and returned for chaining. 

79 

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 

98 

99 

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 

105 

106 

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] 

113 

114 

115def _yearly_bar_colors(values: list[float | None], positive_color: str) -> list[str]: 

116 """Bar colors for the yearly-returns chart. 

117 

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. 

122 

123 Args: 

124 values: The per-year return values; ``None`` counts as negative. 

125 positive_color: The asset's base color. 

126 

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] 

132 

133 

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. 

136 

137 Args: 

138 prices: Cumulative price (NAV) values as a plain Python list. 

139 n: Maximum number of drawdown periods to return. 

140 

141 Returns: 

142 List of dicts with keys ``start_idx``, ``end_idx``, ``valley_idx``, 

143 ``max_drawdown`` (fraction ≤ 0), sorted by severity (worst first). 

144 

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]) 

151 

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}) 

166 

167 periods.sort(key=lambda p: p["max_drawdown"]) 

168 return periods[:n]