贵金属期货动态报价与库存管理策略(AS 模型)

本策略面向上海期货交易所黄金与白银期货,采用 Avellaneda–Stoikov(AS)近似模型,基于盘口中间价、在线波动率和实际净库存生成买卖限价委托。策略同时处理报价维护、期货开平仓方向、资金与持仓约束,以及连续竞价时段结束前的平盘。

1. 策略介绍

贵金属期货做市通过持续挂出买入和卖出限价委托提供流动性,尝试获取买卖成交价差。实际交易中,两侧往往不会同时成交:买入后继续下跌、卖出后继续上涨,都会带来库存损失。因此,报价既要保持成交机会,也要随持仓和波动调整。

本案例将针对上期所贵金属沪金/沪银期货合约实现经典的 Avellaneda-Stoikov (AS) 做市策略,接下来将介绍 AS 策略及其模型原理。

1.1 报价模型

AS 模型将这一问题拆成两部分:先根据库存计算报价中心,再根据风险和订单到达强度确定报价宽度。库存偏多时下调报价,降低继续买入的意愿并促进卖出;库存偏空时上调报价,促进买入回补。价格波动增大时,模型价差扩大,补偿持仓风险。

Avellaneda-Stoikov 做市策略模型是由 Marco Avellaneda 和 Sasha Stoikov 于 2008 年在 High-frequency trading in a limit order book 论文中提出。他们主要研究了在库存风险的前提下,做市商如何制定最优报价。基于对市场价格的动态反应,该策略考虑了价格波动性、做市商的仓位以及其风险偏好。AS 模型通过优化市场中间价来调整做市商的买卖报价,从而最小化库存风险并最大化利润。

1.2 AS 策略模型推导

AS 模型的推导分为两个主要步骤。首先,做市商需要计算出其在当前库存和风险偏好下的无差异价格。其次,做市商根据报价与市场中间价之间的距离,结合市场环境和风险承受能力,推算报价单的成交概率,进而确定最优报价。通过这种方式,做市商能够在提供市场流动性的同时,最小化库存风险并最大化利润。

贵金属合约 AS 近似报价公式:

r t = x t − q t γ v t τ t ,

Δ t = γ v t τ t + 2 γ ln ⁡ ( 1 + γ k ) .
  • r t :库存调整后的报价,单位 tick

  • Δ t :模型买卖总价差,单位 tick

  • x t = b t + a t 2 θ :其中, b t 、 a t 为买一价和卖一价, θ 为最小价格变动

  • q t = L t − S t − q ∗ : L t 、 S t 为实际多空持仓, q ∗ 为目标净库存

  • τ t :风险期限,单位小时

  • k :风险厌恶系数

  • γ :风险厌恶系数

  • v t :中间价方差率,单位 tick²/小时

其中方差率根据快照行情实时更新:

v ^ t = ( 1 − α ) v ^ t − 1 + α ( x t − x t − 1 ) 2 Δ h t , α = 0.1 .

其中 Δ h t 为相邻有效快照的时间间隔,单位为小时。

报价公式

b i d = r t − Δ t / 2

a s k = r t + Δ t / 2

模型总价差限制在 2 到12 tick,买价向下取整、卖价向上取整,并保证报价不穿过当前对手价。公式来源见 Avellaneda–Stoikov 原文。

1.3 交易规则

策略在每条有效快照到达时执行以下规则:

  1. 生成报价:读取买一、卖一和引擎实际持仓,更新方差率,计算 AS 买卖价格。

  2. 维护报价:普通报价每秒最多评估一次;价格变动达到 1 tick、净库存改变或旧单全部成交时,撤销旧单并重新挂单。

  3. 优先减仓:持有空头时,买侧使用买平今;持有多头时,卖侧使用卖平今。对应方向没有库存时才提交开仓单。

  4. 风险控制:波动超限或盘口接近涨跌停时撤单暂停报价;总持仓达到限制时撤单并提交平仓单;资金不足时停止新增开仓。

  5. 时段平盘:每个连续竞价时段结束前停止普通报价,多头按买一卖平今,空头按卖一买平今。

沪金、沪银时段按 21:00—次日 02:30、09:00—10:15、10:30—11:30、13:30—15:00 识别。夜盘保留独立的自然日期和交易日。平仓采用对手价限价单,是否成交由后续行情撮合决定。

2. 策略回测

本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator)3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。

本做市策略回测的整体流程是:先加载合约信息与五档快照行情,再定义 AS 做市所需的辅助函数与回调函数,随后设置合约参数与策略参数,最后创建引擎、回放行情、导出结果。下面按顺序展开。

2.1 准备数据

本例直接从 DolphinDB 服务器目录 path 读取两个已准备好的 CSV 文件:

文件 用途
securityReference.csv

合约乘数、tick、保证金率、手续费和最后交易日

preciousMetalSnapshot.csv

合约代码、事件时间、交易日、五档盘口及价格信息

使用 loadText 和显式 schema 加载合约信息与行情数据,供策略和引擎使用:

// 从 CSV 读取合约信息和快照
// ============================================================================
path = "/.../"
referenceNames = ["symbol","multiplier","marginRatio","tradeUnit","priceUnit","priceTick",
    "commission","deliveryCommissionMode","intradayClosingFee","lastTradingDay"]
referenceTypes = ["STRING","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE",
    "DOUBLE","INT","DOUBLE","DATE"]
securityReference = loadText(path + "securityReference.csv",
    schema=table(referenceNames, referenceTypes), containHeader=true)
securityReference = select * from securityReference where symbol = targetSymbol
messageNames = ["symbol","symbolSource","timestamp","tradingDay","lastPrice","upLimitPrice",
    "downLimitPrice","totalBidQty","totalOfferQty","bidPrice","bidQty","offerPrice","offerQty",
    "highPrice","lowPrice","prevClosePrice","prevSettlementPrice","settlementPrice"]
messageTypes = ["STRING","STRING","TIMESTAMP","DATE","DOUBLE","DOUBLE",
    "DOUBLE","LONG","LONG","DOUBLE[]","LONG[]","DOUBLE[]","LONG[]",
    "DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE"]
snapshot = loadText(path + "preciousMetalSnapshot.csv", schema=table(messageNames, messageTypes), containHeader=true)
// 按交易日归属过滤夜盘,按自然时间顺序回放。
snapshot = select * from snapshot  where symbol = targetSymbol, tradingDay = backtestDate order by timestamp

数据准备的关键点:

  • CSV 的字段顺序应与 schema 严格一致,任何一个字段类型或顺序不匹配都会导致加载失败或数据错位。

  • 行情按目标合约和交易日筛选,再按事件时间排序,确保回放顺序与真实行情一致。

  • 合约信息要求目标合约恰好一条记录,避免多行参数导致后续取值不确定。

  • 期货夜盘跨午夜时,自然日期与交易日不同。脚本保留 timestamp 为事件时间,tradingDay 为交易日,便于时段识别和平盘逻辑正确工作。

2.2 编写策略逻辑

完整策略由辅助函数与回调函数组成,职责如下:

表 1. 表 2-1 策略函数
函数 作用
sessionEnd

根据事件时间计算当前连续竞价时段终点

limitOrder

提交整数手期货限价委托

closeAll

撤单,读取实际多空持仓,提交平今委托

beforeTrading

初始化日志、重置当日报价与方差状态

onSnapshot

校验行情、执行风控、更新方差并维护 AS 报价

整个策略的执行顺序可以概括为:盘前初始化状态 → 每条快照到达时校验行情、执行风控、更新方差、生成报价、判断改价、选择开平仓方向 → 撤销剩余委托。下面按函数分别说明。

判断辅助函数连续竞价时段终点: sessionEnd 根据事件时间计算当前连续竞价时段终点,该函数按上期所贵金属期货的连续竞价时段识别,覆盖日盘与夜盘:

def sessionEnd(t){
	// 返回当前连续竞价时段终点;非交易时段返回 NULL。
	// 保留毫秒,收盘后 1 毫秒已属于非交易时段。
	tm = time(t)
	day = date(t)
	// 毫秒偏移:9000000=02:30,36900000=10:15,41400000=11:30,54000000=15:00。
	// 21:00 后的夜盘终点在下一自然日;午夜后的夜盘终点在当前自然日。
	if (tm >= 21:00:00.000) return timestamp(day+1) + 9000000
	if (tm <= 02:30:00.000) return timestamp(day) + 9000000
	if (tm >= 09:00:00.000 and tm <= 10:15:00.000) return timestamp(day) + 36900000
	if (tm >= 10:30:00.000 and tm <= 11:30:00.000) return timestamp(day) + 41400000
	if (tm >= 13:30:00.000 and tm <= 15:00:00.000) return timestamp(day) + 54000000
	return timestamp(NULL)
}

提交期货委托:limitOrder 参数参考 submitOrder 接口文档。有9个字段订单信息依次为合约、交易所、时间、订单类型、价格、止损/止盈价、数量、方向和有效性。这里使用限价单 5,数量为整数手,有效性 0 表示当日有效。

def limitOrder(mutable context, t, price, qty, direction, label){
	// 资金或数量限制可能将一侧缩减为 0;
	if (qty <= 0) return
	// 商品期货九字段:合约、交易所、时间、订单类型、价格、
    //止损/止盈价、手数、方向、有效性。
	// 5=限价单;止损/止盈价填 0.0;有效性 0=当日有效;long(qty) 保持整数手类型。
	// direction:1=买开,2=卖开,6=卖平今,7=买平今;
    //平今需配置 intradayClosingFee。
	// label 用于在委托明细中识别报价侧或平仓原因;
	//返回值为提交接口返回的订单号。
	return Backtest::submitOrder(context.engine,
	    (context.targetSymbol, "SHFE", t, 5, price, 0.0, 
          long(qty), direction, 0), label)
}

平仓操作函数:closeAll 撤销当前合约的全部挂单,读取引擎实际多空持仓,再以买一价卖平多头、卖一价买平空头,然后清空报价缓存。

def closeAll(mutable context, t, bid1, ask1, reason){
	// 撤销旧报价,避免平盘期间继续增加库存
	Backtest::cancelOrder(context.engine, context.targetSymbol)
	// 使用引擎实际多空持仓
	p = Backtest::getPosition(context.engine, context.targetSymbol)
	longQty = 0l
	shortQty = 0l
	if (size(p) > 0){ longQty = long(p.longPosition); shortQty = long(p.shortPosition) }
	// 多头按买一卖平今,空头按卖一买平今;分别处理,避免净仓为 0 时遗漏双边总仓。
	// 对手价限价委托仍依赖后续行情与深度撮合,不保证提交后立即或最终全部成交。
	if (longQty > 0) limitOrder(context, t, bid1, longQty, 6, "closeLong_" + reason)
	if (shortQty > 0) limitOrder(context, t, ask1, shortQty, 7, "closeShort_" + reason)
	// 清空旧报价参考,使后续恢复报价时不沿用平盘前的改价判断。
	context.lastBidPrice = double(NULL)
	context.lastAskPrice = double(NULL)
}

盘前回调函数: beforeTrading 在盘前重置当日状态。实际持仓始终从引擎查询,不根据目标报价推算。盘前函数如下:

def beforeTrading(mutable context){
	// 每个交易日盘前重置报价和方差状态;
	 context.nextOrderTime = timestamp(NULL)
	context.prevTime = timestamp(NULL)
	context.prevMidTick = double(NULL)
	context.varianceRate = context.initialVarianceRate
	// 保存上一轮目标报价和当时净仓位,用于判断是否需要撤单重报。
	context.lastBidPrice = double(NULL)
	context.lastAskPrice = double(NULL)
	context.lastNetPosition = 0l
	// 这是成交通知回调次数
	context.fillNotificationCount = 0l
}

快照行情回调函数:onSnapshot(mutable context, msg, indicator) 接收合约的快照行情数据,通过 msg.symbol 判断合约。主回调按以下顺序执行:

  • Step 1:行情校验。只处理目标合约,读取当前时段终点,检查买一、卖一有效且买价低于卖价。

  • Step 2:时段与持仓风控。时段结束前优先平盘;读取多空持仓,以净库存计算偏斜、总持仓控制规模。

  • Step 3:更新方差率。用相邻有效快照的 tick 变化和间隔更新 EWMA;间隔超过时重置初值。波动超限、接近涨跌停时撤单并返回。

  • Step 4:生成报价。每秒评估一次,用当前方差率、库存和剩余时间计算 AS 报价,并完成 tick 取整。

  • Step 5:判断改价。有未完成委托且两侧价格变化均小于 1 tick、净库存未变时,保留原单;否则撤单重报。

  • Step 6:选择开平仓方向。优先平掉反向库存,再根据可用资金和持仓余量限制开仓手数,提交买卖委托并记录日志。
    def onSnapshot(mutable context, msg, indicator){
        // 快照主回调:校验 -> 风控 -> 方差 -> AS 报价 -> 改价 -> 委托
        // step 1:限定单合约,识别连续竞价时段并检查一档盘口。
        // step 2:风控和平仓优先于报价,不受下方一秒评估间隔限制。
        // step 3:价格先转为 tick,避免将沪金元/克与沪银元/千克的参数直接混用。
        // step 4:只有普通报价评估;前面的时段和库存风控已经优先执行。
        // step 5:有在途单、两侧变化均小于改价阈值且净库存未变时,保留原委托。
        // step 6:先选择开平仓方向,再根据资金及总仓阈值缩减可提交手数。
      }

以下我们开始详细展示策略的逻辑实现:

Step 1:校验合约、交易时段和盘口。先检查行情是否属于目标合约,不是就直接返回;是则增加已处理快照计数,记录最新处理时间。随后通过 sessionEnd(t) 获取当前连续竞价时段的结束时间,并读取买一 bid1、卖一 ask1。以下情况会撤销旧委托并返回:

  • 当前不属于有效交易时段。
  • 买价或卖价数组为空。
  • 买一、卖一为空,买一不大于零,或者卖一不高于买一。
// step 1:限定单合约,识别连续竞价时段并检查一档盘口。
if (string(msg.symbol) != context.targetSymbol) return
context.processedSnapshotCount += 1l
context.lastProcessedTimestamp = msg.timestamp
data = msg
t = data.timestamp
sessionEndTime = sessionEnd(t)
if (isNull(sessionEndTime)){
    // 时段无效时只撤单;
    Backtest::cancelOrder(context.engine, context.targetSymbol)
    return
}
// 缺少盘口时撤销旧报价,避免继续保留没有价格依据的委托。
if (size(data.bidPrice) == 0 or size(data.offerPrice) == 0){
    Backtest::cancelOrder(context.engine, context.targetSymbol)
    return
}
// 数组下标从 0 开始,一档报价作为中间价、非穿价限制及平盘定价的基准。
bid1 = data.bidPrice[0]
ask1 = data.offerPrice[0]
if (isNull(bid1) or isNull(ask1) or bid1 <= 0 or ask1 <= bid1){
    Backtest::cancelOrder(context.engine, context.targetSymbol)
    return
}

Step 2:处理临近收盘和持仓风控,如果距离当前时段结束不超过规定的时间,调用 closeAll() 撤单并尝试平仓,然后返回。这里包括早盘休息、午休、日盘和夜盘结束。否则读取引擎实际持仓,计算净持仓和总持仓。净持仓用于调整 AS 报价;总持仓用于判断风险。

// step 2:风控和平仓优先于报价,不受下方一秒评估间隔限制。
// 每个连续竞价时段结束前 context.flattenLeadMs 秒停止新报价,包括午休、日盘和夜盘结束。
if (long(sessionEndTime - t) <= context.flattenLeadMs){
    closeAll(context, t, bid1, ask1, "sessionEnd")
    return
}
// 净库存用于 AS 偏斜,总库存用于风控;同时持有多空时二者不能互相替代。
p = Backtest::getPosition(context.engine, context.targetSymbol)
longQty = 0l
shortQty = 0l
if (size(p) > 0){ 
		longQty = long(p.longPosition); 
		shortQty = long(p.shortPosition) 
}
netPos = longQty - shortQty
grossPos = longQty + shortQty
if (grossPos >= context.maxPos){
    // 达到阈值后撤单并尝试平盘
    closeAll(context, t, bid1, ask1, "positionLimit")
    return
}

Step 3:更新波动率,并检查波动和涨跌停风险。先计算盘口中间价,并转换为 tick 单位,如果存在上一条有效记录,且时间间隔在内,就计算本次观察方差率,再用 EWMA 更新,若行情间隔超过时间间隔,则把方差率重置为初始值,清空下次报价时间。随后保存本次时间和中间价,供下一条行情使用。

// step 3:价格先转为 tick,避免将沪金元/克与沪银元/千克的参数直接混用。
midPrice = (bid1 + ask1) / 2.0
midTick = midPrice / context.priceTick
// EWMA 方差率仅使用当前及过去行情,单位 tick^2/小时。
if (not isNull(context.prevTime)){
    elapsedMs = long(t - context.prevTime)
    if (elapsedMs > 0 and elapsedMs <= context.maxQuoteGapMs){
        // 观察方差率 = tick 变化平方 / 实际间隔小时数;不用全样本未来价格估计。
        observedVariance = square(midTick - context.prevMidTick) / (elapsedMs / 3600000.0)
        context.varianceRate = (1.0 - context.ewmaAlpha) * context.varianceRate + context.ewmaAlpha * observedVariance
    }
	else if (elapsedMs > context.maxQuoteGapMs){
			// 大缺口后重置,避免跨休市跳变污染高频方差,并允许重新开始报价评估。
			// 该检查只在新行情到达时触发,没有独立定时器在无行情期间撤销旧单。
			context.varianceRate = context.initialVarianceRate
			context.nextOrderTime = timestamp(NULL)
		}
	}
	// 保存当前事件,供下一条有效快照计算;未到报价时点也会执行上述方差更新。
	context.prevTime = t
	context.prevMidTick = midTick
	if (context.varianceRate > context.maxVarianceRate){
		// 波动过大时暂停报价并撤单;不会在这一分支主动平掉已有持仓。
		Backtest::cancelOrder(context.engine, context.targetSymbol)
		return
	}
	// 买一接近跌停或卖一接近涨停时暂停报价,边界缓冲为一个 priceTick。
	if (bid1 <= data.downLimitPrice + context.priceTick or 
    ask1 >= data.upLimitPrice - context.priceTick){
		Backtest::cancelOrder(context.engine, context.targetSymbol)
		return
}

Step 4:控制报价频率,计算 AS 买卖价格。如果还没到 nextOrderTime,直接返回;否则将下一次报价评估时间设为当前时间加 1 秒。核心实现如下:

// step 4:只有普通报价评估受限制;前面的时段和库存风控已经优先执行。
if ((not isNull(context.nextOrderTime)) and t < context.nextOrderTime) return
// 从当前事件时间安排下一次报价,避免休市或数据缺口。
context.nextOrderTime = t + context.orderIntervalMs
// 风险期限取滚动窗口与本时段剩余时间的较小值,毫秒转为小时。
tau = min(context.horizonMs, long(sessionEndTime - t)) / 3600000.0
// 方差下限只在模型计算时应用;context.varianceRate 仍保留实时估计值。
variance = max(context.minVarianceRate, context.varianceRate)
riskTerm = context.gamma * variance * tau
q = double(netPos - context.targetPosition)
// AS 保留价格 r=s-q*gamma*sigma^2*tau;多头下移、空头上移,价格单位为 tick。
reserveTick = midTick - q * riskTerm
// AS 近似总价差:风险项 + 订单到达距离项;gamma、k 应对应相同 tick 尺度。
spreadTick = riskTerm + (2.0 / context.gamma) * log(1.0 + context.gamma / context.k)
spreadTick = max(context.minSpreadTicks, min(context.maxSpreadTicks, spreadTick))
// 2~12 tick 约束的是取整前的模型总价差。
// 买价向下取整,卖价向上取整,并保证本次报价不主动穿过对手价。
// 买价最多为卖一减一 tick,卖价至少为买一加一 tick;允许改善本方盘口。
// +/-1e-8 用于减轻浮点误差对 floor/ceil 的影响,不改变价格网格。
bidPrice = floor(min(reserveTick - spreadTick / 2.0, ask1 / context.priceTick - 1.0) + 1e-8) * context.priceTick
askPrice = ceil(max(reserveTick + spreadTick / 2.0, bid1 / context.priceTick + 1.0) - 1e-8) * context.priceTick
// 最终候选报价必须在涨跌停内,且买价严格低于卖价。
if (bidPrice < data.downLimitPrice or askPrice > data.upLimitPrice or bidPrice >= askPrice) return

Step 5:判断是否保留旧单,或者撤单重报。有在途单、两侧变化均小于改价阈值且净库存未变时,保留原委托。若旧单已全部成交,即使目标价格相同,也允许重新提交。

orders = Backtest::getOpenOrders(context.engine, context.targetSymbol)
if (rows(orders) > 0 and (not isNull(context.lastBidPrice)) and
    abs(bidPrice - context.lastBidPrice) < context.priceTick * context.requoteTicks - 1e-8 and
    abs(askPrice - context.lastAskPrice) < context.priceTick * context.requoteTicks - 1e-8 and
    netPos == context.lastNetPosition) return
// 需要改价时先撤旧单,防止多组目标报价叠加;当前实现不等待异步撤单确认。
Backtest::cancelOrder(context.engine, context.targetSymbol)

Step 6:确定开平方向、限制数量、提交订单并保存状态。估算每手保证金,如果可用资金不足以覆盖设定的双侧开仓保证金和手续费门槛,就把开仓侧数量设为 0;平仓侧仍可提交。

// Step 6:先选择开平仓方向,再根据资金及总仓阈值缩减可提交手数。
buyQty = long(context.orderQty)
sellQty = long(context.orderQty)
buyDirection = 1
sellDirection = 2
// 优先平掉反向库存
if (shortQty > 0){ buyQty = min(buyQty, shortQty); buyDirection = 7 }
if (longQty > 0){ sellQty = min(sellQty, longQty); sellDirection = 6 }
cash = Backtest::getAvailableCash(context.engine)
marginPerLot = midPrice * context.multiplier * context.marginRatio
if (cash < 2.0 * context.orderQty * marginPerLot + context.commission * 2.0){
    // 资金不足只屏蔽开仓,已有反向库存的平今委托仍可提交。
    if (buyDirection == 1) buyQty = 0l
    if (sellDirection == 2) sellQty = 0l
}
if (buyDirection == 1) buyQty = min(buyQty, max(0l, context.maxPos - grossPos))
if (sellDirection == 2) sellQty = min(sellQty, max(0l, context.maxPos - grossPos))
limitOrder(context, t, bidPrice, buyQty, buyDirection, "AS_bid")
limitOrder(context, t, askPrice, sellQty, sellDirection, "AS_ask")
context.lastBidPrice = bidPrice
context.lastAskPrice = askPrice
context.lastNetPosition = netPos

2.3 设置回测参数

securityReference 提供合约乘数、保证金、tick 和手续费。CSV 中的沪金示例参数如下,期货基本信息的详细参数信息,参考文档 Backtest 插件期货基本信息表。切换品种时修改 CSV 中对应合约的参数:

表 2. 表 2-2 回测参数
参数 AU 示例值 AG 示例值 含义

multiplier

1000

15

价格变动换算为人民币损益

priceTick

0.02

1

合法报价步长

marginRatio

0.12

0.12

保证金率

commission

10 元/手

5 元/手

开仓及普通平仓手续费

intradayClosingFee

0 元/手

5 元/手

平今手续费

deliveryCommissionMode

1

1

按手收费

config 配置分成两层:外层配置回测引擎,配置参数含义见 DolphinDB 期货回测配置,内层 context 配置 AS 策略,并传入各个回调函数。

config = {
    // futures + dataType=1 表示期货快照;
    startDate:backtestDate, endDate:backtestDate, strategyGroup:"futures", cash:10000000.,
    // 快照由模拟撮合引擎处理
    dataType:1, matchingMode:1, frequency:0, latency:1, orderBookMatchingRatio:0.2, matchingRatio:0.2,
    enableSettlementPrice:false,
    // context 中的用户参数会随引擎上下文传入各回调函数。
    context:{
    	targetSymbol:targetSymbol, 
    	priceTick:0.02, 
    	multiplier:1000,
    	marginRatio:0.12, 
    	commission:10.,
    	// gamma/k 使用 tick 价格尺度;orderQty、maxPos、targetPosition 均以手计。
    	gamma:0.05, k:1.5, orderQty:1l, maxPos:5l, targetPosition:0l,
    	// 模型总价差限于 2~12 tick;新旧报价相差至少 1 tick 时才触发普通改价。
    	minSpreadTicks:2.0, maxSpreadTicks:12.0, requoteTicks:1.0,
    	// 以下时间均为毫秒:一秒评估、60 秒风险窗口、时段末提前 20 秒平盘。
    	orderIntervalMs:1000, horizonMs:60000, flattenLeadMs:20000,
    	// EWMA 权重 0.1;方差率以 tick^2/小时计;相邻行情超 60 秒则重置。
    	ewmaAlpha:0.1, initialVarianceRate:3600.0, minVarianceRate:36.0,
    	maxVarianceRate:14400.0, maxQuoteGapMs:60000
        }
}

2.4 创建回测引擎并执行回测

准备好数据、回调和配置后,使用 createBacktester 创建引擎,按事件时间回放行情,再调用 appendEndMarker 接口结束回测。

// 将事件名称映射到上述回调;createBacktester 的 false 参数表示不启用 JIT。
callbacks = {
	beforeTrading:beforeTrading,
	onSnapshot:onSnapshot
}
// 引擎名含品种和当前时间,避免直接覆盖其他已有的命名实例。
strategyName = "PreciousMetal_AS_" + targetSymbol
try { Backtest::dropBacktestEngine(strategyName) } catch(ex) { }
engine = Backtest::createBacktester(strategyName, config, callbacks, false, securityReference)
//
Backtest::appendQuotationMsg(engine, snapshot)
Backtest::appendEndMarker(engine)

第四个参数 false 表示关闭 JIT;securityReference 用于持仓、保证金和费用计算。发送结束消息后即可读取结果。

2.5 获取回测结果

回测结束后,应从成交明细、日度权益维度核对结果:

  • 成交明细 tradeDetails 包含委托、撤单和成交记录。筛选有效成交后统计手数,是判断策略实际交易行为的基础。

  • 日度权益 totalPortfolios 与收益概览 returnSummary 用于评估盈亏、费用与风险。

// tradeDetails 含委托和撤单等生命周期记录
// totalPortfolios 为按日权益,finalPosition 用于检查期末多空仓位是否均为 0。
tradeDetails = Backtest::getTradeDetails(engine)
totalPortfolios = Backtest::getDailyTotalPortfolios(engine)
returnSummary = Backtest::getReturnSummary(engine)
finalPosition = Backtest::getPosition(engine, targetSymbol)

3. 总结

本教程实现了贵金属期货 AS 做市的完整回测流程:从 加载合约信息和五档行情,实时估计方差率,按实际库存调整报价,以整数手限价单执行开平仓,再读取成交、费用、持仓和账户权益。

策略实现层面,本案例覆盖了以下关键点:

  • 行情驱动:以快照事件驱动,直接使用盘口生成报价;

  • 在线估计:方差率用 EWMA 在线更新,随行情变化动态调整价差;

  • 库存管理:以实际持仓计算库存偏斜,报价中心随持仓方向调整;

  • 风控机制:时段末平盘、持仓上限、波动超限撤单三层风控;

  • 交易约束:平今方向、整数手、tick 取整、对手价约束处理。

扩展案例时,可从以下方向调整:

  1. 参数层面:调整 γ 、 k 、风险窗口、报价间隔和改价阈值,观察对报价宽度、成交频率和库存变化的影响;

  2. 品种层面:沪金和沪银每 tick 金额不同,相同模型系数不代表相同的人民币风险偏好,切换品种时需重新评估参数。