在优宽量化交易平台(优宽)的回测系统中研究策略设计是非常方便的,因为实盘、模拟盘是有开盘时间的,时间有限。所以最初的设计放在回测系统中是最合适不过了。策略语言使用JavaScript
,因为在优宽上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股与港股的图表。
通过打印出的数据,我们需要关注两个字段:PriceTick
、VolumeMultiple
。PriceTick
是价格一跳,到策略设计下单时具体要参考这个数据。VolumeMultiple
是一手的股数。从以上数据中可以看出,港股和A股在这些规则上是略有差异的。
接下来我们来研究这两只股票的价格以及差价情况,这里我们就需要思考:“对于A股、港股不同计价单位的股票要怎么处理。A股市场上股票价格是用CNY的,港股市场是港元。是不能直接相减计算差价的”。好在优宽量化交易平台有非常方便的汇率转换函数SetRate
可以换算港元为CNY。
这里比较方便的设计是:
使用两个交易所对象,一个用来处理A股的操作(即:exchanges[0]
),一个用来处理港股的操作(即:exchanges[1]
)。
在优宽量化交易平台上依然很方便的可以编写出测试策略代码:
/*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)
}
}
因为股票数据量实在太大,优宽平台回测系统回测只能使用日线级别,并且是模拟级别回测。差价变动只能非常粗略的宏观表示出来,所以差价变动仅供参考。
融券卖空
因为股票是类似现货交易,如果要做对冲套利需要做空股指。不过今天我们不选择股指之类的金融工具,而是选择类似融券的思路。对于策略测试来说首先就需要先有“融券”了(因为回测系统肯定没有融券这个机制,所以只能预先买入一些作为“融券”),策略开始运行时首先买入一定数量的股票作为“融券”,买入后再记录资金数量作为资产初始数值。
继续在回测中改造策略,让策略能跑起来。
/*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
,对冲交易函数hedge
,main
函数中增加了交易触发条件相关的代码。
因为港股和A股的每手股数不同,对冲时要同时买入卖出相同股数的股票。
所以代码中会有:
Math.max(infoBuy.VolumeMultiple, infoSell.VolumeMultiple)
这样的计算,目的就是取两只股票最小交易单位(一手)中最大的股数,用于下单量的控制。回测系统和实盘时,下单量均为股数,并非手数。并且股数必须严格按照一手的股数下单(必须为一手股数的整倍数)。
这里为了在回测环境里研究,对于对冲的触发差价刻意设置为20元,每当开仓对冲一次level_a2h
、level_h2a
标记变动一次记录(递增1),平仓对冲一次也变动记录(递减1)。并且在对冲开仓、平仓时在图表上标记(通过画线类库$.PlotFlag
函数)
当然这个策略代码目前只是DEMO中的DEMO,不具备创建实盘、模拟盘测试的条件。目前仅仅能在回测系统中测试研究。
可以看到对冲了四次,依次:开仓对冲、平仓对冲、开仓对冲、开仓对冲。
回测系统自动生成的盈亏就不再考量了,因为有开始创建底仓的干扰。可以只看LogProfit
函数输出的收益,这个是对冲收益。
股票的好处就是可以一直持有,通过长期对冲来降低初始股票的建仓成本。
下一篇我们继续扩展这个策略代码,目标是可以在富途的模拟账号下运行。