基于 A 股 ETF 的轮动策略例子 新手学习

我们来设计一个基于 A 股 ETF 的轮动策略(新手学习,不能实盘)

策略核心思想:

该策略旨在通过结合趋势跟踪动量选择,实现在不同市场环境下轮动持有表现较好或风险较低的 ETF,以期达到超越基准、控制回撤的稳健目标。

  1. 趋势过滤 (Risk Off): 使用长期移动平均线 (SMA) 判断市场主要趋势。当核心指数(如沪深 300)或备选 ETF 自身处于长期均线下方时,认为市场风险较高或该资产处于下降趋势,可能切换至避险资产(如货币 ETF 或短债 ETF)。
  2. 动量选择 (Rotation): 在满足趋势条件(即处于均线上方)的备选 ETF 中,选择近期表现(动量)最强的 ETF 进行持有。
  3. 定期轮动: 每隔一个固定周期(如每月)进行一次判断和可能的调仓。

策略特点:

  • 稳健性考量: 趋势过滤机制旨在规避大的系统性下跌风险。
  • 适应性: 通过动量选择,力求捕捉市场风格轮动或阶段性热点。
  • 规则化: 策略逻辑清晰,易于执行和回测。

A 股 ETF 选择 (示例):

为了覆盖不同市场风格和板块,我们选择以下 ETF 作为轮动池:

  • 大盘蓝筹: 510300 (华泰柏瑞沪深300ETF) - 代表整体市场。
  • 成长风格: 159915 (易方达创业板ETF) - 代表中小盘成长股。
  • 价值/红利: 510880 (华泰柏瑞红利ETF) - 代表高股息价值股。
  • (可选) 行业代表: 512170 (医疗ETF) 或 159995 (芯片ETF) 等 - 可以加入特定看好的行业,但会增加复杂性。本示例暂不加入行业 ETF
  • 避险资产: 511880 (银华日利ETF - 货币市场基金) - 当市场整体趋势向下时持有。

策略规则细节:

  1. 轮动周期: 每月最后一个交易日进行判断和调仓。
  2. 趋势判断指标: 使用 120 日简单移动平均线 (SMA120)。约代表半年线。
  3. 动量衡量指标: 使用过去 60 个交易日 (约 3 个月) 的累计收益率。
  4. 调仓规则:
    • 在每月最后一个交易日:
      • 计算沪深 300 指数 (000300.SH,作为大势判断) 和备选的股票型 ETF (510300, 159915, 510880) 的当前价格是否在其 SMA120 之上。
      • 风险规避: 如果沪深 300 指数的价格低于其 SMA120,则无视其他 ETF 信号,下个月全仓持有避险资产 511880
      • 动量选择: 如果沪深 300 指数的价格在其 SMA120 之上:
        • 找出所有自身价格也高于其 SMA120 的备选股票型 ETF。
        • 如果至少有一个股票型 ETF 满足条件:计算这些满足条件的 ETF 的过去 60 日收益率。选择收益率最高的那个 ETF,下个月全仓持有。
        • 如果所有备选股票型 ETF 的价格都低于其各自的 SMA120(即使沪深 300 指数在均线上),则下个月也持有避险资产 511880(视为风格轮动信号不明朗)。
    • 持仓: 每月只持有一只 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可视化完成。策略回测结束。")

代码解释:

  1. 参数设定: 定义了备选 ETF、避险 ETF、大盘基准、回测时间、以及策略所需的 SMA 和动量计算周期、调仓频率。
  2. 数据获取: 使用 akshare 分别获取 ETF 的历史累计净值数据和沪深 300 指数的收盘价数据。包含错误处理。
  3. 数据整合与预处理: 将获取到的数据合并到一个 pandas.DataFrame 中,并进行必要的处理(如填充缺失值 fillna,丢弃无法计算指标的早期数据 dropna)。
  4. 计算指标: 使用 rolling().mean() 计算 SMA,使用 pct_change().shift() 计算滞后的动量。shift(1) 很重要,确保决策基于前一天的数据。
  5. 回测引擎:
    • 初始化投资组合净值、每日收益率、持仓记录等序列。
    • 确定所有再平衡日期。
    • 循环遍历每个交易日:
      • 如果是再平衡日:
        • 首先检查大盘指数 (market_index) 是否在 SMA120 之上(基于前一天数据)。
        • 如果大盘趋势向下,强制持有避险资产 safe_asset_etf
        • 如果大盘趋势向上,则检查备选股票型 ETF 是否在各自的 SMA120 之上。
        • 找出所有满足趋势条件的股票型 ETF。
        • 如果至少有一个满足条件,计算它们的动量(基于前一天数据),选择动量最高的那个持有。
        • 如果没有股票型 ETF 满足趋势条件,也持有避险资产。
        • 更新 current_holding
      • 根据 current_holding 计算当天的投资组合收益,并更新组合净值。
      • 记录当天的持仓。
  6. 绩效评估: 计算常见的策略评估指标,如累计收益、年化收益、年化波动率、夏普比率、最大回撤。还可选计算相对于基准的 Beta 和 Alpha。
  7. 可视化: 绘制策略净值曲线(与基准对比)、回撤曲线、以及策略每月持仓的热力图(或可以改为堆叠面积图),直观展示策略表现和行为。

重要提醒与后续改进:

  • 滑点与成本: 此代码未考虑交易成本(佣金、印花税)、买卖价差(滑点)、ETF 管理费等,这些会显著影响实际表现。
  • 参数敏感性: SMA 周期 (120) 和动量周期 (60) 是经验值,实际效果可能对参数敏感,需要通过参数优化或滚动分析来检验其稳健性。
  • ETF 选择: 备选 ETF 池的选择对策略至关重要。可以尝试加入更多不同风格、行业或主题的 ETF,但也可能增加过拟合风险。
  • 风险模型: 可以引入更复杂的风险模型,例如波动率倒数加权、相关性控制等,来进一步优化组合构建。
  • 大盘判断: 使用单一指数作为大盘判断可能过于简化,可以考虑多个指数或更复杂的市场状态模型。
  • 过拟合: 务必警惕在历史数据上过度优化参数导致的过拟合问题。进行样本外测试或滚动回测是必要的。

这个例子提供了一个相对完整且考虑了稳健性的 A 股 ETF 轮动策略框架,你可以基于此进行修改、扩展和深入研究。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值