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

86 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 05:23 +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 

25 

26if TYPE_CHECKING: 

27 from .optimizer import BasanosConfig, BasanosEngine 

28 

29 

30# ── CSS (reuses the same dark-theme palette as _report.py) ─────────────────── 

31 

32_CSS = """ 

33/* ── Reset & Base ─────────────────────────────────── */ 

34*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 

35body { 

36 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif; 

37 background: #0f1117; 

38 color: #e2e8f0; 

39 line-height: 1.6; 

40} 

41 

42/* ── Header ───────────────────────────────────────── */ 

43.report-header { 

44 background: linear-gradient(135deg, #1a1f35 0%, #0d1b2a 100%); 

45 border-bottom: 2px solid #2d3748; 

46 padding: 2.5rem 2rem 2rem; 

47} 

48.report-header h1 { 

49 font-size: 2rem; 

50 font-weight: 700; 

51 color: #63b3ed; 

52 letter-spacing: -0.5px; 

53} 

54.report-meta { 

55 display: flex; 

56 flex-wrap: wrap; 

57 gap: 1.5rem; 

58 margin-top: 0.75rem; 

59 font-size: 0.875rem; 

60 color: #a0aec0; 

61} 

62.report-meta span strong { color: #e2e8f0; } 

63 

64/* ── Table of Contents ────────────────────────────── */ 

65.toc { 

66 background: #1a1f35; 

67 border-bottom: 1px solid #2d3748; 

68 padding: 0.75rem 2rem; 

69 display: flex; 

70 gap: 1.5rem; 

71 flex-wrap: wrap; 

72 font-size: 0.8rem; 

73 position: sticky; 

74 top: 0; 

75 z-index: 100; 

76} 

77.toc a { 

78 color: #63b3ed; 

79 text-decoration: none; 

80 opacity: 0.8; 

81 transition: opacity 0.2s; 

82} 

83.toc a:hover { opacity: 1; text-decoration: underline; } 

84 

85/* ── Main Content ─────────────────────────────────── */ 

86.container { 

87 max-width: 1200px; 

88 margin: 0 auto; 

89 padding: 2rem; 

90} 

91 

92/* ── Sections ─────────────────────────────────────── */ 

93.section { margin-bottom: 3rem; } 

94.section-title { 

95 font-size: 1.25rem; 

96 font-weight: 600; 

97 color: #90cdf4; 

98 margin-bottom: 1.25rem; 

99 padding-bottom: 0.5rem; 

100 border-bottom: 1px solid #2d3748; 

101 display: flex; 

102 align-items: center; 

103 gap: 0.5rem; 

104} 

105.section-title::before { 

106 content: ""; 

107 display: inline-block; 

108 width: 4px; 

109 height: 1.2em; 

110 background: #4299e1; 

111 border-radius: 2px; 

112} 

113 

114/* ── Chart Card ───────────────────────────────────── */ 

115.chart-card { 

116 background: #1a202c; 

117 border: 1px solid #2d3748; 

118 border-radius: 12px; 

119 padding: 1rem; 

120 overflow: hidden; 

121} 

122.chart-card .js-plotly-plot, 

123.chart-card .plotly-graph-div { width: 100% !important; } 

124.chart-unavailable { 

125 color: #a0aec0; 

126 font-style: italic; 

127 padding: 1rem; 

128} 

129 

130/* ── Parameter & Guidance Tables ─────────────────── */ 

131.param-table, .guidance-table { 

132 width: 100%; 

133 border-collapse: collapse; 

134 font-size: 0.875rem; 

135} 

136.param-table th, .guidance-table th { 

137 background: #2d3748; 

138 color: #90cdf4; 

139 padding: 0.6rem 1rem; 

140 text-align: left; 

141 font-weight: 600; 

142 white-space: nowrap; 

143} 

144.param-table td, .guidance-table td { 

145 padding: 0.5rem 1rem; 

146 border-bottom: 1px solid #2d3748; 

147 vertical-align: top; 

148} 

149.param-table td.param-name { 

150 font-family: "SFMono-Regular", Consolas, monospace; 

151 color: #63b3ed; 

152 white-space: nowrap; 

153 font-weight: 600; 

154} 

155.param-table td.param-value { 

156 font-family: "SFMono-Regular", Consolas, monospace; 

157 color: #68d391; 

158 white-space: nowrap; 

159} 

160.param-table td.param-constraint { 

161 font-family: "SFMono-Regular", Consolas, monospace; 

162 color: #f6ad55; 

163 white-space: nowrap; 

164} 

165.param-table td.param-description { color: #cbd5e0; } 

166.param-table tbody tr:hover, 

167.guidance-table tbody tr:hover { background: #1e2a3a; } 

168.guidance-table td.regime { color: #cbd5e0; white-space: nowrap; } 

169.guidance-table td.shrink-range { color: #f6ad55; font-weight: 600; } 

170.guidance-table td.notes { color: #a0aec0; } 

171 

172/* ── Theory Section ───────────────────────────────── */ 

173.theory-block { 

174 background: #1a202c; 

175 border: 1px solid #2d3748; 

176 border-radius: 12px; 

177 padding: 1.5rem 2rem; 

178 line-height: 1.8; 

179} 

180.theory-block h3 { 

181 color: #90cdf4; 

182 font-size: 1rem; 

183 font-weight: 600; 

184 margin: 1.25rem 0 0.5rem; 

185} 

186.theory-block h3:first-child { margin-top: 0; } 

187.theory-block p { color: #cbd5e0; margin-bottom: 0.75rem; } 

188.theory-block code { 

189 font-family: "SFMono-Regular", Consolas, monospace; 

190 background: #2d3748; 

191 padding: 0.1em 0.4em; 

192 border-radius: 4px; 

193 font-size: 0.875em; 

194 color: #63b3ed; 

195} 

196.theory-block .math-block { 

197 font-family: "SFMono-Regular", Consolas, monospace; 

198 background: #2d3748; 

199 border-left: 3px solid #4299e1; 

200 padding: 0.75rem 1rem; 

201 border-radius: 0 8px 8px 0; 

202 margin: 0.75rem 0; 

203 color: #e2e8f0; 

204 font-size: 0.9rem; 

205} 

206.theory-block ul { 

207 list-style: none; 

208 padding: 0; 

209} 

210.theory-block ul li { 

211 color: #cbd5e0; 

212 padding: 0.2rem 0; 

213 padding-left: 1.5rem; 

214 position: relative; 

215} 

216.theory-block ul li::before { 

217 content: "▸"; 

218 position: absolute; 

219 left: 0; 

220 color: #4299e1; 

221} 

222.theory-block a { color: #63b3ed; } 

223.theory-block a:hover { text-decoration: underline; } 

224.refs { color: #a0aec0; font-size: 0.85rem; margin-top: 1rem; } 

225 

226/* ── Footer ───────────────────────────────────────── */ 

227.report-footer { 

228 text-align: center; 

229 padding: 1.5rem; 

230 color: #4a5568; 

231 font-size: 0.75rem; 

232 border-top: 1px solid #2d3748; 

233 margin-top: 3rem; 

234} 

235""" 

236 

237 

238# ── Parameter metadata ──────────────────────────────────────────────────────── 

239 

240 

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

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

243 parts: list[str] = [] 

244 # Pydantic v2 stores constraints inside field_info.metadata 

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

246 for m in metadata: 

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

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

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

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

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

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

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

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

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

256 

257 

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

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

260 if isinstance(v, float): 

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

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

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

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

265 return f"{v:g}" 

266 return str(v) 

267 

268 

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

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

271 

272 Args: 

273 config: The configuration instance to render. 

274 

275 Returns: 

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

277 """ 

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

279 

280 rows: list[str] = [] 

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

282 value = getattr(config, name) 

283 constraint = _constraint_str(field_info) 

284 description = field_info.description or "—" 

285 required = field_info.is_required() 

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

287 rows.append( 

288 f"<tr>" 

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

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

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

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

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

294 f"</tr>" 

295 ) 

296 

297 return ( 

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

299 "<thead><tr>" 

300 "<th>Parameter</th>" 

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

302 "<th>Constraint</th>" 

303 "<th>Description</th>" 

304 "<th>Default</th>" 

305 "</tr></thead>" 

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

307 "</table>" 

308 ) 

309 

310 

311# ── Lambda-sweep chart ──────────────────────────────────────────────────────── 

312 

313 

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

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

316 

317 Args: 

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

319 are held fixed at their current values. 

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

321 

322 Returns: 

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

324 """ 

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

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

327 

328 # Current config lambda marker 

329 current_lam = engine.cfg.shrink 

330 current_sharpe = engine.sharpe_at_shrink(current_lam) 

331 

332 fig = go.Figure() 

333 

334 # Main sweep line 

335 fig.add_trace( 

336 go.Scatter( 

337 x=list(lambdas), 

338 y=sharpes, 

339 mode="lines+markers", 

340 name="Sharpe(λ)", 

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

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

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

344 ) 

345 ) 

346 

347 # Current lambda marker 

348 fig.add_trace( 

349 go.Scatter( 

350 x=[current_lam], 

351 y=[current_sharpe], 

352 mode="markers", 

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

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

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

356 ) 

357 ) 

358 

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

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

361 fig.add_vline( 

362 x=x_val, 

363 line_dash="dash", 

364 line_color="#718096", 

365 annotation_text=label, 

366 annotation_position="top", 

367 annotation_font_color="#718096", 

368 annotation_font_size=10, 

369 ) 

370 

371 fig.update_layout( 

372 title={ 

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

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

375 }, 

376 xaxis={ 

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

378 "color": "#a0aec0", 

379 "gridcolor": "#2d3748", 

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

381 }, 

382 yaxis={ 

383 "title": "Annualised Sharpe Ratio", 

384 "color": "#a0aec0", 

385 "gridcolor": "#2d3748", 

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

387 }, 

388 paper_bgcolor="#1a202c", 

389 plot_bgcolor="#1a202c", 

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

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

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

393 ) 

394 return fig 

395 

396 

397# ── Guidance table ──────────────────────────────────────────────────────────── 

398 

399_GUIDANCE_ROWS = [ 

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

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

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

403] 

404 

405 

406def _guidance_table_html() -> str: 

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

408 rows = "".join( 

409 f"<tr>" 

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

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

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

413 f"</tr>" 

414 for regime, shrink_range, notes in _GUIDANCE_ROWS 

415 ) 

416 return ( 

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

418 "<thead><tr>" 

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

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

421 "<th>Notes</th>" 

422 "</tr></thead>" 

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

424 "</table>" 

425 ) 

426 

427 

428# ── Theory HTML ─────────────────────────────────────────────────────────────── 

429 

430_THEORY_HTML = """ 

431<div class="theory-block"> 

432 <h3>Linear Shrinkage toward the Identity</h3> 

433 <p> 

434 The <code>shrink</code> parameter (&lambda;) controls how much the EWMA sample 

435 correlation matrix <em>C<sub>EWMA</sub></em> is regularised before being 

436 passed to the linear solver. The shrunk matrix is: 

437 </p> 

438 <div class="math-block">C_shrunk = &lambda; &middot; C_EWMA + (1 - &lambda;) &middot; I_n</div> 

439 <p> 

440 where <em>I<sub>n</sub></em> is the n x n identity matrix. 

441 Setting &lambda; = 1 uses the raw EWMA correlation matrix (no shrinkage); setting 

442 &lambda; = 0 replaces it entirely with the identity (positions become purely 

443 signal-proportional, uncorrelated). 

444 </p> 

445 

446 <h3>Why Shrinkage?</h3> 

447 <p> 

448 When the number of assets <em>n</em> is large relative to the lookback 

449 window <em>T</em> (high concentration ratio <em>n/T</em>), the sample 

450 covariance matrix is poorly estimated. Extreme eigenvalues amplify 

451 estimation noise and cause the linear solver to allocate excessive 

452 leverage to a few eigendirections. Shrinkage toward the identity damps 

453 these extremes, improves the condition number, and produces more stable, 

454 diversified positions. 

455 </p> 

456 

457 <h3>When to Prefer Strong Shrinkage (low &lambda;)</h3> 

458 <ul> 

459 <li>Fewer than ~30 assets with a <code>corr</code> lookback shorter than 100 days.</li> 

460 <li>High-volatility or crisis regimes where correlations spike and the 

461 sample matrix is less representative of the true structure.</li> 

462 <li>Portfolios where estimation noise is more costly than correlation bias 

463 (low signal-to-noise ratio of <code>mu</code>).</li> 

464 </ul> 

465 

466 <h3>When to Prefer Light Shrinkage (high &lambda;)</h3> 

467 <ul> 

468 <li>Many assets with a long lookback (low concentration ratio).</li> 

469 <li>The EWMA correlation structure carries genuine diversification 

470 information that the solver should exploit.</li> 

471 <li>Out-of-sample testing shows that position stability is not a concern.</li> 

472 </ul> 

473 

474 <h3>EWMA Parameters - vola and corr</h3> 

475 <p> 

476 Both <code>vola</code> and <code>corr</code> are span-equivalent EWMA 

477 lookbacks (in trading periods). The EWMA decay factor is 

478 <em>a = 2 / (span + 1)</em>, giving a centre-of-mass of 

479 <em>span / 2</em> periods. <code>corr</code> must be &gt;= <code>vola</code> 

480 to ensure the correlation estimator sees at least as much history as the 

481 volatility normaliser. 

482 </p> 

483 

484 <h3>References</h3> 

485 <p class="refs"> 

486 Ledoit, O. &amp; Wolf, M. (2004). 

487 <em>A well-conditioned estimator for large-dimensional covariance matrices.</em> 

488 Journal of Multivariate Analysis, 88(2), 365-411.<br/> 

489 Chen, Y., Wiesel, A., Eldar, Y. C., &amp; Hero, A. O. (2010). 

490 <em>Shrinkage Algorithms for MMSE Covariance Estimation.</em> 

491 IEEE Transactions on Signal Processing, 58(10), 5016-5029.<br/> 

492 See also: <code>basanos.math._signal.shrink2id</code> for the implementation. 

493 </p> 

494</div> 

495""" 

496 

497 

498# ── Plotly helper ───────────────────────────────────────────────────────────── 

499 

500 

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

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

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

504 

505 

506# ── ConfigReport dataclass ──────────────────────────────────────────────────── 

507 

508 

509@dataclasses.dataclass(frozen=True) 

510class ConfigReport: 

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

512 

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

514 

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

516 constraints, and descriptions. 

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

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

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

520 suggested λ ranges. 

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

522 parameter semantics, and academic references. 

523 

524 Usage:: 

525 

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

527 report = config.report 

528 html_str = report.to_html() 

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

530 

531 # Full report including lambda sweep — from engine 

532 report = engine.config_report 

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

534 """ 

535 

536 config: BasanosConfig 

537 engine: BasanosEngine | None = None 

538 

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

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

541 

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

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

544 pure HTML/CSS. 

545 

546 Args: 

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

548 

549 Returns: 

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

551 """ 

552 cfg = self.config 

553 

554 # ── Parameter table ──────────────────────────────────────────────── 

555 params_html = _params_table_html(cfg) 

556 

557 # ── Lambda sweep ─────────────────────────────────────────────────── 

558 has_engine = self.engine is not None 

559 if has_engine: 

560 try: 

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

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

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

564 except Exception as exc: 

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

566 else: 

567 sweep_section = ( 

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

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

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

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

572 "</p>" 

573 ) 

574 

575 # ── Guidance table ───────────────────────────────────────────────── 

576 guidance_html = _guidance_table_html() 

577 

578 # ── TOC links ────────────────────────────────────────────────────── 

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

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

581 

582 # ── Assemble HTML ────────────────────────────────────────────────── 

583 return f"""<!DOCTYPE html> 

584<html lang="en"> 

585<head> 

586 <meta charset="UTF-8" /> 

587 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 

588 <title>{title}</title> 

589 <style>{_CSS}</style> 

590</head> 

591<body> 

592 

593<header class="report-header"> 

594 <h1>&#x2699;&#xFE0F; {title}</h1> 

595 <div class="report-meta"> 

596 <span><strong>vola:</strong> {cfg.vola}</span> 

597 <span><strong>corr:</strong> {cfg.corr}</span> 

598 <span><strong>clip:</strong> {cfg.clip}</span> 

599 <span><strong>shrink&nbsp;(λ):</strong> {cfg.shrink}</span> 

600 <span><strong>AUM:</strong> {cfg.aum:,.0f}</span> 

601 </div> 

602</header> 

603 

604<nav class="toc"> 

605 <a href="#parameters">Parameters</a> 

606 {toc_extra_sep}{toc_lambda} 

607 <a href="#guidance">Shrinkage Guidance</a> 

608 <a href="#theory">Theory</a> 

609</nav> 

610 

611<div class="container"> 

612 

613 <section class="section" id="parameters"> 

614 <h2 class="section-title">Configuration Parameters</h2> 

615 <div class="chart-card" style="overflow-x: auto;">{params_html}</div> 

616 </section> 

617 

618 <section class="section" id="lambda-sweep"> 

619 <h2 class="section-title">Lambda (Shrinkage) Sweep</h2> 

620 {sweep_section} 

621 </section> 

622 

623 <section class="section" id="guidance"> 

624 <h2 class="section-title">Shrinkage Guidance — n / T Regimes</h2> 

625 <div class="chart-card" style="overflow-x: auto;">{guidance_html}</div> 

626 </section> 

627 

628 <section class="section" id="theory"> 

629 <h2 class="section-title">Theory &amp; References</h2> 

630 {_THEORY_HTML} 

631 </section> 

632 

633</div> 

634 

635<footer class="report-footer"> 

636 Generated by <strong>basanos</strong> 

637</footer> 

638 

639</body> 

640</html>""" 

641 

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

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

644 

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

646 file extension. 

647 

648 Args: 

649 path: Destination file path. 

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

651 

652 Returns: 

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

654 """ 

655 p = Path(path) 

656 if not p.suffix: 

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

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

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

660 return p