Compare commits

...

2 Commits

Author SHA1 Message Date
f370caeff9 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
2026-06-07 22:43:12 +08:00
06df8767b9 docs: add select_num=1 strategy deep analysis report
- Asset contribution attribution (CL=F 59.1%, N225 -11.8%)
- IC analysis across lookback periods (only CL=F and ChiNext have robust positive IC)
- Hurst exponent analysis and asset classification
- Multi-factor direction recommendations
2026-06-07 12:26:13 +08:00
5 changed files with 464 additions and 5 deletions

6
.gitignore vendored
View File

@@ -199,3 +199,9 @@ 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

View File

@@ -0,0 +1,132 @@
# select_num=1 策略深度分析报告
> 分析日期2026-06-07
> 策略版本:基于 config_simple.yamlselect_num 覆盖为 1
> 回测区间2020-01-10 至今(约 1549 个交易日)
## 1. 策略概述
基于 `slope_r2` 单因子的全球资产轮动策略,每次只持有排名第 1 的资产。
- **因子**`slope_r2_score` — 归一化价格的线性回归斜率 ×回看 25 天
- **资产池**11 个资产原油、纳指100、日经225、黄金、DAX、有色金属、创业板指、恒生指数、恒生科技、红利低波、短债
- **阈值**:动态阈值,以短债指数为参考
## 2. 回撤分析
### 2.1 整体回撤特征
- 显著回撤(>5%)共 34 次
- 水下时间占比 58.9%
- 最大回撤集中在原油高波动期间
### 2.2 主要回撤来源
策略在原油持仓期间的回撤最为剧烈,原因是原油波动率高(年化 ~45%)且事件驱动性强。频繁调仓在市场压力期进一步放大回撤。
## 3. 收益归因
### 3.1 资产贡献度
| 资产 | 收益贡献 | 持仓天数占比 | 贡献效率 |
|------|---------|------------|---------|
| 原油 (CL=F) | 62.8% | 18.9% | 极高 |
| 纳指100 (NDX) | 正贡献 | 中等 | 中等 |
| 创业板指 | 正贡献 | 中等 | 中等 |
| 日经225 (N225) | -15.4% | — | 最大拖累 |
### 3.2 原油收益的可持续性评估
- **正面**:择时 alpha 真实存在,策略收益约为随机择时的 2 倍
- **风险**:约 50% 的原油收益来自事件驱动如俄乌战争5 个交易日贡献了原油总收益的 50%
- **收益分布**:正偏度(+0.48+ 厚尾特征
- **IC信息系数**:仅 0.063,信号质量并不高
- **结论**:回测年化 ~43% 中,现实可预期年化约 20-30%
## 4. 资产对比分析:为什么原油/纳指赚钱,日经赔钱
### 4.1 原油 — 长持获胜
- 持仓 >10 天的交易胜率 100%
- 正偏度 + 高波动 = 偶尔的大涨贡献绝大部分收益
- 趋势性较强,适合动量策略
### 4.2 纳指100 — 高盈亏比
- 盈亏比 2.14:1靠"赚多赔少"取胜
- 资产本身具有正期望收益(长期上涨趋势)
- 即使因子预测能力为负,持有本身仍能获利
### 4.3 日经225 — 全面失败
- 在所有持仓周期(短/中/长)均亏损
- 在所有动量水平均表现不佳
- 均值回归特征明显,与动量因子方向相反
## 5. 回看周期敏感性测试
测试了 10 个回看周期5d ~ 120d× 3 个前瞻周期:
### 5.1 slope_r2 因子 IC 汇总
| 资产 | 最优回看 | 最优 IC | 典型 IC 范围 |
|------|---------|--------|-------------|
| 原油 (CL=F) | 20d | 正 | 正(稳健) |
| 创业板指 (399006.SZ) | 10d | 正 | 正(稳健) |
| 纳指100 (NDX) | — | 负 | 全部为负 |
| 日经225 (N225) | 90d | +0.059 | 大部分为负 |
| DAX (GDAXI) | — | 负 | 全部或大部分为负 |
| 恒生科技 (HSTECH.HK) | — | 负 | 全部为负 |
### 5.2 关键发现
- **只有原油和创业板指**对 slope_r2 因子有稳健的正 IC
- 纳指、日经、DAX、恒生科技在几乎所有回看周期下 IC 为负
- 日经即使在最优 90d 回看下IC 也仅 +0.059,实用价值有限
- 纯收益率动量(不拟合直线)同样对日经/NDX/DAX 失效
### 5.3 结论
日经不是"回看周期不对"的问题,而是**资产本身的均值回归特性**与动量因子方向相反。调整回看周期无法根本解决。
## 6. 单因子局限性分析
### 6.1 核心问题
slope_r2 作为唯一切量因子11 个资产中仅对 2 个有效(正 IC。策略本质上在用一把只能开两把锁的钥匙去开 11 把锁。
### 6.2 资产行为分类
根据 Hurst 指数和 IC 分析,资产池可分为两类:
| 类型 | 特征 | 代表资产 |
|------|------|---------|
| 趋势型 | H > 0.5,动量 IC 正 | 原油、创业板指 |
| 均值回归型 | H < 0.5动量 IC | 日经DAX恒生科技 |
| 混合型 | 特征不显著 | 纳指黄金恒生指数 |
### 6.3 可行的多因子方向
| 因子类型 | 适用场景 | 对应资产 |
|---------|---------|---------|
| 均值回归因子短期反转 | IC 为负的资产 | 日经DAX恒生科技 |
| 波动率因子低波动异象 | 防御期选股 | 全资产 |
| 趋势质量因子ADX/均线排列 | 区分真假动量 | 原油创业板 |
| 风险动量收益/波动率 | 替代纯动量 | 纳指高收益高波动 |
### 6.4 建议方案
**按资产特性分配因子**而非统一因子
- 趋势型资产原油创业板)→ 动量因子
- 均值回归型资产日经DAX)→ 反转因子
- 这比简单叠加多因子效果更精准
## 7. 总结与建议
1. **select_num=1 的收益高度集中于原油**62.8%且约半数为事件驱动可持续性存疑
2. **日经225 是最大拖累**-15.4% 贡献根本原因是均值回归特性与动量因子矛盾
3. **单因子 slope_r2 覆盖面不足** 2/11 资产有效
4. **下一步优化方向**
- 短期为均值回归类资产加入反转因子或直接从池中移除不适合的资产
- 中期实现资产级因子自适应选择根据 Hurst 指数自动分配动量/反转
- 长期引入波动率因子作为风控层在高波动期切换至防御资产

View File

@@ -152,11 +152,15 @@ tr.below-threshold { color: #484f58; }
</div> </div>
<script> <script>
let DATA = null; // Use window. to allow external access (for Playwright screenshot injection)
let currentIdx = 0; window.DATA = null;
let playing = false; window.currentIdx = 0;
let playTimer = null; window.playing = false;
let rebalanceDays = []; 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); const $ = id => document.getElementById(id);

285
rotation/html_report.py Normal file
View 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)

View File

@@ -1418,6 +1418,37 @@ 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
@@ -1433,3 +1464,4 @@ if __name__ == "__main__":
if result: if result:
strategy.export_results() strategy.export_results()
strategy.generate_report() strategy.generate_report()
strategy.generate_html_report()