债券双边报价做市策略
本策略构建债券的做市双边报价与平盘策略。核心思路是:在非做市行情上取市场最优买卖收益率,据此生成我方做市报价,并把买卖价差限制在设定的最小、最大容忍度之间。当行情发生变动时,判断是否撤单重报;当报价与对向市场最优相等或倒挂时,视为成交,并立即按点击价格挂出反向平盘单。
本文使用模拟的债券快照行情,通过 Backtest 引擎完成双边报价、成交判断、平盘下单与结果分析。
1. 策略介绍
1.1 背景简介
债券双边报价做市的核心是在同一标的上持续提供买入与卖出意向,为市场提供流动性,并尝试从买卖价差中获得收益。与单方向交易不同,做市策略不仅要判断合理报价,还必须同时管理订单生命周期、成交后的方向性库存、资金占用、借券约束以及行情过期风险。
在银行间市场,做市商需要应对三类问题:
-
报价生成:如何基于市场最优价生成有竞争力的双边报价,同时避免报价过窄或过宽;
-
报价维护:当市场行情变动时,如何判断是否需要撤单重报,避免频繁改价或报价失效;
-
敞口管理:报价被点击后,如何快速挂出反向平盘单,控制日内敞口。
本策略围绕上述三个问题展开,构建一套双边报价 + 成交判断 + 平盘管理的完整做市逻辑。实现以下策略策略目标:
-
从非做市行情中维护每只债券的一档最优买入、卖出收益率。
-
按固定 bp 偏移生成双边收益率,并将报价价差限制在允许区间。
-
仅在市场变化达到改价阈值时撤单重报,减少无效订单更新。
-
发生报价成交时,提交反向平盘报价。
1.2 报价模型
报价生成基于市场最优买卖收益率,并叠加加点 X 与减点 Y ,生成双边报价,加点使买入报价略高于市场最优,减点使卖出报价略低于市场最优,从而提升报价竞争力:
价差约束:做市价差是买入报价与卖出报价之差,用于衡量做市收益空间。做市价差过窄,收益空间不足;价差过宽,报价缺乏竞争力。策略通过最小容忍度 N 与最大容忍度 M 将价差夹在合理区间:
若 sp < N,则向中间价拉开至 N;若 sp > M,则向中间价收窄至 M;否则保持原价差。
其中:
-
myBidY,myAskY 为买入、卖出报价。
-
scale 为报价单位。
-
N 为最小容忍价差。
-
M 为最大容忍价差。
改价判断:市场行情持续变动,若每次变动都撤单重报,会产生大量无效操作。策略设置改价阈值,仅当新报价相对旧报价的偏离达到阈值时,才撤单重报:
仅当 或 达到阈值时,才撤单重报。
1.3 交易规则
提交双边报价:
-
非做市行情到达后,更新市场最优;
-
生成买卖价并提交双边订单;
-
若新报价相对旧报价的偏离达到阈值,则撤单重报。
平盘策略:
-
成交之后,按成交价格挂出本券反向平盘单;
-
平盘单数量等于被标的的持仓数量;
-
平盘后,该券的成交后最小价差 N 提高,以扩大后续报价价差。
开平仓规则:
| 组合 | 严格触发条件 | 平盘腿 |
|---|---|---|
| 买方报价成交 | 按成交价挂卖平盘单 | |
| 卖方报价成交 | 按成交价挂买平盘单 |
2. 策略回测
以下按照准备数据、定义策略、设置参数、创建引擎、回放行情和读取结果的顺序展开。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.6 与模拟撮合引擎插件(Matching Engine Simulator)3.00.6.6 上运行。服务器需要安装与 DolphinDB Server 版本匹配的两个插件;创建引擎前,代码会检查插件是否已安装,并加载这两个插件。
2.1 准备数据
本例准备了 3 只银行间债券的模拟快照行情,并同时包含 CFETS 做市双边、XBOND 与货币经纪行情。
| 数据源 | 用途 |
|---|---|
| snapshot.csv | 债券快照行情,包含 symbol、source、bidYTM、askYTM 等字段 |
| bondRef.csv | 债券基础信息:票息、起息日、到期日、付息频率、发行价、乘数 |
本例直接通过 loadText 读取已准备好的行情与债券基本信息数据。
targetSymbol = "09240307.T0"
dataDir = "/.../"
// 债券静态基本信息表:为 FICC 引擎提供票息、付息频率、起息/到期日、借券费率、
// 面值和合约乘数等信息。字段类型必须与 securityReference 要求一致。
name = ["symbol","multiplier","priceTick","couponRate","frequency","valueDate","maturityDate","bondLendingRate",
"commission","marginRatio","bondType","issuePrice","faceValue"]
type = ["STRING","DOUBLE","DOUBLE","DOUBLE","INT","DATE","DATE","DOUBLE","DOUBLE","DOUBLE","STRING","DOUBLE","DOUBLE"]
allBondRef = loadText(dataDir + "bondRef.csv", schema=table(name,type))
bondRef = select * from allBondRef where symbol = targetSymbol
// FICC 债券参考数据以 xbond 为 key 传入回测引擎。
securityReference = {"xbond": bondRef}
// universe 是 ANY 向量;第一个元素对应 onSnapshot 的 data 行情通道。
universe = array(ANY, 1)
universe[0] = [targetSymbol]
// 回测开始和结束日期
begt=2026.05.18
endt=2026.05.18
name = ["symbol","timestamp","datetime","bidQty","bidPrice","askQty","askPrice","source","preSettlementPrice","prevPrice","openPrice","closePrice","settlementPrice","HighPrice","LowPrice","lastPrice","volume","turnover","openInterest","askYTM","lastYTM","bidYTM"]
type = ["SYMBOL","LONG","TIMESTAMP","LONG[]","DOUBLE[]","LONG[]","DOUBLE[]","STRING","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","LONG","DOUBLE","LONG","DOUBLE[]","DOUBLE","DOUBLE[]"]
allSnapshot =select * from loadText(dataDir + "snapshot.csv", schema=table(name,type)) where symbol = targetSymbol
snapshot = select * from allSnapshot where date(datetime) between begt:endt order by datetime, symbol, source
2.2 编写策略逻辑
策略由两个辅助函数和三个回调函数组成。
-
clampSpread将价差夹在最小容忍度 N 与最大容忍度 M之间。 -
beforeTrading在盘前初始化状态。 -
onSnapshot是快照行情回调,也是策略的主执行函数。
策略辅助函数:clampSpread
def clampSpread(myBidY, myAskY, N, M, scale){
// 将收益率报价的正价差限制在 [N, M] bp 范围内。
// myBidY/myAskY:未经约束的买入/卖出收益率;N/M:最小/最大价差(bp);
// scale:bp 到行情收益率单位的换算系数。返回修正后的两侧报价和最终价差。
// 收益率报价中,本策略以 bidYTM-askYTM 定义正向做市价差。
sp = (myBidY - myAskY) / scale
// 输入缺失时不修正价格,由调用方后续终止本次报价。
if (isNull(sp)) return myBidY, myAskY, NULL
// 非正价差无法构成当前策略定义下的有效双边报价,保持原值返回。
if (sp <= 0) return myBidY, myAskY, sp
if (sp < N){
// 价差过窄:保持中点不变,对称扩大到 N bp。
mid = (myBidY + myAskY) / 2.0
half = N * scale / 2.0
return mid + half, mid - half, N
}
if (sp > M){
// 价差过宽:保持中点不变,对称收窄到 M bp,提升报价竞争力。
mid = (myBidY + myAskY) / 2.0
half = M * scale / 2.0
return mid + half, mid - half, M
}
// 价差已位于允许区间,直接使用原始报价。
return myBidY, myAskY, sp
}
盘前处理函数:beforeTrading
beforeTrading (mutable context) 在当日交易前执行,在这里我们初始化券的行情最优价、订单报价、是否在报状态等全局变量。
def beforeTrading(mutable context){
// 每日重置单券状态,避免跨日引用旧行情和旧报价。
context.bestBidY = double(NULL)
context.bestAskY = double(NULL)
// 当前双边报价收益率与报价状态。
context.myBidY = double(NULL)
context.myAskY = double(NULL)
context.quoting = false
// 动态最小价差初始为 1bp;报价 ID 直接保存接口返回值。
context.N = 1.0
context.quoteIds = []
}
快照决策函数:onSnapshot
onSnapshot(mutable context, data) 是快照行情回调,也是策略的主执行函数。负责单券的行情更新、报价维护、触价后的持仓处理,以及收盘平仓。每收到一条快照执行一次,状态直接保存在 context.bestBidY 等变量中。具体策略的实现分为 6 个步骤实现,大致如下:
def onSnapshot(mutable context, data){
// onSnapshot 是策略主回调,每到达一条债券快照即执行一次。
// 快照包含 symbol/source 以及多档 bidYTM/askYTM;本策略只使用第一档收益率。
sym = data.symbol
// ---------- Step 1:收盘后撤单平仓,停止新报价 ----------
// ---------- Step 2:按行情来源更新参考报价 ----------
// ---------- Step 3:模拟判断已有报价是否可能发生成交 ----------
// ---------- Step 4:校验市场最优并生成候选双边报价 ----------
// ---------- Step 5:执行改价阈值检查 ----------
// ---------- Step 6:撤销旧报价并提交新双边报价 ----------
}
Step 1:首先需要处理目标券和收盘前撤单平仓的操作。
-
只处理目标券:读取
data.symbol,与context.targetSymbol比较。如果不是指定债券,直接返回。 -
16:00 后撤单并平仓,当行情时间达到或超过 16点:
-
撤销已有双边报价,清空报价订单号,设置报价状态 。
-
撤销普通在途订单。
-
查询净持仓:flatQty = 多头数量 − 空头数量。
-
净多头:按当前快照的买一收益率提交卖出平仓单。
-
净空头:按当前快照的卖一收益率提交买入平仓单。
-
-
随后直接返回,不再生成开仓报价。后续收盘快照继续执行上述操作,处理尚未平完的持仓。
// 标的池与行情表均过滤单券,此处再限制回调只能对目标券下单。
if (string(sym) != context.targetSymbol) return
// ---------- Step 1:收盘后撤单平仓,停止新报价 ----------
if(second(context.tradeTime)>=16:00:00){
// 每一个交易日16开始,平头寸
// 取消所有在途订单
ids = context.quoteIds
if (size(ids) > 0){
// 避免原开仓报价在平盘期间继续发生交易。
Backtest::cancelQuotes(context.engine, ids)
context.quoteIds = []
}
context.quoting = false
// FICC 不支持 cancelOrder 的 symbol 参数;本引擎仅处理目标券。
Backtest::cancelOrder(context.engine)
pos = Backtest::getPosition(context.engine, sym)
flatQty = long(pos.longPosition)-long(pos.shortPosition)
bidY = data.bidYTM[0]
askY = data.askYTM[0]
if ( flatQty > 0) {
// 发生成交,债券多头:在卖出侧按成交收益率挂平仓量。
Backtest::submitOrder(context.engine,
dict(`symbol`source`qty`orderType`direction`openClose`ytm,
(sym, "XBOND", flatQty, 5, 2, 2, bidY)), "平盘-卖出")
}
else if (abs(flatQty) >0){
// 卖出侧发生成交,债券空头:在买入侧按成交收益率挂平仓量。
// 取消所以在途订单
Backtest::cancelOrder(context.engine)
Backtest::submitOrder(context.engine,
dict(`symbol`source`qty`orderType`direction`openClose`ytm,
(sym, "XBOND", abs(flatQty), 5, 1, 2, askY)), "平盘-买入")
}
return
}
Step 2:根据行情来源更新最优收益率
// ---------- Step 2:按行情来源更新参考报价 ----------
src = data.source
// 排除做市商自身或同类做市渠道,避免用做市双边报价循环生成新报价。
if (src != "FIESP" and src != "ESP" and src != "CFETS_MM"){
bidY = data.bidYTM[0]
askY = data.askYTM[0]
if (not isNull(bidY) and bidY > 0.){
if (isNull(context.bestBidY) or bidY < context.bestBidY){
context.bestBidY = bidY
}
}
if (not isNull(askY) and askY > 0.){
if (isNull(context.bestAskY) or askY > context.bestAskY){
context.bestAskY = askY
}
}
}
Step 3:判断已有报价是否有可能发生成交,如果报价优于市场最优价,进行撤单操作。如果持仓数量超过限制,进行平仓操作。如果当前正在报价,检查 myBidY <= context.bestAskY 或者 myAskY >= context.bestBidY 任一条件成立,就撤销原双边报价。并查询实际净持仓是否超过阈值,超过进行平仓操作。
// force=true 表示报价成交,后续必须重新生成双边报价,不受改价阈值限制。
force = false
// ---------- Step 3:判断已有报价是否有可能发生成交 ----------
if (context.quoting){
myBidY = context.myBidY
myAskY = context.myAskY
// 收益率与价格方向相反:
// myBidY<=bestAskY 表示买价达到/穿过市场卖价;
// myAskY>=bestBidY 表示卖价达到/穿过市场买价。
// 相等或倒挂时,先撤销该券原双边报价,再按成交收益率提交反向平盘单。
if (myBidY <= context.bestAskY or myAskY >= context.bestBidY){
ids = context.quoteIds
if (size(ids) > 0){
// 避免原开仓报价在平盘期间继续发生交易。
Backtest::cancelQuotes(context.engine, ids)
context.quoteIds = []
}
context.quoting = false
// 成交后扩大该券最小价差,降低连续被动成交和库存累积风险。
context.N = 10.0
force = true
}
// 从引擎读取真实持仓,用实际已形成的多/空数量确定平盘量。
pos = Backtest::getPosition(context.engine, sym)
flatQty = long(pos.longPosition)-long(pos.shortPosition)
if ( flatQty > context.maxPos) {
// 取消所以在途订单
Backtest::cancelOrder(context.engine)
// 发生成交,债券多头:在卖出侧按成交收益率挂平仓量。
Backtest::submitOrder(context.engine,
dict(`symbol`source`qty`orderType`direction`openClose`ytm,
(sym, "XBOND", flatQty, 5, 2, 2, myBidY)), "平盘-卖出")
}
else if (flatQty < -context.maxPos){
// 卖出侧发生成交,债券空头:在买入侧按成交收益率挂平仓量。
// 取消所以在途订单
Backtest::cancelOrder(context.engine)
Backtest::submitOrder(context.engine,
dict(`symbol`source`qty`orderType`direction`openClose`ytm,
(sym, "XBOND", abs(flatQty), 5, 1, 2, myAskY)), "平盘-买入")
}
}
Step 4:计算并约束候选双边报价。这里通过 clampSpread 约束收益率价差。
// ---------- Step 4:校验市场最优并生成候选双边报价 ----------
// 未收到该券有效的非做市双边最优时,无法形成基准报价,直接返回。
if (isNull(context.bestBidY) or isNull(context.bestAskY)) return
// X/Y 为相对市场最优的收益率偏移(bp),scale 完成 bp 到行情单位的换算。
scale = context.scale
myBidY = context.bestBidY + context.X * scale
myAskY = context.bestAskY - context.Y * scale
// 将候选收益率价差限制在动态最小值 N 与最大值 M 之间。
myBidY, myAskY, sp = clampSpread(myBidY, myAskY, context.N, context.M, scale)
// 买入收益率必须严格高于卖出收益率,才能形成当前策略定义下的正报价区间。
if (isNull(sp) or myBidY <= myAskY) return
Step 5:执行改价阈值检查。
if (context.quoting and !force){
// 将两侧新旧收益率变化转换成 bp。
dBid = abs(myBidY - context.myBidY) / scale
dAsk = abs(myAskY - context.myAskY) / scale
// 两侧变化都小于阈值时保留原报价,减少无效撤单和重报。
if (dBid < context.requoteThresh and dAsk < context.requoteThresh){
return
}
}
Step 6:提交新双边报价并保存状态。
// ---------- Step 6:撤销旧报价并提交新双边报价 ----------
// 先更新上下文中的目标报价,供后续改价与成交判断使用。
context.myBidY = myBidY
context.myAskY = myAskY
// 已有未完成报价时先撤单,避免同一标的存在多组在途报价订单。
ids = context.quoteIds
if (size(ids) > 0){
Backtest::cancelQuotes(context.engine, ids)
}
// openClose=1 表示开仓;双边委托量由 quoteQty 配置。
qid = Backtest::submitQuoteOrder(context.engine,
dict(`symbol`source`bidYTM`bidQty`askYTM`askQty`openClose,
(sym, "XBOND", myBidY, context.quoteQty, myAskY, context.quoteQty, 1)), "提交双边报价")
// 保存报价 ID 并标记为正在报价。
context.quoteIds = qid
context.quoting = true
2.3 设置回测参数
回测的开始与结束日期、初始资金、标的资产都可以通过配置参数设置;strategyGroup 填
"FICC"。双边做市策略只支持标的为债券且是快照撮合模式(dataType = 0, 2,
3),本案例用的是快照行情撮合,dataType = 1 。并设置加点 X、减点
Y、价差上下限、单笔数量与改价阈值等参数。
universe = array(ANY, 1)
universe[0] = [targetSymbol]
config = {
startDate: begt,
endDate: endt,
strategyGroup: "FICC",
// 初始资金。
cash: 1e12,
dataType: 0,
matchingMode: 1,
universe: universe,
// 允许形成债券空头,以支持卖出成交以及后续买入平盘。
enableBondLending: true,
// 在明细中保留订单状态和风控信息。
outputOrderInfo: true,
// 用户参数均放入 context
// X/Y 为报价偏移,M 为最大价差,scale=0.01 表示 1bp,
// requoteThresh 为 1bp 改价阈值。
context:{
targetSymbol:targetSymbol,quoteQty:30000000l,
X:1,Y:-3.,M:20.,scale:0.01,requoteThresh:1.,maxPos:1000000
}
}
2.4 创建回测引擎并执行回测
设置引擎名称、引擎配置项、策略回调以及债券基本信息等相应参数之后,通过接口 createBacktester 创建回测引擎实例。
// 当前策略回调函数 onSnapshot 和 beforeTrading。
callbacks = {
beforeTrading: beforeTrading
onSnapshot: onSnapshot
}
securityReference = {"xbond": bondRef}
// 每次使用独立引擎名称,不影响已有回测引擎。
strategyName = "MM_single_bond"
try{ Backtest::dropBacktestEngine(strategyName) }catch(ex){ print ex }
// false 表示不启用 JIT;securityReference 为债券估值及持仓计算提供静态信息。
engine = Backtest::createBacktester(strategyName, config, callbacks, false, securityReference)
// 将已过滤、排序的单券快照放入行情字典。
msg = dict(STRING, ANY)
msg["snapshot"] = snapshot
Backtest::appendQuotationMsg(engine, msg)
// 回放行情并在末尾追加结束标记
Backtest::appendEndMarker(engine)
2.5 获取回测结果
FICC 的做市双边报价通过 getQuoteTradeDetails 函数接口获取交易明细,普通订单则通过 getTradeDetails 接口获取明细:
// 普通订单明细:本例主要用于查看平盘之外的常规委托记录。
tradeDetails = select * from Backtest::getTradeDetails(engine)
dailyPosition =select * from Backtest::getDailyPosition(engine)
// 双边报价专用明细:包含 PLACE/CANCEL、报价 ID、双边量价、成交状态和标签。
quoteDetails = select * from Backtest::getQuoteTradeDetails(engine)
// 查看组合总资金、持仓市值、浮动/已实现损益与总权益。
totalPortfolios= Backtest::getDailyTotalPortfolios(engine)
// 输出实际提交的双边报价数量;完整生命周期记录见 quoteDetails。
其中 quoteDetails 用于核对做市双边报价与成交,totalPortfolios 用于追踪账户权益。
3. 总结
本案例实现了一个基于 CFETS 做市双边、XBOND 与货币经纪快照行情的债券双边报价做市策略。策略先从非做市行情中提取市场最优买卖收益率,通过加点/减点与价差容忍度区间生成我方报价,再在报价被点击后按点击价格挂出反向平盘单,控制日内敞口。
本例展示了从行情导入、双边报价生成、成交判断、平盘下单到回测结果读取的完整流程。用于真实投资研究时,应以具有明确可用时点的真实做市行情、债券条款与借贷信息替换模拟数据,并进一步评估报价被成交的概率、平盘效率、买卖价差收益、交易成本与样本外表现。
4. 附件
-
模拟数据文件:snapshot.csv、bondRef.csv
