From 4d784f961abfc213aa2626617233b275c5e0fe9d Mon Sep 17 00:00:00 2001 From: aszerW Date: Fri, 8 May 2026 22:06:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(visualization):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=8A=A5=E5=91=8A=E7=94=9F=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 visualization/report_generator 模块 - 支持生成精美的 HTML 策略报告 - 包含8个 KPI 指标卡片(收益、胜率、夏普比等) - 集成 ECharts 交互式图表(净值曲线、月度收益、盈亏分布) - 支持按日期和品种筛选调仓记录 - 使用 Jinja2 模板引擎 + Bootstrap 5 样式 - 支持打印为 PDF - 提供 CLI 和 Python API 两种使用方式 --- visualization/report_generator/README.md | 144 ++++++++++ visualization/report_generator/__init__.py | 7 + .../report_generator/generate_report.py | 259 ++++++++++++++++++ 3 files changed, 410 insertions(+) create mode 100644 visualization/report_generator/README.md create mode 100644 visualization/report_generator/__init__.py create mode 100644 visualization/report_generator/generate_report.py diff --git a/visualization/report_generator/README.md b/visualization/report_generator/README.md new file mode 100644 index 0000000..37464c1 --- /dev/null +++ b/visualization/report_generator/README.md @@ -0,0 +1,144 @@ +# ETF轮动策略报告生成器 + +生成精美的 HTML 策略报告,展示回测结果和关键指标。 + +## 功能特性 + +- ✅ **策略 KPI** - 累计收益、年化收益、胜率、夏普比率等 +- ✅ **净值曲线** - 交互式折线图,支持缩放和悬停 +- ✅ **月度收益** - 柱状图展示每月收益分布 +- ✅ **盈亏分布** - 饼图展示盈利/亏损比例 +- ✅ **品种排行** - 横向条形图展示各品种表现 +- ✅ **调仓记录** - 可按日期和品种筛选的交易明细表格 +- ✅ **现代化 UI** - 渐变色头部、卡片布局、响应式设计 +- ✅ **打印友好** - 支持直接打印为 PDF + +## 使用方法 + +### 基础用法 + +```bash +# 生成完整报告 +python visualization/report_generator/generate_report.py + +# 指定时间区间 +python visualization/report_generator/generate_report.py --start 2024-01-01 --end 2024-12-31 + +# 指定输出目录 +python visualization/report_generator/generate_report.py --output my_reports +``` + +### Python API 调用 + +```python +from visualization.report_generator.generate_report import ReportGenerator + +# 创建生成器 +generator = ReportGenerator(results_dir='results') + +# 生成报告 +output_file = generator.generate( + start_date='2024-01-01', + end_date='2024-12-31', + output_dir='reports' +) + +print(f"报告已生成: {output_file}") +``` + +### 定时生成(可选) + +```bash +# 添加到 crontab,每天生成一次 +0 9 * * * cd /path/to/etf && python visualization/report_generator/generate_report.py +``` + +## 依赖 + +```bash +pip install pandas numpy jinja2 +``` + +## 文件结构 + +``` +visualization/report_generator/ +├── template.html # HTML 模板 +├── generate_report.py # 报告生成脚本 +└── README.md # 说明文档 +``` + +## 输出示例 + +生成的报告包含: + +1. **头部区域** - 报告标题和数据区间 +2. **KPI 卡片** - 8 个关键指标(收益、胜率、夏普比等) +3. **净值曲线** - 带渐变填充的折线图 +4. **月度收益** - 红绿柱状图 +5. **盈亏分布** - 环形饼图 +6. **品种排行** - 横向条形图 +7. **调仓表格** - 支持筛选和打印 + +## 自定义 + +### 修改配色方案 + +编辑 `template.html` 中的 CSS 变量: + +```css +:root { + --primary-color: #1890ff; + --success-color: #52c41a; + --danger-color: #ff4d4f; +} +``` + +### 添加新指标 + +在 `generate_report.py` 的 `calculate_kpis()` 方法中添加: + +```python +def calculate_kpis(self, trades_filtered): + # ... 现有代码 ... + + # 添加新指标 + new_metric = ... + + return { + 'total_return': ..., + 'new_metric': new_metric, # 新增 + ... + } +``` + +然后在模板中使用: + +```html +
{{ new_metric }}
+``` + +## 技术栈 + +- **模板引擎**: Jinja2 +- **图表库**: ECharts 5.4 +- **样式框架**: Bootstrap 5.3 +- **图标**: Bootstrap Icons + +## 注意事项 + +1. 确保 `results/report_summary.csv` 和 `results/report_trades.csv` 存在 +2. 数据格式需符合预期(参考现有 CSV 文件) +3. 生成的 HTML 文件可离线查看(ECharts 使用 CDN) +4. 打印时筛选栏会自动隐藏 + +## 示例输出 + +``` +🚀 开始生成策略报告... +✅ 数据加载成功: 1233 条交易记录 +📊 筛选后数据: 1233 条记录 +✅ 报告已生成: reports/strategy_report_20260508_210000.html +📁 文件大小: 125.3 KB +🌐 在浏览器中打开: file:///Users/aszer/Documents/vscode/etf/reports/strategy_report_20260508_210000.html +``` diff --git a/visualization/report_generator/__init__.py b/visualization/report_generator/__init__.py new file mode 100644 index 0000000..c913510 --- /dev/null +++ b/visualization/report_generator/__init__.py @@ -0,0 +1,7 @@ +""" +ETF轮动策略报告生成器 +""" + +from .generate_report import ReportGenerator + +__all__ = ['ReportGenerator'] diff --git a/visualization/report_generator/generate_report.py b/visualization/report_generator/generate_report.py new file mode 100644 index 0000000..2408563 --- /dev/null +++ b/visualization/report_generator/generate_report.py @@ -0,0 +1,259 @@ +""" +ETF轮动策略报告生成器 +======================= +从回测数据生成精美的 HTML 策略报告 + +使用方法: + python generate_report.py + python generate_report.py --start 2024-01-01 --end 2024-12-31 +""" + +import pandas as pd +import numpy as np +from jinja2 import Template +from datetime import datetime +import argparse +import os +import sys + + +class ReportGenerator: + """策略报告生成器""" + + def __init__(self, results_dir='results'): + self.results_dir = results_dir + self.summary_df = None + self.trades_df = None + + def load_data(self): + """加载回测数据""" + # 加载汇总数据 + summary_path = os.path.join(self.results_dir, 'report_summary.csv') + if not os.path.exists(summary_path): + raise FileNotFoundError(f"找不到汇总数据文件: {summary_path}") + + self.summary_df = pd.read_csv(summary_path) + + # 转换百分比 + for col in ['胜率', '平均收益', '累计收益', '最大单次收益', '最大单次亏损']: + if col in self.summary_df.columns: + self.summary_df[col] = self.summary_df[col].str.rstrip('%').astype(float) + + # 加载交易记录 + trades_path = os.path.join(self.results_dir, 'report_trades.csv') + if not os.path.exists(trades_path): + raise FileNotFoundError(f"找不到交易记录文件: {trades_path}") + + self.trades_df = pd.read_csv(trades_path) + self.trades_df['进场日期'] = pd.to_datetime(self.trades_df['进场日期']) + self.trades_df['出场日期'] = pd.to_datetime(self.trades_df['出场日期']) + + print(f"✅ 数据加载成功: {len(self.trades_df)} 条交易记录") + + def calculate_kpis(self, trades_filtered=None): + """计算关键指标""" + df = trades_filtered if trades_filtered is not None else self.trades_df + + # 转换持仓收益为数值(去除百分号) + if df['持仓收益'].dtype == 'object': + df = df.copy() + df['持仓收益_num'] = df['持仓收益'].str.rstrip('%').astype(float) + else: + df = df.copy() + df['持仓收益_num'] = df['持仓收益'] + + # 总收益 + total_return = df['持仓收益_num'].sum() + + # 年化收益 + days = (df['出场日期'].max() - df['出场日期'].min()).days + if days > 0: + annual_return = total_return / (days / 365.0) + else: + annual_return = 0 + + # 胜率 + win_count = (df['持仓收益_num'] > 0).sum() + total_count = len(df) + win_rate = (win_count / total_count * 100) if total_count > 0 else 0 + loss_count = total_count - win_count + + # 夏普比率 + daily_returns = df.groupby('出场日期')['持仓收益_num'].sum() + if daily_returns.std() > 0: + sharpe_ratio = daily_returns.mean() / daily_returns.std() * np.sqrt(252) + else: + sharpe_ratio = 0 + + # 最大回撤(简化计算) + cum_returns = df['持仓收益_num'].cumsum() + running_max = cum_returns.cummax() + drawdown = (cum_returns - running_max) + max_drawdown = drawdown.min() if len(drawdown) > 0 else 0 + + # 调仓次数 + total_trades = len(df) + + # 最佳品种 + symbol_returns = df.groupby('品种代码')['持仓收益_num'].sum() + best_symbol = symbol_returns.idxmax() if len(symbol_returns) > 0 else 'N/A' + + # 平均持仓天数 + avg_holding_days = df['持仓天数'].mean() if len(df) > 0 else 0 + + return { + 'total_return': f"{total_return:.2f}", + 'annual_return': f"{annual_return:.2f}", + 'win_rate': f"{win_rate:.2f}", + 'max_drawdown': f"{max_drawdown:.2f}", + 'sharpe_ratio': f"{sharpe_ratio:.2f}", + 'total_trades': str(total_trades), + 'best_symbol': best_symbol, + 'avg_holding_days': f"{avg_holding_days:.1f}", + 'win_count': int(win_count), + 'loss_count': int(loss_count) + } + + def prepare_chart_data(self, trades_filtered=None): + """准备图表数据""" + df = trades_filtered if trades_filtered is not None else self.trades_df + + # 转换持仓收益为数值 + if df['持仓收益'].dtype == 'object': + df = df.copy() + df['持仓收益_num'] = df['持仓收益'].str.rstrip('%').astype(float) + else: + df = df.copy() + df['持仓收益_num'] = df['持仓收益'] + + df_sorted = df.sort_values('出场日期') + + # 净值曲线数据 + df_sorted['累计收益'] = df_sorted['持仓收益_num'].cumsum() + nav_values = df_sorted['累计收益'].tolist() + nav_dates = df_sorted['出场日期'].dt.strftime('%Y-%m-%d').tolist() + + # 月度收益数据 + df_copy = df_sorted.copy() + df_copy['年月'] = df_copy['出场日期'].dt.to_period('M') + monthly = df_copy.groupby('年月')['持仓收益_num'].sum().reset_index() + monthly['年月_str'] = monthly['年月'].astype(str) + monthly_dates = monthly['年月_str'].tolist() + monthly_values = monthly['持仓收益_num'].round(2).tolist() + + # 品种收益排行 + symbol_returns = df.groupby('品种代码')['持仓收益_num'].sum().sort_values() + symbol_names = [] + symbol_returns_list = [] + + for code, ret in symbol_returns.items(): + name = self.summary_df[self.summary_df['品种代码'] == code] + if len(name) > 0: + symbol_names.append(name.iloc[0]['品种名称']) + else: + symbol_names.append(code) + symbol_returns_list.append(round(ret, 2)) + + # 唯一品种列表 + symbols = df['品种代码'].unique().tolist() + + return { + 'nav_dates': nav_dates, + 'nav_values': [round(v, 2) for v in nav_values], + 'monthly_dates': monthly_dates, + 'monthly_values': monthly_values, + 'symbol_names': symbol_names, + 'symbol_returns': symbol_returns_list, + 'symbols': symbols + } + + def generate(self, start_date=None, end_date=None, output_dir='reports'): + """生成报告""" + print("🚀 开始生成策略报告...") + + # 加载数据 + self.load_data() + + # 筛选数据 + if start_date: + start_date = pd.to_datetime(start_date) + if end_date: + end_date = pd.to_datetime(end_date) + + trades_filtered = self.trades_df.copy() + if start_date: + trades_filtered = trades_filtered[trades_filtered['出场日期'] >= start_date] + if end_date: + trades_filtered = trades_filtered[trades_filtered['出场日期'] <= end_date] + + print(f"📊 筛选后数据: {len(trades_filtered)} 条记录") + + # 计算指标 + kpis = self.calculate_kpis(trades_filtered) + chart_data = self.prepare_chart_data(trades_filtered) + + # 准备交易记录 + trades_display = trades_filtered.copy() + trades_display['进场日期'] = trades_display['进场日期'].dt.strftime('%Y-%m-%d') + trades_display['出场日期'] = trades_display['出场日期'].dt.strftime('%Y-%m-%d') + trades_list = trades_display.to_dict('records') + + # 读取模板 + template_path = os.path.join(os.path.dirname(__file__), 'template.html') + with open(template_path, 'r', encoding='utf-8') as f: + template = Template(f.read()) + + # 渲染模板 + html = template.render( + report_date=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + start_date=start_date.strftime('%Y-%m-%d') if start_date else trades_filtered['出场日期'].min().strftime('%Y-%m-%d'), + end_date=end_date.strftime('%Y-%m-%d') if end_date else trades_filtered['出场日期'].max().strftime('%Y-%m-%d'), + trades=trades_list, + **kpis, + **chart_data + ) + + # 创建输出目录 + os.makedirs(output_dir, exist_ok=True) + + # 保存报告 + output_file = os.path.join( + output_dir, + f'strategy_report_{datetime.now().strftime("%Y%m%d_%H%M%S")}.html' + ) + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(html) + + print(f"✅ 报告已生成: {output_file}") + print(f"📁 文件大小: {os.path.getsize(output_file) / 1024:.1f} KB") + print(f"🌐 在浏览器中打开: file://{os.path.abspath(output_file)}") + + return output_file + + +def main(): + """主函数""" + parser = argparse.ArgumentParser(description='生成ETF轮动策略报告') + parser.add_argument('--start', type=str, help='开始日期 (YYYY-MM-DD)') + parser.add_argument('--end', type=str, help='结束日期 (YYYY-MM-DD)') + parser.add_argument('--output', type=str, default='reports', help='输出目录') + + args = parser.parse_args() + + try: + generator = ReportGenerator() + generator.generate( + start_date=args.start, + end_date=args.end, + output_dir=args.output + ) + except Exception as e: + print(f"❌ 生成失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main()