""" 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 import json class ReportGenerator: """策略报告生成器""" def __init__(self, results_dir='results'): self.results_dir = results_dir self.summary_df = None self.trades_df = None self.metrics = None # 从JSON加载的策略KPI self.nav_df = None # 从CSV加载的净值曲线 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['出场日期']) # 加载策略KPI JSON文件(如果存在) metrics_path = os.path.join(self.results_dir, 'report_metrics.json') if os.path.exists(metrics_path): with open(metrics_path, 'r', encoding='utf-8') as f: self.metrics = json.load(f) print(f"✅ 加载策略指标: {metrics_path}") else: print(f"⚠️ 未找到策略指标文件: {metrics_path}") # 加载净值曲线CSV文件(如果存在) nav_path = os.path.join(self.results_dir, 'report_nav.csv') if os.path.exists(nav_path): self.nav_df = pd.read_csv(nav_path) self.nav_df['日期'] = pd.to_datetime(self.nav_df['日期']) print(f"✅ 加载净值曲线: {nav_path} ({len(self.nav_df)} 条记录)") else: print(f"⚠️ 未找到净值曲线文件: {nav_path}") print(f"✅ 数据加载成功: {len(self.trades_df)} 条交易记录") def calculate_kpis(self, trades_filtered=None): """计算关键指标 - 优先使用轮动策略输出的指标""" # 如果有从JSON加载的策略KPI,直接使用 if self.metrics is not None and '策略' in self.metrics: strategy_metrics = self.metrics['策略'] print("✅ 使用轮动策略输出的KPI指标") # 计算调仓次数(需要从trades数据获取) df = trades_filtered if trades_filtered is not None else self.trades_df total_trades = len(df) # 最佳品种(从summary获取) if self.summary_df is not None: symbol_col = self.summary_df['累计收益'] best_symbol = self.summary_df.loc[symbol_col.idxmax(), '品种代码'] else: best_symbol = 'N/A' # 平均持仓天数 avg_holding_days = df['持仓天数'].mean() if len(df) > 0 else 0 # 盈亏次数(基于trades数据) # 转换持仓收益为数值(统一处理百分号格式) if '持仓收益' in df.columns: # 使用通用转换方法 returns_series = df['持仓收益'].apply( lambda x: float(str(x).rstrip('%')) if pd.notna(x) else 0.0 ) win_count = (returns_series > 0).sum() loss_count = (returns_series < 0).sum() else: win_count = 0 loss_count = 0 return { 'total_return': f"{strategy_metrics['累计收益'] * 100:.2f}", 'annual_return': f"{strategy_metrics['年化收益(自然日)'] * 100:.2f}", 'win_rate': f"{strategy_metrics['日胜率'] * 100:.2f}", 'max_drawdown': f"{strategy_metrics['最大回撤'] * 100:.2f}", 'sharpe_ratio': f"{strategy_metrics['夏普比率']:.2f}", 'best_symbol': best_symbol, 'avg_holding_days': f"{avg_holding_days:.1f}", 'win_count': int(win_count), 'loss_count': int(loss_count) } # 否则重新计算(备用方案) print("⚠️ 未找到策略KPI,重新计算...") df = trades_filtered if trades_filtered is not None else self.trades_df # 使用净值计算真实收益 # 按日期分组计算每日组合净值 daily_nav = df.groupby('出场日期').apply( lambda x: (x['出场净值'].astype(float) * x['仓位占比'].str.rstrip('%').astype(float) / 100).sum(), include_groups=False ).reset_index() daily_nav.columns = ['date', 'nav'] daily_nav = daily_nav.sort_values('date') # 总收益 = (最终净值 - 初始净值) / 初始净值 * 100 initial_nav = daily_nav['nav'].iloc[0] final_nav = daily_nav['nav'].iloc[-1] total_return = (final_nav - initial_nav) / initial_nav * 100 # 年化收益 days = (daily_nav['date'].iloc[-1] - daily_nav['date'].iloc[0]).days if days > 0: annual_return = total_return / (days / 365.0) else: annual_return = 0 # 胜率 - 使用净值变化 daily_nav['nav_change'] = daily_nav['nav'].pct_change() win_count = (daily_nav['nav_change'] > 0).sum() total_count = len(daily_nav) - 1 # 减去第一天 win_rate = (win_count / total_count * 100) if total_count > 0 else 0 loss_count = total_count - win_count # 夏普比率 if daily_nav['nav_change'].std() > 0: sharpe_ratio = daily_nav['nav_change'].mean() / daily_nav['nav_change'].std() * np.sqrt(252) else: sharpe_ratio = 0 # 最大回撤 running_max = daily_nav['nav'].cummax() drawdown = (daily_nav['nav'] - running_max) / running_max * 100 max_drawdown = drawdown.min() # 调仓次数 total_trades = len(df) # 最佳品种 - 从 summary 获取 if self.summary_df is not None: symbol_col = self.summary_df['累计收益'] if symbol_col.dtype == 'object': symbol_col_num = symbol_col.str.rstrip('%').astype(float) else: symbol_col_num = symbol_col best_symbol = self.summary_df.loc[symbol_col_num.idxmax(), '品种代码'] else: best_symbol = '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): """准备图表数据 - 优先使用轮动策略输出的净值曲线""" # 如果有从CSV加载的净值曲线,直接使用 if self.nav_df is not None: print("✅ 使用轮动策略输出的净值曲线") # 净值曲线数据 - 直接读取 nav_dates = self.nav_df['日期'].dt.strftime('%Y-%m-%d').tolist() nav_values = self.nav_df['策略净值'].round(4).tolist() benchmark_values = self.nav_df['基准净值'].round(4).tolist() # 月度收益数据 - 从净值计算 self.nav_df['年月'] = self.nav_df['日期'].dt.to_period('M') monthly_nav = self.nav_df.groupby('年月').agg({ '策略净值': 'last' }).reset_index() monthly_nav.columns = ['年月', 'nav'] monthly_nav = monthly_nav.sort_values('年月') monthly_nav['nav_change'] = monthly_nav['nav'].pct_change() * 100 monthly_nav['nav_change'] = monthly_nav['nav_change'].fillna(0) monthly_nav['年月_str'] = monthly_nav['年月'].astype(str) monthly_dates = monthly_nav['年月_str'].tolist() monthly_values = monthly_nav['nav_change'].round(2).tolist() # 盈亏分布 - 从trades数据计算 df = trades_filtered if trades_filtered is not None else self.trades_df df = df.copy() # 使用通用转换方法处理持仓收益 df['持仓收益_num'] = df['持仓收益'].apply( lambda x: float(str(x).rstrip('%')) if pd.notna(x) else 0.0 ) positive_returns = df[df['持仓收益_num'] > 0]['持仓收益_num'].tolist() negative_returns = df[df['持仓收益_num'] <= 0]['持仓收益_num'].tolist() # 品种收益排行 - 使用累计收益列 symbol_returns = self.summary_df.set_index('品种代码')['累计收益'] symbol_returns = symbol_returns.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(ret) return { 'nav_dates': nav_dates, 'nav_values': nav_values, 'benchmark_values': benchmark_values, 'monthly_dates': monthly_dates, 'monthly_values': monthly_values, 'positive_returns': positive_returns, 'negative_returns': negative_returns, 'symbol_names': symbol_names, 'symbol_returns': symbol_returns_list, } # 否则重新计算(备用方案) print("⚠️ 未找到净值曲线,重新计算...") df = trades_filtered if trades_filtered is not None else self.trades_df # 转换持仓收益为数值(统一处理百分号格式) df = df.copy() df['持仓收益_num'] = df['持仓收益'].apply( lambda x: float(str(x).rstrip('%')) if pd.notna(x) else 0.0 ) df_sorted = df.sort_values('出场日期') # 净值曲线数据 - 使用出场净值(考虑仓位加权) # 按日期分组,计算每日的加权平均净值 daily_nav = df_sorted.groupby('出场日期').apply( lambda x: (x['出场净值'].astype(float) * x['仓位占比'].str.rstrip('%').astype(float) / 100).sum(), include_groups=False ).reset_index() daily_nav.columns = ['date', 'nav'] daily_nav = daily_nav.sort_values('date') nav_values = daily_nav['nav'].round(4).tolist() nav_dates = daily_nav['date'].dt.strftime('%Y-%m-%d').tolist() benchmark_values = [] # 备用方案无基准数据 # 月度收益数据 - 使用净值变化计算 df_copy = df_sorted.copy() df_copy['年月'] = df_copy['出场日期'].dt.to_period('M') monthly_nav = df_copy.groupby('年月').apply( lambda x: (x['出场净值'].astype(float) * x['仓位占比'].str.rstrip('%').astype(float) / 100).sum(), include_groups=False ).reset_index() monthly_nav.columns = ['年月', 'nav'] monthly_nav = monthly_nav.sort_values('年月') monthly_nav['nav_change'] = monthly_nav['nav'].pct_change() * 100 monthly_nav['nav_change'] = monthly_nav['nav_change'].fillna(0) monthly_nav['年月_str'] = monthly_nav['年月'].astype(str) monthly_dates = monthly_nav['年月_str'].tolist() monthly_values = monthly_nav['nav_change'].round(2).tolist() # 品种收益排行 - 使用累计收益列 symbol_returns = self.summary_df.set_index('品种代码')['累计收益'] if symbol_returns.dtype == 'object': symbol_returns = symbol_returns.str.rstrip('%').astype(float) symbol_returns = symbol_returns.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.sort_values('出场日期', ascending=False).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') # 分页参数 page_size = 50 # 每页显示50条记录 total_trades = len(trades_list) total_pages = (total_trades // page_size) + (1 if total_trades % page_size > 0 else 0) # 读取模板 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, page_size=page_size, total_trades=total_trades, total_pages=total_pages, **kpis, **chart_data ) # 创建输出目录 os.makedirs(output_dir, exist_ok=True) # 保存报告(固定文件名) output_file = os.path.join(output_dir, 'strategy_report.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()