市场就是江湖,买方和卖方永远在博弈,这也是交易的永恒主题。今天给大家分享的 Penny Jump 策略属于HFT高频策略之一,最初来源于银行间外汇市场,常用于主流货币对做市。
在高频交易中,主要分为两类策略。分别是买方策略和卖方策略,卖方策略通常都是做市策略,并且这两类策略互为对手。比如:以最快速度抹平市场的一切不合理现象的高频套利买方策略,仗着速度快主动出击,或者吃掉其它做市商的错价。
还有一种,通过分析历史数据或盘口订单规律,提前在不合理的价位埋伏挂单,并随着盘口价格快速变化挂撤单,这类策略常见于被动做市,一旦自己挂单被成交,并且有一定利润或者达到止盈止损条件后平仓了结。被动做市策略通常对速度的要求不是太苛刻,更多的需要策略逻辑和结构。
Penny Jump 翻译成中文就是微量加价的意思,其原理是跟踪盘口买价和卖价,然后不停的根据盘口价格加上或减去微量价格挂单,很明显这是一个被动成交的挂单策略,属于卖方做市策略的一种。它的业务模式和商业逻辑是在交易所挂限价单进行双边交易以提供流动性。
做市策略要求手中有一定量存货,然后同时在买方和卖方双边交易。这种策略的主要收入是交易所提供的做市返还,另外还有做市时低买高卖所赚取的价差。但是对于很多以做市为目的的高频交易商来说,赚取买卖价差虽然是件美好的事,但并不是绝对需要的盈利手段。
我们知道交易市场有许多散户,也有很多大户,比如:游资、公募基金、私募基金等等。散户的资金通常较少,盘口附件的订单就足够交易了,很随意就能买卖一只交易品种。但是大户想要买进或者卖出,就没有这么简单了。
假如一个大户想要买进500手原油,盘口附近根本就没有这么多订单,又不想以市价单买进,那样滑价成本就太大了,所以只能在市场中排队买入,此时市场里面所有的参与者都看到在盘口的买方,有个巨大的订单。
因为这种巨大的订单,在市场里面看起来笨手笨脚的,有时候我们戏称为“大象,elephant”。举个例子,本来市场的盘口数据应该是:卖价400.3,量50;买价400.1,量10。突然这个笨重的大象进场了,买单挂在了400.1的价位。此时盘口数据就变为:卖价400.3,量50;买价400.1,量510。
做交易的朋友都知道,如果一个价位有一个巨量的挂单,那么这个价位就会形成很强的支撑力。当然高频交易者也知道,所以他们会在买一价之上挂买单,那么盘口数据就变为:卖价400.3,量50;买价400.2,量1,400.1的订单变成了买二。那么如果价格上涨至400.3,高频交易者就会赚到0.1的利润。
即便价格没有上涨,在买二这个位置,还有一直“大象”在撑着,也可以很快的反手以400.1的价格卖给这只大象。这就是 Penny Jump 策略。它的策略逻辑就是这么简单,通过监控市场盘口订单状态,来揣测交易对手的意图,然后抢先他人一步建立有利的部位,最后在短时间内以微小的价差获利了结。对于这只“大象”来说,他因为在市场里面挂了一张巨量的买单,所以暴露了他的交易意图,自然就变成高频交易者猎杀的目标。
首先,观察盘口出现概率极低的交易机会,并根据交易逻辑做出相应策略。如果逻辑复杂,就需要利用现有的数学知识,尽可能的用模型描述不合理现象的本质,并且尽量减少拟合。此外还必须用可以见价成交、见量成交的回测引擎去验证。发明者量化目前是唯一支持这两种回测模式的平台。
什么是见价成交和见量成交?见价成交你可以理解为:你的挂单是400.1买入,只有卖单为400.1甚至更低的时候,你的挂单才能成交。它只计算盘口挂单价格数据,而不计算盘口挂单量数据,只符合交易所撮合规则中的价格优先。
见量成交是见价成交的升级版,它既符合价格优先,又符合时间优先,可以说这种撮合模式,与交易所的撮合模式一模一样。通过计算盘口挂单量,来判断当前挂单是否达到被动成交的条件实现见量成交,以此做到真正的模拟实盘环境。
另外,细心的朋友可能会发现 Penny Jump 策略需要市场交易机会的支持,即:盘口至少有两跳的价格空挡。正常情况下商品期货主力合约交易比较频繁,盘口买一与卖一只有一跳的价差,几乎没有下手机会。所以我们把精力放到交易不是太活跃的次主力合约上面,次主力合约偶尔会有两跳甚至三跳的机会出现。比如在MA次主力909合约的盘口就出现下面这种情况:
卖一为2225量551,买一2223量565,向下看几秒,出现这种情况后,几个tick推送后消失,这种情况,我们视为市场的自我纠正,我们要做的就是赶在市场主动纠正前,杀进去,这种逻辑看人工去盯盘是天方夜谈,因为商品期货盘口差价两跳的情况级少出现,三跳最安全,但三跳极少出现,导致交易频率太低,意义不大。
接下来,我们观察盘口之前卖一买一与现在两跳时买一卖一的区别,去填补盘口差价空隙,如果速度够快,就可以排在委托单的最前位置,做为Maker以最快的速度成交后反手卖出,持仓时间很短,有了这个交易逻辑,实现为策略以后,以MA909为例,实盘测试推荐易盛而非CTP接口,易盛仓位与资金变化是推送机制,非常适合高频。
理清交易逻辑后,我们就可以用代码去实现了,由于发明者量化平台的C++例子很少,这里就用C++写本策略,方便大家学习,品种还是商品期货。首先依次打开:fmz.com > 登录 > 控制中心 > 策略库 > 新建策略 > 点击左上角下拉菜单 > 选择C++,开始编写策略,注意看下面代码中的注释。
第1步:先把策略的框架搭建起来,在这个策略中定义了一个HFT类和一个main主函数。在main主函数里面的第1行是清除日志,这样做的目的是每次策略重启的时候,把之前运行的日志信息清除;第2行是过滤一些没有必要提示的错误信息,比如因网络延迟暂时过高,出现的一些提示,这样可以使日志只记录重要的信息,看起来更加整洁;第3行是打印“Init OK”信息,意思是说已经开始启动程序,当然你还可以改成别的,比如“印钞机已经开始启动”;第4行是根据HFT类来创建一个对象,并且对象的名字是hft;第5行程序进入了while无限循环模式,并且一直执行对象hft中的Loop方法,可见Loop方法一定是这个程序的核心逻辑。第6行又是一个打印信息,正常情况下,程序是不会执行到第6行的,如果程序执行到第6行,证明程序已经结束了。
接下来,我们来看下HFT类,这个类中共有5个方法。第1个方法是构造方法,这个不必多说;第2个方法是获取当前是星期几,用来判断是否为新的K线;第3个方法主要是取消所有未成交的订单,以及获取详细的持仓信息,因为在下单交易之前,肯定要先判断当前的持仓状态;第4个方法主要用来打印一些信息,对于这个策略来说,这个方法不是主要的;最主要的是第5个方法,这个方法主要负责处理交易逻辑和下单交易。
// 定义HFT类
class HFT {
public:
HFT() {
// 构造函数
}
int getTradingWeekDay() {
// 获取当前是星期几,用来判断是否为新的K线
}
State getState() {
// 获取订单数据
}
void stop() {
// 打印订单和持仓
}
bool Loop() {
// 策略逻辑和下单
}
};
// 主函数
void main() {
LogReset(); // 清除日志
SetErrorFilter("ready|timeout"); // 过滤错误信息
Log("Init OK"); // 打印日志
HFT hft; // 创建HFT对象
while (hft.Loop()); // 进入无线循环模式
Log("Exit"); // 程序退出,打印日志
}
那么我们来看下,这个HFT类中的每个方法都是怎么具体实现的,以及最核心的Loop方法究竟是怎么工作的。我们从上到下把每个方法的具体实现方式,一个一个过一遍,你就会发现原来高频策略这么简单。在讲HFT这个类之前,首先我们定义了几个全局变量,用于存储hft对象计算后的结果。它们分别是:存储订单状态、持仓状态、持多单方向、持空单方向、买价、买量、卖价、卖量。请看下面的代码:
// 定义全局枚举类型State
enum State {
STATE_NA, // 储存订单状态
STATE_IDLE, // 储存持仓状态
STATE_HOLD_LONG, // 储存持多单方向
STATE_HOLD_SHORT, // 储存持空单方向
};
// 定义全局浮点类型变量
typedef struct {
double bidPrice; // 储存买价
double bidAmount; // 储存买量
double askPrice; // 储存卖价
double askAmount; // 储存卖量
} Book;
有了以上全局变量,我们就可以把hft对象所计算的结果分别存储起来,方便程序后续调用。OK接着我们讲下HFT类中的每个方法的具体实现。首先第1个HFT方法是一个构造函数,它调用第2个getTradingWeekDay方法,并把结果过打印到日志中;第2个getTradingWeekDay方法是获取当前是星期几,用来判断是否为新的K线,实现起来也很简单,获取时间戳,计算出小时和周,最后返回星期数;第3个getState方法有点长,这里不再一行一行去解释,而是自上而下描述它的主要功能,具体可以在下面的策略中看注释,先获取了所有订单,返回的结果是一个普通数组,然后遍历这个数据,一个一个去取消订单,紧接着获取持仓数据,返回来的也是一个数组,然后遍历这个数组,获取详细的持仓信息,包括:方向、持仓量、昨仓还是今仓等等,最后返回结果;第4个stop方法是打印一些信息,这里不再赘述;代码如下:
public:
// 构造函数
HFT() {
_tradingDay = getTradingWeekDay();
Log("current trading weekday", _tradingDay);
}
// 获取当前是星期几,用来判断是否为新的K线
int getTradingWeekDay() {
int seconds = Unix() + 28800; // 获取时间戳
int hour = (seconds/3600)%24; // 小时
int weekDay = (seconds/(60*60*24))%7+4; // 星期
if (hour > 20) {
weekDay += 1;
}
return weekDay;
}
// 获取订单数据
State getState() {
auto orders = exchange.GetOrders(); // 获取所有订单
if (!orders.Valid || orders.size() == 2) { // 如果没有订单或者订单数据的长度等于2
return STATE_NA;
}
bool foundCover = false; // 临时变量,用来控制取消所有未成交的订单
// 遍历订单数组,取消所有未成交的订单
for (auto &order : orders) {
if (order.Id == _coverId) {
if ((order.Type == ORDER_TYPE_BUY && order.Price < _book.bidPrice - _toleratePrice) ||
(order.Type == ORDER_TYPE_SELL && order.Price > _book.askPrice + _toleratePrice)) {
exchange.CancelOrder(order.Id, "Cancel Cover Order"); // 根据订单ID取消订单
_countCancel++;
_countRetry++;
} else {
foundCover = true;
}
} else {
exchange.CancelOrder(order.Id); // 根据订单ID取消订单
_countCancel++;
}
}
if (foundCover) {
return STATE_NA;
}
// 获取持仓数据
auto positions = exchange.GetPosition(); // 获取持仓数据
if (!positions.Valid) { // 如果持仓数据为空
return STATE_NA;
}
// 遍历持仓数组,获取具体的持仓信息
for (auto &pos : positions) {
if (pos.ContractType == Symbol) {
_holdPrice = pos.Price;
_holdAmount = pos.Amount;
_holdType = pos.Type;
return pos.Type == PD_LONG || pos.Type == PD_LONG_YD ? STATE_HOLD_LONG : STATE_HOLD_SHORT;
}
}
return STATE_IDLE;
}
// 打印订单和持仓
void stop() {
Log(exchange.GetOrders()); // 打印订单
Log(exchange.GetPosition()); // 打印持仓
Log("Stop");
}
最后我们着重讲解Loop函数是怎么控制策略逻辑以及下单的,想看得更仔细的小伙伴可以参考代码中的注释。首先判断CTP交易和行情服务器是否连接;接着获取账户的可用余额以及获取星期数;接着设置要交易的品种代码,具体做法是调用发明者量化官方的SetContractType函数,并且可以利用这个函数返回品种的详细信息;然后调用GetDepth函数,获取当前市场的深度数据,深度数据包括:买价、买量、卖价、卖量等等,并且我们用变量把它们存储起来,因为待会还要用到;再然后把这些盘口数据输出到状态栏,方便用户观看当前的市场状态;代码如下:
// 策略逻辑和下单
bool Loop() {
if (exchange.IO("status") == 0) { // 如果CTP交易和行情服务器已经连接
LogStatus(_D(), "Server not connect ...."); // 打印信息到状态栏
Sleep(1000); // 休眠1秒
return true;
}
if (_initBalance == 0) {
_initBalance = _C(exchange.GetAccount).Balance; // 获取账户余额
}
auto day = getTradingWeekDay(); // 获取星期数
if (day != _tradingDay) {
_tradingDay = day;
_countCancel = 0;
}
// 设置期货合约类型,并获取合约具体信息
if (_ct.is_null()) {
Log(_D(), "subscribe", Symbol); // 打印日志
_ct = exchange.SetContractType(Symbol); // 设置期货合约类型
if (!_ct.is_null()) {
auto obj = _ct["Commodity"]["CommodityTickSize"];
int volumeMultiple = 1;
if (obj.is_null()) { // CTP
obj = _ct["PriceTick"];
volumeMultiple = _ct["VolumeMultiple"];
_exchangeId = _ct["ExchangeID"];
} else { // Esunny
volumeMultiple = _ct["Commodity"]["ContractSize"];
_exchangeId = _ct["Commodity"]["ExchangeNo"];
}
if (obj.is_null() || obj <= 0) {
Panic("PriceTick not found");
}
if (_priceTick < 1) {
exchange.SetPrecision(1, 0); // 设置价格与品种下单量的小数位精度, 设置后会自动截断
}
_priceTick = double(obj);
_toleratePrice = _priceTick * TolerateTick;
_ins = _ct["InstrumentID"];
Log(_ins, _exchangeId, "PriceTick:", _priceTick, "VolumeMultiple:", volumeMultiple); // 打印日志
}
Sleep(1000); // 休眠1秒
return true;
}
// 检查订单和头寸以设置状态
auto depth = exchange.GetDepth(); // 获取深度数据
if (!depth.Valid) { // 如果没有获取到深度数据
LogStatus(_D(), "Market not ready"); // 打印状态信息
Sleep(1000); // 休眠1秒
return true;
}
_countTick++;
_preBook = _book;
_book.bidPrice = depth.Bids[0].Price; // 买一价
_book.bidAmount = depth.Bids[0].Amount; // 买一量
_book.askPrice = depth.Asks[0].Price; // 卖一价
_book.askAmount = depth.Asks[0].Amount; // 卖一量
// 判断盘口数据赋值状态
if (_preBook.bidAmount == 0) {
return true;
}
auto st = getState(); // 获取订单数据
// 打印盘口数据到状态栏
LogStatus(_D(), _ins, "State:", st,
"Ask:", depth.Asks[0].Price, depth.Asks[0].Amount,
"Bid:", depth.Bids[0].Price, depth.Bids[0].Amount,
"Cancel:", _countCancel,
"Tick:", _countTick);
}
以上做了这么多铺垫,最后终于可以下单交易了,在交易之前,首先判断当前程序的持仓状态(空仓、多单、空单),这里用到了if…else if…else if,逻辑很简单如果空仓就开仓,如果有多单就按条件平多单,如果有空单,就按条件平空单。为了便于大家理解,这里讲分三段讲解,先讲开仓:
首先声明一个布尔值变量,记录开仓次数,用来控制平仓次数;然后获取当前账户信息,并记录盈利值,接着判断撤单状态,如果撤单超过设置的最大值,就在日志中打印相关信息;然后计算当前买价与卖价差的绝对值,来判断当前盘口买价与卖价之间是否有2跳以上的空间;紧接着获取买一价和卖一价,如果之前的买价大于当前的买价,并且当前的卖量小于买量,证明买一消失,就设置多单开仓价格和下单量;否则如果之前的卖价小于当前的卖价,并且当前买量小于卖量,证明价格卖一消失,就设置空单开仓价格和下单量;最后多单和空单同时进场。具体代码如下:
bool forceCover = _countRetry >= _retryMax; // 布尔值,用来控制平仓次数
if (st == STATE_IDLE) { // 如果无持仓
if (_holdAmount > 0) {
if (_countRetry > 0) {
_countLoss++; // 失败计数
} else {
_countWin++; // 成功技术
}
auto account = exchange.GetAccount(); // 获取账户信息
if (account.Valid) { // 如果获取到账户信息
LogProfit(_N(account.Balance+account.FrozenBalance-_initBalance, 2), "Win:", _countWin, "Loss:", _countLoss); // 记录盈利值
}
}
_countRetry = 0;
_holdAmount = 0;
// 判断撤单状态
if (_countCancel > _cancelMax) {
Log("Cancel Exceed", _countCancel); // 打印日志
return false;
}
bool canDo = false; // 临时变量
if (abs(_book.bidPrice - _book.askPrice) > _priceTick * 1) { // 如果当前盘口买价与卖价之间是否有2跳以上的空间
canDo = true;
}
if (!canDo) {
return true;
}
auto bidPrice = depth.Bids[0].Price; // 买一价
auto askPrice = depth.Asks[0].Price; // 卖一价
auto bidAmount = 1.0;
auto askAmount = 1.0;
if (_preBook.bidPrice > _book.bidPrice && _book.askAmount < _book.bidAmount) { // 如果之前的买价大于当前的买价,并且当前的卖量小于买量
bidPrice += _priceTick; // 设置开多单价格
bidAmount = 2; // 设置开多单量
} else if (_preBook.askPrice < _book.askPrice && _book.bidAmount < _book.askAmount) { // 如果之前的卖价小于当前的卖价,并且当前买量小于卖量
askPrice -= _priceTick; // 设置开空单价格
askAmount = 2; // 设置开空单量
} else {
return true;
}
Log(_book.bidPrice, _book.bidAmount, _book.askPrice, _book.askAmount); // 打印当前盘口数据
exchange.SetDirection("buy"); // 设置下单类型为多头
exchange.Buy(bidPrice, bidAmount); // 多头买入开仓
exchange.SetDirection("sell"); // 设置下单类型为空头
exchange.Sell(askPrice, askAmount); // 空头卖出开仓
}
接着我们讲如何平多单,首先根据当前的持仓状态,设置下单类型,及平昨仓或平今仓,然后获取卖一价,如果当前卖一价大于多单开仓价格,就设置平多单的价格。如果当前卖一价小于多单开仓价格,就重置平仓次数变量为真,接着平掉所有的多单,最后为了保险起见,再来判断平仓是否成功。代码如下:
else if (st == STATE_HOLD_LONG) { // 如果持多单
exchange.SetDirection((_holdType == PD_LONG && _exchangeId == "SHFE") ? "closebuy_today" : "closebuy"); // 设置下单类型,及平昨仓或平今仓
auto sellPrice = depth.Asks[0].Price; // 获取卖一价
if (sellPrice > _holdPrice) { // 如果当前卖一价大于多单开仓价格
Log(_holdPrice, "Hit #ff0000"); // 打印多单开仓价格
sellPrice = _holdPrice + ProfitTick; // 设置平多单价格
} else if (sellPrice < _holdPrice) { // 如果当前卖一价小于多单开仓价格
forceCover = true;
}
if (forceCover) {
Log("StopLoss");
}
_coverId = exchange.Sell(forceCover ? depth.Bids[0].Price : sellPrice, _holdAmount); // 平多单
if (!_coverId.Valid) {
return false;
}
}
最后我们来看下是如何平空单的,原理跟上面的平多单刚好相反,首先根据当前的持仓状态,设置下单类型,及平昨仓或平今仓,然后获取买一价,如果当前买一价小于空单开仓价格,就设置平空单的价格。如果当前买一价大于空单开仓价格,就重置平仓次数变量为真,接着平掉所有的空单,最后为了保险起见,再来判断平仓是否成功。
else if (st == STATE_HOLD_SHORT) { // 如果持空单
exchange.SetDirection((_holdType == PD_SHORT && _exchangeId == "SHFE") ? "closesell_today" : "closesell"); // 设置下单类型,及平昨仓或平今仓
auto buyPrice = depth.Bids[0].Price; // 获取买一价
if (buyPrice < _holdPrice) { // 如果当前买一价小于空单开仓价格
Log(_holdPrice, "Hit #ff0000"); // 打印日志
buyPrice = _holdPrice - ProfitTick; // 设置平空单价格
} else if (buyPrice > _holdPrice) { // 如果当前买一价大于空单开仓价格
forceCover = true;
}
if (forceCover) {
Log("StopLoss");
}
_coverId = exchange.Buy(forceCover ? depth.Asks[0].Price : buyPrice, _holdAmount); // 平空单
if (!_coverId.Valid) {
return false;
}
}
以上就是这个策略完整的解析,俗话说看十遍不如动手一遍,点击复制完整策略源代码,无需配置在线回测。
为满足对高频交易的好奇心,为了更明显的看到结果,此策略回测手续费设定为0,实现了一个简单的拼速度逻辑,想要覆盖手续费实现盈利,实盘需做更多优化,仅凭这个简单的逻辑很难致胜,要考虑更多操作,比如锁仓(降低平今手续费),利用定单薄流进行短期预测提高胜率,再加上交易所手续费返还,从而实现一个可持久盈利的策略,关于高频交易的书籍很多,希望大家多去思考,多去实盘,而不是只停留在原理上。
发明者量化是一个纯技术驱动的团队,为量化交易爱好者提供了一个高可用的回测机制,我们的回测机制是真实的模拟了一个交易所的存在,而不是简单的见价撮合,希望用户能够利用到平台的优点更好的去发挥自己的能力。