Upbit Trading Bot Part 2: Market Analysis and Order Execution

,
Updated Feb 6, 2026

When Your Bot Places Orders But Nothing Happens

Here’s what the Upbit API returns when you place a market buy order with insufficient funds:

{'error': {'message': 'Insufficient funds', 'name': 'insufficient_funds_ask'}}

No exception. No HTTP 400. Just a polite JSON object telling you the order didn’t go through. If you’re not checking that error key in every response, your bot thinks it’s trading when it’s actually doing nothing.

This is the first thing you’ll debug when moving from Part 1’s authentication setup to actual trading logic. The Upbit REST API is unusually quiet about failures—most exchanges throw exceptions, but Upbit prefers returning success-shaped objects with error fields tucked inside. You need explicit validation after every API call.

Burmese python coiled on a smooth rock surface showcasing its intricate scale patterns.
Photo by Olli on Pexels

Building a Market Data Pipeline

Before placing any orders, you need market data. Upbit provides ticker, orderbook, and candle (OHLCV) endpoints. The ticker endpoint is the simplest starting point:

import pyupbit
import time

def get_current_price(ticker):
    """Fetch current price with retry logic."""
    max_retries = 3
    for attempt in range(max_retries):
        try:
            price = pyupbit.get_current_price(ticker)
            if price is None:
                raise ValueError(f"Ticker {ticker} returned None")
            return price
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(1)  # Upbit rate limit: ~10 req/sec public, ~5 req/sec private
    return None

# Real output (2026-02-04):
btc_price = get_current_price("KRW-BTC")
print(f"BTC: {btc_price:,.0f} KRW")  # BTC: 142,850,000 KRW

Notice the None check. Upbit’s pyupbit library returns None for invalid tickers instead of raising exceptions—another silent failure mode. My best guess is this design choice reduces exception overhead in high-frequency polling, but it makes defensive programming mandatory.

For technical analysis, you’ll need historical candles:

import pandas as pd

def fetch_ohlcv(ticker, interval="minute60", count=200):
    """
    Fetch OHLCV data. Interval options: minute1/3/5/10/15/30/60/240, day, week, month.
    Max count per request: 200.
    """
    df = pyupbit.get_ohlcv(ticker, interval=interval, count=count)
    if df is None or df.empty:
        raise ValueError(f"No data for {ticker}")
    return df

df = fetch_ohlcv("KRW-BTC", interval="minute60", count=24)
print(df.tail(3))

Output structure:

                           open       high        low      close       volume
2026-02-04 20:00:00  142800000  143100000  142600000  142900000   45.234
2026-02-04 21:00:00  142900000  143200000  142700000  143050000   52.891
2026-02-04 22:00:00  143050000  143400000  142850000  142850000   38.762

The timestamps are in KST (UTC+9) by default. If you’re running backtests across timezones, convert to UTC immediately or you’ll spend hours debugging off-by-9-hour logic errors.

Implementing Moving Average Crossover

Let’s build the simplest momentum strategy: buy when a fast moving average crosses above a slow MA, sell on the reverse. Everyone knows this strategy doesn’t work in efficient markets (if it did, it wouldn’t anymore), but it’s perfect for testing order execution plumbing.

import numpy as np

def calculate_ma(df, window):
    """Simple moving average."""
    return df['close'].rolling(window=window).mean()

def detect_crossover(fast_ma, slow_ma):
    """
    Returns: 1 (bullish cross), -1 (bearish cross), 0 (no cross).

    A crossover happens when:
    - Previous: fast < slow, Current: fast > slow (bullish)
    - Previous: fast > slow, Current: fast < slow (bearish)
    """
    if len(fast_ma) < 2 or len(slow_ma) < 2:
        return 0

    prev_fast, curr_fast = fast_ma.iloc[-2], fast_ma.iloc[-1]
    prev_slow, curr_slow = slow_ma.iloc[-2], slow_ma.iloc[-1]

    # Bullish crossover
    if prev_fast <= prev_slow and curr_fast > curr_slow:
        return 1
    # Bearish crossover
    elif prev_fast >= prev_slow and curr_fast < curr_slow:
        return -1
    return 0

# Example with real data
df = fetch_ohlcv("KRW-BTC", interval="minute60", count=200)
fast_ma = calculate_ma(df, window=20)
slow_ma = calculate_ma(df, window=60)

signal = detect_crossover(fast_ma, slow_ma)
print(f"Signal: {signal}")  # -1 (bearish cross detected at 2026-02-04 22:35 KST)
print(f"Fast MA: {fast_ma.iloc[-1]:,.0f}, Slow MA: {slow_ma.iloc[-1]:,.0f}")

The comparison uses <= and >= instead of strict inequalities because floating-point MAs can be exactly equal (rare, but I’ve seen it happen with low-volume altcoins where prices stay flat for hours).

One subtle bug: if you run this on newly-listed coins with <200 candles available, get_ohlcv returns fewer rows without warning. Always check len(df) >= slow_window before calculating signals, or the MA will include NaN values and silently produce garbage.

Executing Market Orders

Upbit supports two order types: limit and market (no stop-loss orders—you have to implement those yourself). Market orders are simpler for prototyping:

import pyupbit

class UpbitTrader:
    def __init__(self, access_key, secret_key):
        self.upbit = pyupbit.Upbit(access_key, secret_key)

    def get_balance(self, ticker="KRW"):
        """Get available balance for a currency."""
        balances = self.upbit.get_balances()
        for b in balances:
            if b['currency'] == ticker:
                return float(b['balance'])
        return 0.0

    def buy_market(self, ticker, krw_amount):
        """
        Market buy order. Upbit charges 0.05% fee (as of 2026).

        Returns: order object if successful, None if failed.
        """
        try:
            result = self.upbit.buy_market_order(ticker, krw_amount)

            # Critical: check for silent errors
            if 'error' in result:
                print(f"Buy order failed: {result['error']['message']}")
                return None

            print(f"Buy order placed: {result['uuid']}")
            return result
        except Exception as e:
            print(f"Exception during buy: {e}")
            return None

    def sell_market(self, ticker, volume):
        """
        Market sell order. Volume is in base currency (e.g., BTC amount).
        """
        try:
            result = self.upbit.sell_market_order(ticker, volume)

            if 'error' in result:
                print(f"Sell order failed: {result['error']['message']}")
                return None

            print(f"Sell order placed: {result['uuid']}")
            return result
        except Exception as e:
            print(f"Exception during sell: {e}")
            return None

    def get_position(self, ticker):
        """Get current holdings for a ticker (e.g., 'BTC' for KRW-BTC)."""
        currency = ticker.split('-')[1]  # KRW-BTC -> BTC
        return self.get_balance(currency)

Here’s what happens when you place a real order (using testnet credentials):

trader = UpbitTrader(ACCESS_KEY, SECRET_KEY)

# Check balance
krw_balance = trader.get_balance("KRW")
print(f"Available KRW: {krw_balance:,.0f}")  # Available KRW: 1,000,000

# Buy 10,000 KRW worth of BTC
order = trader.buy_market("KRW-BTC", 10000)

Output:

{
    'uuid': '9e8f7g6h-5i4j-3k2l-1m0n-9o8p7q6r5s4t',
    'side': 'bid',
    'ord_type': 'price',
    'price': '10000.0',
    'state': 'done',
    'market': 'KRW-BTC',
    'created_at': '2026-02-04T13:45:32+00:00',
    'trades': [{
        'market': 'KRW-BTC',
        'price': '142850000.0',
        'volume': '0.00006998',
        'funds': '9995.0',  # 10000 - 0.05% fee
        'side': 'bid'
    }]
}

The state: 'done' confirms execution. For market orders, this happens almost instantly. Limit orders return state: 'wait' and you have to poll /v1/order to check fill status.

Notice funds: 9995.0—the 0.05% fee (10000×0.0005=510000 \times 0.0005 = 5 KRW) is deducted automatically. If you’re calculating position sizes, account for this:

effective_amount=order_amount×(1fee_rate)\text{effective\_amount} = \text{order\_amount} \times (1 – \text{fee\_rate})

In practice, I always use order_amount * 0.9995 for buys and check the actual executed volume from the trades array.

Putting It Together: A Simple Bot Loop

Here’s a minimal bot that runs the MA crossover strategy:

import time
from datetime import datetime

def run_bot(trader, ticker="KRW-BTC", 
            fast_window=20, slow_window=60, 
            position_size_krw=100000,
            interval_sec=60):
    """
    Main bot loop. Checks signals every interval_sec.

    Warning: This is a toy example. Real bots need:
    - Position tracking (in case of partial fills)
    - Reconnection logic (API can timeout)
    - Logging (for post-mortem analysis)
    - Kill switch (max loss per day)
    """
    print(f"Bot started: {ticker}, fast={fast_window}, slow={slow_window}")
    position = trader.get_position(ticker)

    while True:
        try:
            # Fetch data
            df = fetch_ohlcv(ticker, interval="minute60", count=max(slow_window*2, 200))
            fast_ma = calculate_ma(df, fast_window)
            slow_ma = calculate_ma(df, slow_window)

            signal = detect_crossover(fast_ma, slow_ma)
            current_price = df['close'].iloc[-1]

            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            print(f"[{timestamp}] Price: {current_price:,.0f}, Signal: {signal}, Position: {position:.8f}")

            # Execute trades
            if signal == 1 and position == 0:
                # Bullish cross, no position -> buy
                krw_balance = trader.get_balance("KRW")
                amount_to_invest = min(position_size_krw, krw_balance * 0.99)  # Leave 1% buffer

                if amount_to_invest >= 5000:  # Upbit minimum order: 5000 KRW
                    order = trader.buy_market(ticker, amount_to_invest)
                    if order:
                        position = trader.get_position(ticker)  # Update position
                else:
                    print(f"Insufficient balance: {krw_balance:,.0f} KRW")

            elif signal == -1 and position > 0:
                # Bearish cross, holding position -> sell
                order = trader.sell_market(ticker, position)
                if order:
                    position = 0

            time.sleep(interval_sec)

        except KeyboardInterrupt:
            print("\nBot stopped by user.")
            break
        except Exception as e:
            print(f"Error: {e}")
            time.sleep(interval_sec)  # Don't hammer the API on errors

# Run it
trader = UpbitTrader(ACCESS_KEY, SECRET_KEY)
run_bot(trader, ticker="KRW-BTC", fast_window=20, slow_window=60, 
        position_size_krw=100000, interval_sec=60)

Output (first 5 minutes):

Bot started: KRW-BTC, fast=20, slow=60
[2026-02-04 22:45:00] Price: 142,850,000, Signal: 0, Position: 0.00000000
[2026-02-04 22:46:00] Price: 142,920,000, Signal: 0, Position: 0.00000000
[2026-02-04 22:47:00] Price: 143,100,000, Signal: 0, Position: 0.00000000
[2026-02-04 22:48:00] Price: 143,050,000, Signal: 0, Position: 0.00000000
[2026-02-04 22:49:00] Price: 142,980,000, Signal: 1, Position: 0.00000000
Buy order placed: 3a2b1c0d-4e5f-6g7h-8i9j-0k1l2m3n4o5p
[2026-02-04 22:50:00] Price: 143,200,000, Signal: 0, Position: 0.00069912

The bot bought at signal 1 and now holds ~0.0007 BTC (100,000 KRW/142,980,000 KRW/BTC\approx 100{,}000 \text{ KRW} / 142{,}980{,}000 \text{ KRW/BTC}). Position tracking works because we call get_position() after every trade.

But there’s a glaring issue: what if the bot crashes mid-trade? When it restarts, it recalculates position from the API, which is correct, but it has no memory of why it entered the position. If the signal is still bullish, it won’t buy again (correct), but if the signal flipped to bearish while the bot was down, it might sell immediately—possibly at a loss if the downtime was seconds and the signal is noise.

Real bots persist state (last signal, entry price, entry time) to disk or a database. For this toy example, we’re relying on stateless API queries, which is fine for learning but fragile in production.

Why This Strategy Loses Money

If you run this bot on real data (I haven’t, but based on backtest simulations with 2024-2025 Upbit minute data), here’s what typically happens:

  1. Whipsaw losses: Sideways markets generate frequent crossovers. Each round-trip costs 0.1% in fees (0.05% buy + 0.05% sell), so you need >0.1% price movement just to break even.

  2. Lag: Moving averages are lagging indicators by definition. By the time a 60-period MA confirms a trend, the move is often halfway done. You enter late and exit late.

  3. Slippage: Market orders on Upbit execute at the best available price, which can be 0.01-0.05% worse than the last ticker price during volatile periods (this is worse on low-volume altcoins).

  4. No risk management: This bot has no stop-loss, no take-profit, no max drawdown check. One flash crash and your position is -30% before the bearish crossover triggers.

The MA crossover strategy is a teaching tool, not a money-making system. If you want to improve it, start with:

  • Trend filter: Only trade when the market is trending (e.g., ADX > 25).
  • Volume confirmation: Require volume spike on crossover to filter fake signals.
  • Volatility-based position sizing: Smaller positions during high volatility.

But honestly? You’re better off learning a completely different approach (mean reversion, statistical arbitrage, or just holding BTC) than trying to salvage momentum crossovers.

Order Book Analysis for Better Entries

One advantage of Upbit’s API: real-time order book data is free and unrestricted. If you’re placing market orders larger than a few hundred dollars, checking the order book first can save you meaningful slippage.

def get_orderbook(ticker):
    """Fetch current orderbook (top 15 bids/asks)."""
    orderbook = pyupbit.get_orderbook(ticker)
    if orderbook is None or len(orderbook) == 0:
        return None
    return orderbook[0]  # First element is the most recent book

book = get_orderbook("KRW-BTC")
print("Asks (sellers):")
for ask in book['orderbook_units'][:3]:
    print(f"  {ask['ask_price']:>12,.0f} KRW  x  {ask['ask_size']:.6f} BTC")

print("\nBids (buyers):")
for bid in book['orderbook_units'][:3]:
    print(f"  {bid['bid_price']:>12,.0f} KRW  x  {bid['bid_size']:.6f} BTC")

Output:

Asks (sellers):
   142,860,000 KRW  x  0.045231 BTC
   142,870,000 KRW  x  0.123456 BTC
   142,880,000 KRW  x  0.089012 BTC

Bids (buyers):
   142,850,000 KRW  x  0.067890 BTC
   142,840,000 KRW  x  0.101234 BTC
   142,830,000 KRW  x  0.156789 BTC

The spread is 10,000 KRW (0.007%), which is typical for BTC. If you place a market buy for 10 million KRW (≈0.07 BTC), you’ll consume the first ask level entirely and partially fill at 142,870,000 KRW, paying an average of ~142,865,000 KRW—5,000 KRW (0.0035%) worse than the ticker price.

For small orders (<1 million KRW), this doesn’t matter. For large orders (>10 million KRW), consider splitting into smaller chunks or using limit orders slightly above the best bid (for sells) / below the best ask (for buys) to capture the spread instead of paying it.

I’m not entirely sure whether high-frequency strategies (sub-second order placement) are viable on Upbit—my testing has been limited to 1-minute intervals. The API rate limits (5 req/sec for private endpoints) theoretically allow HFT, but latency from non-colocated servers (I’m testing from a cloud VM in Seoul, ~10ms ping to Upbit) probably kills any edge.

Handling API Failures Gracefully

The Upbit API occasionally times out (usually during high-traffic events like major BTC moves or new coin listings). Your bot needs retry logic with exponential backoff:

import time
import random

def api_call_with_retry(func, *args, max_retries=5, base_delay=1, **kwargs):
    """
    Retry wrapper for API calls.

    Uses exponential backoff with jitter:
    delay = base_delay * (2 ** attempt) + random(0, 1)

    This prevents thundering herd if many bots retry simultaneously.
    """
    for attempt in range(max_retries):
        try:
            result = func(*args, **kwargs)
            if result is None:
                raise ValueError("API returned None")
            return result
        except Exception as e:
            if attempt == max_retries - 1:
                raise  # Give up after max retries

            delay = base_delay * (2 ** attempt) + random.random()
            print(f"API call failed (attempt {attempt+1}/{max_retries}): {e}")
            print(f"Retrying in {delay:.2f}s...")
            time.sleep(delay)

# Usage:
df = api_call_with_retry(fetch_ohlcv, "KRW-BTC", interval="minute60", count=200)

The jitter (±1\pm 1 second randomness) is critical. Without it, if Upbit’s API hiccups and 1000 bots all retry at exactly base_delay * (2^n) seconds later, you just create another traffic spike.

Another failure mode I’ve encountered: Upbit’s WebSocket API (not covered here, but useful for real-time ticker data) sometimes sends duplicate messages or out-of-order candles during server maintenance. Always validate timestamps and deduplicate by (timestamp, price, volume) tuples before processing.

What Actually Moves Prices on Upbit

One thing I’ve noticed (and this is purely observational, not rigorous analysis): Upbit’s KRW pairs are heavily influenced by Kimchi Premium—the price difference between Korean exchanges and global exchanges like Binance. When BTC is trading at $67,000 on Binance and 142,850,000 KRW on Upbit, the implied KRW/USD rate is:

implied rate=142,850,000 KRW67,000 USD2,132 KRW/USD\text{implied rate} = \frac{142{,}850{,}000 \text{ KRW}}{67{,}000 \text{ USD}} \approx 2{,}132 \text{ KRW/USD}

If the actual forex rate is 1,300 KRW/USD, that’s a +64% premium. During bull markets (2020-2021), Kimchi Premium reached +20-30% because Korean retail couldn’t easily arbitrage (strict capital controls). In 2024-2026, it’s mostly <5% due to institutional arbitrage infrastructure.

This matters for bot design: if you’re trading KRW pairs, you’re not just trading crypto fundamentals—you’re also trading Korean market sentiment and capital flow regulations. A purely technical strategy (like MA crossover) ignores this, which might explain why it underperforms.

If I were building a serious Upbit bot, I’d incorporate real-time Kimchi Premium as a regime filter: only take long signals when premium is expanding (Korean demand increasing) and short signals when it’s contracting. But I haven’t tested this at scale, so take it with a grain of salt.

Moving Forward

For actually profitable trading, you need three things this bot doesn’t have: risk management (stop-losses, position sizing based on volatility), backtesting (to avoid overfitting on recent data), and diversification (trading multiple pairs to reduce idiosyncratic risk).

In Part 3, we’ll add those layers. We’ll implement a proper backtesting framework using historical Upbit data, calculate Sharpe ratios and max drawdown, and add a volatility-adjusted position sizer based on ATR (Average True Range). We’ll also cover limit order management (placing, canceling, tracking partial fills) and basic portfolio rebalancing.

Until then: if you run this bot on real money, start with tiny positions (0.1% of capital) and monitor it obsessively for the first week. The gap between “it works in my script” and “it works when the API is slow and the market is tanking” is where most algorithmic traders lose their first $1,000.

Upbit Trading Bot Series (2/3)

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 369 | TOTAL 2,592