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

80 statements  

« prev     ^ index     » next       coverage.py v7.17.1, created at 2026-11-03 15:52 +0000

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

2 

3from __future__ import annotations 

4 

5import warnings 

6from datetime import date 

7from typing import TYPE_CHECKING, Any 

8 

9import polars as pl 

10 

11if TYPE_CHECKING: 

12 from jquantstats._protocol import DataLike 

13 

14from .._plots._backend import plot_backend 

15from ._formatting import _env 

16from ._html import ( 

17 _drawdowns_section_html, 

18 _metrics_table_html, 

19 _try_plotly_div, 

20) 

21from ._metrics import ( 

22 _add_drawdown_rows, 

23 _add_full_mode_rows, 

24 _add_overview_rows, 

25 _add_recent_returns_rows, 

26 _add_risk_adjusted_rows, 

27 _add_trading_rows, 

28 _build_metrics_df, 

29) 

30 

31 

32class Reports: 

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

34 

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

36 into report-ready formats such as DataFrames. 

37 """ 

38 

39 __slots__ = ("_data",) 

40 

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

42 self._data = data 

43 

44 def metrics( 

45 self, 

46 mode: str = "basic", 

47 periods_per_year: int | float = 252, 

48 rf: float = 0.0, 

49 ) -> pl.DataFrame: 

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

51 

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

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

54 

55 Args: 

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

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

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

59 periods_per_year: Annualisation factor. Defaults to 252. 

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

61 Defaults to 0.0. 

62 

63 Returns: 

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

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

66 

67 """ 

68 s = self._data.stats 

69 ppy = float(periods_per_year) 

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

71 

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

73 

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

75 asset_cols: list[str] = [] 

76 date_col: str | None = None 

77 has_dates = False 

78 

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

80 date_col = all_df.columns[0] 

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

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

83 

84 _add_overview_rows(rows, s, ppy) 

85 _add_risk_adjusted_rows(rows, s, ppy) 

86 _add_drawdown_rows(rows, s) 

87 _add_trading_rows(rows, s) 

88 

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

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

91 

92 if is_full: 

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

94 

95 return _build_metrics_df(rows) 

96 

97 def full( 

98 self, 

99 title: str = "Performance Report", 

100 periods_per_year: int | float = 252, 

101 rf: float = 0.0, 

102 ) -> str: 

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

104 

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

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

107 dark-themed HTML document. 

108 

109 Args: 

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

111 periods_per_year: Annualisation factor passed to 

112 `metrics`. Defaults to 252. 

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

114 

115 Returns: 

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

117 

118 """ 

119 # ── Metrics ─────────────────────────────────────────────────────────── 

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

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

122 metrics_html = _metrics_table_html(metrics_df) 

123 

124 # ── Period info for header ──────────────────────────────────────────── 

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

126 period_info, temporal_index = _report_period_info(all_df) 

127 

128 # ── Drawdowns ───────────────────────────────────────────────────────── 

129 drawdowns_html = _drawdowns_section_html(self._data, assets) 

130 

131 # ── Charts ──────────────────────────────────────────────────────────── 

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

133 charts_html = _report_charts_html(plots, temporal_index) 

134 

135 return _env.get_template("data_report.html").render( 

136 title=title, 

137 period_info=period_info, 

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

139 metrics_html=metrics_html, 

140 drawdowns_html=drawdowns_html, 

141 charts_html=charts_html, 

142 footer_date=str(date.today()), 

143 ) 

144 

145 

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

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

148 

149 Args: 

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

151 

152 Returns: 

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

154 for a non-temporal (integer) index. 

155 

156 """ 

157 period_info = "" 

158 temporal_index = False 

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

160 date_col = all_df.columns[0] 

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

162 if temporal_index: 

163 start_dt = all_df[date_col].min() 

164 end_dt = all_df[date_col].max() 

165 n = len(all_df) 

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

167 return period_info, temporal_index 

168 

169 

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

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

172 

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

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

175 

176 Args: 

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

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

179 

180 Returns: 

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

182 

183 """ 

184 chart_parts: list[str] = [] 

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

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

187 ("snapshot", {}), 

188 ("returns", {}), 

189 ("drawdown", {}), 

190 ("rolling_sharpe", {}), 

191 ("rolling_volatility", {}), 

192 ("monthly_heatmap", {}), 

193 ("yearly_returns", {}), 

194 ("histogram", {}), 

195 ] 

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

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

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

199 if not temporal_index: 

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

201 warnings.warn( 

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

203 stacklevel=2, 

204 ) 

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

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

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

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

209 with plot_backend("plotly"): 

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

211 

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

213 

214 

215def _collect_chart_divs( 

216 plots: Any, 

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

218 calendar_charts: set[str], 

219 temporal_index: bool, 

220) -> list[str]: 

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

222 

223 Args: 

224 plots: The ``Data.plots`` facade. 

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

226 calendar_charts: Method names that require a temporal index. 

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

228 

229 Returns: 

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

231 

232 """ 

233 chart_parts: list[str] = [] 

234 for method, kwargs in chart_methods: 

235 if not temporal_index and method in calendar_charts: 

236 continue 

237 fn = getattr(plots, method, None) 

238 if fn is None: 

239 continue 

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

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

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

243 return chart_parts