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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 05:23 +0000
1"""HTML report generation for BasanosConfig parameter analysis.
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.
9Examples:
10 >>> import dataclasses
11 >>> from basanos.math._config_report import ConfigReport
12 >>> dataclasses.is_dataclass(ConfigReport)
13 True
14"""
16from __future__ import annotations
18import dataclasses
19from pathlib import Path
20from typing import TYPE_CHECKING
22import numpy as np
23import plotly.graph_objects as go
24import plotly.io as pio
26if TYPE_CHECKING:
27 from .optimizer import BasanosConfig, BasanosEngine
30# ── CSS (reuses the same dark-theme palette as _report.py) ───────────────────
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}
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; }
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; }
85/* ── Main Content ─────────────────────────────────── */
86.container {
87 max-width: 1200px;
88 margin: 0 auto;
89 padding: 2rem;
90}
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}
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}
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; }
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; }
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"""
238# ── Parameter metadata ────────────────────────────────────────────────────────
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 "—"
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)
269def _params_table_html(config: BasanosConfig) -> str:
270 """Render a styled HTML table of all BasanosConfig parameters.
272 Args:
273 config: The configuration instance to render.
275 Returns:
276 An HTML ``<table>`` string ready to embed in a page.
277 """
278 from .optimizer import BasanosConfig # local import to avoid circularity
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 )
297 return (
298 '<table class="param-table">'
299 "<thead><tr>"
300 "<th>Parameter</th>"
301 "<th>Current 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 )
311# ── Lambda-sweep chart ────────────────────────────────────────────────────────
314def _lambda_sweep_fig(engine: BasanosEngine, n_points: int = 21) -> go.Figure:
315 """Build a Plotly figure showing annualised Sharpe vs shrinkage weight λ.
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].
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]
328 # Current config lambda marker
329 current_lam = engine.cfg.shrink
330 current_sharpe = engine.sharpe_at_shrink(current_lam)
332 fig = go.Figure()
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 )
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 )
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 )
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
397# ── Guidance table ────────────────────────────────────────────────────────────
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]
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 )
428# ── Theory HTML ───────────────────────────────────────────────────────────────
430_THEORY_HTML = """
431<div class="theory-block">
432 <h3>Linear Shrinkage toward the Identity</h3>
433 <p>
434 The <code>shrink</code> parameter (λ) 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 = λ · C_EWMA + (1 - λ) · I_n</div>
439 <p>
440 where <em>I<sub>n</sub></em> is the n x n identity matrix.
441 Setting λ = 1 uses the raw EWMA correlation matrix (no shrinkage); setting
442 λ = 0 replaces it entirely with the identity (positions become purely
443 signal-proportional, uncorrelated).
444 </p>
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>
457 <h3>When to Prefer Strong Shrinkage (low λ)</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>
466 <h3>When to Prefer Light Shrinkage (high λ)</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>
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 >= <code>vola</code>
480 to ensure the correlation estimator sees at least as much history as the
481 volatility normaliser.
482 </p>
484 <h3>References</h3>
485 <p class="refs">
486 Ledoit, O. & 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., & 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"""
498# ── Plotly helper ─────────────────────────────────────────────────────────────
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)
506# ── ConfigReport dataclass ────────────────────────────────────────────────────
509@dataclasses.dataclass(frozen=True)
510class ConfigReport:
511 """Facade for generating HTML reports from a :class:`~basanos.math.optimizer.BasanosConfig`.
513 Produces a self-contained, dark-themed HTML document with:
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.
524 Usage::
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")
531 # Full report including lambda sweep — from engine
532 report = engine.config_report
533 report.save("output/config_report_with_sweep.html")
534 """
536 config: BasanosConfig
537 engine: BasanosEngine | None = None
539 def to_html(self, title: str = "Basanos Configuration Report") -> str:
540 """Render a full HTML report as a string.
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.
546 Args:
547 title: HTML ``<title>`` text and visible page heading.
549 Returns:
550 A complete HTML document as a :class:`str`.
551 """
552 cfg = self.config
554 # ── Parameter table ────────────────────────────────────────────────
555 params_html = _params_table_html(cfg)
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 )
575 # ── Guidance table ─────────────────────────────────────────────────
576 guidance_html = _guidance_table_html()
578 # ── TOC links ──────────────────────────────────────────────────────
579 toc_lambda = '<a href="#lambda-sweep">Lambda Sweep</a>' if has_engine else ""
580 toc_extra_sep = " " if has_engine else ""
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>
593<header class="report-header">
594 <h1>⚙️ {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 (λ):</strong> {cfg.shrink}</span>
600 <span><strong>AUM:</strong> {cfg.aum:,.0f}</span>
601 </div>
602</header>
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>
611<div class="container">
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>
618 <section class="section" id="lambda-sweep">
619 <h2 class="section-title">Lambda (Shrinkage) Sweep</h2>
620 {sweep_section}
621 </section>
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>
628 <section class="section" id="theory">
629 <h2 class="section-title">Theory & References</h2>
630 {_THEORY_HTML}
631 </section>
633</div>
635<footer class="report-footer">
636 Generated by <strong>basanos</strong>
637</footer>
639</body>
640</html>"""
642 def save(self, path: str | Path, title: str = "Basanos Configuration Report") -> Path:
643 """Save the HTML report to a file.
645 A ``.html`` suffix is appended automatically when *path* has no
646 file extension.
648 Args:
649 path: Destination file path.
650 title: HTML ``<title>`` text and visible page heading.
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