mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
353 lines
10 KiB
Python
353 lines
10 KiB
Python
"""Composite tearsheet — HTML strategy report with interactive plotly charts."""
|
|
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import webbrowser
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
from manifoldbt.plot._theme import (
|
|
BG_AXES,
|
|
BG_FIGURE,
|
|
DARK_GRAY,
|
|
GRAY,
|
|
WHITE,
|
|
theme_context,
|
|
)
|
|
from manifoldbt.plot._convert import equity_with_dates
|
|
from manifoldbt.plot._utils import auto_title, chart_div, format_pct
|
|
from manifoldbt.plot.backtest import (
|
|
annual_returns,
|
|
drawdown,
|
|
monthly_returns,
|
|
returns_histogram,
|
|
rolling_sharpe,
|
|
rolling_volatility,
|
|
summary,
|
|
var_chart,
|
|
)
|
|
|
|
_CSS = f"""
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{
|
|
background: {BG_FIGURE};
|
|
color: {WHITE};
|
|
font-family: "SF Mono", "Fira Code", "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
-webkit-font-smoothing: antialiased;
|
|
}}
|
|
.container {{
|
|
max-width: 1800px;
|
|
margin: 0 auto;
|
|
padding: 24px 32px;
|
|
}}
|
|
.header {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
padding: 16px 0 12px 0;
|
|
border-bottom: 1px solid #1e1e24;
|
|
margin-bottom: 20px;
|
|
}}
|
|
.header h1 {{
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
color: {WHITE};
|
|
letter-spacing: 0.5px;
|
|
}}
|
|
.header .dates {{
|
|
font-size: 13px;
|
|
color: {GRAY};
|
|
}}
|
|
/* Main layout: metrics left + charts right */
|
|
.main-grid {{
|
|
display: grid;
|
|
grid-template-columns: 420px 1fr;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}}
|
|
.metrics-panel {{
|
|
background: {BG_AXES};
|
|
border: 1px solid #1e1e24;
|
|
border-radius: 4px;
|
|
padding: 16px 20px;
|
|
}}
|
|
.charts-stack {{
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}}
|
|
.section-label {{
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
color: {DARK_GRAY};
|
|
letter-spacing: 1.5px;
|
|
margin-bottom: 4px;
|
|
margin-top: 10px;
|
|
}}
|
|
.section-label:first-child {{
|
|
margin-top: 0;
|
|
}}
|
|
.metric-row {{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
padding: 1px 0;
|
|
font-size: 12px;
|
|
}}
|
|
.metric-label {{
|
|
color: {GRAY};
|
|
}}
|
|
.metric-dots {{
|
|
flex: 1;
|
|
border-bottom: 1px dotted #2a2a2a;
|
|
margin: 0 6px;
|
|
min-width: 10px;
|
|
position: relative;
|
|
top: -3px;
|
|
}}
|
|
.metric-value {{
|
|
color: {GRAY};
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
}}
|
|
.chart-cell {{
|
|
background: {BG_FIGURE};
|
|
border: 1px solid #1e1e24;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}}
|
|
.chart-grid {{
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}}
|
|
.plotly-graph-div {{ width: 100% !important; }}
|
|
"""
|
|
|
|
|
|
def _div(fig, height: int) -> str:
|
|
"""Wrap a plotly figure div in a bordered cell."""
|
|
return f'<div class="chart-cell">{chart_div(fig, height=height)}</div>'
|
|
|
|
|
|
def tearsheet(
|
|
result,
|
|
*,
|
|
benchmark=None,
|
|
title: Optional[str] = None,
|
|
show: bool = False,
|
|
save: Optional[Union[str, Path]] = None,
|
|
dpi: int = 150,
|
|
plotlyjs: str = "cdn",
|
|
) -> str:
|
|
"""Strategy report — self-contained HTML page with interactive charts.
|
|
|
|
Returns the HTML string. Opens in browser when ``show=True``,
|
|
writes to disk when ``save`` is given.
|
|
|
|
Args:
|
|
plotlyjs: ``"cdn"`` (small file, needs network on open) or
|
|
``"inline"`` (fully offline report, ~4.4 MB heavier).
|
|
"""
|
|
_ = benchmark # reserved for future benchmark overlay support
|
|
_ = dpi # kept for backward compatibility (was the PNG export dpi)
|
|
strategy_name = title or auto_title(result, "Backtest")
|
|
metrics = result.metrics if hasattr(result, "metrics") else {}
|
|
ts = metrics.get("trade_stats", {})
|
|
dates, _vals = equity_with_dates(result)
|
|
|
|
date_start = str(dates[0])[:10] if len(dates) > 0 else "?"
|
|
date_end = str(dates[-1])[:10] if len(dates) > 0 else "?"
|
|
|
|
# ── Generate interactive chart divs ────────────────────────────
|
|
with theme_context():
|
|
div_summary = _div(summary(result), height=520)
|
|
div_dd = _div(drawdown(result), height=210)
|
|
div_annual = _div(annual_returns(result), height=330)
|
|
div_monthly = _div(monthly_returns(result), height=340)
|
|
div_hist = _div(returns_histogram(result), height=340)
|
|
div_sharpe = _div(rolling_sharpe(result), height=300)
|
|
div_vol = _div(rolling_volatility(result), height=300)
|
|
div_var = _div(var_chart(result), height=340)
|
|
|
|
# ── Metrics ───────────────────────────────────────────────────
|
|
ret = metrics.get("total_return", 0)
|
|
|
|
def _m(label, value, cls=""):
|
|
esc_v = escape(str(value))
|
|
cls_attr = f' class="metric-value {cls}"' if cls else ' class="metric-value"'
|
|
return (
|
|
f'<div class="metric-row">'
|
|
f'<span class="metric-label">{escape(label)}</span>'
|
|
f'<span class="metric-dots"></span>'
|
|
f'<span{cls_attr}>{esc_v}</span>'
|
|
f'</div>'
|
|
)
|
|
|
|
def _section(label):
|
|
return f'<div class="section-label">{escape(label)}</div>'
|
|
|
|
metrics_html = (
|
|
_section("RETURNS")
|
|
+ _m("Total Return", format_pct(ret))
|
|
+ _m("CAGR", format_pct(metrics.get("cagr", 0)))
|
|
+ _m("Max Drawdown", format_pct(metrics.get("max_drawdown", 0)))
|
|
+ _m("Volatility", format_pct(metrics.get("volatility", 0)))
|
|
+ _m("Best Day", format_pct(metrics.get("best_day", 0)))
|
|
+ _m("Worst Day", format_pct(metrics.get("worst_day", 0)))
|
|
+ _m("% Pos Days", f"{metrics.get('pct_positive_days', 0):.1%}")
|
|
+ _section("RATIOS")
|
|
+ _m("Sharpe", f"{metrics.get('sharpe', 0):.2f}")
|
|
+ _m("Sortino", f"{metrics.get('sortino', 0):.2f}")
|
|
+ _m("Calmar", f"{metrics.get('calmar', 0):.2f}")
|
|
+ _section("TRADING")
|
|
+ _m("Trades", f"{ts.get('total_trades', metrics.get('total_trades', 0))}")
|
|
+ _m("Win Rate", f"{ts.get('win_rate', metrics.get('win_rate', 0)):.1%}")
|
|
+ _m("Profit Factor", f"{ts.get('profit_factor', metrics.get('profit_factor', 0)):.2f}")
|
|
+ _m("Avg Hold", _fmt_hold_time(ts.get("avg_holding_seconds", 0)))
|
|
+ _m("Fees", f"{ts.get('total_fees', 0):.2f}")
|
|
)
|
|
|
|
# ── plotly.js include ─────────────────────────────────────────
|
|
if plotlyjs == "inline":
|
|
import plotly.io as pio
|
|
plotly_js_tag = f"<script>{pio.get_plotlyjs()}</script>"
|
|
else:
|
|
plotly_js_tag = '<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>'
|
|
|
|
# ── Assemble HTML ─────────────────────────────────────────────
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{escape(strategy_name)} — Tearsheet</title>
|
|
<style>{_CSS}</style>
|
|
{plotly_js_tag}
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
|
|
<div class="header">
|
|
<h1>{escape(strategy_name)}</h1>
|
|
<span class="dates">{escape(date_start)} → {escape(date_end)}</span>
|
|
</div>
|
|
|
|
<div class="main-grid">
|
|
<div>
|
|
<div class="metrics-panel" style="margin-bottom:12px;">{metrics_html}</div>
|
|
{div_annual}
|
|
</div>
|
|
<div class="charts-stack">
|
|
{div_summary}
|
|
{div_dd}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-grid">
|
|
{div_monthly}
|
|
{div_hist}
|
|
</div>
|
|
|
|
<div class="chart-grid">
|
|
{div_sharpe}
|
|
{div_vol}
|
|
</div>
|
|
|
|
<div class="chart-grid">
|
|
{div_var}
|
|
</div>
|
|
|
|
</div>
|
|
</body>
|
|
</html>"""
|
|
|
|
# ── Save / Show ───────────────────────────────────────────────
|
|
if save is not None:
|
|
Path(save).write_text(html, encoding="utf-8")
|
|
|
|
if show:
|
|
if save is not None:
|
|
report_path = Path(save).resolve()
|
|
else:
|
|
tmp = tempfile.NamedTemporaryFile(
|
|
suffix=".html", delete=False, mode="w", encoding="utf-8"
|
|
)
|
|
tmp.write(html)
|
|
tmp.close()
|
|
report_path = Path(tmp.name).resolve()
|
|
webbrowser.open(report_path.as_uri())
|
|
|
|
return html
|
|
|
|
|
|
def research_report(
|
|
sweep_result: Optional[Dict[str, Any]] = None,
|
|
wf_result: Optional[Dict[str, Any]] = None,
|
|
stability_result: Optional[Dict[str, Any]] = None,
|
|
*,
|
|
title: str = "Research Report",
|
|
figsize: tuple = (14, 6),
|
|
show: bool = False,
|
|
save: Optional[Union[str, Path]] = None,
|
|
dpi: int = 150,
|
|
) -> List[Any]:
|
|
"""Research report — one figure per analysis (plotly Figures)."""
|
|
from manifoldbt.plot.research import (
|
|
heatmap_2d,
|
|
stability,
|
|
walk_forward,
|
|
)
|
|
|
|
_ = title
|
|
figs = []
|
|
with theme_context():
|
|
if sweep_result is not None:
|
|
figs.append(heatmap_2d(sweep_result, figsize=figsize))
|
|
if wf_result is not None:
|
|
figs.append(walk_forward(wf_result, figsize=figsize))
|
|
if stability_result is not None:
|
|
figs.append(stability(stability_result, figsize=figsize))
|
|
|
|
if not figs:
|
|
raise ValueError("At least one result (sweep, wf, or stability) required.")
|
|
|
|
if save is not None:
|
|
path = Path(save)
|
|
stem, suffix = path.stem, path.suffix or ".html"
|
|
for i, f in enumerate(figs):
|
|
out = path.parent / f"{stem}_{i + 1}{suffix}"
|
|
if suffix.lower() == ".html":
|
|
from manifoldbt.plot._utils import write_responsive_html
|
|
write_responsive_html(f, out)
|
|
else:
|
|
scale = max(1.0, dpi / 96.0)
|
|
f.write_image(str(out), scale=scale)
|
|
if show:
|
|
for f in figs:
|
|
f.show()
|
|
|
|
return figs
|
|
|
|
|
|
# ── Internal ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _fmt_hold_time(seconds):
|
|
"""Format holding time in human-readable units."""
|
|
if seconds <= 0:
|
|
return "—"
|
|
days = seconds / 86400
|
|
if days >= 365:
|
|
return f"{days / 365:.1f}y"
|
|
if days >= 30:
|
|
return f"{days / 30:.1f}mo"
|
|
if days >= 1:
|
|
return f"{days:.0f}d"
|
|
hours = seconds / 3600
|
|
return f"{hours:.0f}h"
|