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

88 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-09-25 12:05 +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 

19import html 

20from pathlib import Path 

21from typing import TYPE_CHECKING 

22 

23import numpy as np 

24import plotly.graph_objects as go 

25import plotly.io as pio 

26from jinja2 import Environment, FileSystemLoader, select_autoescape 

27 

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

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

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

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

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

33if TYPE_CHECKING: 

34 from ._config import BasanosConfig 

35 from .optimizer import BasanosEngine 

36 

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

38_env = Environment( 

39 loader=FileSystemLoader(_TEMPLATES_DIR), 

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

41) 

42 

43 

44# ── Parameter metadata ──────────────────────────────────────────────────────── 

45 

46 

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

48# comparison symbol used to render them. 

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

50 

51 

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

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

54 parts: list[str] = [] 

55 # Pydantic v2 stores constraints inside field_info.metadata 

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

57 for m in metadata: 

58 for attr, symbol in _CONSTRAINT_SYMBOLS: 

59 bound = getattr(m, attr, None) 

60 if bound is not None: 

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

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

63 

64 

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

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

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

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

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

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

71 return f"{v:g}" 

72 

73 

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

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

76 if isinstance(v, float): 

77 return _fmt_float(v) 

78 return str(v) 

79 

80 

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

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

83 

84 Args: 

85 config: The configuration instance to render. 

86 

87 Returns: 

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

89 """ 

90 rows: list[str] = [] 

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

92 value = getattr(config, name) 

93 constraint = _constraint_str(field_info) 

94 description = field_info.description or "—" 

95 required = field_info.is_required() 

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

97 rows.append( 

98 f"<tr>" 

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

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

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

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

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

104 f"</tr>" 

105 ) 

106 

107 return ( 

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

109 "<thead><tr>" 

110 "<th>Parameter</th>" 

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

112 "<th>Constraint</th>" 

113 "<th>Description</th>" 

114 "<th>Default</th>" 

115 "</tr></thead>" 

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

117 "</table>" 

118 ) 

119 

120 

121# ── Lambda-sweep chart ──────────────────────────────────────────────────────── 

122 

123 

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

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

126 

127 Args: 

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

129 are held fixed at their current values. 

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

131 

132 Returns: 

133 A `Figure`. 

134 """ 

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

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

137 

138 # Current config lambda marker 

139 current_lam = engine.cfg.shrink 

140 current_sharpe = engine.sharpe_at_shrink(current_lam) 

141 

142 fig = go.Figure() 

143 

144 # Main sweep line 

145 fig.add_trace( 

146 go.Scatter( 

147 x=list(lambdas), 

148 y=sharpes, 

149 mode="lines+markers", 

150 name="Sharpe(λ)", 

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

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

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

154 ) 

155 ) 

156 

157 # Current lambda marker 

158 fig.add_trace( 

159 go.Scatter( 

160 x=[current_lam], 

161 y=[current_sharpe], 

162 mode="markers", 

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

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

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

166 ) 

167 ) 

168 

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

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

171 fig.add_vline( 

172 x=x_val, 

173 line_dash="dash", 

174 line_color="#718096", 

175 annotation_text=label, 

176 annotation_position="top", 

177 annotation_font_color="#718096", 

178 annotation_font_size=10, 

179 ) 

180 

181 fig.update_layout( 

182 title={ 

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

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

185 }, 

186 xaxis={ 

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

188 "color": "#a0aec0", 

189 "gridcolor": "#2d3748", 

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

191 }, 

192 yaxis={ 

193 "title": "Annualised Sharpe Ratio", 

194 "color": "#a0aec0", 

195 "gridcolor": "#2d3748", 

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

197 }, 

198 paper_bgcolor="#1a202c", 

199 plot_bgcolor="#1a202c", 

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

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

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

203 ) 

204 return fig 

205 

206 

207# ── Guidance table ──────────────────────────────────────────────────────────── 

208 

209_GUIDANCE_ROWS = [ 

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

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

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

213] 

214 

215 

216def _guidance_table_html() -> str: 

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

218 rows = "".join( 

219 f"<tr>" 

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

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

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

223 f"</tr>" 

224 for regime, shrink_range, notes in _GUIDANCE_ROWS 

225 ) 

226 return ( 

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

228 "<thead><tr>" 

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

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

231 "<th>Notes</th>" 

232 "</tr></thead>" 

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

234 "</table>" 

235 ) 

236 

237 

238# ── Plotly helper ───────────────────────────────────────────────────────────── 

239 

240 

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

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

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

244 

245 

246# ── ConfigReport dataclass ──────────────────────────────────────────────────── 

247 

248 

249@dataclasses.dataclass(frozen=True) 

250class ConfigReport: 

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

252 

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

254 

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

256 constraints, and descriptions. 

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

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

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

260 suggested λ ranges. 

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

262 parameter semantics, and academic references. 

263 

264 Usage:: 

265 

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

267 report = config.report 

268 html_str = report.to_html() 

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

270 

271 # Full report including lambda sweep — from engine 

272 report = engine.config_report 

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

274 """ 

275 

276 config: BasanosConfig 

277 engine: BasanosEngine | None = None 

278 

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

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

281 

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

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

284 pure HTML/CSS. 

285 

286 Args: 

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

288 

289 Returns: 

290 A complete HTML document as a `str`. 

291 """ 

292 cfg = self.config 

293 

294 # ── Parameter table ──────────────────────────────────────────────── 

295 params_html = _params_table_html(cfg) 

296 

297 # ── Lambda sweep ─────────────────────────────────────────────────── 

298 engine = self.engine 

299 has_engine = engine is not None 

300 if engine is not None: 

301 try: 

302 fig = _lambda_sweep_fig(engine) 

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

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

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

306 sweep_section = f'<p class="chart-unavailable">Lambda sweep unavailable: {html.escape(str(exc))}</p>' 

307 else: 

308 sweep_section = ( 

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

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

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

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

313 "</p>" 

314 ) 

315 

316 # ── Guidance table ───────────────────────────────────────────────── 

317 guidance_html = _guidance_table_html() 

318 

319 # ── TOC links ────────────────────────────────────────────────────── 

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

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

322 

323 # ── Render template ──────────────────────────────────────────────── 

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

325 return template.render( 

326 title=title, 

327 vola=cfg.vola, 

328 corr=cfg.corr, 

329 clip=cfg.clip, 

330 shrink=cfg.shrink, 

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

332 toc_lambda=toc_lambda, 

333 toc_extra_sep=toc_extra_sep, 

334 params_html=params_html, 

335 sweep_section=sweep_section, 

336 guidance_html=guidance_html, 

337 container_max_width="1200px", 

338 ) 

339 

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

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

342 

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

344 file extension. 

345 

346 Args: 

347 path: Destination file path. 

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

349 

350 Returns: 

351 The resolved `Path` of the written file. 

352 """ 

353 p = Path(path) 

354 if not p.suffix: 

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

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

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

358 return p