国债期货 CTD 期现基差套利策略

本策略以国债期货和每日最便宜可交割券(Cheapest to Deliver,CTD)为交易对象,根据现券和期货买卖一价计算可交易基差。当基差偏低时买入现券、卖出期货;当基差偏高时借券卖出现券、买入期货。策略通过交易现券与期货之间的相对价格偏差,捕捉基差回归带来的收益。

1. 策略介绍

国债期货的标的并不是某一只具体国债,而是一只标准化的名义债券。进入交割月后,期货空头可以从交易所规定的可交割国债篮子中选择实际用于交割的债券。由于不同债券的票息、剩余期限和久期不同,交易所通过“转换因子”将各种可交割券换算为统一的期货交割价值。

在可交割券篮子中,对期货空头而言交割成本最低、经济上最有利的债券称为最便宜可交割券(CTD)。CTD 是连接现券价格与期货价格的核心标的,因此期现套利通常围绕 CTD 展开,而不是任意选择一只国债。

策略同时持有方向相反的现券与期货头寸,旨在降低利率方向性风险,主要收益来自基差回归,而非对利率方向的单边判断。因此它属于市场中性策略。

1.1 核心指标

转换基差用于衡量 CTD 现券价格与国债期货价格经转换因子调整后的价格差异,其计算公式为:

转换基差 = CTD 现券价格 - 国债期货价格 × 转换因子

实际交易中,应根据交易方向使用现券和期货的可成交一档报价:

  • 正套开仓基差 = 现券卖一价 - 期货买一价 × 转换因子

  • 反套开仓基差 = 现券买一价 - 期货卖一价 × 转换因子

使用不同的买卖价格,是为了把两腿买卖价差纳入信号。如果仅使用中间价或收盘价,通常会高估策略的可交易收益。

需要注意,上述开仓基差用于衡量特定交易方向下的入场价格关系,并不等同于完整的套利收益。实际盈亏还受到平仓价格、应计利息、融资或借券成本、手续费等因素的影响。

本策略涉及的报价变量定义如下:

变量 含义 方向
Sa CTD 现券卖一净价 策略买入现券时支付
Sb CTD 现券买一净价 策略卖出现券时获得
Fa 国债期货卖一价 策略买入期货时支付
Fb 国债期货买一价 策略卖出期货时获得
CF CTD 转换因子 无量纲,期货标准券报价 → CTD 交割价口径

上述报价均按每百元面额计。基差单位为“每百元面额的价格点”,例如基差由 3.0 变为 3.1,表示基差增加了 0.1 个价格点,即每百元面额对应的价格差增加了 0.1 元。

1.1.1 正套可交易基差

正套开仓 = 买入现券 + 卖出期货。为保证价格可成交:

  • 现券取卖一价 Sa

  • 期货取买一价 Fb

​ B buy = S a − CF × F b B_{buy}=S_a-CF\times F_b

其中,Bbuy 表示“现在同时买入 CTD 并卖出期货”时能够锁定的开仓基差。Bbuy 越小,CTD 相对期货越便宜。

正套开仓条件:

B buy < B positive open B_{buy} < B^{open}_{positive}

正套平仓 = 卖出现券 + 买入期货,使用 Bsell:

B sell > B positive close B_{sell}>B^{close}_{positive}

正套基差改善量:

G positive = B sell , exit − B buy , entry G_{positive}=B_{sell,exit}-B_{buy,entry}

按阈值,每次完整正套的 Gpositive​ 必须 > 0.1 个价格点。该值为信号上的基差改善,不等于实际净利润。实际收益仍取决于现券面额、期货手数、期货合约乘数、两腿价格点损益的换算比例,以及手续费、融资成本、应计利息等因素。

1.1.2 反套可交易基差

反套开仓 = 卖出现券 + 买入期货:

  • 现券取买一价 Sb​

  • 期货取卖一价 Fa

B sell = S b − CF × F a B_{sell}=S_b-CF\times F_{a}

Bsell 越大,CTD 相对期货越贵。

反套开仓条件:

B sell > B negative open B_{sell}>B^{open}_{negative}

反套平仓 = 买回现券 + 卖出期货,使用 Bbuy​:

B buy < B negative close B_{buy}<B^{close}_{negative}

反套基差改善量:

G negative = B sell , entry − B buy , exit G_{negative}=B_{sell,entry}-B_{buy,exit}

​同样要求每次完整反套的 Gnegative​> 0.1 个价格点。

1.1.3 两市场买卖价差成本

同一时刻,买入方向基差通常大于卖出方向基差,差额为两个市场的买卖价差之和:

W = B buy − B sell = S a − S b + CF × F a − F b \begin{aligned} W&=B_{buy}-B_{sell}\\ &=\left(S_a-S_b\right)+CF\times\left(F_a-F_b\right) \end{aligned}

W 为一次立即反向交易需要克服的最小盘口成本(正常情形下 W ≥ 0 W\geq0 )。该式表明:不能用同一中间价基差同时替代开仓与平仓基差,否则会忽略真实交易成本。

若仅用于监控,可计算中间价基差:

B mid = S a + S b 2 − CF × F a + F b 2 B_{mid}=\frac{S_a+S_b}{2}-CF\times\frac{F_a+F_b}{2}

​​但 Bmid不参与任何交易决策。

1.1.4 基差改善与组合损益

记现券面额为 Q,期货手数为 N,期货合约乘数为 M。忽略应计利息、息票、费用与资金成本时:

正套价差损益:

PnL positive = Q 100 ( S b , exit − S a , entry ) + N M ( F b , entry − F a , exit ) PnL_{positive}=\frac{Q}{100}(S_{b,exit}-S_{a,entry})+NM(F_{b,entry}-F_{a,exit})

反套价差损益:

PnL positive = Q 100 ( S b , exit − S a , entry ) + N M ( F b , entry − F a , exit ) PnL_{positive}=\frac{Q}{100}(S_{b,exit}-S_{a,entry})+NM(F_{b,entry}-F_{a,exit})

上述公式适用于开平仓期间 CTD 与 CF 不变的情形。若期间发生 CTD 换券,还需单独计算卖出旧券、买入新券或反向换券产生的移仓损益。

完整组合净损益:

PnL net = PnL price + CouponIncome + AccruedInterestChange = − FundingCost − BorrowCost − Commission = − Slippage − RollCost \begin{aligned} PnL_{net}={}&PnL_{price}+CouponIncome+AccruedInterestChange\\ &-FundingCost-BorrowCost-Commission\\ &-Slippage-RollCost \end{aligned}

​两腿损益乘数匹配条件:

N M = Q 100 × CF ⇔ Q = 100 N M CF NM=\frac{Q}{100}\times CF \Leftrightarrow Q=\frac{100NM}{CF}

只有当两腿价格点损益乘数满足该式时,基差改善量才能按统一比例直接转换为组合价差损益。否则,基差改善不能直接解释为策略实际毛利,还需用现券与期货 DV01 进一步校正套保比率。

1.2 交易标的

策略的交易对象由两部分组成:

  • 期货腿:国债期货合约。历史回测时使用主力合约,而非固定某一合约(如 T2503)。主力合约按流动性规则(如持仓量、成交量)逐日确定,并在合约切换日完成移仓。

  • 现券腿:当日 CTD。CTD 随交易日变动,由转换因子表或专门的 CTD 表逐日给出。CTD 切换时,策略需完成现券腿换券。

期货合约代码在回测引擎中为具体合约(如 T2503),现券代码根据清算速度映射为 .T0 或 .T2 后缀。

表 1. 表 1-1 交易标的与账户
资产 代码 方向
国债期货 相应的期货主力合约 可多可空
银行间国债 当日 CTD,如 240023.T0 现券多头或借券空头
银行间国债 次日 CTD,如 240017.T0 现券多头或借券空头

上表中的 240023.T0、240017.T0 仅为示例,实际 CTD 由每日 CTD 表决定。

表 2. 表 1-2 CTD 时序与转换因子(示例)
交易日 CTD 期货合约 转换因子
2025-02-17 240023.T0 当日主力合约 0.9199
2025-02-18 240023.T0 当日主力合约 0.9199
2025-02-19 240023.T0 当日主力合约 0.9199
2025-02-20 240017.T0 当日主力合约 0.9274
2025-02-21 240017.T0 当日主力合约 0.9274

历史回测中,CTD 与转换因子均逐日从数据源读取。上表仅用于说明 CTD 切换与转换因子变化的形式。

1.3 交易规则

策略以正套状态与反套状态分别记录持仓意图。两个阈值区间之间留有空档,但当前状态并非强制互斥。

表 3. 表 1-3 基差参数
参数 含义
B positive open B^{open}_{positive} 正套开仓上限
B positive close B^{close}_{positive} ​ 正套平仓下限
B negative open B^{open}_{negative} 反套开仓下限
B negative close B^{close}_{negative} 反套平仓上限
△ B \triangle B 开平仓阈值差(止盈基差)
表 4. 表 1-4 开平仓规则
组合 严格触发条件 现券腿 期货腿
正套开仓 B buy < B positive open B_{buy}<B^{open}_{positive} 以卖一价买入 以买一价卖开
正套平仓 B sell > B positive close B_{sell}>B^{close}_{positive} 以买一价卖平 以卖一价买平
反套开仓 B sell > B negative open B_{sell}>B^{open}_{negative} 以买一价借券卖开 以卖一价买开
反套平仓 B buy < B negative close B_{buy}<B^{close}_{negative} 以卖一价买券平仓 以买一价卖平

每次现券委托量与期货委托量按套保比率确定,当前实现中为固定手数配比。信号与订单均由现券快照驱动;期货到达时只更新报价缓存,不单独触发决策。

1.4 CTD 切换与主力合约移仓

国债期货期现套利通常跨越较长的持仓周期。在实际运作中,策略不仅面临静态基差波动,还需应对两类关键的动态调仓场景:一是因现券收益率曲线变动引起的最便宜可交割券(CTD 券)切换;二是因交割月临近引起的期货主力合约换月移仓。本节重点讨论两者的触发机制、基差跳变影响及调仓成本处理。

1.4.1 CTD 切换

每个交易日的 beforeTrading 从 CTD 表取当日券。CTD 变化时:

  • 旧券无持仓:取消旧券行情订阅,改订阅新券。

  • 旧券仍有持仓:同时订阅新旧 CTD,保留期货腿并换仓现券腿:

    • 旧 CTD 多头:卖平旧券,买开新券。

    • 旧 CTD 空头:买平旧券,卖开新券。

1.4.2 期货合约移仓

回测中,期货腿使用主力合约,合约切换时需完成期货腿移仓:

  • 旧合约无持仓:取消旧合约行情订阅,改订阅新主力合约。

  • 旧合约仍有持仓:同时订阅新旧主力合约,保留现券腿并换仓期货腿:

    • 旧合约空头:买平旧合约,卖开新合约。

    • 旧合约多头:卖平旧合约,买开新合约。

移仓触发规则建议按持仓量或成交量确定主力切换日,并在切换日前后设置缓冲窗口,避免在流动性不足时移仓。

2. 策略回测

回测使用 DolphinDB Backtest 事件驱动引擎,先将期货与现券数据转为统一快照结构,再按时间排序回放。

2.1 准备数据

本策略实现使用模拟的 CSV 数据。主要包含五个数据文件:

其中,统一行情快照(snapshot) 是回测撮合引擎的核心数据源,需严格按照 Backtest FICC 快照 schema 构造,包含 symbol、datetime、timestamp、bidQty、bidPrice、askQty、askPrice、source、结算价与收益率等列。

本例直接通过 loadText 函数从本地读取已准备好的 CSV 数据。

path="/.../"

CTDtable=loadText(path+"ctd.csv")
mainContractTable = select TradingDate, mainContract
    from loadText(path+"mainContract.csv") order by TradingDate
name= ["symbol","datetime","timestamp","bidQty","bidPrice","askQty","askPrice","source","preSettlementPrice","openPrice",
"closePrice","settlementPrice","lastPrice","prevPrice","volume","turnover","openInterest","lastYTM","bidYTM","askYTM"]
type=["SYMBOL","TIMESTAMP","LONG","LONG[]","DOUBLE[]","LONG[]","DOUBLE[]","SYMBOL","DOUBLE","DOUBLE",
"DOUBLE","DOUBLE","DOUBLE","DOUBLE","LONG","DOUBLE","LONG","DOUBLE","DOUBLE[]","DOUBLE[]"]
 snapshot =loadText(path+"allSnapshot.csv",schema=table(name, type))

bondMsg = select * from snapshot where source == "CFETS"
futMsg  = select * from snapshot where source == "CFFEX"
snapshot = select * from snapshot order by timestamp

2.2 编写策略逻辑

本策略基于 DolphinDB Backtest 插件,实现国债期货与 CTD 现券之间的基差偏离套利。核心逻辑概括如下:

  • 标的与信号:每日从数据源动态获取国债期货主力合约与当日 CTD,以现券与期货的买卖一价计算可交易基差,而非中间价。

  • 开平仓规则:当买入方向基差低于开仓阈值时,执行正套(买入现券、卖出期货);当卖出方向基差高于开仓阈值时,执行反套(借券卖出现券、买入期货)。基差回归至平仓阈值时反向平仓。

  • 换仓处理:CTD 随交易日变动,换券时同时订阅新旧券行情,按旧券持仓方向完成买平/卖平与卖开/买开;主力合约切换时同理完成期货腿移仓。

  • 风控与校验:开仓前校验两腿行情新鲜度与价格有效性,跳过过期或异常快照。

下面按事件回调顺序展开实现:

策略状态初始化(initialize):initialize(mutable context) 在引擎创建时调用一次,写入阈值、行情缓存、持仓标记与 CTD / 主力合约切换状态。

def initialize(mutable context){
    // 在回测引擎启动时调用一次。
    // context 是引擎维护的可变策略上下文。

    // ----------四个交易阈值 ----------
    // basis1:正套开仓阈值。现券买一成本减去期货卖一折算价低于该值时开正套。
    context['basis1'] = context['做多基差阈值']
    // basis2:正套平仓阈值。在开仓阈值上增加止盈基差。
    context['basis2'] = context['做多基差阈值'] + context['止盈基差']
    // basis3:反套开仓阈值。现券卖一收入减去期货买一折算价高于该值时开反套。
    context['basis3'] = context['做空基差阈值']
    // basis4:反套平仓阈值。在开仓阈值下扣除止盈基差。
    context['basis4'] = context['做空基差阈值'] - context['止盈基差']

    // ---------- 初始化组合持仓状态 ----------
    // holdingP=1 表示策略逻辑认为当前持有“多现券、空期货”的正套组合。
    context['holdingP'] = 0
    // holdingN=1 表示策略逻辑认为当前持有“空现券、多期货”的反套组合。
    context['holdingN'] = 0

    // ---------- 初始化 CTD 标的与换券状态 ----------
    // thisCTD/lastCTD 分别保存当日 CTD 与切换前的旧 CTD 代码。
    context['thisCTD']        = ''
    context['lastCTD']        = ''
    // holdingLastCTD 标识旧 CTD 是否仍有持仓;ifMoveCTD 标识是否需要执行换券。
    context['holdingLastCTD'] = 0
    context['ifMoveCTD']      = 0
    // ctdLongShort:0 表示旧 CTD 为空头,1 表示旧 CTD 为多头。
    context['ctdLongShort']   = 0
    // base0* 缓存当前 CTD 的买一、卖一、行情时间及转换因子。
    context['base0AP0'] = 0
    context['base0BP0'] = 0
    context['base0Time']= 0
    context['base0CF']  = 1
    context['cvf0']     = -1
    // base1* 缓存旧 CTD 的买一、卖一及转换因子,供换券订单定价。
    context['base1AP0'] = 0
    context['base1BP0'] = 0
    context['base1CF']  = 1
    context['cvf1']     = -1

    // ---------- 初始化期货主力合约及移仓状态 ----------
    // thisMain/lastMain 分别保存当前和上一个主力合约。
    context['thisMain']        = ''
    context['lastMain']        = ''
    // ifMoveMain=1 表示旧主力仍有仓位,需要平旧开新;0 表示无需移仓。
    context['ifMoveMain']      = 0
    // mainLongShort:0 表示旧主力为空头,1 表示旧主力为多头。
    context['mainLongShort']   = 0
    // 缓存旧主力和新主力的一档买卖报价,供移仓订单定价。
    context['lastMainAP0'] = 0
    context['lastMainBP0'] = 0
    context['thisMainAP0'] = 0
    context['thisMainBP0'] = 0

    // ---------- 初始化配对期货行情缓存 ----------
    // pairAP0/pairBP0 是策略计算基差时使用的期货卖一价/买一价。
    context['pairAP0'] = 0
    context['pairBP0'] = 0
    // pairTime 用来与当前 CTD 行情时间比较,防止两腿行情不同步。
    context['pairTime']= 0

    // ---------- 设置两腿行情最大允许时间差 ----------
    // 单位为毫秒;超过 5 秒时,本次快照只更新、不产生交易决策。
    context['maxQuoteGapMs'] = 5000
}

盘前处理(beforeTrading):beforeTrading(mutable context) 在每个交易日开始前调用,负责两件事:CTD 取数与订阅、主力合约取数与订阅:

  • CTD 取数与订阅

    • 从 CTD 表取当日 CTD 与转换因子。

    • 无持仓:直接切换订阅,更新当前 CTD 与转换因子。

    • 有持仓:同时订阅新旧 CTD,设置 ifMoveCTD=1,并按持仓方向设置 ctdLongShort。

  • 主力合约取数与订阅

    • 从主力合约表取当日主力。

    • 无持仓:直接切换订阅。

    • 有持仓:同时订阅新旧主力,设置是否移仓的标志。

def beforeTrading(mutable context,userParam){
  // beforeTrading 在每个交易日首条行情处理前调用。
	tradeDate = date(context["tradeDate"])
	// ========== step 1: CTD 取数与订阅 ==========
	// ========== step 2: 期货主力合约取数与订阅 ==========
}

下面我们分别展示 beforeTrading 回调函数中 CTD 和期货主力合约取数与订阅的详细代码实现:

step 1,在每天回测开始前执行,确定当日 CTD,检查其是否相较于前一交易日发生变化,并根据持仓情况设置行情订阅和换券状态。如果当日 CTD 与当前记录一致,仅订阅当日 CTD。若 CTD 发生变化,先查询旧 CTD 的真实多空持仓。如果旧 CTD 有仓位,保留新旧两只券的行情订阅以完成平旧开新操作。动态订阅行情通过 subscribeQuote 接口实现,具体函数的参数说明参考 接口说明。

// ========== step 1:读取当日 CTD 与转换因子 ==========
	// CTDDict/CFDict 在创建引擎是传入。
	todaysCTD = context['CTDDict'][tradeDate]
	todaysCF  = context['CFDict'][tradeDate]
	// step 1.1:当日 CTD 与当前记录一致时,仅订阅当日 CTD。
	// source="CFETS" 指定现券市场;quoteType="snapshot" 指定快照行情;
	if(todaysCTD == context['thisCTD'] or context['thisCTD'] == ''){
		// 首日把当日 CTD/转换因子写入上下文;后续未换券交易日只刷新转换因子。
		context['thisCTD'] = todaysCTD
		context['cvf0'] = todaysCF
		context['holdingLastCTD'] = 0
		Backtest::subscribeQuote(context["engine"], [context['thisCTD']],
		    "CFETS", "snapshot", 1, 1, 1)
	}
	else{
		// step 1.2:若 CTD 发生变化,先查询旧 CTD 的真实多空持仓。
		baseLongPos = max(Backtest::getPosition(
		    context["engine"], context['thisCTD'], "bonds").longPosition, 0)
		baseShortPos = max(Backtest::getPosition(
		    context["engine"], context['thisCTD'], "bonds").shortPosition, 0)

		if(baseLongPos == 0 and baseShortPos == 0){
			// step 1.2.1:旧 CTD 无仓位,无需换券;取消旧订阅后只订阅新 CTD。
			context['lastCTD'] = context['thisCTD']
			context['thisCTD'] = todaysCTD
			context['cvf0'] = todaysCF
			context['holdingLastCTD'] = 0
			context['ifMoveCTD'] = 0
			Backtest::unsubscribeQuote(context["engine"],
			    [context['lastCTD']], "CFETS")
			Backtest::subscribeQuote(context["engine"], [context['thisCTD']],
			    "CFETS", "snapshot", 1, 1, 1)
		}
		else{
			// step 1.2.2:旧 CTD 有仓位,保留新旧两只券的行情订阅以完成平旧开新。
			context['holdingLastCTD'] = 1
			context['lastCTD'] = context['thisCTD']
			context['thisCTD'] = todaysCTD
			// 保存旧券转换因子、更新新券转换因子,并设置待换券标志。
			context['cvf1'] = context['cvf0']
			context['cvf0'] = todaysCF
			context['ifMoveCTD'] = 1
			// 旧券存在空头时记为 0,否则记为 1(多头)。
			context['ctdLongShort'] = iif(baseShortPos != 0, 0, 1)
			Backtest::subscribeQuote(context["engine"],
			    [context['lastCTD'], context['thisCTD']],
			    "CFETS", "snapshot", 1, 1, 1)
		}
	}

step 2,同样,在每日盘前获取当日期货主力合约,检查其是否发生变化,并根据旧主力合约的实际持仓更新行情订阅和移仓状态。交易日盘前获取当日的主力合约。当主力合约变化时,读取旧主力的真实多空持仓。旧主力无仓位,直接切换订阅主力合约行情,不产生移仓订单。旧主力有仓位,同时订阅新旧合约行情数据并标记待移仓。

// ========== step 2:读取并订阅当日期货主力合约 ==========
	// mainContractDict 由主力合约日表构造,键为交易日,值为当日主力代码。
	todaysMain = context['mainContractDict'][tradeDate]
	// step 2.1:首日或主力未变化时,更新当前主力并订阅
    context['ifMoveMain'] = 0
	if(todaysMain == context['thisMain'] or context['thisMain'] == ''){
		context['thisMain'] = todaysMain
		Backtest::subscribeQuote(context["engine"], [context['thisMain']],
		    "CFFEX", "snapshot", 1, 1, 2)
	}
	else{
		// step 2.2:主力变化时,读取旧主力的真实多空持仓。
		mainLongPos = max(Backtest::getPosition(
		    context["engine"], context['thisMain'], "futures").longPosition, 0)
		mainShortPos = max(Backtest::getPosition(
		    context["engine"], context['thisMain'], "futures").shortPosition, 0)
		if(mainLongPos == 0 and mainShortPos == 0){
		    // step 2.2.1:旧主力无仓位,直接切换订阅,不产生移仓订单。
		    context['lastMain'] = context['thisMain']
		    context['thisMain'] = todaysMain
		    Backtest::unsubscribeQuote(context["engine"],
		        [context['lastMain']], "CFFEX")
		    Backtest::subscribeQuote(context["engine"], [context['thisMain']],
		        "CFFEX", "snapshot", 1, 1, 2)
	    }
	    else{
		// step 2.2.2:旧主力有仓位,同时订阅新旧合约并标记待移仓。
		context['lastMain'] = context['thisMain']
		context['thisMain'] = todaysMain
		context['ifMoveMain'] = 1
		// 旧主力存在空头时记为 0,否则记为 1(多头)。
		context['mainLongShort'] = iif(mainShortPos != 0, 0, 1)
		Backtest::subscribeQuote(context["engine"],
		    [context['lastMain'], context['thisMain']],
		    "CFFEX", "snapshot", 1, 1, 2)
	    }
	}

快照行情回调(onSnapshot ):onSnapshot(mutable context, data1, data2) 是策略执行交易逻辑的核心回调。每当订阅的快照行情到达时,回测引擎都会调用该函数。其中,context 为可变 DICTIONARY,含引擎句柄、交易时间、报价缓存、转换因子与持仓状态。第二个参数 data1 对应现券行情,第三个参数 data2 对应国债期货行情。该回调负责获取行情和执行价差套利的逻辑。先检查行情有效性,再处理 CTD 换券,然后处理期货主力移仓,最后处理正常的正套和反套信号。具体策略的实现分为 5 个步骤实现,大致如下:

// 行情过期检查
def checkQuoteFreshness(bondTime, futTime, maxGapMs){
    // 回测快照的 timestamp 为纳秒 LONG;转换成毫秒后再比较。
    gapNs = abs(long(bondTime) - long(futTime))
    return gapNs <= long(maxGapMs) * 1000000l
}
def onSnapshot(mutable context, data1, data2){
    // onSnapshot 在订阅行情到达时触发。
    // data1 对应现券,data2 对应期货;
	// bidPrice/askPrice 是五档数组。
    // ========== step 1:更新两腿行情 ==========
    // ========== step 2:执行交易前数据质量检查 ==========
    // step 2.1:任一腿尚未收到过有效时间戳时,不计算跨市场基差。
    // step 2.2:两腿时间差超过 maxQuoteGapMs 时返回,避免使用陈旧期货或现券报价。
    // step 2.3:任一买一/卖一非正数时返回,防止无效价格触发信号或订单。
    // step 2.4:一次性计算四个布尔信号,标记当前行情对应的策略状态。
    // ========== step 3:处理 CTD 换券 ==========
    // ========== step 4:处理期货合约移仓 ==========
    // ========== step 5:按优先级处理正常开平仓信号 ==========
    // 处理逻辑包括:正套开仓 -> 正套平仓 -> 反套开仓 -> 反套平仓;
     // 每个命中分支都会 return,保证一次行情回调最多提交一组配对操作。
  }

下面我们分别展示 onSnapshot 回调函数中 5 个步骤的详细代码实现:

step 1,首先更新两腿行情缓存。如果收到期货行情,就记录期货卖一价、买一价和行情时间;如果没有现券行情,则只更新期货缓存并直接返回。如果收到现券行情,则读取其代码、卖一价、买一价和行情时间,并根据代码将价格保存到当前 CTD、旧 CTD、当前主力或旧主力对应的缓存中。策略只使用当前 CTD 行情驱动交易决策。旧 CTD 或其他合约的行情只用于更新换券、移仓所需的价格缓存,不会直接触发交易。只有当本次现券代码等于 thisCTD 时,才继续执行后面的判断。

// ========== step 1:更新两腿行情缓存 ==========
// 先初始化代码变量,便于判断本次回调实际更新的是现货或者期货的行情。
data1Symbol = ""
data2Symbol = ""
// step 1.1:期货通道有数据时,缓存期货代码、卖一、买一和纳秒时间戳。
if(size(data2) > 0){
    data2Symbol = string(data2.symbol)
    context['pairAP0'] = data2.askPrice[0]
    context['pairBP0'] = data2.bidPrice[0]
    context['pairTime']= data2.timestamp
}
// step 1.2:若本次没有现券行情,只保留刚更新的期货缓存,不进行交易判断。
if(size(data1) == 0) return	

// step 1.3:读取本次现券消息的代码、卖一、买一和纳秒时间戳。
data1Symbol =data1.symbol 
sym = data1.symbol
ask = data1.askPrice[0]
bid = data1.bidPrice[0]
ts  = data1.timestamp
// step 1.4:根据消息代码,将报价写入对应的当前/旧合约缓存。
if(sym == context['thisCTD']){
    // 当前 CTD 报价驱动正常交易信号,并参与行情新鲜度检查。
    context['base0AP0'] = ask
    context['base0BP0'] = bid
    context['base0Time']= ts
}
else if(sym == context['lastCTD']){
    // 旧 CTD 报价仅在 CTD 换券时用于旧券平仓定价。
    context['base1AP0'] = ask
    context['base1BP0'] = bid
}
else if(sym == context['lastMain']){
    // 旧期货主力报价用于旧主力平仓定价。
    context['lastMainAP0'] = ask
    context['lastMainBP0'] = bid
}
else if(sym == context['thisMain']){
    // 新期货主力报价用于新主力开仓定价。
    context['thisMainAP0'] = ask
    context['thisMainBP0'] = bid
}
// step 1.5:当前 CTD 是策略主驱动;旧券或其他代码只更新缓存后立即返回。
if(sym != context['thisCTD']) return

step 2,正式计算信号前,策略会检查现券和期货是否都已经收到行情、两腿行情时间差是否小于 5 秒,以及买一价和卖一价是否全部为正数。任一条件不满足,本次回调都会直接结束,以避免使用缺失、无效或不同步的行情交易。数据检查通过后,策略根据当前 CTD 的买卖报价、期货买卖报价及转换因子,计算四类基差信号:正套开仓基差、正套平仓基差、反套开仓基差和反套平仓基差。其中,正套表示买入现券、卖出期货;反套表示卖出现券、买入期货。

// ========== step 2:执行交易前数据质量检查 ==========
// step 2.1:任一腿尚未收到过有效时间戳时,不计算跨市场基差。
if(context['base0Time'] == 0 or context['pairTime'] == 0) return
// step 2.2:两腿时间差超过 maxQuoteGapMs 时返回,避免使用陈旧期货或现券报价。
if(!checkQuoteFreshness(context['base0Time'], context['pairTime'],
        context['maxQuoteGapMs'])) return
// step 2.3:任一买一/卖一非正数时返回,防止无效价格触发信号或订单。
if(context['base0AP0'] <= 0 or context['base0BP0'] <= 0 or
   context['pairAP0'] <= 0 or context['pairBP0'] <= 0) return
	
// step 2.4:一次性计算四个布尔信号,标记当前行情对应的策略状态。
// 正套开仓使用“买入现券的卖一价 - 卖出期货的买一价×转换因子”。
positiveOpenSignal = context['base0AP0'] - context['pairBP0']*context['cvf0'] < context['basis1']
// 正套平仓使用“卖出现券的买一价 - 买入期货的卖一价×转换因子”。
positiveCloseSignal = context['base0BP0'] - context['pairAP0']*context['cvf0'] > context['basis2']
// 反套开仓使用“卖出现券的买一价 - 买入期货的卖一价×转换因子”。
negativeOpenSignal = context['base0BP0'] - context['pairAP0']*context['cvf0'] > context['basis3']
// 反套平仓使用“买入现券的卖一价 - 卖出期货的买一价×转换因子”。
negativeCloseSignal = context['base0AP0'] - context['pairBP0']*context['cvf0'] < context['basis4']

step 3,在处理普通交易信号前,策略会优先检查是否需要进行 CTD 换券。如果 ifMoveCTD 等于 1,说明旧 CTD 仍有持仓。旧券为空头时,策略买入旧券平空,同时卖出新券开空;旧券为多头时,策略卖出旧券平多,同时买入新券开多。

// ========== step 3:处理 CTD 换券 ==========
// ifMoveCTD=1 表示旧 CTD 有持仓;换券优先级高于主力移仓和普通信号。
if(context['ifMoveCTD'] == 1){
if(context['ctdLongShort'] == 0){
    // step 3.1:旧 CTD 是空头。
    // direction=1/openClose=2:按旧券卖一价买入平空。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['lastCTD'], "CFETS", long(100000*context['qty']), 5, 1, 2,
         context['base1AP0'])), "71lastLongClose")
    // direction=2/openClose=1:按新券买一价卖出开空,保持组合方向不变。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['thisCTD'], "CFETS", long(100000*context['qty']), 5, 2, 1,
         context['base0BP0'])), "72thisShortOpen")
}
else{
    // step 3.2:旧 CTD 是多头。
    // direction=2/openClose=2:按旧券买一价卖出平多。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['lastCTD'], "CFETS", long(100000*context['qty']), 5, 2, 2,
         context['base1BP0'])), "81lastShortClose")
    // direction=1/openClose=1:按新券卖一价买入开多,保持组合方向不变。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['thisCTD'], "CFETS", long(100000*context['qty']), 5, 1, 1,
         context['base0AP0'])), "82thisLongOpen")
}
    // step 3.3:订单提交后清除换券标志;本次回调不再处理其他信号。
    context['holdingLastCTD'] = 0
    context['ifMoveCTD'] = 0
    return
}

step 4,如果不需要 CTD 换券,策略继续检查是否需要进行期货主力移仓。如果旧主力为空头,就买入旧主力平空并卖出新主力开空;如果旧主力为多头,就卖出旧主力平多并买入新主力开多。

// ========== step 4:处理期货主力合约移仓 ==========
// 仅在没有待处理 CTD 换券时进入;ifMoveMain=1 表示旧主力仍有持仓。
if(context['ifMoveMain'] == 1){
if(context['mainLongShort'] == 0){
    // step 4.1:旧主力是空头,先买入平旧空,再卖出开新空。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['lastMain'], "CFFEX", long(context['qty']), 5, 1, 2,
         context['lastMainAP0'])), "91lastMainLongClose")
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['thisMain'], "CFFEX", long(context['qty']), 5, 2, 1,
         context['thisMainBP0'])), "92thisMainShortOpen")
}
else{
    // step 4.2:旧主力是多头,先卖出平旧多,再买入开新多。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['lastMain'], "CFFEX", long(context['qty']), 5, 2, 2,
         context['lastMainBP0'])), "93lastMainShortClose")
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['thisMain'], "CFFEX", long(context['qty']), 5, 1, 1,
         context['thisMainAP0'])), "94thisMainLongOpen")
}
    // step 4.3:订单提交后清除移仓标志并结束本次回调。
    context['ifMoveMain'] = 0
    return
}

step 5,如果没有换券和移仓任务,策略才处理普通开平仓信号,并按照“正套开仓、正套平仓、反套开仓、反套平仓”的顺序判断。当正套开仓基差低于阈值时,策略按现券卖一价买入 10 万面额 CTD,同时按期货买一价卖出 1 手期货,并将 holdingP 设置为 1,防止重复开仓。当基差上升至正套平仓阈值后,策略先查询两腿的实际持仓,再卖出现券多头并买入平掉期货空头,最后将 holdingP 恢复为 0。当反套开仓基差高于阈值时,策略按现券买一价卖空 10 万面额 CTD,同时按期货卖一价买入 1 手期货,并将 holdingN 设为 1。当基差下降至反套平仓阈值后,策略查询实际持仓,再买入平掉现券空头并卖出平掉期货多头,最后将 holdingN 恢复为 0。

// ========== step 5:按优先级处理正常开平仓信号 ==========
// 处理逻辑包括:正套开仓 -> 正套平仓 -> 反套开仓 -> 反套平仓;
// 每个命中分支都会 return,保证一次行情回调最多提交一组配对操作。
// step 5.1:正套开仓——买入 10 万面额 CTD,同时卖出 1 手期货。
if(context['base0AP0'] - context['pairBP0']*context['cvf0'] < context['basis1']){
    // holdingP 防止在正套信号持续存在时重复开仓。
    if(context['holdingP'] == 0){
          // 现券按卖一价买入开多:direction=1,openClose=1。
          Backtest::submitOrder(context["engine"],
              dict(`symbol`source`qty`orderType`direction`openClose`price,
              (context['thisCTD'], "CFETS", long(100000*context['qty']), 5, 1, 1,
               context['base0AP0'])), "11bondLongOpen")
          // 期货按买一价卖出开空:direction=2,openClose=1。
          Backtest::submitOrder(context["engine"],
              dict(`symbol`source`qty`orderType`direction`openClose`price,
              (context['pairSymbol'], "CFFEX", long(context['qty']), 5, 2, 1,
               context['pairBP0'])), "12futuresShortOpen")
          // 标记正套组合已提交,后续等待平仓信号。
          context['holdingP'] = 1
        }
        return
}

// step 5.2:正套平仓——卖出 CTD 多头,同时买入平期货空头。
closePositive = context['base0BP0'] - context['pairAP0']*context['cvf0'] > context['basis2']
if(closePositive and context['holdingP'] == 1){
    // 下单前读取两腿真实持仓,避免无仓位时提交平仓单。
    baseLongPos = max(Backtest::getPosition(
        context["engine"], context['thisCTD'], "bonds").longPosition[0], 0)
    pairShortPos = max(Backtest::getPosition(
    context["engine"], context['pairSymbol'], "futures").shortPosition[0], 0)
    if(baseLongPos >= 100000*context['qty']){
        // 现券按买一价卖出平多:direction=2,openClose=2。
        Backtest::submitOrder(context["engine"],
            dict(`symbol`source`qty`orderType`direction`openClose`price,
            (context['thisCTD'], "CFETS", long(100000*context['qty']), 5, 2, 2,
             context['base0BP0'])), "21bondShortClose")
    }
  if(pairShortPos >= 1){
    // 期货按卖一价买入平空:direction=1,openClose=2。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['pairSymbol'], "CFFEX", long(context['qty']), 5, 1, 2,
         context['pairAP0'])), "22futuresLongClose")
    }
    // 清除正套状态,允许后续再次开正套。
    context['holdingP'] = 0
    return
}

// step 5.3:反套开仓——卖空 10 万面额 CTD,同时买入 1 手期货。
if(context['base0BP0'] - context['pairAP0']*context['cvf0'] > context['basis3']){
// holdingN 防止在反套信号持续存在时重复开仓。
if(context['holdingN'] == 0){
    // 现券按买一价卖出开空:direction=2,openClose=1。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['thisCTD'], "CFETS", long(100000*context['qty']), 5, 2, 1,
         context['base0BP0'])), "31bondShortOpen")
    // 期货按卖一价买入开多:direction=1,openClose=1。
    Backtest::submitOrder(context["engine"],
        dict(`symbol`source`qty`orderType`direction`openClose`price,
        (context['pairSymbol'], "CFFEX", long(context['qty']), 5, 1, 1,
         context['pairAP0'])), "32futuresLongOpen")
    // 标记反套组合已提交,后续等待平仓信号。
    context['holdingN'] = 1
    }
    return
}

// step 5.4:反套平仓——买入平 CTD 空头,同时卖出平期货多头。
closeNegative = context['base0AP0'] - context['pairBP0']*context['cvf0'] < context['basis4']
if(closeNegative and context['holdingN'] == 1){
    // 下单前读取两腿真实持仓,避免无仓位时提交平仓单。
    baseShortPos = max(Backtest::getPosition(
        context["engine"], context['thisCTD'], "bonds").shortPosition[0], 0)
    pairLongPos = max(Backtest::getPosition(
        context["engine"], context['pairSymbol'], "futures").longPosition[0], 0)
    if(baseShortPos >= 100000*context['qty']){
        // 现券按卖一价买入平空:direction=1,openClose=2。
        Backtest::submitOrder(context["engine"],
            dict(`symbol`source`qty`orderType`direction`openClose`price,
            (context['thisCTD'], "CFETS", long(100000*context['qty']), 5, 1, 2,
             context['base0AP0'])), "41bondLongClose")
    }
    if(pairLongPos >= 1){
        // 期货按买一价卖出平多:direction=2,openClose=2。
        Backtest::submitOrder(context["engine"],
            dict(`symbol`source`qty`orderType`direction`openClose`price,
            (context['pairSymbol'], "CFFEX", long(context['qty']), 5, 2, 2,
             context['pairBP0'])), "42futuresShortClose")
    }
    // 清除反套状态,允许后续再次开反套。
    context['holdingN'] = 0
    return
}

2.3 设置回测参数

创建回测之前,我们先设置策略回测的参数,包括:回测的开始日期,回测结束日期,策略的初始资金,CTD 、转换因子,以及期货的主力合约信息等。

futuSymbols = exec distinct symbol from futMsg
universe = array(ANY, 2)
universe[0] = exec distinct CTDcode from CTDtable
universe[1] =  futuSymbols
config = {
	startDate:startDate,
	endDate: endDate,
	strategyGroup: "FICC",
	cash: 120000000.0,
	dataType: 0,
	matchingMode: 1,
	latency: 0,
	tax:0.0,
	universe: universe,
	outputOrderInfo: true,
	context: {
		做多基差阈值:2,
		做空基差阈值:3,
		止盈基差:0.1,
		更换CTD阈值:5,
		pairSymbol:'T2503.CFE',
		maxHolding:1,
		qty:1,
		CTDDict: dict(CTDtable.TradingDate,CTDtable.CTDcode),
		CFDict:dict(CTDtable.TradingDate,CTDtable.CF),	
        mainContractDict:
        dict(mainContractTable.TradingDate,mainContractTable.mainContract)
	}
}

2.4 创建回测引擎

创建 FICC 回测时,还需配置债券和期货合约参考信息表,信息包括乘数、最小变动价位、票息、保证金、手续费等,并设置 securityReference:

name = ["symbol","multiplier","strikePrice","marginRatio","tradeUnit","priceUnit","priceTick","commission","deliveryCommissionMode","intradayClosingFee","deliveryPrice","lastTradingDay"]
type =["STRING","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","DOUBLE","INT","DOUBLE","DOUBLE","DATE"]
futuSecurityReference =loadText(path+"futuSecurityReference.csv",schema=table(name,type))
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"]
bondsSecurityReference =loadText(path+"bondsSecurityReference.csv",schema=table(name,type))
//设置合约的基本信息数据
securityReference = {
	"xbond": bondsSecurityReference,
	"futures": futuSecurityReference
}

设置引擎名称、配置项与事件回调后,先通过 Backtest::dropBacktestEngine 接口清除同名引擎,再创建回测引擎。

//设置策略回调函数
callbacks = {
	initialize: initialize,
	beforeTrading:beforeTrading,
	onSnapshot:onSnapshot
}
engineName = `simEngineName
try { Backtest::dropBacktestEngine(engineName) } catch(ex) { }
engine = Backtest::createBacktester(engineName, config, callbacks, false, securityReference)

Backtest::createBacktester 用于创建回测引擎,参数说明如下:

Backtest::createBacktester(name, config, eventCallbacks, securityReference)
//name表示回测引擎名称
//config 字典类型,表示回测引擎的配置项
//eventCallbacks 字典类型,表示策略回调函数
//securityReference 字典类型,合约的基本信息表,key 是回测的资产类型,value 是一个表,代表相应的基本信息表。

2.5. 执行回测引擎

先设置行情字典。msg 是字典形式,key 可取 “snapshot”、“ohlc”、”transaction”、“curveSignal” 四种,值为对应行情数据表。本例仅使用快照行情触发策略。

msg = dict(STRING, ANY)
msg["snapshot"] = snapshot

通过 appendQuotationMsg 接口向引擎插入行情并执行回测:

Backtest::appendQuotationMsg(engine, msg)

通过 appendEndMarker 接口结束回测:

Backtest::appendEndMarker(engine)

2.6. 获取回测结果

回测结束后,可通过接口获取成交明细、收益汇总以及组合权益与资金等结果。使用 getTradeDetails 查看成交明细,getReturnSummary 查看收益统计,getTotalPortfolios 查看组合层面的权益与可用资金变化:

dailyTotalPortfolios = Backtest::getDailyTotalPortfolios(engine)
futurePortfolios     = Backtest::getDailyTotalPortfolios(engine, "futures")
bondsPortfolios      = Backtest::getDailyTotalPortfolios(engine, "xbond")
allTradeDetails      = Backtest::getTradeDetails(engine)
returnSummary        = Backtest::getReturnSummary(engine)
print(dailyTotalPortfolios)

下图为本例获得的交易明细数据:

3. 总结

本案例实现了一个基于国债期货与 CTD 之间基差变化的期现套利策略。策略按交易日确定 CTD、转换因子与期货主力合约,使用现券与期货的同步买卖一价计算基差;基差低于阈值时买入现券、卖出期货建立正套,基差高于阈值时卖出现券、买入期货建立反套,并在基差回归后平仓。CTD 或主力合约发生变化时,策略保持原有多空方向完成换券或移仓。

本例展示了从模拟行情导入、CTD 与主力合约维护、跨市场行情同步校验、基差信号计算、期现两腿下单、换券移仓到回测结果读取的完整流程。

用于真实投资研究时,应:

  1. 以具有明确可用时点的真实现券、国债期货、转换因子与可交割券数据替换模拟数据;

  2. 进一步评估DV01 套保比例、现券流动性、单腿成交风险、交易成本与样本外表现;

  3. 建立成交回报驱动的互斥状态机,避免策略意图与账户真实持仓不一致。