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
« prev ^ index » next coverage.py v7.14.1, created at 2026-09-25 12:05 +0000
1"""HTML report generation for BasanosConfig parameter analysis.
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.
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
19import html
20from pathlib import Path
21from typing import TYPE_CHECKING
23import numpy as np
24import plotly.graph_objects as go
25import plotly.io as pio
26from jinja2 import Environment, FileSystemLoader, select_autoescape
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
37_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
38_env = Environment(
39 loader=FileSystemLoader(_TEMPLATES_DIR),
40 autoescape=select_autoescape(["html"]),
41)
44# ── Parameter metadata ────────────────────────────────────────────────────────
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", "≤"))
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 "—"
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}"
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)
81def _params_table_html(config: BasanosConfig) -> str:
82 """Render a styled HTML table of all BasanosConfig parameters.
84 Args:
85 config: The configuration instance to render.
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 )
107 return (
108 '<table class="param-table">'
109 "<thead><tr>"
110 "<th>Parameter</th>"
111 "<th>Current 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 )
121# ── Lambda-sweep chart ────────────────────────────────────────────────────────
124def _lambda_sweep_fig(engine: BasanosEngine, n_points: int = 21) -> go.Figure:
125 """Build a Plotly figure showing annualised Sharpe vs shrinkage weight λ.
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].
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]
138 # Current config lambda marker
139 current_lam = engine.cfg.shrink
140 current_sharpe = engine.sharpe_at_shrink(current_lam)
142 fig = go.Figure()
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 )
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 )
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 )
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
207# ── Guidance table ────────────────────────────────────────────────────────────
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]
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 )
238# ── Plotly helper ─────────────────────────────────────────────────────────────
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))
246# ── ConfigReport dataclass ────────────────────────────────────────────────────
249@dataclasses.dataclass(frozen=True)
250class ConfigReport:
251 """Facade for generating HTML reports from a `BasanosConfig`.
253 Produces a self-contained, dark-themed HTML document with:
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.
264 Usage::
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")
271 # Full report including lambda sweep — from engine
272 report = engine.config_report
273 report.save("output/config_report_with_sweep.html")
274 """
276 config: BasanosConfig
277 engine: BasanosEngine | None = None
279 def to_html(self, title: str = "Basanos Configuration Report") -> str:
280 """Render a full HTML report as a string.
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.
286 Args:
287 title: HTML ``<title>`` text and visible page heading.
289 Returns:
290 A complete HTML document as a `str`.
291 """
292 cfg = self.config
294 # ── Parameter table ────────────────────────────────────────────────
295 params_html = _params_table_html(cfg)
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 )
316 # ── Guidance table ─────────────────────────────────────────────────
317 guidance_html = _guidance_table_html()
319 # ── TOC links ──────────────────────────────────────────────────────
320 toc_lambda = '<a href="#lambda-sweep">Lambda Sweep</a>' if has_engine else ""
321 toc_extra_sep = " " if has_engine else ""
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 )
340 def save(self, path: str | Path, title: str = "Basanos Configuration Report") -> Path:
341 """Save the HTML report to a file.
343 A ``.html`` suffix is appended automatically when *path* has no
344 file extension.
346 Args:
347 path: Destination file path.
348 title: HTML ``<title>`` text and visible page heading.
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