0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【立花証券e支店】リアルタイム株価取得(WebSocket Client)

Posted at

はじめに

システムでデイトレするためには株価の変化をリアルタイムで欲しいですよね?

そんな時に使えるのが 立花証券e支店 APIEVENT I/F です。

EVENT I/F にはHTTP Protocol版とWebSocket Protocol版がありますが、ここではWebSocket版の話です。

今は eスマート証券(旧カブコム証券) の kabuSTATION API を使ったプログラムを運用してますが、ちょっと立花証券e支店 API は、どんなものかと思って開発中です。
で、立花証券e支店 API の情報が少ないと思ったので投稿してみます。
一応、公式サンプルとかはあるのですが…

ちなみに、立花証券e支店APIは先日(2025-09-27) v4r8がリリースされて POST対応 されました。
これでやっと違和感なく使える感じになりましたね。

WebSocketApp

ここで紹介するのは websocket-client ライブラリの WebSocketApp を使用したものです。
WebSocketAppは非常にシンプルなI/Fで気に入ってます。
非同期I/O asyncio には対応していないのですが、今回は使わない予定です。

というのも、前述のいま運用しているプログラムをGeminiさんに非同期I/O化して貰ったのですが、逆に遅くなってしまいました。
自分のデイトレプログラムはリアルタイムで株価を受け取ってOHLCや複数の移動平均値を計算してるのですが、asyncioが苦手な「CPUバウンドな処理」になるようです。

ということで、事前にライブラリをインストールしておきましょう。

$ uv add websocket-client
$ uv add requests
又は
$ pip install websocket-client
$ pip install requests

※uv を使いだすと、もう便利で戻れませんね。

WebSocket Client サンプル

必要最低限でエラー処理などしていません。

te_websocket_client.py
from urllib.parse import urlencode

import websocket


class TeWebSocketClient:
    def __init__(self, ws_url: str):
        self.ws_url = ws_url
        self.ws = None
        self._is_connected = False

    def _on_message(self, ws, message):
        print(f"Received: {message}")

    def _on_error(self, ws, error):
        print(f"Error: {error}")

    def _on_close(self, ws, close_status_code, close_msg):
        print("### closed ###")
        self._is_connected = False

    def _on_open(self, ws):
        print("Opened connection")
        self._is_connected = True

    def connect(self):
        # EVENT I/F へのパラメーター
        payload = {}
        payload["p_rid"] = "22"
        payload["p_board_no"] = "1000"
        payload["p_eno"] = "0"
        payload["p_evt_cmd"] = "ST,KP,FD,EC,NS,SS,US"
        # 以下、本来は動的に生成
        payload["p_issue_code"] = "7201,7202,7203"
        payload["p_gyou_no"] = "1,2,3"
        payload["p_mkt_code"] = "00,00,00"

        # EVENT I/F はPOST対応していない(v4r8時点)
        # パラメータ付URL(カンマ(,)はエンコードしない)
        full_url = f"{self.ws_url}?{urlencode(payload, safe=',')}"

        self.ws = websocket.WebSocketApp(
            full_url,
            on_open=self._on_open,
            on_message=self._on_message,
            on_error=self._on_error,
            on_close=self._on_close,
        )
        # Run in a separate thread to not block the main thread
        import threading

        self.ws_thread = threading.Thread(target=self.ws.run_forever)
        self.ws_thread.daemon = (
            True  # Allow the main program to exit even if the thread is still running
        )
        self.ws_thread.start()

        # Wait a bit for the connection to establish
        import time

        time.sleep(1)
        return self._is_connected

    def disconnect(self):
        if self.ws:
            self.ws.close()
            self.ws = None
            self._is_connected = False

    def is_connected(self):
        return self._is_connected

    def join(self):
        if self.ws_thread:
            self.ws_thread.join()

    def send_message(self, message: str):
        if self._is_connected and self.ws:
            self.ws.send(message)
        else:
            print("WebSocket is not connected.")
            return False
        return True

パラメーターに渡す ws_url はログインすると発行される仮想URLの一つなので、先にログインする必要があります。

ログイン用のクラスがこちら!

te_api_client.py
import threading
import typing
from datetime import datetime

import requests


class TeApiClient:
    def __init__(self):
        self.session = requests.Session()

        self._lock = threading.Lock()
        self.request_counter: int = 0

        # ログイン情報
        self.session_info: dict = {}

    def _get_next_p_no(self) -> str:
        """
        APIリクエストごとにインクリメントされるp_no(リクエスト番号)を生成します。
        """
        self.request_counter += 1
        return str(self.request_counter)

    def _get_current_datetime(self) -> str:
        """
        p_sd_date用の日時文字列(YYYY.MM.DD-HH:MI:SS.ms)を生成します。
        """
        # APIの仕様に合わせ、マイクロ秒を示す末尾3桁を削除しミリ秒までの精度にします。
        return datetime.now().strftime("%Y.%m.%d-%H:%M:%S.%f")[:-3]

    def _call_api(
        self,
        url: str,
        payload: dict,
        method: str = "POST",
        headers: dict[str, str] = {},
    ) -> dict:
        """
        APIサーバーへリクエストを送信し、結果を取得する共通メソッド。

        APIをコールし、取得したJSONの辞書オブジェクトを返します。
        このメソッド内で、リクエスト共通のパラメータが付与されます。

        Args:
            url (str): エンドポイントURL。
            payload (dict): 送信パラメータ。
            method (str): "GET" または "POST"。
            headers (dict[str, str]): HTTPヘッダー。

        Raises:
            requests.exceptions.RequestException: APIサーバーへ接続出来なかった場合。
            Exception: その他の例外発生時。

        Returns:
            dict: APIから取得したJSONを辞書へ変換したオブジェクト。
        """
        dict_result: dict = {}
        with self._lock:
            try:
                # 共通パラメーターの付与
                payload["p_no"] = self._get_next_p_no()
                payload["p_sd_date"] = self._get_current_datetime()
                payload["sJsonOfmt"] = "4"

                response = self.session.request(
                    method=method,
                    url=url,
                    headers=headers,
                    json=payload,
                    timeout=20,
                )

                response.raise_for_status()
                response.encoding = "cp932"
                if "application/json" in response.headers.get("Content-Type", ""):
                    dict_result = response.json()
                    return dict_result
                else:
                    print(response.text)
                    return {"p_errno": "9999", "p_err": "No JSON"}

            except requests.exceptions.RequestException as exp:
                print(exp)
                if exp.response is not None:
                    print(
                        f"CODE: {exp.response.status_code}, REASON: {exp.response.reason}"
                    )
                raise exp

        return dict_result

    def get_info(self, key: str, default: typing.Any = None) -> typing.Any:
        """
        ログイン時に取得したセッション情報から、指定したキーの値を取得します。
        """
        return self.session_info.get(key, default)

    def login(self, url: str, user_id: str, password: str) -> bool:
        payload = {
            "sCLMID": "CLMAuthLoginRequest",
            "sUserId": user_id,
            "sPassword": password,
        }

        results = self._call_api(url, payload)
        print(results)

        self.session_info = results

        if self.get_info("sUrlRequest") is None:
            print("契約締結前書面が未読です。")
            return False

        return True

    def logout(self) -> bool:
        payload = {
            "sCLMID": "CLMAuthLogoutRequest",
        }
        url = self.get_info("sUrlRequest")
        results = self._call_api(url, payload)
        print(results)

        self.session_info = {}
        return True

メインモジュールは、こちら!

main.py
import time

from te_api_client import TeApiClient
from te_websocket_client import TeWebSocketClient


def main():
    # 立花証券e支店のデモ環境
    url = "https://demo-kabuka.e-shiten.jp/e_api_v4r8/auth/"

    # 自分のID/PW
    user_id = "XXXX"
    password = "XXXX"

    api_client = TeApiClient()

    # ログイン
    result = api_client.login(url, user_id, password)
    if result is False:
        print("ログイン失敗")
        return

    # EVENT I/F WebSocket版のURL
    ws_url = api_client.get_info("sUrlEventWebSocket")
    print(ws_url)

    ws_client = TeWebSocketClient(ws_url)

    # WebSocket接続(別スレッドが起動する)
    ws_client.connect()

    while ws_client.is_connected():
        try:
            time.sleep(1)
        except KeyboardInterrupt:
            # Ctrl+C で終了
            print("Interrupted")
            break

    ws_client.disconnect()
    ws_client.join()

    # ログアウト
    api_client.logout()


if __name__ == "__main__":
    main()

実行すると以下のような出力が得られます。

(.venv) PS > python main.py
{'p_sd_date': '2025.10.23-23:26:20.602', 'p_no': '1', 'p_rv_date': '2025.10.23-23:26:20.409', 'p_errno': '0', 'p_err': '', 'sCLMID': 'CLMAuthLoginAck', 'sResultCode': '0', 'sResultText': '', 'sZyoutoekiKazeiC': '1', 'sSecondPasswordOmit': '0', 'sLastLoginDate': '20251023230250', 'sSogoKouzaKubun': '1', 'sHogoAdukariKouzaKubun': '1', 'sFurikaeKouzaKubun': '1', 'sGaikokuKouzaKubun': '1', 'sMRFKouzaKubun': '0', 'sTokuteiKouzaKubunGenbutu': '1', 'sTokuteiKouzaKubunSinyou': '1', 'sTokuteiKouzaKubunTousin': '0', 'sTokuteiHaitouKouzaKubun': '1', 'sTokuteiKanriKouzaKubun': '0', 'sSinyouKouzaKubun': '1', 'sSakopKouzaKubun': '0', 'sMMFKouzaKubun': '0', 'sTyukokufKouzaKubun': '0', 'sKawaseKouzaKubun': '0', 'sHikazeiKouzaKubun': '1', 'sKinsyouhouMidokuFlg': '0', 'sUrlRequest': 'https://demo-kabuka.e-shiten.jp/e_api_v4r8/request/NDA5MjAyNjIzMjMxMC0xMTYtNTM5MzA=/', 'sUrlMaster': 'https://demo-kabuka.e-shiten.jp/e_api_v4r8/master/NDA5MjAyNjIzMjMxMC0xMTYtNTM5MzA=/', 'sUrlPrice': 'https://demo-kabuka.e-shiten.jp/e_api_v4r8/price/NDA5MjAyNjIzMjMxMC0xMTYtNTM5MzA=/', 'sUrlEvent': 'https://demo-kabuka.e-shiten.jp/e_api_v4r8/event/NDA5MjAyNjIzMjMxMC0xMTYtNTM5MzA=/', 'sUrlEventWebSocket': 'wss://demo-kabuka.e-shiten.jp/e_api_v4r8/event_ws/NDA5MjAyNjIzMjMxMC0xMTYtNTM5MzA=/', 'sUpdateInformWebDocument': '20241103', 'sUpdateInformAPISpecFunction': '20241103'}
wss://demo-kabuka.e-shiten.jp/e_api_v4r8/event_ws/NDA5MjAyNjIzMjMxMC0xMTYtNTM5MzA=/
Opened connection
Received: p_no1p_date2025.10.23-23:26:20.693p_cmdKP
Received: p_no2p_date2025.10.23-23:26:20.696p_cmdFDp_1_AV94100p_1_BV100p_1_DHF0000p_1_DHP377.7p_1_DHP:T14:33p_1_DJ6635432420p_1_DLF0000p_1_DLP370.8p_1_DLP:T10:00p_1_DOP373.8p_1_DOP:T09:00p_1_DPG0057p_1_DPP377.6p_1_DPP:T15:30p_1_DV17673900p_1_DYRP-0.02p_1_DYWP-0.1p_1_GAP1377.6p_1_GAP10378.5p_1_GAP2377.7p_1_GAP3377.8p_1_GAP4377.9p_1_GAP5378.0p_1_GAP6378.1p_1_GAP7378.2p_1_GAP8378.3p_1_GAP9378.4p_1_GAV194100p_1_GAV1015700p_1_GAV211200p_1_GAV37300p_1_GAV412200p_1_GAV591700p_1_GAV632100p_1_GAV71300p_1_GAV8100p_1_GAV910400p_1_GBP1377.4p_1_GBP10376.2p_1_GBP2377.2p_1_GBP3377.0p_1_GBP4376.9p_1_GBP5376.8p_1_GBP6376.6p_1_GBP7376.5p_1_GBP8376.4p_1_GBP9376.3p_1_GBV1100p_1_GBV10100p_1_GBV2400p_1_GBV37000p_1_GBV4600p_1_GBV5300p_1_GBV62000p_1_GBV76200p_1_GBV856500p_1_GBV910000p_1_LISSCCDFD7B2D1p_1_PRP377.7p_1_QAP377.6p_1_QAS0101p_1_QBP377.4p_1_QBS0101p_1_QOV7279500p_1_QUV5788800p_1_VWAP375.4368p_2_AV200p_2_BV500p_2_DHF0000p_2_DHP1939.0p_2_DHP:T14:15p_2_DJ3731461100p_2_DLF0000p_2_DLP1915.5p_2_DLP:T09:42p_2_DOP1928.0p_2_DOP:T09:00p_2_DPG0058p_2_DPP1931.0p_2_DPP:T15:30p_2_DV1932500p_2_DYRP-0.02p_2_DYWP-0.5p_2_GAP11936.0p_2_GAP101940.5p_2_GAP21936.5p_2_GAP31937.0p_2_GAP41937.5p_2_GAP51938.0p_2_GAP61938.5p_2_GAP71939.0p_2_GAP81939.5p_2_GAP91940.0p_2_GAV1200p_2_GAV10400p_2_GAV2100p_2_GAV3700p_2_GAV41100p_2_GAV5200p_2_GAV63000p_2_GAV73200p_2_GAV813600p_2_GAV918100p_2_GBP11930.5p_2_GBP101926.0p_2_GBP21930.0p_2_GBP31929.5p_2_GBP41929.0p_2_GBP51928.5p_2_GBP61928.0p_2_GBP71927.5p_2_GBP81927.0p_2_GBP91926.5p_2_GBV1500p_2_GBV10400p_2_GBV214400p_2_GBV3400p_2_GBV47600p_2_GBV5400p_2_GBV6500p_2_GBV7400p_2_GBV89200p_2_GBV9400p_2_LISSCCDFD7B2D1p_2_PRP1931.5p_2_QAP1936.0p_2_QAS0101p_2_QBP1930.5p_2_QBS0101p_2_QOV693000p_2_QUV462200p_2_VWAP1930.8984p_3_AV19200p_3_BV1400p_3_DHF0000p_3_DHP3112p_3_DHP:T09:00p_3_DJ55764856000p_3_DLF0000p_3_DLP3057p_3_DLP:T09:39p_3_DOP3100p_3_DOP:T09:00p_3_DPG0057p_3_DPP3091p_3_DPP:T15:30p_3_DV18085400p_3_DYRP-0.41p_3_DYWP-13p_3_GAP13092p_3_GAP103101p_3_GAP23093p_3_GAP33094p_3_GAP43095p_3_GAP53096p_3_GAP63097p_3_GAP73098p_3_GAP83099p_3_GAP93100p_3_GAV119200p_3_GAV1010300p_3_GAV265100p_3_GAV310100p_3_GAV452200p_3_GAV5101600p_3_GAV637000p_3_GAV7115700p_3_GAV821700p_3_GAV9333700p_3_GBP13090p_3_GBP103081p_3_GBP23089p_3_GBP33088p_3_GBP43087p_3_GBP53086p_3_GBP63085p_3_GBP73084p_3_GBP83083p_3_GBP93082p_3_GBV11400p_3_GBV1047500p_3_GBV2900p_3_GBV36100p_3_GBV4200p_3_GBV52300p_3_GBV672900p_3_GBV76900p_3_GBV843500p_3_GBV927600p_3_LISSCCDFD7B2D1p_3_PRP3104p_3_QAP3092p_3_QAS0101p_3_QBP3090p_3_QBS0101p_3_QOV6718500p_3_QUV3647100p_3_VWAP3083.4184
Received: p_no3p_date2025.10.23-23:26:20.812p_cmdSSp_PVMSGSVp_ENO44p_ALT0p_CT20251023081012p_LK1p_SS1
Received: p_no4p_date2025.10.23-23:26:20.812p_cmdUSp_PVMSGSVp_ENO56p_ALT0p_CT20251023084015p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US100
Received: p_no5p_date2025.10.23-23:26:20.812p_cmdUSp_PVMSGSVp_ENO158p_ALT0p_CT20251023111012p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US140
Received: p_no6p_date2025.10.23-23:26:20.812p_cmdUSp_PVMSGSVp_ENO167p_ALT0p_CT20251023111212p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US160
Received: p_no7p_date2025.10.23-23:26:20.921p_cmdUSp_PVMSGSVp_ENO180p_ALT0p_CT20251023114512p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US200
Received: p_no8p_date2025.10.23-23:26:20.921p_cmdUSp_PVMSGSVp_ENO272p_ALT0p_CT20251023144011p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US260
Received: p_no9p_date2025.10.23-23:26:20.921p_cmdUSp_PVMSGSVp_ENO284p_ALT0p_CT20251023144212p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US280
Received: p_no10p_date2025.10.23-23:26:20.921p_cmdUSp_PVMSGSVp_ENO299p_ALT0p_CT20251023151004p_MC00p_GSCDp_SHSBp_UC01p_UU0101p_EDK0p_US200
Received: p_no11p_date2025.10.23-23:26:25.013p_cmdKP
Received: p_no12p_date2025.10.23-23:26:30.035p_cmdKP
Received: p_no13p_date2025.10.23-23:26:35.058p_cmdKP
Received: p_no14p_date2025.10.23-23:26:40.081p_cmdKP
Received: p_no15p_date2025.10.23-23:26:45.003p_cmdKP
Received: p_no16p_date2025.10.23-23:26:50.026p_cmdKP
Interrupted
### closed ###
{'p_sd_date': '2025.10.23-23:26:56.046', 'p_no': '2', 'p_rv_date': '2025.10.23-23:26:55.993', 'p_errno': '0', 'p_err': '', 'sCLMID': 'CLMAuthLogoutAck', 'sResultCode': '0', 'sResultText': ''}
(.venv) > 

最初の一行目はログインした時に得られるログイン情報です。
仮想URLはログアウトすると無効になるので公開しても問題なしということです。

Received: に続く文字列が受信したメッセージです。
これは特殊文字(^A, ^B, ^C)で区切られているので、これを解析して辞書型などへ変換します。

もう少しアプリケーションに近い形のソースコードやメッセージ解析について解説していますので、良ければ参考にしてください。

役に立ったら「いいね」お願いします。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?