最近認真用 python 的 pandas 函式庫讀取股票每日收盤資料, 結論是: 嗯, 還是用陽春 python 處理比較簡單快速 -- 執行的時間跟程式除錯的時間都比較省。 本篇是跟 pandas 的分手宣言。 閱讀本文之前, 請先按照 我上個月的 pandas 開箱文 操作一遍。 (好短的戀情啊~~)
請下載本文的主角, 我的範例程式 sana.py。 也請用 pip3 安裝它用到的一堆套件, 例如 pandas, numpy, tulipy, cProfile 等等。 另外請按照 sana.py 裡面的 datapath 的設定, 建立一個空目錄 ~/stock/day , 再把 sana-daily-data.tgz 在這裡解壓縮。 這是去年 10 月到目前為止的台股所有個股每日收盤價等等資料。
進入 python3 之後, 執行 exec(open("sana.py").read())
然後查看 twstocks
變數的值、
查看一下 twstocks['2330'].name
, 確認一下程式執行成功。
一、 把兩個維度擠在 index 裡面
股票一日收盤資料是二維的表格, 很適合放在一張試算表裡: 每列是一檔股票; 每行則有代號、 名稱、 成交量、 ... 收盤價等等共八欄。 可是很多天的資料疊起來, 就變成三維的資料。 讀取的時候每個檔案是一天; 分析的時候卻希望按照每檔股票做 「剖面/橫切面」, 得到 「每頁是一檔股票、 每列代表一天」 的很多張試算表。 這正是採用 多層索引 multiindex 最自然的時機。
所謂多層索引, 可以這樣想: 如果我要用陽春的 python 來存放三維的資料, 但是又不准我用多維陣列, 那麼我可能會用一個 dict, 它的每個 key 是一個長度 3 的 tuple, 類似這樣:
twstocks = {} twstocks[('2330','181001','收盤價')] = 263 twstocks[('2330','181002','收盤價')] = 257.5 ...
既然 pandas 的 dataframe 本身就長得像是二維陣列, 我們的 key 就不需要是 3-tuple, 用 2-tuple (股票代號, 日期) 就可以了; 成交量、 ... 收盤價等等欄位這個維度則拿來當作 columns。 以 pandas 的術語來說, 這個 multiindex 有兩個 levels。
二、 讀取 csv 檔
先試一下這一句:
oneday = pd.read_csv(datapath+'/181001.csv',
index_col=0, dtype={'代號':str, '名稱':str})
意思是: 讀入 2018 年 10 月 1 日那天的 csv 檔,
建立一個 dataframe, 以最左邊那一欄作為 index,
其中欄位名稱 「代號」 跟 「名稱」 這兩欄是字串;
其他欄位預設會被當成是數字。
現在請看 sana.py 裡我自己定義的 read_csv() 函數。
我偶爾會在某一些 csv 檔裡面用 # 放註解列。
呼叫 pandas 的 read_csv() 時, 只要傳 comment='#'
就可以處理這種狀況。
另外, 我也常在產生 csv 檔時多印一些空白字元, 降低壓迫感。
這時必須指定 skipinitialspace=True
要求 pandas 的 read_csv 忽略逗點後面的多餘空格。
我的 read_csv() 可能不只用來讀每日價格,
未來還會拿來讀其他各種 csv 檔, 例如某檔股票的每月營收。
為了讓呼叫者可以用欄位名稱 (而不是固定用第 0 欄)
來指定要由哪一欄當 index, 所以在讀檔時先用 index_col=False
指定不要 index, 之後才用 set_index() 依名稱指定 index。
對 index 排序, 是為了後續使用方便。
例如月營收檔的 index 可能是月份,
所以 index 必須先排序, 之後才可以用
df.loc[begin:end]
的語法截取起迄年月之間的所有資料。
注意: set_index() 跟 sort_index() (還有其他很多函數也是)
預設並不會更動原本的 dataframe, 而是會傳回一個新的 dataframe。
指定 inplace=True
叫它不要囉嗦原地解決就好, 這可以結省一些時間。
有時在某些 csv 檔裡, 該是數值的欄位,
可能出現文字 (例如某些冷門股當天沒有任何交易)。
為了要讓後續的處理順暢, 應該把所有的文字都變成 NaN。
根據
這個問答, 可以用 df[colnames] =
df[colnames].apply(pd.to_numeric, errors='coerce')
來達成。
三、 按照每檔股票切片資料, 慢版
再來很簡單啊, 每讀入一天的資料, 就把它切成一橫條一橫條, 每一橫條是一檔股票, 分別疊到各檔股票各自的 (按日堆疊) 表格去。 自己手動做一次就知道我的意思:
s2330 = stock('2330', {'名稱':'台積電', '收盤價':222.5}) oneday = read_csv(datapath+'/181001.csv') s2330.byday = pd.DataFrame(columns=oneday.columns) s2330.byday = s2330.byday.append(oneday.loc['2330']) oneday = read_csv(datapath+'/181002.csv') s2330.byday = s2330.byday.append(oneday.loc['2330'])
先在 s2330 這個股票物件裡建一個 .byday 屬性,
按照 oneday 的欄位建一個空的 dataframe。
然後把 2018/10/01 跟 2018/10/02 兩天的台積電數據填進去。
其中 oneday.loc['2330']
也可寫成 oneday['2330':'2330']
。
後面這種語法一定要有起迄 index,
通常較適用於 index 已排序的情況, 例如一段年齡層區間或
(某種排名) 第幾名到第幾名的資料。
一個好處是可以截取一段區間 (好幾橫列);
另一個好處是當你不確定這個 (key) 值是否出現在 index 之中時,
用後面這種語法可能會傳回一列或零列的資料,
程式都可以正常運作而不會出現 exception。
下面的實驗馬上會看到。
現在請看 sana.py 裡的 slow_read_daily() 函數。
把上面那段手動的指令改一下, 就變成了函數中雙層迴圈的最內層。
如果為了想要省略那句 if not sid in table: continue
因而把內層迴圈的 for sid in all_sids:
改成
for sid in daily_data.index:
,
那麼執行時會出現 KeyError , 而且還看不出是哪一列出錯的!
這時若再把 daily_data.loc[sid]
改成
daily_data[sid:sid]
就不會有這個錯誤了。
在 slow_read_daily() 的最後面, 一列一列剪貼完之後, 再選取 「日期」 欄作為每一檔個股的 index, 並且把 「名稱」 欄刪掉。
我的主程式呼叫的其實是快版的 fast_read_daily() (下詳),
因為慢版的... 慢到令人受不了:
請把 cProfile.runctx(...)
那一句裡的 fast_read_daily()
改成 slow_read_daily()
,
重新再 exec(open("sana.py").read())
一次。
超級久, 有沒有? 我的電腦要六七分鐘!
「用 cProfile 測量效能瓶頸」 這是從
gtwang 大大的文章 學來的, 再按照
這個問答 小修改過。
剛剛已在 datapath 目錄底下建立了一個 techan.prof 效能分析二進位檔。
回到命令列下, 大致照著 gtwang 的文章做,
先在 bash 底下用 sudo apt install graphviz
跟 pip3 install gprof2dot
安裝所需套件,
再用 cd ~/stock/day ; python3 -m gprof2dot
-f pstats techan.prof | dot -T svg -o slow.svg
產生 slow.svg 最後從瀏覽器裡按 ctrl-o 打開壯觀的時間分析圖。
暖色系 (紅、黃) 表示耗時較多的函數呼叫。
可以看到呼叫 (data)frame 的 append 就像是走爛泥巴路一樣,耗掉了最多的時間。
(我猜主要是因為
每 append 一次整個 dataframe 就必須複製一次。)
雖然這個版本超級慢, 但讓我學了不少東西, 很值得記錄下來。
四、 快版: 很多片起司同時切條
現在請把 slow_read_daily()
改回
fast_read_daily()
, 重跑一次,
在我的電腦上, 只花了不到三秒的時間!
回到 bash, 重新產生另一張圖 fast.svg ,
可以看到耗時第一名的函數變成了 xs() (下詳);
原先在 slow.svg 裡面 (相對來說, 小到) 被忽略掉的 read_csv()
現在變得明顯可見, 位居第二名。 怎麼差那麼多?
想像有很多起司片要切條重新整理。 你會一片一片切條嗎? 當然不會! 你會把 N 片起司疊整齊 (抱歉, 找不到更整齊的圖片) 然後每一刀切下去就同時切出 N 條, 這樣不是快多了嗎?
請看 fast_read_daily() 函數。 讀了 這個討論串 之後 (尤其是 "performant" 那一段), 發現大家都偏好使用 concat, 而且一口氣做, 效率會比較高。 所以我們先讀入所有的資料, 不加工直接放進一個陽春 dict 裡。 (或是陽春陣列也可以) 然後 只呼叫一次 concat 把陽春 dict 裡所有的 datafranes 通通串起來。 這一步就相當於把所有的起司片疊整齊。 因為原來資料的 index 是 「代號」; 希望產生的新資料則將以 「日期」 作為 index, 所以呼叫 concat() 時, 傳入 names=['日期','代號'], 指定以這兩欄 (兩個維度) 合併作為 multiindex 的兩個 levels。 「使用 concat 時順便建立 mutliindex」 從這篇自問自答學到最多: pandas.concat: The Missing Manual。
把沒用的 「名稱」 欄位丟掉之後, 最後的重點就是呼叫 dataframe 的 xs() 函數 -- 它的名字是 cross-section (橫切面) 的簡寫 -- 這一步就相當於一刀切下去。 傳入 level='代號' 意指刀子切的方向是沿著股票代號這個維度, 每檔股票的所有資料變成一個 dataframe。 當然, 這些新的 datafranes 的 index 就只有單純的一個維度 (一個 level), 而不再是複雜的 multi-index。 不過呢, 有些個股沒有每日交易資訊, 所以遇到這種切片時, pandas 會跳出 KeyError 的 exception。 Pandas xs 函數的主要開發者建議此時應該 用 reindex 把原始資料的所有 index 組合預先擴張完整。 我跟提 issue 的原 po 想法一樣: 如果它可以傳回一個空的 dataframe 不是也很合理而且更加方便嗎? 總之, 最後我的解決方式是: 先檢查這檔股票到底有沒有每日交易資訊, 如果有, 就做橫切面; 如果沒有, 就比照第一檔股票 (1101 台泥, 一定有資料) 的欄位建立一個空的 dataframe。
五、 Tulip 技術指標函式庫
Tulip Indicator 函式庫
提供了數十種 K 線圖技術分析指標函數。
Tulipy 是它的 python 語言轉接頭。
在 ubuntu 底下, 要先安裝 cython3 套件, 才可以 pip3 install tulipy
。
要呼叫 tulipy 的函數之前, 必須先把每日收盤價等等資料補齊 -- 比方說, 會不會有某些檔股票在某些日子裡完全沒出現在 csv 檔當中? 例如最近剛上市或最近剛下市的股票? 或是某幾天暫停交易的股票? 所以在 need_days() 裡面, 我們呼叫 reindex() 函數, 傳進 「(等一下需要用到資料的) 所有日期」, 以便把 dataframe 擴張補齊、 填好填滿。 當然補進去的數值都是 NaN -- 來自 numpy 的常數, 表示 not-a-number。
補齊之後, 程式至少可以執行, 不會出現 exception。 可是運算式裡凡有一個值是 NaN, 很可能結果就是 NaN, 於是等一下會衍生出一整片 NaN ... 所以在 rid_nan() 裡面, 我們呼叫 interpolate() 函數 (內插法補值) 勉強找個大約可能有意義的值取代每個 NaN。 它預設採用 method='linear' 也就是線性內插。 對別的技術指標有沒有意義我也不知道; 不過反正我目前只會看移動平均 (sma), 這樣的內插法應該... 還算 ok 吧? 內插法必須靠左右兩邊的數值來猜測中間的數值。 那如果最左邊或最右邊有一小段全都是 NaN 呢? 沒問題, 那就再抓最接近的非 NaN 來充場面吧。 從 這個問答 學到用 next 的寫法搜尋 「第一筆/最後一筆非 NaN」。 把兩頭都照顧好了, 才真的呼叫 interpolate()。
六、 心得
這兩年學習 python 是為了跟上時代; 其實 perl 語言才是我長年以來的最愛。
寫 python 的時候, 一直很懷念 perl 程式的精簡。
例如未設定初始值的變數總是很自動地被當成 0 或空字串或空陣列。
又例如 if sid in ok_sids ...
那四句, 如果改用 perl, 只需要一句話。
本來以為 pandas 函式庫犧牲陽春陣列與陽春 dict 的效率, 可以換來類似 perl 的簡潔有力程式碼。 撰寫這個程式的過程當中, 才發現不僅執行速度慢、 程式碼並沒有變得比較簡潔, 而且還要花很多力氣爬文學習複雜的 API。 甚至連陽春 python 的優點 -- 照著 pythonic 的精神與原則寫程式, 可讀性會很高 -- 都被掩蓋掉了。 例如 存取一段資料 這件最常做的事, 就有 很多寫法, 效果各不相同。
以後啊, 還是避開 pandas, 盡量採用 python 的陽春資料結構比較實在。 等到其他函式庫堅持要讀 pandas 的資料結構時, 再臨時轉格式給它。
不過這趟旅程並沒有白走, 至少我很認真地學過一點 pandas, 有一點資格可以評論它, 而且也產出了這篇文章, 相信對 pandas 初學者會有很大的幫助。
沒有留言:
張貼留言
因為垃圾留言太多,現在改為審核後才發佈,請耐心等候一兩天。