你知道吗,优宽作为专业的量化交易平台,不仅支持商品期货市场,股票市场也是支持的。现如今股票量化交易的入市要求是需要50W元的验证资金,对于想使用程序化交易进行入门学习的同学,确实是一个比较高的门槛,所以国内股票市场程序化、量化这些技术还都是大机构、大庄家的工具。而在优宽平台,不需要任何的资金要求,我们可以申请一个模拟的股票账号,编写策略对接真实的市场进行仿真交易。这样在提升自己量化学习能力的同时,如果自己的策略足够成熟,在仿真交易中可以获得一定的收益,我们也可以使用自己的交易策略进行真实的市场交易。
相对于其他的量化模拟工具,优宽省去了搭建各种环境和api设置的烦恼。今天,我们就要来一步步展示一下如何在优宽平台,进行针对于股票的在回测系统中的策略编写和回测测试。话不多说,我们现在开始。
今天我们要使用的策略是股票版布林带策略,这个策略原理相信大家都很熟悉,当价格上穿布林带上轨的时候,我们进行开多;在快线下穿布林带下轨的时候,我们进行平多。在优宽平台上这个策略有很多版本,例如:商品期货版本,数字货币版本等,还有各种不同编程语言的版本。为什么这个策略比较适合入门呢?因为这个策略涵盖了策略开发的很多方面,诸如策略图表,实时状态信息显示,数据处理,交易逻辑设计等等。并且策略并不复杂,代码也不难懂。本节课我们使用的这个股票版布林带策略就是移植于商品期货版的布林带策略。
/*backtest
start: 2022-01-03 09:00:00
end: 2023-07-21 15:00:51
period: 1d
basePeriod: 1d
exchanges: [{"eid":"Futures_XTP","currency":"STOCK","feeMin":0,"depthDeep":20}]
args: [["StrIds","[\"600519.SH\", \"600690.SH\", \"600006.SH\", \"601328.SH\", \"600887.SH\"]"],["bollPeriod",30]]
*/
var Ids = []
var _Symbols = []
var STATE_IDLE = 0
var STATE_LONG = 1
var SlideTick = 10
var StatusMsg = ""
var _Chart = null
var _ArrChart = []
var Interval = 1000
var ArrStateStr = ["空闲", "多仓"]
function newDate() {
var timezone = 8
var offset_GMT = new Date().getTimezoneOffset()
var nowDate = new Date().getTime()
var targetDate = new Date(nowDate + offset_GMT * 60 * 1000 + timezone * 60 * 60 * 1000)
return targetDate
}
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 Buy(e, contractType, opAmount, insDetail) {
var initPosition = GetPosition(e, contractType)
var isFirst = true
var initAmount = initPosition ? initPosition.Amount : 0
var positionNow = initPosition
if(!IsVirtual() && opAmount % insDetail.LotSize != 0) {
throw "每手数量不匹配"
}
while (true) {
var needOpen = opAmount
if (isFirst) {
isFirst = false
} else {
Sleep(Interval*20)
positionNow = GetPosition(e, contractType)
if (positionNow) {
needOpen = opAmount - (positionNow.Amount - initAmount)
}
}
if (needOpen < insDetail.LotSize || (needOpen % insDetail.LotSize != 0 && !IsVirtual())) {
break
}
var depth = _C(e.GetDepth)
// 需要检测是否涨跌停
var amount = needOpen
e.SetDirection("buy")
var orderId = e.Buy(depth.Asks[0].Price + (insDetail.PriceSpread * SlideTick), amount, contractType, 'Ask', depth.Asks[0])
// CancelPendingOrders
while (true) {
Sleep(Interval*20)
var orders = _C(e.GetOrders)
if (orders.length === 0) {
break
}
for (var j = 0; j < orders.length; j++) {
e.CancelOrder(orders[j].Id)
if (j < (orders.length - 1)) {
Sleep(Interval*20)
}
}
}
}
var ret = null
if (!positionNow) {
return ret
}
ret = positionNow
return ret
}
function Sell(e, contractType, lots, insDetail) {
var initAmount = 0
var firstLoop = true
if(!IsVirtual() && lots % insDetail.LotSize != 0) {
throw "每手数量不匹配"
}
while (true) {
var n = 0
var total = 0
var positions = _C(e.GetPosition)
var nowAmount = 0
for (var i = 0; i < positions.length; i++) {
if (positions[i].ContractType != contractType) {
continue
}
nowAmount += positions[i].Amount
}
if (firstLoop) {
initAmount = nowAmount
firstLoop = false
}
var amountChange = initAmount - nowAmount
if (typeof(lots) == 'number' && amountChange >= lots) {
break
}
for (var i = 0; i < positions.length; i++) {
if (positions[i].ContractType != contractType) {
continue
}
var amount = positions[i].Amount
var depth
var opAmount = 0
var opPrice = 0
if (positions[i].Type == PD_LONG) {
Sleep(Interval*20)
depth = _C(e.GetDepth)
// 需要检测是否涨跌停
opAmount = amount
opPrice = depth.Bids[0].Price - (insDetail.PriceSpread * SlideTick)
}
if (typeof(lots) === 'number') {
opAmount = Math.min(opAmount, lots - (initAmount - nowAmount))
}
if (opAmount > 0) {
if (positions[i].Type == PD_LONG) {
e.SetDirection("closebuy")
e.Sell(opPrice, opAmount, contractType, "平仓", 'Bid', depth.Bids[0])
}
n++
}
// break to check always
if (typeof(lots) === 'number') {
break
}
}
if (n === 0) {
break
}
while (true) {
Sleep(Interval*20)
var orders = _C(e.GetOrders)
if (orders.length === 0) {
break
}
for (var j = 0; j < orders.length; j++) {
e.CancelOrder(orders[j].Id)
if (j < (orders.length - 1)) {
Sleep(Interval*20)
}
}
}
}
}
function IsTrading() {
// 使用 newDate() 代替 new Date() 因为服务器时区问题
var now = newDate()
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 init () {
Ids = JSON.parse(StrIds)
/* 回测系统下单量已经修改为股票股数
if (IsVirtual()) {
Amount = 1
}
*/
for (var i = 0 ; i < Ids.length ; i++) {
_Symbols[i] = {}
_Symbols[i].ContractTypeName = Ids[i]
_Symbols[i].LastBarTime = 0
_Symbols[i].State = STATE_IDLE
_Symbols[i].ChartIndex = i * 3
_Symbols[i].Status = ""
_Symbols[i].Pos = null
_Symbols[i].InsDetail = null
_Symbols[i].upLine = null
_Symbols[i].downLine = null
_Symbols[i].CurrPrice = null
_Symbols[i].ChartCfg = {
__isStock: true,
title: {
text: Ids[i]
},
series: [{
type: 'candlestick',
name: '当前周期',
id: 'primary',
data: []
}, {
type: 'line',
id: 'upLine',
name: 'upLine',
yAxis: 0,
data: []
}, {
type: 'line',
id: 'downLine',
name: 'downLine',
yAxis: 0,
data: []
}]
}
_ArrChart.push(_Symbols[i].ChartCfg)
}
_Chart = Chart(_ArrChart)
if (IsReset) {
_Chart.reset()
} else {
_Chart.reset(1000)
}
}
function Process (symbols) {
for (var i = 0 ; i < symbols.length ; i++) {
var contractTypeName = symbols[i].ContractTypeName
var insDetail = _C(exchange.SetContractType, contractTypeName)
symbols[i].InsDetail = insDetail
symbols[i].InstrumentName = insDetail.InstrumentName
// 判断是不是交易状态
if ((!insDetail.IsTrading && !IsVirtual()) || !IsTrading()) {
continue
}
Sleep(2000)
var r = exchange.GetRecords()
if (!r || r.length < bollPeriod) {
continue
}
Sleep(2000)
var ticker = exchange.GetTicker()
if (!ticker) {
continue
}
if (IsVirtual()) {
ticker.Info = {}
ticker.Info.LotSize = 1
ticker.Info.PriceSpread = 0.01
}
Sleep(2000)
var depth = exchange.GetDepth()
if (!depth || depth.Bids[0].Amount == 0 || depth.Asks[0].Amount == 0) {
// 标记涨跌停
symbols[i].Status = "涨跌停"
continue
}
symbols[i].Status = "正常交易"
// 检测持仓
Sleep(2000)
var pos = GetPosition(exchange, contractTypeName)
symbols[i].Pos = pos
var posAmount = pos ? pos.Amount : 0
// 同步持仓状态
if (symbols[i].State == STATE_IDLE && posAmount > 0) {
symbols[i].State = STATE_LONG
} else if (symbols[i].State == STATE_LONG && posAmount == 0) {
symbols[i].State = STATE_IDLE
}
var upLine = TA.BOLL(r, bollPeriod)[0]
var downLine = TA.BOLL(r, bollPeriod)[2]
// 更新行情数据
symbols[i].upLine = upLine[upLine.length - 1]
symbols[i].downLine = downLine[downLine.length - 1]
symbols[i].CurrPrice = r[r.length - 1].Close
var Bar = r[r.length - 1]
var index = symbols[i].ChartIndex
if (symbols[i].LastBarTime !== Bar.Time) {
if (symbols[i].LastBarTime > 0) {
var PreBar = r[r.length - 2]
var upLinePreBar = upLine[upLine.length - 2]
var downLinePreBar = downLine[downLine.length - 2]
_Chart.add(index, [PreBar.Time, PreBar.Open, PreBar.High, PreBar.Low, PreBar.Close], -1)
_Chart.add(index + 1, [PreBar.Time, upLinePreBar], -1)
_Chart.add(index + 2, [PreBar.Time, downLinePreBar], -1)
} else {
for (var j = r.length; j > 1; j--) {
var b = r[r.length - j]
_Chart.add(index, [b.Time, b.Open, b.High, b.Low, b.Close])
}
}
_Chart.add(index, [Bar.Time, Bar.Open, Bar.High, Bar.Low, Bar.Close])
_Chart.add(index + 1, [Bar.Time, upLine[upLine.length - 1]])
_Chart.add(index + 2, [Bar.Time, downLine[downLine.length - 1]])
_Chart.update(_ArrChart)
symbols[i].LastBarTime = Bar.Time
} else {
_Chart.add(index, [Bar.Time, Bar.Open, Bar.High, Bar.Low, Bar.Close], -1)
_Chart.add(index + 1, [Bar.Time, upLine[upLine.length - 1]], -1)
_Chart.add(index + 2, [Bar.Time, downLine[downLine.length - 1]], -1)
}
if(symbols[i].State == STATE_IDLE && symbols[i].CurrPrice > upLine[upLine.length - 2]) {
Log("品种:", contractTypeName, "交易信号: 上穿上轨", "建议操作:买入", "#FF0000", "@")
Buy(exchange, contractTypeName, Amount, ticker.Info)
symbols[i].State = STATE_LONG
} else if (symbols[i].State == STATE_LONG && pos && ticker.Info.LotSize <= pos.CanCoverAmount && symbols[i].CurrPrice < downLine[downLine.length - 2]) {
Log("品种:", contractTypeName, "交易信号: 下穿下轨", "建议操作:买入", "#FF0000", "@")
Sell(exchange, contractTypeName, Amount, ticker.Info)
symbols[i].State = STATE_IDLE
}
}
}
function main(){
if(IsReset) {
LogReset(1)
}
SetErrorFilter("market not ready|502:|503:|tcp|character|unexpected|network|timeout|WSARecv|Connect|GetAddr|no such|reset|http|received|EOF|reused|Futures_OP 2|Futures_OP 4")
exchange.SetPrecision(3, 0)
while(true){
var tbl = {
"type" : "table",
"title": "信息",
"cols": ["股票代码", "名称", "布林带上轨", "布林带下轨", "当前价格", "状态"],
"rows": [],
}
for(var i = 0 ; i < _Symbols.length; i++) {
tbl.rows.push([_Symbols[i].ContractTypeName, _Symbols[i].InsDetail ? _Symbols[i].InsDetail.InstrumentName : "--", _Symbols[i].upLine, _Symbols[i].downLine, _Symbols[i].CurrPrice, _Symbols[i].Status])
}
var tblPos = {
"type" : "table",
"title" : "持仓",
"cols" : ["名称", "价格", "数量", "盈亏", "类型", "冻结数量", "可平量"],
"rows" : [],
}
for (var j = 0 ; j < _Symbols.length; j++) {
if(_Symbols[j].Pos) {
tblPos.rows.push([_Symbols[j].Pos.ContractType, _Symbols[j].Pos.Price, _Symbols[j].Pos.Amount, _Symbols[j].Pos.Profit, _Symbols[j].Pos.Type, _Symbols[j].Pos.FrozenAmount, _Symbols[j].Pos.CanCoverAmount])
}
}
LogStatus(_D(), StatusMsg, "\n`" + JSON.stringify([tbl, tblPos]) + "`")
Process(_Symbols)
Sleep(1000)
}
}
这个策略在优宽平台是有源码的,我们可以直接点击进入这个策略,这样就进入了策略的回测页面。虽然策略逻辑很简单,但是从期货市场移植到股票市场,还是有很多差别的。其中有几个点需要和大家重点提示一下。
这个区别很重要,这一点就导致我们在设计策略时,需要在开仓、平仓检测时增加一些额外的判断。因为期货市场是T+0,所以开仓之后持仓数据中的FrozenAmount字段是0,仓位并不处于冻结状态,就是想平仓随时可以平仓。但是股票市场这点就有差别了,当天开仓后,持仓数据中的FrozenAmount字段是和开仓数量相同的数值,表示开仓后仓位数量全部冻结,当天不能平仓交易。平仓时需要额外注意的就是需要计算可平仓数量,因为有可能有部分持仓是冻结的(通常用持仓数据中的Amount减去FrozenAmount算出可平仓数量)。
和期货市场一样下单前要设置交易方向,不过方向只能设置开多,也就是调用exchange.SetDirection(“buy”)。因为股票市场是属于现货交易,并没有开空头仓位的概念。所以是不能调用exchange.SetDirection(“sell”)。所以原版商品期货策略中的做空相关的代码都可以剔除。
在GetTicker函数返回的数据中Info是券商接口的原始应答数据,其中LotSize字段就是当前品种(某只股票)的最小下单量。这个数值通常是100,如果下单数量不能被这个数值整除,下单会报错。下单价格精度同样也需要控制,和商品期货一样,股票信息中也有priceTick。在GetTicker函数返回的数据Info中PriceSpread字段即为价格一跳数据。
股票市场的涨跌停还是比较常见的,所以策略需要检测这种情况,尤其是程序化交易多只股票的时候,不能让一个涨跌停的股票,调用接口失败导致一直卡着。涨跌停时,深度接口GetDepth()返回的数据中第一档订单量为0,以此识别。
depth.Bids[0].Amount == 0表示跌停。
depth.Asks[0].Amount == 0表示涨停。
除了要检测涨跌停,还需要检测当前股票交易状态,是否停牌等等。这些信息可以通过GetTicker、SetContractType函数返回的数据中检测。
股票市场和期货市场交易时段有一定的差别,策略可以根据要交易的板块、市场,具体定制交易时间,让策略在非交易时段休眠。
和商品期货不同,股票限制接口访问频率更加严格,需要在每次访问接口后增加一定的间隔时间,并且间隔时间还需要设置挺大(几秒),否则会触发报错(超过限制频率)。所以在股票策略中需要注意使用_C接口,避免卡死。
我们回测运行一下,这里我们选择的时间段是去年的1月份到今年的7月,k线周期我们选择1天,然后更多参数底层k线也要选择1天。然后布林带周期我们选择月度周期,30。回测日志显示我们取得了将近7000元的收益,在最近A股如此惨淡的情况下,确实很难得,不过大家也不必太过纠结于本策略的收益,因此作为该策略是比较简单的,大家重点要了解的是怎样编写股票类的量化策略并运行它。
接着看到状态信息这里,显示我们订阅了贵州茅台,海尔智家,东风汽车,交通银行,和伊利股份的这五支股票,包括具体的状态信息都有显示;在持仓状态信息栏,显示了我们目前持有的股票名称,价格,数量,盈亏和可平的数量。
在策略图表里,动态化显示了这五只股票的k线走势和布林带上下轨的具体交叉情形。而在日志信息里,显示了开仓信号和具体交易的操作,我们看到7月12号,当价格下穿下轨,是平仓的信号,我们卖出交通银行的股票。以上策略的运行,状态栏的设计,和图表的展示我们在优宽的量化交易课程中都有涉及到,大家如果想从头开始学习的话,可以翻看我们前面的课程,也欢迎大家提出疑问。我们也会热心解答。
最后需要注意下,量化交易的准入门槛这么高,确实也是因为风险比较大,大家在学习,使用模拟盘尝试的时候,请勿直接应用于实盘,这个真实的市场我们都领教过,确实是很残酷的。所以,大家可以在优宽平台尽情地使用回测系统和模拟盘学习和探索,但是对接真实的市场,一定要谨慎谨慎再谨慎。
本节课,我们初步讲解了如何在优宽配置量化交易的模拟实盘,如果大家对股票的量化交易感兴趣的话,我们可以多多留言。我们也会根据大家的意见,推出更多的股票相关的量化课程。当然如果大家在运行代码的过程中出现问题的话,也欢迎提问,我们也会热心解答。