Python量化策略的算法性能提升指南
性能問題
Python在2016年里可以說是風靡國內量化投資圈,目前整個生態鏈已經初具規模:
- 交易:vn.py、easytrader、at_py
- 數據:tushare
- 回測:rqalpha
- 在線平臺:UQER、RiceQuant、JoinQuant
隨著用戶越來越多,Python語言的性能問題也就逐漸成為整個社區關注的重點,經常遇到新手問:Python寫的量化交易程序是不是很慢啊?
在他們心中,Python估計是這個樣子:
(即使作為破舊自行車,我也深表懷疑這輛能不能騎好吧)
網上關于如何提升Python程序性能的文章不少,但大多不成體系只是非常簡單的例子,總有點隔靴搔癢的感覺,和現實中應用的距離比較遠。
作者一看,填補市場空白(裝逼)的機會來了!!
在這篇文章里,將會通過實際的例子展示如何對一段量化策略常用的代碼實現百倍加速。
一段常用的代碼
接下來要用的例子相信幾乎所有做量化策略的人都寫過類似的代碼:對時間序列求算術移動平均值。
這里我們先初始化即將用到的數據:10萬個數據點(隨機整數),遍歷計算窗口為500的算術移動平均值,每種算法運行10次求平均耗時。
# 這個測試目標在于仿造一個類似于實盤中,不斷有新的數據推送過來,
# 然后需要計算移動平均線數值,這么一個比較常見的任務。
from __future__ import division
import time
import random
# 生成測試用的數據
data = []
data_length = 100000 # 總數據量
ma_length = 500 # 移動均線的窗口
test_times = 10 # 測試次數
for i in range(data_length):
data.append(random.randint(1, 100))
在每次測試中,我們都通過遍歷測試用數據的方式來模擬實盤中策略不斷收到新數據推送的情況(同樣適用于事件驅動的回測模式),將計算出的移動平均值不斷保存到一個列表list中作為最終結果返回。
測試用電腦的配置情況:Core i7-6700K 4.0G/16G/Windows 7。
第一步我們以最簡單、最原始的方式來計算移動平均值:
# 計算500期的移動均線,并將結果保存到一個列表里返回
def ma_basic(data, ma_length):
# 用于保存均線輸出結果的列表
ma = []
# 計算均線用的數據窗口
data_window = data[:ma_length]
# 測試用數據(去除了之前初始化用的部分)
test_data = data[ma_length:]
# 模擬實盤不斷收到新數據推送的情景,遍歷歷史數據計算均線
for new_tick in test_data:
# 移除最老的數據點并增加最新的數據點
data_window.pop(0)
data_window.append(new_tick)
# 遍歷求均線
sum_tick = 0
for tick in data_window:
sum_tick += tick
ma.append(sum_tick/ma_length)
# 返回數據
return ma
# 運行測試
start = time.time()
for i in range(test_times):
result = ma_basic(data, ma_length)
time_per_test = (time.time()-start)/test_times
time_per_point = time_per_test/(data_length - ma_length)
print u'單次耗時:%s秒' %time_per_test
print u'單個數據點耗時:%s微秒' %(time_per_point*1000000)
print u'最后10個移動平均值:', result[-10:]
單次耗時指的是遍歷完整個測試數據計算移動平均值所需的時間,單個數據點耗時指的是遍歷過程中每個數據點的平均計算耗時,最后10個移動平均值用于和后續的算法進行比對,保證計算結果的正確性。
ma_basic測試結果
- 單次耗時:1.15699999332秒
- 單個數據點耗時:11.6281406364微秒
大約10萬個數據點(說大約因為有500個用于初始化了),這個測試結果不能說很好但也還過得去。考慮到一個簡單的雙均線CTA策略(Double SMA Strategy),每個數據點來了后會進行兩次均線計算,通常均線窗口不會超過500,且比較兩根均線交叉情況的算法開銷更低,估計策略單純在信號計算方面的耗時會在30微秒以內,對于一個通常跑在1分鐘線甚至更高時間周期上的策略而言已經是綽綽有余。
有了起點,下面來試著一步步提升性能。
試試NumPy?
用Python做數值運算性能不夠的時候,很多人的第一反應就是上NumPy:之前的ma_basic里,比較慢的地方應該在每一個新的數據點加入到data_window中后遍歷求平均值的代碼,那么改用numpy.array數組來求和應該性能就會有所提升了吧?
# 改用numpy(首先是一種常見的錯誤用法)
import numpy as np
def ma_numpy_wrong(data, ma_length):
ma = []
data_window = data[:ma_length]
test_data = data[ma_length:]
for new_tick in test_data:
data_window.pop(0)
data_window.append(new_tick)
# 使用numpy求均線,注意這里本質上每次循環
# 都在創建一個新的numpy數組對象,開銷很大
data_array = np.array(data_window)
ma.append(data_array.mean())
return ma
ma_numpy_wrong測試結果
- 單次耗時:2.11879999638秒
- 單個數據點耗時:21.2944723254微秒
WTF?!用NumPy后居然反而速度降低了一半(耗時增加到了快2倍)!
這里的寫法是一個非常常見的NumPy錯誤用法,問題就出在:
data_array = np.array(data_window)
由于NumPy中的對象大多實現得比較復雜(提供了豐富的功能),所以其對象創建和銷毀的開銷都非常大。上面的這句代碼意味著在計算每一個新數據點時,都要創建一個新的array對象,并且僅使用一次后就會銷毀,使用array.mean方法求均值帶來的性能提升還比不上array對象創建和銷毀帶來的額外開銷。
正確的用法是把np.array作為data_window時間序列的容器,每計算一個新的數據點時,使用底層數據偏移來實現數據更新:
# numpy的正確用法
def ma_numpy_right(data, ma_length):
ma = []
# 用numpy數組來緩存計算窗口內的數據
data_window = np.array(data[:ma_length])
test_data = data[ma_length:]
for new_tick in test_data:
# 使用numpy數組的底層數據偏移來實現數據更新
data_window[0:ma_length-1] = data_window[1:ma_length]
data_window[-1] = new_tick
ma.append(data_window.mean())
return ma
ma_numpy_right測試結果
- 單次耗時:0.614300012589秒
- 單個數據點耗時:6.17386947325微秒
速度比ma_basic提高了大約2倍,看來NumPy也就這么回事了。
JIT神器:Numba
關心過Python性能的朋友應該都聽過PyPy的大名,通過重新設計的Python解釋器,PyPy內建的JIT技術號稱可以將Python程序的速度提高幾十倍(相比于CPython),可惜由于兼容性的問題并不適合于量化策略開發這一領域。
幸運的是,我們還有Anaconda公司推出的Numba。Numba允許用戶使用基于LLVM的JIT技術,對程序內想要提高性能的部分(函數)進行局部優化。同時Numba在設計理念上更加務實:可以直接在CPython中使用,和其他常用的Python模塊的兼容性良好,并且最爽的是使用方法傻瓜到了極點:
# 使用numba加速,ma_numba函數和ma_basic完全一樣
import numba
@numba.jit
def ma_numba(data, ma_length):
ma = []
data_window = data[:ma_length]
test_data = data[ma_length:]
for new_tick in test_data:
data_window.pop(0)
data_window.append(new_tick)
sum_tick = 0
for tick in data_window:
sum_tick += tick
ma.append(sum_tick/ma_length)
return ma
ma_numba測試結果
- 單次耗時:0.043700003624秒
- 單個數據點耗時:0.439196016321微秒
OMG!就加了一行@numba.jit,性能竟然提高了26倍!這估計是按照代碼修改行數算,性價比最高的優化方案了。
改寫算法
從編程哲學的角度來看,想提高計算機程序的速度,一個最基本的原則就是降低算法復雜度。看到這里估計早就有量化老手ma_basic不爽了,弄個復雜度O(N)的算法來算平均值,就不能緩存下求和的結果,把復雜度降低到O(1)么?
# 將均線計算改寫為高速算法
def ma_online(data, ma_length):
ma = []
data_window = data[:ma_length]
test_data = data[ma_length:]
# 緩存的窗口內數據求和結果
sum_buffer = 0
for new_tick in test_data:
old_tick = data_window.pop(0)
data_window.append(new_tick)
# 如果緩存結果為空,則先通過遍歷求第一次結果
if not sum_buffer:
sum_tick = 0
for tick in data_window:
sum_tick += tick
ma.append(sum_tick/ma_length)
# 將求和結果緩存下來
sum_buffer = sum_tick
else:
# 這里的算法將計算復雜度從O(n)降低到了O(1)
sum_buffer = sum_buffer - old_tick + new_tick
ma.append(sum_buffer/ma_length)
return ma
ma_online測試結果
- 單次耗時:0.0348000049591秒
- 單個數據點耗時:0.349748793559微秒
哲學果然才是最強大的力量!!!
(索羅斯:其實我是個哲學家。)
改寫算法后的ma_online無需JIT就超越了ma_numba,將性能提高到了33倍(對比ma_basic),如果再把numba加上會如何?
# 高速算法和numba結合,ma_online_numba函數和ma_online完全一樣
@numba.jit
def ma_online_numba(data, ma_length):
ma = []
data_window = data[:ma_length]
test_data = data[ma_length:]
sum_buffer = 0
for new_tick in test_data:
old_tick = data_window.pop(0)
data_window.append(new_tick)
if not sum_buffer:
sum_tick = 0
for tick in data_window:
sum_tick += tick
ma.append(sum_tick/ma_length)
sum_buffer = sum_tick
else:
sum_buffer = sum_buffer - old_tick + new_tick
ma.append(sum_buffer/ma_length)
return ma
ma_online_numba測試結果
- 單次耗時:0.0290000200272秒
- 單個數據點耗時:0.29145748771微秒
盡管性能進一步提升了到了40倍,不過相比較于ma_numba對比ma_basic的提升沒有那么明顯,果然哲學的力量還是太強大了。
終極武器:Cython
到目前為止使用純Python環境下的優化方法我們已經接近了極限,想要再進一步就得發揮Python膠水語言的特性了:使用其他擴展語言。由于CPython虛擬機的開發語言是C,因此在性能提升方面的擴展語言主要選擇就是C/C++,相關的工具包括ctypes、cffi、Swig、Boost.Python等,盡管功能十分強大,不過以上工具都無一例外的需要用戶擁有C/C++語言相關的編程能力,對于很多Python用戶而言是個比較麻煩的事。
好在Python社區對于偷懶的追求是永無止境的,Cython這一終極武器應運而生。
先來試試最簡單的方法:完全不修改任何代碼,只是把函數放到.pyx文件里,調用Cython編譯成.pyd擴展模塊。
# 基礎的cython加速
def ma_cython(data, ma_length):
ma = []
data_window = data[:ma_length]
test_data = data[ma_length:]
for new_tick in test_data:
data_window.pop(0)
data_window.append(new_tick)
sum_tick = 0
for tick in data_window:
sum_tick += tick
ma.append(sum_tick/ma_length)
return ma
ma_cython測試結果
- 單次耗時:0.600800013542秒
- 單個數據點耗時:6.03819109088微秒
ma_cython和ma_basic的代碼完全相同,簡單使用Cython編譯后性能提高了大約1倍,不過這和之前我們已經達成的優化效果比可以說是毫無吸引力。
Cython官方的Quick Start里,第一步是教會用戶如何去編譯程序,第二步就是如何使用靜態聲明來大幅提高性能,所以我們的下一步就是:靜態聲明+高速算法。
# cython和高速算法
def ma_cython_online(data, ma_length):
# 靜態聲明變量
cdef int sum_buffer, sum_tick, old_tick, new_tick
ma = []
data_window = data[:ma_length]
test_data = data[ma_length:]
sum_buffer = 0
for new_tick in test_data:
old_tick = data_window.pop(0)
data_window.append(new_tick)
if not sum_buffer:
sum_tick = 0
for tick in data_window:
sum_tick += tick
ma.append(sum_tick/ma_length)
sum_buffer = sum_tick
else:
sum_buffer = sum_buffer - old_tick + new_tick
ma.append(sum_buffer/ma_length)
return ma
ma_cython_online測試結果
- 單次耗時:0.00980000495911秒
- 單個數據點耗時:0.0984925121518微秒
117倍!!!比ma_online_numba的速度還提高了接近3倍,98納秒的計算速度已經足以滿足大部分毫秒級別高頻策略的延時需求。
主要的功臣是這行:
cdef int sum_buffer, sum_tick, old_tick, new_tick
把函數中用到的變量靜態聲明成int類型后,Cython在編譯時無需再考慮Python對象的動態性特點,可以把整個函數高度優化成類似靜態語言的實現,從而達到了接近C語言的運行性能,再加上復雜度O(1)的高速算法,有這個級別的性能提升也就不足為奇了。
附上簡單的Cython使用指南:
- 把要使用Cython編譯的函數放到.pyx文件中,比如test.pyx
- 創建setup.py用于設置相關編譯選項
- 打開cmd或者terminal進入到test.pyx和setup.py所在的文件夾
- 運行 python setup.py build_ext --inplace ,執行編譯
- 若編譯成功則在當前文件夾下會出現test.pyd
- 打開python像使用其他模塊一樣載入(import)test.pyd使用
寫在最后
感謝Numba、Cython和哲學的強大力量,作者最終裝逼成功,從自己挖的“百倍加速”這個坑里爬了出來,不用當標題黨了。
最終的算法性能對比圖:
最后做一些總結吧:
- 現實工作中遇到需要優化Python程序性能時,首先要做的就是去尋找程序里延時較大的熱點代碼,找到了問題所在,解決方案才有意義;
- 所有的優化工作都應該基于測試來一步步推進,同樣的優化方法對于不同類型的代碼效果可能是截然相反的,同時錯誤的優化方法還不如不要優化(比如ma_numpy_wrong);
- 只需增加一句代碼(@numba.jit)就能實現加速的Numba無疑是性價比最高的優化方案,值得優先嘗試,不過需要注意numba的JIT技術局限性比較大(主要針對數值計算相關的邏輯);
- 學習如何降低算法復雜度和編寫更高效的算法,可以在潛移默化中提高自己的編程水平,在長期而言是對Quant或者程序員最有價值的優化方法;
- 如果其他優化方法都無法達到令你滿意的性能水平,試試Cython(記得一定要加靜態聲明);
- 一個好的程序架構設計非常重要,把功能不同的計算邏輯分解到不同的函數里,適當降低每個函數的代碼行數,會有助于后期的性能優化工作。
來自:https://zhuanlan.zhihu.com/p/24168485