fix: 回测细节导出、交易日历测试和动量因子修复

修复项:
- export_backtest_detail.py: 统一回测导出脚本的数据源调用逻辑
- test_trading_calendar.py: 交易日历功能测试
- verify_fix_result.py: 修复结果验证
- verify_mode_b.py: 模式 B 验证

策略修复:
- momentum.py: 动量因子计算优化
- strategy.py: StrategyBase 数据获取修复(fetch_indices 返回 dict)
This commit is contained in:
2026-05-24 14:26:35 +08:00
parent 0954458114
commit 5212b004dc
5 changed files with 999 additions and 0 deletions

208
tests/verify_mode_b.py Normal file
View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""
验证 Mode B: 指数信号 → ETF收益
文档预期结果:
CAGR: 28.07%, 最大回撤: -13.34%, 夏普: 1.685, Calmar: 2.104
"""
import sys
from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from dotenv import load_dotenv
load_dotenv()
import pandas as pd
import numpy as np
import yaml
from datetime import datetime
from strategies.rotation.strategy import RotationStrategy
def calculate_metrics(nav: pd.Series) -> dict:
"""计算绩效指标"""
start_date = nav.index[0]
end_date = nav.index[-1]
days = (end_date - start_date).days
years = days / 365
total_return = nav.iloc[-1] - 1
cagr = (nav.iloc[-1] / nav.iloc[0]) ** (1/years) - 1
daily_ret = nav.pct_change().dropna()
sharpe = daily_ret.mean() / daily_ret.std() * np.sqrt(252) if daily_ret.std() > 0 else 0
peak = nav.cummax()
drawdown = (nav - peak) / peak
max_dd = drawdown.min()
calmar = cagr / abs(max_dd) if max_dd != 0 else 0
win_rate = (daily_ret > 0).sum() / len(daily_ret)
return {
'start_date': start_date.strftime('%Y-%m-%d'),
'end_date': end_date.strftime('%Y-%m-%d'),
'years': years,
'days': len(nav),
'total_return': total_return,
'cagr': cagr,
'max_dd': max_dd,
'sharpe': sharpe,
'calmar': calmar,
'win_rate': win_rate
}
def run_mode_b_backtest(data: dict, signals: pd.DataFrame, valid_codes: list,
etf_code_map: dict, a_share_dates: pd.DatetimeIndex,
trade_cost: float, select_num: int) -> dict:
"""
Mode B: 使用ETF价格计算收益
Args:
data: 包含 etf_data 的数据字典
signals: 指数生成的信号
valid_codes: 指数代码列表
etf_code_map: {指数代码: ETF代码} 映射
a_share_dates: A股交易日历
trade_cost: 交易成本
select_num: 选股数量
"""
from framework.execution import BacktestExecutor
etf_data = data.get('etf_data')
if etf_data is None:
print("❌ ETF数据不可用")
return {'result': None}
# 将信号对齐到 A 股日历
if a_share_dates is not signals.index:
signals = signals.reindex(a_share_dates, method='ffill').dropna(subset=[signals.columns[0]])
# 使用ETF收盘价计算收益率
returns_data = {}
for code in valid_codes:
etf_code = etf_code_map.get(code)
if etf_code and etf_code in etf_data.columns:
etf_close = etf_data[etf_code].dropna()
# 对齐到A股日历
etf_aligned = etf_close.reindex(a_share_dates, method='ffill')
returns_aligned = etf_aligned.pct_change(fill_method=None)
# 使用指数代码作为列名(与信号匹配)
returns_data[f'日收益率_{code}'] = returns_aligned
else:
# 没有ETF映射的标的回退使用指数数据
index_data = data.get('index_data', {})
if code in index_data and 'close' in index_data[code].columns:
close_series = index_data[code]['close'].dropna()
close_aligned = close_series.reindex(a_share_dates, method='ffill')
returns_data[f'日收益率_{code}'] = close_aligned.pct_change(fill_method=None)
returns_df = pd.DataFrame(returns_data)
# 对齐日期
common_dates = signals.index.intersection(returns_df.index)
signals = signals.loc[common_dates]
returns_df = returns_df.loc[common_dates]
print(f" Mode B 对齐后日期: {len(common_dates)}")
print(f" 使用ETF计算收益: {len([c for c in valid_codes if etf_code_map.get(c)])}")
executor = BacktestExecutor(
initial_capital=100000,
trade_cost=trade_cost,
select_num=select_num
)
portfolio = executor.execute(signals, returns_df)
if hasattr(portfolio, 'backtest_result'):
return {'result': portfolio.backtest_result, 'portfolio': portfolio}
return {'result': None}
def main():
# 加载配置
config_path = project_root / 'strategies/rotation/config.yaml'
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
# 设置回测区间
config['start_date'] = '2020-01-02'
config['end_date'] = '2026-05-19'
print('='*70)
print('Mode B 验证: 指数信号 → ETF收益')
print('='*70)
# 初始化策略
strategy = RotationStrategy(config)
# 获取数据
print('\n获取数据...')
data = strategy.get_data(use_flask_api=False)
# 计算因子(使用指数数据)
print('\n计算因子(指数信号)...')
factor_df = strategy.compute_factors(data)
# 生成信号
print('\n生成信号...')
signals = strategy.generate_signals(factor_df)
# 执行 Mode B 回测
print('\n执行 Mode B 回测ETF收益...')
result_b = run_mode_b_backtest(
data=data,
signals=signals,
valid_codes=data['valid_codes'],
etf_code_map=data['etf_code_map'],
a_share_dates=data.get('a_share_dates'),
trade_cost=config.get('trade_cost', 0.001),
select_num=config.get('select_num', 3)
)
if result_b.get('result') is None:
print('❌ Mode B 回测未生成结果')
return
# 计算指标
nav_b = result_b['result']['策略净值']
metrics_b = calculate_metrics(nav_b)
# 输出结果
print('\n' + '='*70)
print('Mode B 回测结果')
print('='*70)
print(f"回测区间: {metrics_b['start_date']} ~ {metrics_b['end_date']}")
print(f"回测年数: {metrics_b['years']:.2f}")
print(f"交易天数: {metrics_b['days']}")
print('-'*70)
print(f"CAGR: {metrics_b['cagr']:.2%}")
print(f"最大回撤: {metrics_b['max_dd']:.2%}")
print(f"夏普比率: {metrics_b['sharpe']:.3f}")
print(f"Calmar比率: {metrics_b['calmar']:.3f}")
print(f"日胜率: {metrics_b['win_rate']:.2%}")
print(f"累计收益: {metrics_b['total_return']:.2%}")
print('='*70)
# 文档预期对比
print('\n文档预期 (Mode B):')
print(' CAGR: 28.07%, MaxDD -13.34%, Sharpe 1.685, Calmar 2.104')
cagr_diff = abs(metrics_b['cagr'] - 0.2807)
print(f'\nCAGR差异: {cagr_diff:.2%}')
if cagr_diff < 0.05:
print('✓ 结果与文档预期基本一致')
else:
print('⚠ 结果与文档预期有差异')
return metrics_b
if __name__ == '__main__':
main()