- Add html_report.py module for Playwright-based screenshot generation - Add generate_html_report() method to SimpleRotationStrategy - Modify backtest_viewer.html to use window-scoped variables for external injection - Inject monthly/yearly returns table into screenshot - Auto-generate HTML report in __main__ after export_results() Output: simple_rotation_html_report.png with ranking table + monthly returns
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""
|
|
HTML Report Screenshot Generator
|
|
|
|
Uses Playwright to render backtest_viewer.html in headless Chromium
|
|
and capture a full-page screenshot as PNG.
|
|
|
|
Intended to be called after export_results() has produced simple_rotation_detail.json.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Optional, List, Dict
|
|
import numpy as np
|
|
|
|
|
|
def compute_monthly_returns(daily_records: List[Dict]) -> List[Dict]:
|
|
"""Compute monthly cumulative returns from daily_records.
|
|
|
|
Returns list of {year, month, return_pct, trading_days} dicts.
|
|
"""
|
|
if not daily_records:
|
|
return []
|
|
|
|
# Group by (year, month)
|
|
monthly: Dict[str, List[float]] = {}
|
|
for rec in daily_records:
|
|
date_str = rec['date']
|
|
ym = date_str[:7] # "2024-01"
|
|
if ym not in monthly:
|
|
monthly[ym] = []
|
|
monthly[ym].append(rec['nav'])
|
|
|
|
results = []
|
|
for ym in sorted(monthly.keys()):
|
|
navs = monthly[ym]
|
|
if len(navs) < 1:
|
|
continue
|
|
# Monthly return = last_nav / prev_last_nav - 1
|
|
# prev_last_nav = last nav of previous month, or first nav of this month's start
|
|
# More accurate: use the nav just before the first day of this month
|
|
results.append({
|
|
'year_month': ym,
|
|
'start_nav': navs[0],
|
|
'end_nav': navs[-1],
|
|
'trading_days': len(navs),
|
|
})
|
|
|
|
# Compute returns sequentially
|
|
for i, r in enumerate(results):
|
|
if i == 0:
|
|
# First month: return relative to first NAV (which is usually ~1.0)
|
|
# Use previous day's nav if available, otherwise start_nav itself
|
|
r['return_pct'] = 0.0 # first month baseline
|
|
else:
|
|
r['return_pct'] = r['start_nav'] / results[i-1]['end_nav'] - 1
|
|
r['month_return'] = r['end_nav'] / r['start_nav'] - 1
|
|
|
|
return results
|
|
|
|
|
|
def generate_html_screenshot(
|
|
detail_json_path: str,
|
|
output_path: str,
|
|
target_date: Optional[str] = None,
|
|
viewport_width: int = 1920,
|
|
viewport_height: int = 1080,
|
|
show_monthly_table: bool = True,
|
|
daily_records: Optional[List[Dict]] = None,
|
|
):
|
|
"""Render backtest_viewer.html and capture screenshot.
|
|
|
|
Args:
|
|
detail_json_path: Path to simple_rotation_detail.json
|
|
output_path: Output PNG path
|
|
target_date: Date string to navigate to (YYYY-MM-DD). None = last day.
|
|
viewport_width: Browser viewport width
|
|
viewport_height: Browser viewport height (minimum, will expand for full page)
|
|
show_monthly_table: Whether to inject monthly returns table
|
|
daily_records: List of daily record dicts (needed for monthly table)
|
|
"""
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError:
|
|
print(" x Playwright not installed. Run: pip install playwright && playwright install chromium")
|
|
return
|
|
|
|
detail_path = Path(detail_json_path)
|
|
if not detail_path.exists():
|
|
print(f" x Detail JSON not found: {detail_json_path}")
|
|
return
|
|
|
|
output = Path(output_path)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
html_path = Path(__file__).parent / 'backtest_viewer.html'
|
|
if not html_path.exists():
|
|
print(f" x backtest_viewer.html not found: {html_path}")
|
|
return
|
|
|
|
with open(detail_path, 'r', encoding='utf-8') as f:
|
|
detail_data = json.load(f)
|
|
|
|
print(f" Launching headless Chromium...")
|
|
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=True)
|
|
page = browser.new_page(viewport={'width': viewport_width, 'height': viewport_height})
|
|
|
|
# Load the HTML file
|
|
page.goto(f'file://{html_path.absolute()}')
|
|
page.wait_for_selector('#loader')
|
|
|
|
# Inject data directly (skip file input)
|
|
page.evaluate(f"""
|
|
(data) => {{
|
|
DATA = data;
|
|
document.getElementById('loader').style.display = 'none';
|
|
document.getElementById('app').classList.add('active');
|
|
|
|
// Build rebalance day index
|
|
rebalanceDays = [];
|
|
DATA.days.forEach((d, i) => {{ if (d.is_rebalance) rebalanceDays.push(i); }});
|
|
|
|
// Set date picker range
|
|
document.getElementById('date-picker').min = DATA.days[0].date;
|
|
document.getElementById('date-picker').max = DATA.days[DATA.days.length - 1].date;
|
|
}}
|
|
""", detail_data)
|
|
|
|
# Navigate to target date
|
|
if target_date:
|
|
page.evaluate(f"""
|
|
(targetDate) => {{
|
|
const idx = DATA.days.findIndex(d => d.date === targetDate);
|
|
if (idx >= 0) {{
|
|
currentIdx = idx;
|
|
render(currentIdx);
|
|
drawChart();
|
|
drawChartMarker();
|
|
}}
|
|
}}
|
|
""", target_date)
|
|
else:
|
|
# Navigate to last day
|
|
page.evaluate("""
|
|
() => {
|
|
currentIdx = DATA.days.length - 1;
|
|
render(currentIdx);
|
|
drawChart();
|
|
drawChartMarker();
|
|
}
|
|
""")
|
|
|
|
# Wait for rendering
|
|
page.wait_for_timeout(500)
|
|
|
|
# Inject monthly returns table if requested
|
|
if show_monthly_table and daily_records:
|
|
monthly = compute_monthly_returns(daily_records)
|
|
if monthly:
|
|
_inject_monthly_table(page, monthly)
|
|
page.wait_for_timeout(300)
|
|
|
|
# Take full page screenshot
|
|
page.screenshot(path=str(output), full_page=True)
|
|
browser.close()
|
|
|
|
print(f" + HTML Report Screenshot: {output}")
|
|
|
|
|
|
def _inject_monthly_table(page, monthly_data: List[Dict]):
|
|
"""Inject monthly returns table into the page."""
|
|
|
|
# Build HTML for monthly table
|
|
rows_html = ""
|
|
for r in monthly_data:
|
|
ret = r['month_return']
|
|
cls = 'positive' if ret >= 0 else 'negative'
|
|
rows_html += f"""
|
|
<tr>
|
|
<td>{r['year_month']}</td>
|
|
<td style="color:{'#3fb950' if ret >= 0 else '#da3633'};font-weight:600">
|
|
{(ret * 100):+.2f}%
|
|
</td>
|
|
<td>{r['end_nav']:.4f}</td>
|
|
<td>{r['trading_days']}</td>
|
|
</tr>
|
|
"""
|
|
|
|
# Also compute yearly returns
|
|
yearly: Dict[str, float] = {}
|
|
yearly_navs: Dict[str, float] = {} # last nav of each year
|
|
for r in monthly_data:
|
|
year = r['year_month'][:4]
|
|
yearly[year] = r['end_nav']
|
|
yearly_list = sorted(yearly.keys())
|
|
yearly_rows = ""
|
|
for i, year in enumerate(yearly_list):
|
|
if i == 0:
|
|
yr_ret = 0.0
|
|
else:
|
|
prev_year = yearly_list[i-1]
|
|
yr_ret = yearly[year] / yearly[prev_year] - 1
|
|
cls_color = '#3fb950' if yr_ret >= 0 else '#da3633'
|
|
yearly_rows += f"""
|
|
<tr>
|
|
<td>{year}</td>
|
|
<td style="color:{cls_color};font-weight:600">{(yr_ret * 100):+.2f}%</td>
|
|
<td>{yearly[year]:.4f}</td>
|
|
</tr>
|
|
"""
|
|
|
|
html = f"""
|
|
<div id="monthly-panel" style="
|
|
padding: 16px;
|
|
background: #161b22;
|
|
border-top: 1px solid #30363d;
|
|
">
|
|
<div style="display: flex; gap: 32px; align-items: flex-start;">
|
|
<div style="flex: 1;">
|
|
<h3 style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">
|
|
月度累计收益
|
|
</h3>
|
|
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:monospace;">
|
|
<thead>
|
|
<tr style="background:#0d1117;">
|
|
<th style="text-align:left;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">月份</th>
|
|
<th style="text-align:right;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">月收益</th>
|
|
<th style="text-align:right;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">月末净值</th>
|
|
<th style="text-align:right;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">交易天数</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows_html}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div style="min-width: 240px;">
|
|
<h3 style="color:#8b949e;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">
|
|
年度收益
|
|
</h3>
|
|
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:monospace;">
|
|
<thead>
|
|
<tr style="background:#0d1117;">
|
|
<th style="text-align:left;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">年度</th>
|
|
<th style="text-align:right;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">年收益</th>
|
|
<th style="text-align:right;padding:6px 8px;color:#8b949e;border-bottom:1px solid #30363d;">年末净值</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{yearly_rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
|
|
# Append to app container
|
|
page.evaluate(f"""
|
|
(htmlContent) => {{
|
|
const app = document.getElementById('app');
|
|
const div = document.createElement('div');
|
|
div.innerHTML = htmlContent;
|
|
app.appendChild(div);
|
|
}}
|
|
""", html)
|
|
|
|
# Apply row borders
|
|
page.evaluate("""
|
|
() => {
|
|
document.querySelectorAll('#monthly-panel td').forEach(td => {
|
|
td.style.borderBottom = '1px solid #21262d';
|
|
td.style.padding = '5px 8px';
|
|
});
|
|
}
|
|
""")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Test: run with existing detail JSON
|
|
import sys
|
|
detail = sys.argv[1] if len(sys.argv) > 1 else "rotation/results/simple_rotation_detail.json"
|
|
output = sys.argv[2] if len(sys.argv) > 2 else "rotation/results/simple_rotation_html_report.png"
|
|
generate_html_screenshot(detail, output)
|