ETF 动量轮动策略

红利与科创 50 的收益驱动因素差异明显:前者偏向低估值与股息回报,后者偏向成长与科技周期。本策略用一条极简的规则在两者之间来回切换——比较两只标的近 20 个交易日的收益率,只持有表现较强的一只,并保持单一标的、接近满仓。策略以 DolphinDB 的 Backtest 回测引擎实现,信号在交易日收盘后产生,订单在次一交易日开盘时成交。

本文所有行情、基准与回测结果均为模拟数据,不对应真实沪深 300 指数、真实成分股或真实市场收益。

1. 策略介绍

本策略只使用一个动量因子,即 20 个交易日收益率 ret20,在两只标的之间做二选一的轮动。下面分别说明因子定义、交易标的和执行规则。

1.1 核心因子

策略用 ret20 衡量标的近期的涨跌力度,计算方式为当前收盘价相对 20 个交易日之前收盘价的变化率:

ret20 t = close t close t − 20 − 1

其中 close 表示日收盘价,下标 t 表示当前交易日。ret20 是无量纲的收益率,例如 ret20 = 0.05 表示近 20 个交易日上涨 5%,ret20 = -0.03 表示下跌 3%。

脚本在数据准备阶段一次性计算该因子,写作 double(close / move(close, 20) - 1),并按标的、按时间排序(context by symbol csort tradeTime),保证每只标的只使用自己的历史价格。每只标的的前 20 条记录因为缺少 20 个交易日之前的价格而为 NULL,脚本遇到 NULL 时跳过当日信号,因此策略从每只标的的第 21 个交易日才开始产生信号。

该因子只用于两只标的之间的相对比较,不设绝对阈值:策略没有“动量不足就空仓”的规则,只要两只标的都有有效因子,资金就始终停留在其中一只标的上。这与同时包含相对动量与绝对动量过滤的经典轮动策略不同。

1.2 交易标的

  • 资产类型与方向:日频基金类多头标的,使用普通现金账户,只做多,不融资、不融券、不做空。

  • 标的映射:脚本用 000001 表示红利类标的(dividendETF),用 000002 表示科创 50 类标的(starETF),两者构成候选池 symbolPool。

  • 行情频率:日频(dataType = 4),每个交易日一条行情。行情时间戳为交易日的零点标记,只表示交易日归属,不代表在零点成交。

  • 模拟标的与真实标的的区别:脚本读取服务器上的一个 CSV 文件,标的编号不带交易所后缀,属于测试用数据。运行前需要确认 000001、000002 在该文件中实际对应的产品,接入真实数据时再替换为真实代码与行情。

  • 交易成本:手续费率 0.00015。脚本把 tax 设为 0.0005,即按卖出金额的 0.05% 计提印花税;基金份额在真实交易中通常免征印花税,接入真实数据前应确认该取值。

1.3 交易规则

一次完整的轮动按以下顺序执行:

  1. 每个交易日收盘后读取两只标的当日的 ret20。只要有一只标的当日没有行情、成交量为 0,或者因子为 NULL,就跳过当天,不产生信号。

  2. 比较两只标的的 ret20,选择数值较大的一只作为目标标的;如果目标标的当日成交量为 0,同样跳过,不产生信号。

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

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

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

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

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

时间顺序上,信号只使用信号日及之前的收盘数据,成交价格取次一交易日的开盘价,因此不存在用未来价格下单的问题。策略每个交易日都重新比较因子,一旦强弱关系反转,就会在下一次开盘完成切换,换手率取决于两只标的相对强弱的切换频率。

数量约束方面,买入数量不足 100 份时不下单;卖出委托按引擎返回的实际持仓数量提交。买入数量用信号日收盘价锁定,次日开盘跳空不会改变目标数量,但实际买入仍受当时可用资金限制,可能买不到目标数量。

2. 策略回测

下文按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

2.1 准备数据

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

表 1. 表 2-1 原始数据字段
列名 类型 说明
securityid STRING 标的编号,取值 000001 与 000002
tradeTime 时间类型 行情时间,日频时以交易日零点标记
open DOUBLE 当日开盘价
low DOUBLE 当日最低价
high DOUBLE 当日最高价
close DOUBLE 当日收盘价
vol 数值类型 成交量
val DOUBLE 成交额
vwap DOULBLE 成交均价

数据准备把原始列改名为回测引擎要求的列名、按标的计算 20 日收益率 ret20、把行情按时间排序。

data = loadText("/.../ETFSeesaw.csv")

// 因子 ret20:在数据侧算好(20 日收益率)
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
tradeBars = select symbol, tradeTime, open, low, high, close, volume, amount,
        upLimitPrice, downLimitPrice, double(close / move(close, 20) - 1) as ret20
    from etfAll context by symbol csort tradeTime
tradeBars = select * from tradeBars 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
ret20 DOUBLE 扩展字段,20 个交易日收益率,供策略回调读取

其中 symbol、tradeTime、volume、amount 等列名需要与引擎校验的名称保持一致,否则行情回放会报错。官方股票回测在分钟频与日频行情下给出的必需字段为 symbol、tradeTime、open、low、high、close、volume、upLimitPrice、downLimitPrice,首列必须是 symbol,同时允许附加 INT、DOUBLE、STRING 类型的扩展列;脚本额外保留了 amount 列(脚本注释说明该列名同样需要与引擎校验的名称一致),并把新增的 ret20 作为扩展列传入。

2.2 编写策略逻辑

策略由 roundLot、initialize 和 onBar组成。roundLot 负责把按资金算出的原始数量调整为可申报的数量,initialize 建立本次回测需要的参数与状态,onBar 负责执行上一信号日锁定的调仓并生成新的信号。

initialize中的关键初始状态如下:

def initialize(mutable context) {
    // 策略参数
    context["commission"] = 0.00015
    context["dividendETF"] = "000001"
    context["starETF"] = "000002"
    context["symbolPool"] = ["000001", "000002"]
    // 调仓状态
    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,以及数据准备阶段附加的扩展列 ret20。

  • indicator:引擎为指标结果预留的参数。脚本没有订阅指标,因此函数签名保留该参数,但策略逻辑没有读取它。

函数没有返回值,副作用包括提交买卖委托、更新调仓状态,以及维护 selectedSymbol 与 targetQty。内先执行上一信号日锁定的调仓,再判断当日是否产生新信号。这个顺序保证用当日收盘数据算出的目标不会在当日成交。

关键实现细节:

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

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

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

  4. 每天比强弱产生信号:每个交易日收盘比较两只标的近 20 个交易日的涨幅,谁强就把谁定为目标(不做阈值判断、只比相对强弱);有任一标的的 20 日数据还没预热够,就当天不动作。

  5. 锁定明天的目标数量:按当天收盘时的账户总权益和目标标的收盘价,算出明天应该持有多少份(取成 100 份的整数倍),连同目标标的记下来,交给明天开盘执行。

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. 每个交易日按 20 日动量择强,锁定次日目标数量 ----------
    dividendETF = context["dividendETF"]
    starETF = context["starETF"]
    if (!(dividendETF in msg) || !(starETF in msg)) { return }
    retDividend = msg[dividendETF]["ret20"]
    retStar = msg[starETF]["ret20"]
    if (isNull(retDividend) || isNull(retStar)) { return }   // 因子预热不足,不产生信号

    target = iif(retDividend > retStar, dividendETF, starETF)
    if (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 万元的场内账户,在 2024 全年按日频行情、按开盘价撮合、按比例收佣金与印花税,模拟"每日收盘择强、次日开盘换仓"的满仓 ETF 跷跷板轮动;0.00015手续费率,买入与卖出都按成交金额的比例收取。手续费率与 initialize 中的 commission 重复设置,两处必须保持一致,否则撮合计价与目标数量计算会使用不同的费率。

config = {
    startDate: 2024.01.01,
    endDate: 2024.12.31,
    strategyGroup: "stock",
    cash: 1000000.0,
    commission: 0.00015,
    tax: 0.0005,
    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 = "dividendSeesawStrategy"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)

同一个节点同时只能有一个 Backtest 引擎实例,引擎会占用节点内存,重复运行脚本前应先删除旧引擎(脚本已通过 dropBacktestEngine 处理),运行结束后也建议手动释放。

2.5 执行回测

数据准备好之后,把行情写入引擎并回放。

Backtest::appendQuotationMsg(engine, tradeBars)
Backtest::appendEndMarker(engine)

这里传入按时间排序的行情表 tradeBars,引擎按 tradeTime 顺序逐条回放,日频行情下每个交易日触发一次 onBar 回调。

成交数量上限受 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)上的实际运行。行情为上述模拟日线(570 行、2 只标的、2023-11-01 至 2024-12-31),回测区间 2024-01-01 至 2024-12-31 共 242 个交易日;初始资金 100 万元,手续费率 0.00015,卖出印花税 0.0005,日频按开盘价撮合,单笔委托不超过行情成交量的 20%。

表 3. 表 3-1 回测结果
指标 数值
区间总收益 -98.81%
年化收益 -99.01%
年化波动率 1353.87%
最大回撤 99.91%
夏普比率 -0.0731
换手率 38.21%
日胜率 50.0%

交易明细共 346 行委托,标签为调仓买入 201 条、调仓卖出 145 条,其中实际成交 183 笔;逐日持仓 187 行、逐日权益 242 行。换手率 38.21%,明显高于月末调仓类策略:每天都可能更换目标标的,而每次卖出既要付 0.00015 的手续费,又要付 0.0005 的印花税,一年 145 次卖出委托的费用会持续侵蚀账户权益。

亏损到这个量级需要区分原因。一方面,模拟数据的价格是随机游走,两只模拟标的没有真实的风格轮动结构,20 日动量在其中没有可捕捉的规律,年化波动率 1353.87%、最大回撤 99.91% 本身就说明模拟价格路径极端;另一方面,日频切换叠加印花税与手续费会放大成本。因此这份结果只能说明流程跑通:每天比较两只标的的 ret20、信号与成交分处两个交易日、换仓按 100 份整数倍执行、印花税按卖出金额计收、结果对象可用,不能据此判断“红利与科创 50 跷跷板”在真实市场上有效或无效。

4. 总结

本策略用单一动量因子 ret20 在红利类与科创 50 类两只标的之间做相对强弱轮动:每个交易日比较两只标的近 20 个交易日的收益率,锁定较强的一只作为目标,保持单一持仓,并在次一交易日开盘先卖后买完成切换,买入数量按 100 份取整、按可用资金缩量。因子在数据侧用 move 按标的一次算好并作为扩展列传给引擎;回调内把“执行上一信号”与“产生新信号”分成两段,避免当日信号当日成交;买入前用预计现金与引擎可用资金取最小值做双重约束;每次调仓只尝试一次,不追单。

使用前需要注意:手续费率与印花税要按标的实际费率设置;20% 的成交量撮合比例可能造成部分成交,需要考虑放大回测资金或提高撮合比例;回测未模拟涨跌停约束,且不含基准对比。若要进一步改进,可以在现有框架上增加绝对动量过滤(下跌趋势时空仓)、为强弱比较加入缓冲阈值以减少来回切换,或按成交限制拆分委托。

5. 附录

以下为本策略的完整策略脚本(包含策略函数、回测配置和引擎执行流程)以及配套的模拟行情数据文件: