黄金 ETF 避险切换策略
本策略以月度为观察窗口,用参考指数近 60 个交易日的累计收益率判断市场处于上涨还是下跌状态:跌幅超过 10% 时把全部资金切换到黄金类标的,否则继续持有股票类标的。策略以 DolphinDB 的 Backtest 回测引擎实现,信号在每月最后一个交易日产生,订单在次一交易日开盘时成交。
本文所有行情、基准与回测结果均为模拟数据,不对应真实沪深 300 指数、真实成分股或真实市场收益。
1. 策略介绍
策略由两部分构成:判断市场状态的绝对动量指标 idxRet60,以及只在月末评估一次的切换规则。下面分别说明因子定义、交易标的和执行规则。
1.1 核心因子
策略用参考指数近 60 个交易日的累计收益率判断市场环境,计算方式为当前收盘价相对 60 个交易日之前收盘价的变化率:
其中 close 为参考指数的日收盘价,下标 t 表示当前交易日。idxRet60 是无量纲的收益率,例如 idxRet60 =
-0.12 表示参考指数近 60 个交易日下跌 12%,低于脚本设定的 -0.10 阈值,意味着转入避险状态。
脚本在数据准备阶段用参考指数(编号 000001)单独计算该序列,写作 double(close / move(close, 60) -
1),且只保留 securityid == "000001"
的记录;随后按交易日把该序列左连接到待交易标的的日线上,使每个交易日的两条行情都带上同一个指数收益率:
tradeBars = select symbol, tradeTime, open, low, high, close, volume, amount,
upLimitPrice, downLimitPrice, idxRet60
from lj(etfAll, idxRetTbl, "tradeTime") order by tradeTime
参考指数的前 60 条记录因为缺少 60 个交易日之前的价格而为 NULL,脚本遇到 NULL 直接跳过,因此从数据文件的起始位置开始回测时,前 60 个交易日不会产生任何信号。
与常见的“权益—避险”轮动策略相比,本策略有三点差异:
-
判断市场状态用的是参考指数,而不是被交易标的自身的收益率。
-
评估频率为每月一次,而不是每日或每周。
-
阈值固定为 -10%,没有波动率调整或信号平滑。
1.2 交易标的
-
资产类型与方向:日频基金类多头标的,使用普通现金账户,只做多,不融资、不融券、不做空。
-
标的映射:脚本用 000002 表示股票类标的(equityETF),用 000003 表示黄金类避险标的( goldETF),两者构成候选池 symbolPool;编号 000001 只作为计算因子用的参考指数,不作为交易标的。
-
行情频率:日频(dataType = 4),每个交易日一条行情。行情时间戳为交易日的零点标记,只表示交易日归属,不代表在零点成交。
-
交易成本:手续费率 0.00015,脚本把 tax 设为 0.0,即不计提印花税。接入真实数据时应按实际交易品种确认该项设置。
1.3 交易规则
一次完整的评估与切换按以下顺序执行:
-
每个交易日的行情回调中先判断当天是否为当月最后一个交易日,只有两者相等的那一天才继续下面的步骤,其余交易日直接返回。
-
读取参考指数当日的 idxRet60。如果该值为 NULL(因子预热不足),跳过,不产生信号。
-
比较 idxRet60 与阈值:idxRet60 小于 -0.10 时目标标的为黄金类标的 000003,否则为股票类标的 000002。
-
检查目标标的当日是否有行情且成交量大于 0,不满足则跳过,不产生信号。
-
以信号日的账户总权益为基数计算目标数量:目标数量 = 账户权益 ÷ (收盘价 × (1 + 手续费率)),再向下取整到 100 份。
-
保存目标标的、目标数量和信号日,并把“待执行调仓”标记置为真。
-
次一交易日的行情回调中先卖出不在目标中的持仓,委托价格取当日开盘价,数量为当前持仓数量。
-
随后买入目标标的,数量为“目标数量 − 当前持仓数量”,并同时受估算现金与引擎返回的可用资金限制,按 100 份取整。
-
无论买入是否全部成交,执行一次后即清除待执行标记,后续交易日不会自动追单补足目标数量。
时间顺序上,信号只使用月末交易日的收盘数据,成交价格取次一交易日的开盘价,因此不存在用未来价格下单的问题。由于信号只在每月最后一天产生,组合在月中不会调整,即使参考指数月中已经跌破阈值,也要等到下一个月末才会触发切换。
数量约束方面,买入数量不足 100 份时不下单;卖出委托按引擎返回的实际持仓数量提交。每次切换时目标数量按信号日收盘价锁定,实际买入受次日可用资金限制,可能买不到目标数量。当目标标的与当前持仓相同时,脚本仍会计算目标数量并补足差额,因此每月月末会对已有持仓做一次再平衡。
2. 策略回测
脚本按股票账户的日频回测编写(strategyGroup 设为 "stock"),需要服务器已安装 Backtest 与 MatchingEngineSimulator 两个插件,能够读取脚本中给出的行情 CSV 文件,并且已维护上交所交易日历(月末判断依赖它)。
2.1 准备数据
下载附录中的 stockDaily_gold.csv 文件,作为回测的案例数据。先将对应区间的 CSV 数据导入,脚本中data = loadText("/.../stockDaily_gold.csv") 改为实际存储 CSV 文件的真实路径。
| 列名 | 类型 | 说明 |
|---|---|---|
| securityid | STRING | 标的编号,文件中包含 000001、000002、000003 |
| tradeTime | 时间类型 | 行情时间,日频时以交易日零点标记 |
| open | DOUBLE | 当日开盘价 |
| low | DOUBLE | 当日最低价 |
| high | DOUBLE | 当日最高价 |
| close | DOUBLE | 当日收盘价 |
| vol | 数值类型 | 成交量 |
| val | DOUBLE | 成交额 |
| vwap | DOUBLE | 成交均价 |
数据准备先用参考指数 000001 的收盘价计算出 60 日收益率序列 idxRet60 ,再把该序列按交易日拼接到待交易标的 000002、000003 的日线上。
data = loadText("/.../stockDaily_gold.csv")
// 因子 idxRet60:在数据侧算好(用 000001 作为参考指数计算 60 日收益率)
idxRetTbl = select timestamp(tradeTime) as tradeTime, double(close / move(close, 60) - 1) as idxRet60
from data where securityid == "000001" order by tradeTime
// 回测标的行情(000002 / 000003);symbol / tradeTime / volume / amount 为回测引擎校验的行情列名
etfAll = select securityid as symbol, timestamp(tradeTime) as tradeTime, open, low, high, close,
long(vol) as volume, val as amount, double(NULL) as upLimitPrice, double(NULL) as downLimitPrice
from data where securityid in ["000002", "000003"]
tradeBars = select symbol, tradeTime, open, low, high, close, volume, amount,
upLimitPrice, downLimitPrice, idxRet60
from lj(etfAll, idxRetTbl, "tradeTime") order by tradeTime
传给回测引擎的行情表包含以下列:
| 列名 | 类型 | 说明 |
|---|---|---|
| symbol | STRING | 标的代码,由 securityid 改名而来 |
| tradeTime | TIMESTAMP | 行情时间,由 timestamp(tradeTime) 转换而来 |
| open / low / high / close | DOUBLE | 当日开高低收价格 |
| volume | LONG | 成交量,由 long(vol) 转换而来 |
| amount | DOUBLE | 成交额,由 val 改名而来 |
| upLimitPrice / downLimitPrice | DOUBLE | 涨跌停价,脚本传 NULL |
| idxRet60 | DOUBLE | 扩展字段,参考指数 60 个交易日收益率 |
其中 symbol、tradeTime、volume、amount 等列名需要与引擎校验的名称保持一致,否则行情回放会报错。官方股票回测在分钟频与日频行情下给出的必需字段为 symbol、tradeTime、open、low、high、close、volume、upLimitPrice、downLimitPrice,首列必须是 symbol,同时允许附加 INT、DOUBLE、STRING 类型的扩展列;脚本额外保留了 amount 列,并把新增的 idxRet60 作为扩展列传入。
2.2 编写策略逻辑
策略由 roundLot、initialize 和 onBar 组成。roundLot 负责把按资金算出的原始数量调整为可申报的数量,initialize 建立本次回测需要的参数与状态,onBar 负责执行上一信号日锁定的切换,并在每月最后一个交易日判断是否需要切换持仓。
initialize 中的关键初始状态如下:
def initialize(mutable context) {
// 策略参数
context["commission"] = 0.00015
context["equityETF"] = "000002"
context["goldETF"] = "000003"
context["symbolPool"] = ["000002", "000003"]
context["riskThresh"] = -0.10
// 调仓状态
context["pendingRebalance"] = false
context["signalDate"] = NULL
context["selectedSymbol"] = ""
context["targetQty"] = 0l
}
日线回调:onBar
onBar(mutable context, msg, indicator) 是日频行情回调,每个交易日执行一次,既负责执行上一信号日留下的调仓,也负责在月末判断是否需要切换。
-
context:可变 DICTIONARY,包含初始化写入的参数与状态,引擎还会注入当前行情的 tradeTime、当前交易日 tradeDate 和引擎句柄 engine。
-
msg:DICTIONARY,以标的代码为键,值是该标的当日行情记录。函数从中读取 open、close、volume,以及数据准备阶段附加的扩展列 idxRet60。
-
indicator:引擎为指标结果预留的参数。脚本没有订阅指标,因此函数签名保留该参数,但策略逻辑没有读取它。
该函数没有返回值,副作用包括提交买卖委托、更新调仓状态,以及维护 selectedSymbol 与 targetQty。每根日线 bar 做两件事:先执行昨天锁定的调仓,再判断今天要不要产生新信号;信号与执行分离,杜绝未来数据。
关键实现细节:
-
决定今天是否调仓:昨天收盘已经定好目标,所以今天开盘才动手执行——信号与成交错开一天,不用当天信息做当天交易。
-
先卖后买:先把当前持有但不是今天目标的持仓按开盘价全部卖出,并把这笔卖出回款立即算作当天可以买入的资金,避免“卖了却买不进”。
-
买入目标标的:用目标数量减去已有持仓算出还缺多少,再受当天可用资金限制、按 100 份的整数倍缩小,最后按开盘价买入;一次调仓只尝试一次,不成交就等下一次月末机会。
-
只在每月最后一个交易日判断风险:其余交易日不产生任何信号;只在那一天读取参考指数近 60 个交易日的涨跌幅(行情里已备好),跌幅超过 10% 就把目标换成避险标的,否则继续持有原标的;若该指数的 60 日数据还没预热够,当天不动作。
-
锁定明天的目标数量:按当天收盘时的账户总权益和目标标的收盘价,算出明天应该持有多少份(取成 100 份的整数倍),连同目标标的记下来,交给明天开盘执行。
要点:每个月末只看一次参考指数的 60 日涨跌幅,跌幅超过 10% 就在下一个交易日开盘换到避险标的,否则保持持有原标的;因为一个月才可能动一次,绝大多数交易日什么都不做;始终满仓押一只标的、不加杠杆也不空仓;先卖后买保证切换能在同一天内完成;如果次日因停牌或资金不足没成交,这次调仓就作废,要等下一个自然月末。
def onBar(mutable context, msg, indicator) {
curDate = date(msg.values()[0]["tradeTime"])
// ---------- 1. 执行信号日锁定的切换:次一交易日按开盘价成交 ----------
if (context["pendingRebalance"] && curDate != context["signalDate"]) {
expectedCash = Backtest::getAvailableCash(context.engine)
// 先卖出不在目标中的持仓
for (s in context["symbolPool"]) {
if (!(s in msg) || msg[s]["volume"] <= 0) { continue }
pos = Backtest::getPosition(context.engine, s)
held = iif(size(pos) > 0, long(pos["longPosition"]), 0l)
if (s != context["selectedSymbol"] && held > 0) {
Backtest::submitOrder(context.engine,
(s, context.tradeTime, 5, msg[s]["open"], held, 3), "调仓卖出")
expectedCash = expectedCash + held * msg[s]["open"] * (1 - context["commission"])
}
}
// 再买入目标标的
s = context["selectedSymbol"]
if ((s in msg) && msg[s]["volume"] > 0) {
bar = msg[s]
pos = Backtest::getPosition(context.engine, s)
held = iif(size(pos) > 0, long(pos["longPosition"]), 0l)
need = context["targetQty"] - held
if (need > 0) {
cash = min(expectedCash, Backtest::getAvailableCash(context.engine))
qty = min(need, roundLot(cash / (bar["open"] * (1 + context["commission"]))))
if (qty > 0) {
Backtest::submitOrder(context.engine,
(s, context.tradeTime, 5, bar["open"], qty, 1), "调仓买入")
}
}
}
context["pendingRebalance"] = false
}
// ---------- 2. 每月最后一个交易日产生调仓信号 ----------
if (transFreq(monthEnd(curDate), "XSHG") != curDate) { return }
ret60 = msg[context["equityETF"]]["idxRet60"] // 当日指数 60 日收益率(因子)
if (isNull(ret60)) { return } // 因子预热不足,不产生信号
target = iif(ret60 < context["riskThresh"], context["goldETF"], context["equityETF"])
if (!(target in msg) || msg[target]["volume"] <= 0) { return }
equity = Backtest::getTotalPortfolios(context.engine).totalEquity[0]
context["selectedSymbol"] = target
context["targetQty"] = roundLot(equity / (msg[target]["close"] * (1 + context["commission"])))
context["pendingRebalance"] = true
context["signalDate"] = curDate
}
2.3 设置回测参数
用一个 100 万元的场内账户,在 2025-08-13 至 2026-08-12 这一年内,按日频行情做 ETF 之间的满仓切换。回测参数主要包括手续费率
0.00015、两个标的代码与避险阈值 -0.10 等,其中手续费率与 initialize 中的
commission 重复设置,两处必须保持一致,否则撮合计价与目标数量计算会使用不同的费率。
config = {
startDate: 2025.08.13,
endDate: 2026.08.12,
strategyGroup: "stock",
cash: 1000000.0,
commission: 0.00015,
tax: 0.0,
dataType: 4,
outputOrderInfo: true,
matchingMode: 2,
orderBookMatchingRatio: 0.2,
enableMinimumPerTransactionFee: false
}
2.4 创建回测引擎
创建引擎前需要先加载两个插件:
-
MatchingEngineSimulator:模拟撮合引擎插件,Backtest 的撮合依赖它。
-
Backtest:回测插件,提供
createBacktester、submitOrder、appendQuotationMsg等接口。
try{loadPlugin("MatchingEngineSimulator")}catch(ex){print ex}
try{loadPlugin("Backtest")}catch(ex){print ex}
go
回调函数通过 DICTIONARY 注册:键名使用 initialize 与 onBar,值与函数名对应。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
创建引擎时先删除同名引擎,再调用 createBacktester:
engineName = "goldEtfSwitchStrategy"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)
loadPlugin 只加载本机已安装的插件,安装插件需执行官方安装包中的脚本;引擎名在同一会话内不能重复,故创建前先用
Backtest::dropBacktestEngine 清理同名引擎。
2.5 执行回测
数据准备好之后,把行情写入引擎并回放。
Backtest::appendQuotationMsg(engine, tradeBars)
Backtest::appendEndMarker(engine)
这里传入按时间排序的行情表 tradeBars,引擎按 tradeTime 顺序逐条回放,日频行情下每个交易日触发一次 onBar 回调;一年左右的区间会触发约 240 次回调,其中只有月末交易日进入信号分支。撮合方式由 matchingMode 决定:取 2 时以当日开盘价撮合,脚本也用 msg[s]["open"] 作为委托价,因此委托价与成交价一致。
-
成交数量上限受 orderBookMatchingRatio 影响,约为当日成交量的 20%。当目标数量较大而当日成交量较小时,委托可能只成交一部分,剩余部分在本次回调结束后不会继续挂单。
2.6 获取回测结果
回测结束后按四类结果读取数据:
tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
-
tradeDetails:每笔委托连同它的成交与状态变化各占一行,可按
label(“调仓买入”等)追到是哪次调仓、成交价是否等于次日开盘价、成交手数与状态。 -
returnSummary:查看区间收益、年化收益与波动、偏度峰度、夏普、最大回撤与回撤比率、beta/alpha、换手率与日胜率。
-
dailyPosition:查看每个标的逐日的多空持仓、持仓均价、当日买卖量与金额以及收盘价。
-
totalPortfolios:查看账户现金、市值、总权益、净值与累计收益率的逐日曲线,以及浮盈浮亏、已实现盈亏、手续费和冻结资金。
3. 回测结果分析
本节结果来自 DolphinDB Server 3.00.6(Linux x86_64)上的实际运行。行情为上述模拟日线(1,170 行、3 只标的),回测区间 2025-08-13 至 2026-08-12 共 242 个交易日;初始资金 100 万元,手续费率 0.00015,不收印花税,日频按开盘价撮合,单笔委托不超过行情成交量的 20%。
| 指标 | 数值 |
|---|---|
| 区间总收益 | 179.91% |
| 年化收益 | 192.07% |
| 年化波动率 | 1075.25% |
| 最大回撤 | 90.39% |
| 夏普比率 | 0.1786 |
| 换手率 | 2.19% |
| 日胜率 | 50.22% |
交易明细共 60 行委托,标签为调仓买入 35 条、调仓卖出 25 条,其中实际成交 37 笔,其余为未全部成交的委托记录,与 20% 的成交量参与度上限有关。逐日持仓 341 行、逐日权益 242 行,与 242 个交易日对应。切换只在月末发生,换手率仅 2.19%,符合低频避险切换的设计。
收益与风险指标要放在模拟数据的背景下阅读:年化波动率 1075.25%、最大回撤 90.39% 说明模拟价格路径远比真实 ETF 剧烈,179.91% 的总收益来自随机价格路径的极端波动,而非策略获得稳定优势。这份结果能证明流程正确:因子在数据侧算出并送达引擎、月末判断按月生效、次一交易日按开盘价完成卖出与买入、委托数量符合 100 份整数倍;它不能证明策略在真实市场有效。
4. 总结
本策略是一个基于绝对动量的月度避险切换模型:在每月最后一个交易日用参考指数近 60 个交易日的收益率判断市场环境,跌幅超过 10% 时持有黄金类避险标的,否则持有股票类标的,保持单一持仓,并在次一交易日开盘先卖后买完成切换,买入数量按 100 份取整、按可用资金缩量。
使用前需要注意:月末判断依赖服务器上的上交所交易日历,日历未更新时月末日期会判断错误;判断市场状态的是参考指数而非标的自身,行情序列的口径必须核对;20% 的成交量撮合比例在小成交量标的上可能造成部分成交;回测未模拟涨跌停约束,且不含基准对比。若要进一步改进,可以在现有框架上加入日频监控与阈值迟滞(月中跌破后提前或延后切换)、用多个月度阈值降低单一参数的敏感性,或把判断对象换成标的自身收益与指数的组合。
