refactor(archive): move unused modules to archive/
Archive legacy framework and utility modules that are no longer referenced by the active core (datasource/ and rotation/): - framework/ -> archive/framework/ - framework_v2/ -> archive/framework_v2/ - strategies/ -> archive/strategies/ - config/ -> archive/config/ - visualization/ -> archive/visualization/ - scripts/ -> archive/scripts/ - tests/ -> archive/tests/ - run_rotation.py, run_us_rotation.py -> archive/single_files/ - compare_*.py, test_api_dates.py -> archive/single_files/
This commit is contained in:
0
archive/visualization/__init__.py
Normal file
0
archive/visualization/__init__.py
Normal file
0
archive/visualization/charts/__init__.py
Normal file
0
archive/visualization/charts/__init__.py
Normal file
238
archive/visualization/charts/indicators.py
Normal file
238
archive/visualization/charts/indicators.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
技术指标绘制组件
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import talib as ta
|
||||
import random
|
||||
from lightweight_charts import Chart
|
||||
|
||||
|
||||
def get_fixed_color(num: int) -> str:
|
||||
"""根据数字生成固定颜色"""
|
||||
random.seed(num)
|
||||
r = random.randint(0, 255)
|
||||
g = random.randint(0, 255)
|
||||
b = random.randint(0, 255)
|
||||
color = "#{:02x}{:02x}{:02x}".format(r, g, b)
|
||||
random.seed(None)
|
||||
return color
|
||||
|
||||
|
||||
def add_ema(
|
||||
chart: Chart,
|
||||
df: pd.DataFrame,
|
||||
period: int = 20,
|
||||
color: str = None,
|
||||
price_label: bool = False,
|
||||
):
|
||||
"""添加EMA指标线"""
|
||||
name = f"EMA_{period}"
|
||||
df[name] = ta.EMA(df["close"], timeperiod=period)
|
||||
|
||||
line_color = color or get_fixed_color(period)
|
||||
line = chart.create_line(
|
||||
name, color=line_color, width=2,
|
||||
price_label=price_label, price_line=False
|
||||
)
|
||||
line.set(df[["time", name]])
|
||||
return line
|
||||
|
||||
|
||||
def add_cci(
|
||||
chart: Chart,
|
||||
df: pd.DataFrame,
|
||||
period: int = 14,
|
||||
height: float = 0.15,
|
||||
position: str = "bottom",
|
||||
):
|
||||
"""添加CCI副图"""
|
||||
cci = ta.CCI(df["high"], df["low"], df["close"], timeperiod=period)
|
||||
df[f"CCI_{period}"] = cci
|
||||
|
||||
# 创建副图
|
||||
cci_chart = chart.create_subchart(
|
||||
position=position, width=1, height=height, sync=True
|
||||
)
|
||||
cci_chart.layout(font_family="Times New Roman")
|
||||
cci_chart.legend(visible=True, font_size=14, color="#FFFFFF")
|
||||
cci_chart.time_scale(visible=False)
|
||||
|
||||
# CCI线
|
||||
cci_line = cci_chart.create_line(
|
||||
name=f"CCI_{period}", color="#FF0000", width=2
|
||||
)
|
||||
cci_line.set(df[["time", f"CCI_{period}"]])
|
||||
|
||||
# 水平参考线
|
||||
for level, label in [(100, "+100"), (-100, "-100")]:
|
||||
df[f"cci_{label}"] = level
|
||||
ref_line = cci_chart.create_line(
|
||||
name=label, color="#D4C21C", width=1,
|
||||
style="dashed", price_label=False, price_line=False
|
||||
)
|
||||
ref_line.set(df[["time", f"cci_{label}"]])
|
||||
|
||||
return cci_chart
|
||||
|
||||
|
||||
def add_macd(
|
||||
chart: Chart,
|
||||
df: pd.DataFrame,
|
||||
fastperiod: int = 12,
|
||||
slowperiod: int = 26,
|
||||
signalperiod: int = 9,
|
||||
height: float = 0.15,
|
||||
position: str = "bottom",
|
||||
):
|
||||
"""添加MACD副图"""
|
||||
macd, signal, hist = ta.MACD(
|
||||
df["close"],
|
||||
fastperiod=fastperiod,
|
||||
slowperiod=slowperiod,
|
||||
signalperiod=signalperiod,
|
||||
)
|
||||
|
||||
df["DIF"] = macd
|
||||
df["DEA"] = signal
|
||||
macd_name = f"MACD_{fastperiod}_{slowperiod}_{signalperiod}"
|
||||
df[macd_name] = hist * 2
|
||||
|
||||
# 创建副图
|
||||
macd_chart = chart.create_subchart(
|
||||
position=position, width=1, height=height, sync=True
|
||||
)
|
||||
macd_chart.layout(font_family="Times New Roman")
|
||||
macd_chart.legend(visible=True, font_size=14, color="#FFFFFF")
|
||||
macd_chart.time_scale(visible=False)
|
||||
|
||||
# 柱状图
|
||||
histogram = macd_chart.create_histogram(name=macd_name)
|
||||
hist_data = df[["time", macd_name]].copy()
|
||||
hist_data["prev_value"] = hist_data[macd_name].shift(1)
|
||||
|
||||
def get_color(row):
|
||||
current, prev = row[macd_name], row["prev_value"]
|
||||
is_hollow = (current >= 0 and current < prev) or (current < 0 and current > prev)
|
||||
if current >= 0:
|
||||
return "rgba(255, 0, 0, 0.5)" if is_hollow else "#ff0000"
|
||||
else:
|
||||
return "rgba(0, 255, 0, 0.5)" if is_hollow else "#00FF00"
|
||||
|
||||
hist_data["color"] = hist_data.apply(get_color, axis=1)
|
||||
hist_data = hist_data.drop("prev_value", axis=1)
|
||||
histogram.set(hist_data)
|
||||
|
||||
# DIF线
|
||||
dif_line = macd_chart.create_line(
|
||||
name="DIF", color="#2962FF", width=2, price_label=False, price_line=False
|
||||
)
|
||||
dif_line.set(df[["time", "DIF"]])
|
||||
|
||||
# DEA线
|
||||
dea_line = macd_chart.create_line(
|
||||
name="DEA", color="#FF0000", width=2, price_label=False, price_line=False
|
||||
)
|
||||
dea_line.set(df[["time", "DEA"]])
|
||||
|
||||
return macd_chart
|
||||
|
||||
|
||||
def add_td_sequence(chart: Chart, df: pd.DataFrame):
|
||||
"""添加TD序列标记"""
|
||||
close = df["close"].to_list()
|
||||
td = [0, 0, 0, 0]
|
||||
up = 0
|
||||
down = 0
|
||||
|
||||
for i in range(4, len(close)):
|
||||
if close[i] > close[i - 4]:
|
||||
up += 1
|
||||
down = 0
|
||||
td.append(up)
|
||||
else:
|
||||
down -= 1
|
||||
up = 0
|
||||
td.append(down)
|
||||
|
||||
df["TD"] = td
|
||||
|
||||
# 添加标记
|
||||
markers = []
|
||||
for _, row in df.iterrows():
|
||||
td_val = row["TD"]
|
||||
if td_val in [9, 13]:
|
||||
markers.append({
|
||||
"time": row["time"].strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"position": "above",
|
||||
"shape": "arrow_down",
|
||||
"color": "#00FF00",
|
||||
"text": str(td_val),
|
||||
})
|
||||
elif td_val in [-9, -13]:
|
||||
markers.append({
|
||||
"time": row["time"].strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"position": "below",
|
||||
"shape": "arrow_up",
|
||||
"color": "#FF0000",
|
||||
"text": str(abs(td_val)),
|
||||
})
|
||||
|
||||
chart.marker_list(markers)
|
||||
|
||||
|
||||
def add_buy_sell_signals(chart: Chart, df: pd.DataFrame):
|
||||
"""添加买卖信号标记"""
|
||||
if "buy" not in df.columns and "sell" not in df.columns:
|
||||
return
|
||||
|
||||
markers = []
|
||||
for _, row in df.iterrows():
|
||||
if row.get("buy") == 1:
|
||||
markers.append({
|
||||
"time": row["time"].strftime("%Y-%m-%d"),
|
||||
"position": "below",
|
||||
"shape": "arrow_up",
|
||||
"color": "#00FF00",
|
||||
"text": "B",
|
||||
})
|
||||
elif row.get("sell") == 1:
|
||||
markers.append({
|
||||
"time": row["time"].strftime("%Y-%m-%d"),
|
||||
"position": "above",
|
||||
"shape": "arrow_down",
|
||||
"color": "#FF0000",
|
||||
"text": "S",
|
||||
})
|
||||
|
||||
chart.marker_list(markers)
|
||||
|
||||
|
||||
class IndicatorOverlay:
|
||||
"""指标叠加器"""
|
||||
|
||||
def __init__(self, chart: Chart):
|
||||
self.chart = chart
|
||||
|
||||
def add_default_indicators(self, df: pd.DataFrame):
|
||||
"""添加默认指标组合"""
|
||||
# 短期EMA
|
||||
for period in [3, 5, 8, 10, 12, 15]:
|
||||
add_ema(self.chart, df, period=period, color=5)
|
||||
|
||||
# 长期EMA
|
||||
for period in [30, 35, 40, 45, 50, 60]:
|
||||
add_ema(self.chart, df, period=period, color=10)
|
||||
|
||||
# 年线
|
||||
add_ema(self.chart, df, period=260)
|
||||
|
||||
# MACD
|
||||
add_macd(self.chart, df, fastperiod=30, slowperiod=90)
|
||||
|
||||
# CCI
|
||||
add_cci(self.chart, df, period=120)
|
||||
|
||||
# TD序列
|
||||
add_td_sequence(self.chart, df)
|
||||
97
archive/visualization/charts/kline.py
Normal file
97
archive/visualization/charts/kline.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
K线图表组件
|
||||
|
||||
基于lightweight-charts的K线图表
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from lightweight_charts import Chart
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class KlineChart:
|
||||
"""K线图表类"""
|
||||
|
||||
def __init__(self, title: str = "K线图", toolbox: bool = True):
|
||||
self.title = title
|
||||
self.toolbox = toolbox
|
||||
self.chart = None
|
||||
self.subcharts = {}
|
||||
|
||||
def create(self, maximize: bool = True) -> Chart:
|
||||
"""创建图表实例"""
|
||||
self.chart = Chart(toolbox=self.toolbox, inner_height=0.8, maximize=maximize)
|
||||
self.chart.layout(font_family="Times New Roman")
|
||||
self.chart.legend(visible=True, font_size=14, color="#FFFFFF")
|
||||
return self.chart
|
||||
|
||||
def set_data(self, df: pd.DataFrame, time_col: str = "time"):
|
||||
"""设置K线数据"""
|
||||
if self.chart is None:
|
||||
self.create()
|
||||
|
||||
# 验证数据
|
||||
required_cols = ["open", "high", "low", "close", "volume"]
|
||||
missing = [c for c in required_cols if c not in df.columns]
|
||||
if missing:
|
||||
raise ValueError(f"缺少必要列: {missing}")
|
||||
|
||||
# 确保时间列格式正确
|
||||
df = df.copy()
|
||||
if time_col in df.columns:
|
||||
df[time_col] = pd.to_datetime(df[time_col])
|
||||
|
||||
self.chart.set(df)
|
||||
|
||||
def set_visible_range(self, start_time: datetime, end_time: datetime):
|
||||
"""设置可见范围"""
|
||||
if self.chart:
|
||||
self.chart.set_visible_range(start_time, end_time)
|
||||
|
||||
def add_topbar_text(self, key: str, text: str):
|
||||
"""添加顶部栏文本"""
|
||||
if self.chart:
|
||||
self.chart.topbar.textbox(key, text)
|
||||
|
||||
def show(self, block: bool = True):
|
||||
"""显示图表"""
|
||||
if self.chart:
|
||||
self.chart.show(block=block)
|
||||
|
||||
|
||||
def create_kline_chart(
|
||||
df: pd.DataFrame,
|
||||
symbol: str,
|
||||
name: str,
|
||||
timeframe: str,
|
||||
init_visible_bars: int = 90,
|
||||
) -> Chart:
|
||||
"""
|
||||
快速创建K线图表
|
||||
|
||||
Args:
|
||||
df: DataFrame with OHLCV data
|
||||
symbol: 标的代码
|
||||
name: 标的名称
|
||||
timeframe: 时间周期
|
||||
init_visible_bars: 初始可见K线数量
|
||||
|
||||
Returns:
|
||||
Chart实例
|
||||
"""
|
||||
chart = KlineChart()
|
||||
chart.create(maximize=True)
|
||||
|
||||
chart.add_topbar_text("symbol", symbol)
|
||||
chart.add_topbar_text("name", name)
|
||||
chart.add_topbar_text("timeframe", timeframe)
|
||||
|
||||
chart.set_data(df)
|
||||
|
||||
# 设置初始可见范围
|
||||
if len(df) > init_visible_bars:
|
||||
end_time = df["time"].iloc[-1]
|
||||
start_time = df["time"].iloc[-init_visible_bars]
|
||||
chart.set_visible_range(start_time, end_time)
|
||||
|
||||
return chart.chart
|
||||
144
archive/visualization/report_generator/README.md
Normal file
144
archive/visualization/report_generator/README.md
Normal file
@@ -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
|
||||
<div class="kpi-value">{{ new_metric }}</div>
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **模板引擎**: 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
|
||||
```
|
||||
7
archive/visualization/report_generator/__init__.py
Normal file
7
archive/visualization/report_generator/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
ETF轮动策略报告生成器
|
||||
"""
|
||||
|
||||
from .generate_report import ReportGenerator
|
||||
|
||||
__all__ = ['ReportGenerator']
|
||||
420
archive/visualization/report_generator/generate_report.py
Normal file
420
archive/visualization/report_generator/generate_report.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user