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.

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 ( KRW) is deducted automatically. If you’re calculating position sizes, account for this:
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 (). 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:
-
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.
-
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.
-
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).
-
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 ( 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:
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.
Did you find this helpful?
☕ Buy me a coffee
Leave a Reply