多资产股指期货期权套利策略

股指期货期权套利策略利用看涨、看跌期权之间的平价关系,比较期权组合与股指期货的相对价格,在价差偏离时建立组合,并用期货管理方向性风险。本文基于 DolphinDB Backtest 多资产回测引擎,介绍快照数据准备、策略回调编写、参数配置、行情回放和结果分析的完整流程。

1. 策略介绍

本策略通过比较期权组合与股指期货的价格,捕捉两者的定价偏离,并通过期货对冲管理组合的方向性风险。

1.1 核心因子

期权平价关系(Put-Call Parity,PCP)描述同一标的、相同行权价和到期日的看涨期权 Call 与看跌期权 Put 之间的定价联系。本策略以期权组合价格相对于期货价格的偏离作为交易信号。

设 F 为期货最新价,K 为行权价,DF = exp(-r × T) 为折现因子,其中 r 为年化无风险利率,T 为到期剩余天数除以 365。正向开仓检查 C_bid - P_ask + K × DF < F × DF - threshold;反向开仓检查 C_ask - P_bid + K × DF > F × DF + threshold。下标 bid、ask 分别表示买一价和卖一价。

1.2 交易标的

本策略交易沪深 300 股指期货(IF)及同到期月份的股指期权(IO)。期货合约按月份切换,期权选择与其对应的平值看涨期权和平值看跌期权。平值期权(At-the-Money,ATM)指本例中行权价最接近期货最新价的期权。

正向组合买 Call、卖 Put,构建多头合成远期;反向组合卖 Call、买 Put,构建空头合成远期。期货用于对冲期权组合的方向性风险。

1.3 交易规则

  • 合约选择:每日盘前确定交易月份,以当月合约到期日前一周周三为切换点,选择当月或下月合约。收到主力期货行情后,从相应月份的期权中选取行权价最接近期货最新价的 Call 和 Put。

  • 开仓:对应的两条期权腿持仓均小于 maxQty,且满足价差条件时,每条腿提交 orderQty 手。正向组合买 Call、卖 Put;反向组合卖 Call、买 Put。

  • 平仓:正向组合的 Call 多头、Put 空头均达到 maxQty,且 C_bid - P_ask + K × DF ≥ F × DF - threshold / 2 时,卖平 Call、买平 Put;反向组合的 Call 空头、Put 多头均达到 maxQty,且 C_ask - P_bid + K × DF ≤ F × DF + threshold / 2 时,买平 Call、卖平 Put。

  • 对冲:根据期权 Delta 与期货持仓计算组合敞口,超过阈值时换算期货手数并提交对冲订单。Delta 用于衡量标的价格变化带来的方向性风险。

  • 委托管理:检查超过 5 秒未成交的期权订单。可配对的 Call、Put 订单一起撤销;不能配对的剩余订单撤销后,根据委托方向读取当前报价,按 orderQty 重新提交。存在未成交期权订单时,优先处理这些订单,不继续生成本次回调的开平仓信号。

2. 策略回测

本案例使用 DolphinDB Server 3.00.6.3,以及 DolphinDB Backtest 与 MatchingEngineSimulator 插件包(包版本均为 3.00.6.9)。Backtest 负责策略回调、资金与持仓管理,MatchingEngineSimulator 负责订单撮合。运行脚本前,需安装与服务器版本匹配的插件,并准备分布式数据库和中金所交易日历。插件安装与加载方法参见 Backtest和 MatchingEngineSimulator。

完整策略的执行入口为附录中的 2.futOptArbitrage.dos。准备好数据库与行情后,运行该脚本即可创建引擎并提交行情回放作业。

2.1 准备数据

本例使用 dfs://fut_opt_snapshot 数据库中的两张分区表:if 保存期货快照,io 保存期权快照及合约信息。使用模拟数据时,依次执行附录中的 0.createDBsTBs.dos 和 1.mockData.dos,完成建库建表和模拟行情写入;使用已有真实行情时,需按相同字段结构准备数据。

模拟脚本按中金所交易日历生成 2026.01.01 至 2026.02.28 的快照。每个交易日的快照覆盖 09:30:00.000—11:29:59.500 和 13:00:00.000—14:59:59.500,相邻快照间隔 500 毫秒。

合约包括 2026 年 1—6 月的 IF 期货,以及对应月份、行权价为 3900、4100、4300、4500、4650、4800、5000、5200、5400 的 Call 和 Put。

模拟价格带有随机扰动。看涨期权和看跌期权的 Delta 分别设为 0.5 和 -0.5;这些模拟行情不代表真实市场价格。

期货与期权快照分别保存在 if 和 io 表中。各数据对象的原始字段及类型如下;回放时,appendData 将这些字段转换为引擎要求的行情格式,转换关系见 2.5 节。

表 1. 表 2-1 期货原始快照表 if 结构
字段名称 DolphinDB 数据类型 字段说明
symbol SYMBOL 合约代码
datetime TIMESTAMP 快照时间戳
limit_up DOUBLE 涨停价
limit_down DOUBLE 跌停价
volume INT 成交量字段;模拟取值由 1.mockData.dos 构造
turnover LONG 成交金额字段;模拟取值由 1.mockData.dos 构造
open_interest INT 持仓量字段
last_price DOUBLE 最新价
mark_price DOUBLE 标记价格;模拟数据取 last_price
bid_price_1 DOUBLE 买一价
ask_price_1 DOUBLE 卖一价
bid_volume_1 INT 买一数量
ask_volume_1 INT 卖一数量
long_volume INT 保留字段;模拟数据填 0,当前回放不读取
long_amount LONG 保留字段;模拟数据填 0,当前回放不读取
short_volume INT 保留字段;模拟数据填 0,当前回放不读取
short_amount LONG 保留字段;模拟数据填 0,当前回放不读取
表 2. 表 2-2 期权原始快照表 io 结构
字段名称 DolphinDB 数据类型 字段说明
symbol SYMBOL 合约代码
datetime TIMESTAMP 快照时间戳
type SYMBOL 期权类型,CALL 为看涨,PUT 为看跌
strike_price DOUBLE 行权价
expiry_datetime TIMESTAMP 到期时间;模拟数据为对应到期日的 15:00:00
underlying SYMBOL 对应期货合约代码
volume INT 成交量字段;模拟取值由 1.mockData.dos 构造
turnover LONG 成交金额字段;模拟取值由 1.mockData.dos 构造
open_interest INT 持仓量字段
last_price DOUBLE 最新价
bid_price_1 DOUBLE 买一价
ask_price_1 DOUBLE 卖一价
bid_volume_1 INT 买一数量
ask_volume_1 INT 卖一数量
bid_iv DECIMAL128(20) 买一隐含波动率;模拟数据为 0.15
ask_iv DECIMAL128(20) 卖一隐含波动率;模拟数据为 0.16
mark_price DOUBLE 标记价格;模拟数据取 last_price
mark_iv DECIMAL128(20) 标记隐含波动率;模拟数据为 0.15
underlying_price DOUBLE 标的价格
delta DECIMAL128(20) Delta;模拟数据中 Call 为 0.5,Put 为 -0.5
gamma DECIMAL128(20) Gamma;模拟数据为 0.001
vega DECIMAL128(20) Vega;模拟数据为 0.2
theta DECIMAL128(20) Theta;模拟数据为 -0.1
theory_price DOUBLE 理论价格字段;模拟数据取 last_price

策略从 io 表提取合约信息和到期日映射,构造合约基本信息表 securityReference 和到期日映射表 expiryMap。前者用于合约选择、持仓、资金与交易费用核算,后者用于每日盘前选择交易月份。以下代码构造这两个数据对象,供策略回调和引擎创建使用。

optCodes = exec distinct symbol from loadTable("dfs://fut_opt_snapshot", "io") 
optSecurityRef = select symbol,
                take(3,size(optCodes)) as assetType,                         // 股指期权
                last(underlying) as underlyingCode,                          // 标的资产代码
                last(iif(type == "CALL", 1, 2)) as optType,
                last(strike_price) as strikePrice,
                take(100.0,size(optCodes)) as multiplier,                    // 股指期权合约乘数
                take(0.12,size(optCodes)) as marginRatio,                    // 参考值0.05-0.12
                take(1., size(optCodes)) as tradeUnit,                       // 合约单位,股指期权为1
                take(1.,size(optCodes)) as priceUnit,                        // 报价单位,指数点
                take(0.2, size(optCodes)) as priceTick,                      // 最小变动价位,指数点
                take(15.,size(optCodes)) as commission,                      // 每手合约手续费
                take(1,size(optCodes)) as deliveryCommissionMode,            // 交割手续费模式:1=每手固定金额,2=按成交金额比例
                last(date(expiry_datetime)) as lastTradingDay,
                last(date(expiry_datetime)) as exerciseDate,
                last(date(expiry_datetime)) as exerciseSettlementDate
            from loadTable("dfs://fut_opt_snapshot", "io") 
            group by symbol

// 期货、期权合约到期日映射
expiryMap = select distinct
            substr(string(symbol), 0, 6) as symbol,
            date(expiry_datetime) as expiryDate,
            underlying
        from loadTable("dfs://fut_opt_snapshot", "io")

// 期货基本表
futCodes = select distinct symbol from loadTable("dfs://fut_opt_snapshot", "if")
futSecurityRef = select symbol,
                take(1,size(futCodes)) as assetType,                         // 期货
                symbol as underlyingCode,                                    // 无效字段,仅用于对齐基本表
                take(2,size(futCodes)) as optType,                           // 无效字段,仅用于对齐基本表
                take(2.,size(futCodes)) as strikePrice,                      // 无效字段,仅用于对齐基本表
                take(300.0,size(futCodes)) as multiplier,                    // 股指期货合约乘数
                take(0.12,size(futCodes)) as marginRatio,                    // 参考值0.08-0.15
                take(1.,size(futCodes)) as tradeUnit,                        // 合约单位
                take(1.,size(futCodes)) as priceUnit,                        // 报价单位,指数点
                take(0.2,size(futCodes)) as priceTick,                       // 最小变动价位,指数点
                take(0.000023,size(futCodes))  as commission,                // 手续费
                take(2, size(futCodes)) as deliveryCommissionMode,           // 交割手续费模式:1=每手固定金额,2=按成交金额比例
                expiryDate as lastTradingDay,
                expiryDate as exerciseDate,
                expiryDate as exerciseSettlementDate
            from lj(futCodes,expiryMap,`symbol,`underlying)

securityReference = unionAll(futSecurityRef, optSecurityRef)

接入其他数据时,应保留策略使用的合约代码格式、到期日映射和报价字段,并使基本信息覆盖全部回测标的。完整输入结构参见 多资产回测配置。

2.2 编写策略逻辑

策略主要通过 initialize、beforeTrading 和 onSnapshot 三个回调推进。context 保存参数与状态,msg 提供当前快照,indicator 为订阅指标参数。开平仓、Delta 对冲和订单管理都在 onSnapshot 中处理。

初始化函数 initialize 在策略开始前调用一次,用于初始化主力期货合约名称、最新价、平值期权行权价和剩余到期期限,并建立已处理订单字典。此时主力合约尚未确定。

def initialize(mutable context){
    context['front_month_contract'] = ""                      // 主力期货合约名称
    context['front_month_contract_latest_price']=double(NULL) // 主力期货合约最新价
    context['strike_price']=double(NULL)                      // 平值期权合约的行权价
    context["maturity"] = double(NULL)                        // 到期时间:距到期日的日期/365
    context["managedOrderIds"] = dict(LONG, BOOL)             // 订单管理字典,记录已处理的订单id,防止重复撤单追单
}

盘前函数 beforeTrading 在每日盘前调用,选择交易月份、更新主力期货和剩余到期期限,并重建该月份期权的报价字典与 Delta 字典。后续快照逐条更新这些报价,供 ATM 合约选择和价差计算使用。

def beforeTrading(mutable context){
    // 每天初始化一次
    context['options'] = dict(STRING, ANY)                    // 主力期权标的与最新价映射
    context["deltas"] = dict(STRING, DOUBLE)                  // 主力期权delta值映射
    context['ATM_C']=""                                       // 平值看涨期权合约名
    context['ATM_P']=""                                       // 平值看跌期权合约名
    // 获取当前日期
    today = context['tradeTime'].date()
    print('当前时间'+context['tradeTime'])

    // ---------------------- 移仓换月 ---------------------
    // 根据当前日期获取当前到期日
    context['expiryDate'] = exec expiryDate[0] from context['expiryMap'] where month(expiryDate) =month(today)
    // 标记当前主力合约标的
    if(today<transFreq(weekBegin(context['expiryDate']) - 5, "CCFX")){ // 到期日前一周周三前,用当月合约,否则移仓换月
        context["maturity"] = temporalDiff(context['expiryDate'],today,"d")\365 
        context['front_month_contract'] = exec underlying[0] from context['expiryMap'] where expiryDate = context['expiryDate'] 
        mainOption = exec symbol[0] from context['expiryMap'] where underlying = context['front_month_contract']
        mainOptions = exec symbol from context['basicInfo'] where startsWith(symbol,mainOption)
        print("当前主力期权:"+mainOption)
    }
    else{ // 否则走移仓换月,用下月合约
        context['expiryDate'] = exec expiryDate[0] from context['expiryMap'] where month(expiryDate) = month(context['expiryDate'])+1;
        context["maturity"] = temporalDiff(context['expiryDate'],today,"d")\365 
        context['front_month_contract'] = exec underlying[0] from context['expiryMap'] where expiryDate = context['expiryDate'] 
        mainOption = exec symbol[0] from context['expiryMap'] where underlying = context['front_month_contract']
        mainOptions = exec symbol from context['basicInfo'] where startsWith(symbol,mainOption)
        print("当前主力期权:"+mainOption)
    }
    // 构造主力期权最新价字典
    for(option in mainOptions){ 
        px = dict(STRING, ANY)
        px["bidPrice"] = double(NULL)
        px["askPrice"] = double(NULL)
        context["options"][option] = px  
    }   
    // 构造主力期权delta字典
    for(option in mainOptions){
        context["deltas"][option] = double(NULL)
    }
}

快照回调函数 onSnapshot 在快照到达时处理行情。收到主力期货快照时,更新期货最新价、ATM 合约及行权价,并检查是否需要对冲;收到当前期权池中的快照时,更新相应报价和 Delta。对于 ATM 期权行情,若存在未成交订单,则优先处理这些订单;否则,结合当前持仓、报价和策略参数判断是否提交开平仓订单。

函数完整定义如下。

def onSnapshot(mutable context, msg, indicator){
    symbol = string(msg["symbol"])
    //判断标的是否为期货,期货端处理
    if (substr(symbol,0,2)=="IF"){
        if (symbol == context['front_month_contract']){
            context["front_month_contract_latest_price"]=msg.lastPrice
            // ---------------------更新平值期权---------------------
            // 逻辑为寻找离主力期货价格最近的行权价
            // 需要先找到主力期货对应的各个期权合约
            contracts=context['options'].keys()
            optType = regexFindStr(contracts, "[0-9]+([CP])[0-9]+$", true,0).substr(4,1)
            prices=int(contracts.substr(7))
            diff=abs(prices-msg.lastPrice)
            minDiff = min(diff)
            context["ATM_C"]=contracts[diff == minDiff and optType == "C"][0]
            context["ATM_P"]=contracts[diff == minDiff and optType == "P"][0]
            context['strike_price'] = int(context['ATM_C'].substr(7))
            //--------------------- 期货delta hedge ----------------------
            // 获取持仓
            optPos = Backtest::getPosition(context["engine"], ,"options")
            futPos = Backtest::getPosition(context['engine'], ,'futures')
            // 无期权持仓,无需hedge,直接return
            if(size(optPos)==0){
                return 
            }
            // 计算 delta值
            optDelta = exec sum((longPosition - shortPosition) * context["deltas"][symbol]) from optPos
            futDelta = exec sum((longPosition - shortPosition))[0] from futPos
            // 无期货持仓,更新futDelta为0,防止空值计算
            if(size(futPos)==0){
                futDelta = 0
            }
            totalDelta = optDelta + futDelta
            if(totalDelta == NULL){
                return
            }
            // hedge
            // 如果有未成交期货单子,return
            opens=Backtest::getOpenOrders(context["engine"], , , , accountType="futures")
            // 计算期货手数
            futMultiplier = exec multiplier[0] from context['basicInfo'] where substr(symbol,0,2)=="IF"
            optMultiplier = exec multiplier[0] from context['basicInfo'] where substr(symbol,0,2)=="IO"
            futQty = abs(int(totalDelta*optMultiplier\futMultiplier))
            // 超过Delta_Limit,下单,对手方更优价
            if(totalDelta> context['Delta_Limit'] and futQty>0){// 做空期货
                // 为避免重复下单,将futQty减去openOrders中的现有单子。
                // 计算已下单未成交头寸
                if(opens.size()>0 && opens[0]["direction"] == 2){  
                    current_open_order=opens[0]["openQty"]
                } else {current_open_order=0}
                if (futQty - current_open_order <= 0){
                    return
                }
                orderMsg = (context['front_month_contract'],    // 标的代码
                            msg["symbolSource"],                // 交易所代码
                            context['tradeTime'],               // 下单时间
                            5,                                  // 订单类型,5为限价单
                            msg["bidPrice"][0],                 // 委托订单价格
                            0.,                                 // 止损价,不需要可填0
                            0.,                                 // 止盈价
                            futQty - current_open_order,        // 委托订单数量
                            2,                                  // 买卖方向,1:买开,2:卖开,3:卖平,4:买平
                            0,                                  // 滑点
                            0,                                  // 委托订单有效性
                             )                                  // 委托订单到期时间
                Backtest::submitOrder(context['engine'], orderMsg, 'hedgeShortFut', 0, 'futures')
            } else if(totalDelta< -context['Delta_Limit'] and futQty>0) {// 做多期货
                // 计算已下单未成交头寸
                if(opens.size()>0 && opens[0]["direction"] == 1){
                    current_open_order=opens[0]["openQty"]
                } else {current_open_order=0}
                if (futQty - current_open_order <= 0){
                    return
                }
                orderMsg = (context['front_month_contract'], 
                            msg["symbolSource"], 
                            context['tradeTime'], 
                            5, 
                            msg["offerPrice"][0],
                            0., 
                            0., 
                            futQty - current_open_order,
                            1, 
                            0, 
                            0,
                             )
                Backtest::submitOrder(context['engine'], orderMsg, 'hedgeLongFut', 0, 'futures')
            }
        }
        else{
            return
        } 
    }
    //判断合约是否为期权,期权端处理
    else if (substr(symbol,0,2)=="IO"){
        // 判断合约是否为主力期权    
        if (!(symbol in context['options'].keys())){
            return 
        }
        //更新主力期权的最新买一卖一价格
        context['options'][symbol]['bidPrice']=msg.bidPrice[0]
        context['options'][symbol]['askPrice']=msg.offerPrice[0]
        //更新主力期权delta值
        context['deltas'][symbol]=msg.Delta

        //判断是否已知平值期权(如果还没接到第一条期货行情,则ATM_C和ATM_P为空,未知则return
        if (context['ATM_C'] == NULL or context['ATM_P'] == NULL or context['strike_price'] == NULL or context['front_month_contract_latest_price'] == NULL){
            return
        }
        //判断是否为平值期权
        if (symbol != context['ATM_C'] and symbol != context['ATM_P']){
            return 
        }
        // ---------------------- 期权撤单及追单 ----------------------
        // 如果有未成交的订单,判断是否超过5s,执行撤单或追单
        opens=Backtest::getOpenOrders(context["engine"], , , , accountType="option")
        if(opens.size()>0){
            order_match = dict(STRING, ANY)  // 记录所有超过5秒准备撤单的合约{期货id+'P'/'C'+行权价:[对应orders]}。能组队的直接撤;不能组队的撤单后用对手价重发订单
            for(order in opens){
                if (temporalDiff(context["tradeTime"], order["timestamp"]) > 5000){ // 超过5秒未成交,则撤单或追单
                    print("orderId: " + order["orderId"])
                    if (order["orderId"] in context["managedOrderIds"]){ // 如果已处理,不再重复处理该订单
                        print("Skip managed orderId: " + order["orderId"])
                        continue
                    }
                    order_fut = string(order["symbol"].substr(2,4))
                    order_pc = string(order["symbol"].substr(6,1))
                    order_strike = string(order["symbol"].substr(7))
                    order_key = order_fut + order_pc + order_strike
                    order_key_reversed = order_fut + iif(order_pc == "C", "P", "C") + order_strike
                    if(not order_key_reversed in order_match){
                        if (not order_key in order_match){
                            order_match[order_key] = [order]
                        } else {
                            order_match[order_key].append!(order)
                        }
                        print("Added.")
                        print(keys(order_match))
                    } else {
                        matched_order = order_match[order_key_reversed].pop!()
                        print("Matched.")

                        Backtest::cancelOrder(context["engine"], order["symbol"], [order["orderId"]], , "option")
                        context["managedOrderIds"][order["orderId"]] = true // 标记已处理的订单id
                        print("Cancelled order: " + order["symbol"] + "_" + order["label"])

                        Backtest::cancelOrder(context["engine"], matched_order["symbol"], [matched_order["orderId"]], , "option")
                        context["managedOrderIds"][matched_order["orderId"]] = true
                        print("Cancelled order: " + matched_order["symbol"] + "_" + matched_order["label"])
                        if (order_match[order_key_reversed].size() == 0){
                            order_match.erase!(order_key_reversed)
                        }
                    }
                }
            }
            // 此时,order_match中剩下的就是超过5秒且没有匹配到的订单,即另一条腿已经成交,需要撤单后用对手价重发
            for(left_key in keys(order_match)){
                original_orders = order_match[left_key]
                print("Entered for loop.")

                for (original_order in original_orders) {
                    if (original_order["orderId"] in context["managedOrderIds"]){ // 如果已处理,不再重复处理该订单
                        print("Skip managed orderId: " + original_order["orderId"])
                        continue
                    }
                    opponent_price = iif(
                        original_order["direction"] == 1 or original_order["direction"] == 3,
                        context['options'][original_order["symbol"]]['askPrice'],
                        context['options'][original_order["symbol"]]['bidPrice']
                    )

                    if (opponent_price == NULL){
                        print("Opponent price null.")
                        continue
                    }

                    Backtest::cancelOrder(context["engine"], original_order["symbol"], [original_order["orderId"]], , "option")
                    print("Cancelled order: " + original_order["symbol"] + "_" + original_order["label"] + "_" + original_order["orderId"])
                    context["managedOrderIds"][original_order["orderId"]] = true
                    Backtest::submitOrder(
                        context['engine'],
                        (original_order["symbol"], msg["symbolSource"], context['tradeTime'], 5,
                        opponent_price,
                        , , context['orderQty'], original_order["direction"], ,,),
                        "resubmit_" + original_order["label"] + original_order["orderId"],
                        0,
                        'option'
                    )
                    print('Resubmitted time:' + context['tradeTime'] + " orderID:" + original_order["orderId"])
                    print("Resubmitted order: " + original_order["symbol"] + "_" + original_order["label"] + " with opponent price: " + string(opponent_price))
                }
            }
            return
        }
        // ---------------------- 期权套利,开平仓 ---------------------- 
        // 相关变量:期货最新价、期权行权价、折现因子、套利阈值
        f_last = context['front_month_contract_latest_price']
        strike = context['strike_price']
        discount_factor = pow(exp(1),-context["rf"]*context["maturity"]) 
        threshold = context['threshold']
        // // 仓位控制
        callPos = Backtest::getPosition(context["engine"], context['ATM_C'],"options")
        callLongPos = callPos.longPosition
        callShortPos = callPos.shortPosition
        putPos = Backtest::getPosition(context['engine'], context['ATM_P'],"options")
        putLongPos = putPos.longPosition
        putShortPos = putPos.shortPosition

        //判断是否开仓:己方更优价
        //1. 判断是否做多头:(C_bid - P_ask + K * DF) < Lastprice(F)* DF-threshold 
        //  corner case:判断 ATM_C买一 或 ATM_P卖一 是否为空
        if (context['options'][context['ATM_C']]['bidPrice'] != NULL and context['options'][context['ATM_P']]['askPrice'] != NULL){
            c_bid = context['options'][context['ATM_C']]['bidPrice']
            p_ask = context['options'][context['ATM_P']]['askPrice']  
            if (callLongPos<context['maxQty'] && putShortPos<context['maxQty'] && 
                ((c_bid-p_ask+strike*discount_factor) < (f_last*discount_factor-threshold)) ){
                // 下单(标的代码, 交易所代码, 时间, 订单类型, 委托订单价格, 止损价/止盈价,委托订单数量,买卖方向,委托订单有效性,委托订单到期时间)
                print(context['tradeTime']+":开仓多头") // 买call卖put
                print("买入:"+context['ATM_C'])
                print("卖出:"+context['ATM_P'])
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_C'],msg["symbolSource"],context['tradeTime'],5,c_bid, , ,context['orderQty'], 1,,,),"buyOpenCall", 0, 'option')
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_P'],msg["symbolSource"],context['tradeTime'],5,p_ask, , ,context['orderQty'], 2,,,),"buyOpenPut",0, 'option')
            }
        }
        //2. 判断是否做空头:(C_ask - P_bid + K * DF) > Lastprice(F)* DF+threshold
        if (context['options'][context['ATM_C']]['askPrice'] != NULL and context['options'][context['ATM_P']]['bidPrice'] != NULL){
            c_ask = context['options'][context['ATM_C']]['askPrice']
            p_bid = context['options'][context['ATM_P']]['bidPrice']
            if (callShortPos<context['maxQty'] && putLongPos<context['maxQty'] &&
                (c_ask-p_bid+strike*discount_factor) > (f_last*discount_factor+threshold) ){
                //下单
                print(context['tradeTime']+":开仓空头") // 卖call买put
                print("卖出:"+context['ATM_C'])
                print("买入:"+context['ATM_P'])
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_C'],msg["symbolSource"],context['tradeTime'],5,c_ask, , ,context['orderQty'], 2,,,),"sellOpenCall", 0, 'option')
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_P'],msg["symbolSource"],context['tradeTime'],5,p_bid, , ,context['orderQty'], 1,,,),"sellOpenPut", 0, 'option')
            }
        }
        // 判断是否平仓:
        // 多头平仓
        if (context['options'][context['ATM_C']]['bidPrice'] != NULL and context['options'][context['ATM_P']]['askPrice'] != NULL){
            c_bid = context['options'][context['ATM_C']]['bidPrice']
            p_ask = context['options'][context['ATM_P']]['askPrice']  
            if (callLongPos>=context['maxQty'] && putShortPos>=context['maxQty'] && 
                ((c_bid-p_ask+strike*discount_factor) >= (f_last*discount_factor-threshold\2)) ){
                // 下单(标的代码, 交易所代码, 时间, 订单类型, 委托订单价格, 止损价/止盈价,委托订单数量,买卖方向,委托订单有效性)
                print(context['tradeTime']+":平仓多头") 
                print("卖出:"+context['ATM_C'])
                print("买入:"+context['ATM_P'])
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_C'],msg["symbolSource"],context['tradeTime'],5,c_bid, , ,context['orderQty'], 3,,,),"buyCloseCall", 0, 'option')
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_P'],msg["symbolSource"],context['tradeTime'],5,p_ask, , ,context['orderQty'], 4,,,),"buyClosePut", 0, 'option')
            }
        }
        // 空头平仓
        if (context['options'][context['ATM_C']]['askPrice'] != NULL and context['options'][context['ATM_P']]['bidPrice'] != NULL){
            c_ask = context['options'][context['ATM_C']]['askPrice']
            p_bid = context['options'][context['ATM_P']]['bidPrice']
            if (callShortPos>=context['maxQty'] && putLongPos>=context['maxQty'] &&
                (c_ask-p_bid+strike*discount_factor) <= (f_last*discount_factor+threshold\2) ){
                // 下单
                print(context['tradeTime']+":平仓空头") 
                print("买入:"+context['ATM_C'])
                print("卖出:"+context['ATM_P'])
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_C'],msg["symbolSource"],context['tradeTime'],5,c_ask, , ,context['orderQty'], 4,,,),"sellCloseCall", 0, 'option')
                Backtest::submitOrder(context["engine"], 
                    (context['ATM_P'],msg["symbolSource"],context['tradeTime'],5,p_bid, , ,context['orderQty'], 3,,,),"sellClosePut", 0, 'option')
            }
        }
    }
    else{
        return 
    }
}

期货对冲根据组合 Delta 和期货、期权乘数换算手数。若第一笔未成交期货订单与对冲方向相同,则扣除该订单的未成交数量;剩余数量大于 0 时再下单。期权追单使用 orderQty,并通过 managedOrderIds 记录已处理订单,避免重复撤单、追单。保证金和手续费由基本信息表及引擎核算,信号成立不代表两条期权腿立即成交。

脚本还定义了 onOrder、onTrade、afterTrading 和 finalized,分别作为委托、成交、盘后和结束回调。本例未在这些回调中增加处理逻辑,但创建引擎前需完成其定义,以便注册到回调字典。

def onOrder(mutable context, orders){
}

def onTrade(mutable context, trades){
}

def afterTrading(mutable context){
}

def finalized(mutable context){
}

2.3 设置回测参数

创建引擎前,将回测区间、共享资金和行情类型等引擎参数保存在 userConfig 中,将价差阈值、每次报单手数等策略参数保存在 userParam 中,并通过 userConfig["context"] 传入回调。

startDate = 2026.01.01
endDate = 2026.02.28

userConfig = dict(STRING, ANY)
userConfig["startDate"] = startDate
userConfig["endDate"] = endDate
userConfig["strategyGroup"] = "multiAsset"
userConfig["frequency"] = 0
cashDict = dict(STRING, DOUBLE)
cashDict["futures, options"] = 100000000.0
userConfig["cash"] = cashDict
userConfig["dataType"] = 1        // snapshot 模式,触发 onSnapshot;若需 onBar 则设 frequency>0
userConfig["latency"] = 0
userConfig["commission"] = 0.0
userConfig["tax"] = 0.0
userConfig["msgAsTable"] = false
userConfig["multiAssetQuoteUnifiedInput"] = false
userConfig["depth"] = 5
userConfig["matchingMode"] = 1
userConfig["isBacktestMode"] = false
userConfig['outputOrderInfo'] = true // 输出风控日志

// 用户自定义参数,传入 context
userParam = dict(STRING, ANY)
userParam['basicInfo'] = securityReference  // 基本信息表
userParam['expiryMap'] = expiryMap          // 到期日映射表
userParam['threshold'] = 0.5                // 套利阈值
userParam["rf"] = 0.02                      // 无风险利率
userParam['orderQty'] = 1                   // 开平仓下单手数
userParam['maxQty'] = 3                     // 期权开平仓最大下单手数
userParam['Delta_Limit'] = 1.0              // delta风险上限
userConfig["context"] = userParam

回测区间为 2026.01.01 至 2026.02.28,期货与期权共享 1 亿元初始资金。dataType = 1 表示快照行情,frequency = 0 表示不合成 K 线;订单延迟为 0 毫秒,multiAssetQuoteUnifiedInput = false 表示分别输入两类行情。本例设置 depth = 5、matchingMode = 1、isBacktestMode = false;快照订单由模拟撮合引擎处理。

期货合约乘数为 300,期权乘数为 100,保证金比例均为 0.12。期权手续费为 15 元/手,期货手续费按成交额的 0.000023 计算,均在合约基本信息中设置。引擎级 commission 设为 0,期货与期权的交易费用由合约基本信息中的费率计算。

策略参数保存在 userParam 中:threshold 为开仓价差阈值,设为 0.5 点,平仓价差门槛取其一半;rf 为年化无风险利率,设为 2%;orderQty 为每次期权报单手数,设为 1 手;maxQty 设为 3 手,用于开平仓的持仓判断;Delta_Limit 设为 1.0,控制期货对冲触发。更换合约或调整数量时,应同时核对基本信息中的乘数、手续费和保证金设置。

2.4 创建回测引擎

加载插件并定义全部策略函数后,将初始化、盘前、行情、委托、成交、盘后和结束回调注册到 callbacks。创建引擎时,userConfig 提供配置与上下文,callbacks 提供事件处理函数,securityReference 提供合约基本信息。

// 回调函数注册
callbacks = dict(STRING, ANY)
callbacks["initialize"] = initialize
callbacks["beforeTrading"] = beforeTrading
callbacks["onSnapshot"] = onSnapshot
callbacks["onOrder"] = onOrder
callbacks["onTrade"] = onTrade
callbacks["afterTrading"] = afterTrading
callbacks["finalize"] = finalized

strategyName = "futOptArbitrage"

// =========================== 创建回测引擎 ===========================
try{Backtest::dropBacktestEngine(strategyName)}catch(ex){print ex}
engine = Backtest::createBacktester(strategyName, userConfig, callbacks, false, securityReference)
go

结束回调通过 callbacks["finalize"] = finalized 注册。其中,finalize 是引擎约定的事件键,finalized 是本例定义的函数对象;事件键与函数名不必相同。

引擎名称为 futOptArbitrage。创建前,脚本尝试清理同名引擎,随后建立新的实例。重复运行时,应先保存需要保留的结果,再重新创建引擎,避免将同一批行情重复写入已有状态的实例。

2.5 执行回测

appendData 按交易日读取期货、期权行情,并分别按 datetime 排序。读取时,将最新价、涨跌停价和成交量转换为引擎要求的字段,将买卖一档价格与数量构造成盘口数组;期权行情额外传入标的价格和希腊值。两类行情分别放入 futures、options 字典项,通过 appendQuotationMsg 写入引擎。

两张行情表共用合约代码、行情时间、价格和盘口等 15 个字段;期权行情另含标的价格和 5 个期权指标字段。下表列出本例的字段转换关系。

表 3. 表 2-3 行情字段转换
引擎字段 本例输出类型 ifTicks 的数据来源 ioTicks 的数据来源
symbol STRING string(symbol) string(symbol)
symbolSource STRING 固定值 CCFX 固定值 CCFX
tradingDay DATE date(datetime) date(datetime)
timestamp TIMESTAMP datetime datetime
lastPrice DOUBLE last_price last_price
upLimitPrice DOUBLE limit_up ask_price_1 × 1.10
downLimitPrice DOUBLE limit_down bid_price_1 × 0.90
totalBidQty LONG long(volume) long(volume)
totalOfferQty LONG long(volume) long(volume)
bidPrice DOUBLE[] bid_price_1 转为单档数组 bid_price_1 转为单档数组
bidQty LONG[] bid_volume_1 转为 LONG 后组成单档数组 bid_volume_1 转为 LONG 后组成单档数组
offerPrice DOUBLE[] ask_price_1 转为单档数组 ask_price_1 转为单档数组
offerQty LONG[] ask_volume_1 转为 LONG 后组成单档数组 ask_volume_1 转为 LONG 后组成单档数组
highPrice DOUBLE last_price last_price
lowPrice DOUBLE last_price last_price
underlyingPrice DOUBLE — underlying_price
Theta DOUBLE — double(theta)
Vega DOUBLE — double(vega)
Gamma DOUBLE — double(gamma)
Delta DOUBLE — double(delta)
IV DOUBLE — 固定值 double(0.0)

本例将 multiAssetQuoteUnifiedInput 设为 false,通过行情字典中的 futures 和 options 键区分资产类型,无需在行情表中加入 assetType 字段。表中的输出类型与下方脚本保持一致。

def appendData(startDate,endDate){
    engine=Backtest::getBacktestEngineList()["futOptArbitrage"]
    // 按天分批加载行情数据并推入引擎 
    dates = exec distinct date(datetime) from loadTable("dfs://fut_opt_snapshot", "if") 
            where date(datetime) between startDate:endDate order by date(datetime)

    for(aDate in dates){
        dayStart = timestamp(aDate)
        dayEnd = timestamp(aDate + 1)

        // --- 当日 IF 期货 tick ---
        ifTicks = select
            string(symbol) as symbol,
            "CCFX" as symbolSource,
            date(datetime) as tradingDay,
            datetime as timestamp,
            last_price as lastPrice,
            limit_up as upLimitPrice,
            limit_down as downLimitPrice,
            long(volume) as totalBidQty,                            // 区间买量
            long(volume) as totalOfferQty,                          // 区间卖量
            fixedLengthArrayVector([bid_price_1]) as bidPrice,
            fixedLengthArrayVector([long(bid_volume_1)]) as bidQty,
            fixedLengthArrayVector([ask_price_1]) as offerPrice,
            fixedLengthArrayVector([long(ask_volume_1)]) as offerQty,
            last_price as highPrice,
            last_price as lowPrice
            from loadTable("dfs://fut_opt_snapshot", "if")
            where datetime >= dayStart and datetime < dayEnd
            order by datetime

        // --- 当日 IO 期权 tick ---
        ioTicks = select
            string(symbol) as symbol,
            "CCFX" as symbolSource,
            date(datetime) as tradingDay,
            datetime as timestamp,
            last_price as lastPrice,
            ask_price_1 * 1.10 as upLimitPrice,
            bid_price_1 * 0.90 as downLimitPrice,
            long(volume) as totalBidQty,
            long(volume) as totalOfferQty,
            fixedLengthArrayVector([bid_price_1]) as bidPrice,
            fixedLengthArrayVector([long(bid_volume_1)]) as bidQty,
            fixedLengthArrayVector([ask_price_1]) as offerPrice,
            fixedLengthArrayVector([long(ask_volume_1)]) as offerQty,
            last_price as highPrice,
            last_price as lowPrice,
            underlying_price as underlyingPrice,
            double(theta) as Theta,
            double(vega) as Vega,
            double(gamma) as Gamma,
            double(delta) as Delta,
            double(0.0) as IV
            from loadTable("dfs://fut_opt_snapshot", "io")
            where datetime >= dayStart and datetime < dayEnd
            order by datetime

        // 推入引擎
        if(ifTicks.size() > 0 or ioTicks.size() > 0){
            dictMsg = dict(STRING, ANY)
            dictMsg["futures"] = ifTicks
            dictMsg["options"] = ioTicks
            Backtest::appendQuotationMsg(engine, dictMsg)
        }
    }
    Backtest::appendEndMarker(engine)
    print("行情数据全部推送完毕")
}

// 提交后台任务,开始插入行情执行回测
submitJob("backtest_append_data","append data to backtest",appendData{startDate,endDate})

本例采用仿真模式(isBacktestMode = false),引擎根据行情日期变化自动完成跨日结算。全部行情写入后,在 appendData 中调用 Backtest::appendEndMarker(engine),完成最后交易日的结算并触发结束回调。

submitJob 在后台执行 appendData,向引擎推送行情。使用 getRecentJobs 和 getJobMessage 查看作业状态与日志,使用 getBacktestEngineStat 查看引擎状态。作业完成且引擎结束后,即可调用结果查询接口获取完整结果。

本例将日内高低价映射为当前最新价,期权涨跌停字段由买卖报价计算;行情未提供结算价时,以当日最后一条行情的最新价进行结算。接入真实行情时,应检查这些字段与数据源的口径是否一致。运行完整策略前,需先完成 2.1 节的数据库和行情准备。

2.6 获取回测结果

回测结束后,通过 Backtest 接口分别查询期货、期权交易明细,以及账户每日权益、每日持仓和收益概述。

engine=Backtest::getBacktestEngineList()["futOptArbitrage"]
futuresTradeDetails = Backtest::getTradeDetails(engine, "futures")
optionsTradeDetails = Backtest::getTradeDetails(engine, "options")
TotalPortfolios = Backtest::getDailyTotalPortfolios(engine)
Dailyposition = Backtest::getDailyPosition(engine)
ReturnSummary = Backtest::getReturnSummary(engine)

futuresTradeDetails 和 optionsTradeDetails 分别保存期货和期权的订单与成交明细;TotalPortfolios 保存每日账户权益与资金变化,Dailyposition 保存每日持仓,ReturnSummary 保存收益与风险统计。分析成交时,应结合订单状态和成交数量区分报单、撤单与成交记录。

需要保留的查询结果应在清理或重建引擎前保存。本例未设置基准标的,因此不使用默认的基准相关字段评价策略的超额收益。

3. 回测结果展示

下表为 DolphinDB 官网案例公布的 2026.01.01 至 2026.02.28 实际行情回测结果,列出区间收益率、最大回撤和夏普比率,用于分析策略在该样本区间内的收益与风险表现。

表 4. 表 3-1 实际行情回测结果
指标 回测结果
区间收益率 0.4509%
最大回撤 1.1354%
夏普比率 0.8145

该样本区间内,策略收益为正,最大回撤为 1.1354%。收益中包含手续费和持仓估值的影响,可结合每日权益与成交明细,进一步分析价差交易、期货对冲和交易成本对账户资金的影响。

附录提供模拟行情生成脚本,不包含上表所用的实际行情数据;模拟回测结果会随生成数据变化。接入实际行情时,按 2.1 节准备数据,并结合成交明细与每日权益,分析多腿成交时差、结算价和保证金占用的影响。

4. 总结

本文介绍了一套基于股指期货与期权 PCP 关系的多资产套利策略。策略结合交易月份选择、ATM 筛选、持仓判断、Delta 对冲和超时订单处理,管理交易信号与多腿执行风险。通过 DolphinDB Backtest,可将合约信息、快照行情与策略回调连接起来,在共享资金账户下完成期货期权回测,并通过交易明细、每日权益和收益统计分析组合表现。

使用真实行情开展策略研究时,需要保持合约信息、报价字段、手续费和保证金口径一致,并结合成交明细关注两条期权腿的成交时差与期货对冲后的资金占用。