我们来创建一个基于 A 股市场的简化版全天候策略 ETF 回测示例。
核心说明:
- 教学目的: 此代码主要用于教学演示,展示如何在 A 股背景下构思和回测全天候策略的基本流程。并非真实的投资建议,请勿直接用于实盘。
- 简化策略: 采用固定权重的方式,而非更复杂的风险平价模型,以简化理解。
- A 股标的: 选用国内常见的、有代表性的 ETF 作为各资产类别的代理。特别注意: A 股市场缺乏理想的直接对应多种类大宗商品的 ETF,因此商品部分的代表性相对较弱,这里选用有色金属 ETF 作为近似替代。
- 数据获取: 使用
AkShare
库获取 A 股 ETF 的历史数据。你需要先安装它 (pip install akshare
)。AkShare 是一个开源库,数据源来自公开渠道,请注意数据质量和及时性。 - 回测局限: 未考虑交易成本、滑点、ETF 管理费/托管费、冲击成本、分红处理(AkShare 的“累计净值”已包含分红再投)等实际因素。
- 环境要求: Python 环境,并安装
pandas
,numpy
,akshare
,matplotlib
库。
代码结构:
- 环境设置与导入库
- 选择 A 股 ETF 标的与目标权重
- 使用 AkShare 获取历史数据
- 数据预处理与合并
- 固定权重回测引擎
- 计算绩效指标
- 结果可视化
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("再次提醒:此为简化示例,实际投资决策需更深入研究和风险评估。")
如何运行和理解代码:
- 安装库: 确保你的 Python 环境安装了
pandas
,numpy
,akshare
,matplotlib
。在终端或命令提示符运行pip install pandas numpy akshare matplotlib
。 - 运行脚本: 将代码保存为
.py
文件(例如all_weather_a_share.py
),然后在终端运行python all_weather_a_share.py
。 - 查看输出:
- 控制台会打印出选择的 ETF、目标权重、数据获取过程、绩效指标(累计收益、年化收益、波动率、夏普比率、最大回撤)。
- 会弹出三个图表:
- 净值曲线图: 显示策略随时间增长的情况,并与沪深 300 ETF 基准进行对比。
- 回撤曲线图: 显示策略从前期高点下跌的幅度,帮助理解风险。
- 权重变化图: 展示各资产在组合中的实际权重随时间的变化(由于市场波动产生的漂移以及再平衡的效果)。
进一步改进的方向(进阶):
- 风险平价模型: 替换固定权重逻辑,实现基于风险贡献的权重分配。这需要估计资产的波动率和相关性(协方差矩阵),并使用优化算法求解。可以使用
PyPortfolioOpt
库或自己实现。 - 商品替代方案: 深入研究 A 股市场是否有更合适的商品类 ETF 或其他工具(如商品期货 ETF、相关行业指数等)来代表商品风险敞口。
- 考虑交易成本: 在回测中加入佣金、印花税(股票卖出时)等交易成本。
- 滑点模拟: 加入买卖价差或冲击成本的估计。
- 动态调整: 基于宏观经济指标(如 PMI、CPI、GDP 增长率等)构建模型,动态调整资产权重。
- 滚动回测: 使用滚动时间窗口进行回测,评估策略参数的稳定性和样本外表现。
- 更复杂的基准: 使用股债混合指数等作为业绩比较基准。
希望这个 A 股版本的示例能帮助你理解全天候策略在本地市场的开发思路!