Build an Upbit & Bithumb Listing Bot in Python

Last updated

Upbit and Bithumb publish their new listings, delistings and caution releases in Korean, on their own notice boards, and a listing notice comes before trading opens. This tutorial builds a small Python bot that receives those announcements as structured JSON over WebSocket, understands what is specific to the Korean exchanges — quote markets, the caution track, grouped tickers — and places a sized, deduplicated order on the venue of your choice, in dry-run mode until you decide otherwise. It is the Korean-exchange sibling of our Binance listing sniper bot tutorial.

Scope. This is a consumer-side tutorial: your bot reads an announcement feed and acts on it. It does not scrape the exchanges' websites. Orders go through ccxt, so the execution venue is a one-line choice.

What the bot does

The bot has three stages, and each one is shaped by how the Korean exchanges publish:

  1. Receive — one WebSocket connection delivers every Upbit and Bithumb announcement, with the ticker already extracted from the Korean title.
  2. Classify — branch on listingType: spot_listing (a new market opens), caution_released (a caution designation is lifted) or spot_delisting (trading support ends). For Upbit listings, also read which quote markets open.
  3. Act — look the ticker up on your venue, apply your risk limits, then place the order, or print it while in dry-run.

Why the Korean exchanges deserve their own bot: the titles are in Korean, one notice can open several tokens at once, a listing on the won (KRW) market is a different event from one that only adds a BTC or USDT pair, and both exchanges run a caution track on tokens that already trade. Each of these details changes what your code should do. For the market background, read the Upbit listing effect and the kimchi premium and Bithumb vs Upbit.

Prerequisites

pip install "websockets>=14" ccxt

Step 1 — Connect to the announcement WebSocket

Upbit and Bithumb are served from two endpoints: wss://kr.cryptolisting.ws in Seoul (Upbit and Bithumb) and wss://cryptolisting.ws in Tokyo (Binance, Upbit and Bithumb). Pick the one closest to where your bot and its venue run; if the bot also trades Binance announcements, Tokyo carries all three. The same key works on both, and rate limits and connection caps are counted per endpoint. Authentication is a single X-API-Key header on the handshake — keys in the query string are refused.

# 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:
                    # a heartbeat arrives every 30 s: 60 s of silence = dead link
                    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"key rejected: {reason}")
                return
        except websockets.InvalidStatus as e:
            status = e.response.status_code
            if status in (401, 403):
                print(f"key refused: HTTP {status}")
                return
            print(f"handshake refused: HTTP {status}")   # 429: back off
        except (asyncio.TimeoutError, OSError) as e:
            print(f"connection lost: {e!r}")
        await asyncio.sleep(min(2 ** attempt, 300))
        attempt += 1

The library answers the server's PING frames on its own. What it cannot notice is a server that disappears without closing the connection, so the loop treats 60 seconds without any message as a dead link and reconnects. A key that is refused at the handshake (HTTP 401 or 403), or that expires or is revoked during a session (key_expired, key_invalidated), stops the bot instead of making it reconnect in a loop; every other disconnect is retried with exponential backoff, capped at five minutes.

Step 2 — Read the announcement

Every frame is UTF-8 JSON with a type field. Only announcement is a market event. welcome, heartbeat, changelog, renewal_notice, test_announcement and error are not, and new types can appear at any time — ignore what you do not know. A real-format Upbit listing:

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

A Bithumb event has the same envelope, without markets. The title stays in Korean — for example 셀로(CELO) 원화 마켓 추가 for a new won-market listing — but you never have to read it: ticker, publisher and listingType are already extracted. Three rules keep a handler robust:

Step 3 — Read the Upbit markets field

Upbit lists an asset on one or more of its three quote markets: KRW (the Korean won), BTC and USDT. A listing that opens the won market is a different event from one that only adds a BTC or USDT pair, and markets carries that distinction. It is present only on Upbit spot_listing events, always in the fixed order KRW, BTC, USDT (seven possible values), and it is delivered on every tier, SpeedTrial included.

One rule matters more than the others: a missing markets means "unknown", never "no market". The field is derived from the headline, and an unusual wording yields no field rather than a wrong one. Use it to size an order, not to decide whether the event exists:

BASE_SIZE_USDT = 50     # notional per token
NON_KRW_FACTOR = 0.5    # your policy for BTC/USDT-only listings

def order_size(msg):
    markets = msg.get("markets")   # Upbit spot listings only; absent = unknown
    if markets is None or "KRW" in markets.split(","):
        return BASE_SIZE_USDT
    return BASE_SIZE_USDT * NON_KRW_FACTOR

The factor is a setting for you to choose from your own records, not a recommendation. Bithumb events carry no markets field: the market is named in the title (원화 마켓 is the won market).

Step 4 — Handle caution_released vs spot_delisting

Both Korean exchanges run a multi-stage risk track on tokens that already trade: a warning, a formal caution designation (유의 종목 on Upbit, 투자유의종목 on Bithumb), extensions, then either a release or a delisting (거래지원 종료, end of trading support). The feed forwards the two stages that drive a trading decision:

The intermediate stages are not forwarded. A notice that combines a release and a delisting arrives as separate events, one per listingType. Route each type to its own function:

async def route(msg):
    lt = msg["listingType"]
    tickers = [t for t in msg["ticker"].split(",") if t]
    if not tickers:
        return                                   # SpeedTrial: ticker redacted
    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"watch-list: caution lifted on {msg['publisher']} for {tickers}")
    elif lt == "spot_delisting":
        await asyncio.gather(*(reduce_exposure(t) for t in tickers))

In this example a caution release only goes to a watch-list: decide from your own history before you trade it. A delisting only reduces a spot holding you already have — the bot never opens a short on it.

Step 5 — Find where the token already trades

A token that Upbit or Bithumb lists has often traded on other exchanges before, which is why a Korean listing can move its price everywhere. Your bot needs to know, before the event, whether your venue has a market for it. Load the venue's markets once at startup and refresh them on a timer, in the background — then resolving a symbol inside the handler is a dictionary lookup, not a network call:

import ccxt.async_support as ccxt

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

def resolve(ticker):
    # spot pair first, then the USDT-margined perpetual
    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)

Symbols do not always match between exchanges: tokens get renamed, and some perpetuals trade under a multiplied ticker such as a 1000 prefix. When resolve returns None, skip the token — never guess a symbol.

Step 6 — Risk limits and dry-run on FreeDelayed

Put the guard-rails in before the first real order:

FreeDelayed is the natural tier for this phase: it delivers the full ticker and title, with the same schema as the paid tiers and a +240 ms delay. Let the bot run in dry-run for a few weeks of real Upbit and Bithumb notices, then read its log — which symbols it would have bought, at what size, and which tokens it skipped because your venue did not list them. When you trust that log, set DRY_RUN = False and move to a paid tier: Basic adds +20 ms, Premium adds no delay.

Full code

The complete bot, in under 150 lines. Fill in your two keys; it starts in dry-run.

# Upbit & Bithumb listing bot — 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")   # Upbit spot listings only; absent = unknown
    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"skip {ticker}: no market on {venue.id}")
        return
    try:
        price = (await venue.fetch_ticker(symbol)).get("last")
        if not price:
            print(f"skip {symbol}: no last price yet")
            return
        amount = float(venue.amount_to_precision(symbol, size_usdt / price))
        if DRY_RUN:
            print(f"[dry-run] buy {amount} {symbol} (~{size_usdt} USDT) after {msg['publisher']} listing")
            return
        order = await venue.create_market_buy_order(symbol, amount)
        print(f"bought {symbol}: order {order['id']}")
    except ccxt.BaseError as e:
        print(f"order failed for {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"[dry-run] sell {held} {symbol} after a delisting notice")
        elif held:
            await venue.create_market_sell_order(symbol, float(venue.amount_to_precision(symbol, held)))
    except ccxt.BaseError as e:
        print(f"reduce failed for {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                                   # duplicate, or SpeedTrial redaction
    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"watch-list: caution lifted on {msg['publisher']} for {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"connected: tier={msg.get('tier')} cex={msg.get('allowedCex')}")
    elif kind == "renewal_notice":
        print(msg.get("title"))
    elif kind == "announcement":              # never test_announcement
        if msg.get("abnormalDetectionLatency"):
            print(f"flagged event, not traded: {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"key rejected: {reason}")
                return
        except websockets.InvalidStatus as e:
            status = e.response.status_code
            if status in (401, 403):
                print(f"key refused: HTTP {status}")
                return
            print(f"handshake refused: HTTP {status}")   # 429: back off
        except (asyncio.TimeoutError, OSError) as e:
            print(f"connection lost: {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"market refresh failed: {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())

What the skeleton leaves to you: exchange-specific order rules (minimum notional, lot size — amount_to_precision covers only the rounding), position limits across events, and persistence of the seen set across restarts. The API documentation covers the protocol in full: the message reference lists every field and listing type, and the exchange filtering page explains ?cex= against your key's scope.

Frequently asked questions

Which endpoint should an Upbit or Bithumb bot connect to?

wss://kr.cryptolisting.ws (Seoul) carries Upbit and Bithumb; wss://cryptolisting.ws (Tokyo) carries Binance, Upbit and Bithumb. Pick the endpoint closest to where your bot and its trading venue run, or Tokyo if the bot also needs Binance. The same API key works on both.

Do I need to parse the Korean announcement title?

No. ticker, publisher and listingType are already extracted, so the bot branches on them. The original Korean title stays in title, which is useful as a cross-check.

Does the feed include every stage of the caution track?

No. It forwards the two stages that drive a trading decision: caution_released and spot_delisting. The intermediate stages are not forwarded.

Is the markets field available for Bithumb listings?

No. markets is present only on Upbit spot listings. When it is absent, the markets are unknown — which never means there is no KRW market.

Can I build and run this bot for free?

Yes. FreeDelayed delivers the full ticker and title with a +240 ms delay, enough to run the bot end to end in dry-run. SpeedTrial delivers every event but redacts the ticker on tradeable events.

This article is an engineering tutorial for informational purposes only and is not financial or trading advice. Automated trading carries substantial risk. CryptoListing.ws is a technical data feed service — see Legal.

Related

Build your Korean-exchange bot on a free key

Real-time Upbit and Bithumb announcements — listings, caution releases, delistings — as structured JSON over WebSocket. See pricing & tiers — FreeDelayed delivers the full ticker for a realistic dry-run, paid tiers remove the delay.

Get started on Telegram