Coverage for src/jquantstats/_reports/_data.py: 100%

78 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-23 04:11 +0000

1"""Financial report generation from returns data.""" 

2 

3from __future__ import annotations 

4 

5import warnings 

6from typing import TYPE_CHECKING, Any 

7 

8import polars as pl 

9 

10if TYPE_CHECKING: 

11 from jquantstats._protocol import DataLike 

12 

13from .._plots._backend import plot_backend 

14from ._html import ( 

15 _build_full_html, 

16 _drawdowns_section_html, 

17 _metrics_table_html, 

18 _try_plotly_div, 

19) 

20from ._metrics import ( 

21 _add_drawdown_rows, 

22 _add_full_mode_rows, 

23 _add_overview_rows, 

24 _add_recent_returns_rows, 

25 _add_risk_adjusted_rows, 

26 _add_trading_rows, 

27 _build_metrics_df, 

28) 

29 

30 

31class Reports: 

32 """A class for generating financial reports from Data objects. 

33 

34 This class provides methods for calculating and formatting various financial metrics 

35 into report-ready formats such as DataFrames. 

36 """ 

37 

38 __slots__ = ("_data",) 

39 

40 def __init__(self, data: DataLike) -> None: 

41 self._data = data 

42 

43 def metrics( 

44 self, 

45 mode: str = "basic", 

46 periods_per_year: int | float = 252, 

47 rf: float = 0.0, 

48 ) -> pl.DataFrame: 

49 """Comprehensive performance metrics table matching ``qs.reports.metrics``. 

50 

51 Computes an ordered set of performance, risk, and trading metrics for 

52 every asset in the dataset and returns them as a tidy DataFrame. 

53 

54 Args: 

55 mode: ``"basic"`` (default) for core metrics, ``"full"`` for the 

56 extended set including smart ratios, expected returns, streaks, 

57 best/worst periods, win rates, and benchmark greeks. 

58 periods_per_year: Annualisation factor. Defaults to 252. 

59 rf: Annualised risk-free rate used in ratio calculations. 

60 Defaults to 0.0. 

61 

62 Returns: 

63 pl.DataFrame: One row per metric, one column per asset, plus a 

64 leading ``"Metric"`` column with the metric label. 

65 

66 """ 

67 s = self._data.stats 

68 ppy = float(periods_per_year) 

69 is_full = mode.lower() == "full" 

70 

71 rows: list[tuple[str, dict[str, Any]]] = [] 

72 

73 all_df: pl.DataFrame | None = getattr(self._data, "all", None) 

74 asset_cols: list[str] = [] 

75 date_col: str | None = None 

76 has_dates = False 

77 

78 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive 

79 date_col = all_df.columns[0] 

80 asset_cols = [c for c in all_df.columns if c != date_col] 

81 has_dates = all_df[date_col].dtype.is_temporal() 

82 

83 _add_overview_rows(rows, s, ppy) 

84 _add_risk_adjusted_rows(rows, s, ppy) 

85 _add_drawdown_rows(rows, s) 

86 _add_trading_rows(rows, s) 

87 

88 if has_dates and date_col is not None and all_df is not None: # pragma: no branch 

89 _add_recent_returns_rows(rows, all_df, date_col, asset_cols, ppy, s) 

90 

91 if is_full: 

92 _add_full_mode_rows(rows, s, ppy, self._data, all_df, date_col, asset_cols) 

93 

94 return _build_metrics_df(rows) 

95 

96 def full( 

97 self, 

98 title: str = "Performance Report", 

99 periods_per_year: int | float = 252, 

100 rf: float = 0.0, 

101 ) -> str: 

102 """Generate a self-contained HTML performance report. 

103 

104 Combines a comprehensive metrics table (full mode), worst-5 drawdown 

105 periods per asset, and interactive Plotly charts into a single 

106 dark-themed HTML document. 

107 

108 Args: 

109 title: Page ``<h1>`` title. Defaults to ``"Performance Report"``. 

110 periods_per_year: Annualisation factor passed to 

111 `metrics`. Defaults to 252. 

112 rf: Annualised risk-free rate. Defaults to 0.0. 

113 

114 Returns: 

115 str: A complete, self-contained HTML document. 

116 

117 """ 

118 # ── Metrics ─────────────────────────────────────────────────────────── 

119 metrics_df = self.metrics(mode="full", periods_per_year=periods_per_year, rf=rf) 

120 assets = [c for c in metrics_df.columns if c != "Metric"] 

121 metrics_html = _metrics_table_html(metrics_df) 

122 

123 # ── Period info for header ──────────────────────────────────────────── 

124 all_df: pl.DataFrame | None = getattr(self._data, "all", None) 

125 period_info, temporal_index = _report_period_info(all_df) 

126 

127 # ── Drawdowns ───────────────────────────────────────────────────────── 

128 drawdowns_html = _drawdowns_section_html(self._data, assets) 

129 

130 # ── Charts ──────────────────────────────────────────────────────────── 

131 plots = getattr(self._data, "plots", None) 

132 charts_html = _report_charts_html(plots, temporal_index) 

133 

134 return _build_full_html( 

135 title=title, 

136 period_info=period_info, 

137 assets_str=", ".join(assets), 

138 metrics_html=metrics_html, 

139 drawdowns_html=drawdowns_html, 

140 charts_html=charts_html, 

141 ) 

142 

143 

144def _report_period_info(all_df: pl.DataFrame | None) -> tuple[str, bool]: 

145 """Derive the header period string and whether the index is temporal. 

146 

147 Args: 

148 all_df: The combined ``Data.all`` frame, or ``None`` if unavailable. 

149 

150 Returns: 

151 A ``(period_info, temporal_index)`` tuple. ``period_info`` is empty 

152 for a non-temporal (integer) index. 

153 

154 """ 

155 period_info = "" 

156 temporal_index = False 

157 if all_df is not None: # pragma: no branch — Data always exposes .all; getattr default is defensive 

158 date_col = all_df.columns[0] 

159 temporal_index = all_df[date_col].dtype.is_temporal() 

160 if temporal_index: 

161 start_dt = all_df[date_col].min() 

162 end_dt = all_df[date_col].max() 

163 n = len(all_df) 

164 period_info = f"{start_dt!s}{end_dt!s} | {n:,} observations" 

165 return period_info, temporal_index 

166 

167 

168def _report_charts_html(plots: Any, temporal_index: bool) -> str: 

169 """Render every available report chart as embedded Plotly ``<div>`` HTML. 

170 

171 Calendar-based charts (which resample by ``dt.year``/``dt.month``) are 

172 skipped with a warning when the index is not temporal. 

173 

174 Args: 

175 plots: The ``Data.plots`` facade, or ``None`` if unavailable. 

176 temporal_index: Whether the underlying index is date/datetime typed. 

177 

178 Returns: 

179 Concatenated chart HTML, or a placeholder when none could be rendered. 

180 

181 """ 

182 chart_parts: list[str] = [] 

183 if plots is not None: # pragma: no branch — Data always exposes .plots; getattr default is defensive 

184 _chart_methods: list[tuple[str, dict[str, Any]]] = [ 

185 ("snapshot", {}), 

186 ("returns", {}), 

187 ("drawdown", {}), 

188 ("rolling_sharpe", {}), 

189 ("rolling_volatility", {}), 

190 ("monthly_heatmap", {}), 

191 ("yearly_returns", {}), 

192 ("histogram", {}), 

193 ] 

194 # These charts aggregate by calendar period (resample, dt.year/ 

195 # dt.month) and cannot be computed for an integer index. 

196 _calendar_charts = {"snapshot", "monthly_heatmap", "yearly_returns"} 

197 if not temporal_index: 

198 skipped = ", ".join(m for m, _ in _chart_methods if m in _calendar_charts) 

199 warnings.warn( 

200 f"Index is not temporal; skipping calendar-based charts: {skipped}.", 

201 stacklevel=2, 

202 ) 

203 # The report embeds Plotly JSON and links plotly.js, so its charts must be 

204 # Plotly figures no matter what `set_plot_backend` has been told. Without 

205 # this, a matplotlib default would leave every chart silently blank: 

206 # `_try_plotly_div` swallows the resulting failure and returns "". 

207 with plot_backend("plotly"): 

208 chart_parts = _collect_chart_divs(plots, _chart_methods, _calendar_charts, temporal_index) 

209 

210 return "\n".join(chart_parts) if chart_parts else "<p>No charts available.</p>" 

211 

212 

213def _collect_chart_divs( 

214 plots: Any, 

215 chart_methods: list[tuple[str, dict[str, Any]]], 

216 calendar_charts: set[str], 

217 temporal_index: bool, 

218) -> list[str]: 

219 """Render each requested chart to an embedded ``<div>``, skipping the impossible. 

220 

221 Args: 

222 plots: The ``Data.plots`` facade. 

223 chart_methods: Ordered ``(method_name, kwargs)`` pairs to attempt. 

224 calendar_charts: Method names that require a temporal index. 

225 temporal_index: Whether the underlying index is date/datetime typed. 

226 

227 Returns: 

228 The successfully rendered chart ``<div>`` strings, in request order. 

229 

230 """ 

231 chart_parts: list[str] = [] 

232 for method, kwargs in chart_methods: 

233 if not temporal_index and method in calendar_charts: 

234 continue 

235 fn = getattr(plots, method, None) 

236 if fn is None: 

237 continue 

238 div = _try_plotly_div(fn(**kwargs), include_cdn=not chart_parts) 

239 if div: # pragma: no branch — _try_plotly_div only returns falsy on render failure 

240 chart_parts.append(f'<div style="margin-bottom:24px">{div}</div>') 

241 return chart_parts