我们来设计一个基于 A 股 ETF 的轮动策略(新手学习,不能实盘)
策略核心思想:
该策略旨在通过结合趋势跟踪和动量选择,实现在不同市场环境下轮动持有表现较好或风险较低的 ETF,以期达到超越基准、控制回撤的稳健目标。
- 趋势过滤 (Risk Off): 使用长期移动平均线 (SMA) 判断市场主要趋势。当核心指数(如沪深 300)或备选 ETF 自身处于长期均线下方时,认为市场风险较高或该资产处于下降趋势,可能切换至避险资产(如货币 ETF 或短债 ETF)。
- 动量选择 (Rotation): 在满足趋势条件(即处于均线上方)的备选 ETF 中,选择近期表现(动量)最强的 ETF 进行持有。
- 定期轮动: 每隔一个固定周期(如每月)进行一次判断和可能的调仓。
策略特点:
- 稳健性考量: 趋势过滤机制旨在规避大的系统性下跌风险。
- 适应性: 通过动量选择,力求捕捉市场风格轮动或阶段性热点。
- 规则化: 策略逻辑清晰,易于执行和回测。
A 股 ETF 选择 (示例):
为了覆盖不同市场风格和板块,我们选择以下 ETF 作为轮动池:
- 大盘蓝筹:
510300
(华泰柏瑞沪深300ETF) - 代表整体市场。 - 成长风格:
159915
(易方达创业板ETF) - 代表中小盘成长股。 - 价值/红利:
510880
(华泰柏瑞红利ETF) - 代表高股息价值股。 - (可选) 行业代表:
512170
(医疗ETF) 或159995
(芯片ETF) 等 - 可以加入特定看好的行业,但会增加复杂性。本示例暂不加入行业 ETF。 - 避险资产:
511880
(银华日利ETF - 货币市场基金) - 当市场整体趋势向下时持有。
策略规则细节:
- 轮动周期: 每月最后一个交易日进行判断和调仓。
- 趋势判断指标: 使用 120 日简单移动平均线 (SMA120)。约代表半年线。
- 动量衡量指标: 使用过去 60 个交易日 (约 3 个月) 的累计收益率。
- 调仓规则:
- 在每月最后一个交易日:
- 计算沪深 300 指数 (
000300.SH
,作为大势判断) 和备选的股票型 ETF (510300
,159915
,510880
) 的当前价格是否在其 SMA120 之上。 - 风险规避: 如果沪深 300 指数的价格低于其 SMA120,则无视其他 ETF 信号,下个月全仓持有避险资产
511880
。 - 动量选择: 如果沪深 300 指数的价格在其 SMA120 之上:
- 找出所有自身价格也高于其 SMA120 的备选股票型 ETF。
- 如果至少有一个股票型 ETF 满足条件:计算这些满足条件的 ETF 的过去 60 日收益率。选择收益率最高的那个 ETF,下个月全仓持有。
- 如果所有备选股票型 ETF 的价格都低于其各自的 SMA120(即使沪深 300 指数在均线上),则下个月也持有避险资产
511880
(视为风格轮动信号不明朗)。
- 计算沪深 300 指数 (
- 持仓: 每月只持有一只 ETF(要么是动量最优的股票型 ETF,要么是避险的货币 ETF)。
- 在每月最后一个交易日:
Python 代码示例:
import pandas as pd
import numpy as np
import akshare as ak # 数据源
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import datetime
# --- 0. Matplotlib 中文显示设置 ---
plt.rcParams['font.sans-serif'] = ['SimHei'] # 指定默认字体
plt.rcParams['axes.unicode_minus'] = False # 解决保存图像是负号'-'显示为方块的问题
print("--- A 股 ETF 轮动稳健性策略 回测示例 ---")
print("策略: 趋势过滤 + 动量轮动 (月度)")
print("注意: 本代码为教学演示,非投资建议,基于历史数据,未考虑实际成本。")
# --- 1. 参数设定 ---
# 备选轮动 ETF (股票型)
candidate_etfs = ['510300', '159915', '510880']
# 避险 ETF (货币基金)
safe_asset_etf = '511880'
# 大盘趋势判断基准 (指数代码,AkShare 需要加 .SH 或 .SZ)
market_index = '000300' # 沪深300指数代码 (东方财富格式)
# 所有需要获取数据的代码
all_tickers = candidate_etfs + [safe_asset_etf]
# 回测时间段
start_date = '20150101' # 确保所选 ETF 和指数在该日期后有数据
end_date = datetime.datetime.now().strftime('%Y%m%d')
# 策略参数
sma_lookback = 120 # 趋势判断 - 移动平均线周期 (交易日)
momentum_lookback = 60 # 动量计算 - 回看周期 (交易日)
rebalance_freq = 'BM' # 再平衡频率 ('BM' = Business Month End 月末最后一个工作日)
print("\n--- 参数设定 ---")
print(f"备选 ETF: {', '.join(candidate_etfs)}")
print(f"避险资产: {safe_asset_etf}")
print(f"大盘基准: {market_index}")
print(f"回测区间: {start_date} to {end_date}")
print(f"SMA 周期: {sma_lookback} 天")
print(f"动量周期: {momentum_lookback} 天")
print(f"调仓频率: {rebalance_freq} (月末工作日)")
print("-" * 30)
# --- 2. 数据获取 ---
print("正在获取数据 (可能需要一些时间)...")
etf_data = {}
error_list = []
# 获取 ETF 数据 (使用累计净值)
for ticker in all_tickers:
try:
df = ak.fund_etf_hist_em(symbol=ticker, period="daily", start_date=start_date, end_date=end_date, adjust="qfq") # qfq: 前复权累计净值
if df.empty:
print(f"警告: ETF {ticker} 数据获取为空。")
error_list.append(ticker)
continue
df['日期'] = pd.to_datetime(df['日期'])
df.set_index('日期', inplace=True)
# 使用 '累计净值' 作为价格序列
etf_data[ticker] = df[['累计净值']].rename(columns={'累计净值': ticker})
print(f"成功获取 ETF {ticker} 数据。")
except Exception as e:
print(f"错误: 获取 ETF {ticker} 数据失败: {e}")
error_list.append(ticker)
# 获取大盘指数数据 (收盘价)
try:
# 注意:指数代码格式可能与上面ETF不同,ak.stock_zh_index_daily 需要 'sh000300' 或 'sz399001' 格式
index_code_ak = f"sh{market_index}" if market_index.startswith(('0','6')) else f"sz{market_index}" # 简单判断是上证还是深证
index_df = ak.stock_zh_index_daily(symbol=index_code_ak)
index_df['date'] = pd.to_datetime(index_df['date'])
index_df.set_index('date', inplace=True)
# 选择需要的日期范围
index_df = index_df[(index_df.index >= pd.to_datetime(start_date)) & (index_df.index <= pd.to_datetime(end_date))]
market_price = index_df[['close']].rename(columns={'close': market_index})
print(f"成功获取大盘指数 {market_index} 数据。")
except Exception as e:
print(f"错误: 获取大盘指数 {market_index} 数据失败: {e}")
# 如果指数获取失败,策略无法执行风险规避,可以选择退出或继续(但不推荐)
exit()
# 移除获取失败的 ETF
candidate_etfs = [etf for etf in candidate_etfs if etf not in error_list]
all_tickers = candidate_etfs + ([safe_asset_etf] if safe_asset_etf not in error_list else [])
if not candidate_etfs or safe_asset_etf not in all_tickers:
print("\n错误:缺少必要的 ETF 数据(备选或避险),无法继续。")
exit()
print("\n数据获取与初步处理完成。")
print("-" * 30)
# --- 3. 数据整合与预处理 ---
# 合并 ETF 数据
price_data = pd.concat(etf_data.values(), axis=1)
# 合并指数数据到价格数据中,用于后续 SMA 计算
price_data = pd.concat([price_data, market_price], axis=1)
# 填充缺失值 (向前填充)
price_data.fillna(method='ffill', inplace=True)
# 丢弃初始可能仍存在的 NaN 行
price_data.dropna(how='any', inplace=True) # 使用 any 确保所有列都有数据
if price_data.empty:
print("错误:数据合并或处理后为空。")
exit()
print("数据整合与填充完成。")
print(f"最终数据范围: {price_data.index.min().date()} to {price_data.index.max().date()}")
print("数据预览 (后5行):")
print(price_data.tail())
print("-" * 30)
# --- 4. 计算指标 ---
print("计算 SMA 和动量指标...")
# 计算 SMA
sma_data = price_data.rolling(window=sma_lookback, min_periods=int(sma_lookback*0.8)).mean() # 允许少量数据缺失
# 计算动量 (过去 N 日收益率)
# 使用 .shift(1) 是为了避免使用未来信息计算当期动量
# pct_change(N) 计算的是 N+1 天的收益率,所以用 N-1
momentum_data = price_data.pct_change(periods=momentum_lookback - 1).shift(1)
print("指标计算完成。")
print("-" * 30)
# --- 5. 回测引擎 ---
print("开始执行回测...")
# 初始化
initial_capital = 10000.0
portfolio_value = pd.Series(index=price_data.index, dtype=float)
portfolio_value.iloc[sma_lookback - 1] = initial_capital # 从有足够数据计算 SMA 后开始
holdings = pd.Series(index=price_data.index, dtype=str) # 记录每月持有的 ETF 代码
daily_returns = pd.Series(index=price_data.index, dtype=float)
# 获取再平衡日期 (确保在数据范围内)
rebalance_dates = price_data.resample(rebalance_freq).last().index
rebalance_dates = rebalance_dates[rebalance_dates >= price_data.index[sma_lookback]] # 从SMA计算完成后开始
rebalance_dates = rebalance_dates[rebalance_dates <= price_data.index[-1]]
# 获取 ETF 的日收益率 (用于计算净值)
etf_returns = price_data[all_tickers].pct_change().fillna(0)
current_holding = None # 当前持有的 ETF
last_rebalance_date = None
# 迭代每个交易日
for i in range(sma_lookback, len(price_data)): # 从有 SMA 数据开始
current_date = price_data.index[i]
previous_date = price_data.index[i-1]
# --- 判断是否为再平衡日 ---
is_rebalance_day = current_date in rebalance_dates
if is_rebalance_day:
# --- 执行再平衡决策 ---
target_holding = None # 本月目标持有
# 1. 大盘趋势判断 (使用上一日的数据做决策)
market_price_prev = price_data.loc[previous_date, market_index]
market_sma_prev = sma_data.loc[previous_date, market_index]
if pd.isna(market_price_prev) or pd.isna(market_sma_prev):
# 如果数据缺失,则倾向于避险
target_holding = safe_asset_etf
# print(f"{previous_date.date()}: 大盘指数或SMA数据缺失,切换至避险资产 {safe_asset_etf}")
elif market_price_prev < market_sma_prev:
# 大盘趋势向下,持有避险资产
target_holding = safe_asset_etf
# print(f"{previous_date.date()}: 大盘趋势向下 (价格 {market_price_prev:.2f} < SMA {market_sma_prev:.2f}),切换至避险资产 {safe_asset_etf}")
else:
# 大盘趋势向上,进行动量选择
# print(f"{previous_date.date()}: 大盘趋势向上 (价格 {market_price_prev:.2f} >= SMA {market_sma_prev:.2f}),进行动量选择...")
eligible_etfs = []
for etf in candidate_etfs:
etf_price_prev = price_data.loc[previous_date, etf]
etf_sma_prev = sma_data.loc[previous_date, etf]
if pd.notna(etf_price_prev) and pd.notna(etf_sma_prev) and etf_price_prev > etf_sma_prev:
eligible_etfs.append(etf)
# print(f" - ETF {etf}: 趋势向上 (价格 {etf_price_prev:.3f} > SMA {etf_sma_prev:.3f})")
# else:
# print(f" - ETF {etf}: 趋势向下或数据缺失,不考虑")
if not eligible_etfs:
# 没有符合趋势条件的股票 ETF,持有避险资产
target_holding = safe_asset_etf
# print(f" -> 没有符合趋势的股票ETF,切换至避险资产 {safe_asset_etf}")
else:
# 计算符合条件 ETF 的动量
momentum_scores = momentum_data.loc[previous_date, eligible_etfs]
# print(f" 符合条件的 ETF 动量 (前 {momentum_lookback} 日收益): \n{momentum_scores}")
if momentum_scores.isnull().all(): # 如果动量数据都缺失
target_holding = safe_asset_etf
# print(f" -> 符合条件的 ETF 动量数据缺失,切换至避险资产 {safe_asset_etf}")
else:
# 选择动量最高的 ETF
target_holding = momentum_scores.idxmax()
# print(f" -> 选择动量最高的 ETF: {target_holding} (动量: {momentum_scores.max():.2%})")
# 更新当前持有标的
current_holding = target_holding
last_rebalance_date = current_date
# --- 计算当日收益与组合净值 ---
if current_holding is None: # 初始阶段或决策失败
if last_rebalance_date is None: # 如果是回测非常早期
daily_returns.loc[current_date] = 0
portfolio_value.loc[current_date] = initial_capital
else: # 如果是因为数据问题决策失败,则维持上一日净值
daily_returns.loc[current_date] = 0
portfolio_value.loc[current_date] = portfolio_value.loc[previous_date]
else:
# 获取当前持有 ETF 的当日收益率
today_return = etf_returns.loc[current_date, current_holding]
daily_returns.loc[current_date] = today_return
# 更新组合净值
portfolio_value.loc[current_date] = portfolio_value.loc[previous_date] * (1 + today_return)
# 记录当日持有
holdings.loc[current_date] = current_holding
# 清理数据 (移除回测开始前的 NaN)
portfolio_value = portfolio_value.dropna()
daily_returns = daily_returns.dropna()
holdings = holdings.dropna()
print("\n回测执行完毕。")
print("-" * 30)
# --- 6. 绩效评估 ---
print("--- 绩效评估 ---")
if portfolio_value.empty or len(portfolio_value) < 2:
print("错误:无法计算绩效指标,回测结果数据不足。")
else:
# 1. 累计收益率
total_return = (portfolio_value.iloc[-1] / portfolio_value.iloc[0]) - 1
print(f"累计收益率: {total_return:.2%}")
# 2. 年化收益率 (CAGR)
days = (portfolio_value.index[-1] - portfolio_value.index[0]).days
years = max(days / 365.25, 1/365.25) # 至少算1天
cagr = (portfolio_value.iloc[-1] / portfolio_value.iloc[0]) ** (1 / years) - 1
print(f"年化收益率 (CAGR): {cagr:.2%}")
# 3. 年化波动率
annual_volatility = daily_returns.std() * np.sqrt(252) # A股年交易日约252天
print(f"年化波动率: {annual_volatility:.2%}")
# 4. 年化夏普比率 (假设无风险利率为 0%)
risk_free_rate = 0.0
if annual_volatility == 0:
sharpe_ratio = 0
else:
sharpe_ratio = (cagr - risk_free_rate) / annual_volatility
print(f"年化夏普比率 (Rf=0%): {sharpe_ratio:.2f}")
# 5. 最大回撤 (Max Drawdown)
rolling_max = portfolio_value.cummax()
drawdown = (portfolio_value - rolling_max) / rolling_max
max_drawdown = drawdown.min()
print(f"最大回撤: {max_drawdown:.2%}")
# 6. Beta 和 Alpha (相对于沪深300基准) - 可选
if market_index in price_data.columns:
benchmark_returns = price_data[market_index].pct_change().fillna(0)
# 确保基准收益率与策略收益率对齐
common_index = daily_returns.index.intersection(benchmark_returns.index)
strat_returns_aligned = daily_returns.loc[common_index]
bench_returns_aligned = benchmark_returns.loc[common_index]
if len(common_index) > 1: # 需要至少两个数据点来计算协方差
cov_matrix = np.cov(strat_returns_aligned, bench_returns_aligned)
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
# Alpha = 策略年化收益 - Rf - Beta * (基准年化收益 - Rf)
benchmark_cagr = ( (1 + bench_returns_aligned).prod() )**(252/len(bench_returns_aligned)) - 1
alpha_annual = (cagr - risk_free_rate) - beta * (benchmark_cagr - risk_free_rate)
print(f"相对于 {market_index} 的 Beta: {beta:.2f}")
print(f"年化 Alpha: {alpha_annual:.2%}")
else:
print("数据不足,无法计算 Beta 和 Alpha。")
print("-" * 30)
# --- 7. 可视化 ---
print("生成可视化图表...")
# 图 1: 净值曲线对比
fig1, ax1 = plt.subplots(figsize=(14, 7))
ax1.plot(portfolio_value.index, portfolio_value / initial_capital, label='ETF 轮动策略', color='red', linewidth=2)
# 添加基准线 (例如沪深300 ETF 510300)
if '510300' in price_data.columns:
benchmark_etf = '510300'
# 对齐基准的开始日期和初始值
benchmark_price_aligned = price_data.loc[portfolio_value.index, benchmark_etf]
benchmark_nav = benchmark_price_aligned / benchmark_price_aligned.iloc[0]
ax1.plot(benchmark_nav.index, benchmark_nav, label=f'基准: {benchmark_etf}', color='grey', linestyle='--', alpha=0.8)
ax1.set_title('策略净值与基准对比', fontsize=16)
ax1.set_ylabel('累计净值 (归一化)')
ax1.set_xlabel('日期')
ax1.legend()
ax1.grid(True)
# 格式化Y轴为普通数字显示
ax1.yaxis.set_major_formatter(mtick.FormatStrFormatter('%.2f'))
# 图 2: 回撤曲线
fig2, ax2 = plt.subplots(figsize=(14, 5))
ax2.plot(drawdown.index, drawdown, color='red', linewidth=1)
ax2.fill_between(drawdown.index, drawdown, 0, color='red', alpha=0.3)
ax2.set_title('策略回撤曲线')
ax2.set_ylabel('回撤')
ax2.set_xlabel('日期')
ax2.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1.0))
ax2.grid(True)
# 图 3: 持仓分布 (热力图或堆叠图)
holdings_map = {etf: i for i, etf in enumerate(all_tickers)} # 给每个 ETF 一个数字 ID
holding_numeric = holdings.map(holdings_map).fillna(-1) # 用 -1 表示空缺
fig3, ax3 = plt.subplots(figsize=(14, 3)) # 调整高度使其更像时间条
cmap = plt.get_cmap('tab10', len(all_tickers)) # 使用离散的颜色图
# 使用 pcolormesh 绘制热力图,横轴是时间,纵轴固定,颜色代表持仓
# 需要构造 X 和 Y 轴数据
dates_numeric = plt.matplotlib.dates.date2num(holding_numeric.index)
y_coords = [0, 1] # 创建一个高度为1的条
# X 轴需要 n+1 个边界点
x_coords = np.append(dates_numeric, dates_numeric[-1] + (dates_numeric[-1] - dates_numeric[-2]))
# Z 值需要 reshape 成 (Y-1, X-1) 的形式
z_values = holding_numeric.values[:-1].reshape(1, -1) # 取 n-1 个值
mesh = ax3.pcolormesh(x_coords, y_coords, z_values, cmap=cmap, vmin=0, vmax=len(all_tickers)-1)
# 设置 Y 轴不可见
ax3.yaxis.set_visible(False)
# 设置 X 轴为日期格式
ax3.xaxis_date()
fig3.autofmt_xdate() # 自动格式化日期显示
# 添加颜色条和标签
cbar = fig3.colorbar(mesh, ax=ax3, ticks=np.arange(len(all_tickers)), label='持有 ETF')
cbar.ax.set_yticklabels([f"{etf} ({a_share_etfs[etf]['name'] if etf in a_share_etfs else '货币'})" for etf in all_tickers]) # 使用字典获取名称
ax3.set_title('策略逐月持仓')
ax3.set_xlabel('日期')
plt.tight_layout()
plt.show()
print("\n可视化完成。策略回测结束。")
代码解释:
- 参数设定: 定义了备选 ETF、避险 ETF、大盘基准、回测时间、以及策略所需的 SMA 和动量计算周期、调仓频率。
- 数据获取: 使用
akshare
分别获取 ETF 的历史累计净值数据和沪深 300 指数的收盘价数据。包含错误处理。 - 数据整合与预处理: 将获取到的数据合并到一个
pandas.DataFrame
中,并进行必要的处理(如填充缺失值fillna
,丢弃无法计算指标的早期数据dropna
)。 - 计算指标: 使用
rolling().mean()
计算 SMA,使用pct_change().shift()
计算滞后的动量。shift(1)
很重要,确保决策基于前一天的数据。 - 回测引擎:
- 初始化投资组合净值、每日收益率、持仓记录等序列。
- 确定所有再平衡日期。
- 循环遍历每个交易日:
- 如果是再平衡日:
- 首先检查大盘指数 (
market_index
) 是否在 SMA120 之上(基于前一天数据)。 - 如果大盘趋势向下,强制持有避险资产
safe_asset_etf
。 - 如果大盘趋势向上,则检查备选股票型 ETF 是否在各自的 SMA120 之上。
- 找出所有满足趋势条件的股票型 ETF。
- 如果至少有一个满足条件,计算它们的动量(基于前一天数据),选择动量最高的那个持有。
- 如果没有股票型 ETF 满足趋势条件,也持有避险资产。
- 更新
current_holding
。
- 首先检查大盘指数 (
- 根据
current_holding
计算当天的投资组合收益,并更新组合净值。 - 记录当天的持仓。
- 如果是再平衡日:
- 绩效评估: 计算常见的策略评估指标,如累计收益、年化收益、年化波动率、夏普比率、最大回撤。还可选计算相对于基准的 Beta 和 Alpha。
- 可视化: 绘制策略净值曲线(与基准对比)、回撤曲线、以及策略每月持仓的热力图(或可以改为堆叠面积图),直观展示策略表现和行为。
重要提醒与后续改进:
- 滑点与成本: 此代码未考虑交易成本(佣金、印花税)、买卖价差(滑点)、ETF 管理费等,这些会显著影响实际表现。
- 参数敏感性: SMA 周期 (120) 和动量周期 (60) 是经验值,实际效果可能对参数敏感,需要通过参数优化或滚动分析来检验其稳健性。
- ETF 选择: 备选 ETF 池的选择对策略至关重要。可以尝试加入更多不同风格、行业或主题的 ETF,但也可能增加过拟合风险。
- 风险模型: 可以引入更复杂的风险模型,例如波动率倒数加权、相关性控制等,来进一步优化组合构建。
- 大盘判断: 使用单一指数作为大盘判断可能过于简化,可以考虑多个指数或更复杂的市场状态模型。
- 过拟合: 务必警惕在历史数据上过度优化参数导致的过拟合问题。进行样本外测试或滚动回测是必要的。
这个例子提供了一个相对完整且考虑了稳健性的 A 股 ETF 轮动策略框架,你可以基于此进行修改、扩展和深入研究。