"""HTML 回测报告生成(浅底深字研报风,ECharts 图表)。"""
from __future__ import annotations
import json
from typing import Dict
def _fmt_pct(x) -> str:
try:
return f"{float(x) * 100:.2f}%"
except (TypeError, ValueError):
return "-"
def _metric_cards(m: Dict) -> str:
items = [
("累计收益", _fmt_pct(m.get("total_return")), "total"),
("年化收益", _fmt_pct(m.get("cagr")), "total"),
("年化波动", _fmt_pct(m.get("ann_vol")), ""),
("夏普比率", f"{m.get('sharpe', 0):.2f}" if m.get("sharpe") is not None else "-", ""),
("最大回撤", _fmt_pct(m.get("max_drawdown")), "neg"),
("卡玛比率", f"{m.get('calmar', 0):.2f}" if m.get("calmar") is not None else "-", ""),
("日胜率", _fmt_pct(m.get("daily_win_rate")), ""),
("相对基准年化超额", _fmt_pct(m.get("excess_cagr_vs_bench")), ""),
]
cards = "".join(
f'<div class="card"><div class="card-label">{k}</div>'
f'<div class="card-value {"neg" if t == "neg" else ("pos" if t == "total" else "")}">{v}</div></div>'
for k, v, t in items
)
return f'<div class="cards">{cards}</div>'
def build_report(payload: Dict) -> str:
s = payload["strategy"]
m = payload["metrics"]
pref, sel, port, rc = s["preferences"], s["selection"], s["portfolio"], s["risk_control"]
curve = payload["curve"]
bench = payload["benchmark"]
monthly = payload["monthly"]
holdings = payload["latest_holdings"]
ic = payload["ic"]
notes = payload["data_notes"]
factor_rows = "".join(
f"<tr><td>{k}</td><td>{v:.0%}</td><td>{sel['factor_lookbacks'].get(k, '-')}</td></tr>"
for k, v in sorted(sel["factors"].items(), key=lambda x: -x[1])
)
holding_rows = "".join(
f"<tr><td>{h['code']}</td><td>{h['name']}</td><td>{h['weight']:.1%}</td></tr>"
for h in holdings[:30]
) or "<tr><td colspan=3>当前空仓(风控降仓中)</td></tr>"
monthly_rows = "".join(
f'<span class="mchip {"up" if v >= 0 else "down"}">{mm} {v * 100:+.1f}%</span>'
for mm, v in [(x["month"], x["ret"]) for x in monthly]
)
ic_vals = [round(v, 4) for v in ic.values()]
yearly = payload.get("yearly", {})
yearly_rows = "".join(f"<tr><td>{y}</td><td class='{'pos' if v>=0 else 'neg'}'>{v*100:+.2f}%</td></tr>"
for y, v in yearly.items())
data_json = json.dumps({
"curve": curve,
"bench": bench,
"icDates": list(ic.keys()),
"icVals": ic_vals,
}, ensure_ascii=False)
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>{s['name']} 回测报告</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<style>
:root {{ --ink:#1c2333; --sub:#5a6478; --line:#e3e7ef; --bg:#f7f8fb; --card:#ffffff;
--red:#c0392b; --green:#1e8e5a; --accent:#2b5fd9; }}
* {{ box-sizing:border-box; margin:0; padding:0; }}
body {{ font-family:"Microsoft YaHei","PingFang SC",sans-serif; color:var(--ink);
background:var(--bg); font-size:14px; line-height:1.6; }}
.wrap {{ max-width:1080px; margin:0 auto; padding:28px 20px 60px; }}
h1 {{ font-size:22px; margin-bottom:4px; }}
h2 {{ font-size:16px; margin:30px 0 12px; padding-left:10px; border-left:4px solid var(--accent); }}
.meta {{ color:var(--sub); font-size:12px; margin-bottom:18px; }}
.cards {{ display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin:16px 0; }}
.card {{ background:var(--card); border:1px solid var(--line); border-radius:8px; padding:12px 14px; }}
.card-label {{ color:var(--sub); font-size:12px; }}
.card-value {{ font-size:20px; font-weight:700; margin-top:4px; font-variant-numeric:tabular-nums; }}
.card-value.pos {{ color:var(--red); }}
.card-value.neg {{ color:var(--green); }}
.chart {{ background:var(--card); border:1px solid var(--line); border-radius:8px; padding:10px; height:360px; }}
.chart.small {{ height:260px; }}
table {{ width:100%; border-collapse:collapse; background:var(--card); border:1px solid var(--line);
border-radius:8px; overflow:hidden; }}
th,td {{ padding:8px 12px; border-bottom:1px solid var(--line); text-align:left; font-size:13px; }}
th {{ background:#eef1f7; color:var(--sub); font-weight:600; }}
.grid2 {{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }}
.prefbox {{ background:var(--card); border:1px solid var(--line); border-radius:8px; padding:14px 18px; }}
.prefbox li {{ margin:4px 0 4px 18px; color:var(--ink); }}
.mchip {{ display:inline-block; margin:3px 4px; padding:2px 8px; border-radius:4px; font-size:12px; }}
.mchip.up {{ background:#fdecea; color:var(--red); }}
.mchip.down {{ background:#e6f4ee; color:var(--green); }}
.pos {{ color:var(--red); }} .neg {{ color:var(--green); }}
.note {{ color:var(--sub); font-size:12px; margin-top:10px; }}
.disclaim {{ margin-top:36px; padding:14px 16px; background:#fff8e6; border:1px solid #f0dfa8;
border-radius:8px; color:#6b5b1e; font-size:12px; }}
@media (max-width:760px) {{ .cards {{ grid-template-columns:repeat(2,1fr); }} .grid2 {{ grid-template-columns:1fr; }} }}
</style>
</head>
<body>
<div class="wrap">
<h1>{s['name']} · 回测报告</h1>
<div class="meta">生成时间 {s['generated_at']} | 股票池:{notes['universe']} | 基准:{notes['benchmark']} | 数据源:{notes['kline_source']}</div>
<h2>策略画像(由偏好参数推导)</h2>
<div class="prefbox">
<li>风险偏好:<b>{pref['risk']}</b> —— {pref['risk_desc']}。目标波动率 {rc['target_vol']:.0%};
回撤超过 {rc['max_drawdown_derisk']:.0%} 自动降仓 {rc['de_risk_ratio']:.0%};
单票上限 {port['max_weight']:.0%};持仓 {sel['max_positions']} 只;现金缓冲 {port['cash_buffer']:.0%}。</li>
<li>持有周期:<b>{pref['horizon']}</b> —— {pref['horizon_desc']}。
每 {port['rebalance_days']} 个交易日调仓;动量回看 {sel['factor_lookbacks']['momentum']} 日、
趋势回看 {sel['factor_lookbacks']['trend']} 日。</li>
<li>风控过滤:{'价格低于 ' + str(rc['trend_filter_ma']) + ' 日均线不入选(熊市保护)' if rc['trend_filter'] else '未启用趋势过滤'}。</li>
<li>成本假设:佣金 {s['execution']['commission']:.2%}/单边 + 卖出印花税 {s['execution']['stamp_tax_sell']:.2%} + 滑点 {s['execution']['slippage']:.2%}/单边。</li>
</div>
<h2>核心绩效指标</h2>
{_metric_cards(m)}
<div class="note">因子有效性:IC 均值 {m.get('ic_mean', 0):.3f}({m.get('n_ic_obs', 0)} 次调仓截面),ICIR {m.get('ic_ir', 0):.2f},IC 为正比例 {_fmt_pct(m.get('ic_positive_ratio'))};单次调仓平均换手 {_fmt_pct(m.get('avg_turnover_per_rebalance'))}。经验阈值:IC>0.03 且 ICIR>0.5 视为有效。</div>
<h2>净值曲线(策略 vs 沪深300)</h2>
<div id="equity" class="chart"></div>
<div class="grid2">
<div>
<h2>月度收益</h2>
<div style="margin-top:6px">{monthly_rows or '-'}</div>
</div>
<div>
<h2>分年度收益</h2>
<table><tr><th>年份</th><th>收益率</th></tr>{yearly_rows}</table>
</div>
</div>
<h2>因子权重与回看窗口</h2>
<table><tr><th>因子</th><th>权重</th><th>回看窗口(交易日)</th></tr>{factor_rows}</table>
<div class="grid2">
<div>
<h2>因子 IC 时序</h2>
<div id="ic" class="chart small"></div>
</div>
<div>
<h2>最新持仓</h2>
<table><tr><th>代码</th><th>名称</th><th>权重</th></tr>{holding_rows}</table>
</div>
</div>
<h2>方法论与数据说明</h2>
<div class="note">
<ul style="margin-left:18px">
<li>信号时序:T 日收盘计算因子并排名,T+1 起按新权重计价,无前向偏差;持仓权重按每日收益自然漂移。</li>
<li>因子均为价量因子(动量/低波/趋势/反转/流动性等),截面去极值(MAD 3 倍)后 Z-score 加权合成;基本面因子(PE/PB/ROE)需历史基本面面板数据,属于扩展点。</li>
<li>风控覆盖层:波动率目标(60 日已实现波动率 > 目标值时降仓)+ 组合回撤熔断降仓。</li>
<li>{notes['universe']}</li>
<li>回测收益通常高估实盘:滑点模型简化、未含冲击成本,且未考虑涨跌停/停牌无法成交的极端情况。历史回测结果不代表未来表现。</li>
</ul>
</div>
<div class="disclaim"><b>免责声明</b>:本报告由量化工具自动生成,基于公开数据和历史回测,仅供学习与研究参考,不构成任何投资建议。市场有风险,投资需谨慎。过往表现不预示未来收益。</div>
</div>
<script>
var D = {data_json};
var eqChart = echarts.init(document.getElementById('equity'));
var dates = D.curve.map(function(x){{return x.date;}});
var series = [{{name:'策略净值', type:'line', data:D.curve.map(function(x){{return x.equity;}}),
showSymbol:false, lineStyle:{{width:2, color:'#c0392b'}}, itemStyle:{{color:'#c0392b'}}}}];
if (D.bench && D.bench.length > 0) {{
series.push({{name:'沪深300', type:'line', data:D.bench.map(function(x){{return x.equity;}}),
showSymbol:false, lineStyle:{{width:1.5, color:'#8a93a6'}}, itemStyle:{{color:'#8a93a6'}}}});
}}
eqChart.setOption({{
backgroundColor:'#ffffff',
tooltip:{{trigger:'axis'}},
legend:{{data:series.map(function(s){{return s.name;}}), top:4}},
grid:{{left:56,right:20,top:34,bottom:28}},
xAxis:{{type:'category', data:dates}},
yAxis:{{type:'value', scale:true, axisLabel:{{formatter:function(v){{return v.toFixed(2);}}}}}},
series:series
}});
var icChart = echarts.init(document.getElementById('ic'));
icChart.setOption({{
backgroundColor:'#ffffff',
tooltip:{{trigger:'axis'}},
grid:{{left:44,right:14,top:16,bottom:44}},
xAxis:{{type:'category', data:D.icDates, axisLabel:{{rotate:45, fontSize:10}}}},
yAxis:{{type:'value'}},
series:[{{type:'bar', data:D.icVals.map(function(v){{return {{value:v,
itemStyle:{{color: v>=0 ? '#c0392b' : '#1e8e5a'}}}};}}), barMaxWidth:12}}]
}});
window.addEventListener('resize', function(){{ eqChart.resize(); icChart.resize(); }});
</script>
</body>
</html>"""
return html