"""
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"""
| {r['year_month']} |
{(ret * 100):+.2f}%
|
{r['end_nav']:.4f} |
{r['trading_days']} |
"""
# 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"""
| {year} |
{(yr_ret * 100):+.2f}% |
{yearly[year]:.4f} |
"""
html = f"""
月度累计收益
| 月份 |
月收益 |
月末净值 |
交易天数 |
{rows_html}
年度收益
| 年度 |
年收益 |
年末净值 |
{yearly_rows}
"""
# 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)