问题: - HTML报告错误地将所有品种持仓收益简单累加 - 没有考虑仓位占比权重 - 导致净值曲线和KPI指标与策略实际结果不一致 修复: - 使用出场净值和仓位占比计算每日组合净值 - 净值 = sum(出场净值 * 仓位占比) - 总收益 = (最终净值 - 初始净值) / 初始净值 * 100 - 月度收益使用净值变化率计算 - 最大回撤基于真实净值曲线计算 - 胜率基于每日净值涨跌计算 - 修复pandas FutureWarning警告 现在HTML报告的净值曲线、收益指标与轮动策略完全一致
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""
|
|
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
|
|
|
|
# 使用净值计算真实收益
|
|
# 按日期分组计算每日组合净值
|
|
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):
|
|
"""准备图表数据"""
|
|
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('出场日期')
|
|
|
|
# 净值曲线数据 - 使用出场净值(考虑仓位加权)
|
|
# 按日期分组,计算每日的加权平均净值
|
|
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()
|
|
|
|
# 月度收益数据 - 使用净值变化计算
|
|
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.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()
|