Coverage for src/basanos/math/_config_report.py: 100%
86 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +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
19from pathlib import Path
20from typing import TYPE_CHECKING
22import numpy as np
23import plotly.graph_objects as go
24import plotly.io as pio
25from jinja2 import Environment, FileSystemLoader, select_autoescape
27# Both BasanosConfig and BasanosEngine are referenced only as type hints here;
28# `type(config).model_fields` reads the model metadata off the instance at
29# runtime instead of the class, so neither needs a runtime import. Keeping them
30# under TYPE_CHECKING means this rendering layer creates no runtime edge back to
31# `_config`/`optimizer`, so `_config` can import `ConfigReport` at module level.
32if TYPE_CHECKING:
33 from ._config import BasanosConfig
34 from .optimizer import BasanosEngine
36_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
37_env = Environment(
38 loader=FileSystemLoader(_TEMPLATES_DIR),
39 autoescape=select_autoescape(["html"]),
40)
43# ── Parameter metadata ────────────────────────────────────────────────────────
46# Pydantic v2 constraint attributes on a metadata object, paired with the
47# comparison symbol used to render them.
48_CONSTRAINT_SYMBOLS = (("gt", ">"), ("ge", "≥"), ("lt", "<"), ("le", "≤"))
51def _constraint_str(field_info: object) -> str:
52 """Extract a compact constraint string from a pydantic FieldInfo."""
53 parts: list[str] = []
54 # Pydantic v2 stores constraints inside field_info.metadata
55 metadata = getattr(field_info, "metadata", [])
56 for m in metadata:
57 for attr, symbol in _CONSTRAINT_SYMBOLS:
58 bound = getattr(m, attr, None)
59 if bound is not None:
60 parts.append(f"{symbol} {bound}")
61 return ", ".join(parts) if parts else "—"
64def _fmt_float(v: float) -> str:
65 """Format a float config value for compact display."""
66 if v == int(v) and abs(v) >= 1e4:
67 return f"{v:.2e}"
68 if abs(v) < 0.01 and v != 0.0:
69 return f"{v:.2e}"
70 return f"{v:g}"
73def _fmt_value(v: object) -> str:
74 """Format a config field value for display."""
75 if isinstance(v, float):
76 return _fmt_float(v)
77 return str(v)
80def _params_table_html(config: BasanosConfig) -> str:
81 """Render a styled HTML table of all BasanosConfig parameters.
83 Args:
84 config: The configuration instance to render.
86 Returns:
87 An HTML ``<table>`` string ready to embed in a page.
88 """
89 rows: list[str] = []
90 for name, field_info in type(config).model_fields.items():
91 value = getattr(config, name)
92 constraint = _constraint_str(field_info)
93 description = field_info.description or "—"
94 required = field_info.is_required()
95 default_label = "required" if required else f"default: {_fmt_value(field_info.default)}"
96 rows.append(
97 f"<tr>"
98 f'<td class="param-name">{name}</td>'
99 f'<td class="param-value">{_fmt_value(value)}</td>'
100 f'<td class="param-constraint">{constraint}</td>'
101 f'<td class="param-description">{description}</td>'
102 f'<td class="param-description" style="color:#718096;white-space:nowrap">{default_label}</td>'
103 f"</tr>"
104 )
106 return (
107 '<table class="param-table">'
108 "<thead><tr>"
109 "<th>Parameter</th>"
110 "<th>Current Value</th>"
111 "<th>Constraint</th>"
112 "<th>Description</th>"
113 "<th>Default</th>"
114 "</tr></thead>"
115 f"<tbody>{''.join(rows)}</tbody>"
116 "</table>"
117 )
120# ── Lambda-sweep chart ────────────────────────────────────────────────────────
123def _lambda_sweep_fig(engine: BasanosEngine, n_points: int = 21) -> go.Figure:
124 """Build a Plotly figure showing annualised Sharpe vs shrinkage weight λ.
126 Args:
127 engine: The engine to sweep. All parameters other than ``shrink``
128 are held fixed at their current values.
129 n_points: Number of evenly-spaced λ values to evaluate in [0, 1].
131 Returns:
132 A `Figure`.
133 """
134 lambdas = np.linspace(0.0, 1.0, n_points)
135 sharpes = [engine.sharpe_at_shrink(float(lam)) for lam in lambdas]
137 # Current config lambda marker
138 current_lam = engine.cfg.shrink
139 current_sharpe = engine.sharpe_at_shrink(current_lam)
141 fig = go.Figure()
143 # Main sweep line
144 fig.add_trace(
145 go.Scatter(
146 x=list(lambdas),
147 y=sharpes,
148 mode="lines+markers",
149 name="Sharpe(λ)",
150 line={"color": "#4299e1", "width": 2},
151 marker={"size": 5, "color": "#4299e1"},
152 hovertemplate="λ = %{x:.2f}<br>Sharpe = %{y:.3f}<extra></extra>",
153 )
154 )
156 # Current lambda marker
157 fig.add_trace(
158 go.Scatter(
159 x=[current_lam],
160 y=[current_sharpe],
161 mode="markers",
162 name=f"Current λ = {current_lam:.2f}",
163 marker={"size": 12, "color": "#f6ad55", "symbol": "diamond"},
164 hovertemplate=f"Current λ = {current_lam:.2f}<br>Sharpe = {current_sharpe:.3f}<extra></extra>",
165 )
166 )
168 # Vertical reference lines at λ=0 and λ=1
169 for x_val, label in [(0.0, "λ=0 (identity)"), (1.0, "λ=1 (no shrinkage)")]:
170 fig.add_vline(
171 x=x_val,
172 line_dash="dash",
173 line_color="#718096",
174 annotation_text=label,
175 annotation_position="top",
176 annotation_font_color="#718096",
177 annotation_font_size=10,
178 )
180 fig.update_layout(
181 title={
182 "text": "Annualised Sharpe Ratio vs Shrinkage Weight λ",
183 "font": {"color": "#e2e8f0", "size": 15},
184 },
185 xaxis={
186 "title": "Shrinkage weight λ (0 = full identity, 1 = raw EWMA)",
187 "color": "#a0aec0",
188 "gridcolor": "#2d3748",
189 "title_font": {"color": "#a0aec0"},
190 },
191 yaxis={
192 "title": "Annualised Sharpe Ratio",
193 "color": "#a0aec0",
194 "gridcolor": "#2d3748",
195 "title_font": {"color": "#a0aec0"},
196 },
197 paper_bgcolor="#1a202c",
198 plot_bgcolor="#1a202c",
199 font={"color": "#e2e8f0"},
200 legend={"bgcolor": "#1a202c", "bordercolor": "#2d3748", "borderwidth": 1},
201 margin={"t": 60, "b": 50, "l": 60, "r": 20},
202 )
203 return fig
206# ── Guidance table ────────────────────────────────────────────────────────────
208_GUIDANCE_ROWS = [
209 ("n > 20, T < 40", "0.3 - 0.5", "Near-singular matrix likely; strong regularisation needed."),
210 ("n ~= 10, T ~= 60", "0.5 - 0.7", "Balanced regime; moderate regularisation."),
211 ("n < 10, T > 100", "0.7 - 0.9", "Well-conditioned sample; light shrinkage for stability."),
212]
215def _guidance_table_html() -> str:
216 """Return an HTML table of shrinkage regime guidance (n / T heuristics)."""
217 rows = "".join(
218 f"<tr>"
219 f'<td class="regime">{regime}</td>'
220 f'<td class="shrink-range">{shrink_range}</td>'
221 f'<td class="notes">{notes}</td>'
222 f"</tr>"
223 for regime, shrink_range, notes in _GUIDANCE_ROWS
224 )
225 return (
226 '<table class="guidance-table">'
227 "<thead><tr>"
228 "<th>n (assets) / T (corr lookback)</th>"
229 "<th>Suggested shrink (λ)</th>"
230 "<th>Notes</th>"
231 "</tr></thead>"
232 f"<tbody>{rows}</tbody>"
233 "</table>"
234 )
237# ── Plotly helper ─────────────────────────────────────────────────────────────
240def _figure_div(fig: go.Figure, include_plotlyjs: bool | str) -> str:
241 """Return an HTML div string for *fig*."""
242 return str(pio.to_html(fig, full_html=False, include_plotlyjs=include_plotlyjs))
245# ── ConfigReport dataclass ────────────────────────────────────────────────────
248@dataclasses.dataclass(frozen=True)
249class ConfigReport:
250 """Facade for generating HTML reports from a `BasanosConfig`.
252 Produces a self-contained, dark-themed HTML document with:
254 * A **parameter table** listing all config fields, their current values,
255 constraints, and descriptions.
256 * An interactive **lambda-sweep chart** (requires *engine*) showing
257 annualised Sharpe as a function of the shrinkage weight λ across [0, 1].
258 * A **shrinkage-guidance table** mapping concentration-ratio regimes to
259 suggested λ ranges.
260 * A **theory section** covering Ledoit-Wolf linear shrinkage, EWMA
261 parameter semantics, and academic references.
263 Usage::
265 # Static report (no lambda sweep) — from config alone
266 report = config.report
267 html_str = report.to_html()
268 report.save("output/config_report.html")
270 # Full report including lambda sweep — from engine
271 report = engine.config_report
272 report.save("output/config_report_with_sweep.html")
273 """
275 config: BasanosConfig
276 engine: BasanosEngine | None = None
278 def to_html(self, title: str = "Basanos Configuration Report") -> str:
279 """Render a full HTML report as a string.
281 The document is self-contained: Plotly.js is loaded from the CDN
282 only when a lambda-sweep chart is included. All other sections are
283 pure HTML/CSS.
285 Args:
286 title: HTML ``<title>`` text and visible page heading.
288 Returns:
289 A complete HTML document as a `str`.
290 """
291 cfg = self.config
293 # ── Parameter table ────────────────────────────────────────────────
294 params_html = _params_table_html(cfg)
296 # ── Lambda sweep ───────────────────────────────────────────────────
297 has_engine = self.engine is not None
298 if has_engine:
299 try:
300 fig = _lambda_sweep_fig(self.engine) # type: ignore[arg-type]
301 sweep_div = _figure_div(fig, include_plotlyjs="cdn")
302 sweep_section = f'<div class="chart-card">{sweep_div}</div>'
303 except Exception as exc: # noqa: BLE001 - optional chart: any rendering failure degrades to a placeholder
304 sweep_section = f'<p class="chart-unavailable">Lambda sweep unavailable: {exc}</p>'
305 else:
306 sweep_section = (
307 '<p class="chart-unavailable" style="padding:1.5rem;">'
308 "Lambda sweep is available when accessing this report via "
309 "<code>engine.config_report</code> (requires a "
310 "<strong>BasanosEngine</strong> instance with price and signal data)."
311 "</p>"
312 )
314 # ── Guidance table ─────────────────────────────────────────────────
315 guidance_html = _guidance_table_html()
317 # ── TOC links ──────────────────────────────────────────────────────
318 toc_lambda = '<a href="#lambda-sweep">Lambda Sweep</a>' if has_engine else ""
319 toc_extra_sep = " " if has_engine else ""
321 # ── Render template ────────────────────────────────────────────────
322 template = _env.get_template("config_report.html")
323 return template.render(
324 title=title,
325 vola=cfg.vola,
326 corr=cfg.corr,
327 clip=cfg.clip,
328 shrink=cfg.shrink,
329 aum=f"{cfg.aum:,.0f}",
330 toc_lambda=toc_lambda,
331 toc_extra_sep=toc_extra_sep,
332 params_html=params_html,
333 sweep_section=sweep_section,
334 guidance_html=guidance_html,
335 container_max_width="1200px",
336 )
338 def save(self, path: str | Path, title: str = "Basanos Configuration Report") -> Path:
339 """Save the HTML report to a file.
341 A ``.html`` suffix is appended automatically when *path* has no
342 file extension.
344 Args:
345 path: Destination file path.
346 title: HTML ``<title>`` text and visible page heading.
348 Returns:
349 The resolved `Path` of the written file.
350 """
351 p = Path(path)
352 if not p.suffix:
353 p = p.with_suffix(".html")
354 p.parent.mkdir(parents=True, exist_ok=True)
355 p.write_text(self.to_html(title=title), encoding="utf-8")
356 return p