资源加载中... loading...

浅谈商品期货中的蝶式套利策略

Author: ianzeng123, Created: 2024-02-02 17:14:32, Updated: 2024-02-28 21:44:47

img

在商品期货市场中,如果你问一个交易老手首先要考虑的因素是什么?答案可能是唯一的,风险!因为无论我们的基本面判断是多么精确,还是技术分析使用的多么熟练,然而这个世界是动态变化的,谁也不能完全避免风险。因此怎样可以在无风险或者风险较小的情况下,获得稳定的利润,并形成利润的复利效应,才是我们应该追寻的目标。

如何避免风险呢?那么我们就需要在不确定的市场中寻找到一个相对的确定性。这种相对的不确定性会存在于上下游品种之间的价格相关性(铁矿石和螺纹钢,玉米和玉米淀粉),还有相同品种不同月份合约之间的差价等。依靠我们使用肉眼的判断和手工的计算确实比较麻烦,幸好我们可以借助量化工具的帮忙,在纷繁变化的市场中找到不合理的偏差,偏差总是要修正的,我们的目标就是及时找到这个偏差进行入场,然后在它修正之前平仓获利了解。今天我们介绍的策略蝶式套利策略,依据的原理就是如此。

首先我们介绍一下蝶式套利策略。蝶式套利是跨期套利中的一种常见形式。它是由共享居中交割月份的一个牛市套利和一个熊市套利组合而成。由于较近月份和较远月份的期货合约分别处于居中月份的两侧,形同蝴蝶的两个翅膀,故称之为蝶式套利。

image

其中,居中月份合约的数量等于较近月份和远期月份数量之和,近端、远端合约的方向一致,中间合约的方向则和它们相反。这相当于在较近月份与居中月份之间的牛市(或熊市)套利和在居中月份与远期月份之间的熊市(或牛市)套利的一种组合。即一组是:买近月、卖中间月、买远月(多头蝶式套利);另一组是:卖近月、买中间月、卖远月(空头蝶式套利)。两组交易所跨的是三种不同的交割期,这三种不同交割期的期货合约,不仅品种相同,而且数量也相等,差别仅仅是价格。

正是由于不同交割月份的期货合约在客观上存在着价格水平的差异,而且随着市场供求关系的变动,中间交割月份的合约与两旁交割月份的合约价格还有可能会出现更大的价差。这就造成了套利者对蝶式套利的高度兴趣,即通过操作蝶式套利,利用不同交割月份期货合约价差的变动对冲了结,平仓获利。

蝶式套利在净头寸上没有开口,它在头寸的策略就是买入(或卖出)较近月份合约,同时卖出(或买入)居中月份合约,并买入(或卖出)远期月份合约。因为没有净头寸,所以在一定程度上可以大大的降低风险。

因此,我们要首先确定的事情是,价差是否会出现偏移,并且偏移过后是否会稳定回归?我们可以在优宽平台,获取完整一日的SA403,SA405,SA409分钟级别价格走势图,也就是目前正在交易的纯碱的403代表近期合约,纯碱405主力代表居中合约,纯碱409代表远期合约,查看它们之间的差价走势图。

第一个我们展示的价差是SA403和SA405的价差,可以看到除了尾盘最后十分钟,其他时间段基本都处于均值55附近,呈现不规则的波动。

image

下面我们来看SA405和SA409之间的价差,可以看到这个变化的更加平均,以均值为40,上下4个单位频繁的跳动。

image

因此,在一定程度上可以认为,在一个交易日内,价差是比较稳定的,如果价差出现偏离,那么在很大程度上可以得到回归。有些朋友可能还有疑问,这只是一个交易日的结果,在所有的交易日内,价差都会稳定回归吗?并且可以维持在相同的均值范围吗?我们画图展示一下。

image

image

第一个图像是近期和中期的价差十日走势图,第二个是中期和远期的。根据图像中展示,价差并不是稳定不变的,并且在1月19日到1月25日出现了巨量的偏移(这期间纯碱多个合约出现了涨停的情况),因此如果我们编写这个蝶式套利策略,我们需要考虑这些特殊情况。相对于使用固定的价差,我们可以设置在每一个不同的交易日内,通过不同品种之间的ticker差价,计算价差的波动区间。当价差超过稳定的波动区间,但是也不能超过一定的阈值(证明价格是在合理范围内波动),我们入场进行交易,等到获得固定的利润(价差回归),或者损失达到一定范围(价差单边趋势),我们离场,然后等待下一次的入场机会。

所以我们可以整理一下策略的逻辑:

  1. 在判断在一个交易日内的情况下,不断收集三个合约的ticker价格,计算三个合约价差(包括近期中期价差,和中期远期价差)均值和波动标准差;
  2. 根据不同价差的均值和标准差,计算价差的正套入场阈值和反套入场阈值;为了避免价差出现单边的行情,比如上面图中呈现的单边趋势,3. 我们需要保证价差是在合理范围内波动的,所以还需要一个价差的上限和下限,当价差在合理范围内,才会进行交易;
  3. 实时判断价差和价差阈值的关系,当出现下列的情况时:
  • 实时近期中期价差大于近期中期阈值,实时中期远期价差小于中期远期阈值,并且分别没有超过各自的上下限,证明中期合约价格较低,而近期和远期价格较高,所以可以进行多两份中期合约,分别空一份近期和远期的合约的操作;
  • 实时近期中期价差小于近期中期阈值,实时中期远期价差大于中期远期阈值,并且分别没有超过各自的上下限,证明中期合约价格较高,而近期和远期价格较低,可以进行空两份中期合约,分别多一份近期和远期的合约的操作;
  1. 根据设置的止盈线和止损线,进行平仓离场的操作。

并且我们也做了一些策略的优化,在晚上开盘和早上开盘前20分钟之内,还有尾盘的10分钟,都是是波动比较剧烈的行情,我们选择跳过这些时间段进行交易;而且为了避免持仓横跨两个交易日的风险,在尾盘10分钟的时候,如果持仓,我们平掉所有的仓位。

这就是策略的基本编写思路,我们在优宽平台编写代码试一下:

// 判断当前是否在交易时间范围内的函数
function timeSel() {
    var t = new Date();                  
    var hour = t.getHours();             
    var minute = t.getMinutes();         
    var day = t.getDay();

    var isYes = false;

    // 判断条件包括周一至周五的不同时间段(常规品种交易时间)
    if ((day >= 1 && day <= 5) && 
        ((hour === 9 && minute >= 0) || (hour > 9 && hour < 11) || (hour === 11 && minute < 30)) ||
        ((hour === 13 && minute >= 30) || (hour > 13 && hour < 15)) ||
        ((hour >= 21 && hour < 23))) {
        isYes = true;
    }

    return isYes;
}

// 计算给定数据的均值、方差和标准差的函数
function calculateStats(data) {
    var mean = data.reduce((acc, val) => acc + val, 0) / data.length;
    var squaredDiffs = data.map(val => Math.pow(val - mean, 2));
    var variance = squaredDiffs.reduce((acc, val) => acc + val, 0) / squaredDiffs.length;
    var stdDev = Math.sqrt(variance);
    
    return { mean, variance, stdDev };
}

// 主要的交易策略函数
function main() {
    // 初始化
    SetErrorFilter("502:|503:|tcp|character|unexpected|network|timeout|WSARecv|Connect|GetAddr|no such|reset|http|received|EOF|reused|(CTP_T@10010)")
    var initAccount = _C(exchange.GetAccount)

    var p = $.NewPositionManager()
    var pretime = -1
    var islock = false //防止重复开仓

    var posContract = []
    var posType = []
    var posPrice = []
    var posProfit = []

    var curprofit = '--'
    var wincount = 0
    var losscount = 0

    var nearTickList = []
    var middleTickList = []
    var farTickList = []

    while(1) {
        if(exchange.IO("status") && timeSel()) {
            // 获取当前时间
            var t = new Date();
            var day = t.getDate();
            var hour = t.getHours();
            var minute = t.getMinutes();
            
            // 处理新的交易日
            if(day != pretime) {
                nearTickList = []
                middleTickList = []
                farTickList = []
                pretime = day
            } 

            // 获取近、中、远三个合约的最新行情
            _C(exchange.SetContractType, nearContract)
            var neartick = _C(exchange.GetTicker) 
            if(neartick) nearTickList.push(neartick.Last)

            _C(exchange.SetContractType, middleContract)
            var middletick = _C(exchange.GetTicker)
            if(middletick) middleTickList.push(middletick.Last)

            _C(exchange.SetContractType, farContract)
            var fartick = _C(exchange.GetTicker)
            if(fartick) farTickList.push(fartick.Last)

            // 非特定时间段执行策略
            if(!((hour == 21 && minute < 20) || (hour == 9 && minute < 20) || (hour == 14 && minute > 50))) {
                
                // 处理蝴蝶左翼的差价及统计信息
                var valueA = neartick.Last - middletick.Last
                var diffArrA = nearTickList.map((val, index) => val - middleTickList[index])
                var statsA = calculateStats(diffArrA)

                var diffnearmiddleUpperThre = statsA.mean + stdThre * statsA.stdDev
                var diffnearmiddleUpperBound = statsA.mean + stdBound * statsA.stdDev
                var diffnearmiddleLowerThre = statsA.mean - stdThre * statsA.stdDev
                var diffnearmiddleLowerBound = statsA.mean - stdBound * statsA.stdDev

                var posAContidion = valueA > diffnearmiddleUpperThre && valueA < diffnearmiddleUpperBound
                var negAContidion = valueA < diffnearmiddleLowerThre && valueA > diffnearmiddleLowerBound

                // 处理蝴蝶右翼的差价及统计信息
                var valueB = middletick.Last - fartick.Last
                var diffArrB = middleTickList.map((val, index) => val - farTickList[index])
                var statsB = calculateStats(diffArrB)

                var difffarmiddleUpperThre = statsB.mean + stdThre * statsB.stdDev
                var difffarmiddleUpperBound = statsB.mean + stdBound * statsB.stdDev
                var difffarmiddleLowerThre = statsB.mean - stdThre * statsB.stdDev
                var difffarmiddleLowerBound = statsB.mean - stdBound * statsB.stdDev
                
                // 获取交易信号
                var posBContidion = valueB > difffarmiddleUpperThre && valueB < difffarmiddleUpperBound
                var negBContidion = valueB < difffarmiddleLowerThre && valueB > difffarmiddleLowerBound
                

                // 根据条件进行交易
                if(islock == false && posAContidion && negBContidion){
                    p.OpenLong(middleContract, 2)
                    p.OpenShort(nearContract, 1)
                    p.OpenShort(farContract, 1)
                    islock = true 
                }

                if(islock == false && negAContidion && posBContidion){
                    p.OpenShort(middleContract, 2)
                    p.OpenLong(nearContract, 1)
                    p.OpenLong(farContract, 1)
                    islock = true 
                }

                // 处理持仓盈亏
                var pos = _C(exchange.GetPosition)

                if(pos && pos.length > 0){
                    profit = pos.reduce((sum, item) => sum + item.Profit, 0)
                    curprofit = profit
                }

                // 止盈止损
                if(pos.length > 0 && profit >= stopWin){
                    Log(profit, '胜利#FF0000')
                    p.CoverAll()
                    wincount += 1
                    islock = false
                    curprofit = '--'
                }

                if(pos.length > 0 && profit <= stopLoss){
                    Log(profit, '失败#00FF00')
                    p.CoverAll()
                    losscount += 1
                    islock = false
                    curprofit = '--'
                }
            }

            // 处理交易日结束
            if(hour == 14 && minute > 50){
                var pos = _C(exchange.GetPosition)

                if(pos.length > 0){
                    Log(profit, '时间到#0000FF')
                    p.CoverAll()
                    curprofit = '--'
                    islock = false
                }
            }

            // 绘制差价曲线图
            $.PlotMultLine("近月中期差价", "差价", valueA, new Date().getTime(),{layout: 'single', height: '600px'})  
            $.PlotMultLine("中期远月差价", "差价", valueB, new Date().getTime(),{layout: 'single', height: '600px'})  

            // 更新策略运行统计表
            tblStatusA = {
                "type" : "table",
                "title" : "策略运行统计表",
                "cols" : ["实时盈利", "止盈次数", "止损次数"],
                "rows" : [] 
            }

            tblStatusA.rows = [];
            tblStatusA.rows.push([curprofit, wincount, losscount])

            // 更新策略实时状态表
            tblAStatusB = {
                "type" : "table",
                "title" : "策略实时状态表",
                "cols" : ["持仓品种", "持仓类型", "持仓价格", "持仓盈亏"],
                "rows" : [] 
            }

            var statusPos = _C(exchange.GetPosition)

            var posContract = []
            var posType = []
            var posPrice = []
            var posProfit = []

            if(statusPos && statusPos.length > 0){
                for (var i = 0; i < statusPos.length; i++) {
                    posType.push(statusPos[i]['Type'] % 2 === 0 ? '多头' : '空头')
                    posContract.push(statusPos[i]['ContractType'])
                    posPrice.push(statusPos[i]['Price'])
                    posProfit.push(statusPos[i]['Profit'])
                }
            }

            tblAStatusB.rows = []

            for (var j = 0; j < posContract.length; j++) {
                tblAStatusB.rows.push([posContract[j], posType[j], posPrice[j], posProfit[j]]);
            }

            // 合并表格信息
            lastStatus = '`' + JSON.stringify(tblStatusA) + '`\n' + '`' + JSON.stringify(tblAStatusB) + '`'

            // 打印策略信息
            LogStatus(lastStatus)

            // 打印收益信息
            var accountInfo = _C(exchange.GetAccount)
            var curprofit = accountInfo.Info.Balance - initAccount.Info.Balance//该收益计算只能在实盘中进行使用
            LogProfit(curprofit, "权益", '&')

            Sleep(100)
        } else {
            // 处理未连接到交易服务器的情况
            LogStatus("正在等待与交易服务器连接, " + _D())
            Sleep(3000)
        }
    }
}

下面我们介绍一下各部分的函数代码:

  • 时间选择逻辑 (timeSel函数): TimeSel 函数用于确定当前是否处于交易时间范围内。交易时间范围包括周一至周五的目标品种的交易时间段,这是设置的是包含夜盘的一般品种,如果大家钟爱只有日盘或者其他特殊时间段的品种,可以自行设置。如果在交易时间范围内,返回 true,否则返回 false。

  • 统计函数 (calculateStats 函数): CalculateStats 函数接收一个数据数组,计算其均值、方差和标准差。通过使用这些统计值可以量化数据分布的特征。

  • 交易逻辑主体 (main 函数):

  1. 代码开头初始化一些变量,包括账户信息、交易类库,策略运行和持仓状态显示,ticklist保存tick列表变量等。这里为了防止重复的开仓,设置islock变量。
  2. 进入while 循环,检查交易服务器连接状态和交易时间。
  3. 在每个交易日的开始,重置不同合约的列表,然后获取近、中、远三个合约的最新行情,并使用对应的列表进行保存。
  4. 选择特定的交易时间(排除晚上早上开盘和最后收盘时间),当检查到尚未开仓(islock == false),开始计算中期和近期(蝴蝶左翼A),和远期和中期(蝴蝶右翼B)合约的实时的差价(valueA,valueB),并保存两个差价列表。然后使用 calculateStats 函数计算获取统计信息,然后设置差价的阈值和上下限。这里设置的差价标准差系数是外部参数的形式,对于波动性比较大的品种可以适当调大,对于波动性比较小的品种可以适当缩小;然后根据实时动态的差价,差价波动阈值,和差价波动上下限的关系,设置posAContidion(近期中期差价过大),negAContidion(近期中期差价过小),posBContidion(中期远期差价过大)和negBContidion(中期远期差价过小)四个交易信号。
  5. 在判断没有持仓,posAContidion和negBContidion成立条件下,证明中期合约价格较低,而近期和远期价格较高,对应的进行开多中期两手,开空近期和远期分别一手;于此相对,满足negAContidion和posBContidion条件,开空中期两手,开多近期和远期分别一手。开仓完成以后,需要设置islock为真,防止重复开仓;
  6. 进行平仓处理,获取持仓数据以后,如果实时的利润超过止盈上限或者跌破止损下限,及时的选择平仓,并且我们也可以统计止盈次数和止损次数,然后设置islock为false,重新进行开仓的操作;
  7. 当达到收盘时间,在有持仓情况下,平掉所有仓位,防止额外的风险。
  8. 最后部分,绘制实时差价图,并更新策略运行统计表,包括实时盈利,和止盈止损次数;以及策略实时状态表,包括当前持仓品种,持仓方向,持仓价格和持仓盈亏;结尾部分,对策略的收益进行了实时的展示。

对于策略的外部参数,我们可以设置目标品种,包括短期,中期和长期的合约,价差标准差阈值和上下限,以及止盈和止损的系数。大家可以根据自己喜欢的品种进行不同的尝试。

image

总体来说,这个策略的核心思路是在两个品种的差价上设置阈值,当差价超过阈值时执行相应的交易动作。整个逻辑结构还是比较清晰的,通过不断获取行情数据和根据条件执行交易来实现策略的自动化交易。

大家可能最关心的是这个策略的收益,比较让人兴奋的一点是,这个策略可以做到极高的胜率,但是出现开仓的信号和平仓的信号等待的时间是比较长的,如果使用了走势特别平稳的品种,比如纸浆玉米等,开仓和平仓的等待都是比较耗时的(可能一天也等不到);因此,它更加适合于存在差价回归,但是差价也并不是特别严格的品种,大家可以在对价差分析的基础上挑选合适的品种。当然,本策略实际上算是高频策略的一种,当价差出现偏差需要立即开仓,当达到盈利点位,需要立即平仓,因此代码部分可以优化的地方还有很多。本篇文章更多的是一个抛砖引玉的作用,希望更多朋友可以提出宝贵的意见,我们共同完善好这个策略。


更多内容