Coverage for src/jquantstats/_plots/_data/_drawdown.py: 100%

46 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +0000

1"""Drawdown charts (underwater curve and worst-period shading).""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING 

6 

7import plotly.express as px 

8import plotly.graph_objects as go 

9import polars as pl 

10 

11from ._styling import _apply_base_layout, _compute_drawdown_periods, _hex_to_rgba, _ticker_colors 

12 

13if TYPE_CHECKING: 

14 from jquantstats._protocol import DataLike 

15 

16 

17class _DrawdownPlotsMixin: 

18 """Drawdown plots for :class:`DataPlots`.""" 

19 

20 __slots__ = () 

21 

22 _data: DataLike 

23 

24 def drawdown(self, title: str = "Drawdowns") -> go.Figure: 

25 """Underwater equity curve (drawdown) chart. 

26 

27 Shows the percentage decline from the running peak for every column 

28 in the dataset (assets and benchmark where present). 

29 

30 Args: 

31 title: Chart title. Defaults to ``"Drawdowns"``. 

32 

33 Returns: 

34 go.Figure: Interactive Plotly filled-area chart. 

35 

36 """ 

37 df = self._data.all 

38 date_col = df.columns[0] 

39 tickers = [c for c in df.columns if c != date_col] 

40 colors = _ticker_colors(tickers) 

41 

42 prices = df.with_columns([(1.0 + pl.col(t)).cum_prod().alias(t) for t in tickers]) 

43 

44 fig = go.Figure() 

45 for ticker in tickers: 

46 price_s = prices[ticker] 

47 hwm = price_s.cum_max() 

48 dd = ((price_s - hwm) / hwm).to_list() 

49 

50 fig.add_trace( 

51 go.Scatter( 

52 x=prices[date_col], 

53 y=dd, 

54 mode="lines", 

55 fill="tozeroy", 

56 fillcolor=_hex_to_rgba(colors[ticker], 0.3), 

57 line={"color": colors[ticker], "width": 1.5}, 

58 name=ticker, 

59 hovertemplate=f"{ticker}: %{{y:.2%}}", 

60 ) 

61 ) 

62 

63 fig.add_hline(y=0, line_width=1, line_color="gray") 

64 _apply_base_layout(fig, title) 

65 fig.update_yaxes(title_text="Drawdown", tickformat=".0%") 

66 return fig 

67 

68 def drawdowns_periods( 

69 self, 

70 n: int = 5, 

71 title: str = "Top Drawdown Periods", 

72 asset: str | None = None, 

73 ) -> go.Figure: 

74 """Cumulative returns chart with the worst *n* drawdown periods shaded. 

75 

76 Identifies the *n* deepest drawdown periods and overlays coloured 

77 rectangular shading on the cumulative returns line. One asset is 

78 shown per call. 

79 

80 Args: 

81 n: Number of worst drawdown periods to highlight. Defaults to 5. 

82 title: Chart title. Defaults to ``"Top Drawdown Periods"``. 

83 asset: Asset column name. Defaults to the first non-date column. 

84 

85 Returns: 

86 go.Figure: Interactive Plotly figure. 

87 

88 """ 

89 df = self._data.all 

90 date_col = df.columns[0] 

91 tickers = [c for c in df.columns if c != date_col] 

92 col = asset if asset in tickers else tickers[0] 

93 

94 price_series = (1.0 + df[col].cast(pl.Float64)).cum_prod() 

95 price_list = price_series.to_list() 

96 dates = df[date_col].to_list() 

97 

98 drawdown_periods = _compute_drawdown_periods(price_list, n) 

99 

100 dd_colors = px.colors.qualitative.Plotly 

101 

102 fig = go.Figure() 

103 fig.add_trace( 

104 go.Scatter( 

105 x=dates, 

106 y=price_list, 

107 mode="lines", 

108 name=col, 

109 line={"color": "#1f77b4", "width": 2}, 

110 hovertemplate=f"<b>%{{x|%b %Y}}</b><br>{col}: %{{y:.2f}}x", 

111 ) 

112 ) 

113 

114 for i, period in enumerate(drawdown_periods): 

115 start_date = dates[period["start_idx"]] 

116 end_date = dates[min(period["end_idx"] + 1, len(dates) - 1)] 

117 max_dd = period["max_drawdown"] 

118 shade_color = _hex_to_rgba(dd_colors[i % len(dd_colors)], alpha=0.2) 

119 

120 fig.add_vrect( 

121 x0=start_date, 

122 x1=end_date, 

123 fillcolor=shade_color, 

124 line_width=0, 

125 annotation_text=f"#{i + 1} {max_dd:.1%}", 

126 annotation_position="top left", 

127 annotation_font_size=10, 

128 ) 

129 

130 _apply_base_layout(fig, f"{title}{col}") 

131 fig.update_yaxes(title_text="Cumulative Return", tickformat=".2f") 

132 return fig