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
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-02 17:47 +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
25from jinja2 import Environment, FileSystemLoader, select_autoescape
27if TYPE_CHECKING:
28 from .optimizer import BasanosConfig, BasanosEngine
30_TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
31_env = Environment(
32 loader=FileSystemLoader(_TEMPLATES_DIR),
33 autoescape=select_autoescape(["html"]),
34)
37# ── Parameter metadata ────────────────────────────────────────────────────────
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 "—"
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)
68def _params_table_html(config: BasanosConfig) -> str:
69 """Render a styled HTML table of all BasanosConfig parameters.
71 Args:
72 config: The configuration instance to render.
74 Returns:
75 An HTML ``<table>`` string ready to embed in a page.
76 """
77 from .optimizer import BasanosConfig # local import to avoid circularity
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 )
96 return (
97 '<table class="param-table">'
98 "<thead><tr>"
99 "<th>Parameter</th>"
100 "<th>Current 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 )
110# ── Lambda-sweep chart ────────────────────────────────────────────────────────
113def _lambda_sweep_fig(engine: BasanosEngine, n_points: int = 21) -> go.Figure:
114 """Build a Plotly figure showing annualised Sharpe vs shrinkage weight λ.
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].
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]
127 # Current config lambda marker
128 current_lam = engine.cfg.shrink
129 current_sharpe = engine.sharpe_at_shrink(current_lam)
131 fig = go.Figure()
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 )
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 )
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 )
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
196# ── Guidance table ────────────────────────────────────────────────────────────
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]
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 )
227# ── Plotly helper ─────────────────────────────────────────────────────────────
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)
235# ── ConfigReport dataclass ────────────────────────────────────────────────────
238@dataclasses.dataclass(frozen=True)
239class ConfigReport:
240 """Facade for generating HTML reports from a :class:`~basanos.math.optimizer.BasanosConfig`.
242 Produces a self-contained, dark-themed HTML document with:
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.
253 Usage::
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")
260 # Full report including lambda sweep — from engine
261 report = engine.config_report
262 report.save("output/config_report_with_sweep.html")
263 """
265 config: BasanosConfig
266 engine: BasanosEngine | None = None
268 def to_html(self, title: str = "Basanos Configuration Report") -> str:
269 """Render a full HTML report as a string.
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.
275 Args:
276 title: HTML ``<title>`` text and visible page heading.
278 Returns:
279 A complete HTML document as a :class:`str`.
280 """
281 cfg = self.config
283 # ── Parameter table ────────────────────────────────────────────────
284 params_html = _params_table_html(cfg)
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 )
304 # ── Guidance table ─────────────────────────────────────────────────
305 guidance_html = _guidance_table_html()
307 # ── TOC links ──────────────────────────────────────────────────────
308 toc_lambda = '<a href="#lambda-sweep">Lambda Sweep</a>' if has_engine else ""
309 toc_extra_sep = " " if has_engine else ""
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 )
328 def save(self, path: str | Path, title: str = "Basanos Configuration Report") -> Path:
329 """Save the HTML report to a file.
331 A ``.html`` suffix is appended automatically when *path* has no
332 file extension.
334 Args:
335 path: Destination file path.
336 title: HTML ``<title>`` text and visible page heading.
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