群益API行情串接(二)


像股票這樣的金融商品百百種,加上市場的變化性與多樣的市場規則,使得這類的分析總是相當困難。但相關數據取得與蒐集的管道也相當稀少,甚至難以整合與上手。國內幾家券商提供之API服務便顯得相當地重要。

前言

上一篇群益API行情串接(一)介紹了群益金鼎證券提供之API服務,包含先備知識,並且完成元件註冊與帳號連線。本篇將會繼續往下介紹如何實現相關報價功能。

因此,本篇將會真正進行報價功能的實作與使用,報價功能主要是一個事件,事件的相關方法我們可以透過Class去定義與實作出來,並且透過comtypes套件進行事件的註冊。

在開始實作之前,要提醒一下,相關的設定或變數、函式宣告與定義等部份,在這邊並不會重新提及,這是由於篇幅考量,所以這個系列基本上就是垂直往下連貫著進行。如果有在這個系列中發現有些變數或函式,甚至是名詞等等未定義,可以在先前的篇章中找到。


程式環境

程式語言: Python 3.6.8

作業系統: Windows 10 64位元

API版本: 2.13.16 x86版本

撰寫自己的報價功能

為了將事件的實際功能實作出來,我們首先可以定義一個Class:

class SKQuoteLibEvents:
def __init__(self):
self.__is_connect = False
self.__is_ready = False
self.__is_data_get = False
self.__Stock_list_dict = {key: [] for key in market_number_dict.keys()}
self.__Stock_dict = {}
self.__Stock_id = ''

在這個Class裡面,我們先使用__init__(self)初始化需要的變數,這邊的變數我們都使用private的形式,所以變數名稱開頭會是以兩個底線(__)作為命名。其中,定義了三個flag,以布林值為型態,分別為<self.__is_connect>代表是否已連線、<self.__is_ready>代表報價相關功能是否已準備好、<self.__is_data_get>代表資料是否已正常取得(正常取得也包含無資料回傳的訊息,且資料可以是連線的狀態所回傳的訊息)。

另外,還有三個變數,代表與股票等商品相關之資料儲存,<self.__Stock_id>用來代表每一次請求的商品代號、<self.__Stock_list_dict>用來儲存不同類型市場下的所有能取得之商品代號名稱、<self.__Stock_dict>用來儲存不同商品代號各自的所有能取得之交易訊息。

這邊的變數名稱是寫<…Stock…>,但其實也可以是涵蓋其他商品,各位可以依照自己的需求進行修改。反正,變數名稱能夠一定程度地被人辨識出其意義就行了。(如果使用<…Goods…>等等變數名稱,或許還可能與其他定義混淆,所以這邊選擇<...Stock...>)


如果需要存取以上這些private變數的話,雖然在python還是可以直接進行呼叫與修改,但為了保持習慣,還是寫幾個set()get()函式:

class SKQuoteLibEvents:
...

    """
set、get fuction.
"""
def set_is_ready(self, status: bool):
self.__is_ready = status

def set_is_data_get(self, status: bool):
self.__is_data_get = status

def set_Stock_id(self, id: str):
self.__Stock_id = id

def get_is_connect(self):
return self.__is_connect

def get_is_ready(self):
return self.__is_ready

def get_is_data_get(self):
return self.__is_data_get

def get_Stock_list_dict(self, key: int):
if key not in self.__Stock_list_dict:
return []
return self.__Stock_list_dict[key]

def get_Stock_dict(self, key: str):
if key not in self.__Stock_dict:
return []
return self.__Stock_dict[key]

我們並不需要針對每個變數都設置一個set()與get()函式,只需要考慮有需要在在物件外部存取的。像是<self.__is_connect>只需要設置get()函式,因為是否連線的狀態,應該是這個物件內部自己能夠管理與設置就行。

這裡我們設定了三個set()函式,以及五個get()函式。

就像是剛才所說的,<self.__Stock_id>用來代表每一次請求的商品代號,這邊的做法是會隨時用來將它來使物件改變狀態,代表對不同的商品進行請求,因此,設置一個set_Stock_id()函式。set_is_ready()以及set_is_data_get()也是同樣道理

get()函式方面,除了<self.__is_connect>變數之外,皆各自設置一個,方便進行確認。

其中,get_Stock_list_dict()get_Stock_dict()函式都有一個<key>傳入參數,代表這個查詢的動作,在前者是針對市場類別進行回傳,而後者是針對一個特定的商品代號進行查詢。


這邊為了顯示統一,所以另外寫一個物件的member function:

class SKQuoteLibEvents:
...

    def print(self, message: str):
print('>\t' + message)

接著,我們終於要來進入主題,要來決定要定義與使用的事件,詳細的功能與有哪些事件能夠去調用,可以參考群益API附帶的官方文件。

我們要使用到的ATL物件是SKQuoteLib,所以可以查看其擁有的相關可用函式。而首先當然是要進行連線,我們會需要去定義自己的OnConnection()函式。從這邊開始,要注意的是,自己定義的相關函式,其傳入參數必需按照群益API官方文件所提供的架構去寫,如此才能正確對應。

class SKQuoteLibEvents:
...

    """
define events.
"""
def OnConnection(self, nKind, nCode):
self.__is_data_get = True
self.__is_connect = False
str_msg = "Connect Error!"

if nCode == 0:
if nKind == 3001:
str_msg = "Connected!"
self.__is_connect = True
elif nKind == 3002:
str_msg = "DisConnected!"
elif nKind == 3003:
str_msg = "Stocks ready!"
self.__is_connect = True
self.__is_ready = True
elif nKind == 3021:
str_msg = "Connect Error!"

self.print(str_msg)

OnConnection()函式有兩個傳入參數,分別是<nKind>以及<nCode>,兩者都是一組代碼,前者用來代表連線狀態,後者用來代表連線的執行是否成功,成功的話會回傳0,否則代表有例外產生。

到時候這個連線事件若有觸發,會針對不同的狀態(由<nKind>以及<nCode>兩個傳入參數組合而成)進行調整。

這邊要特別注意的是,先前提到,<self.__is_data_get>代表資料是否已正常取得(正常取得也包含無資料回傳的訊息,且資料可以是連線的狀態所回傳的訊息),所以由於事件觸發了,代表有能取得正常資料的能力,我們將其設定為True。


再來,我們會需要取得目前不同市場類別底下各自的商品有哪些,會需要用到OnNotifyStockList事件:

class SKQuoteLibEvents:
...

    def OnNotifyStockList(self, sMarketNo: int, bstrStockData: str):

        """
    市場編號分別為:
    上市 0、上櫃 1、期貨 2、選擇權 3、興櫃 4
    各類股(水泥、食品、塑膠等)以「換行」做分隔。
    """

        self.__is_data_get = True
# [商品代碼],[商名名稱],[最後交易日];
if not bstrStockData.startswith('##'):
row_list = ['[OnNotifyStockList]']
row_list.extend(bstrStockData.split(';')[:-1])
self.__Stock_list_dict[sMarketNo].append(row_list)
else:
self.print("StockList Response end.")
self.__is_ready = True

OnNotifyStockList()函式需要包含兩個傳入參數,分別為<sMarketNo>以及<bstrStockData>,其實名稱可以自己命名,但要記得其原本意義為何。

<sMarketNo>代表市場類別的代號,這也對應到前一篇設置的<market_number_dict>變數:

market_number_dict = {
0: '上市',
1: '上櫃',
2: '期貨',
3: '選擇權',
4: '興櫃'
}

而<bstrStockData>會回傳所請求的市場類別下,所有商品代號,不同類股會分不同次回傳,每一個商品又各自以一組逗號(,)分隔之資料表示,並以分號(;)結尾([商品代碼],[商名名稱],[最後交易日];)。

這邊使用到了bstrStockData.split(';')進行分割,回傳是一個List型態的物件。這行程式處理完後,會如下圖這樣(我們等等會進行測試,這邊只是先進行輸出,先看看格式):

而由於每組商品字串,都會以分號(;)結尾,所以最後會多分割出一個空字串,我們使用Python Slice用法,bstrStockData.split(';')[:-1]忽略這個空字串就行。

而當所有資料結束回傳時,會有一筆以"##"開頭的字串作為結尾。當我們讀到這筆資料,代表這個步驟的資料取得正常結束,並準備完成,將<self.__is_ready>設定為True作為標記。


雖然還有一些功能尚未設定,但我們已經可以準備先跑跑看事件能不能正常運作了。

所以我們先在SKQuoteLibEvents物件告一段落,其他功能等測試完再加。

這邊我們要撰寫連接與離線報價伺服器的程式,為了方便管理,就以自定義函式呈現:

def EnterMonitor():
m_nCode = skQ.SKQuoteLib_EnterMonitor()
print("Quote enter monitor [{}]".format(skC.SKCenterLib_GetReturnCodeMessage(m_nCode)))

def LeaveMonitor():
m_nCode = skQ.SKQuoteLib_LeaveMonitor()
print("Quote leave monitor [{}]".format(skC.SKCenterLib_GetReturnCodeMessage(m_nCode)))

skQ是上一篇我們在進行環境初始化時,設定的一個報價物件,這邊是直接使用全域變數,就不另行以傳入參數的形式了。這個報價物件裡面有SKQuoteLib_EnterMonitor()以及SKQuoteLib_LeaveMonitor()函式,分別代表連線報價伺服器與斷線報價伺服器的功能,它們會各自回傳一個代碼,這個代碼可以透過skC物件(登入與環境管理用)中的SKCenterLib_GetReturnCodeMessage()函式,將這個代碼轉為文字訊息,這樣可以更直觀地在螢幕上做輸出。


然後,這邊有一個很重要的東西要去實作,前一篇我們有提到Event Loop以及Message Queue,然後我們有說會使用pywin32套件裡pythoncom模組包含的PumpWaitingMessages()將獲取到的訊息,從Message Queue裡面取出並利用。

所以現在就要將其實現,我們可以設想一下,假設這個報價功能正常執行後,每一次收到訊息都是好幾串,隨著時間一筆一筆回傳回來,這意味著,我們得不停地去從Message Queue中去接收訊息,直到回傳結束。

這個功能我們把它包裝為一個函式,以方便使用與呼叫:

def run_callback_sync(QuoteEvent: SKQuoteLibEvents):
start_time = time.time()

while not QuoteEvent.get_is_ready():
pythoncom.PumpWaitingMessages()

while not QuoteEvent.get_is_connect():
print('未成功連線...')
capital_Login()
EnterMonitor()
pythoncom.PumpWaitingMessages()
time.sleep(5)

if not QuoteEvent.get_is_data_get() and time.time() - start_time > 1.0:
break

QuoteEvent.set_is_ready(False)
QuoteEvent.set_is_data_get(False)

這個函式取名為run_callback_sync,會傳入一個實例化後SKQuoteLibEvents的物件,而由於在Python中將一個實例化的物件當作一個函式傳入參數,實際上是做別名(Aliasing)的動作,所以若在這個函式中對這個傳入參數物件<QuoteEvent>進行修改,對整個程式而言,被當作傳入參數的這個物件也會跟著改變狀態。(它們各自存取的記憶體位置相同)

這邊的流程大概是,如果資料尚未準備好(資料尚未全部取得完畢),就會一直執行pythoncom.PumpWaitingMessages()函式。

只是過程中我們會另外檢查是否有連線,若無連線會反覆重試,(不過目前來說,只有在一開始像是未連接網路導致的"未連線成功"有用而已)因為SKQuoteLibEvents物件中的<self.__is_connect>並不會馬上在自己在斷線時改變狀態,當斷線一定時間,OnConnection事件也會又觸發一次,回傳連線失敗的訊息。此外,就算是重新連線,也要經過商品下載與重新註冊商品等等動作,否則也是無法繼續往下做。無論如何,這邊就簡單判斷就好,反正遇到問題,或是先確保網路沒問題,再去執行一次就行了。(如果真的要自動化這個部分,其實也能透過相關的檢查連線狀態函式去確認,並且記錄已抓取的資料有哪些,這樣就只剩去抓取尚未成功抓取完成的資料就好)

這邊需要特別注意,事件每次回傳訊息都會存在Message Queue中,所以即便登入或連線,都要呼叫pythoncom.PumpWaitingMessages()函式,才能取得正常的數據。

另外,如果任何訊息都無法獲取,且超過一秒鐘,我們就會將迴圈停止,就當作沒有回傳訊息了。

這邊我們可以先來看看實際執行結果(等等會講到如何呼叫與使用已經實作好的功能),如下圖所示:

我們可以看到,如果一開始網路斷線導致登入未成功,報價伺服器也未成功連線,等到網路恢復,就能繼續往下正常執行。


我們最後來測試一下,到目前為止,所實現的功能運作起來會是甚麼樣子,首先會需要進行事件的註冊,我們可以透過comtypes.client.GetEvents()函式去進行綁定:

if __name__ == '__main__':
skC, skQ = capital_api_init(api_path)
capital_Login(account, password)

    # event registration.
SKQuoteEvent = SKQuoteLibEvents()
SKQuoteLibEventHandler = comtypes.client.GetEvents(skQ, SKQuoteEvent)

接著,是進行報價伺服器的連線,以及取得商品列表:

if __name__ == '__main__':
skC, skQ = capital_api_init(api_path)
capital_Login(account, password)

# event registration.
SKQuoteEvent = SKQuoteLibEvents()
SKQuoteLibEventHandler = comtypes.client.GetEvents(skQ, SKQuoteEvent)


EnterMonitor()

print('start...')
run_callback_sync(SKQuoteEvent)

# get Goods List.
for index in market_number_dict.keys():
print("RequestStockList: {}".format(index))
skQ.SKQuoteLib_RequestStockList(index)

run_callback_sync(SKQuoteEvent)

取得商品列表之後,我們可以透過skQ.SKQuoteLib_RequestTicks()函式去請求商品資訊,其會需要兩個傳入參數,第一個參數代表Page Number,基礎會有1~49 Page可以使用,不同的函式所訂立一個Page各自的索取量不同,如在skQ.SKQuoteLib_RequestStocks()中,一個Page可以有100檔的索取量;這邊則是一個Page只接受一檔。而若重複使用相同Page,內容會被新的索取內容所取代。第二個參數代表商品代號。

這邊要注意的是,目前我們尚未去設定從相關事件(像是歷史Tick回補等等)的接收函式,所以使用skQ.SKQuoteLib_RequestTicks()函式是沒有特別的功用,所以我們先註解掉:

if __name__ == '__main__':
...

    page_index = 1
page_size_index = 0
for index in market_number_dict.keys():
for row in SKQuoteEvent.get_Stock_list_dict(index):
print(row)
for item in row[1:]:
stock_info = item.split(',')

# 國內股價指數類商品:臺股期貨(TX)、小型臺指期貨(MTX)、電子期貨(TE)及臺指選擇權(TXO)
if index == 2:
if not (stock_info[0].startswith('TX') or stock_info[0].startswith('MTX') or
stock_info[0].startswith('TE')):
continue
if index == 3 and not stock_info[0].startswith('TXO'):
continue
if (index < 2 or index > 3) and len(stock_info[0]) > 5:
continue

SKQuoteEvent.set_Stock_id(stock_info[0])
# skQ.SKQuoteLib_RequestTicks(page_index, stock_info[0])
#
# run_callback_sync(SKQuoteEvent)

print("RequestTicks: {}\t{}\t{}".format(stock_info[0], market_number_dict[index], page_index))

if page_size_index >= 80:
page_size_index = 0
page_index += 1
else:
page_size_index += 1

由於不確定,其說明文件所表示的一個Page最大100檔,會不會與重複使用同一Page相衝突,比方說同一Page的內容覆蓋超過100次會發生什麼事等等。這邊的作法是,每抓取81檔就換新的一頁,因為有Page編號上限問題,除非欲抓取的資料超過81 * 49 = 3969檔,否則可以先這樣處理。(這邊有另外進行篩選資料,只是簡單做個處理,各位可以依照自己的需求進行篩選,或者調整篩選方法)

最後,進行離線的動作:

if __name__ == '__main__':
...

    LeaveMonitor()

SKQuoteLibEventHandler.disconnect()

以下附上實際執行結果:

每一次執行,預設會在執行檔同目錄下產生記錄檔,由於主要執行檔是Python,所以在執行的python.exe所在目錄底下,會生成一個CapitalLog的紀錄檔:




結論

雖然還有幾個相關事件尚未進行處理,但本篇基本上已經把大部分會需要知道的概念跟相關實作提及了,所以先進行一個總結。

本篇主要介紹到了如何註冊事件接收事件的訊息報價物件的部分函式功能以及報價伺服器的連線與離線等等。最後,我們也成功取得了事件回傳的訊息。因此,歷史Tick回補等等功能,將會更容易去實作,基本上只差要去理解那複雜的群益API的官方使用說明。


9 則留言:

  1. 群益api官方使用說明真的超複雜,謝謝大大,有第三篇嗎

    回覆刪除
    回覆
    1. 感謝您的回覆,由於目前比較忙,所以很多想法未能分享出來,如果回響更大,或是有機會會再更新。

      刪除
  2. 真的很感謝您,交會了我很多!!!
    小弟想問個問題就是上面有提到"skQ是上一篇我們在進行環境初始化時,設定的一個報價物件,這邊是直接使用全域變數,就不另行以傳入參數的形式了"
    我想要建立兩個報價物件該怎麼做呢?我試了很多方法都是不出來,還想請大大開示!! 謝謝

    回覆刪除
    回覆
    1. 理論是我已經登入第二條報價行情線了
      但是我的skQ.SKQuoteLib_RequestStock()就只會傳入第一條行情線
      想跟J大討教一下

      刪除
    2. 依照目前版本可以理解為一個人最多有兩條連線,而這個人可以開啟國內與海外期貨帳戶。
      在這樣的條件之下,可以有幾種搭配方式,就分兩大類吧。
      1. 兩條連線都是國內,或者都是海外
      2. 兩條連線分別是國內以及海外

      最簡單且直觀的方式,就是直接分開執行,執行的程序不同,連線出去,不會互相衝突,因此可以達成兩個執行的程式各自占用一條國內連線(也就是第一種分類,第一種分類可以通,第二種分類也能通)。

      若是你要單跑一支程式就能連兩條行情線,就得要建立另一個skQ2(舉例),同樣要有事件的註冊,監控連線等等,用來控制第二條聯線,並且應該是只能適用第二種分類,不然會互相衝突,認證不會過。

      刪除
    3. 感謝J大回復,小弟在努力試試看!!

      刪除
  3. 抱歉,手機怪怪的回覆到了3次⋯⋯

    回覆刪除
  4. 想請問J大 為何在取得商品列表後還要再執行 run_callback_sync 不是一直連線中嗎

    回覆刪除
    回覆
    1. Dear 石方,
      在定義run_callback_sync函式時,有提到我們需要自己去從系統中的Message Queue中去接收訊息,不僅僅是為了連線。

      刪除