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

88 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-02 17:47 +0000

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

2 

3This module defines the :class:`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 :class:`~basanos.math.optimizer.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 

27if TYPE_CHECKING: 

28 from .optimizer import BasanosConfig, BasanosEngine 

29 

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

31_env = Environment( 

32 loader=FileSystemLoader(_TEMPLATES_DIR), 

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

34) 

35 

36 

37# ── Parameter metadata ──────────────────────────────────────────────────────── 

38 

39 

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

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

42 parts: list[str] = [] 

43 # Pydantic v2 stores constraints inside field_info.metadata 

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

45 for m in metadata: 

46 if hasattr(m, "gt") and m.gt is not None: 

47 parts.append(f"> {m.gt}") 

48 if hasattr(m, "ge") and m.ge is not None: 

49 parts.append(f"{m.ge}") 

50 if hasattr(m, "lt") and m.lt is not None: 

51 parts.append(f"< {m.lt}") 

52 if hasattr(m, "le") and m.le is not None: 

53 parts.append(f"{m.le}") 

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

55 

56 

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

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

59 if isinstance(v, float): 

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

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

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

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

64 return f"{v:g}" 

65 return str(v) 

66 

67 

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

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

70 

71 Args: 

72 config: The configuration instance to render. 

73 

74 Returns: 

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

76 """ 

77 from .optimizer import BasanosConfig # local import to avoid circularity 

78 

79 rows: list[str] = [] 

80 for name, field_info in BasanosConfig.model_fields.items(): 

81 value = getattr(config, name) 

82 constraint = _constraint_str(field_info) 

83 description = field_info.description or "—" 

84 required = field_info.is_required() 

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

86 rows.append( 

87 f"<tr>" 

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

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

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

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

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

93 f"</tr>" 

94 ) 

95 

96 return ( 

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

98 "<thead><tr>" 

99 "<th>Parameter</th>" 

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

101 "<th>Constraint</th>" 

102 "<th>Description</th>" 

103 "<th>Default</th>" 

104 "</tr></thead>" 

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

106 "</table>" 

107 ) 

108 

109 

110# ── Lambda-sweep chart ──────────────────────────────────────────────────────── 

111 

112 

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

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

115 

116 Args: 

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

118 are held fixed at their current values. 

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

120 

121 Returns: 

122 A :class:`plotly.graph_objects.Figure`. 

123 """ 

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

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

126 

127 # Current config lambda marker 

128 current_lam = engine.cfg.shrink 

129 current_sharpe = engine.sharpe_at_shrink(current_lam) 

130 

131 fig = go.Figure() 

132 

133 # Main sweep line 

134 fig.add_trace( 

135 go.Scatter( 

136 x=list(lambdas), 

137 y=sharpes, 

138 mode="lines+markers", 

139 name="Sharpe(λ)", 

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

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

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

143 ) 

144 ) 

145 

146 # Current lambda marker 

147 fig.add_trace( 

148 go.Scatter( 

149 x=[current_lam], 

150 y=[current_sharpe], 

151 mode="markers", 

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

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

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

155 ) 

156 ) 

157 

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

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

160 fig.add_vline( 

161 x=x_val, 

162 line_dash="dash", 

163 line_color="#718096", 

164 annotation_text=label, 

165 annotation_position="top", 

166 annotation_font_color="#718096", 

167 annotation_font_size=10, 

168 ) 

169 

170 fig.update_layout( 

171 title={ 

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

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

174 }, 

175 xaxis={ 

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

177 "color": "#a0aec0", 

178 "gridcolor": "#2d3748", 

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

180 }, 

181 yaxis={ 

182 "title": "Annualised Sharpe Ratio", 

183 "color": "#a0aec0", 

184 "gridcolor": "#2d3748", 

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

186 }, 

187 paper_bgcolor="#1a202c", 

188 plot_bgcolor="#1a202c", 

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

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

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

192 ) 

193 return fig 

194 

195 

196# ── Guidance table ──────────────────────────────────────────────────────────── 

197 

198_GUIDANCE_ROWS = [ 

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

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

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

202] 

203 

204 

205def _guidance_table_html() -> str: 

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

207 rows = "".join( 

208 f"<tr>" 

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

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

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

212 f"</tr>" 

213 for regime, shrink_range, notes in _GUIDANCE_ROWS 

214 ) 

215 return ( 

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

217 "<thead><tr>" 

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

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

220 "<th>Notes</th>" 

221 "</tr></thead>" 

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

223 "</table>" 

224 ) 

225 

226 

227# ── Plotly helper ───────────────────────────────────────────────────────────── 

228 

229 

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

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

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

233 

234 

235# ── ConfigReport dataclass ──────────────────────────────────────────────────── 

236 

237 

238@dataclasses.dataclass(frozen=True) 

239class ConfigReport: 

240 """Facade for generating HTML reports from a :class:`~basanos.math.optimizer.BasanosConfig`. 

241 

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

243 

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

245 constraints, and descriptions. 

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

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

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

249 suggested λ ranges. 

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

251 parameter semantics, and academic references. 

252 

253 Usage:: 

254 

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

256 report = config.report 

257 html_str = report.to_html() 

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

259 

260 # Full report including lambda sweep — from engine 

261 report = engine.config_report 

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

263 """ 

264 

265 config: BasanosConfig 

266 engine: BasanosEngine | None = None 

267 

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

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

270 

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

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

273 pure HTML/CSS. 

274 

275 Args: 

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

277 

278 Returns: 

279 A complete HTML document as a :class:`str`. 

280 """ 

281 cfg = self.config 

282 

283 # ── Parameter table ──────────────────────────────────────────────── 

284 params_html = _params_table_html(cfg) 

285 

286 # ── Lambda sweep ─────────────────────────────────────────────────── 

287 has_engine = self.engine is not None 

288 if has_engine: 

289 try: 

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

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

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

293 except Exception as exc: 

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

295 else: 

296 sweep_section = ( 

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

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

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

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

301 "</p>" 

302 ) 

303 

304 # ── Guidance table ───────────────────────────────────────────────── 

305 guidance_html = _guidance_table_html() 

306 

307 # ── TOC links ────────────────────────────────────────────────────── 

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

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

310 

311 # ── Render template ──────────────────────────────────────────────── 

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

313 return template.render( 

314 title=title, 

315 vola=cfg.vola, 

316 corr=cfg.corr, 

317 clip=cfg.clip, 

318 shrink=cfg.shrink, 

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

320 toc_lambda=toc_lambda, 

321 toc_extra_sep=toc_extra_sep, 

322 params_html=params_html, 

323 sweep_section=sweep_section, 

324 guidance_html=guidance_html, 

325 container_max_width="1200px", 

326 ) 

327 

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

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

330 

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

332 file extension. 

333 

334 Args: 

335 path: Destination file path. 

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

337 

338 Returns: 

339 The resolved :class:`pathlib.Path` of the written file. 

340 """ 

341 p = Path(path) 

342 if not p.suffix: 

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

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

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

346 return p