债券期现 IRR 套利策略

债券期现 IRR 套利策略通过比较现券买入成本与期货交割对应的价值,估算持有至交割的年化隐含回购利率(Implied Repo Rate,IRR),并据此提交两腿订单。本例读取现券和国债期货快照,将计算好的 IRR 作为行情扩展信号送入 DolphinDB Backtest。当前实现采用固定交割参数和模拟行情配置,适合说明信号传递及配对下单流程,尚不能直接代表真实债券期现套利的收益。

1. 策略介绍

策略把期现价格差转换为与剩余持有天数有关的年化指标,再根据阈值判断是否提交订单。理解该指标时,需要同时区分经济含义、输入价格口径和脚本实际执行的交易方向。

1.1 核心指标

IRR 可以理解为买入现券并通过期货交割回收资金时,价格差所对应的年化回报。脚本使用简单年化公式,没有通过现金流折现方程求解内部收益率:

IRR=Ft×CF+DI−(Pt−I)Pt−I×365T−t.

  • F 为计算时使用的期货最新成交价,不代表未来交割日已知的成交价;

  • P 为现券卖一报价 spotAsk;

  • CF 为转换因子;

  • DI 为交割利息;

  • I 为脚本假设的现券单次付息;

  • T 为缴款日;

  • t 为现券时间戳取日期后的计算日。

价格和利息需要统一为每百元面值口径,T-t 以自然日计。

本文案例取 CF=1、I=0、DI=0,所以指标简化为期货价格相对现券卖一报价的溢价 * 365 / 剩余天数。使用时必须满足 P-I > 0、T-t > 0。

附件 CSV 的模拟行情将现券报价约定为每百元面值的示例全价,但没有模拟真实净价及应计利息之间的换算。替换为真实报价时,需先确认全价口径。真实研究还需要按债券条款、付息日和交割安排确定各项现金流;脚本没有计算资金成本或选择最便宜可交割券。

1.2 交易标的

本例使用模拟行情数据,现券标的为 CB.240023.ZS,期货标的为 T2503。

引擎的 strategyGroup 为 futures,两个标的使用相同的合约乘数、保证金率及交易单位。这是统一期货账户的示例建模,没有配置独立的现券现金账户、债券清算速度、付息或借券机制,不能据此还原真实现券与期货的账户占款。

1.3 交易规则

每次快照回调按以下顺序处理:

  1. 遍历本次消息,读取卖一、买一和 signal[0]。若任一遍历到的卖一价格不大于 0,立即退出整个回调。

  2. 遇到现券或期货时,将相应买卖一价格和接收时间缓存到上下文。遍历结束后检查两腿缓存价格均大于 0。

  3. 若 signal > openBias 且 pairAmount <= 5,提交现券方向编码 1、期货方向编码 2 的两笔订单,每腿数量 100,随后将 pairAmount 加 5。

  4. 若 signal < -closeBias 且 pairAmount >= -5,再提交标为 BaseOrderClose、PairOrderClose 的两笔订单,每腿数量 100,随后将 pairAmount 减 5。

两个条件分别使用独立的 if。默认 openBias 和 closeBias 都为 -10,因此开仓条件为 IRR > -10,第二条件为 IRR < 10。在 -10 < IRR < 10 时,两段逻辑都可能执行;第二段读取的是第一段更新后的计数,而非进入回调前的值。例如初始计数为 0 且信号落在重叠区间时,代码可以连续提交四笔订单,计数先变5再回到0。这只是代码路径推导,不是实际成交结果。

订单标签和注释不决定买卖方向。按期货普通订单接口,方向 1 为买开、2 为卖开、3 为卖平、4 为买平。

两腿独立提交,没有原子成交、失败补偿、成交配比校验或主动撤单。getPosition 的查询结果只是赋给局部变量,没有用于限制订单。onOrder 和 onTrade 均为空,因此拒单、部分成交和单腿成交不会纠正计数。脚本没有实际的资金检查、止损、融资利率比较或持仓退出闭环。

2. 策略回测

回测使用附件中的样例数据,区间配置为 2025-01-31 至 2025-03-01,初始资金为 1,000,000 元。先导入文件,再计算 IRR 并回放行情。运行环境需预先安装并加载与 DolphinDB Server 版本匹配的 Backtest 及其所需撮合插件,并提供下述项目数据函数和 XSHG 交易日历。本案例已在 DolphinDB Server 3.00.6.1、Backtest 3.00.6.4 与 MatchingEngineSimulator 3.00.6.4 上运行。

2.1 准备数据

先将配套附件 FutureIRR.csv 保存到 DolphinDB 服务器可读取的目录,再将 csvPath 改为该文件在服务器上的实际路径。

csvSchema = table(
    ["symbol", "symbolSource", "timestamp", "tradingDay", "lastPrice", "upLimitPrice", "downLimitPrice", "totalBidQty", "totalOfferQty", "bidPrice", "bidQty", "offerPrice", "offerQty", "highPrice", "lowPrice", "prevClosePrice", "settlementPrice", "prevSettlementPrice"] as name,
    ["STRING", "STRING", "TIMESTAMP", "DATE", "DOUBLE", "DOUBLE", "DOUBLE", "LONG", "LONG", "DOUBLE", "LONG", "DOUBLE", "LONG", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE", "DOUBLE"] as type)
messageTb = loadText(csvPath, ",", csvSchema, containHeader=true)

模拟行情 CSV 文件每行保存一个标的一条快照,同一时点包含现券和期货各一行,主键为 symbol, timestamp。生成器已完成字段映射、两腿合并及 timestamp, symbol 排序。CSV 与输入行情行数相同,每日理论上480行,字段如下:

表 1. 表 2-1 CSV文件字段说明
字段 CSV 类型 含义
symbol、symbolSource STRING 模拟标的及来源
timestamp TIMESTAMP 快照时间
tradingDay DATE 快照所属日期
lastPrice DOUBLE 现券买一或期货最新价
upLimitPrice、downLimitPrice DOUBLE 参考价格上下5%的模拟边界
totalBidQty、totalOfferQty LONG 示例买卖一数量
bidPrice、offerPrice DOUBLE 单档买卖报价,元/百元面值
bidQty、offerQty LONG 单档数量,示例订单单位
highPrice、lowPrice DOUBLE 参考价格上下 5%的模拟高低价
prevClosePrice、settlementPrice、prevSettlementPrice DOUBLE 使用当前参考价代替的示例字段

原始数据中的买卖价和数量是单档标量,需要在 DolphinDB 中处理为数组向量,并计算 IRR 指标。

spot = select timestamp, offerPrice as spotAsk from messageTb where symbol = baseSymbol
futures = select timestamp, lastPrice as futuresLast from messageTb where symbol = pairSymbol
pair = ej(spot, futures, `timestamp)
pair['IRR'] = (pair.futuresLast * CF + DI - (pair.spotAsk - I)) * 365.0 /
    ((pair.spotAsk - I) * (T - date(pair.timestamp)))
signalByTime = dict(pair.timestamp, pair.IRR)
messageTb['signal'] = fixedLengthArrayVector(signalByTime[messageTb.timestamp])

// CSV 一档标量还原为引擎要求的数组列,不再生成行情字段或重新排序。
for (c in `bidPrice`bidQty`offerPrice`offerQty) {
    replaceColumn!(messageTb, c, fixedLengthArrayVector(messageTb[c]))
}

2.2 编写策略逻辑

初始化函数负责保存策略参数;快照函数负责更新缓存、读取信号和下单;其余回调保留接口位置而不执行业务。所有函数均没有显式返回业务结果,主要通过修改上下文或调用引擎产生作用。

初始化函数 initialize

创建引擎时通过 initialize{,userParam} 绑定用户参数。

  • contextDict:可变 DICTIONARY,供引擎与策略共享状态,写入标的、阈值及计数。

  • userParam:DICTIONARY,键为 STRING、值允许 ANY;读取标的和阈值。

函数将 pairAmount 初始化为 INT 值0。

def initialize(mutable contextDict,userParam){
    contextDict['baseSymbol'] = userParam['baseSymbol']
    contextDict['pairSymbol'] = userParam['pairSymbol']
    contextDict['openBias'] = userParam['openBias']
    contextDict['closeBias'] = userParam['closeBias']
    contextDict['pairAmount'] = 0
}

快照处理函数 onSnapshot

该函数在行情回调中提交订单,信号与下单处于同一次回调,不采用下一交易日执行模式。

  • contextDict:可变 DICTIONARY,读取 engine、tradeTime、标的和阈值,更新两腿报价、接收时间及计数。

  • msg:DICTIONARY,以标的代码为键,各值包含快照字段;读取 offerPrice[0]、bidPrice[0] 和 signal[0]。

遍历每个标的时,都会更新 signal;循环结束后,使用最后一次赋值的结果。

def onSnapshot(mutable contextDict, msg, indicator=null){
    for (isymbol in msg.keys()){
        askPrice0 = msg[isymbol]["offerPrice"][0]
		bidPrice0 = msg[isymbol]["bidPrice"][0]
        signal = msg[isymbol]["signal"][0]
        if(askPrice0 <= 0){return}
        if(isymbol == contextDict['baseSymbol']){
            contextDict['baseAP0'] = askPrice0
            contextDict['baseBP0'] = bidPrice0
            contextDict['baseReceivedTime'] = contextDict["tradeTime"]
        }
        else if(isymbol == contextDict['pairSymbol']){
            contextDict['pairAP0'] = askPrice0
            contextDict['pairBP0'] = bidPrice0
            contextDict['pairReceivedTime'] = contextDict["tradeTime"]
        }
    }
    if(contextDict['baseAP0'] <=0 or contextDict['baseBP0']<=0 or contextDict['pairAP0']<=0 or contextDict['pairBP0']<=0){return}
    // 正套
    if(signal > contextDict['openBias']){
        // 下面这行代码仅作为示例
        baseLongPos=Backtest::getPosition(contextDict["engine"],contextDict['baseSymbol']).longPosition[0]
        pairShortgPos=Backtest::getPosition(contextDict["engine"],contextDict['pairSymbol']).shortPosition[0]
        // 我们通过 contextDict['pairAmount'] 记录配对的数量
        if(contextDict['pairAmount'] <= 5){
            // 开多近期
            Backtest::submitOrder(contextDict["engine"], 
            (contextDict['baseSymbol'],'XDCE',contextDict["tradeTime"],0, 
            contextDict['baseAP0'], contextDict['baseAP0']*0.95, 100, 1, 0),"BaseOrderOpen")
            // 开空远期
            Backtest::submitOrder(contextDict["engine"], 
            (contextDict['pairSymbol'],'XDCE',contextDict["tradeTime"],0, 
            contextDict['pairBP0'], contextDict['baseAP0']*1.05, 100, 2, 0),"PairOrderOpen")
            contextDict['pairAmount'] = contextDict['pairAmount'] + 5
            print(contextDict['pairAmount'])
        }
    }
    // 反套
    if(signal < -contextDict['closeBias']){
        baseLongPos=Backtest::getPosition(contextDict["engine"],contextDict['baseSymbol']).longPosition[0]
        // print('close0')
        // print(contextDict['pairAmount'])
        if(contextDict['pairAmount'] >= -5){
            // print('close1')
            // 卖平近期
            Backtest::submitOrder(contextDict["engine"], 
            (contextDict['baseSymbol'],'CFFEX',contextDict["tradeTime"],0, 
            contextDict['baseBP0'], contextDict['baseAP0']*1.05, 100, 1, 0),"BaseOrderClose")
            // 买平远期
            Backtest::submitOrder(contextDict["engine"], 
            (contextDict['pairSymbol'],'CFFEX',contextDict["tradeTime"],0, 
            contextDict['pairAP0'], contextDict['baseAP0']*0.95, 100, 2, 0),"PairOrderClose")
            contextDict['pairAmount'] = contextDict['pairAmount'] - 5
        }
    }
}

2.3 设置回测参数

参数分为 IRR 计算参数、引擎配置、策略参数及合约基础信息。

userConfig = dict(STRING,ANY)
userConfig["startDate"] = startDate
userConfig["endDate"] = endDate
userConfig["strategyGroup"] = "futures"
userConfig["frequency"] = 0
userConfig["cash"] = 1000000
userConfig["dataType"] = 1
userConfig["latency"] = 0
userConfig["commission"] = 0.0
userConfig["tax"] = 0.0
userConfig["msgAsTable"] = false
userConfig["depth"] = 1;

userParam = dict(STRING,ANY)
userParam['baseSymbol'] = baseSymbol
userParam['pairSymbol'] = pairSymbol
userParam['openBias'] = -10 //设置开仓价差
userParam['closeBias'] = -10 //设置平仓价差

strategyName="xfArb"

// 合约信息设置
symbol=[userParam['baseSymbol'],userParam['pairSymbol']]
basicInfo = table(symbol as symbol, take(100.,size(symbol)) as multiplier, take(0.2,size(symbol)) as marginRatio,
                 take(0.01,size(symbol)) as tradeUnit, take(0.02,size(symbol)) as priceUnit, 
                 take(0.03,size(symbol)) as priceTick, take(0.0,size(symbol)) as commission, take(1,size(symbol)) as deliveryCommissionMode)
  • startDate:DATE 标量,2025.01.31,配置回测开始日。

  • endDate:DATE 标量,2025.03.01,配置回测结束日。

  • strategyGroup:STRING 标量,"futures",本例为期货模型。

  • frequency:INT 标量,0,本例不设置快照合成 Bar 周期。

  • cash:INT 字面量,1,000,000,表示初始资金金额。

  • dataType:INT 标量,1,快照行情模式。

  • latency:INT 标量,0,单位毫秒,未模拟订单处理时延。

  • commission:DOUBLE 标量,0.0,本例手续费配置为零则。

  • tax:DOUBLE 标量,0.0,本例税费配置为零。

  • msgAsTable:BOOL 标量,false,回调按字典读取行情。

  • depth:INT 标量,1,源码的撮合深度配置。

2.4 创建回测引擎

engineName 为固定 STRING,engine 保存创建接口返回的引擎句柄。创建前会尝试删除同名引擎,因此同一节点上并行任务需要避免名称冲突。

try{Backtest::dropBacktestEngine(strategyName)}catch(ex){print ex}
engine = Backtest::createBacktestEngine(strategyName, userConfig,basicInfo,initialize{,userParam}, 
	beforeTrading,,onSnapshot,onOrder,onTrade,afterTrading,finalized)
go

2.5 执行回测

先将 messageTb 回放到引擎,timer 用于记录该调用耗时。随后使用 Backtest::appendEndMarker(engine) 发送结束消息。

执行前要确认 messageTb 非空、排序有效且时间范围与回测区间一致。CSV 已按回测区间过滤,末条快照为 15:00,结束时间 16:00 晚于它;若换用其他文件,应保证最后一条行情不晚于结束消息。源码没有在结束前显式平仓。

timer Backtest::appendQuotationMsg(engine,messageTb)
Backtest::appendEndMarker(engine)

2.6 获取回测结果

结束消息发送后,脚本把结果保存到会话变量中。读取这些对象时,应区分策略意向计数、未成交订单、实际成交和每日持仓。

DailyPosition = Backtest::getDailyPosition(long(engine))
OpenOrdersTable = Backtest::getOpenOrders(engine)
DailyTotalPortfolios = Backtest::getDailyTotalPortfolios(long(engine))
OpenOrdersTable = Backtest::getOpenOrders(engine)
ContextDictTable = Backtest::getContextDict(long(engine))
TradeDetails = Backtest::getTradeDetails(long(engine))
ReturnSummary = Backtest::getReturnSummary(long(engine))

接口用途详见 Backtest 结果查询接口。

3. 回测结果分析

使用上述版本、不启用即时编译(Just-In-Time Compilation,JIT)的模式、附件提供的数据,回测处理 8,640 条行情,初始权益为 1,000,000.00 元,区间总收益为 1.0271%。由于本次回测使用模拟数据,该收益指标不能用于评价策略在真实市场中的相对表现。

表 2. 表 3-1 回测结果
指标 本次结果 解读
区间总收益 1.0271% 期末权益相对 100 万元初始资金的累计变化
年化收益 15.3804% 引擎按回测日收益年化后的结果
年化波动率 22.9680% 价格路径波动幅度
夏普比率 0.6696 去掉风险波动后的收益
最大回撤 2.8100% 期内净值相对先前高点的最大下跌
回撤比率 5.4734 引擎返回的收益与回撤相关统计
日胜率 50.0000% 每日收益为正的交易日占比

4. 总结

本策略实现了从数据导入、简化 IRR 计算、扩展信号传递到两腿订单提交和结果查询的框架。核心指标使用期货最新成交价、现券卖一报价及剩余自然日数,当前忽略真实转换因子差异、付息及交割利息。

使用时最需要关注信号、订单、成交和持仓之间的区别,默认阈值存在条件重叠,标为平仓的订单没有使用平仓方向,计数也不随成交更新。解决上述问题,并落实真实券种属性、账户结构和交易成本的相关设置后,才适合进一步开展基于真实历史数据的研究。

5. 附录

完整源脚本包含 CSV 导入、行情转换、回调、参数配置、引擎创建及结果查询。