沪深300 ETF 网格交易策略
ETF 网格交易策略是一种典型的利用价格波动、通过分批低买高卖获取收益的量化交易策略。它不需要准确预测市场未来的涨跌方向,而是在预先设定的价格区间内,按照固定的价格间隔布置买卖订单,通过反复交易赚取价格波动产生的差价。本文通过 Backtest 引擎,对模拟数据进行网格划分、信号获取、调仓和结果分析。
1. 策略介绍
网格交易把价格波动转换为分层买卖决策,适合研究反复震荡时的仓位变化。该策略的主要思想是:价格下跌时逐步买入,价格上涨时逐步卖出,在震荡行情中反复获取价差收益。
1.1 网格层级
以首个正式行情日的开盘价作为基准价 ,收盘价为 ,网格宽度 w = 0.02。原始层级为:
其中 trunc 表示向零取整,正数向下取整、负数向上取整。价格不足一格时层级为 0;例如相对基准价上涨 3% 对应第 1 层,下跌 3% 对应第 -1 层。网格价格是 ,不是相邻网格之间复利递增 2%。截断到 ±5 表示超出基准价 ±10% 后层级不再继续扩展,不是止损边界。
以最近一次记录的信号层级 orderLevel 为 L, 。当 时记录买入意图;当 且当前多头持仓大于 0 时记录卖出意图。跨多格会合并为一个意图,以 作为金额倍数。向上移动但没有持仓时不会更新 orderLevel,因而后续比较仍以旧层级为准。
1.2 交易标的
交易池仅包含单一标的 510300.SH,本案例使用模拟数据,输入价格与成交量由随机值构造,不是该 ETF 的真实历史行情。账户仅做多,使用日频行情;本例不导入真实沪深 300 指数,也不设置可用于评估超额收益的真实基准。
回测配置区间为 2025-08-13 至 2026-08-12,初始现金为 100 万元。日线策略只根据每日收盘价判断层级,不追踪盘中是否触及多个网格。
1.3 交易规则
首个正式行情日保存基准价,将 orderLevel 设为 0,只记录 init 意图,不生成普通网格信号。
下一不同日期的有效回调先执行旧意图。初始建仓预算为 50 万元,普通买卖预算为跨格数乘以 10 万元。
用执行日开盘价换算数量,按 100 份向下取整;卖出数量再限制为不超过当前多头总持仓。
清空意图队列后,使用当日收盘价计算新层级,必要时记录下一次买卖意图。
2. 策略回测
以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.9 与 MatchingEngineSimulator 3.00.6.9 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前的代码会检查并加载插件。
2.1 准备数据
本案例使用附录提供的模拟数据,先将配套附件保存到 DolphinDB 服务器可读取的目录,再将路径变量修改为对应 CSV 文件在服务器上的实际路径。
csvPath = "ETF300.csv"
actualStartDate = 2025.08.13
endDate = 2026.08.12
csvSchema = table(
["symbol", "tradeTime", "open", "low", "high", "close", "volume", "amount",
"upLimitPrice", "downLimitPrice", "adj_factor", "vwap"] as name,
["STRING", "TIMESTAMP", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "LONG", "DOUBLE",
"DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE"] as type)
data = loadText(csvPath, ",", csvSchema, containHeader=true)
data = select * from data order by tradeTime, symbol
stockPool = distinct(data.symbol)
模拟数据中,每个 XSHG 交易日一条记录,时间戳为交易日零点,按 tradeTime、symbol 排序。没有滚动指标,所以不需要预热行。全部价格、成交量和涨跌停价均为模拟值,不包含停牌、真实公司行为或指数行情。
| 字段 | DolphinDB 类型 | 含义 |
|---|---|---|
symbol |
STRING |
固定模拟标签 510300.SH |
tradeTime |
TIMESTAMP |
交易日零点 |
open、low、high、close |
DOUBLE |
模拟开、低、高、收价格,元/份,保留 3 位小数 |
volume |
LONG |
模拟成交份数,正值 |
amount |
DOUBLE |
模拟金额,元,等于 |
upLimitPrice、downLimitPrice |
DOUBLE |
模拟前收盘价的 110%、90% |
adj_factor |
DOUBLE |
固定为 1,无复权事件 |
vwap |
DOUBLE |
|
2.2 编写策略逻辑
策略包括买卖数量、条件四个辅助函数和initialize初始化函数、onBar 回调函数。辅助函数负责数量和价格判断,初始化函数建立状态,回调函数处理旧意图和生成新意图。
初始化函数 initialize
initialize(mutable context) 是回测引擎的初始化回调。每次创建新引擎时调用一次,用于建立本次回测需要的可变状态。
context:可变 DICTIONARY,由创建引擎时传入。函数在其中写入调仓计数、待执行标记、信号日期、入选股票和目标数量。
函数没有显式返回值,而是更新 context。网格策略主要参数为gridMaxLevel, 该例中context["gridMaxLevel"] = 5 表示该策略上下各分5层。
def initialize(mutable context) {
context["initialCapital"] = 1000000.0
context["maxStockPct"] = 1.0
context["gridMaxLevel"] = 5
context["basePrice"] = dict(STRING, DOUBLE)
context["state"] = dict(STRING, STRING)
context["lowestSince"] = dict(STRING, DOUBLE)
context["highestSince"] = dict(STRING, DOUBLE)
context["triggerN"] = dict(STRING, INT)
context["orderLevel"] = dict(STRING, INT)
context["initialized"] = dict(STRING, BOOL)
// 延迟成交队列(信号 bar 记意图,下根 bar 入口用 bar.open 撮合) — 跟 signal/pair 路径口径一致
context["pendingGridAction"] = dict(STRING, STRING) // "init" / "buy" / "sell"
context["pendingGridN"] = dict(STRING, INT) // 触发层数 N(init 时为 1)
context["pendingGridDate"] = NULL
}
回调函数onBar
context:可变 DICTIONARY,包含引擎注入的 engine、tradeTime 及策略状态。
msg:DICTIONARY,以股票代码为键,以当日该股票的日线及因子记录为值。
indicator:引擎为指标结果预留的参数。本例没有配置独立指标,因此函数签名保留该参数,但策略逻辑没有读取它。
函数无显式返回值,主要作用包括更新状态字典和提交订单。
首先排除 2025-08-13 前的行情,再执行旧意图并计算当日新信号,代码读取执行日全天成交量决定是否提单。
def onBar(mutable context, msg, indicator) {
// === 延迟执行块:取出上根 bar 锁定的意图,用 bar.open 价提单 ===
curDate_ = date(msg.values()[0]["tradeTime"])
// 区间前行情不参与交易;配套CSV不含预热数据
if (curDate_ < 2025.08.13) { return }
if (context["pendingGridDate"] != NULL && curDate_ != context["pendingGridDate"]) {
for (istock in msg.keys()) {
if (!(istock in context["pendingGridAction"])) { continue }
bar = msg[istock]
if (bar["volume"] <= 0) { continue }
action_ = context["pendingGridAction"][istock]
n_pend_ = context["pendingGridN"][istock]
posInfo_d = Backtest::getPosition(context.engine, istock)
posLong_d = iif(size(posInfo_d) > 0, posInfo_d["longPosition"], 0)
if (action_ == "init") {
initBudget_d = 1000000.0 * 0.5 / 1
initQty_d = roundLot(istock, initBudget_d / bar["open"])
if (initQty_d > 0) {
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], initQty_d, 1), "gridInitialBuy")
}
} else if (action_ == "buy") {
qty_d = roundLot(istock, n_pend_ * 100000.0 / bar["open"])
currentValue_d = posLong_d * bar["open"]
maxValue_d = context["initialCapital"] * context["maxStockPct"]
if (qty_d > 0 && currentValue_d < maxValue_d) {
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], qty_d, 1), "gridBuy")
}
} else if (action_ == "sell") {
qty_d = roundLot(istock, n_pend_ * 100000.0 / bar["open"])
qty_d = min(qty_d, posLong_d)
if (qty_d > 0) {
Backtest::submitOrder(context.engine, (istock, context.tradeTime, 5, bar["open"], qty_d, 3), "gridSell")
}
}
}
context["pendingGridAction"] = dict(STRING, STRING)
context["pendingGridN"] = dict(STRING, INT)
context["pendingGridDate"] = NULL
}
for (istock in msg.keys()) {
bar = msg[istock]
posInfo = Backtest::getPosition(context.engine, istock)
posLong = 0
if (size(posInfo) > 0) { posLong = posInfo["longPosition"] }
// 初始化每只股票的状态 + 可选的初始底仓建仓
if (!(istock in context["initialized"])) {
context["basePrice"][istock] = bar["open"]
context["state"][istock] = "idle"
context["lowestSince"][istock] = 0.0
context["highestSince"][istock] = 0.0
context["triggerN"][istock] = 0
context["orderLevel"][istock] = 0
context["initialized"][istock] = true
context["pendingGridAction"][istock] = "init"
context["pendingGridN"][istock] = 1
context["pendingGridDate"] = curDate_
// 初始底仓优先:首个正式 bar 不允许普通网格信号覆盖 init 意图
continue
}
basePrice = context["basePrice"][istock]
widthRatio_ = 0.02
envelopeOK_ = true
levelRaw_ = (bar["close"] / basePrice - 1.0) / widthRatio_
N_ = iif(levelRaw_ >= 0, int(floor(levelRaw_)), int(ceil(levelRaw_)))
N_ = max(min(N_, context["gridMaxLevel"]), -context["gridMaxLevel"])
orderLevel_ = context["orderLevel"][istock]
delta_ = N_ - orderLevel_
if (delta_ <= -1 && envelopeOK_) {
// 跌超 |delta| 层:记买入意图,下根 bar.open 撮合
context["pendingGridAction"][istock] = "buy"
context["pendingGridN"][istock] = abs(delta_)
context["pendingGridDate"] = curDate_
context["orderLevel"][istock] = N_ // 立刻更新 orderLevel,避免下根 bar 信号重复触发
}
else if (delta_ >= 1 && posLong > 0) {
// 涨超 delta 层:记卖出意图,下根 bar.open 撮合
context["pendingGridAction"][istock] = "sell"
context["pendingGridN"][istock] = delta_
context["pendingGridDate"] = curDate_
context["orderLevel"][istock] = N_
}
}
}
2.3 设置回测参数
这里主要设置回测区间、初始资金、撮合时点等,并将回调中使用的参数及日志表放入 context 。以下为完整引擎配置:
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
}
startDate、endDate:DATE 标量,2025.08.13、2026.08.12,定义引擎区间。CSV 生成区间、导入区间和回调起始日期也要保持一致。
strategyGroup:STRING 标量,"stock",采用股票资产回测框架。
cash:DOUBLE 标量,1000000.0 元,为引擎初始现金。
commission:DOUBLE 标量,0.00015,即成交金额的 0.015%,买卖手续费参数。
tax:DOUBLE 标量,0.0,当前未计印花税。
dataType:INT 标量,4,输入日频行情。
outputOrderInfo:BOOL 标量,true,结果包含订单状态信息,不能将所有行都计为成交。
matchingMode:INT 标量,2,日频开盘价撮合。
orderBookMatchingRatio:DOUBLE 标量,与行情订单薄的成交百分比,低频取 0.2。
enableMinimumPerTransactionFee:BOOL 标量,false,不启用每笔最低交易费用。
2.4 创建回测引擎
MatchingEngineSimulator 负责订单撮合,Backtest 负责事件回调、持仓、资金与绩效管理。下面先检查并加载已经安装的插件,再注册初始化和日线回调。加载命令不会自动安装缺少的插件,需先在服务器端安装插件。
引擎通过 config 获取配置与上下文,通过 callbacks 找到策略函数。每次运行使用带时间戳的名称,结果取出后清理本次创建的实例,避免重复使用含旧持仓的引擎。
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["onBar"] = onBar
engineName = "ETF300GridBacktest"
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false)
2.5 执行回测
将排序后的模拟数据日线一次性送入引擎,回调按交易日推进,依次执行信号计算和后续交易。首个正式日只记录底仓意图,下一有效行情日才按开盘价提交底仓订单。
行情发送完毕后,使用Backtest::appendEndMarker(engine),以通知引擎回放结束。
Backtest::appendQuotationMsg(engine, data)
Backtest::appendEndMarker(engine)
2.6 获取回测结果
处理完结束消息后,分别查询收益概述、订单与成交明细、每日持仓和每日账户权益。
tradeDetails = Backtest::getTradeDetails(engine)
returnSummary = Backtest::getReturnSummary(engine)
dailyPosition = Backtest::getDailyPosition(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
if (size(dailyPosition) > 0) { dailyPosition = select * from dailyPosition where tradeDate >= actualStartDate }
if (size(totalPortfolios) > 0) {
totalPortfolios = select * from totalPortfolios where tradeDate >= actualStartDate
update totalPortfolios set strategyNetValue = totalEquity / 1000000.0
}
resultDict = dict(STRING, ANY)
resultDict["inputRowCount"] = size(data)
resultDict["returnSummary"] = returnSummary
resultDict["tradeDetails"] = tradeDetails
resultDict["dailyPosition"] = dailyPosition
resultDict["totalPortfolios"] = totalPortfolios
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
resultDict
3. 回测结果分析
在上述软件版本和非 JIT 模式下,使用附录提供的模拟数据进行回测。本次回测处理 8242 条行情记录,初始权益为 1,000,000.00 元,区间总收益为 -15.0378%。
由于输入数据为模拟数据,且回测结果受行情生成方式、成交价格假设和成本配置影响,这些指标仅用于说明策略在本案例数据上的运行结果,不能据此评价策略在真实市场中的表现。
| 指标 | 本次结果 | 解读 |
|---|---|---|
区间总收益 |
-15.0378% |
期末权益相对初始资金的累计变化 |
年化收益 |
-15.6080% |
引擎按回测日收益年化后的结果 |
年化波动率 |
10.1556% |
价格路径波动幅度 |
夏普比率 |
-1.5369 |
去掉风险波动后的收益 |
最大回撤 |
15.0424% |
期内净值相对先前高点的最大下跌 |
回撤比率 |
-1.0376 |
引擎返回的收益与回撤相关统计 |
日胜率 |
46.8880% |
每日收益为正的交易日占比 |
4. 总结
网格交易策略先根据基准价格划分网格层级,再根据当天价格所处区间来处理买卖信号。本例使用固定基准价、每格 2%、上下各 5 层和每层 10 万元名义金额,使用 Backtest 插件实现了简单的网格策略回测。
评价结果时需关注现金、仓位、未成交处理及开盘撮合读取全天成交量的信息时点。模拟数据没有公司行为或真实基准,用于理解回测流程,真实研究仍需另行准备可靠历史数据。
5. 附录
完整源脚本包含 CSV 导入、行情转换、回调、参数配置、引擎创建及结果查询。
