""" 全球市场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)