파이썬으로 업비트·빗썸 상장 봇 만들기

최종 업데이트

업비트와 빗썸은 신규 상장, 거래지원 종료, 유의 종목 지정 해제를 각자의 공지사항에 한국어로 올리고, 상장 공지는 거래가 열리기 전에 먼저 나옵니다. 이 튜토리얼에서는 이런 공지를 WebSocket으로 구조화된 JSON 형태로 받아, 한국 거래소 특유의 요소 — 원화·BTC·USDT 마켓, 유의 종목 단계, 여러 티커를 묶은 공지 — 를 이해하고, 원하는 거래소에 크기가 정해지고 중복이 걸러진 주문을 넣는 작은 파이썬 봇을 만듭니다. 직접 해제하기 전까지는 드라이런(모의 실행)으로만 동작합니다. 바이낸스 상장 스나이퍼 봇 튜토리얼 (EN)의 한국 거래소 편입니다.

범위. 이 글은 구독자 쪽 튜토리얼입니다. 봇은 공지 피드를 읽고 그에 따라 움직일 뿐, 거래소 웹사이트를 스크래핑하지 않습니다. 주문은 ccxt로 넣기 때문에 주문 거래소는 코드 한 줄로 바꿀 수 있습니다.

봇이 하는 일

봇은 세 단계로 움직이고, 각 단계는 한국 거래소가 공지를 내는 방식에 맞춰져 있습니다.

  1. 수신 — WebSocket 연결 하나로 업비트와 빗썸의 모든 공지를 받습니다. 티커는 한국어 제목에서 이미 추출되어 옵니다.
  2. 분류 — listingType으로 분기합니다. spot_listing(신규 마켓 개설), caution_released(유의 종목 지정 해제), spot_delisting(거래지원 종료)입니다. 업비트 상장이면 어떤 마켓이 열리는지도 읽습니다.
  3. 실행 — 주문 거래소에서 티커를 찾고, 위험 한도를 적용한 뒤 주문을 넣습니다. 드라이런 중에는 주문 내용을 출력만 합니다.

한국 거래소용 봇을 따로 만드는 이유가 있습니다. 제목이 한국어이고, 공지 하나로 여러 토큰이 한꺼번에 열리며, 원화(KRW) 마켓 상장은 BTC나 USDT 마켓만 추가되는 상장과 성격이 다른 이벤트이고, 두 거래소 모두 이미 거래 중인 토큰에 유의 종목 제도를 운영합니다. 이 하나하나가 코드가 해야 할 일을 바꿉니다. 시장 배경은 업비트 상장 효과와 김치 프리미엄, 빗썸 vs 업비트를 참고하세요.

준비물

pip install "websockets>=14" ccxt

1단계 — 공지 WebSocket에 연결하기

업비트와 빗썸은 두 엔드포인트에서 전송됩니다. 서울의 wss://kr.cryptolisting.ws(업비트·빗썸)와 도쿄의 wss://cryptolisting.ws(바이낸스·업비트·빗썸)입니다. 봇과 주문 거래소가 있는 곳에서 가까운 쪽을 고르세요. 바이낸스 공지도 매매한다면 세 거래소를 모두 전송하는 도쿄가 맞습니다. 같은 키가 두 곳 모두에서 작동하며, 요청 한도와 연결 수 제한은 엔드포인트별로 따로 계산됩니다. 인증은 핸드셰이크 때 X-API-Key 헤더 하나로 하고, URL 쿼리에 키를 넣으면 거부됩니다.

# websockets>=14
import asyncio, json, websockets

API_KEY = "YOUR_KEY"
FEED_URL = "wss://kr.cryptolisting.ws?cex=upbit,bithumb"

async def listen():
    attempt = 0
    while True:
        try:
            async with websockets.connect(
                FEED_URL, additional_headers={"X-API-Key": API_KEY}
            ) as ws:
                attempt = 0
                while True:
                    # heartbeat가 30초마다 오므로 60초 동안 아무것도 없으면 끊긴 연결
                    raw = await asyncio.wait_for(ws.recv(), timeout=60)
                    await handle(json.loads(raw))
        except websockets.ConnectionClosed as e:
            reason = e.rcvd.reason if e.rcvd else ""
            if reason in ("key_expired", "key_invalidated"):
                print(f"키 거부: {reason}")
                return
        except websockets.InvalidStatus as e:
            status = e.response.status_code
            if status in (401, 403):
                print(f"키 거부: HTTP {status}")
                return
            print(f"핸드셰이크 거부: HTTP {status}")   # 429: 간격을 두고 재시도
        except (asyncio.TimeoutError, OSError) as e:
            print(f"연결 끊김: {e!r}")
        await asyncio.sleep(min(2 ** attempt, 300))
        attempt += 1

서버가 보내는 PING 프레임에는 라이브러리가 알아서 응답합니다. 라이브러리가 알아채지 못하는 것은 연결을 닫지 않고 사라진 서버입니다. 그래서 이 루프는 60초 동안 아무 메시지도 없으면 연결이 죽은 것으로 보고 다시 연결합니다. 핸드셰이크에서 키가 거부되거나(HTTP 401, 403), 세션 도중 키가 만료·폐기되면(key_expired, key_invalidated) 봇은 재연결을 반복하지 않고 멈춥니다. 그 밖의 끊김은 최대 5분까지 늘어나는 지수 백오프로 다시 시도합니다.

2단계 — 공지 메시지 읽기

모든 프레임은 type 필드가 있는 UTF-8 JSON입니다. 시장 이벤트는 announcement뿐입니다. welcome, heartbeat, changelog, renewal_notice, test_announcement, error는 시장 이벤트가 아니며, 새로운 타입이 언제든 추가될 수 있으니 모르는 타입은 무시하세요. 업비트 상장은 이런 형태로 옵니다.

{
  "type": "announcement",
  "title": "플루언트(BLEND) 신규 거래지원 안내 (KRW, BTC, USDT 마켓)",
  "ticker": "BLEND",
  "publisher": "upbit",
  "listingType": "spot_listing",
  "detectedTimestampUs": 1710345000005000,
  "dispatchTimestampUs": 1710345000006000,
  "abnormalDetectionLatency": false,
  "markets": "KRW,BTC,USDT"
}

빗썸 이벤트도 구조는 같고 markets만 없습니다. 제목은 한국어 그대로입니다. 예를 들어 원화 마켓 신규 상장이면 셀로(CELO) 원화 마켓 추가 같은 제목입니다. 하지만 직접 읽을 필요는 없습니다. ticker, publisher, listingType이 이미 추출되어 오기 때문입니다. 처리 코드를 튼튼하게 만드는 규칙은 세 가지입니다.

3단계 — 업비트 markets 필드 읽기

업비트는 세 가지 마켓, 즉 원화(KRW), BTC, USDT 마켓 중 하나 이상에 자산을 상장합니다. 원화 마켓이 열리는 상장은 BTC나 USDT 마켓만 추가되는 상장과 다른 이벤트이고, markets가 그 차이를 알려 줍니다. 이 필드는 업비트 spot_listing 이벤트에만 있고, 항상 KRW, BTC, USDT의 고정된 순서(가능한 값 7가지)로 오며, SpeedTrial을 포함한 모든 등급에 전달됩니다.

가장 중요한 규칙은 이것입니다. markets가 없다는 것은 "알 수 없음"이지, "마켓 없음"이 아닙니다. 이 필드는 공지 제목에서 도출되기 때문에, 낯선 문구가 나오면 틀린 값 대신 필드 자체가 빠집니다. 이벤트를 처리할지 말지가 아니라 주문 크기를 정하는 데 쓰세요.

BASE_SIZE_USDT = 50     # 토큰당 주문 금액
NON_KRW_FACTOR = 0.5    # BTC/USDT 마켓만 열리는 상장에 대한 본인의 정책

def order_size(msg):
    markets = msg.get("markets")   # 업비트 현물 상장에만 존재, 없으면 알 수 없음
    if markets is None or "KRW" in markets.split(","):
        return BASE_SIZE_USDT
    return BASE_SIZE_USDT * NON_KRW_FACTOR

이 계수는 본인의 기록을 보고 정할 설정값이지 권장값이 아닙니다. 빗썸 이벤트에는 markets 필드가 없습니다. 마켓은 제목에 적혀 있습니다(원화 마켓이 원화 마켓입니다).

4단계 — caution_released와 spot_delisting 처리하기

두 한국 거래소는 이미 거래 중인 토큰에 여러 단계의 유의 종목 제도를 운영합니다. 경고, 정식 지정(업비트 유의 종목, 빗썸 투자유의종목), 기간 연장을 거쳐, 지정 해제 또는 거래지원 종료(거래지원 종료)로 끝납니다. 피드는 매매 판단에 직결되는 두 단계를 전송합니다.

중간 단계는 전송하지 않습니다. 해제와 거래지원 종료를 함께 담은 공지는 listingType별로 나뉜 별도의 이벤트로 옵니다. 타입마다 처리 함수를 따로 두세요.

async def route(msg):
    lt = msg["listingType"]
    tickers = [t for t in msg["ticker"].split(",") if t]
    if not tickers:
        return                                   # SpeedTrial: 티커 가림
    if lt == "spot_listing":
        size = order_size(msg)
        await asyncio.gather(*(buy(t, size, msg) for t in tickers[:MAX_TOKENS_PER_EVENT]))
    elif lt == "caution_released":
        print(f"관심 목록: {msg['publisher']} 유의 종목 해제 {tickers}")
    elif lt == "spot_delisting":
        await asyncio.gather(*(reduce_exposure(t) for t in tickers))

이 예제에서 유의 종목 해제는 관심 목록에만 올라갑니다. 매매하기 전에 본인의 기록으로 판단하세요. 거래지원 종료는 이미 보유한 현물만 줄이며, 봇이 그 토큰에 숏 포지션을 여는 일은 없습니다.

5단계 — 토큰이 이미 거래되는 곳 찾기

업비트나 빗썸이 상장하는 토큰은 그 전부터 다른 거래소에서 거래되던 경우가 많고, 그래서 한국 상장이 전 세계 가격을 움직일 수 있습니다. 봇은 이벤트가 오기 전에 주문 거래소에 해당 마켓이 있는지 알고 있어야 합니다. 시작할 때 거래소의 마켓 목록을 한 번 불러오고, 백그라운드에서 주기적으로 갱신하세요. 그러면 처리 함수 안에서 심볼을 찾는 일은 네트워크 호출이 아니라 딕셔너리 조회가 됩니다.

import ccxt.async_support as ccxt

venue = ccxt.binance({"apiKey": "VENUE_KEY", "secret": "VENUE_SECRET"})

def resolve(ticker):
    # 현물 페어 먼저, 그다음 USDT 마진 무기한 선물
    for symbol in (f"{ticker}/USDT", f"{ticker}/USDT:USDT"):
        if symbol in venue.markets:
            return symbol
    return None

async def refresh_markets():
    while True:
        await asyncio.sleep(300)
        await venue.load_markets(reload=True)

심볼이 거래소마다 항상 같지는 않습니다. 토큰 이름이 바뀌기도 하고, 어떤 무기한 선물은 1000 같은 접두어가 붙은 티커로 거래됩니다. resolve가 None을 돌려주면 그 토큰은 건너뛰세요. 심볼을 추측해서는 안 됩니다.

6단계 — 위험 한도와 FreeDelayed 드라이런

첫 실제 주문 전에 안전장치를 넣어 두세요.

이 단계에는 FreeDelayed가 제격입니다. 유료 등급과 같은 형식으로 티커와 제목 전체를 +240 ms 지연으로 전달합니다. 실제 업비트·빗썸 공지가 몇 주 쌓일 때까지 봇을 드라이런으로 돌린 뒤 기록을 읽어 보세요. 어떤 심볼을 얼마만큼 샀을지, 주문 거래소에 없어서 어떤 토큰을 건너뛰었는지가 남습니다. 그 기록을 믿을 수 있을 때 DRY_RUN = False로 바꾸고 유료 등급으로 옮기면 됩니다. Basic은 +20 ms, Premium은 지연이 추가되지 않습니다.

전체 코드

150줄이 채 안 되는 봇 전체입니다. 키 두 개만 채우면 되고, 드라이런으로 시작합니다.

# 업비트·빗썸 상장 봇 — pip install "websockets>=14" ccxt
import asyncio, json, websockets
import ccxt.async_support as ccxt

API_KEY = "YOUR_KEY"
FEED_URL = "wss://kr.cryptolisting.ws?cex=upbit,bithumb"
DRY_RUN = True
BASE_SIZE_USDT = 50
NON_KRW_FACTOR = 0.5
MAX_TOKENS_PER_EVENT = 3

venue = ccxt.binance({"apiKey": "VENUE_KEY", "secret": "VENUE_SECRET"})
seen = set()


def order_size(msg):
    markets = msg.get("markets")   # 업비트 현물 상장에만 존재, 없으면 알 수 없음
    if markets is None or "KRW" in markets.split(","):
        return BASE_SIZE_USDT
    return BASE_SIZE_USDT * NON_KRW_FACTOR


def resolve(ticker):
    for symbol in (f"{ticker}/USDT", f"{ticker}/USDT:USDT"):
        if symbol in venue.markets:
            return symbol
    return None


async def buy(ticker, size_usdt, msg):
    symbol = resolve(ticker)
    if symbol is None:
        print(f"건너뜀 {ticker}: {venue.id}에 마켓 없음")
        return
    try:
        price = (await venue.fetch_ticker(symbol)).get("last")
        if not price:
            print(f"건너뜀 {symbol}: 아직 체결가 없음")
            return
        amount = float(venue.amount_to_precision(symbol, size_usdt / price))
        if DRY_RUN:
            print(f"[드라이런] 매수 {amount} {symbol} (~{size_usdt} USDT), {msg['publisher']} 상장")
            return
        order = await venue.create_market_buy_order(symbol, amount)
        print(f"매수 완료 {symbol}: 주문 {order['id']}")
    except ccxt.BaseError as e:
        print(f"주문 실패 {symbol}: {e}")


async def reduce_exposure(ticker):
    symbol = f"{ticker}/USDT"
    if symbol not in venue.markets:
        return
    try:
        held = (await venue.fetch_balance()).get("free", {}).get(ticker) or 0
        if held and DRY_RUN:
            print(f"[드라이런] 매도 {held} {symbol}, 거래지원 종료 공지")
        elif held:
            await venue.create_market_sell_order(symbol, float(venue.amount_to_precision(symbol, held)))
    except ccxt.BaseError as e:
        print(f"축소 실패 {symbol}: {e}")


async def route(msg):
    lt = msg["listingType"]
    tickers = [t for t in msg["ticker"].split(",") if t]
    tickers = [t for t in tickers if (msg["publisher"], lt, t) not in seen]
    seen.update((msg["publisher"], lt, t) for t in tickers)
    if not tickers:
        return                                   # 중복이거나 SpeedTrial 가림
    if lt == "spot_listing":
        size = order_size(msg)
        await asyncio.gather(*(buy(t, size, msg) for t in tickers[:MAX_TOKENS_PER_EVENT]))
    elif lt == "caution_released":
        print(f"관심 목록: {msg['publisher']} 유의 종목 해제 {tickers}")
    elif lt == "spot_delisting":
        await asyncio.gather(*(reduce_exposure(t) for t in tickers))


async def handle(msg):
    kind = msg.get("type")
    if kind == "welcome":
        print(f"연결됨: tier={msg.get('tier')} cex={msg.get('allowedCex')}")
    elif kind == "renewal_notice":
        print(msg.get("title"))
    elif kind == "announcement":              # test_announcement는 절대 매매하지 않음
        if msg.get("abnormalDetectionLatency"):
            print(f"표시된 이벤트, 매매 안 함: {msg.get('title')}")
            return
        await route(msg)


async def listen():
    attempt = 0
    while True:
        try:
            async with websockets.connect(
                FEED_URL, additional_headers={"X-API-Key": API_KEY}
            ) as ws:
                attempt = 0
                while True:
                    raw = await asyncio.wait_for(ws.recv(), timeout=60)
                    await handle(json.loads(raw))
        except websockets.ConnectionClosed as e:
            reason = e.rcvd.reason if e.rcvd else ""
            if reason in ("key_expired", "key_invalidated"):
                print(f"키 거부: {reason}")
                return
        except websockets.InvalidStatus as e:
            status = e.response.status_code
            if status in (401, 403):
                print(f"키 거부: HTTP {status}")
                return
            print(f"핸드셰이크 거부: HTTP {status}")   # 429: 간격을 두고 재시도
        except (asyncio.TimeoutError, OSError) as e:
            print(f"연결 끊김: {e!r}")
        await asyncio.sleep(min(2 ** attempt, 300))
        attempt += 1


async def refresh_markets():
    while True:
        await asyncio.sleep(300)
        try:
            await venue.load_markets(reload=True)
        except ccxt.BaseError as e:
            print(f"마켓 갱신 실패: {e}")


async def main():
    await venue.load_markets()
    refresher = asyncio.create_task(refresh_markets())
    try:
        await listen()
    finally:
        refresher.cancel()
        await venue.close()


asyncio.run(main())

이 뼈대가 남겨 둔 부분은 직접 채워야 합니다. 거래소별 주문 규칙(최소 주문 금액, 수량 단위 — amount_to_precision은 반올림만 처리합니다), 여러 이벤트에 걸친 포지션 한도, 재시작 뒤에도 유지되는 seen 저장이 그것입니다. 프로토콜 전체는 API 문서 (EN)에 있습니다. 메시지 레퍼런스 (EN)에는 모든 필드와 이벤트 타입이, 거래소 필터링 (EN)에는 ?cex=와 키 권한의 관계가 정리되어 있습니다.

자주 묻는 질문

업비트·빗썸 봇은 어느 엔드포인트에 연결해야 하나요?

wss://kr.cryptolisting.ws(서울)는 업비트와 빗썸을, wss://cryptolisting.ws(도쿄)는 바이낸스·업비트·빗썸을 전송합니다. 봇과 주문 거래소가 있는 곳에서 가까운 엔드포인트를 고르고, 바이낸스도 필요하면 도쿄를 쓰세요. 같은 API 키가 두 곳 모두에서 작동합니다.

한국어 공지 제목을 직접 파싱해야 하나요?

아니요. ticker, publisher, listingType이 이미 추출되어 오므로 봇은 이 필드로 분기하면 됩니다. 원문 한국어 제목은 title에 그대로 남아 있어 교차 확인에 쓸 수 있습니다.

유의 종목의 모든 단계가 피드로 오나요?

아니요. 매매 판단에 직결되는 두 단계, caution_released와 spot_delisting만 전송합니다. 중간 단계는 전송하지 않습니다.

빗썸 상장에도 markets 필드가 있나요?

아니요. markets는 업비트 현물 상장에만 있습니다. 필드가 없으면 "알 수 없음"이라는 뜻이며, 원화 마켓이 없다는 뜻이 결코 아닙니다.

이 봇을 무료로 만들고 돌려볼 수 있나요?

네. FreeDelayed는 티커와 제목 전체를 +240 ms 지연으로 전달하므로, 드라이런으로 봇 전체를 돌려보기에 충분합니다. SpeedTrial은 모든 이벤트를 전달하지만 거래 가능한 이벤트의 티커를 가립니다.

본 글은 정보 제공 목적의 기술 튜토리얼이며 투자 또는 매매 조언이 아닙니다. 자동 매매에는 상당한 위험이 따릅니다. CryptoListing.ws는 기술 데이터 피드 서비스입니다 — 법적 고지 (EN)를 참조하십시오.

관련 글

무료 키로 한국 거래소 봇을 시작하세요

업비트·빗썸의 신규 상장, 유의 종목 지정 해제, 거래지원 종료 공지를 WebSocket으로, 구조화된 JSON으로 실시간 전달합니다. 요금제 및 등급을 확인하세요 — FreeDelayed는 현실적인 드라이런을 위해 티커 전체를 전달하고, 유료 등급은 지연을 줄입니다.

텔레그램에서 시작하기