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()