← 返回 下载 report.py
"""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&gt;0.03 且 ICIR&gt;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 日已实现波动率 &gt; 目标值时降仓)+ 组合回撤熔断降仓。</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