沪深300 ETF 网格交易策略

ETF 网格交易策略是一种典型的利用价格波动、通过分批低买高卖获取收益的量化交易策略。它不需要准确预测市场未来的涨跌方向,而是在预先设定的价格区间内,按照固定的价格间隔布置买卖订单,通过反复交易赚取价格波动产生的差价。本文通过 Backtest 引擎,对模拟数据进行网格划分、信号获取、调仓和结果分析。

1. 策略介绍

网格交易把价格波动转换为分层买卖决策,适合研究反复震荡时的仓位变化。该策略的主要思想是:价格下跌时逐步买入,价格上涨时逐步卖出,在震荡行情中反复获取价差收益。

1.1 网格层级

以首个正式行情日的开盘价作为基准价 P 0 ,收盘价为 C t ,网格宽度 w = 0.02。原始层级为:

x t = C t / P 0 − 1 w , N t = max ( − 5 , min ( 5 , trunc ( x t ) ) )

其中 trunc 表示向零取整,正数向下取整、负数向上取整。价格不足一格时层级为 0;例如相对基准价上涨 3% 对应第 1 层,下跌 3% 对应第 -1 层。网格价格是 P 0 ( 1 + 0.02 ⁢ k ) ,不是相邻网格之间复利递增 2%。截断到 ±5 表示超出基准价 ±10% 后层级不再继续扩展,不是止损边界。

以最近一次记录的信号层级 orderLevel 为 L, Δ = N t − L 。当 Δ ≤ − 1 时记录买入意图;当 Δ ≥ 1 且当前多头持仓大于 0 时记录卖出意图。跨多格会合并为一个意图,以 Δ 作为金额倍数。向上移动但没有持仓时不会更新 orderLevel,因而后续比较仍以旧层级为准。

1.2 交易标的

交易池仅包含单一标的 510300.SH,本案例使用模拟数据,输入价格与成交量由随机值构造,不是该 ETF 的真实历史行情。账户仅做多,使用日频行情;本例不导入真实沪深 300 指数,也不设置可用于评估超额收益的真实基准。

回测配置区间为 2025-08-13 至 2026-08-12,初始现金为 100 万元。日线策略只根据每日收盘价判断层级,不追踪盘中是否触及多个网格。

1.3 交易规则

  1. 首个正式行情日保存基准价,将 orderLevel 设为 0,只记录 init 意图,不生成普通网格信号。

  2. 下一不同日期的有效回调先执行旧意图。初始建仓预算为 50 万元,普通买卖预算为跨格数乘以 10 万元。

  3. 用执行日开盘价换算数量,按 100 份向下取整;卖出数量再限制为不超过当前多头总持仓。

  4. 清空意图队列后,使用当日收盘价计算新层级,必要时记录下一次买卖意图。

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 排序。没有滚动指标,所以不需要预热行。全部价格、成交量和涨跌停价均为模拟值,不包含停牌、真实公司行为或指数行情。

表 1. 表 2-1 字段说明
字段 DolphinDB 类型 含义

symbol

STRING

固定模拟标签 510300.SH

tradeTime

TIMESTAMP

交易日零点

open、low、high、close

DOUBLE

模拟开、低、高、收价格,元/份,保留 3 位小数

volume

LONG

模拟成交份数,正值

amount

DOUBLE

模拟金额,元,等于 vwap * volume

upLimitPrice、downLimitPrice

DOUBLE

模拟前收盘价的 110%、90%

adj_factor

DOUBLE

固定为 1,无复权事件

vwap

DOUBLE

(open + close) / 2,模拟均价

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%。

由于输入数据为模拟数据,且回测结果受行情生成方式、成交价格假设和成本配置影响,这些指标仅用于说明策略在本案例数据上的运行结果,不能据此评价策略在真实市场中的表现。

表 2. 表 3-1 回测结果
指标 本次结果 解读

区间总收益

-15.0378%

期末权益相对初始资金的累计变化

年化收益

-15.6080%

引擎按回测日收益年化后的结果

年化波动率

10.1556%

价格路径波动幅度

夏普比率

-1.5369

去掉风险波动后的收益

最大回撤

15.0424%

期内净值相对先前高点的最大下跌

回撤比率

-1.0376

引擎返回的收益与回撤相关统计

日胜率

46.8880%

每日收益为正的交易日占比

4. 总结

网格交易策略先根据基准价格划分网格层级,再根据当天价格所处区间来处理买卖信号。本例使用固定基准价、每格 2%、上下各 5 层和每层 10 万元名义金额,使用 Backtest 插件实现了简单的网格策略回测。

评价结果时需关注现金、仓位、未成交处理及开盘撮合读取全天成交量的信息时点。模拟数据没有公司行为或真实基准,用于理解回测流程,真实研究仍需另行准备可靠历史数据。

5. 附录

完整源脚本包含 CSV 导入、行情转换、回调、参数配置、引擎创建及结果查询。