债券双边报价做市策略

本策略构建债券的做市双边报价与平盘策略。核心思路是:在非做市行情上取市场最优买卖收益率,据此生成我方做市报价,并把买卖价差限制在设定的最小、最大容忍度之间。当行情发生变动时,判断是否撤单重报;当报价与对向市场最优相等或倒挂时,视为成交,并立即按点击价格挂出反向平盘单。

本文使用模拟的债券快照行情,通过 Backtest 引擎完成双边报价、成交判断、平盘下单与结果分析。

1. 策略介绍

1.1 背景简介

债券双边报价做市的核心是在同一标的上持续提供买入与卖出意向,为市场提供流动性,并尝试从买卖价差中获得收益。与单方向交易不同,做市策略不仅要判断合理报价,还必须同时管理订单生命周期、成交后的方向性库存、资金占用、借券约束以及行情过期风险。

在银行间市场,做市商需要应对三类问题:

  1. 报价生成:如何基于市场最优价生成有竞争力的双边报价,同时避免报价过窄或过宽;

  2. 报价维护:当市场行情变动时,如何判断是否需要撤单重报,避免频繁改价或报价失效;

  3. 敞口管理:报价被点击后,如何快速挂出反向平盘单,控制日内敞口。

本策略围绕上述三个问题展开,构建一套双边报价 + 成交判断 + 平盘管理的完整做市逻辑。实现以下策略策略目标:

  • 从非做市行情中维护每只债券的一档最优买入、卖出收益率。

  • 按固定 bp 偏移生成双边收益率,并将报价价差限制在允许区间。

  • 仅在市场变化达到改价阈值时撤单重报,减少无效订单更新。

  • 发生报价成交时,提交反向平盘报价。

1.2 报价模型

报价生成基于市场最优买卖收益率,并叠加加点 X 与减点 Y ,生成双边报价,加点使买入报价略高于市场最优,减点使卖出报价略低于市场最优,从而提升报价竞争力:

myBidY = bestBidY + X × scale

myAskY = bestAskY − Y × scale

价差约束:做市价差是买入报价与卖出报价之差,用于衡量做市收益空间。做市价差过窄,收益空间不足;价差过宽,报价缺乏竞争力。策略通过最小容忍度 N 与最大容忍度 M 将价差夹在合理区间:

sp = myBidY − myAskY scale

若 sp < N,则向中间价拉开至 N;若 sp > M,则向中间价收窄至 M;否则保持原价差。

其中:

  • myBidY,myAskY 为买入、卖出报价。

  • scale 为报价单位。

  • N 为最小容忍价差。

  • M 为最大容忍价差。

改价判断:市场行情持续变动,若每次变动都撤单重报,会产生大量无效操作。策略设置改价阈值,仅当新报价相对旧报价的偏离达到阈值时,才撤单重报:

d bid = | myBidY new − myBidY old | scale

d ask = | myAskY new − myAskY old | scale

仅当 d bid ​ 或 d ask ​ 达到阈值时,才撤单重报。

1.3 交易规则

提交双边报价:

  • 非做市行情到达后,更新市场最优;

  • 生成买卖价并提交双边订单;

  • 若新报价相对旧报价的偏离达到阈值,则撤单重报。

平盘策略:

  • 成交之后,按成交价格挂出本券反向平盘单;

  • 平盘单数量等于被标的的持仓数量;

  • 平盘后,该券的成交后最小价差 N 提高,以扩大后续报价价差。

开平仓规则:

组合 严格触发条件 平盘腿
买方报价成交 myBidY ≤ bestAskY 按成交价挂卖平盘单
卖方报价成交 myAskY ≥ bestBidY 按成交价挂买平盘单

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 用于追踪账户权益。

图 1. 图 2-1 交易明细

3. 总结

本案例实现了一个基于 CFETS 做市双边、XBOND 与货币经纪快照行情的债券双边报价做市策略。策略先从非做市行情中提取市场最优买卖收益率,通过加点/减点与价差容忍度区间生成我方报价,再在报价被点击后按点击价格挂出反向平盘单,控制日内敞口。

本例展示了从行情导入、双边报价生成、成交判断、平盘下单到回测结果读取的完整流程。用于真实投资研究时,应以具有明确可用时点的真实做市行情、债券条款与借贷信息替换模拟数据,并进一步评估报价被成交的概率、平盘效率、买卖价差收益、交易成本与样本外表现。