Coverage for src/basanos/math/_config_report.py: 100%

86 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-04 07:53 +0000

1"""HTML report generation for BasanosConfig parameter analysis. 

2 

3This module defines the `ConfigReport` facade which produces a 

4self-contained HTML document summarising all configuration parameters, 

5their constraints and descriptions, an interactive lambda-sweep chart 

6(when a `BasanosEngine` is provided), a 

7shrinkage-guidance table, and a theory section on Ledoit-Wolf shrinkage. 

8 

9Examples: 

10 >>> import dataclasses 

11 >>> from basanos.math._config_report import ConfigReport 

12 >>> dataclasses.is_dataclass(ConfigReport) 

13 True 

14""" 

15 

16from __future__ import annotations 

17 

18import dataclasses 

19from pathlib import Path 

20from typing import TYPE_CHECKING 

21 

22import numpy as np 

23import plotly.graph_objects as go 

24import plotly.io as pio 

25from jinja2 import Environment, FileSystemLoader, select_autoescape 

26 

27# Both BasanosConfig and BasanosEngine are referenced only as type hints here; 

28# `type(config).model_fields` reads the model metadata off the instance at 

29# runtime instead of the class, so neither needs a runtime import. Keeping them 

30# under TYPE_CHECKING means this rendering layer creates no runtime edge back to 

31# `_config`/`optimizer`, so `_config` can import `ConfigReport` at module level. 

32if TYPE_CHECKING: 

33 from ._config import BasanosConfig 

34 from .optimizer import BasanosEngine 

35 

36_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" 

37_env = Environment( 

38 loader=FileSystemLoader(_TEMPLATES_DIR), 

39 autoescape=select_autoescape(["html"]), 

40) 

41 

42 

43# ── Parameter metadata ──────────────────────────────────────────────────────── 

44 

45 

46# Pydantic v2 constraint attributes on a metadata object, paired with the 

47# comparison symbol used to render them. 

48_CONSTRAINT_SYMBOLS = (("gt", ">"), ("ge", "≥"), ("lt", "<"), ("le", "≤")) 

49 

50 

51def _constraint_str(field_info: object) -> str: 

52 """Extract a compact constraint string from a pydantic FieldInfo.""" 

53 parts: list[str] = [] 

54 # Pydantic v2 stores constraints inside field_info.metadata 

55 metadata = getattr(field_info, "metadata", []) 

56 for m in metadata: 

57 for attr, symbol in _CONSTRAINT_SYMBOLS: 

58 bound = getattr(m, attr, None) 

59 if bound is not None: 

60 parts.append(f"{symbol} {bound}") 

61 return ", ".join(parts) if parts else "—" 

62 

63 

64def _fmt_float(v: float) -> str: 

65 """Format a float config value for compact display.""" 

66 if v == int(v) and abs(v) >= 1e4: 

67 return f"{v:.2e}" 

68 if abs(v) < 0.01 and v != 0.0: 

69 return f"{v:.2e}" 

70 return f"{v:g}" 

71 

72 

73def _fmt_value(v: object) -> str: 

74 """Format a config field value for display.""" 

75 if isinstance(v, float): 

76 return _fmt_float(v) 

77 return str(v) 

78 

79 

80def _params_table_html(config: BasanosConfig) -> str: 

81 """Render a styled HTML table of all BasanosConfig parameters. 

82 

83 Args: 

84 config: The configuration instance to render. 

85 

86 Returns: 

87 An HTML ``<table>`` string ready to embed in a page. 

88 """ 

89 rows: list[str] = [] 

90 for name, field_info in type(config).model_fields.items(): 

91 value = getattr(config, name) 

92 constraint = _constraint_str(field_info) 

93 description = field_info.description or "—" 

94 required = field_info.is_required() 

95 default_label = "required" if required else f"default: {_fmt_value(field_info.default)}" 

96 rows.append( 

97 f"<tr>" 

98 f'<td class="param-name">{name}</td>' 

99 f'<td class="param-value">{_fmt_value(value)}</td>' 

100 f'<td class="param-constraint">{constraint}</td>' 

101 f'<td class="param-description">{description}</td>' 

102 f'<td class="param-description" style="color:#718096;white-space:nowrap">{default_label}</td>' 

103 f"</tr>" 

104 ) 

105 

106 return ( 

107 '<table class="param-table">' 

108 "<thead><tr>" 

109 "<th>Parameter</th>" 

110 "<th>Current&nbsp;Value</th>" 

111 "<th>Constraint</th>" 

112 "<th>Description</th>" 

113 "<th>Default</th>" 

114 "</tr></thead>" 

115 f"<tbody>{''.join(rows)}</tbody>" 

116 "</table>" 

117 ) 

118 

119 

120# ── Lambda-sweep chart ──────────────────────────────────────────────────────── 

121 

122 

123def _lambda_sweep_fig(engine: BasanosEngine, n_points: int = 21) -> go.Figure: 

124 """Build a Plotly figure showing annualised Sharpe vs shrinkage weight λ. 

125 

126 Args: 

127 engine: The engine to sweep. All parameters other than ``shrink`` 

128 are held fixed at their current values. 

129 n_points: Number of evenly-spaced λ values to evaluate in [0, 1]. 

130 

131 Returns: 

132 A `Figure`. 

133 """ 

134 lambdas = np.linspace(0.0, 1.0, n_points) 

135 sharpes = [engine.sharpe_at_shrink(float(lam)) for lam in lambdas] 

136 

137 # Current config lambda marker 

138 current_lam = engine.cfg.shrink 

139 current_sharpe = engine.sharpe_at_shrink(current_lam) 

140 

141 fig = go.Figure() 

142 

143 # Main sweep line 

144 fig.add_trace( 

145 go.Scatter( 

146 x=list(lambdas), 

147 y=sharpes, 

148 mode="lines+markers", 

149 name="Sharpe(λ)", 

150 line={"color": "#4299e1", "width": 2}, 

151 marker={"size": 5, "color": "#4299e1"}, 

152 hovertemplate="λ = %{x:.2f}<br>Sharpe = %{y:.3f}<extra></extra>", 

153 ) 

154 ) 

155 

156 # Current lambda marker 

157 fig.add_trace( 

158 go.Scatter( 

159 x=[current_lam], 

160 y=[current_sharpe], 

161 mode="markers", 

162 name=f"Current λ = {current_lam:.2f}", 

163 marker={"size": 12, "color": "#f6ad55", "symbol": "diamond"}, 

164 hovertemplate=f"Current λ = {current_lam:.2f}<br>Sharpe = {current_sharpe:.3f}<extra></extra>", 

165 ) 

166 ) 

167 

168 # Vertical reference lines at λ=0 and λ=1 

169 for x_val, label in [(0.0, "λ=0 (identity)"), (1.0, "λ=1 (no shrinkage)")]: 

170 fig.add_vline( 

171 x=x_val, 

172 line_dash="dash", 

173 line_color="#718096", 

174 annotation_text=label, 

175 annotation_position="top", 

176 annotation_font_color="#718096", 

177 annotation_font_size=10, 

178 ) 

179 

180 fig.update_layout( 

181 title={ 

182 "text": "Annualised Sharpe Ratio vs Shrinkage Weight λ", 

183 "font": {"color": "#e2e8f0", "size": 15}, 

184 }, 

185 xaxis={ 

186 "title": "Shrinkage weight λ (0 = full identity, 1 = raw EWMA)", 

187 "color": "#a0aec0", 

188 "gridcolor": "#2d3748", 

189 "title_font": {"color": "#a0aec0"}, 

190 }, 

191 yaxis={ 

192 "title": "Annualised Sharpe Ratio", 

193 "color": "#a0aec0", 

194 "gridcolor": "#2d3748", 

195 "title_font": {"color": "#a0aec0"}, 

196 }, 

197 paper_bgcolor="#1a202c", 

198 plot_bgcolor="#1a202c", 

199 font={"color": "#e2e8f0"}, 

200 legend={"bgcolor": "#1a202c", "bordercolor": "#2d3748", "borderwidth": 1}, 

201 margin={"t": 60, "b": 50, "l": 60, "r": 20}, 

202 ) 

203 return fig 

204 

205 

206# ── Guidance table ──────────────────────────────────────────────────────────── 

207 

208_GUIDANCE_ROWS = [ 

209 ("n > 20, T < 40", "0.3 - 0.5", "Near-singular matrix likely; strong regularisation needed."), 

210 ("n ~= 10, T ~= 60", "0.5 - 0.7", "Balanced regime; moderate regularisation."), 

211 ("n < 10, T > 100", "0.7 - 0.9", "Well-conditioned sample; light shrinkage for stability."), 

212] 

213 

214 

215def _guidance_table_html() -> str: 

216 """Return an HTML table of shrinkage regime guidance (n / T heuristics).""" 

217 rows = "".join( 

218 f"<tr>" 

219 f'<td class="regime">{regime}</td>' 

220 f'<td class="shrink-range">{shrink_range}</td>' 

221 f'<td class="notes">{notes}</td>' 

222 f"</tr>" 

223 for regime, shrink_range, notes in _GUIDANCE_ROWS 

224 ) 

225 return ( 

226 '<table class="guidance-table">' 

227 "<thead><tr>" 

228 "<th>n (assets) / T (corr lookback)</th>" 

229 "<th>Suggested shrink (λ)</th>" 

230 "<th>Notes</th>" 

231 "</tr></thead>" 

232 f"<tbody>{rows}</tbody>" 

233 "</table>" 

234 ) 

235 

236 

237# ── Plotly helper ───────────────────────────────────────────────────────────── 

238 

239 

240def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str: 

241 """Return an HTML div string for *fig*.""" 

242 return str(pio.to_html(fig, full_html=False, include_plotlyjs=include_plotlyjs)) 

243 

244 

245# ── ConfigReport dataclass ──────────────────────────────────────────────────── 

246 

247 

248@dataclasses.dataclass(frozen=True) 

249class ConfigReport: 

250 """Facade for generating HTML reports from a `BasanosConfig`. 

251 

252 Produces a self-contained, dark-themed HTML document with: 

253 

254 * A **parameter table** listing all config fields, their current values, 

255 constraints, and descriptions. 

256 * An interactive **lambda-sweep chart** (requires *engine*) showing 

257 annualised Sharpe as a function of the shrinkage weight λ across [0, 1]. 

258 * A **shrinkage-guidance table** mapping concentration-ratio regimes to 

259 suggested λ ranges. 

260 * A **theory section** covering Ledoit-Wolf linear shrinkage, EWMA 

261 parameter semantics, and academic references. 

262 

263 Usage:: 

264 

265 # Static report (no lambda sweep) — from config alone 

266 report = config.report 

267 html_str = report.to_html() 

268 report.save("output/config_report.html") 

269 

270 # Full report including lambda sweep — from engine 

271 report = engine.config_report 

272 report.save("output/config_report_with_sweep.html") 

273 """ 

274 

275 config: BasanosConfig 

276 engine: BasanosEngine | None = None 

277 

278 def to_html(self, title: str = "Basanos Configuration Report") -> str: 

279 """Render a full HTML report as a string. 

280 

281 The document is self-contained: Plotly.js is loaded from the CDN 

282 only when a lambda-sweep chart is included. All other sections are 

283 pure HTML/CSS. 

284 

285 Args: 

286 title: HTML ``<title>`` text and visible page heading. 

287 

288 Returns: 

289 A complete HTML document as a `str`. 

290 """ 

291 cfg = self.config 

292 

293 # ── Parameter table ──────────────────────────────────────────────── 

294 params_html = _params_table_html(cfg) 

295 

296 # ── Lambda sweep ─────────────────────────────────────────────────── 

297 has_engine = self.engine is not None 

298 if has_engine: 

299 try: 

300 fig = _lambda_sweep_fig(self.engine) # type: ignore[arg-type] 

301 sweep_div = _figure_div(fig, include_plotlyjs="cdn") 

302 sweep_section = f'<div class="chart-card">{sweep_div}</div>' 

303 except Exception as exc: # noqa: BLE001 - optional chart: any rendering failure degrades to a placeholder 

304 sweep_section = f'<p class="chart-unavailable">Lambda sweep unavailable: {exc}</p>' 

305 else: 

306 sweep_section = ( 

307 '<p class="chart-unavailable" style="padding:1.5rem;">' 

308 "Lambda sweep is available when accessing this report via " 

309 "<code>engine.config_report</code> (requires a " 

310 "<strong>BasanosEngine</strong> instance with price and signal data)." 

311 "</p>" 

312 ) 

313 

314 # ── Guidance table ───────────────────────────────────────────────── 

315 guidance_html = _guidance_table_html() 

316 

317 # ── TOC links ────────────────────────────────────────────────────── 

318 toc_lambda = '<a href="#lambda-sweep">Lambda Sweep</a>' if has_engine else "" 

319 toc_extra_sep = "&nbsp;&nbsp;" if has_engine else "" 

320 

321 # ── Render template ──────────────────────────────────────────────── 

322 template = _env.get_template("config_report.html") 

323 return template.render( 

324 title=title, 

325 vola=cfg.vola, 

326 corr=cfg.corr, 

327 clip=cfg.clip, 

328 shrink=cfg.shrink, 

329 aum=f"{cfg.aum:,.0f}", 

330 toc_lambda=toc_lambda, 

331 toc_extra_sep=toc_extra_sep, 

332 params_html=params_html, 

333 sweep_section=sweep_section, 

334 guidance_html=guidance_html, 

335 container_max_width="1200px", 

336 ) 

337 

338 def save(self, path: str | Path, title: str = "Basanos Configuration Report") -> Path: 

339 """Save the HTML report to a file. 

340 

341 A ``.html`` suffix is appended automatically when *path* has no 

342 file extension. 

343 

344 Args: 

345 path: Destination file path. 

346 title: HTML ``<title>`` text and visible page heading. 

347 

348 Returns: 

349 The resolved `Path` of the written file. 

350 """ 

351 p = Path(path) 

352 if not p.suffix: 

353 p = p.with_suffix(".html") 

354 p.parent.mkdir(parents=True, exist_ok=True) 

355 p.write_text(self.to_html(title=title), encoding="utf-8") 

356 return p