snapshot 不能拿來輪詢、連線數只有五條所以一定要單例。時間戳要用 UTC 解、漲跌幅要自己算、昨收要 round。斷線之後它會安靜地降級繼續跑。
發布於 2026-09-06・約 5 分鐘
我把永豐金的 Shioaji 接進自己的選股程式當即時報價來源,原本用的證交所 MIS 保留為備援。實測 1.7.4 版,記一下文件上找不到的東西。
2026-09-01 查官方文件(不是估的):
行情查詢 50 次 / 10 秒 每日流量 500 MB(帳號當日無 API 成交時) 訂閱檔數 200 檔 / 連線 連線數 5 條 / 帳號 登入次數 1000 次 / 日
兩條最關鍵:
永豐明文禁止拿 snapshot 當即時來源反覆輪詢。 超過會停權,先停一分鐘,累犯封 IP 加帳號。所以你不能把原本 MIS 那種「每 4 秒抓一次」的寫法直接搬過來。
連線數只有 5 條。 這條決定了整個程式的結構:絕對不能讓各個模組各自 new 一個 client。開發時反覆重啟幾次就用光了。
new
我最後的分工是混合模式:
client 一定要走單例:
_client = None def get_quote_client(): global _client if _client is None: _client = _login() return _client def reset(): global _client if _client is not None: _client.logout() _client = None
介面刻意做成跟原本的 MIS client 一模一樣,這樣呼叫端不用改,切換只靠一個設定值(mis / shioaji / auto)。
mis
shioaji
auto
一、ts 要用 UTC 解讀。
ts
永豐塞進去的是「把台北時間當成 UTC 編碼」的值。用本地時區去解會得到 22:30 這種台股不存在的時間。
from datetime import datetime, timezone # ❌ 得到 22:30 dt = datetime.fromtimestamp(snap.ts / 1e9) # ✅ 得到 14:30 dt = datetime.fromtimestamp(snap.ts / 1e9, tz=timezone.utc)
「台股不存在的時間」是很好的偵測訊號。任何時間欄位進來,先確認它落在 09:00–13:30(加上盤後定價到 14:30)之間,落在外面就是解讀方式錯了。
二、漲跌幅要自己算,不要用 change_rate。
change_rate
永豐是無條件捨去,MIS 是四捨五入。同一檔會差 0.01。
差 0.01 聽起來沒什麼,但「漲幅 3% 以上」這種邊界判斷會莫名其妙翻掉——2.995% 在一邊是 2.99(不過),在另一邊是 3.00(過)。而我所有的回測基準都是用 MIS 的算法建立的,換一套算法等於整組門檻要重驗。
change_pct = round((close - prev_close) / prev_close * 100, 2)
三、昨收要 round。
round
>>> 25.3 - 0.9 24.400000000000002
浮點數的經典問題。這個值拿去跟 MIS 比對永遠不相等,寫測試會一直紅。
prev_close = round(close - change_price, 2)
# 文件寫的 實際要這樣 api.login(..., fetch_contract=True) api.login(...); api.fetch_contracts() api.quote.subscribe(...) api.subscribe(...) # 舊的已 deprecated sj.constant.QuoteType.Tick sj.QuoteType.Tick # 舊的已 deprecated
14:30 之後查,永豐的 total_volume 會比 MIS 多,因為它含 13:40–14:30 的盤後定價交易。實測台積電差 138 張。
total_volume
盤中兩者一致。所以如果你在寫「跟舊資料源比對」的測試,時間點要挑盤中,不然會得到一個看起來像 bug 的正常差異。
_ensure_login() 我原本寫成這樣:
_ensure_login()
def _ensure_login(self): if self._logged_in: # ❌ return self._login()
Solace session 中途斷掉之後,物件狀態還是 True,於是它永遠不會重登。tick 推播和 snapshot 雙雙失效,而且不會恢復。
True
2026-09-03 實測那天:10:27 斷線,last_tick_age_sec 一路累積到 11816 秒,live_ratio_pct 從 98.9% 掉到 31.9%,而且退回去的那 95,857 次 snapshot 正在燒額度。13:35 的收盤日報 31 檔全部印出前一次的價格。
last_tick_age_sec
live_ratio_pct
整個過程沒有任何錯誤訊息。 程式一直在跑,數字一直有值,只是那些值是舊的。
診斷時我還被自己的測法騙了一次:另開一個行程測 MIS client,31/31 全中,於是判斷「資料源沒問題」。但排程用的根本不是 MIS,是 Shioaji。測錯對象了。
同樣的教訓還有一條:單例是行程內的,另開行程去問狀態永遠回 None,而且真的初始化還會多佔一條連線。查健康度只能打服務自己的端點:
None
curl localhost:8001/quote-health
不要 docker exec python -c "..."。
docker exec python -c "..."
現在的判準是三個,缺一不可:
{ "source": "shioaji", # 現在用的是誰 "fallback_reason": None, # 有沒有降級,為什麼 "subscribed": 189, # 訂閱了幾檔 "last_tick_age_sec": 0.0, # 最後一筆推播多久以前 ← 關鍵 "live_ratio_pct": 98.9, # 讀快取 vs 退回 snapshot 的比例 }
last_tick_age_sec 是最有用的一個。盤中它超過 60 秒就是出事了,不管別的欄位多正常。
另外兩個連帶的坑:
關機沒登出會靜默降級。 lifespan 收尾時原本只停排程,沒呼叫 reset(),等於每次重啟都留一條連線不登出。5 條用完之後,client 會自動退回免費報價繼續跑、不報錯,只有 fallback_reason 看得出來。開發時反覆重啟特別容易踩。
reset()
fallback_reason
改設定要重建容器,不是 restart。 環境變數在建容器那一刻就固定了,docker restart 重啟的是行程不是容器,改完 .env 值還是舊的。改程式碼才是 restart(Python 已載入的模組不會自己更新)。
docker restart
.env
標籤:Shioaji、永豐金、台股、即時報價、Python、API