Revert "feat: add HTML report screenshot generation via Playwright"
This reverts commit f370caeff9.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -199,9 +199,3 @@ zhihu-articles/
|
|||||||
|
|
||||||
# Results directory (test outputs, charts, etc.)
|
# Results directory (test outputs, charts, etc.)
|
||||||
results/
|
results/
|
||||||
|
|
||||||
# Node.js (local testing only)
|
|
||||||
node_modules/
|
|
||||||
package.json
|
|
||||||
package-lock.json
|
|
||||||
rotation/test_screenshot.mjs
|
|
||||||
|
|||||||
@@ -152,15 +152,11 @@ tr.below-threshold { color: #484f58; }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Use window. to allow external access (for Playwright screenshot injection)
|
let DATA = null;
|
||||||
window.DATA = null;
|
let currentIdx = 0;
|
||||||
window.currentIdx = 0;
|
let playing = false;
|
||||||
window.playing = false;
|
let playTimer = null;
|
||||||
window.playTimer = null;
|
let rebalanceDays = [];
|
||||||
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);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
"""
|
|
||||||
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,37 +1418,6 @@ class SimpleRotationStrategy:
|
|||||||
plt.close()
|
plt.close()
|
||||||
print(f" + Report: {chart_path}")
|
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
|
# Entry point
|
||||||
@@ -1464,4 +1433,3 @@ if __name__ == "__main__":
|
|||||||
if result:
|
if result:
|
||||||
strategy.export_results()
|
strategy.export_results()
|
||||||
strategy.generate_report()
|
strategy.generate_report()
|
||||||
strategy.generate_html_report()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user