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:
208
tests/verify_mode_b.py
Normal file
208
tests/verify_mode_b.py
Normal 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()
|
||||
Reference in New Issue
Block a user