diff --git a/docs/experiments/004_france_cac40_test.md b/docs/experiments/004_france_cac40_test.md new file mode 100644 index 0000000..0efd334 --- /dev/null +++ b/docs/experiments/004_france_cac40_test.md @@ -0,0 +1,182 @@ +# 实验记录 004: 法国CAC40市场大类添加影响 + +## 实验信息 + +| 项目 | 内容 | +|------|------| +| 实验编号 | 004 | +| 实验日期 | 2026-05-06 | +| 实验类型 | A/B对比测试(新大类添加) | +| 矧究问题 | 添加法国CAC40作为欧洲新大类对策略绩效的影响 | + +--- + +## 1. 实验背景 + +### 与初衷:欧洲内部分散 + +当前配置已有德国DAX作为欧洲代表: +``` +添加法国CAC40(奢侈品风格)作为新大类: +├─ 目的:验证欧洲内部分散是否有效 +└─ 与德国工业风格形成互补 +``` + +### 德国 vs 法国对比 + +| 维度 | 德国DAX | 法国CAC40 | +|-----|---------|---------| +| **成分股风格** | 工业制造(汽车、机械) | 奢侈品(LV, 欧莱雅) | +| **权重行业** | 西门子、奔驰、宝马 | LVMH 欧莱雅 达尔 | +| **波动特性** | 工业周期性明显 | 奢侈品相对平缓 | +| **动量适应性** | ✓ 高(波动大) | ? 中(波动平缓) | + +--- + +## 2. 实验设计 + +### A/B组配置 + +| 组别 | 欧洲配置 | 其他大类 | +|------|---------|---------| +| **A组(对照组)** | 仅德国DAX (513030.SH) | A股2、港股2美股、日本、 商品、 固收 | +| **B组(实验组)** | 德国DAX + 法国CAC40 (513080.SH) | 同A组 | + +### 技术细节 + +``` +指数代码: ^FCHI(YFinance法国CAC40指数) +ETF代码: 513080.SH(华安法国ETF) +大类标记: FR(法国, 独立于德国的EU大类) +``` + +--- + +## 3. 回测结果 + +### 绩效对比 + +| 指标 | A组(仅德国) | B组(有法国) | 差异 | +|------|-------------|-------------|------| +| **大类数量** | 7 | 8 | **+1** ✓ | +| **累计收益** | **1467.35%** | 1300.55% | **-166.80%** | +| **CAGR** | **48.10%** | 45.74% | **-2.36%** | +| **Sharpe** | **2.21** | 2.15 | **-0.06** | +| MaxDD | -17.33% | -17.33% | +0.00% | +| Calmar | 2.78 | 2.64 | -0.14 | +| **日胜率** | **56.45%** | 54.17% | **-2.28%** | +| 调仓次数 | 459 | 463 | +4 | + +--- + +## 4. 关键发现 + +### 发现1:大类数量增加但收益下降 + +``` +跨类分散提升: +├─ 大类从7→8(+1) +└─ 但累计收益下降166.80% +``` + +### 发现2:法国动量信号不如德国 + +``` +德国工业风格更适合动量策略: +├─ 工业周期波动大 → 动量信号明显 +├─ 奢侈品波动平缓 → 动量信号较弱 +└─ 法国被选入Top3后 表现不如德国 +``` + +### 发现3:Sharpe和日胜率双降 + +``` +风险调整指标全面下降: +├─ Sharpe: 2.21 → 2.15 (-0.06) +├─ 日胜率: 56.45% → 54.17% (-2.28%) +└─ 跻加法国降低整体风险收益比质量 +``` + +### 发现4:调仓次数略增 + +``` +调仓增加4次: +├─ 可能增加欧洲内切换(德国↔法国) +└─ 刯换成本略微增加 +``` + +--- + +## 5. 实验结论 + +### 核心结论 + +| 儒设 | 实证结果 | +|-----|---------| +| 新大类增加跨类分散 | ✓ **验证通过** | +| 新大类提升收益 | ✗ **验证失败**(收益下降167%) | +| 奢侈品风格适合动量 | ✗ **验证失败**(不如工业) | + +### 重要洞察 + +``` +添加新大类 ≠ 必然提升收益 + +关键因素: +├─ 标的本身表现能力(动量信号强度) +├─ 标的风格与策略的匹配度 +└─ 德国工业 > 法国奢侈品(对动量策略) +``` + +### 策略建议 + +``` +建议:暂不添加法国CAC40 + +原因: +1. 收益下降167%,Sharpe下降0006 +2. 奢侈品风格不如工业风格适合动量策略 +3. 日胜率下降2.28%,调仓增加4次 +4. 保持德国DAX作为欧洲代表 + +德国 vs 法国: +├─ 德国DAX: 工业周期明显 → 动量信号强 ✓ +├─ 法国CAC 奢侈品波动平缓 → 动量信号弱 ✗ +└─ 工业风格更适合动量策略 +``` + +--- + +## 6. 与其他实验对比 + +| 实验 | 操作类型 | 大类变化 | 收益变化 | +|------|---------|---------|---------| +| 001 | 同大类添加标普500 | 0 | -291% | +| 002 | 纳指替换标普500 | 0 | -348% | +| 003 | 新大类添加印度 | +1 | -205% | +| 004 | 新大类添加法国 | +1 | **-167%** | + +**统一结论**:标的质量比大类数量更重要 + +--- + +## 7. 相关文件 + +| 文件 | 说明 | +|-----|------| +| `tests/experiments/ab_test_france.py` | 法国市场A/B测试脚本 | +| `tests/experiments/ab_test_france_in_eu.py` | 法国EU大类内测试脚本 | +| `results/ab_test_france.csv` | 测试结果数据 | + +--- + +## 8. 后续研究方向 + +1. 测试其他欧洲标的(如英国富时100) +2. 测试沙特ETF(520830.SH) 作为中东新大类 +3. 線德国 vs 法国不同市场周期的表现差异(牛市/熊市分别测试) + +--- + +*实验记录版本: v1.0* +*最后更新: 2026-05-06* \ No newline at end of file diff --git a/docs/experiments/README.md b/docs/experiments/README.md index 74ac1a6..bdf479d 100644 --- a/docs/experiments/README.md +++ b/docs/experiments/README.md @@ -11,6 +11,9 @@ | [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),成长风格更适合动量 | | [003](003_emerging_market_india.md) | 添加新兴市场大类(印度) | 2026-05-06 | A/B测试 | 新大类≠必然提升收益,标的本身表现能力更重要(收益-205%) | +| [004](004_france_cac40_test.md) | 法国CAC40市场大类添加 | 2026-05-06 | A/B测试 | 奢侈品风格不适合动量策略,德国工业优于法国(收益-167%) | +| [005](005_sea_etf_limited_test.md) | 东南亚科技ETF受限测试 | 2026-05-06 | A/B测试 | 测试失败:数据源逻辑限制 + QDII-ETF不能作为指数代码 | +| [006](006_france_in_eu_category.md) | 法国放入EU大类(类内竞争) | 2026-05-06 | A/B测试 | 类内竞争比新大类损失更小(-149% vs -167%),但仍选德国DAX | --- diff --git a/tests/experiments/ab_test_emerging_market.py b/tests/experiments/ab_test_emerging_market.py index 8a2192f..5dd9980 100644 --- a/tests/experiments/ab_test_emerging_market.py +++ b/tests/experiments/ab_test_emerging_market.py @@ -2,7 +2,7 @@ A/B测试:添加新兴市场大类的影响 对比: - A组(对照组):当前配置(无新兴市场) -- B组(实验组):添加印度作为新兴市场大类 +- B组(实验组):添加印度/法国作为新兴/欧洲市场大类 核心问题:添加新大类是否增加跨类分散、提升绩效 """ diff --git a/tests/experiments/ab_test_france.py b/tests/experiments/ab_test_france.py new file mode 100644 index 0000000..94922b9 --- /dev/null +++ b/tests/experiments/ab_test_france.py @@ -0,0 +1,200 @@ +""" +A/B测试:添加法国CAC40市场大类的影响 +对比: +- A组(对照组):当前配置(无法国) +- B组(实验组):添加法国CAC40作为新大类 + +核心问题:法国市场是否能有效补充欧洲分散 +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from strategies.rotation.engine import RotationStrategy +import pandas as pd +import yaml + + +def create_config_with_france(base_config: dict) -> dict: + """在基础配置上添加法国市场""" + config = base_config.copy() + config['code_list'] = base_config['code_list'].copy() + + # 添加法国CAC40(新大类) + # 当前已有德国DAX(EU),法国可以增加欧洲内部的多样性 + # 注意:德国和法国同属EU大类,但这是不同的指数 + config['code_list']['^FCHI'] = { + 'name': '法国CAC40', + 'etf': '513080.SH', # 法国ETF华安 + 'market': 'FR' # 法国大类(独立于德国) + } + + 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 if years > 0 else 0 + 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) + + # 统计大类数量 + markets = set() + for code_info in config['code_list'].values(): + markets.add(code_info.get('market', 'A')) + + metrics = { + 'label': label, + '大类数量': len(markets), + '累计收益': 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组(无法国)':<15} {'B组(有法国)':<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) + + 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}%" + elif key == '大类数量': + a_str = str(a_val) + b_str = str(b_val) + diff_str = f"+{diff}" if diff > 0 else str(diff) + 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"添加法国CAC40大类效果:") + + if b_metrics['大类数量'] > a_metrics['大类数量']: + print(f" ✓ 大类数量增加 {b_metrics['大类数量'] - a_metrics['大类数量']}") + + if b_metrics['累计收益'] > a_metrics['累计收益']: + print(f" ✓ 累计收益提升 {b_metrics['累计收益'] - a_metrics['累计收益']:.2%}") + print(f" → 法国市场确实带来收益增益") + elif b_metrics['累计收益'] < a_metrics['累计收益']: + print(f" ✗ 累计收益下降 {a_metrics['累计收益'] - b_metrics['累计收益']:.2%}") + print(f" → 法国动量信号可能不如德国强") + + if b_metrics['Sharpe'] > a_metrics['Sharpe']: + print(f" ✓ Sharpe改善 {b_metrics['Sharpe'] - a_metrics['Sharpe']:.2f}") + else: + print(f" ✗ Sharpe下降 {a_metrics['Sharpe'] - b_metrics['Sharpe']:.2f}") + + print(f"\n【与德国DAX对比分析】") + print(f" 当前配置:德国DAX (513030.SH) → EU大类") + print(f" 新增配置:法国CAC40 (513080.SH) → FR大类") + print(f" ") + print(f" 德国 vs 法国特点:") + print(f" ├─ 德国DAX:工业权重高(汽车、机械)") + print(f" ├─ 法国CAC:奢侈品权重高(LV、欧莱雅)") + print(f" └─ 两者风格不同,可能互补") + + print(f"\n【策略建议】") + if b_metrics['累计收益'] > a_metrics['累计收益'] and b_metrics['Sharpe'] >= a_metrics['Sharpe'] * 0.95: + print(f" 建议:添加法国CAC40(欧洲分散有效)") + elif b_metrics['累计收益'] < a_metrics['累计收益'] * 0.95: + print(f" 建议:暂不添加法国(收益损失较大)") + print(f" 原因:法国动量信号可能不如德国DAX强") + else: + print(f" 建议:保持观察,欧洲分散效果有限") + + +def main(): + """主函数""" + config_path = Path(__file__).parent.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测试:添加法国CAC40市场大类") + print(f"{'='*60}") + print(f"\n研究问题:") + print(f" - 添加法国CAC40作为新大类(FR)") + print(f" - 与德国DAX(EU)形成欧洲内部分散") + print(f" - 德国工业风格 vs 法国奢侈品风格") + print(f" - 验证欧洲分散是否有效") + + # A组:当前配置 + a_metrics = run_backtest(base_config, "A组: 当前配置(仅德国DAX)") + + # B组:添加法国CAC40 + config_with_france = create_config_with_france(base_config) + b_metrics = run_backtest(config_with_france, "B组: 添加法国CAC40") + + # 对比 + 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.parent / 'results' / 'ab_test_france.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_france_in_eu.py b/tests/experiments/ab_test_france_in_eu.py new file mode 100644 index 0000000..cfb7baf --- /dev/null +++ b/tests/experiments/ab_test_france_in_eu.py @@ -0,0 +1,218 @@ +""" +A/B测试:法国CAC40与德国DAX类内竞争对比 +对比: +- A组(对照组):仅德国DAX代表欧洲(EU) +- B组(实验组):德国DAX + 法国CAC40 同属EU大类(类内竞争) + +核心问题: +- 法国放在欧洲大类下(market='EU'),与德国竞争 +- diversified模式下,EU大类只输出1个冠军 +- 验证:类内竞争选择最优,而非盲目增加大类 + +对比003/004实验: +- 003/004:法国/印度作为独立大类 → 收益下降 +- 本次:法国放EU大类内 → 类内竞争,不增加大类数量 +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from strategies.rotation.engine import RotationStrategy +import pandas as pd +import yaml + + +def create_config_with_france_in_eu(base_config: dict) -> dict: + """在基础配置上添加法国,但放入EU大类""" + config = base_config.copy() + config['code_list'] = base_config['code_list'].copy() + + # 添加法国CAC40,但market='EU'(与德国DAX同大类) + # diversified模式下:德国 vs 法国 → 选动量最强的1个进入Top3 + config['code_list']['^FCHI'] = { + 'name': '法国CAC40', + 'etf': '513080.SH', + 'market': 'EU' # 与德国DAX同属欧洲大类 + } + + 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 if years > 0 else 0 + 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) + + # 统计大类数量 + markets = set() + for code_info in config['code_list'].values(): + markets.add(code_info.get('market', 'A')) + + # 统计EU大类标的数量 + eu_count = sum(1 for c in config['code_list'].values() if c.get('market') == 'EU') + + metrics = { + 'label': label, + '大类数量': len(markets), + 'EU大类标的数': eu_count, + '累计收益': total_return, + 'CAGR': cagr, + 'Sharpe': sharpe, + 'MaxDD': max_dd, + 'Calmar': calmar, + '日胜率': win_rate, + } + + print(f"\n大类数量: {metrics['大类数量']}") + print(f"EU大类标的数: {metrics['EU大类标的数']}") + 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组(仅德国)':<15} {'B组(德国+法国)':<15} {'差异':<15}") + print("-" * 60) + + metrics_keys = ['大类数量', 'EU大类标的数', '累计收益', 'CAGR', 'Sharpe', 'MaxDD', 'Calmar', '日胜率'] + + for key in metrics_keys: + a_val = a_metrics.get(key, 0) + b_val = b_metrics.get(key, 0) + + 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}%" + elif key in ['大类数量', 'EU大类标的数']: + a_str = str(a_val) + b_str = str(b_val) + diff_str = f"+{diff}" if diff > 0 else str(diff) + 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"法国放入EU大类(与德国类内竞争):") + + if b_metrics['大类数量'] == a_metrics['大类数量']: + print(f" ✓ 大类数量不变({b_metrics['大类数量']}大类)") + print(f" → 不增加跨类分散,避免占用Top3权重") + + if b_metrics['EU大类标的数'] > a_metrics['EU大类标的数']: + print(f" ✓ EU大类标的增加 {b_metrics['EU大类标的数'] - a_metrics['EU大类标的数']}") + print(f" → 德国 vs 法国 类内竞争,选动量最强") + + if b_metrics['累计收益'] > a_metrics['累计收益']: + print(f" ✓ 累计收益提升 {b_metrics['累计收益'] - a_metrics['累计收益']:.2%}") + print(f" → 类内竞争选择最优,比新大类添加更有效") + elif b_metrics['累计收益'] < a_metrics['累计收益']: + loss = a_metrics['累计收益'] - b_metrics['累计收益'] + print(f" ✗ 累计收益下降 {loss:.2%}") + if loss < 0.05: # 下降5%以内 + print(f" → 收益损失较小(vs 004实验下降167%)") + else: + print(f" → 法国动量信号不如德国,类内竞争仍选德国") + + print(f"\n【与004实验对比】") + print(f" 004实验(法国作为独立FR大类):") + print(f" ├─ 大类数量: 7 → 8 (+1)") + print(f" └─ 累计收益: -166.80%") + print(f" ") + print(f" 本次实验(法国放入EU大类):") + print(f" ├─ 大类数量: 保持{b_metrics['大类数量']}不变") + print(f" ├─ EU类内竞争:德国DAX vs 法国CAC40") + print(f" └─ 累计收益变化: {b_metrics['累计收益'] - a_metrics['累计收益']:.2%}") + + print(f"\n【策略建议】") + if b_metrics['累计收益'] >= a_metrics['累计收益'] * 0.98: + print(f" 建议:法国放入EU大类可行") + print(f" 原因:类内竞争自动选最优,不增加大类数量") + elif abs(b_metrics['累计收益'] - a_metrics['累计收益']) < 0.05: + print(f" 建议:法国放入EU大类可考虑") + print(f" 原因:收益损失小于5%,可接受") + else: + print(f" 建议:保持仅德国DAX") + print(f" 原因:法国动量信号不如德国") + + +def main(): + """主函数""" + config_path = Path(__file__).parent.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测试:法国CAC40放入EU大类(类内竞争)") + print(f"{'='*60}") + print(f"\n研究问题:") + print(f" - 法国CAC40放入EU大类(market='EU')") + print(f" - 与德国DAX类内竞争,选动量最强的1个") + print(f" - 不增加大类数量,避免占用Top3权重") + print(f" - 对比004实验(法国独立大类)的差异") + + # A组:仅德国DAX + a_metrics = run_backtest(base_config, "A组: 仅德国DAX代表欧洲") + + # B组:德国+法国(同属EU大类) + config_with_france = create_config_with_france_in_eu(base_config) + b_metrics = run_backtest(config_with_france, "B组: 德国+法国(EU类内竞争)") + + # 对比 + 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.parent / 'results' / 'ab_test_france_in_eu.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_sea_etf.py b/tests/experiments/ab_test_sea_etf.py new file mode 100644 index 0000000..eea7fba --- /dev/null +++ b/tests/experiments/ab_test_sea_etf.py @@ -0,0 +1,208 @@ +""" +A/B测试:添加东南亚科技ETF的影响(受限测试) +对比: +- A组(对照组):当前配置(无东南亚) +- B组(实验组):添加东南亚科技ETF + +限制说明: +- 东南亚科技ETF(513730.SH)2023年12月上市,数据仅约2年 +- 新交所泛东南亚科技指数在YFinance中暂无数据 +- 本次测试使用ETF价格作为信号源(非最佳实践,仅作参考) +- 回测时间范围将被缩短 + +核心问题:新兴市场ETF流动性是否优于印度LOF +""" + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from strategies.rotation.engine import RotationStrategy +import pandas as pd +import yaml + + +def create_config_with_sea(base_config: dict) -> dict: + """在基础配置上添加东南亚科技""" + config = base_config.copy() + config['code_list'] = base_config['code_list'].copy() + + # 添加东南亚科技(新大类) + # 注意:由于指数数据不可用,使用ETF价格作为信号源 + # 513730.SH 同时作为指数代码和ETF代码 + config['code_list']['513730.SH'] = { + 'name': '东南亚科技', + 'etf': '513730.SH', # 华泰柏瑞东南亚科技ETF + 'market': 'SEA' # 东南亚大类 + } + + 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 if years > 0 else 0 + 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) + + # 统计大类数量 + markets = set() + for code_info in config['code_list'].values(): + markets.add(code_info.get('market', 'A')) + + metrics = { + 'label': label, + '大类数量': len(markets), + '回测天数': days, + '回测年数': years, + '累计收益': total_return, + 'CAGR': cagr, + 'Sharpe': sharpe, + 'MaxDD': max_dd, + 'Calmar': calmar, + '日胜率': win_rate, + } + + print(f"\n大类数量: {metrics['大类数量']}") + print(f"回测天数: {metrics['回测天数']}") + print(f"回测年数: {metrics['回测年数']:.2f}") + 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组(无东南亚)':<15} {'B组(有东南亚)':<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) + + 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}%" + elif key in ['大类数量', '回测天数']: + a_str = str(int(a_val)) + b_str = str(int(b_val)) + diff_str = f"+{int(diff)}" if diff > 0 else str(int(diff)) + 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" ⚠ 本次测试数据量受限(东南亚ETF仅2年数据)") + print(f" ⚠ 使用ETF价格作为信号源(指数数据暂不可用)") + print(f" ⚠ 结果仅供参考,不建议直接用于决策") + + print(f"\n【关键发现】") + + if b_metrics['大类数量'] > a_metrics['大类数量']: + print(f" ✓ 大类数量增加 {b_metrics['大类数量'] - a_metrics['大类数量']}") + + if b_metrics['累计收益'] > a_metrics['累计收益']: + print(f" ✓ 累计收益提升 {b_metrics['累计收益'] - a_metrics['累计收益']:.2%}") + else: + print(f" ✗ 累计收益下降 {a_metrics['累计收益'] - b_metrics['累计收益']:.2%}") + + if b_metrics['Sharpe'] > a_metrics['Sharpe']: + print(f" ✓ Sharpe改善 {b_metrics['Sharpe'] - a_metrics['Sharpe']:.2f}") + else: + print(f" ✗ Sharpe下降 {a_metrics['Sharpe'] - b_metrics['Sharpe']:.2f}") + + print(f"\n【策略建议】") + print(f" 建议:等待东南亚科技ETF积累更多数据后再测试") + print(f" 原因:") + print(f" 1. 数据量不足(仅{b_metrics['回测年数']:.1f}年)") + print(f" 2. 指数信号源暂不可用") + print(f" 3. ETF价格作为信号源存在溢价干扰") + + +def main(): + """主函数""" + config_path = Path(__file__).parent.parent.parent / 'config' / 'strategies' / 'rotation.yaml' + with open(config_path, 'r') as f: + base_config = yaml.safe_load(f) + + # 设置回测结束日期 + from datetime import datetime + base_config['end_date'] = datetime.now().strftime('%Y-%m-%d') + + # ⚠ 重要:由于东南亚ETF数据从2023年12月开始 + # 需要调整start_date以匹配数据可用范围 + # 本次测试将使用较短的时间窗口 + + print(f"\n{'='*60}") + print(f" A/B测试:添加东南亚科技ETF(受限测试)") + print(f"{'='*60}") + print(f"\n⚠ 限制说明:") + print(f" - 东南亚科技ETF(513730.SH)2023年12月上市") + print(f" - 数据仅约2年,回测时间范围受限") + print(f" - 指数数据暂不可用,使用ETF价格作为信号源") + print(f" - 结果仅供参考,不建议直接用于决策") + + # A组:当前配置(使用较短时间窗口) + config_a = base_config.copy() + config_a['start_date'] = '2024-01-01' # 调整为东南亚ETF有数据的起始时间 + a_metrics = run_backtest(config_a, "A组: 当前配置(2024年起)") + + # B组:添加东南亚科技 + config_b = create_config_with_sea(base_config) + config_b['start_date'] = '2024-01-01' + b_metrics = run_backtest(config_b, "B组: 添加东南亚科技") + + # 对比 + 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.parent / 'results' / 'ab_test_sea_etf.csv' + results_df.to_csv(results_path, index=False) + print(f"\n对比结果已保存: {results_path}") + + +if __name__ == '__main__': + main() \ No newline at end of file