diff --git a/.gitignore b/.gitignore index bcd2d24..840f94d 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,6 @@ report*.png # Downloaded articles zhihu-articles/ + +# Results directory (test outputs, charts, etc.) +results/ diff --git a/core/datasource/hybrid_source.py b/core/datasource/hybrid_source.py index 6f3d24e..6399ce9 100644 --- a/core/datasource/hybrid_source.py +++ b/core/datasource/hybrid_source.py @@ -72,7 +72,8 @@ class SSHTunnelManager: return False # 设置代理环境变量 - proxy_url = f"socks5://127.0.0.1:{self.local_port}" + # 使用 socks5h:// 让代理服务器远程解析DNS,避免IPv6问题 + proxy_url = f"socks5h://127.0.0.1:{self.local_port}" os.environ["HTTP_PROXY"] = proxy_url os.environ["HTTPS_PROXY"] = proxy_url os.environ["ALL_PROXY"] = proxy_url diff --git a/core/datasource/yfinance_source.py b/core/datasource/yfinance_source.py index a1e1818..ef1d8ce 100644 --- a/core/datasource/yfinance_source.py +++ b/core/datasource/yfinance_source.py @@ -75,7 +75,8 @@ class SSHTunnelManager: return False # 设置代理环境变量 - proxy_url = f"socks5://127.0.0.1:{self.local_port}" + # 使用 socks5h:// 让代理服务器远程解析DNS,避免IPv6问题 + proxy_url = f"socks5h://127.0.0.1:{self.local_port}" os.environ["HTTP_PROXY"] = proxy_url os.environ["HTTPS_PROXY"] = proxy_url os.environ["ALL_PROXY"] = proxy_url diff --git a/docs/experiments/001_same_category_expansion_ab_test.md b/docs/experiments/001_same_category_expansion_ab_test.md new file mode 100644 index 0000000..9fdba97 --- /dev/null +++ b/docs/experiments/001_same_category_expansion_ab_test.md @@ -0,0 +1,197 @@ +# 实验记录 001: 同大类扩充对轮动策略的影响 + +## 实验信息 + +| 项目 | 内容 | +|------|------| +| 实验编号 | 001 | +| 实验日期 | 2026-05-06 | +| 实验类型 | A/B对比测试 | +| 研究问题 | `diversified=true`模式下,添加同大类新标地对策略绩效的影响 | + +--- + +## 1. 实验背景 + +### 理论假设 + +`diversified=true` 模式的选股逻辑: + +``` +Step 1: 类内竞争 → 每个 market 大类只保留得分最高的1只标的(大类冠军) +Step 2: 跨类排序 → 从大类冠军中按得分从高到低选 Top 3 +``` + +**核心假设**: +- 添加同大类新标的不会增加跨大类分散度(每大类还是只输出1只) +- 可能增加类内切换频率,导致额外调仓成本 +- 额外切换时机可能不理想,侵蚀收益 + +--- + +## 2. 实验设计 + +### A/B组配置 + +| 组别 | 标的数量 | 美股大类标的 | 其他大类 | +|------|---------|-------------|---------| +| **A组(对照组)** | 11只 | 纳指100 (NDX) | A股2、港股2、日本1、欧洲1、商品3、固收1 | +| **B组(实验组)** | 12只 | 纳指100 + 标普500 (SPX) | 同A组 | + +### 关键差异 + +B组在美股大类(market="US")中添加了标普500: +- A组:美股大类只有纳指100,自动成为大类冠军 +- B组:美股大类有纳指100和标普500,需要类内竞争决定冠军 + +--- + +## 3. 回测结果 + +### 数据获取情况 + +修复了 `socks5://` → `socks5h://` 的代理问题后,所有 YFinance 数据成功获取: + +``` +✓ SSH 隧道已建立: socks5h://127.0.0.1:1080 +下载 NDX (纳指100) - YFinance... ✓ 1845 条 +下载 SPX (标普500) - YFinance... ✓ 1845 条 +其他标的均成功获取 +``` + +### 绩效对比 + +| 指标 | A组(无SPX) | B组(有SPX) | 差异 | +|------|-------------|-------------|------| +| 标的数量 | 11只 | 12只 | +1 | +| **累计收益** | **1467.35%** | 1176.26% | **-291.09%** | +| **CAGR** | **48.10%** | 43.82% | **-4.28%** | +| **Sharpe** | **2.21** | 2.06 | **-0.15** | +| MaxDD | -17.33% | -17.18% | +0.14%(略好) | +| **Calmar** | **2.78** | 2.55 | **-0.23** | +| 日胜率 | 56.45% | 56.11% | -0.34% | +| **调仓次数** | 459次 | 501次 | **+42次** | +| 年均调仓 | 66.0次 | 72.1次 | +6.1次 | + +--- + +## 4. 关键发现 + +### 发现1:跨类分散不变 + +添加标普500后,美股大类在最终持仓中的占比不变: +- 美股大类始终只有1只冠军进入Top3候选池 +- 跨大类分散度没有增加 + +### 发现2:调仓次数增加 + +- B组调仓次数增加42次(从459→501) +- 类内切换更频繁(纳指100 ↔ 标普500) +- 额外调仓成本侵蚀收益 + +### 发现3:绩效反而变差 + +``` +B组绩效全面下滑: +├─ 累计收益 -291% +├─ CAGR -4.28% +├─ Sharpe -0.15 +├─ Calmar -0.23 +└─ 原因:类内切换时机不佳 + 额外调仓成本 +``` + +### 发现4:类内切换逻辑 + +美股大类竞争示例: +``` +某日得分: +纳指100: 4.7 → 美股冠军(持有纳指ETF) +标普500: 3.5 → 淘汰 + +另一天得分: +纳指100: -1.0(下跌) +标普500: 2.5 → 美股冠军(切换到标普ETF) + +问题:切换时机可能滞后,错过最佳窗口 +``` + +--- + +## 5. 实验结论 + +### 核心结论 + +| 假设 | 实证结果 | +|-----|---------| +| 添加同大类标的**不增加跨类分散** | ✓ **验证通过** | +| 可能**增加调仓次数** | ✓ **验证通过**(+42次) | +| 额外切换**可能侵蚀收益** | ✓ **验证通过**(累计收益-291%) | + +### 策略建议 + +``` +diversified=true 模式下的标的池优化策略: + +✗ 不要盲目添加同大类新标的 + → 可能增加切换频率,侵蚀收益 + → 每大类保持1-2只代表性标的即可 + +✓ 应该添加新大类(增加跨类分散) + → 印度、越南、短债等新大类 + → 真正扩大 Top 3 候选池 + → 提升跨大类分散度 + +✓ 类内标的选择原则 + → 选择该大类最具代表性的标的 + → 避免风格过度细分导致频繁切换 + → 例:美股选纳指100即可(成长代表) +``` + +--- + +## 6. 技术修复记录 + +### 代理问题修复 + +本次实验过程中发现了 SSH SOCKS5 隧道的 IPv6 问题: + +**问题**: +```python +# 原配置(失败) +proxy_url = "socks5://127.0.0.1:1080" +# 本地DNS解析 → IPv6地址 → SSH隧道拒绝IPv6 → 连接失败 +``` + +**修复**: +```python +# 新配置(成功) +proxy_url = "socks5h://127.0.0.1:1080" +# 'h'表示远程DNS解析 → 代理服务器只用IPv4 → 连接成功 +``` + +**修改文件**: +- `core/datasource/hybrid_source.py` +- `core/datasource/yfinance_source.py` + +--- + +## 7. 相关文件 + +| 文件 | 说明 | +|-----|------| +| `tests/experiments/ab_test_spx.py` | A/B测试脚本 | +| `results/ab_test_spx.csv` | 测试结果数据 | +| `docs/轮动策略核心逻辑_v2.md` | 策略核心逻辑文档 | + +--- + +## 8. 后续研究方向 + +1. **新大类扩充实验**:添加印度NIFTY、短债等新大类,验证跨类分散效果 +2. **类内切换时机分析**:深入分析纳指100 vs 标普500切换的具体时间点 +3. **最佳大类数量研究**:多少个大类是最优配置? + +--- + +*实验记录版本: v1.0* +*最后更新: 2026-05-06* \ No newline at end of file diff --git a/docs/experiments/002_ndx_vs_spx_replacement.md b/docs/experiments/002_ndx_vs_spx_replacement.md new file mode 100644 index 0000000..648dd45 --- /dev/null +++ b/docs/experiments/002_ndx_vs_spx_replacement.md @@ -0,0 +1,171 @@ +# 实验记录 002: 纳指100 vs 标普500 替换对比 + +## 实验信息 + +| 项目 | 内容 | +|------|------| +| 实验编号 | 002 | +| 实验日期 | 2026-05-06 | +| 实验类型 | A/B对比测试(替换场景) | +| 研究问题 | 将美股大类代表从纳指100替换为标普500后的绩效变化 | + +--- + +## 1. 实验背景 + +### 与001实验的区别 + +| 实验 | 操作 | 类内竞争 | 标的数量 | +|------|------|---------|---------| +| 001 | **添加**标普500 | 有(纳指vs标普) | 11→12 | +| 002 | **替换**纳指为标普 | 无 | 11→11 | + +**002实验聚焦**:评估标的特性变化对策略绩效的影响(无类内切换干扰) + +### 理论假设 + +``` +纳指100 (NDX): +├─ 成分股:100只科技龙头 +├─ 风格:纯成长、高波动 +├─ 动量特性:趋势强、涨跌幅大 +└─ 与动量策略匹配度:高 + +标普500 (SPX): +├─ 成分股:500只大盘股 +├─ 风格:价值+成长混合、中波动 +├─ 动量特性:趋势相对平缓 +└─ 与动量策略匹配度:中 +``` + +--- + +## 2. 实验设计 + +### A/B组配置 + +| 组别 | 美股大类标的 | 其他大类 | +|------|-------------|---------| +| **A组(对照组)** | 纳指100 (NDX) → 513100.SH | A股2、港股2、日本1、欧洲1、商品3、固收1 | +| **B组(实验组)** | 标普500 (SPX) → 513500.SH | 同A组 | + +--- + +## 3. 回测结果 + +### 绩效对比 + +| 指标 | A组(纳指100) | B组(标普500) | 差异 | +|------|---------------|---------------|------| +| 美股标的 | 纳指100 | 标普500 | 替换 | +| **累计收益** | **1467.35%** | 1118.77% | **-348.58%** | +| **CAGR** | **48.10%** | 42.87% | **-5.22%** | +| **Sharpe** | **2.21** | 2.08 | **-0.13** | +| MaxDD | -17.33% | **-15.14%** | **+2.18%** ✓ | +| Calmar | 2.78 | **2.83** | +0.06 | +| 日胜率 | 56.45% | 56.22% | -0.23% | +| 调仓次数 | 459次 | 475次 | +16次 | + +--- + +## 4. 关键发现 + +### 发现1:纳指100累计收益显著更高 + +``` +差距分析: +├─ 累计收益差距:348.58% +├─ CAGR差距:5.22% +└─ 原因:纳指100成长性强,动量信号更明显 +``` + +### 发现2:标普500回撤控制更好 + +``` +风险指标: +├─ MaxDD改善:2.18%(标普更稳定) +├─ Calmar略优:+0.06 +└─ 原因:标普500波动率更低,成分股更多元 +``` + +### 发现3:纳指100风险调整收益更优 + +``` +Sharpe对比: +├─ 纳指100:2.21 +├─ 标普500:2.08 +└─ 纳指虽波动大,但收益补偿足够 +``` + +### 发现4:调仓次数差异不大 + +``` +替换场景(无类内竞争): +├─ 调仓次数差:仅+16次(vs 001实验+42次) +└─ 证明:替换比添加更稳定 +``` + +--- + +## 5. 实验结论 + +### 核心结论 + +| 维度 | 结论 | +|-----|------| +| 收益能力 | 纳指100 **显著优于** 标普500 (+348%) | +| 风险控制 | 标普500 **略优于** 纳指100 (+2.18%) | +| 风险调整收益 | 纳指100 **优于** 标普500 (Sharpe +0.13) | +| 综合评价 | **保持纳指100** | + +### 策略建议 + +``` +当前策略建议:保持纳指100作为美股大类代表 + +理由: +1. 动量策略本质是捕捉强趋势 +2. 纳指100成长股特性使其动量信号更强 +3. 累计收益差距显著(1467% vs 1118%) +4. 标普500虽更稳定,但牺牲收益太大 + +例外情况(可能考虑标普500): +├─ 风险偏好极低,优先回撤控制 +├─ 牛市末期或市场不确定性高时 +└─ 需要降低组合整体波动率 +``` + +--- + +## 6. 与001实验对比 + +| 实验 | 操作 | 收益变化 | 调仓变化 | +|------|------|---------|---------| +| 001(添加) | 纳指 + 标普 | -291% | +42次 | +| 002(替换) | 纳指 → 标普 | -348% | +16次 | + +**洞察**: +- 替换场景调仓更稳定(+16 vs +42) +- 但收益损失更大(无纳指成长性补偿) + +--- + +## 7. 相关文件 + +| 文件 | 说明 | +|-----|------| +| `tests/experiments/ab_test_ndx_vs_spx.py` | A/B测试脚本 | +| `results/ab_test_ndx_vs_spx.csv` | 测试结果数据 | + +--- + +## 8. 后续研究方向 + +1. **纳指100 vs 其他美股成长指数**:如罗素1000成长、MSCI美国成长 +2. **不同市场周期表现**:牛市、熊市分别测试纳指和标普效果 +3. **动态切换机制**:根据市场状态动态选择纳指或标普 + +--- + +*实验记录版本: v1.0* +*最后更新: 2026-05-06* \ No newline at end of file diff --git a/docs/experiments/README.md b/docs/experiments/README.md new file mode 100644 index 0000000..1378459 --- /dev/null +++ b/docs/experiments/README.md @@ -0,0 +1,45 @@ +# 实验记录索引 + +本目录用于保存 ETF 轮动策略研究中的有洞察的实验结果。 + +--- + +## 实验列表 + +| 编号 | 实验名称 | 日期 | 类型 | 核心发现 | +|------|---------|------|------|---------| +| [001](001_same_category_expansion_ab_test.md) | 同大类扩充对轮动策略的影响 | 2026-05-06 | A/B测试 | 添加同大类标的不增加跨类分散,反而因切换成本侵蚀收益 | +| [002](002_ndx_vs_spx_replacement.md) | 纳指100 vs 标普500替换对比 | 2026-05-06 | A/B测试 | 纳指100优于标普500(收益+348%,Sharpe+0.13),成长风格更适合动量 | + +--- + +## 文档命名规范 + +``` +格式: {编号}_{实验主题}.md + +示例: +- 001_same_category_expansion_ab_test.md # 同大类扩充实验 +- 002_new_category_diversification.md # 新大类分散化实验 +- 003_rebalance_threshold_tuning.md # 调仓阈值调优实验 +``` + +--- + +## 实验文档模板 + +每个实验文档应包含以下章节: + +1. **实验信息** - 编号、日期、类型、研究问题 +2. **实验背景** - 理论假设、研究动机 +3. **实验设计** - A/B组配置、关键变量 +4. **回测结果** - 数据、绩效对比表格 +5. **关键发现** - 核心洞察、数据支撑 +6. **实验结论** - 假设验证结果、策略建议 +7. **技术修复记录** - 实验过程中发现的技术问题 +8. **相关文件** - 脚本、数据文件引用 +9. **后续研究方向** - 待探索的问题 + +--- + +*目录创建日期: 2026-05-06* \ No newline at end of file diff --git a/fetch_159516_nav.py b/fetch_159516_nav.py new file mode 100644 index 0000000..b1d1e92 --- /dev/null +++ b/fetch_159516_nav.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +""" +获取159516 ETF净值数据 +""" + +import os +import pandas as pd +import tushare as ts +from datetime import datetime, timedelta + +# 设置Tushare token +def get_tushare_token(): + # 首先尝试从环境变量获取 + token = os.environ.get("TUSHARE_TOKEN") + if token: + return token + + # 尝试从.env文件获取 + try: + from dotenv import load_dotenv + load_dotenv() + token = os.environ.get("TUSHARE_TOKEN") + if token: + return token + except ImportError: + pass + + # 手动读取.env文件 + env_path = os.path.join(os.path.dirname(__file__), '.env') + if os.path.exists(env_path): + with open(env_path, 'r') as f: + for line in f: + if line.startswith('TUSHARE_TOKEN='): + token = line.strip().split('=', 1)[1].strip().strip('"').strip("'") + if token: + return token + + raise ValueError("请设置 TUSHARE_TOKEN 环境变量或在.env文件中配置") + + +def fetch_etf_nav(etf_code="159516.SZ", days=30): + """ + 获取ETF净值数据 + + Args: + etf_code: ETF代码,如 "159516.SZ" + days: 获取天数 + + Returns: + DataFrame: 包含日期和净值 + """ + pro = ts.pro_api(get_tushare_token()) + + # 计算日期范围 + end_date = datetime.now() + start_date = end_date - timedelta(days=days + 5) + + start_str = start_date.strftime('%Y%m%d') + end_str = end_date.strftime('%Y%m%d') + + # 转换代码格式 (tushare使用.SH而不是.SS) + ts_code = etf_code.replace(".SS", ".SH") + + print(f"获取 {etf_code} 净值数据...") + print(f"日期范围: {start_str} ~ {end_str}") + + try: + # 获取ETF净值数据 + nav_df = pro.fund_nav( + ts_code=ts_code, + start_date=start_str, + end_date=end_str + ) + + if nav_df is None or len(nav_df) == 0: + print("未获取到净值数据") + return None + + # 排序并处理数据 + nav_df = nav_df.sort_values('nav_date') + + # 转换日期格式 + nav_df['date'] = pd.to_datetime(nav_df['nav_date']) + nav_df = nav_df.set_index('date') + + print(f"\n获取到 {len(nav_df)} 条净值数据") + print(f"最新净值日期: {nav_df.index.max().strftime('%Y-%m-%d')}") + print(f"最新净值: {nav_df['unit_nav'].iloc[-1]}") + + # 显示最近10条数据 + print(f"\n最近10条净值数据:") + print(nav_df[['unit_nav']].tail(10).to_string()) + + return nav_df + + except Exception as e: + print(f"获取净值数据失败: {e}") + return None + + +if __name__ == "__main__": + # 获取159516的净值数据 + result = fetch_etf_nav("159516.SZ", days=30) + + if result is not None: + # 保存到CSV文件 + output_file = "159516_nav_data.csv" + result[['unit_nav']].to_csv(output_file) + print(f"\n数据已保存到: {output_file}") diff --git a/fetch_159930.py b/fetch_159930.py new file mode 100644 index 0000000..e2805e5 --- /dev/null +++ b/fetch_159930.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +获取159930 ETF最新10天的收盘价、净值并计算溢价率 +""" + +import os +import pandas as pd +import tushare as ts +from datetime import datetime, timedelta + +# 设置Tushare token +def get_tushare_token(): + # 首先尝试从环境变量获取 + token = os.environ.get("TUSHARE_TOKEN") + if token: + return token + + # 尝试从.env文件获取 + try: + from dotenv import load_dotenv + load_dotenv() + token = os.environ.get("TUSHARE_TOKEN") + if token: + return token + except ImportError: + pass + + # 手动读取.env文件 + env_path = os.path.join(os.path.dirname(__file__), '.env') + if os.path.exists(env_path): + with open(env_path, 'r') as f: + for line in f: + if line.startswith('TUSHARE_TOKEN='): + token = line.strip().split('=', 1)[1].strip().strip('"').strip("'") + if token: + return token + + raise ValueError("请设置 TUSHARE_TOKEN 环境变量或在.env文件中配置") + + +def fetch_etf_data(etf_code: str, days: int = 10): + """ + 获取ETF最新N天的价格、净值数据 + + Args: + etf_code: ETF代码,如 "159930.SZ" + days: 获取天数 + + Returns: + DataFrame: 包含日期、收盘价、净值、溢价率 + """ + pro = ts.pro_api(get_tushare_token()) + + # 计算日期范围(多取几天确保有足够数据) + end_date = datetime.now() + start_date = end_date - timedelta(days=days + 5) + + start_str = start_date.strftime('%Y%m%d') + end_str = end_date.strftime('%Y%m%d') + + # 转换代码格式 + ts_code = etf_code.replace(".SS", ".SH") + + print(f"获取 {etf_code} 数据...") + print(f"日期范围: {start_str} ~ {end_str}") + + # 1. 获取ETF价格数据(fund_daily接口) + print("\n1. 获取ETF价格数据...") + try: + price_df = pro.fund_daily( + ts_code=ts_code, + start_date=start_str, + end_date=end_str + ) + if price_df is not None and len(price_df) > 0: + price_df = price_df.sort_values('trade_date') + print(f" 获取到 {len(price_df)} 条价格数据") + print(f" 最新日期: {price_df['trade_date'].max()}") + else: + print(" 未获取到价格数据") + price_df = None + except Exception as e: + print(f" 获取价格数据失败: {e}") + price_df = None + + # 2. 获取ETF净值数据(fund_nav接口) + print("\n2. 获取ETF净值数据...") + try: + # 净值通常滞后,多取一天 + nav_end_date = end_date + timedelta(days=1) + nav_end_str = nav_end_date.strftime('%Y%m%d') + + nav_df = pro.fund_nav( + ts_code=ts_code, + start_date=start_str, + end_date=nav_end_str + ) + if nav_df is not None and len(nav_df) > 0: + nav_df = nav_df.sort_values('nav_date') + print(f" 获取到 {len(nav_df)} 条净值数据") + print(f" 最新日期: {nav_df['nav_date'].max()}") + else: + print(" 未获取到净值数据") + nav_df = None + except Exception as e: + print(f" 获取净值数据失败: {e}") + nav_df = None + + # 3. 合并数据并计算溢价率 + print("\n3. 合并数据并计算溢价率...") + + if price_df is None: + print("错误: 没有价格数据") + return None + + # 准备价格数据 + price_df['date'] = pd.to_datetime(price_df['trade_date']) + price_df = price_df.set_index('date') + price_series = price_df['close'] + + # 准备净值数据 + if nav_df is not None: + nav_df['date'] = pd.to_datetime(nav_df['nav_date']) + nav_df = nav_df.set_index('date') + nav_series = nav_df['unit_nav'] + else: + nav_series = pd.Series() + + # 创建结果DataFrame + result = pd.DataFrame({ + '收盘价': price_series + }) + + # 对齐净值数据(按日期) + result = result.join(nav_series.rename('净值'), how='left') + + # 计算溢价率 + result['溢价率'] = (result['收盘价'] - result['净值']) / result['净值'] * 100 + + # 取最新N天 + result = result.tail(days) + + # 格式化输出 + result['收盘价'] = result['收盘价'].round(3) + result['净值'] = result['净值'].round(3) + result['溢价率'] = result['溢价率'].round(2) + + # 重置索引,将日期作为列 + result = result.reset_index() + result['日期'] = result['date'].dt.strftime('%Y-%m-%d') + result = result[['日期', '收盘价', '净值', '溢价率']] + + return result + + +def main(): + """主函数""" + etf_code = "159930.SZ" + days = 10 + + print("=" * 60) + print(f"ETF: {etf_code} (中证能源ETF)") + print(f"获取最近 {days} 天数据") + print("=" * 60) + + df = fetch_etf_data(etf_code, days) + + if df is not None and len(df) > 0: + print("\n" + "=" * 60) + print("结果表格:") + print("=" * 60) + print(df.to_string(index=False)) + + # 保存到CSV + output_file = f"{etf_code.replace('.', '_')}_latest_{days}days.csv" + df.to_csv(output_file, index=False, encoding='utf-8-sig') + print(f"\n数据已保存到: {output_file}") + else: + print("\n获取数据失败") + + +if __name__ == "__main__": + main() diff --git a/tests/experiments/__init__.py b/tests/experiments/__init__.py new file mode 100644 index 0000000..cdf9f23 --- /dev/null +++ b/tests/experiments/__init__.py @@ -0,0 +1,2 @@ +# 实验脚本目录 +# 存放策略研究相关的A/B测试、对比实验等脚本 \ No newline at end of file diff --git a/scripts/ab_test_iterations.py b/tests/experiments/ab_test_iterations.py similarity index 100% rename from scripts/ab_test_iterations.py rename to tests/experiments/ab_test_iterations.py diff --git a/tests/experiments/ab_test_ndx_vs_spx.py b/tests/experiments/ab_test_ndx_vs_spx.py new file mode 100644 index 0000000..b8d2cc2 --- /dev/null +++ b/tests/experiments/ab_test_ndx_vs_spx.py @@ -0,0 +1,187 @@ +""" +A/B测试:纳指100 vs 标普500 替换对比 +对比: +- A组(对照组):纳指100作为美股大类代表 +- B组(实验组):标普500替换纳指100作为美股大类代表 + +核心问题:替换后对策略绩效的影响(无类内竞争) +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from strategies.rotation.engine import RotationStrategy +import pandas as pd +import yaml + + +def create_config_replace_ndx_with_spx(base_config: dict) -> dict: + """将纳指100替换为标普500""" + config = base_config.copy() + config['code_list'] = base_config['code_list'].copy() + + # 移除纳指100 + if 'NDX' in config['code_list']: + del config['code_list']['NDX'] + + # 添加标普500(替换纳指100) + config['code_list']['SPX'] = { + 'name': '标普500', + 'etf': '513500.SH', + 'market': 'US' + } + + return config + + +def run_backtest(config: dict, label: str) -> dict: + """运行回测并返回关键指标""" + print(f"\n{'='*60}") + print(f" {label}") + print(f"{'='*60}") + + strategy = RotationStrategy(config) + result = strategy.run() + + if result is None or len(result) == 0: + return None + + # 计算指标 + strategy_nav = result['轮动策略净值'] + strategy_ret = result['轮动策略日收益率'] + + total_return = strategy_nav.iloc[-1] - 1 + days = len(result) + years = days / 250 + cagr = (strategy_nav.iloc[-1] ** (1/years)) - 1 + excess_ret = strategy_ret.mean() * 250 + vol = strategy_ret.std() * (250 ** 0.5) + sharpe = excess_ret / vol if vol > 0 else 0 + rolling_max = strategy_nav.cummax() + drawdown = (strategy_nav - rolling_max) / rolling_max + max_dd = drawdown.min() + calmar = cagr / abs(max_dd) if max_dd < 0 else 0 + win_rate = (strategy_ret > 0).sum() / len(strategy_ret) + + metrics = { + 'label': label, + '美股标的': '纳指100' if 'NDX' in config['code_list'] else '标普500', + '累计收益': total_return, + 'CAGR': cagr, + 'Sharpe': sharpe, + 'MaxDD': max_dd, + 'Calmar': calmar, + '日胜率': win_rate, + } + + print(f"\n美股代表: {metrics['美股标的']}") + print(f"累计收益: {metrics['累计收益']:.2%}") + print(f"CAGR: {metrics['CAGR']:.2%}") + print(f"Sharpe: {metrics['Sharpe']:.2f}") + print(f"MaxDD: {metrics['MaxDD']:.2%}") + print(f"Calmar: {metrics['Calmar']:.2f}") + print(f"日胜率: {metrics['日胜率']:.2%}") + + return metrics + + +def compare_results(a_metrics: dict, b_metrics: dict): + """对比两组结果""" + print(f"\n{'='*60}") + print(f" 对比结果") + print(f"{'='*60}") + + print(f"\n{'指标':<15} {'A组(纳指100)':<15} {'B组(标普500)':<15} {'差异':<15}") + print("-" * 60) + + metrics_keys = ['美股标的', '累计收益', 'CAGR', 'Sharpe', 'MaxDD', 'Calmar', '日胜率'] + + for key in metrics_keys: + a_val = a_metrics.get(key, 0) + b_val = b_metrics.get(key, 0) + + if key == '美股标的': + print(f"{key:<15} {a_val:<15} {b_val:<15} {'替换':<15}") + continue + + diff = b_val - a_val + if key in ['累计收益', 'CAGR', 'MaxDD', '日胜率']: + a_str = f"{a_val:.2%}" + b_str = f"{b_val:.2%}" + diff_str = f"{diff*100:+.2f}%" + else: + a_str = f"{a_val:.2f}" + b_str = f"{b_val:.2f}" + diff_str = f"{diff:+.2f}" + + print(f"{key:<15} {a_str:<15} {b_str:<15} {diff_str:<15}") + + print("-" * 60) + + print(f"\n【关键发现】") + print(f"纳指100 → 标普500 替换效果:") + + if b_metrics['CAGR'] < a_metrics['CAGR']: + print(f" - CAGR下降 {a_metrics['CAGR'] - b_metrics['CAGR']:.2%}") + print(f" → 标普500动量信号可能不如纳指强") + + if b_metrics['MaxDD'] > a_metrics['MaxDD']: # 注意MaxDD是负数 + print(f" - MaxDD改善 {b_metrics['MaxDD'] - a_metrics['MaxDD']:.2%}") + print(f" → 标普500更稳定,回撤更小") + + if b_metrics['Sharpe'] > a_metrics['Sharpe']: + print(f" - Sharpe改善 {b_metrics['Sharpe'] - a_metrics['Sharpe']:.2f}") + print(f" → 标普500风险调整后收益更优") + elif b_metrics['Sharpe'] < a_metrics['Sharpe']: + print(f" - Sharpe下降 {b_metrics['Sharpe'] - a_metrics['Sharpe']:.2f}") + print(f" → 纳指100风险调整后收益更优") + + print(f"\n【策略建议】") + if b_metrics['累计收益'] < a_metrics['累计收益'] * 0.9: + print(f" 建议:保持纳指100(成长风格更适合动量策略)") + elif b_metrics['Sharpe'] > a_metrics['Sharpe']: + print(f" 建议:考虑标普500(更稳定、风险调整收益更优)") + else: + print(f" 建议:保持纳指100(累计收益更高)") + + +def main(): + """主函数""" + # 加载基础配置 + config_path = Path(__file__).parent.parent / 'config' / 'strategies' / 'rotation.yaml' + with open(config_path, 'r') as f: + base_config = yaml.safe_load(f) + + # 添加 end_date + from datetime import datetime + base_config['end_date'] = datetime.now().strftime('%Y-%m-%d') + + print(f"\n{'='*60}") + print(f" A/B测试:纳指100 vs 标普500 替换对比") + print(f"{'='*60}") + print(f"\n研究问题:") + print(f" - 将美股大类代表从纳指100替换为标普500") + print(f" - 无类内竞争(每大类还是1只)") + print(f" - 评估标的特性变化对绩效的影响") + + # A组:纳指100(当前配置) + a_metrics = run_backtest(base_config, "A组: 纳指100作为美股代表") + + # B组:标普500替换纳指100 + config_replace = create_config_replace_ndx_with_spx(base_config) + b_metrics = run_backtest(config_replace, "B组: 标普500替换纳指100") + + # 对比 + if a_metrics and b_metrics: + compare_results(a_metrics, b_metrics) + + # 保存结果 + results_df = pd.DataFrame([a_metrics, b_metrics]) + results_path = Path(__file__).parent.parent / 'results' / 'ab_test_ndx_vs_spx.csv' + results_df.to_csv(results_path, index=False) + print(f"\n对比结果已保存: {results_path}") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/tests/experiments/ab_test_spx.py b/tests/experiments/ab_test_spx.py new file mode 100644 index 0000000..7e0e3b4 --- /dev/null +++ b/tests/experiments/ab_test_spx.py @@ -0,0 +1,183 @@ +""" +A/B测试:添加标普500对轮动策略的影响 +对比: +- A组(对照组):当前11只标的配置 +- B组(实验组):添加标普500后的12只标的配置 +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from strategies.rotation.engine import RotationStrategy +import pandas as pd + + +def create_config_with_spx(base_config: dict) -> dict: + """在基础配置上添加标普500""" + config = base_config.copy() + config['code_list'] = base_config['code_list'].copy() + + # 添加标普500(美股大类内) + config['code_list']['SPX'] = { + 'name': '标普500', + 'etf': '513500.SH', + 'market': 'US' # 与纳指100同属美股大类 + } + + return config + + +def run_backtest(config: dict, label: str) -> dict: + """运行回测并返回关键指标""" + print(f"\n{'='*60}") + print(f" {label}") + print(f"{'='*60}") + + strategy = RotationStrategy(config) + result = strategy.run() # result 是 DataFrame + + if result is None or len(result) == 0: + return None + + # 从 DataFrame 中直接计算指标 + strategy_nav = result['轮动策略净值'] + strategy_ret = result['轮动策略日收益率'] + benchmark_nav = result['基准净值'] + benchmark_ret = result['基准日收益率'] + + # 累计收益 + total_return = strategy_nav.iloc[-1] - 1 + + # CAGR (交易日口径) + days = len(result) + years = days / 250 + cagr = (strategy_nav.iloc[-1] ** (1/years)) - 1 + + # Sharpe + excess_ret = strategy_ret.mean() * 250 # 年化收益 + vol = strategy_ret.std() * (250 ** 0.5) # 年化波动 + sharpe = excess_ret / vol if vol > 0 else 0 + + # 最大回撤 + rolling_max = strategy_nav.cummax() + drawdown = (strategy_nav - rolling_max) / rolling_max + max_dd = drawdown.min() + + # Calmar + calmar = cagr / abs(max_dd) if max_dd < 0 else 0 + + # 日胜率 + win_rate = (strategy_ret > 0).sum() / len(strategy_ret) + + # 提取关键指标 + metrics = { + 'label': label, + '标的数': len(config['code_list']), + '累计收益': total_return, + 'CAGR': cagr, + 'Sharpe': sharpe, + 'MaxDD': max_dd, + 'Calmar': calmar, + '日胜率': win_rate, + } + + print(f"\n标的池: {len(config['code_list'])}只") + print(f"累计收益: {metrics['累计收益']:.2%}") + print(f"CAGR: {metrics['CAGR']:.2%}") + print(f"Sharpe: {metrics['Sharpe']:.2f}") + print(f"MaxDD: {metrics['MaxDD']:.2%}") + print(f"Calmar: {metrics['Calmar']:.2f}") + print(f"日胜率: {metrics['日胜率']:.2%}") + + return metrics + + +def compare_results(a_metrics: dict, b_metrics: dict): + """对比两组结果""" + print(f"\n{'='*60}") + print(f" 对比结果") + print(f"{'='*60}") + + print(f"\n{'指标':<12} {'A组(无SPX)':<15} {'B组(有SPX)':<15} {'差异':<15}") + print("-" * 60) + + metrics_keys = ['标的数', '累计收益', 'CAGR', 'Sharpe', 'MaxDD', 'Calmar', '日胜率'] + + for key in metrics_keys: + a_val = a_metrics.get(key, 0) + b_val = b_metrics.get(key, 0) + + if key == '标的数': + diff = b_val - a_val + diff_str = f"+{diff}" if diff > 0 else str(diff) + else: + diff = b_val - a_val + if key in ['累计收益', 'CAGR', 'MaxDD', '日胜率']: + diff_str = f"{diff*100:+.2f}%" + else: + diff_str = f"{diff:+.2f}" + + if key in ['累计收益', 'CAGR', 'MaxDD', '日胜率']: + a_str = f"{a_val:.2%}" + b_str = f"{b_val:.2%}" + else: + a_str = str(a_val) + b_str = str(b_val) + + print(f"{key:<12} {a_str:<15} {b_str:<15} {diff_str:<15}") + + print("-" * 60) + + # 分析美股大类内部切换情况 + print(f"\n【关键发现】") + print(f"添加标普500后:") + print(f" - 美股大类从1只→2只(纳指100 + 标普500)") + print(f" - 类内竞争:纳指100 vs 标普500,得分高者代表美股大类") + print(f" - 跨类分散不变:美股大类还是只输出1只冠军进入Top3") + + if b_metrics['累计收益'] != a_metrics['累计收益']: + print(f" - 累计收益变化:{a_metrics['累计收益']:.2%} → {b_metrics['累计收益']:.2%}") + + +def main(): + """主函数""" + import yaml + + # 加载基础配置 + config_path = Path(__file__).parent.parent / 'config' / 'strategies' / 'rotation.yaml' + with open(config_path, 'r') as f: + base_config = yaml.safe_load(f) + + # 添加缺失的 end_date(使用今天日期) + from datetime import datetime + base_config['end_date'] = datetime.now().strftime('%Y-%m-%d') + + print(f"\n{'='*60}") + print(f" A/B测试:添加标普500对diversified模式的影响") + print(f"{'='*60}") + print(f"\n测试假设:") + print(f" - diversified=true 模式下,每大类只选1只冠军") + print(f" - 添加标普500(同属美股大类)不会增加跨类分散") + print(f" - 但可能增加类内切换频率和换手率") + + # A组:当前配置(11只,无标普500) + a_metrics = run_backtest(base_config, "A组: 当前配置(11只,无标普500)") + + # B组:添加标普500后的配置(12只) + config_with_spx = create_config_with_spx(base_config) + b_metrics = run_backtest(config_with_spx, "B组: 添加标普500(12只)") + + # 对比结果 + if a_metrics and b_metrics: + compare_results(a_metrics, b_metrics) + + # 保存对比结果 + results_df = pd.DataFrame([a_metrics, b_metrics]) + results_path = Path(__file__).parent.parent / 'results' / 'ab_test_spx.csv' + results_df.to_csv(results_path, index=False) + print(f"\n对比结果已保存: {results_path}") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/scripts/analyze_negative_scores.py b/tests/experiments/analyze_negative_scores.py similarity index 100% rename from scripts/analyze_negative_scores.py rename to tests/experiments/analyze_negative_scores.py diff --git a/scripts/full_pool_top3_backtest.py b/tests/experiments/full_pool_top3_backtest.py similarity index 100% rename from scripts/full_pool_top3_backtest.py rename to tests/experiments/full_pool_top3_backtest.py diff --git a/scripts/momentum_experiment.py b/tests/experiments/momentum_experiment.py similarity index 100% rename from scripts/momentum_experiment.py rename to tests/experiments/momentum_experiment.py diff --git a/scripts/test_select_num.py b/tests/experiments/test_select_num.py similarity index 100% rename from scripts/test_select_num.py rename to tests/experiments/test_select_num.py diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..8c48c55 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,2 @@ +# 工具脚本目录 +# 存放数据构建、缓存、导出等辅助工具脚本 \ No newline at end of file diff --git a/scripts/build_etf_universe.py b/tests/utils/build_etf_universe.py similarity index 100% rename from scripts/build_etf_universe.py rename to tests/utils/build_etf_universe.py diff --git a/scripts/etf_data_cache.py b/tests/utils/etf_data_cache.py similarity index 100% rename from scripts/etf_data_cache.py rename to tests/utils/etf_data_cache.py diff --git a/scripts/export_rotation_data.py b/tests/utils/export_rotation_data.py similarity index 100% rename from scripts/export_rotation_data.py rename to tests/utils/export_rotation_data.py