基于 A 股市场的简化版全天候策略 ETF 回测示例

我们来创建一个基于 A 股市场的简化版全天候策略 ETF 回测示例。

核心说明:

  1. 教学目的: 此代码主要用于教学演示,展示如何在 A 股背景下构思和回测全天候策略的基本流程。并非真实的投资建议,请勿直接用于实盘。
  2. 简化策略: 采用固定权重的方式,而非更复杂的风险平价模型,以简化理解。
  3. A 股标的: 选用国内常见的、有代表性的 ETF 作为各资产类别的代理。特别注意: A 股市场缺乏理想的直接对应多种类大宗商品的 ETF,因此商品部分的代表性相对较弱,这里选用有色金属 ETF 作为近似替代
  4. 数据获取: 使用 AkShare 库获取 A 股 ETF 的历史数据。你需要先安装它 (pip install akshare)。AkShare 是一个开源库,数据源来自公开渠道,请注意数据质量和及时性。
  5. 回测局限: 未考虑交易成本、滑点、ETF 管理费/托管费、冲击成本、分红处理(AkShare 的“累计净值”已包含分红再投)等实际因素。
  6. 环境要求: Python 环境,并安装 pandas, numpy, akshare, matplotlib 库。

代码结构:

  1. 环境设置与导入库
  2. 选择 A 股 ETF 标的与目标权重
  3. 使用 AkShare 获取历史数据
  4. 数据预处理与合并
  5. 固定权重回测引擎
  6. 计算绩效指标
  7. 结果可视化

Python 代码示例 (包含中文注释):

import pandas as pd
import numpy as np
import akshare as ak # 用于获取 A 股数据
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick # 用于格式化 Y 轴为百分比
import datetime

# --- 0. Matplotlib 中文显示设置 ---
# 解决中文显示问题,请确保你的系统安装了支持中文的字体,例如 SimHei
plt.rcParams['font.sans-serif'] = ['SimHei']  # 指定默认字体
plt.rcParams['axes.unicode_minus'] = False   # 解决保存图像是负号 '-' 显示为方块的问题

print("--- A 股全天候策略 ETF (简化版) 回测示例 ---")
print("重要提示:本代码仅为教学演示,非投资建议,请勿用于实盘。")
print("数据来源: AkShare (公开市场数据)")
print("策略类型: 固定权重")
print("局限性: 未考虑交易成本、滑点、费用等实际因素。商品代表性有限。\n")

# --- 1. 环境设置与导入库 (已在顶部完成) ---

# --- 2. 选择 A 股 ETF 标的与目标权重 ---

# A 股市场 ETF 选择 (代码 + 名称,方便理解)
# 注意:这里的选择和权重仅为示例,实际应用需要深入研究
# .SH 表示上海交易所, .SZ 表示深圳交易所 (AkShare 可能不需要后缀,取决于具体函数)
a_share_etfs = {
    # 股票 (对应增长超预期)
    '510300': {'name': '华泰柏瑞沪深300ETF', 'weight': 0.15}, # 大盘蓝筹
    '159949': {'name': '华安创业板50ETF',    'weight': 0.15}, # 成长/中小盘

    # 债券 (对应增长低于预期/通缩)
    '511260': {'name': '富国中证10年期国债ETF', 'weight': 0.40}, # 长期国债

    # 黄金 (对应通胀超预期/避险)
    '518880': {'name': '华安黄金易ETF',        'weight': 0.15}, # 黄金

    # 商品 (近似替代,对应通胀超预期) - A股缺乏综合商品ETF,用有色金属替代
    '512400': {'name': '国泰中证有色金属ETF', 'weight': 0.15}  # 有色金属 (作为商品代理)
}

tickers = list(a_share_etfs.keys())
target_weights = {ticker: info['weight'] for ticker, info in a_share_etfs.items()}

# 检查权重总和是否为 1
total_weight = sum(target_weights.values())
assert abs(total_weight - 1.0) < 1e-6, f"目标权重之和 ({total_weight:.2f}) 不为 1"

print("选定 A 股 ETF 标的及目标权重:")
for ticker, info in a_share_etfs.items():
    print(f"- {ticker} ({info['name']}): {info['weight']:.0%}")
print("-" * 30)

# --- 3. 使用 AkShare 获取历史数据 ---

# 定义回测时间段
# 注意:开始日期要考虑所有 ETF 的上市时间,特别是商品替代品
start_date = '20150101' # 示例开始日期,确保所选 ETF 在此日期后已上市且有数据
end_date = datetime.datetime.now().strftime('%Y%m%d') # 结束日期为今天

print(f"数据获取时间范围: {start_date} to {end_date}")
print("正在使用 AkShare 获取数据,请稍候...")

all_data = {}
error_tickers = []

for ticker in tickers:
    try:
        # 使用 ak.fund_etf_hist_em 获取场内基金历史行情 (数据来源:东方财富)
        # '累计净值' 通常包含了分红再投资,适合用于计算收益率
        etf_hist = ak.fund_etf_hist_em(symbol=ticker, period="daily", start_date=start_date, end_date=end_date, adjust="qfq") # qfq: 前复权

        if etf_hist.empty:
             print(f"警告: Ticker {ticker} ({a_share_etfs[ticker]['name']}) 在指定时间段内未获取到数据。")
             error_tickers.append(ticker)
             continue

        # 将日期列设为索引,并转换为 datetime 对象
        etf_hist['日期'] = pd.to_datetime(etf_hist['日期'])
        etf_hist.set_index('日期', inplace=True)

        # 选取 '累计净值' 作为我们的调整后收盘价
        # 如果需要使用'收盘价'并自己处理复权会更复杂
        adj_close = etf_hist[['累计净值']].copy()
        adj_close.rename(columns={'累计净值': ticker}, inplace=True) # 重命名列名为 ticker

        all_data[ticker] = adj_close
        print(f"成功获取 {ticker} ({a_share_etfs[ticker]['name']}) 的数据。")

    except Exception as e:
        print(f"错误: 获取 Ticker {ticker} ({a_share_etfs[ticker]['name']}) 数据时出错: {e}")
        error_tickers.append(ticker)

# 移除获取失败的 ticker
tickers = [t for t in tickers if t not in error_tickers]
if not tickers:
    print("\n错误:所有标的数据获取失败,无法继续回测。请检查网络或 Ticker 代码。")
    exit()
elif error_tickers:
     print(f"\n警告:以下 Ticker 数据获取失败,已从回测中移除: {', '.join(error_tickers)}")

if not all_data:
    print("未能获取任何有效数据,程序退出。")
    exit()

print("\n数据获取完成。")
print("-" * 30)

# --- 4. 数据预处理与合并 ---

# 合并所有 ETF 的数据到一个 DataFrame
# 使用 outer join 保留所有日期,然后处理 NaN
data = pd.concat(all_data.values(), axis=1) # 按列合并

# 填充 NaN 值:使用前一个有效值填充 (forward fill)
# 这假设如果某天某个 ETF 停牌或无数据,其价值不变(简化处理)
data.fillna(method='ffill', inplace=True)

# 再次移除数据合并或填充后可能仍然存在的初始 NaN 行
data.dropna(inplace=True)

if data.empty:
    print("错误:数据合并或处理后为空,无法继续。请检查各 ETF 的数据时间覆盖范围。")
    exit()

print("数据预处理与合并完成。")
print("合并后的数据预览 (前5行):")
print(data.head())
print(f"\n最终数据时间范围: {data.index.min().date()} to {data.index.max().date()}")
print("-" * 30)


# --- 5. 固定权重回测引擎 ---

# 计算每日收益率
returns = data.pct_change().fillna(0)

# 定义再平衡频率 ('M' 代表月末, 'BM' 代表月末的最后一个工作日)
rebalance_freq = 'BM' # 使用 'BM' 更贴合交易日

# 初始化投资组合
initial_capital = 10000.0 # 初始资金
portfolio_value = pd.Series(index=data.index, dtype=float)
if not data.empty:
    portfolio_value.iloc[0] = initial_capital
else:
    print("错误:数据为空,无法初始化投资组合。")
    exit()

# 过滤掉获取失败的 Ticker 的权重
valid_target_weights_list = [target_weights[t] for t in data.columns]
# 重新归一化权重(如果某些 ticker 获取失败)
weight_sum_valid = sum(valid_target_weights_list)
current_weights = pd.Series({ticker: target_weights[ticker] / weight_sum_valid for ticker in data.columns})

# 获取所有交易日
dates = data.index

# 标记再平衡日期 (基于目标频率找到对应的交易日)
rebalance_dates = data.resample(rebalance_freq).last().index
# 确保再平衡日期在实际交易日内,并移除第一个日期(因为回测从第二天开始计算收益)
rebalance_trading_dates = dates[dates.isin(rebalance_dates)][1:]

print(f"回测频率: {rebalance_freq} (月末工作日)")
print(f"计算回测表现 (从 {dates[1].date()} 开始)...")

# 模拟每日价值变化和再平衡
portfolio_daily_returns = pd.Series(index=dates, dtype=float)
portfolio_daily_returns.iloc[0] = 0 # 第一天收益为0

# 记录权重变化(可选)
weights_history = pd.DataFrame(index=dates, columns=data.columns, dtype=float)
weights_history.iloc[0] = current_weights

for i in range(1, len(dates)):
    current_date = dates[i]
    previous_date = dates[i-1]

    # 1. 计算当日组合收益(使用上一日的权重)
    daily_ret = (returns.loc[current_date] * current_weights).sum()
    portfolio_daily_returns.loc[current_date] = daily_ret

    # 2. 更新组合总价值
    portfolio_value.loc[current_date] = portfolio_value.loc[previous_date] * (1 + daily_ret)

    # 3. 计算当日结束时的权重(漂移后的权重)
    # 先计算各项资产的价值变化
    current_asset_values = current_weights * (1 + returns.loc[current_date])
    # 计算总价值变化后的比例
    weights_denominator = current_asset_values.sum()
    if weights_denominator != 0: # 避免除以零
        current_weights = current_asset_values / weights_denominator
    else:
        # 如果分母为0(异常情况),保持权重不变
        pass

    # 4. 检查是否为再平衡日
    if current_date in rebalance_trading_dates:
        # print(f"再平衡于: {current_date.date()}")
        # 重置为目标权重 (已归一化)
        valid_target_weights_series = pd.Series({ticker: target_weights[ticker] / weight_sum_valid for ticker in data.columns})
        current_weights = valid_target_weights_series

    # 记录当日结束时的权重
    weights_history.loc[current_date] = current_weights

# 移除计算产生的初始 NaN 值 (如果还有的话)
portfolio_value = portfolio_value.dropna()
portfolio_daily_returns = portfolio_daily_returns.dropna()

print("回测计算完成。")
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
    if days == 0: # 避免除以零
        cagr = 0
        print("警告:回测时间不足一天,无法计算年化收益率。")
    else:
        years = days / 365.25
        cagr = (portfolio_value.iloc[-1] / portfolio_value.iloc[0]) ** (1 / years) - 1
        print(f"年化收益率 (CAGR): {cagr:.2%}")

    # 3. 年化波动率
    # 使用日收益率计算标准差,乘以 sqrt(252) 进行年化 (假设一年252个交易日)
    annual_volatility = portfolio_daily_returns.std() * np.sqrt(252)
    print(f"年化波动率: {annual_volatility:.2%}")

    # 4. 年化夏普比率 (假设无风险利率为 0%)
    # 这里使用简化的年化收益率 CAGR 计算,更精确可以用日收益率均值年化
    risk_free_rate = 0.0
    # 防止波动率为 0 的情况
    if annual_volatility == 0:
        sharpe_ratio = np.nan # 或 0,取决于定义
        print("警告:年化波动率为 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%}")

print("-" * 30)

# --- 7. 结果可视化 ---

print("生成可视化图表...")

# 图 1: 策略净值曲线 与 基准 (例如沪深300) 对比
fig1, ax1 = plt.subplots(figsize=(14, 7))

# 绘制策略净值曲线
ax1.plot(portfolio_value.index, portfolio_value, label='简化全天候策略 (固定权重)', color='blue', linewidth=2)
ax1.set_xlabel('日期')
ax1.set_ylabel('投资组合净值', color='blue')
ax1.tick_params(axis='y', labelcolor='blue')
ax1.set_title(f'A 股简化全天候策略回测 ({data.index.min().date()} to {data.index.max().date()})', fontsize=14)
ax1.grid(True)

# 添加基准对比 (沪深300 ETF: 510300)
if '510300' in data.columns:
    benchmark_ticker = '510300'
    benchmark_name = a_share_etfs[benchmark_ticker]['name']
    benchmark_value = (1 + returns[benchmark_ticker]).cumprod() * initial_capital
    ax1.plot(benchmark_value.index, benchmark_value, label=f'基准: {benchmark_name} ({benchmark_ticker})', color='grey', linestyle='--', alpha=0.8)

ax1.legend(loc='upper left')

# 图 2: 回撤曲线
fig2, ax2 = plt.subplots(figsize=(14, 5))
ax2.plot(drawdown.index, drawdown, label='策略回撤', color='red', linewidth=1)
ax2.fill_between(drawdown.index, drawdown, 0, color='red', alpha=0.3) # 填充回撤区域
ax2.set_xlabel('日期')
ax2.set_ylabel('回撤')
ax2.set_title('策略回撤曲线')
ax2.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1.0)) # 格式化为百分比
ax2.grid(True)
ax2.legend(loc='lower right')


# 图 3: 资产权重变化图 (可选,按月展示)
fig3, ax_weights = plt.subplots(figsize=(14, 6))
# 按月重采样,获取月末的权重
weights_history_monthly = weights_history.resample('M').last().dropna()
# 获取有效的 Ticker 名称用于图例
valid_ticker_names = [f"{t} ({a_share_etfs[t]['name']})" for t in weights_history_monthly.columns]

ax_weights.stackplot(weights_history_monthly.index, weights_history_monthly.T,
                     labels=valid_ticker_names, alpha=0.8)
ax_weights.set_xlabel('日期')
ax_weights.set_ylabel('资产权重')
ax_weights.set_title('资产权重月度变化 (固定权重目标,含漂移)')
ax_weights.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize='small') # 图例放在右侧外部
ax_weights.yaxis.set_major_formatter(mtick.PercentFormatter(xmax=1.0))
ax_weights.grid(True, axis='y')


plt.tight_layout() # 调整布局避免重叠
plt.show()

print("\n回测与可视化完成。")
print("再次提醒:此为简化示例,实际投资决策需更深入研究和风险评估。")

如何运行和理解代码:

  1. 安装库: 确保你的 Python 环境安装了 pandas, numpy, akshare, matplotlib。在终端或命令提示符运行 pip install pandas numpy akshare matplotlib
  2. 运行脚本: 将代码保存为 .py 文件(例如 all_weather_a_share.py),然后在终端运行 python all_weather_a_share.py
  3. 查看输出:
    • 控制台会打印出选择的 ETF、目标权重、数据获取过程、绩效指标(累计收益、年化收益、波动率、夏普比率、最大回撤)。
    • 会弹出三个图表:
      • 净值曲线图: 显示策略随时间增长的情况,并与沪深 300 ETF 基准进行对比。
      • 回撤曲线图: 显示策略从前期高点下跌的幅度,帮助理解风险。
      • 权重变化图: 展示各资产在组合中的实际权重随时间的变化(由于市场波动产生的漂移以及再平衡的效果)。

进一步改进的方向(进阶):

  1. 风险平价模型: 替换固定权重逻辑,实现基于风险贡献的权重分配。这需要估计资产的波动率和相关性(协方差矩阵),并使用优化算法求解。可以使用 PyPortfolioOpt 库或自己实现。
  2. 商品替代方案: 深入研究 A 股市场是否有更合适的商品类 ETF 或其他工具(如商品期货 ETF、相关行业指数等)来代表商品风险敞口。
  3. 考虑交易成本: 在回测中加入佣金、印花税(股票卖出时)等交易成本。
  4. 滑点模拟: 加入买卖价差或冲击成本的估计。
  5. 动态调整: 基于宏观经济指标(如 PMI、CPI、GDP 增长率等)构建模型,动态调整资产权重。
  6. 滚动回测: 使用滚动时间窗口进行回测,评估策略参数的稳定性和样本外表现。
  7. 更复杂的基准: 使用股债混合指数等作为业绩比较基准。

希望这个 A 股版本的示例能帮助你理解全天候策略在本地市场的开发思路!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值