ETF 大小盘风格轮动策略
本案例使用沪深 300 ETF(510300.SH)和中证 1000 ETF(512100.SH)构建大小盘风格轮动策略。策略比较沪深 300 指数与中证 1000 指数过去 20 个交易日的动量,在每月最后 1 个交易日选择近期相对强势的一类,并于下一交易日开盘完成调仓。本文使用 DolphinDB Backtest 引擎回放模拟数据,演示从数据准备、信号生成、跨日交易、回测执行到结果分析的完整流程。
1. 策略介绍
大小盘风格轮动策略试图在大盘蓝筹与中小市值风格之间进行切换。沪深 300 ETF 可近似代表大盘风格,中证 1000 ETF 可近似代表中小盘风格。策略不直接预测市场涨跌,而是在每个调仓日比较两类风格的近期相对强弱,并将全部资金配置到动量较高的一只 ETF。
1.1 核心因子
本策略基于 20 日动量效应进行大小盘轮动。20 日动量的数学表达式为:(Price_i(t) - Price_i(t-20)) / Price_i(t-20)
具体规则是:以指数作为信号源,对比沪深 300 和中证 1000 的 20 日动量大小。当沪深 300 趋势更强时,做多其对应的场内 ETF;反之,则做多中证 1000 对应的场内 ETF。因子计算与实盘执行分别独立,确保交易的高效与灵活性。
| 因子要素 | 策略设定 | 含义 |
|---|---|---|
| 观察窗口 | 20 个交易日 | 在月度调仓中捕捉近期趋势 |
| 比较对象 | 000300.SH 与 000852.SH | 用指数信号代表两类风格 |
| 信号方向 | 选择动量更高的一方 | 将相对强弱转为单标的仓位 |
| 预热期 | 向前 35 个交易日 | 确保滚动窗口具备历史数据 |
1.2 交易标的
策略交易两个 ETF:沪深 300 ETF(510300.SH)和中证 1000 ETF(512100.SH)。信号计算使用对应指数行情,即 000300.SH 和 000852.SH。组合采用单标的、满仓持有的简化结构,每次只持有一只 ETF。
这种设计便于核验信号、目标仓位、订单和持仓之间的关系。实际研究可进一步扩展为分层仓位、风险预算、波动率目标或多资产组合。
1.3 交易规则
-
每月最后一个交易日比较两个指数的 20 日动量。
-
若目标 ETF 与当前持仓不同,信号日按收盘价确定调仓目标。
-
信号日(T 日)收盘提交卖出意图,下一交易日(T+1 日)开盘卖出非目标 ETF。
-
卖出完成后,T+1 日开盘买入目标 ETF。
-
买入数量按 100 份整数倍向下取整,并预留手续费。
-
买入需满足非停牌且未涨停,卖出需满足非停牌且未跌停。
简而言之,本规则确立了基于月末动量信号的 T+1 调仓框架。策略通过“次日开盘先卖后买”完成底层资产切换,同时内置了资金头寸管理(费率预留、向下取整)及异常交易状态过滤(剔除涨跌停与停牌限制),保障了策略在真实市场环境下的可落地性。
2. 策略回测
以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator) 3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
下载附录中的 feedData.csv 文件,作为回测的案例数据。运行脚本前需将 loadText 中的参数改为实际存放 CSV 文件的路径。
feedData = loadText("/.../feedData.csv")
feedData.replaceColumn!("volume", long(feedData.volume))
feedData.replaceColumn!("upLimitPrice", double(feedData.upLimitPrice))
feedData.replaceColumn!("downLimitPrice", double(feedData.downLimitPrice))
模拟的日线行情数据覆盖预热期和 2024 年全年回测期。数据映射为两只 ETF 和两条指数,包含证券代码、交易时间、开盘价、收盘价、最高价、最低价、成交量和成交量加权平均价(VWAP)等字段。20 日动量按证券代码分组、按交易时间排序后计算。
所需的行情字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| symbol | STRING | 证券代码,包括指数代码与 ETF 代码。 |
| tradeTime | TIMESTAMP | 交易时间,用于按时间顺序回放日线行情。 |
| open | DOUBLE | 开盘价,用于 T+1 日开盘买入撮合。 |
| low | DOUBLE | 当日最低价。 |
| high | DOUBLE | 当日最高价。 |
| close | DOUBLE | 收盘价,用于信号日卖出撮合和收益计算。 |
| volume | LONG | 成交量。 |
| amount | DOUBLE | 成交金额。 |
| upLimitPrice | DOUBLE | 涨停价,用于判断买入是否受涨停限制。 |
| downLimitPrice | DOUBLE | 跌停价,用于判断卖出是否受跌停限制。 |
| adj_factor | DOUBLE | 复权因子,用于统一价格复权口径。 |
| vwap | DOUBLE | 成交量加权平均价,可用于成交价格或行情校验。 |
| ret20 | DOUBLE | 20 日动量收益率,用于比较沪深 300 与中证 1000 的相对强弱。 |
接入真实数据时,应重点检查以下内容:
-
指数与 ETF 交易日是否正确对齐。
-
价格是否采用一致的复权口径。
-
停牌、涨跌停和异常价格是否按真实市场规则处理。
-
历史行情是否覆盖信号计算所需的预热期。
-
指数信号与 ETF 成交标的之间是否存在代码映射错误。
2.2 编写策略逻辑
策略由四个交易辅助函数和两个回调函数组成。
-
roundLot负责买入数量的申报单位处理,针对 ETF 保证按 100 份整数倍向下取整 -
normalizeSellQty负责卖出数量的规范化,在清仓退出时允许卖出不足一个申报单位的剩余可用持仓 -
isBuyableAt与isSellableAt处理停牌和涨跌停约束,过滤无效交易信号 -
initialize初始化回测状态参数 -
onBar执行调仓并产生新信号。
初始化函数:
initialize
initialize(mutable context) 在创建引擎时调用一次。它用于设置手续费以及控制跨日执行流转的关键状态变量:
def initialize(mutable context) {
context["commission"] = 0.00015
context["initialRebalancePending"] = true // 使第一根日线直接产生首次建仓信号
context["pendingBuyStock"] = "" // 增加变量用于缓存跨日(T+1 日)需要买入的标的
}
回调函数:
onBar
onBar 是日线行情回调,也是策略的主执行函数,其内部流程分为三部分:
-
延迟买入执行(T+1 日开盘):如果存在上一交易日(T 日)生成的待买入目标,优先在开盘执行。策略会动态获取当前引擎所有可用资金,检查目标标的的可买状态(非停牌且未涨停),并统一使用当日开盘价提单买入。执行完毕后清空该标记。
-
信号生成与目标选择:判断当日是否为月末最后一个交易日或首日建仓。若是,读取沪深 300 与中证 1000 指数的 20 日动量(ret20)。根据动量孰强原则选定目标 ETF。
-
卖出非目标持仓(T 日收盘):确认目标后,检查账户是否持有非目标 ETF。若持有,在满足可卖条件(非跌停)下,根据 T 日收盘价提交全仓卖出意图。同时,将选定的目标 ETF 写入待买入标的(pendingBuyStock),推迟到下一个交易日(T+1 日)开盘买入。若当前已持有目标 ETF,则不触发卖出,继续持有不动。
逻辑分层与状态管理。这种信号和交易分离的核心状态完全由 context 字典接管。实际执行中需注意:
-
买单提交前不进行理论数量锁定,而是根据 T+1 日开盘的真实可用现金和开盘价动态计算可买数量(扣除手续费并向下取整),适配 T 日卖出资金在 T+1 日可用的结算规则。
-
实盘中,若 T 日卖出因跌停未成交,T+1 日获取的可用现金将不足,从而自然避免“没钱硬买”的无效订单;若 T+1 日目标 ETF 涨停或停牌,
isBuyableAt会拦截指令,策略不会自动在后续交易日盲目追单。
完整的策略逻辑如下:
def onBar(mutable context, msg, indicator) {
curBarTs = msg.values()[0]["tradeTime"]
curDate = date(curBarTs)
// 检查上一交易日(T日)是否留有待买入的标的,若有则优先在开盘执行
if (context["pendingBuyStock"] != "") {
targetStock = context["pendingBuyStock"]
if (targetStock in msg) {
targetBar = msg[targetStock]
buyPrice = targetBar["open"] // T+1日使用开盘价买入
expectedCash = Backtest::getAvailableCash(context.engine)
if (isBuyableAt(buyPrice, targetBar)) {
affordableQty = roundLot(targetStock, expectedCash / (buyPrice * (1 + context["commission"])))
if (affordableQty > 0) {
Backtest::submitOrder(context.engine, (targetStock, context.tradeTime, 5, buyPrice, affordableQty, 1), "rebalance_buy")
}
}
}
context["pendingBuyStock"] = ""
}
// 判断是否为调仓日(月末最后一个交易日,或者首日建仓)
isRebalanceDay = (transFreq(monthEnd(curDate), "XSHG") == curDate) || context["initialRebalancePending"]
context["initialRebalancePending"] = false
if (!isRebalanceDay) { return }
// 读取参考指数的 20 日动量
if (!("000300.SH" in msg && "000852.SH" in msg)) { return }
hs300_ret = double(msg["000300.SH"]["ret20"])
zz1000_ret = double(msg["000852.SH"]["ret20"])
if (isNull(hs300_ret) || isNull(zz1000_ret)) { return }
// 确定目标标的:动量大者胜出
targetStock = iif(hs300_ret > zz1000_ret, "510300.SH", "512100.SH")
otherStock = iif(targetStock == "510300.SH", "512100.SH", "510300.SH")
// T日 卖出非目标持仓(若当前持有另一只 ETF,则清仓卖出)
if (otherStock in msg) {
otherBar = msg[otherStock]
posInfo = Backtest::getPosition(context.engine, otherStock)
pos = 0
pos_sellable = 0
if (size(posInfo) > 0) {
pos = posInfo["longPosition"]
pos_sellable = max(0, posInfo["lastDayLongPosition"] - posInfo["todaySellVolume"])
}
if (pos > 0 && pos_sellable > 0 && isSellableAt(otherBar["close"], otherBar)) {
sellQty = normalizeSellQty(otherStock, pos, pos_sellable)
if (sellQty > 0) {
Backtest::submitOrder(context.engine, (otherStock, context.tradeTime, 5, otherBar["close"], sellQty, 3), "rebalance_sell")
}
}
}
// 记录目标标的,推迟到 T+1 日买入(若已持有目标 ETF 则继续持有,不做重复买入)
if (targetStock in msg) {
targetPosInfo = Backtest::getPosition(context.engine, targetStock)
targetPos = 0
if (size(targetPosInfo) > 0) { targetPos = targetPosInfo["longPosition"] }
// 若当前未持有目标标的,将其记录在 context 中,留待 T+1 日开盘处理
if (targetPos == 0) {
context["pendingBuyStock"] = targetStock
}
}
}
2.3 设置回测参数
本策略的主要回测参数如下:
| 参数 | 设定 | 说明 |
|---|---|---|
| 回测区间 | 2024-01-01 至 2024-12-31 | 覆盖 2024 年全年交易日 |
| 预热期 | 35 个交易日 | 用于计算 20 日动量 |
| 初始资金 | 1,000,000 元 | 模拟账户初始权益 |
| 佣金 | 0.015% | 按成交金额计收 |
| 税费 | 0.05% | 按案例配置计入交易成本 |
| 撮合方式 | 卖出收盘、买入次日开盘 | 模拟跨日调仓 |
示例配置如下:
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 创建回测引擎
将初始化函数和日线回调注册到 callbacks 字典,再创建 Backtest 引擎。重复运行前应清理同名遗留引擎,避免旧持仓、现金或状态影响新结果。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "largeSmallRotationEngine"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)
本案例未开启 JIT 优化。若需要开启,应将创建引擎的最后一个参数改为 true,并先确认回调函数、上下文类型和表操作均符合 JIT 要求。具体检查方法参阅DolphinDB 回测引擎使用和性能优化攻略。
2.5 执行回测
行情按交易时间和证券代码排序后输入引擎。每月批次可额外读取此前 35 个交易日的数据用于预热,但只有回测区间内的数据进入正式回放,避免预热行情重复计入收益。最后通过 appendEndMarker 发送结束标记,并保留当前引擎实例,以便在下一环节提取回测指标与交易明细。
// 提前 35 个交易日预热动量滑动窗口
warmupStartDate = temporalAdd(startDate, -35, "XSHG")
Backtest::appendQuotationMsg(engine, feedData)
Backtest::appendEndMarker(engine)
回放结束后,策略不会强制平仓,最后一个交易日的持仓按收盘价计入组合权益。
2.6 获取回测结果
回测结束后读取收益汇总、交易明细、每日持仓和每日组合净值。结果读取完成后再删除引擎,避免在结果尚未保存时丢失数据。
returnSummary = Backtest::getReturnSummary(engine)
tradeDetails = Backtest::getTradeDetails(engine)
dailyPosition = Backtest::getDailyPosition(engine)
dailyPortfolios = Backtest::getDailyTotalPortfolios(engine)
Backtest::dropBacktestEngine(engineName)
建议重点核验:
-
信号是否只在每月最后一个交易日生成。
-
卖出是否发生在 T 日收盘,买入是否发生在 T+1 日开盘。
-
成交数量是否满足 100 份整数倍规则。
-
订单状态是否区分已成交、未成交和部分成交。
-
每日组合净值是否包含现金、持仓估值和交易成本。
3. 回测结果分析
本章仅汇总附件提供的模拟行情下的回测结果,重点用于检查策略链路是否闭环,不应直接作为实盘投资依据。
| 指标 | 本次结果 | 解读 |
|---|---|---|
| 期末净值 | 0.5023 | 期末权益约为初始资金的 50.23% |
| 总收益 | -49.77% | 策略在该仿真路径上明显亏损 |
| 年化收益 | -46.55% | 未产生正向年度回报 |
| 年化波动率 | 2163.48% | 反映模拟价格的异常波动特征 |
| 夏普比率 | -0.02 | 风险调整后收益缺乏优势 |
| 最大回撤 | 96.49% | 该模拟路径下风险极高 |
| 换手率 | 1.24% | 符合月度低频轮动特征 |
| 成交订单 | 12 笔 | 买入 4 笔、卖出 8 笔 |
本次结果显示,策略技术链路能够完成数据导入、月度信号、跨日交易、撮合和绩效输出。
4. 总结
本策略以两只 ETF 代表大盘和小盘风格,使用指数 20 日相对动量决定月度目标仓位,并按照 T 日收盘信号、T+1 日开盘交易的规则完成轮动。DolphinDB Backtest 负责回放行情、撮合订单、维护资金和持仓,并输出收益汇总、交易明细及每日组合数据。
进入真实研究前,应替换为复权后的真实 ETF 与指数数据,补充停牌、涨跌停、分红拆分、滑点和冲击成本处理,并进行多窗口、多样本、滚动样本外和不同市场阶段的稳定性检验。
