摘要
市場就是江湖,買方和賣方永遠在博弈,這也是交易的永恆主題。今天給大家分享的 Penny Jump 策略屬於HFT高頻策略之一,最初來源於銀行間外匯市場,常用於主流貨幣對做市。
高頻策略分類
在高頻交易中,主要分為兩類策略。分別是買方策略和賣方策略,賣方策略通常都是做市策略,並且這兩類策略互為對手。比如:以最快速度抹平市場的一切不合理現象的高頻套利買方策略,仗着速度快主動出擊,或者吃掉其它做市商的錯價。
還有一種,通過分析歷史數據或盤口訂單規律,提前在不合理的價位埋伏掛單,並隨着盤口價格快速變化掛撤單,這類策略常見於被動做市,一旦自己掛單被成交,並且有一定利潤或者達到止盈止損條件后平倉了結。被動做市策略通常對速度的要求不是太苛刻,更多的需要策略邏輯和結構。
什么是 Penny Jump 策略
Penny Jump 翻譯成中文就是微量加價的意思,其原理是跟蹤盤口買價和賣價,然后不停的根據盤口價格加上或減去微量價格掛單,很明顯這是一個被動成交的掛單策略,屬於賣方做市策略的一種。它的業務模式和商業邏輯是在交易所掛限價單進行雙邊交易以提供流動性。
做市策略要求手中有一定量存貨,然后同時在買方和賣方雙邊交易。這種策略的主要收入是交易所提供的做市返還,另外還有做市時低買高賣所賺取的價差。但是對於很多以做市為目的的高頻交易商來說,賺取買賣價差雖然是件美好的事,但並不是絕對需要的盈利手段。
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 策略。它的策略邏輯就是這么簡單,通過監控市場盤口訂單狀態,來揣測交易對手的意圖,然后搶先他人一步建立有利的部位,最后在短時間內以微小的價差獲利了結。對於這只“大象”來說,他因為在市場里面掛了一張巨量的買單,所以暴露了他的交易意圖,自然就變成高頻交易者獵殺的目標。
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,實現了一個簡單的拼速度邏輯,想要覆蓋手續費實現盈利,實盤需做更多優化,僅憑這個簡單的邏輯很難致勝,要考慮更多操作,比如鎖倉(降低平今手續費),利用定單薄流進行短期預測提高勝率,再加上交易所手續費返還,從而實現一個可持久盈利的策略,關於高頻交易的書籍很多,希望大家多去思考,多去實盤,而不是只停留在原理上。
關於我們
發明者量化是一個純技術驅動的團隊,為量化交易愛好者提供了一個高可用的回測機制,我們的回測機制是真實的模擬了一個交易所的存在,而不是簡單的見價撮合,希望用戶能夠利用到平台的優點更好的去發揮自己的能力。