""" 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)