贵金属期货动态报价与库存管理策略(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 近似报价公式:
-
:库存调整后的报价,单位 tick
-
:模型买卖总价差,单位 tick
-
:其中, 、 为买一价和卖一价, 为最小价格变动
-
: 、 为实际多空持仓, 为目标净库存
-
:风险期限,单位小时
-
:风险厌恶系数
-
:风险厌恶系数
-
:中间价方差率,单位 tick²/小时
其中方差率根据快照行情实时更新:
其中 为相邻有效快照的时间间隔,单位为小时。
报价公式
模型总价差限制在 2 到12 tick,买价向下取整、卖价向上取整,并保证报价不穿过当前对手价。公式来源见 Avellaneda–Stoikov 原文。
1.3 交易规则
策略在每条有效快照到达时执行以下规则:
生成报价:读取买一、卖一和引擎实际持仓,更新方差率,计算 AS 买卖价格。
维护报价:普通报价每秒最多评估一次;价格变动达到 1 tick、净库存改变或旧单全部成交时,撤销旧单并重新挂单。
优先减仓:持有空头时,买侧使用买平今;持有多头时,卖侧使用卖平今。对应方向没有库存时才提交开仓单。
风险控制:波动超限或盘口接近涨跌停时撤单暂停报价;总持仓达到限制时撤单并提交平仓单;资金不足时停止新增开仓。
时段平盘:每个连续竞价时段结束前停止普通报价,多头按买一卖平今,空头按卖一买平今。
沪金、沪银时段按 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 编写策略逻辑
完整策略由辅助函数与回调函数组成,职责如下:
| 函数 | 作用 |
|---|---|
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 中对应合约的参数:
| 参数 | 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 取整、对手价约束处理。
扩展案例时,可从以下方向调整:
参数层面:调整 、 、风险窗口、报价间隔和改价阈值,观察对报价宽度、成交频率和库存变化的影响;
品种层面:沪金和沪银每 tick 金额不同,相同模型系数不代表相同的人民币风险偏好,切换品种时需重新评估参数。
