바이낸스(Binance) 웹 소켓을 이용한 트레이딩 환경 구축(4) – 호가창

바이낸스(Binance) 웹 소켓을 이용한 트레이딩 환경 구축(4) – 호가창

호가창을 구현하는 코드를 작성해두고 한동안 방치했습니다. 지금 다시 보니 마음에 들지 않는 부분이 많네요. 함수 명도 마음에 안 들고, ‘왜 이렇게 작성했지?’ 싶은 코드도 눈에 띕니다. 그렇다고 전체를 처음부터 다시 작성하기엔 너무 번거롭고, 어디서부터 손을 대야 할지 고민하다 보니 계속 미루게 되네요. 나중에 최종 수정을 하던지.. 나중에 생각하겠습니다.

이 글에서는 호가(OrderBook) 데이터를 호출하고 웹 페이지에서 데이터를 확인하는 과정을 진행합니다. ccxtpro에서 데이터를 호출하는 방법과 여러 비동기 데이터를 병렬 처리하는 방법을 살펴보겠습니다.


OrderBook 호출 테스트

1. 데이터 호출

bs.watch_order_book() 함수로 orderbook 데이터를 받아올 수 있습니다.

import os
import pprint
import dotenv
import asyncio
import ccxt.pro as ccxtpro

# .env 파일 로드
dotenv.load_dotenv()

# 환경 변수 가져오기
API_KEY = os.getenv("API_KEY")
SECRET_KEY = os.getenv("SECRET_KEY")

symbol = "BTC/USDT"

async def future_orderbook_socket():
    bs = ccxtpro.binance({
        'apiKey': API_KEY,
        'secret': SECRET_KEY,
        'enableRateLimit': True,
        'options': {
            'defaultType': 'future'
        }
    })
    while True:
            orderbook = await bs.watch_order_book(symbol=symbol, limit=1000)
            asks = orderbook['asks']
            bids = orderbook['bids']
            pprint.pprint(orderbook)
            await asyncio.sleep(0.25)

asyncio.run(future_orderbook_socket())
2. 호출 결과
{
    'asks': [[57966.9, 1.406], [57966.8, 0.012], [57966.7, 0.06], .... ], # 1000개
    'bids': [[57965.3, 0.087], [57965.2, 0.145], [57965.1, 0.104], ... ], # 1000개
    'datetime': '2024-09-12T11:56:35.931Z',
    'nonce': 5329288527656,
    'symbol': 'BTC/USDT:USDT',
    'timestamp': 1726142195931
}

bs.watch_order_book()함수로 asks(매도), bids(매수) 각 1000개의 데이터를 받아옵니다. 리스트의 각 원소는 [price, amount]를 의미합니다.


호출한 데이터를 프론트에서 확인하기

1. app.py 수정

기존 trades 데이터를 수신하는 코드에 추가적으로 orderbook 데이터를 수신하는 코드를 작성합니다.

# app.py
...
async def future_trades_socket():
...
async def future_orderbook_socket():
    bs = ccxtpro.binance({
        'apiKey': API_KEY,
        'secret': SECRET_KEY,
        'options': {
            'defaultType': 'future'
        }
    })

    while True:
        try:
            orderbook = await bs.watch_order_book(symbol=symbol, limit=1000)
            asks = orderbook['asks']
            bids = orderbook['bids']
            socketio.emit('orderbook', {
                "payload": {
                    'asks': asks,
                    'bids': bids,
                }
            })
            await asyncio.sleep(0.25)

        except Exception as e:
            print("An error occurred:", e)
            break

async def sockets():
    await asyncio.gather(
        future_trades_socket(),
        future_orderbook_socket(),
    )
...
if __name__ == '__main__':
    websocket_thread = threading.Thread(
        target=lambda: asyncio.run(sockets()))    # 수정
    websocket_thread.start()
    socketio.run(app, debug=True)

기존 작성된 코드에 빨간색 부분을 추가합니다.
future_orderbook_socket()함수는 asksbids를 프론트에 전달하는 역할을 하고, asyncio.gather()는 여러 개의 비동기 작업을 동시에 수행할 수 있게 해줍니다.

2. index.html 수정

html로 orderbook 데이터가 표시될 곳을 생성합니다.

<!-- index.html -->
<div id="trades">
    ....
</div>
<div id="orderbook">
      <h1>호가창</h1>
      <table id="orderbookTable">
        <colgroup>
          <col />
          <col />
          <col />
          <col />
          <col />
          <col />
          <col />
          <col />
        </colgroup>
        <tbody></tbody>
      </table>
    </div>
/* style.css */
#orderbook {
  flex: 2;
}

#orderbookTable {
  border-collapse: collapse;
  width: 100%;
}

.asks {
  color: red;
}

.bids {
  color: blue;
}
3. socket.js 수정
binance practice Chrome 2024 09 14 오후 6 50 30 1

표시될 영역(파란색)을 만들었고 자바스크립트로 업데이트하는 코드를 작성합니다.

// socket.js
...
const eventHandlers = {
  trades_data: updateTrades,
  orderbook_data: updateOrderbook,
};

// socket.on(){...} 을 아래 코드로 변경
socket.onAny((event, data) => {
  if (eventHandlers[event]) {
    eventHandlers[event](data.payload);
  } else {
    console.log("Unhandled event: ", event);
  }
});

function updateOrderbook(data) {
  var tbody = document.querySelector("#orderbookTable tbody");

  tbody.innerHTML = "";

  var asks = data["asks"].slice(0, 10).reverse();
  var bids = data["bids"].slice(0, 10);

  asks.reverse().forEach(function (row) {
    // tr
    var tr = document.createElement("tr");
    tr.classList.add("asks");

    tr.appendChild(document.createElement("td"));
    tr.appendChild(document.createElement("td"));

    row.forEach(function (cellData) {
      var td = document.createElement("td");
      td.textContent = cellData;
      tr.appendChild(td);
    });

    tr.appendChild(document.createElement("td"));
    tr.appendChild(document.createElement("td"));
    tr.appendChild(document.createElement("td"));
    tbody.appendChild(tr);
  });

  bids.forEach(function (row) {
    // tr
    var tr = document.createElement("tr");
    tr.classList.add("bids");

    tr.appendChild(cancelOrder);
    tr.appendChild(buyOrder);

    row.forEach(function (cellData) {
      var td = document.createElement("td");
      td.textContent = cellData;
      tr.appendChild(td);
    });

    tr.appendChild(document.createElement("td"));
    tr.appendChild(document.createElement("td"));

    tbody.appendChild(tr);
  });
}

function updateTrades(data) {...}
...
  • 여러 개의 이벤트를 처리하기 위해 socket.on()socket.onAny()로 변경합니다. 이후 추가할 이벤트는 eventHandlers에 추가하면 됩니다.
  • 전달받은 1000개의 bids, asks를 모두 표시하기에는 무리가 있으니 slice(0, 10)으로 10개만 사용합니다.
4. 동작 확인

받아온 데이터를 출력하는 것은 성공했지만 문제점이 보입니다. 호가 단위가 너무 작아서 비어 있는 호가도 보이고 체결될 때 호가창이 너무 빠르게 움직입니다. 이 문제를 해결하기 위해서 호가 단위를 조정해보겠습니다.


호가 단위 그룹화

1. app.py 수정
async def future_orderbook_socket():
    ...
    while True:
        try:
            orderbook = await bs.watch_order_book(symbol=symbol, limit=1000)
            asks = orderbook['asks']
            bids = orderbook['bids']
            asks_group = group_by(asks, 5, "asks")
            bids_group = group_by(bids, 5, "bids")
            socketio.emit('orderbook_data', {
                "payload": {
                    'asks': asks_group,
                    'bids': bids_group,
                }
            })
            await asyncio.sleep(0.25)
...

def group_by(data, size, type):
    idx = 0
    price = 0
    amount = 0
    group_by_data = []

    while idx + 1 < len(data):
        if price == 0:
            price = data[idx][0] // size
        if data[idx][0] // size == price:
            amount += data[idx][0] * data[idx][1]
            idx += 1
        else:
            if type == "asks":
                group_by_data.append([int((1 + price) * size), amount])
            if type == "bids":
                group_by_data.append([int(price * size), amount])
            price = 0
            amount = 0

    return group_by_data

호가 단위를 그룹화 해서 0.1에서 5로 변경하는 코드 입니다. 호가가 비어 있지 않다고 가정했을 때, 50개의 데이터를 그룹화 해서 10개를 출력하기 때문에 limit=500 이상을 설정해야합니다.

종목에 따라서 호가 단위가 다르기 때문에 적절한 size와 limit를 설정해야합니다. (XRPUSDT는 호가 단위 0.0001)

2. socket.js 수정
function updateOrderbook(data) {
    ...
    row.forEach(function (cellData) {
      var td = document.createElement("td");
      td.textContent = Math.round(cellData).toLocaleString("en-US");
      tr.appendChild(td);
    });
    ...
  bids.forEach(function (row) {
    ....
    row.reverse().forEach(function (cellData) {
      var td = document.createElement("td");
      td.textContent = Math.round(cellData).toLocaleString("en-US");
      tr.appendChild(td);
    });

숫자에 반올림과 콤마를 적용합니다.

3. 동작 확인
binance practice Chrome 2024 09 23 오전 12 35 57

현재 호가 단위가 5로 적용된 모습입니다. 바이낸스에서는 [0.1, 1, 10, 50, 100]과 같은 고정된 호가 단위를 사용하지만, 직접 구현할 경우 0.5, 2, 3, 5 등 원하는 단위를 자유롭게 적용할 수 있습니다.
마지막엔 호가창 주문도 구현할 예정이며, 이 주문 단위는 주문 체결 여부를 결정하는 요소가 됩니다.

작성된 코드는 깃 허브에서 확인할 수 있습니다.