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

76 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-06 04:52 +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 ._html import ( 

14 _build_full_html, 

15 _drawdowns_section_html, 

16 _metrics_table_html, 

17 _try_plotly_div, 

18) 

19from ._metrics import ( 

20 _add_drawdown_rows, 

21 _add_full_mode_rows, 

22 _add_overview_rows, 

23 _add_recent_returns_rows, 

24 _add_risk_adjusted_rows, 

25 _add_trading_rows, 

26 _build_metrics_df, 

27) 

28 

29 

30class Reports: 

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

32 

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

34 into report-ready formats such as DataFrames. 

35 """ 

36 

37 __slots__ = ("_data",) 

38 

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

40 self._data = data 

41 

42 def metrics( 

43 self, 

44 mode: str = "basic", 

45 periods_per_year: int | float = 252, 

46 rf: float = 0.0, 

47 ) -> pl.DataFrame: 

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

49 

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

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

52 

53 Args: 

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

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

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

57 periods_per_year: Annualisation factor. Defaults to 252. 

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

59 Defaults to 0.0. 

60 

61 Returns: 

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

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

64 

65 """ 

66 s = self._data.stats 

67 ppy = float(periods_per_year) 

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

69 

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

71 

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

73 asset_cols: list[str] = [] 

74 date_col: str | None = None 

75 has_dates = False 

76 

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

78 date_col = all_df.columns[0] 

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

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

81 

82 _add_overview_rows(rows, s, ppy) 

83 _add_risk_adjusted_rows(rows, s, ppy) 

84 _add_drawdown_rows(rows, s) 

85 _add_trading_rows(rows, s) 

86 

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

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

89 

90 if is_full: 

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

92 

93 return _build_metrics_df(rows) 

94 

95 def full( 

96 self, 

97 title: str = "Performance Report", 

98 periods_per_year: int | float = 252, 

99 rf: float = 0.0, 

100 ) -> str: 

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

102 

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

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

105 dark-themed HTML document. 

106 

107 Args: 

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

109 periods_per_year: Annualisation factor passed to 

110 `metrics`. Defaults to 252. 

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

112 

113 Returns: 

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

115 

116 """ 

117 # ── Metrics ─────────────────────────────────────────────────────────── 

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

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

120 metrics_html = _metrics_table_html(metrics_df) 

121 

122 # ── Period info for header ──────────────────────────────────────────── 

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

124 period_info, temporal_index = _report_period_info(all_df) 

125 

126 # ── Drawdowns ───────────────────────────────────────────────────────── 

127 drawdowns_html = _drawdowns_section_html(self._data, assets) 

128 

129 # ── Charts ──────────────────────────────────────────────────────────── 

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

131 charts_html = _report_charts_html(plots, temporal_index) 

132 

133 return _build_full_html( 

134 title=title, 

135 period_info=period_info, 

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

137 metrics_html=metrics_html, 

138 drawdowns_html=drawdowns_html, 

139 charts_html=charts_html, 

140 ) 

141 

142 

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

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

145 

146 Args: 

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

148 

149 Returns: 

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

151 for a non-temporal (integer) index. 

152 

153 """ 

154 period_info = "" 

155 temporal_index = False 

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

157 date_col = all_df.columns[0] 

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

159 if temporal_index: 

160 start_dt = all_df[date_col].min() 

161 end_dt = all_df[date_col].max() 

162 n = len(all_df) 

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

164 return period_info, temporal_index 

165 

166 

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

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

169 

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

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

172 

173 Args: 

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

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

176 

177 Returns: 

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

179 

180 """ 

181 chart_parts: list[str] = [] 

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

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

184 ("snapshot", {}), 

185 ("returns", {}), 

186 ("drawdown", {}), 

187 ("rolling_sharpe", {}), 

188 ("rolling_volatility", {}), 

189 ("monthly_heatmap", {}), 

190 ("yearly_returns", {}), 

191 ("histogram", {}), 

192 ] 

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

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

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

196 if not temporal_index: 

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

198 warnings.warn( 

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

200 stacklevel=2, 

201 ) 

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

203 

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

205 

206 

207def _collect_chart_divs( 

208 plots: Any, 

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

210 calendar_charts: set[str], 

211 temporal_index: bool, 

212) -> list[str]: 

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

214 

215 Args: 

216 plots: The ``Data.plots`` facade. 

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

218 calendar_charts: Method names that require a temporal index. 

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

220 

221 Returns: 

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

223 

224 """ 

225 chart_parts: list[str] = [] 

226 for method, kwargs in chart_methods: 

227 if not temporal_index and method in calendar_charts: 

228 continue 

229 fn = getattr(plots, method, None) 

230 if fn is None: 

231 continue 

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

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

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

235 return chart_parts