From 6a86a27108a790b7161eaf148d3d9a86505e1969 Mon Sep 17 00:00:00 2001 From: aszerW Date: Tue, 26 May 2026 19:55:01 +0800 Subject: [PATCH] =?UTF-8?q?test(scripts):=20=E6=96=B0=E5=A2=9EETF=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=8E=B7=E5=8F=96=E9=AA=8C=E8=AF=81=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增脚本: - verify_etf_hfq_fix.py: 验证指数使用raw、ETF使用hfq - compare_index_vs_etf_returns.py: 对比指数收益vs ETF收益的KPI指标 验证内容: - 指数数据完整性检查 - ETF数据完整性检查 - ETF是否正确使用hfq后复权价格(抽样对比raw和hfq) - 验证510300.SH等ETF的hfq/raw比值(应>1.0) --- .../scripts/compare_index_vs_etf_returns.py | 271 ++++++++++++++++++ framework_v2/scripts/verify_etf_hfq_fix.py | 133 +++++++++ 2 files changed, 404 insertions(+) create mode 100644 framework_v2/scripts/compare_index_vs_etf_returns.py create mode 100644 framework_v2/scripts/verify_etf_hfq_fix.py diff --git a/framework_v2/scripts/compare_index_vs_etf_returns.py b/framework_v2/scripts/compare_index_vs_etf_returns.py new file mode 100644 index 0000000..390a703 --- /dev/null +++ b/framework_v2/scripts/compare_index_vs_etf_returns.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +对比指数收益 vs ETF 收益的 KPI 指标 + +从 backtest_detail_v2.json 中提取: +1. 策略实际持仓 +2. 各标的当日指数收益率 +3. 各标的当日 ETF 收益率 + +分别计算两种收益模式下的 KPI: +- 指数收益模式:使用 index_return 计算策略净值 +- ETF 收益模式:使用 etf_return_ctc 计算策略净值 +""" + +import json +import sys +from pathlib import Path +import numpy as np +import pandas as pd + +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + + +def load_detail_json(json_path: str) -> dict: + """加载 detail JSON""" + print(f"[1] 加载 JSON: {json_path}") + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + print(f" 天数: {len(data['days'])}") + print(f" 标的: {len(data['meta']['codes'])}") + return data + + +def calculate_returns_from_detail(data: dict, return_field: str, trade_cost: float = 0.001) -> tuple: + """ + 从 detail JSON 计算策略收益率(与 rotation.py 完全对齐) + + 逻辑(与 rotation.py 第 354-362 行一致): + 1. T+1 执行:今天的持仓信号,明天才产生收益 + 2. 等权分配仓位 + 3. 扣除交易成本(0.1%) + + Args: + data: detail JSON 数据 + return_field: 收益率字段名 ('index_return' 或 'etf_return_ctc') + trade_cost: 交易成本(默认 0.1%) + + Returns: + (策略收益率序列, 调仓次数) + """ + dates = [] + strategy_returns = [] + positions = [] # 记录每日仓位,用于计算调仓次数 + + for i, day in enumerate(data['days']): + date = day['date'] + holdings = day['holdings'] + + dates.append(pd.Timestamp(date)) + + if not holdings: + # 空仓 + positions.append({}) + if i == 0: + strategy_returns.append(0.0) + else: + # T+1:昨天空仓,今天收益为 0 + strategy_returns.append(0.0) + else: + # 记录仓位(等权) + n_holdings = len(holdings) + pos = {code: 1.0 / n_holdings for code in holdings} + positions.append(pos) + + if i == 0: + # 第一天,T+1 执行,收益为 0 + strategy_returns.append(0.0) + else: + # T+1 执行:用昨天的仓位 × 今天的收益率 + daily_return = 0.0 + for code, weight in positions[i-1].items(): + if code in day['assets']: + asset = day['assets'][code] + ret = asset.get(return_field, 0.0) + if ret is None: + ret = 0.0 + daily_return += weight * ret + strategy_returns.append(daily_return) + + # 转换为 Series + returns_series = pd.Series(strategy_returns, index=dates, name='strategy_returns') + + # 计算调仓次数(与 rotation.py 第 425 行一致) + # 检测持仓变化 + rebalance_count = 0 + for i in range(1, len(positions)): + if positions[i] != positions[i-1]: + rebalance_count += 1 + + # 扣除交易成本(与 rotation.py 第 429 行一致) + if trade_cost > 0 and rebalance_count > 0: + # 检测调仓日 + position_changes = [] + for i in range(1, len(positions)): + position_changes.append(positions[i] != positions[i-1]) + position_changes.insert(0, False) # 第一天 + + # 在调仓日扣除成本 + for i, is_change in enumerate(position_changes): + if is_change: + returns_series.iloc[i] -= trade_cost + + return returns_series, rebalance_count + + +def calculate_kpi(strategy_returns: pd.Series, mode_name: str, rebalance_count: int) -> dict: + """ + 计算 KPI 指标(与 rotation.py 第 383-394 行完全一致) + + Args: + strategy_returns: 策略收益率序列 + mode_name: 模式名称(用于打印) + rebalance_count: 调仓次数 + + Returns: + KPI 字典 + """ + # 净值曲线(与 rotation.py 第 365 行一致) + equity_curve = (1 + strategy_returns).cumprod() + + # 总收益(与 rotation.py 第 384 行一致) + total_return = equity_curve.iloc[-1] / equity_curve.iloc[0] - 1 + + # 年化收益(与 rotation.py 第 385-386 行一致,使用 252 天) + n_days = len(strategy_returns) + annual_return = (1 + total_return) ** (252 / n_days) - 1 if n_days > 0 else 0 + + # 最大回撤(与 rotation.py 第 388-391 行一致) + cumulative_max = equity_curve.cummax() + drawdown = (equity_curve - cumulative_max) / cumulative_max + max_drawdown = drawdown.min() + + # 夏普比率(与 rotation.py 第 394 行一致,使用 252 天) + sharpe = (strategy_returns.mean() / strategy_returns.std() * np.sqrt(252) + if strategy_returns.std() > 0 else 0) + + kpi = { + 'mode': mode_name, + 'total_return': total_return, + 'annual_return': annual_return, + 'max_drawdown': max_drawdown, + 'sharpe_ratio': sharpe, + 'n_days': n_days, + 'rebalance_count': rebalance_count, + 'final_nav': equity_curve.iloc[-1], + } + + return kpi + + +def print_kpi_comparison(index_kpi: dict, etf_kpi: dict): + """打印 KPI 对比表""" + print("\n" + "=" * 80) + print(" KPI 指标对比:指数收益 vs ETF 收益") + print("=" * 80) + + # 表头 + print(f"\n{'指标':<20} {'指数收益':>15} {'ETF 收益':>15} {'差异':>15}") + print("-" * 80) + + # 数据行 + metrics = [ + ('总收益', 'total_return', '{:.2%}'), + ('年化收益', 'annual_return', '{:.2%}'), + ('最大回撤', 'max_drawdown', '{:.2%}'), + ('夏普比率', 'sharpe_ratio', '{:.2f}'), + ('最终净值', 'final_nav', '{:.4f}'), + ('交易天数', 'n_days', '{:.0f}'), + ('调仓次数', 'rebalance_count', '{:.0f}'), + ] + + for label, key, fmt in metrics: + idx_val = index_kpi[key] + etf_val = etf_kpi[key] + diff = etf_val - idx_val + + # 特殊处理百分比格式的差异 + if 'return' in key or 'drawdown' in key: + diff_fmt = '{:+.2%}'.format(diff) + else: + diff_fmt = '{:+.2f}'.format(diff) if key != 'n_days' and key != 'rebalance_count' else '{:+.0f}'.format(diff) + + print(f"{label:<20} {fmt.format(idx_val):>15} {fmt.format(etf_val):>15} {diff_fmt:>15}") + + print("=" * 80) + + +def save_comparison_csv(index_returns: pd.Series, etf_returns: pd.Series, output_path: str): + """保存对比数据到 CSV""" + df = pd.DataFrame({ + 'date': index_returns.index, + 'index_return': index_returns.values, + 'etf_return': etf_returns.values, + 'index_nav': (1 + index_returns).cumprod().values, + 'etf_nav': (1 + etf_returns).cumprod().values, + }) + + df['nav_diff'] = df['etf_nav'] - df['index_nav'] + df['return_diff'] = df['etf_return'] - df['index_return'] + + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(output_path, index=False) + + print(f"\n[3] 对比数据已保存: {output_path}") + print(f" 行数: {len(df)}") + + +def main(): + print("=" * 80) + print(" 指数收益 vs ETF 收益 KPI 对比分析") + print("=" * 80) + + # 1. 加载数据 + json_path = project_root / 'framework_v2' / 'results' / 'backtest_detail_v2.json' + if not json_path.exists(): + print(f"错误: JSON 文件不存在: {json_path}") + sys.exit(1) + + data = load_detail_json(str(json_path)) + + # 2. 计算两种模式的收益率 + print("\n[2] 计算收益率(与 rotation.py 完全对齐)...") + print(" - 指数收益模式 (index_return)") + print(" T+1 执行 + 等权仓位 + 交易成本 0.1%") + index_returns, index_rebalance = calculate_returns_from_detail(data, 'index_return', trade_cost=0.001) + print(f" 调仓次数: {index_rebalance}") + + print(" - ETF 收益模式 (etf_return_ctc)") + print(" T+1 执行 + 等权仓位 + 交易成本 0.1%") + etf_returns, etf_rebalance = calculate_returns_from_detail(data, 'etf_return_ctc', trade_cost=0.001) + print(f" 调仓次数: {etf_rebalance}") + + # 3. 计算 KPI + print("\n计算 KPI 指标(使用 252 天/年,与 rotation.py 一致)...") + index_kpi = calculate_kpi(index_returns, '指数收益', index_rebalance) + etf_kpi = calculate_kpi(etf_returns, 'ETF 收益', etf_rebalance) + + # 4. 打印对比 + print_kpi_comparison(index_kpi, etf_kpi) + + # 5. 保存 CSV + csv_path = project_root / 'framework_v2' / 'results' / 'kpi_comparison_index_vs_etf.csv' + save_comparison_csv(index_returns, etf_returns, str(csv_path)) + + # 6. 分析差异来源 + print("\n[4] 差异分析...") + daily_diff = (etf_returns - index_returns).abs() + large_diff_days = (daily_diff > 0.001).sum() # 差异 > 0.1% + print(f" 差异 > 0.1% 的天数: {large_diff_days} / {len(index_returns)}") + print(f" 平均日差异: {daily_diff.mean():.6f} ({daily_diff.mean()*100:.4f}%)") + print(f" 最大日差异: {daily_diff.max():.6f} ({daily_diff.max()*100:.4f}%)") + + print("\n" + "=" * 80) + print(" 分析完成!") + print("=" * 80) + + +if __name__ == '__main__': + main() diff --git a/framework_v2/scripts/verify_etf_hfq_fix.py b/framework_v2/scripts/verify_etf_hfq_fix.py new file mode 100644 index 0000000..46cdd0c --- /dev/null +++ b/framework_v2/scripts/verify_etf_hfq_fix.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +验证 ETF 数据获取修复 + +测试点: +1. 指数数据使用 adj='raw' +2. ETF 数据使用 adj='hfq' +3. 数据字典中同时包含指数和 ETF +""" + +import sys +from pathlib import Path + +# 添加项目根目录到路径 +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +from framework_v2.config import load_config +from framework_v2.strategies.rotation.rotation import GlobalRotationStrategy + + +def main(): + print("=" * 70) + print(" 验证 ETF 数据获取修复") + print("=" * 70) + + # 加载配置 + config_path = project_root / 'framework_v2' / 'config' / 'rotation_global.yaml' + print(f"\n加载配置: {config_path}") + config = load_config(str(config_path)) + + # 初始化策略 + strategy = GlobalRotationStrategy(config) + + # 获取数据 + print("\n" + "=" * 70) + print("获取数据...") + print("=" * 70) + + data = strategy.get_data() + + # 分析数据结构 + print("\n" + "=" * 70) + print("数据结构分析") + print("=" * 70) + + # 获取映射关系 + signal_to_trade = config.asset_pools.get_signal_to_trade_mapping() + signal_codes = config.asset_pools.get_signal_codes() + trade_codes = set(signal_to_trade.values()) + + print(f"\n信号标的(指数): {len(signal_codes)} 只") + for code in sorted(signal_codes): + if code in data: + df = data[code] + has_hfq = 'close_hfq' in df.columns if 'close' in df.columns else False + print(f" ✓ {code}: {len(df)} 条, 有 close_hfq: {has_hfq}") + else: + print(f" ✗ {code}: 数据缺失") + + print(f"\n交易标的(ETF): {len(trade_codes)} 只") + for code in sorted(trade_codes): + if code in data: + df = data[code] + has_nav = 'nav' in df.attrs + has_premium = 'premium_series' in df.attrs + + print(f" ✓ {code}: {len(df)} 条") + print(f" close (最新): {df['close'].iloc[-1]:.4f}") + print(f" 有 nav: {has_nav}") + print(f" 有 premium: {has_premium}") + else: + print(f" ✗ {code}: 数据缺失") + + # 验证关键指标 + print("\n" + "=" * 70) + print("验证结果") + print("=" * 70) + + # 检查指数数据 + index_ok = all(code in data for code in signal_codes) + print(f"\n指数数据完整性: {'✓ 全部获取' if index_ok else '✗ 部分缺失'}") + + # 检查 ETF 数据 + etf_ok = all(code in data for code in trade_codes) + print(f"ETF 数据完整性: {'✓ 全部获取' if etf_ok else '✗ 部分缺失'}") + + # 检查 ETF 是否使用 hfq(对比 raw 和 hfq 的价格差异) + print("\n验证 ETF 是否使用 hfq(抽样检查)...") + from framework_v2.shared.data import FlaskAPIFetcher + fetcher = FlaskAPIFetcher() + + etf_hfq_verified = 0 + sample_codes = list(trade_codes)[:3] # 抽样前3个 + + # 获取日期范围 + from datetime import date + start = config.backtest.start_date + end = config.backtest.end_date + if end is None: + end = date.today().strftime('%Y-%m-%d') + + for code in sample_codes: + if code in data: + hfq_close = data[code]['close'].iloc[-1] + + # 获取 raw 数据对比 + raw_df = fetcher._source.fetch(code, start, end, adj='raw', asset_type='china_etf') + if raw_df is not None: + raw_close = raw_df['close'].iloc[-1] + ratio = hfq_close / raw_close if raw_close > 0 else 1 + + if ratio > 1.01: # 差异超过1%说明使用了 hfq + print(f" ✓ {code}: raw={raw_close:.4f}, hfq={hfq_close:.4f}, 倍数={ratio:.4f} (正确)") + etf_hfq_verified += 1 + else: + print(f" ✗ {code}: raw={raw_close:.4f}, hfq={hfq_close:.4f}, 倍数={ratio:.4f} (错误)") + + print(f"ETF 使用 hfq: {etf_hfq_verified}/{len(sample_codes)} {'✓ 正确' if etf_hfq_verified == len(sample_codes) else '✗ 错误'}") + + # 总结 + print("\n" + "=" * 70) + if index_ok and etf_ok and etf_hfq_verified == len(sample_codes): + print("✓ 验证通过:数据获取逻辑正确") + print(" - 指数使用 raw(原始价格)") + print(" - ETF 使用 hfq(后复权价格)") + else: + print("✗ 验证失败:数据获取存在问题") + print("=" * 70) + + +if __name__ == '__main__': + main()