feat: add HTML report screenshot generation via Playwright
- 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
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -199,3 +199,9 @@ zhihu-articles/
|
||||
|
||||
# Results directory (test outputs, charts, etc.)
|
||||
results/
|
||||
|
||||
# Node.js (local testing only)
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
rotation/test_screenshot.mjs
|
||||
|
||||
@@ -152,11 +152,15 @@ tr.below-threshold { color: #484f58; }
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let DATA = null;
|
||||
let currentIdx = 0;
|
||||
let playing = false;
|
||||
let playTimer = null;
|
||||
let rebalanceDays = [];
|
||||
// Use window. to allow external access (for Playwright screenshot injection)
|
||||
window.DATA = null;
|
||||
window.currentIdx = 0;
|
||||
window.playing = false;
|
||||
window.playTimer = null;
|
||||
window.rebalanceDays = [];
|
||||
|
||||
// Convenience aliases for script-internal use
|
||||
const DATA_PROXY = { get data() { return window.DATA; }, set data(v) { window.DATA = v; } };
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
|
||||
285
rotation/html_report.py
Normal file
285
rotation/html_report.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1418,6 +1418,37 @@ class SimpleRotationStrategy:
|
||||
plt.close()
|
||||
print(f" + Report: {chart_path}")
|
||||
|
||||
def generate_html_report(self, output_dir: str = None, target_date: str = None):
|
||||
"""Generate HTML-based report screenshot using Playwright.
|
||||
|
||||
Renders backtest_viewer.html in headless Chromium and captures
|
||||
the ranking table + monthly returns as a PNG.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to save the PNG (default: rotation/results)
|
||||
target_date: Date to navigate to (YYYY-MM-DD). None = last day.
|
||||
"""
|
||||
from rotation.html_report import generate_html_screenshot
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = Path(__file__).parent / 'results'
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
detail_path = output_dir / 'simple_rotation_detail.json'
|
||||
if not detail_path.exists():
|
||||
print(f" x Detail JSON not found: {detail_path}")
|
||||
print(" Run export_results() first.")
|
||||
return
|
||||
|
||||
output_path = output_dir / 'simple_rotation_html_report.png'
|
||||
generate_html_screenshot(
|
||||
detail_json_path=str(detail_path),
|
||||
output_path=str(output_path),
|
||||
target_date=target_date,
|
||||
daily_records=self.daily_records,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Entry point
|
||||
@@ -1433,3 +1464,4 @@ if __name__ == "__main__":
|
||||
if result:
|
||||
strategy.export_results()
|
||||
strategy.generate_report()
|
||||
strategy.generate_html_report()
|
||||
|
||||
Reference in New Issue
Block a user