黄金 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 交易规则

一次完整的评估与切换按以下顺序执行:

  1. 每个交易日的行情回调中先判断当天是否为当月最后一个交易日,只有两者相等的那一天才继续下面的步骤,其余交易日直接返回。

  2. 读取参考指数当日的 idxRet60。如果该值为 NULL(因子预热不足),跳过,不产生信号。

  3. 比较 idxRet60 与阈值:idxRet60 小于 -0.10 时目标标的为黄金类标的 000003,否则为股票类标的 000002。

  4. 检查目标标的当日是否有行情且成交量大于 0,不满足则跳过,不产生信号。

  5. 以信号日的账户总权益为基数计算目标数量:目标数量 = 账户权益 ÷ (收盘价 × (1 + 手续费率)),再向下取整到 100 份。

  6. 保存目标标的、目标数量和信号日,并把“待执行调仓”标记置为真。

  7. 次一交易日的行情回调中先卖出不在目标中的持仓,委托价格取当日开盘价,数量为当前持仓数量。

  8. 随后买入目标标的,数量为“目标数量 − 当前持仓数量”,并同时受估算现金与引擎返回的可用资金限制,按 100 份取整。

  9. 无论买入是否全部成交,执行一次后即清除待执行标记,后续交易日不会自动追单补足目标数量。

时间顺序上,信号只使用月末交易日的收盘数据,成交价格取次一交易日的开盘价,因此不存在用未来价格下单的问题。由于信号只在每月最后一天产生,组合在月中不会调整,即使参考指数月中已经跌破阈值,也要等到下一个月末才会触发切换。

数量约束方面,买入数量不足 100 份时不下单;卖出委托按引擎返回的实际持仓数量提交。每次切换时目标数量按信号日收盘价锁定,实际买入受次日可用资金限制,可能买不到目标数量。当目标标的与当前持仓相同时,脚本仍会计算目标数量并补足差额,因此每月月末会对已有持仓做一次再平衡。

2. 策略回测

脚本按股票账户的日频回测编写(strategyGroup 设为 "stock"),需要服务器已安装 Backtest 与 MatchingEngineSimulator 两个插件,能够读取脚本中给出的行情 CSV 文件,并且已维护上交所交易日历(月末判断依赖它)。

2.1 准备数据

下载附录中的 stockDaily_gold.csv 文件,作为回测的案例数据。先将对应区间的 CSV 数据导入,脚本中data = loadText("/.../stockDaily_gold.csv") 改为实际存储 CSV 文件的真实路径。

表 1. 表 2-1 原始数据字段
列名 类型 说明
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

传给回测引擎的行情表包含以下列:

表 2. 表 2-2 行情表数据字段
列名 类型 说明
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 做两件事:先执行昨天锁定的调仓,再判断今天要不要产生新信号;信号与执行分离,杜绝未来数据。

关键实现细节:

  1. 决定今天是否调仓:昨天收盘已经定好目标,所以今天开盘才动手执行——信号与成交错开一天,不用当天信息做当天交易。

  2. 先卖后买:先把当前持有但不是今天目标的持仓按开盘价全部卖出,并把这笔卖出回款立即算作当天可以买入的资金,避免“卖了却买不进”。

  3. 买入目标标的:用目标数量减去已有持仓算出还缺多少,再受当天可用资金限制、按 100 份的整数倍缩小,最后按开盘价买入;一次调仓只尝试一次,不成交就等下一次月末机会。

  4. 只在每月最后一个交易日判断风险:其余交易日不产生任何信号;只在那一天读取参考指数近 60 个交易日的涨跌幅(行情里已备好),跌幅超过 10% 就把目标换成避险标的,否则继续持有原标的;若该指数的 60 日数据还没预热够,当天不动作。

  5. 锁定明天的目标数量:按当天收盘时的账户总权益和目标标的收盘价,算出明天应该持有多少份(取成 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%。

表 3. 表 3-1 回测结果
指标 数值
区间总收益 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% 的成交量撮合比例在小成交量标的上可能造成部分成交;回测未模拟涨跌停约束,且不含基准对比。若要进一步改进,可以在现有框架上加入日频监控与阈值迟滞(月中跌破后提前或延后切换)、用多个月度阈值降低单一参数的敏感性,或把判断对象换成标的自身收益与指数的组合。