Files
etf/tests/experiments/ab_test_france_in_eu.py
aszerW 9776ae7de0 test(experiments): add France CAC40 and SEA ETF experiments
- Add France CAC40 market test (004)
- Add SEA ETF limited test (005)
- Add France in EU category test (006)
- Update experiment README with new results
- Modify emerging market test description
2026-05-06 22:23:12 +08:00

218 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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()