前陣子在點部落整理一些黃金交易程式的開發心得,剛好碰到一個很實際的效能問題:黃金即時 API 的回應其實不慢,但回測和指標計算的過程中,程式常常需要重複讀取同一段歷史 K 線,頁面刷新或策略運行時就會出現卡頓。我統計了一下,單次拉取一年份的黃金 1 分鐘 K 線約需 3.6 秒,同一段資料在一次回測中重複請求了 38 次,光等待重複取數就多花了超過兩分鐘。
重複查詢為什麼會拖慢行情系統
做黃金行情相關程式時,歷史 K 線算是被存取最頻繁的資料類型之一。計算均線、布林通道、回測訊號,常常要連續讀取好幾天甚至好幾個月的 K 線。如果每次計算都重新向黃金即時 API 要資料,真正拖慢速度的通常不是指標公式,而是網路來回、資料解析與等待回應。
歷史行情有一個很大的特性:變動極少。昨天的黃金 1 分鐘 K 線今天幾乎不會改變,這類資料其實沒有必要反覆請求。
所以我後來把資料讀取邏輯改成兩段式:先檢查本機是否已經有需要的資料,如果沒有或不完整,才回頭向 API 補齊缺口。
怎麼設計歷史 K 線的快取層
實作上,我會依照資料變化頻率分成兩種快取方式。
- 即時報價、最新 tick 這種變化很快的資料,適合放在記憶體裡面,只保留最近一小段時間,讀取速度會比較快。
- 歷史 K 線則適合持久化保存。我會把已經抓下來的資料寫入本機檔案或資料庫,下次程式啟動時直接載入,不用重新下載。
快取每筆歷史 K 線時,我會同時把對應的 metadata 一起存下來:
| 欄位 | 作用 |
|---|---|
| symbol | 區分不同交易品種 |
| 週期 | 判斷 K 線級別 |
| 開始和結束時間 | 比對查詢範圍 |
| OHLC 資料 | 用於指標計算與回測 |
這樣查詢時可以先比對 metadata。如果只缺某幾個小時的資料,就只補那一段,不用把完整區間重新抓一遍。
即時行情如何與快取串起來
很多行情程式在設計上會把即時資料與歷史資料分開處理,但實際運作時,兩段最好能接起來。我的做法是讓即時行情先進到快取,同時依照時間週期組出最新 K 線;等一個週期結束後,再把完整 K 線存下來。這樣歷史資料與最新行情之間就不會出現明顯斷層。
以 AllTick API 的即時行情流為例,我會透過 websocket 接收 tick,並把最新價格暫存在記憶體快取中:
import websocket
import json
from datetime import datetime
market_cache = {}
def on_message(ws, message):
data = json.loads(message)
symbol = data.get("symbol")
price = data.get("price")
timestamp = data.get("timestamp")
market_cache[symbol] = {
"price": price,
"timestamp": timestamp,
"update_time": datetime.now()
}
print(symbol, price)
ws = websocket.WebSocketApp(
"wss://api.alltick.co/ws",
on_message=on_message
)
ws.run_forever()
即時價格進來之後,後面的 K 線計算或行情顯示就可以直接讀快取,不用再等 API 回應。這樣的做法除了減少重複請求,也能把資料流程收斂到同一個入口。
快取維護時容易忽略的細節
快取做好之後,並不代表可以放著不管。黃金市場交易時間長,行情更新頻率也高,如果即時資料在記憶體裡放太久,顯示出來的價格可能會延遲。所以即時資料與歷史資料的快取週期要分開設定。
另一個很容易被忽略的是更新方式。假設程式執行中發現某段歷史 K 線有缺口,比較好的做法是只補缺少的那一段,而不是把整個時間範圍重新請求一次。對於長期運行的行情系統來說,這種差異會愈來愈明顯。
如果系統規模變大,需要多個策略同時讀取行情,我會考慮把原本的記憶體快取升級成 Redis,讓不同任務可以存取同一份資料,避免重複載入。
優化後的整體變化
這次調整之後,我對行情系統的理解也有了一些轉變。以前總覺得影響效率的關鍵是 API 速度,後來才發現資料怎麼流動也一樣重要。即時行情負責提供變化,快取負責減少重複存取,兩者搭配起來,回測、指標計算與行情顯示才會穩定。
對於需要頻繁查詢歷史 K 線的程式來說,快取不是可有可無的小優化,而是整個資料處理流程中相當重要的一環。提早把快取邏輯設計好,之後面對更多品種、更大資料量時,系統也比較容易擴展。這篇筆記就記錄在點部落,給同樣在玩黃金交易程式的朋友參考。
