A股港股对冲套利策略(1)

Author: 扫地僧, Created: 2021-09-01 14:42:07, Updated: 2023-11-22 20:38:11

img

A股港股对冲套利策略(1)

回测系统中研究

在发明者量化交易平台(FMZ)的回测系统中研究策略设计是非常方便的,因为实盘、模拟盘是有开盘时间的,时间有限。所以最初的设计放在回测系统中是最合适不过了。策略语言使用JavaScript,因为在FMZ上JavaScript语言是最方便上手的。

我们选择股票:长城汽车作为研究对象。 港股代码:601633.SH,A股代码:02333.HK

首先我们可以先测试订阅股票代码,观察股票的基本信息。

function main() {
    var infoA = exchange.SetContractType("601633.SH")
    var infoH = exchange.SetContractType("02333.HK")    
    Log(infoA)
    Log(infoH)
}

打印出来的信息,首先看港股:

{
	"ExchangeID": "HK",
	"ExchangeInstID": "02333.HK",
	"InstrumentID": "02333.HK",
	"InstrumentName": "长城汽车",
	"LongMarginRatio": 1,
	"MaxLimitOrderVolume": 10000,
	"MinBuyVolume": 1,
	"OpenDate": "",
	"PriceTick": 0.05,
	"ShortMarginRatio": 1,
	"VolumeMultiple": 500
}

再来看A股:

{
	"ExchangeID": "SSE",
	"ExchangeInstID": "601633.SH",
	"InstrumentID": "601633.SH",
	"InstrumentName": "长城汽车",
	"LongMarginRatio": 1,
	"MaxLimitOrderVolume": 10000,
	"MinBuyVolume": 1,
	"OpenDate": "20110928",
	"PriceTick": 0.01,
	"ProductClass": "stock",
	"ShortMarginRatio": 1,
	"VolumeMultiple": 100
}

回测系统也输出了长城汽车A股与港股的图表。

img

通过打印出的数据,我们需要关注两个字段:PriceTickVolumeMultiplePriceTick是价格一跳,到策略设计下单时具体要参考这个数据。VolumeMultiple是一手的股数。从以上数据中可以看出,港股和A股在这些规则上是略有差异的。

接下来我们来研究这两只股票的价格以及差价情况,这里我们就需要思考:“对于A股、港股不同计价单位的股票要怎么处理。A股市场上股票价格是用CNY的,港股市场是港元。是不能直接相减计算差价的”。好在FMZ量化交易平台有非常方便的汇率转换函数SetRate可以换算港元为CNY。

这里比较方便的设计是: 使用两个交易所对象,一个用来处理A股的操作(即:exchanges[0]),一个用来处理港股的操作(即:exchanges[1])。

在FMZ量化交易平台上依然很方便的可以编写出测试策略代码:

/*backtest
start: 2020-09-01 09:00:00
end: 2021-08-31 15:00:00
period: 1d
basePeriod: 1d
exchanges: [{"eid":"Futures_XTP","currency":"STOCK","minFee":0},{"eid":"Futures_XTP","currency":"STOCK","minFee":0}]
*/


var symbolH = "02333.HK"    // exchanges[1]
var symbolA = "601633.SH"   // exchanges[0]
var H2A_Rate = 0.8302       // 港币对CNY汇率

function newDate() {
    var timezone = 8                                //目标时区时间,东八区
    var offset_GMT = new Date().getTimezoneOffset() // 本地时间和格林威治的时间差,单位为分钟
    var nowDate = new Date().getTime()              // 本地时间距 1970 年 1 月 1 日午夜(GMT 时间)之间的毫秒数
    var targetDate = new Date(nowDate + offset_GMT * 60 * 1000 + timezone * 60 * 60 * 1000)
    return targetDate
}

function IsTrading() {
    var now = newDate()            // 使用 newDate() 代替 new Date() 因为服务器时区问题
    var day = now.getDay()
    var hour = now.getHours()
    var minute = now.getMinutes()
    StatusMsg = "非交易时段"

    if (day === 0 || day === 6) {
        return false
    }

    if((hour == 9 && minute >= 30) || (hour == 11 && minute < 30) || (hour > 9 && hour < 11)) {
    	// 9:30-11:30
        StatusMsg = "交易时段"
        return true 
    } else if (hour >= 13 && hour < 15) {
    	// 13:00-15:00
        StatusMsg = "交易时段"
        return true 
    }
    
    return false 
}

function main() {
    SetErrorFilter("market not ready")
    for (var i in exchanges) {
        if((exchanges[i].GetCurrency() != "STOCK" && exchanges[i].GetCurrency() != "STOCK_CNY" ) || (exchanges[i].GetName() != "Futures_Futu" && exchanges[i].GetName() != "Futures_XTP")) {
            throw "不支持"
        }
        exchanges[i].SetPrecision(2, 0)
    }

    // 设置港币汇率
    if (exchanges.length != 2) {
        throw "需要添加2个交易所对象,可以使用同一个账号配置2个交易所对象。"
    } else {
        if (!symbolA.includes(".SH") && !symbolA.includes(".SZ")) {
            throw "参数symbolA需要设置A股代码。"
        }
        if (!symbolH.includes(".HK")) {
            throw "参数symbolH需要设置港股代码。"
        }
        exchanges[1].SetRate(H2A_Rate)
        Log("设置港元->CNY的汇率:", H2A_Rate)
    }

    while (true) {
        Sleep(1000 *2)
        if (!IsTrading()) {
            continue 
        }
        
        var infoA = exchanges[0].SetContractType(symbolA)        
        if (!infoA) {
            continue
        }
        var tickerA = exchanges[0].GetTicker()
        
        var infoH = exchanges[1].SetContractType(symbolH)    
        if (!infoH) {
            continue
        }
        var tickerH = exchanges[1].GetTicker()
        
        if (!tickerA || !tickerH) {
            continue 
        }
        
        var a2h = tickerA.Buy - tickerH.Sell
        var h2a = tickerA.Sell - tickerH.Buy
        
        var ts = new Date().getTime()
        $.PlotLine("a2h", a2h, ts)
        $.PlotLine("h2a", h2a, ts)
        
    }
}

因为股票数据量实在太大,FMZ平台回测系统回测只能使用日线级别,并且是模拟级别回测。差价变动只能非常粗略的宏观表示出来,所以差价变动仅供参考。

img

融券卖空

img

img

因为股票是类似现货交易,如果要做对冲套利需要做空股指。不过今天我们不选择股指之类的金融工具,而是选择类似融券的思路。对于策略测试来说首先就需要先有“融券”了(因为回测系统肯定没有融券这个机制,所以只能预先买入一些作为“融券”),策略开始运行时首先买入一定数量的股票作为“融券”,买入后再记录资金数量作为资产初始数值。

继续在回测中改造策略,让策略能跑起来。

/*backtest
start: 2020-09-01 09:00:00
end: 2021-08-31 15:00:00
period: 1d
basePeriod: 1d
exchanges: [{"eid":"Futures_XTP","currency":"STOCK","minFee":0},{"eid":"Futures_XTP","currency":"STOCK","minFee":0}]
*/


var symbolH = "02333.HK"    // exchanges[1]
var symbolA = "601633.SH"   // exchanges[0]
var isGetBaseStocks = true  // 是否需要建底仓
var hedgeAmount = 1000      // 每次对冲
var baseStocks = 10000      // 底仓股数,不是手数
var slidePoint = 5          // 滑价点数
var H2A_Rate = 0.8302       // 港币对CNY汇率

function newDate() {
    var timezone = 8                                //目标时区时间,东八区
    var offset_GMT = new Date().getTimezoneOffset() // 本地时间和格林威治的时间差,单位为分钟
    var nowDate = new Date().getTime()              // 本地时间距 1970 年 1 月 1 日午夜(GMT 时间)之间的毫秒数
    var targetDate = new Date(nowDate + offset_GMT * 60 * 1000 + timezone * 60 * 60 * 1000)
    return targetDate
}

function IsTrading() {
    var now = newDate()            // 使用 newDate() 代替 new Date() 因为服务器时区问题
    var day = now.getDay()
    var hour = now.getHours()
    var minute = now.getMinutes()
    StatusMsg = "非交易时段"

    if (day === 0 || day === 6) {
        return false
    }

    if((hour == 9 && minute >= 30) || (hour == 11 && minute < 30) || (hour > 9 && hour < 11)) {
    	// 9:30-11:30
        StatusMsg = "交易时段"
        return true 
    } else if (hour >= 13 && hour < 15) {
    	// 13:00-15:00
        StatusMsg = "交易时段"
        return true 
    }
    
    return false 
}

function GetPosition(e, contractTypeName) {
    var allAmount = 0
    var allProfit = 0
    var allFrozen = 0
    var posMargin = 0
    var price = 0
    var direction = null
    positions = _C(e.GetPosition)
    for (var i = 0; i < positions.length; i++) {
        if (positions[i].ContractType != contractTypeName) {
            continue
        }
        if (positions[i].Type == PD_LONG) {
            posMargin = positions[i].MarginLevel
            allAmount += positions[i].Amount
            allProfit += positions[i].Profit
            allFrozen += positions[i].FrozenAmount
            price = positions[i].Price
            direction = positions[i].Type
        }
    }
    if (allAmount === 0) {
        return null
    }
    return {
        MarginLevel: posMargin,
        FrozenAmount: allFrozen,
        Price: price,
        Amount: allAmount,
        Profit: allProfit,
        Type: direction,
        ContractType: contractTypeName,
        CanCoverAmount: allAmount - allFrozen
    }
}

function getBaseStocks(priceA, priceH) {
    var infoA = _C(exchanges[0].SetContractType, symbolA)
    exchanges[0].SetDirection("buy")
    exchanges[0].Buy(priceA + infoA.PriceTick * slidePoint, (baseStocks - baseStocks % infoA.VolumeMultiple), "一手股数:" + infoA.VolumeMultiple)
    Log(symbolA, exchanges[0].GetPosition(symbolA))
    
    var infoH = _C(exchanges[1].SetContractType, symbolH)
    exchanges[1].SetDirection("buy")    
    exchanges[1].Buy(priceH + infoH.PriceTick * slidePoint, (baseStocks - baseStocks % infoH.VolumeMultiple), "一手股数:" + infoH.VolumeMultiple)
    Log(symbolH, exchanges[1].GetPosition(symbolH))    
    
    Log("底仓建仓完毕", "#FF0000")
    var acc0 = _C(exchanges[0].GetAccount)
    var acc1 = _C(exchanges[1].GetAccount)
    var initAcc = {"initAcc_A" : acc0, "initAcc_H" : acc1}
    _G("initAcc", initAcc)
}

function hedge(buySymbol, sellSymbol, buyPrice, sellPrice, amount) {
    var buyEx = buySymbol == symbolA ? exchanges[0] : exchanges[1]
    var sellEx = sellSymbol == symbolH ? exchanges[1] : exchanges[0]

    // 卖出的检查持仓
    var infoSell = sellEx.SetContractType(sellSymbol)
    var sellExPos = GetPosition(sellEx, sellSymbol)
    if (!sellExPos) {
        return 
    }

    // 检查资产数值
    var infoBuy = buyEx.SetContractType(buySymbol)
    var buyExAcc = buyEx.GetAccount()
    if (!buyExAcc) {
        return 
    }
    
    // 检查资金、仓位
    amount = Math.min(buyExAcc.Balance / buyPrice, sellExPos.CanCoverAmount, amount)
    var minAmount = Math.max(infoBuy.VolumeMultiple, infoSell.VolumeMultiple)
    if (amount < minAmount) {
        return 
    }
    amount = amount - amount % minAmount

    buyEx.SetDirection("buy")
    var buyId = buyEx.Buy(buyPrice + infoBuy.PriceTick * slidePoint, amount, buySymbol, "一手股数:" + infoBuy.VolumeMultiple)
    
    sellEx.SetDirection("closebuy")
    var sellId = sellEx.Sell(sellPrice - infoSell.PriceTick * slidePoint, amount, sellSymbol, "一手股数:" + infoSell.VolumeMultiple)
    return {"buyId" : buyId, "sellId" : sellId}
}

function main() {
    SetErrorFilter("market not ready")
    for (var i in exchanges) {
        if((exchanges[i].GetCurrency() != "STOCK" && exchanges[i].GetCurrency() != "STOCK_CNY" ) || (exchanges[i].GetName() != "Futures_Futu" && exchanges[i].GetName() != "Futures_XTP")) {
            throw "不支持"
        }
        exchanges[i].SetPrecision(2, 0)
    }
    var initAcc = null
    var level_a2h = 0
    var level_h2a = 0

    // 设置港币汇率
    if (exchanges.length != 2) {
        throw "需要添加2个交易所对象,可以使用同一个账号配置2个交易所对象。"
    } else {
        if (!symbolA.includes(".SH") && !symbolA.includes(".SZ")) {
            throw "参数symbolA需要设置A股代码。"
        }
        if (!symbolH.includes(".HK")) {
            throw "参数symbolH需要设置港股代码。"
        }
        exchanges[1].SetRate(H2A_Rate)
        Log("设置港元->CNY的汇率:", H2A_Rate)
    }

    while (true) {
        Sleep(1000 *2)
        if (!IsTrading()) {
            continue 
        }
        
        var infoA = exchanges[0].SetContractType(symbolA)        
        if (!infoA) {
            continue
        }
        var tickerA = exchanges[0].GetTicker()
        
        var infoH = exchanges[1].SetContractType(symbolH)    
        if (!infoH) {
            continue
        }
        var tickerH = exchanges[1].GetTicker()
        
        if (!tickerA || !tickerH) {
            continue 
        }

        // 需要判断涨跌停

        
        if (isGetBaseStocks) {
            getBaseStocks(tickerA.Sell, tickerH.Sell)
            isGetBaseStocks = false 
        }
        if (!initAcc) {
            initAcc = _G("initAcc")
            Log("初始账户数据", initAcc)
        }
        
        var a2h = tickerA.Buy - tickerH.Sell
        var h2a = tickerA.Sell - tickerH.Buy
        
        var ts = new Date().getTime()
        $.PlotLine("a2h", a2h, ts)
        $.PlotLine("h2a", h2a, ts)
        
        if (a2h > 20 + level_a2h * 10) {            
            var ret = hedge(symbolH, symbolA, tickerH.Sell, tickerA.Buy, hedgeAmount)
            if (ret) {
                level_a2h++
                $.PlotFlag(ts, 'sell', 'S')
            }
        } else if (-h2a > 20 + level_h2a * 10) {            
            var ret = hedge(symbolA, symbolH, tickerA.Sell, tickerH.Buy, hedgeAmount)
            if (ret) {
                level_h2a++        
                $.PlotFlag(ts, 'buy', 'B')
            }
        }
        
        if (a2h < 15 && level_a2h > 0) {            
            var ret = hedge(symbolA, symbolH, tickerA.Sell, tickerH.Buy, hedgeAmount)
            if (ret) {
                level_a2h--
                $.PlotFlag(ts, 'buy', 'B')
                var acc0 = _C(exchanges[0].GetAccount)
                var acc1 = _C(exchanges[1].GetAccount)
                LogProfit((acc0.Balance + acc1.Balance) - (initAcc.initAcc_A.Balance + initAcc.initAcc_H.Balance), {"initAcc_A" : acc0, "initAcc_H" : acc1})
            }
        } else if (-h2a < 15 && level_h2a > 0) {            
            var ret = hedge(symbolH, symbolA, tickerH.Sell, tickerA.Buy, hedgeAmount)
            if (ret) {
                level_h2a--
                $.PlotFlag(ts, 'sell', 'S')
                var acc0 = _C(exchanges[0].GetAccount)
                var acc1 = _C(exchanges[1].GetAccount)
                LogProfit((acc0.Balance + acc1.Balance) - (initAcc.initAcc_A.Balance + initAcc.initAcc_H.Balance), {"initAcc_A" : acc0, "initAcc_H" : acc1})
            }
        }        
        LogStatus(_D(), "\n", "账户信息:", exchanges[0].GetAccount(), exchanges[1].GetAccount(), "\n level_a2h:", level_a2h, "level_h2a:", level_h2a, "\n", exchanges[0].GetPosition(), exchanges[1].GetPosition())
    }
}

这次增加了:创建底仓的函数getBaseStocks,对冲交易函数hedgemain函数中增加了交易触发条件相关的代码。 因为港股和A股的每手股数不同,对冲时要同时买入卖出相同股数的股票。

所以代码中会有:

Math.max(infoBuy.VolumeMultiple, infoSell.VolumeMultiple)

这样的计算,目的就是取两只股票最小交易单位(一手)中最大的股数,用于下单量的控制。回测系统和实盘时,下单量均为股数,并非手数。并且股数必须严格按照一手的股数下单(必须为一手股数的整倍数)。

这里为了在回测环境里研究,对于对冲的触发差价刻意设置为20元,每当开仓对冲一次level_a2hlevel_h2a标记变动一次记录(递增1),平仓对冲一次也变动记录(递减1)。并且在对冲开仓、平仓时在图表上标记(通过画线类库$.PlotFlag函数)

当然这个策略代码目前只是DEMO中的DEMO,不具备创建实盘、模拟盘测试的条件。目前仅仅能在回测系统中测试研究。

img

可以看到对冲了四次,依次:开仓对冲、平仓对冲、开仓对冲、开仓对冲。

img

img

回测系统自动生成的盈亏就不再考量了,因为有开始创建底仓的干扰。可以只看LogProfit函数输出的收益,这个是对冲收益。

股票的好处就是可以一直持有,通过长期对冲来降低初始股票的建仓成本。

下一篇我们继续扩展这个策略代码,目标是可以在富途的模拟账号下运行。


更多内容