feat(全球市场): 迁移聚宽策略为Tushare独立回测版本
从聚宽平台迁移全球市场ETF轮动策略,复用动量.py核心模块。ETF池: 纳指100/日经225/德国DAX/黄金/有色金属/南方原油/30年国债/红利低波/创业板。回测(2019~2026): CAGR=44.29%, Sharpe=1.50, MaxDD=-16.93%, Calmar=2.62, 盈利年份8/8, 跑赢基准7/8
This commit is contained in:
299
全球市场.py
Normal file
299
全球市场.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"""
|
||||||
|
全球市场ETF轮动策略 - 本地回测版本
|
||||||
|
原始策略来源:聚宽 https://www.joinquant.com/post/1399
|
||||||
|
|
||||||
|
核心逻辑(与动量策略共用):
|
||||||
|
1. 加权线性回归(权重1→2递增)计算趋势得分
|
||||||
|
2. score = 年化收益率 × R²
|
||||||
|
3. ATR动态调整回看窗口(20~60天)
|
||||||
|
4. 崩盘过滤:连续3天任一天跌>5%则得分归零
|
||||||
|
5. 溢价过滤:溢价率≥5%则降权
|
||||||
|
6. 全仓单一品种轮动
|
||||||
|
|
||||||
|
ETF池:全球化配置
|
||||||
|
纳指100 / 日经225 / 德国DAX / 黄金 / 有色金属 /
|
||||||
|
南方原油 / 30年国债 / 红利低波 / 创业板
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import warnings
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
|
# 添加项目根目录
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ==================== 策略配置 ====================
|
||||||
|
CONFIG = {
|
||||||
|
# 全球市场ETF池(聚宽代码 -> Tushare代码映射)
|
||||||
|
'etf_pool': {
|
||||||
|
'513100.SH': '纳指100ETF',
|
||||||
|
'513520.SH': '日经225ETF',
|
||||||
|
'513030.SH': '德国DAX ETF',
|
||||||
|
'518880.SH': '黄金ETF华安',
|
||||||
|
'159980.SZ': '有色金属ETF',
|
||||||
|
'501018.SH': '南方原油LOF',
|
||||||
|
'511090.SH': '30年国债ETF',
|
||||||
|
'512890.SH': '红利低波ETF',
|
||||||
|
'159915.SZ': '创业板ETF易方达',
|
||||||
|
},
|
||||||
|
'target_num': 1, # 持仓数量
|
||||||
|
'auto_day': True, # 是否启用动态周期
|
||||||
|
'fixed_days': 25, # 固定回看天数
|
||||||
|
'min_days': 20, # 动态周期最小值
|
||||||
|
'max_days': 60, # 动态周期最大值
|
||||||
|
'premium_threshold': 5.0, # 溢价率阈值(%)
|
||||||
|
'trade_cost': 0.001, # 单次交易成本(双边)
|
||||||
|
'start_date': '2019-01-01',
|
||||||
|
'benchmark': '000300.SH', # 基准:沪深300
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==================== 复用动量策略核心模块 ====================
|
||||||
|
from 动量 import (
|
||||||
|
fetch_all_etf_data,
|
||||||
|
fetch_etf_nav_data,
|
||||||
|
calc_atr,
|
||||||
|
calc_weighted_momentum_score,
|
||||||
|
apply_crash_filter,
|
||||||
|
calc_premium_rate,
|
||||||
|
print_performance,
|
||||||
|
print_yearly_returns,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 回测引擎 ====================
|
||||||
|
def run_backtest(config: dict):
|
||||||
|
"""执行回测"""
|
||||||
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
etf_pool = config['etf_pool']
|
||||||
|
etf_codes = list(etf_pool.keys())
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(" 全球市场ETF轮动策略 - 本地回测")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f" 候选ETF: {len(etf_codes)} 只")
|
||||||
|
for code, name in etf_pool.items():
|
||||||
|
print(f" {code} {name}")
|
||||||
|
print(f" 持仓数量: {config['target_num']}")
|
||||||
|
print(f" 动态周期: {'开启' if config['auto_day'] else '关闭'}")
|
||||||
|
if config['auto_day']:
|
||||||
|
print(f" 回看范围: {config['min_days']}~{config['max_days']} 天")
|
||||||
|
else:
|
||||||
|
print(f" 固定回看: {config['fixed_days']} 天")
|
||||||
|
print(f" 回测区间: {config['start_date']} ~ {end_date}")
|
||||||
|
|
||||||
|
# 1. 获取数据
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("下载ETF价格数据...")
|
||||||
|
all_data = fetch_all_etf_data(etf_codes, config['start_date'], end_date, etf_pool)
|
||||||
|
|
||||||
|
print("\n下载ETF净值数据...")
|
||||||
|
nav_data = fetch_etf_nav_data(etf_codes, config['start_date'], end_date)
|
||||||
|
print(f" 净值数据: {len(nav_data)} 只")
|
||||||
|
|
||||||
|
if not all_data:
|
||||||
|
print("无数据,退出")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 构建交易日历
|
||||||
|
all_dates = set()
|
||||||
|
for df in all_data.values():
|
||||||
|
all_dates.update(df.index.tolist())
|
||||||
|
trade_dates = sorted(all_dates)
|
||||||
|
trade_dates = [d for d in trade_dates if d >= pd.Timestamp(config['start_date'])]
|
||||||
|
|
||||||
|
print(f"\n交易日数: {len(trade_dates)}")
|
||||||
|
print(f"区间: {trade_dates[0].strftime('%Y-%m-%d')} ~ {trade_dates[-1].strftime('%Y-%m-%d')}")
|
||||||
|
|
||||||
|
# 3. 逐日回测
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("开始回测...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
max_lookback = config['max_days'] + 10
|
||||||
|
holding = None
|
||||||
|
daily_returns = []
|
||||||
|
signals = []
|
||||||
|
|
||||||
|
for i, today in enumerate(trade_dates):
|
||||||
|
# 计算每只ETF的得分
|
||||||
|
scores = {}
|
||||||
|
score_details = {}
|
||||||
|
|
||||||
|
for code in etf_codes:
|
||||||
|
if code not in all_data:
|
||||||
|
continue
|
||||||
|
df = all_data[code]
|
||||||
|
|
||||||
|
hist = df[df.index <= today].tail(max_lookback + 1)
|
||||||
|
if len(hist) < config['min_days']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
close_arr = hist['close'].values
|
||||||
|
|
||||||
|
if config['auto_day']:
|
||||||
|
if len(hist) < max_lookback:
|
||||||
|
lookback = config['fixed_days']
|
||||||
|
else:
|
||||||
|
long_atr = calc_atr(hist['high'], hist['low'], hist['close'],
|
||||||
|
config['max_days'])
|
||||||
|
short_atr = calc_atr(hist['high'], hist['low'], hist['close'],
|
||||||
|
config['min_days'])
|
||||||
|
la = long_atr.iloc[-1]
|
||||||
|
sa = short_atr.iloc[-1]
|
||||||
|
if la > 0 and not np.isnan(la) and not np.isnan(sa):
|
||||||
|
ratio = min(0.9, sa / la)
|
||||||
|
lookback = int(config['min_days'] +
|
||||||
|
(config['max_days'] - config['min_days']) * (1 - ratio))
|
||||||
|
else:
|
||||||
|
lookback = config['fixed_days']
|
||||||
|
prices = close_arr[-lookback:]
|
||||||
|
else:
|
||||||
|
prices = close_arr[-config['fixed_days']:]
|
||||||
|
|
||||||
|
if len(prices) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = calc_weighted_momentum_score(prices)
|
||||||
|
score = result['score']
|
||||||
|
score = apply_crash_filter(close_arr, score)
|
||||||
|
|
||||||
|
# 溢价过滤
|
||||||
|
if code in nav_data:
|
||||||
|
nav_df = nav_data[code]
|
||||||
|
nav_row = nav_df[nav_df.index <= today]
|
||||||
|
if not nav_row.empty:
|
||||||
|
nav_val = nav_row.iloc[-1]['nav']
|
||||||
|
etf_price = close_arr[-1]
|
||||||
|
premium = calc_premium_rate(etf_price, nav_val)
|
||||||
|
if premium >= config['premium_threshold']:
|
||||||
|
score -= 1
|
||||||
|
|
||||||
|
if 0 < score < 6:
|
||||||
|
scores[code] = score
|
||||||
|
score_details[code] = result
|
||||||
|
|
||||||
|
# 选出排名最高的标的
|
||||||
|
if scores:
|
||||||
|
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
target = ranked[0][0]
|
||||||
|
else:
|
||||||
|
target = None
|
||||||
|
|
||||||
|
# 计算当日收益
|
||||||
|
if holding is not None and holding in all_data:
|
||||||
|
df_h = all_data[holding]
|
||||||
|
if today in df_h.index:
|
||||||
|
prev_dates = df_h[df_h.index < today].index
|
||||||
|
if len(prev_dates) > 0:
|
||||||
|
prev_date = prev_dates[-1]
|
||||||
|
prev_price = df_h.loc[prev_date, 'close']
|
||||||
|
today_price = df_h.loc[today, 'close']
|
||||||
|
daily_ret = today_price / prev_price - 1
|
||||||
|
else:
|
||||||
|
daily_ret = 0.0
|
||||||
|
else:
|
||||||
|
daily_ret = 0.0
|
||||||
|
else:
|
||||||
|
daily_ret = 0.0
|
||||||
|
|
||||||
|
# 调仓成本
|
||||||
|
trade_cost = 0.0
|
||||||
|
if target != holding:
|
||||||
|
trade_cost = config['trade_cost']
|
||||||
|
if holding is not None:
|
||||||
|
signals.append({
|
||||||
|
'date': today, 'action': '调仓',
|
||||||
|
'from': holding, 'to': target or '空仓',
|
||||||
|
'score': scores.get(target, 0) if target else 0,
|
||||||
|
})
|
||||||
|
holding = target
|
||||||
|
|
||||||
|
daily_returns.append({
|
||||||
|
'date': today,
|
||||||
|
'daily_return': daily_ret - trade_cost if trade_cost > 0 else daily_ret,
|
||||||
|
'holding': holding or '空仓',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. 计算绩效
|
||||||
|
result_df = pd.DataFrame(daily_returns).set_index('date')
|
||||||
|
result_df['nav'] = (1 + result_df['daily_return']).cumprod()
|
||||||
|
|
||||||
|
# 基准数据
|
||||||
|
benchmark_code = config['benchmark']
|
||||||
|
print(f"\n获取基准数据 {benchmark_code}...")
|
||||||
|
import os, tushare as ts
|
||||||
|
pro = ts.pro_api(os.getenv("TUSHARE_TOKEN"))
|
||||||
|
bench_df = pro.index_daily(
|
||||||
|
ts_code=benchmark_code,
|
||||||
|
start_date=config['start_date'].replace('-', ''),
|
||||||
|
end_date=end_date.replace('-', ''),
|
||||||
|
)
|
||||||
|
if bench_df is not None and not bench_df.empty:
|
||||||
|
bench_df['date'] = pd.to_datetime(bench_df['trade_date'])
|
||||||
|
bench_df = bench_df.set_index('date').sort_index()
|
||||||
|
bench_close = bench_df['close'].reindex(result_df.index, method='ffill')
|
||||||
|
result_df['bench_return'] = bench_close / bench_close.iloc[0]
|
||||||
|
else:
|
||||||
|
result_df['bench_return'] = 1.0
|
||||||
|
|
||||||
|
# 5. 输出绩效报告
|
||||||
|
print_performance(result_df, signals, config)
|
||||||
|
|
||||||
|
# 6. 年度收益统计
|
||||||
|
print_yearly_returns(result_df)
|
||||||
|
|
||||||
|
# 7. 生成图表
|
||||||
|
save_chart(result_df, config)
|
||||||
|
|
||||||
|
return result_df
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 图表生成 ====================
|
||||||
|
def save_chart(result_df: pd.DataFrame, config: dict):
|
||||||
|
"""生成净值曲线图"""
|
||||||
|
try:
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use('Agg')
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
matplotlib.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans']
|
||||||
|
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), height_ratios=[3, 1],
|
||||||
|
gridspec_kw={'hspace': 0.3})
|
||||||
|
|
||||||
|
ax1.plot(result_df.index, result_df['nav'], label='全球市场轮动', linewidth=1.5, color='#2ecc71')
|
||||||
|
ax1.plot(result_df.index, result_df['bench_return'], label='沪深300', linewidth=1, color='#95a5a6')
|
||||||
|
ax1.set_title('全球市场ETF轮动策略 净值曲线', fontsize=14, fontweight='bold')
|
||||||
|
ax1.legend(loc='upper left')
|
||||||
|
ax1.grid(True, alpha=0.3)
|
||||||
|
ax1.set_ylabel('净值')
|
||||||
|
|
||||||
|
peak = result_df['nav'].cummax()
|
||||||
|
drawdown = (result_df['nav'] - peak) / peak
|
||||||
|
ax2.fill_between(result_df.index, drawdown, 0, alpha=0.4, color='#e74c3c')
|
||||||
|
ax2.set_title('回撤', fontsize=12)
|
||||||
|
ax2.set_ylabel('回撤')
|
||||||
|
ax2.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
chart_path = Path(__file__).parent / 'results' / 'global_market_chart.png'
|
||||||
|
chart_path.parent.mkdir(exist_ok=True)
|
||||||
|
fig.savefig(chart_path, dpi=150, bbox_inches='tight')
|
||||||
|
plt.close(fig)
|
||||||
|
print(f"\n报告图表已保存: {chart_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n图表生成失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主入口 ====================
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_backtest(CONFIG)
|
||||||
Reference in New Issue
Block a user