Bybit Unified Account API with Python: Complete Guide
Master Bybit's Unified Account API using Python — from authentication and account setup to live order execution and position management for algo traders.
Master Bybit's Unified Account API using Python — from authentication and account setup to live order execution and position management for algo traders.
Bybit's Unified Trading Account (UTA) changed the game for serious traders. Instead of juggling separate spot, derivatives, and options wallets, you get a single margin pool that works across all product types. For Python developers building bots or systematic strategies, this means one API surface to master instead of three. The pybit library is the official Python SDK, and it abstracts most of the complexity — but knowing what's happening underneath is what separates a bot that works from one that blows up at 3am.
Before writing a single line of code, understand what 'unified' actually means in practice. On Bybit, the Unified Account consolidates margin across USDT Perpetuals, USDC Perpetuals, Inverse Contracts, Spot, and Options into one account. Your BTC held as collateral can back a USDT-margined ETH short — that cross-margining is the core feature. Contrast this with Binance, where you still manage separate Sub-wallets for different product lines, or OKX's Unified Account which works similarly to Bybit's but has different tier requirements. The practical implication for your Python bot: all account state queries go to one endpoint, and your position risk is portfolio-level, not per-product.
Bybit offers two account modes: Classic and Unified. Most new accounts default to Unified. Verify your account type in the dashboard before testing — Classic accounts use different endpoints and the pybit session classes differ.
Start by installing pybit, the officially maintained Python SDK for Bybit. It handles request signing, rate limiting, and WebSocket reconnection logic so you don't have to reinvent those wheels.
pip install pybit python-dotenv
Store your API credentials in a .env file — never hardcode them in source files, especially if you're pushing to GitHub. Create your API key in Bybit's account settings under API Management, and enable the permissions you actually need (read-only for monitoring bots, trade permissions only for execution bots).
import os
from dotenv import load_dotenv
from pybit.unified_trading import HTTP
load_dotenv()
API_KEY = os.getenv("BYBIT_API_KEY")
API_SECRET = os.getenv("BYBIT_API_SECRET")
# testnet=True for paper trading, testnet=False for live
session = HTTP(
testnet=False,
api_key=API_KEY,
api_secret=API_SECRET
)
# Verify connectivity and check account type
try:
info = session.get_account_info()
account_type = info["result"]["unifiedMarginStatus"]
print(f"Account type: {account_type}")
# 1 = Classic, 3 = Unified Margin, 4 = UTA Pro
except Exception as e:
print(f"Auth failed: {e}")
The unifiedMarginStatus field tells you exactly what mode you're in. Status 4 is UTA Pro with portfolio margin — the full unified experience. Status 3 is standard Unified Account. If you get status 1 you're on Classic and need to upgrade through the Bybit UI before the unified endpoints will return meaningful data.
The wallet balance endpoint is your single source of truth for capital available across the entire unified account. It returns equity, unrealized PnL, available margin, and a breakdown by coin — all in one call.
def get_account_state(session):
"""Fetch full unified account snapshot."""
try:
response = session.get_wallet_balance(accountType="UNIFIED")
if response["retCode"] != 0:
raise Exception(f"API error: {response['retMsg']}")
account = response["result"]["list"][0]
total_equity = float(account["totalEquity"])
total_unrealized_pnl = float(account["totalPerpUPL"])
available_to_withdraw = float(account["totalAvailableBalance"])
print(f"Total Equity: ${total_equity:,.2f}")
print(f"Unrealized PnL: ${total_unrealized_pnl:,.2f}")
print(f"Available Balance: ${available_to_withdraw:,.2f}")
# Coin-level breakdown
for coin in account["coin"]:
if float(coin["walletBalance"]) > 0:
print(f" {coin['coin']}: {coin['walletBalance']} "
f"(usd val: {coin['usdValue']})")
return account
except KeyError as e:
print(f"Unexpected response structure: {e}")
return None
account_data = get_account_state(session)
For open positions, use get_positions with category set to 'linear' for USDT perpetuals or 'inverse' for coin-margined contracts. The unified account returns all positions regardless of which sub-product they belong to, so you can build a complete portfolio view without multiple calls.
def get_open_positions(session, symbol=None):
"""Get all open positions or filter by symbol."""
params = {
"category": "linear",
"settleCoin": "USDT"
}
if symbol:
params["symbol"] = symbol
response = session.get_positions(**params)
if response["retCode"] != 0:
raise Exception(f"Positions fetch failed: {response['retMsg']}")
positions = response["result"]["list"]
active = [p for p in positions if float(p["size"]) > 0]
for pos in active:
side = pos["side"] # Buy or Sell
size = pos["size"]
entry = pos["avgPrice"]
pnl = pos["unrealisedPnl"]
liq_price = pos["liqPrice"]
print(f"{pos['symbol']} {side} {size} @ {entry} | "
f"uPnL: {pnl} | Liq: {liq_price}")
return active
positions = get_open_positions(session)
Order placement on the Unified Account uses the place_order endpoint with a category parameter that routes to the correct product. For most systematic traders, linear USDT perpetuals are the primary instrument — deep liquidity, straightforward funding, and the unified margin means you can hold spot BTC as collateral while running perp strategies simultaneously.
def place_limit_order(session, symbol, side, qty, price, reduce_only=False):
"""
Place a limit order on Bybit USDT perpetuals.
side: 'Buy' or 'Sell'
qty: position size in base currency (e.g. '0.01' for 0.01 BTC)
price: limit price as string
"""
try:
response = session.place_order(
category="linear",
symbol=symbol,
side=side,
orderType="Limit",
qty=str(qty),
price=str(price),
timeInForce="PostOnly", # maker-only, avoids taker fees
reduceOnly=reduce_only,
closeOnTrigger=False
)
if response["retCode"] != 0:
print(f"Order rejected: {response['retMsg']}")
return None
order_id = response["result"]["orderId"]
print(f"Order placed: {order_id}")
return order_id
except Exception as e:
print(f"Order placement error: {e}")
return None
def cancel_order(session, symbol, order_id):
"""Cancel an open order by ID."""
response = session.cancel_order(
category="linear",
symbol=symbol,
orderId=order_id
)
if response["retCode"] == 0:
print(f"Order {order_id} cancelled")
else:
print(f"Cancel failed: {response['retMsg']}")
# Example: place a buy limit 0.01 BTC at $65,000
order_id = place_limit_order(
session,
symbol="BTCUSDT",
side="Buy",
qty=0.01,
price=65000
)
# Cancel it if needed
if order_id:
cancel_order(session, "BTCUSDT", order_id)
Use PostOnly for limit orders when you want to guarantee maker fee rates. Bybit's maker fee on VIP0 is 0.01% vs 0.06% taker. On a $1M monthly volume that difference is $500. PostOnly orders get rejected if they would immediately fill — handle that case in your error logic.
REST polling is fine for account queries and order management, but for real-time price data and trade signals you need WebSocket streams. Bybit's public WebSocket feeds orderbook, trades, and kline data with sub-100ms latency. For private streams (your positions and orders updating in real time), you authenticate the WebSocket connection with a time-limited signature.
If you're using VoiceOfChain for trade signals, the workflow typically looks like: VoiceOfChain fires a signal → your Python bot receives it via webhook or its own analysis → WebSocket confirms current price before execution → REST call places the order. This pattern avoids the race condition of placing orders on stale REST data.
from pybit.unified_trading import WebSocket
from time import sleep
def handle_orderbook(message):
"""Process orderbook update."""
data = message["data"]
best_bid = float(data["b"][0][0]) if data["b"] else None
best_ask = float(data["a"][0][0]) if data["a"] else None
if best_bid and best_ask:
spread = best_ask - best_bid
mid = (best_bid + best_ask) / 2
print(f"BTC | Bid: {best_bid} | Ask: {best_ask} | "
f"Spread: {spread:.1f} | Mid: {mid:.1f}")
def handle_private_orders(message):
"""Process private order updates."""
for order in message["data"]:
print(f"Order update: {order['orderId']} "
f"status={order['orderStatus']} "
f"filled={order['cumExecQty']}/{order['qty']}")
# Public WebSocket — no auth needed
ws_public = WebSocket(
testnet=False,
channel_type="linear"
)
ws_public.orderbook_stream(
depth=1,
symbol="BTCUSDT",
callback=handle_orderbook
)
# Private WebSocket — needs API credentials
ws_private = WebSocket(
testnet=False,
channel_type="private",
api_key=API_KEY,
api_secret=API_SECRET
)
ws_private.order_stream(callback=handle_private_orders)
print("Streaming... Ctrl+C to stop")
try:
while True:
sleep(1)
except KeyboardInterrupt:
ws_public.exit()
ws_private.exit()
Production bots fail at the error handling layer, not the happy path. Bybit returns error codes in retCode — zero means success, anything else is a problem. The most common issues are rate limits (retCode 10006), invalid API key after rotation (retCode 10003), and insufficient margin errors (retCode 110007). Build a retry wrapper that handles transient errors automatically but surfaces permanent errors immediately.
import time
import logging
from functools import wraps
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Bybit error codes worth handling specifically
ERROR_RATE_LIMIT = 10006
ERROR_INVALID_KEY = 10003
ERROR_INSUFFICIENT_MARGIN = 110007
ERROR_ORDER_NOT_EXISTS = 110001
def bybit_request(max_retries=3, backoff=1.5):
"""Decorator for Bybit API calls with retry + error handling."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(max_retries):
try:
response = func(*args, **kwargs)
ret_code = response.get("retCode", -1)
if ret_code == 0:
return response
# Non-retryable errors — fail fast
if ret_code in (ERROR_INVALID_KEY, ERROR_INSUFFICIENT_MARGIN):
logger.error(f"Fatal API error {ret_code}: "
f"{response.get('retMsg')}")
return None
# Rate limit — back off and retry
if ret_code == ERROR_RATE_LIMIT:
wait = backoff ** attempt
logger.warning(f"Rate limited, waiting {wait:.1f}s")
time.sleep(wait)
continue
logger.error(f"API error {ret_code}: {response.get('retMsg')}")
last_error = response
except Exception as e:
logger.error(f"Request exception (attempt {attempt+1}): {e}")
time.sleep(backoff ** attempt)
return last_error
return wrapper
return decorator
@bybit_request(max_retries=3)
def safe_get_balance(session):
return session.get_wallet_balance(accountType="UNIFIED")
@bybit_request(max_retries=2)
def safe_place_order(session, **kwargs):
return session.place_order(**kwargs)
# Usage
balance = safe_get_balance(session)
if balance:
print(f"Equity: {balance['result']['list'][0]['totalEquity']}")
Bybit's rate limits for the Unified Account REST API are generous compared to, say, Coinbase Advanced Trade or Gate.io — 10 requests per second for most order endpoints at VIP0. The WebSocket approach eliminates most polling entirely, so you rarely need to worry about rate limits in a well-designed bot. Keep a request counter per second in production and log any 10006 responses as they indicate architectural issues, not transient noise.
| Endpoint | Limit | Window |
|---|---|---|
| Place/Amend/Cancel Order | 10 req/s | Per second |
| Get Orders (open) | 10 req/s | Per second |
| Get Positions | 10 req/s | Per second |
| Get Wallet Balance | 10 req/s | Per second |
| WebSocket (private) | Unlimited | Per connection |
The code patterns above give you a working foundation, but production bots need a few more layers: persistent order state (a local SQLite or Redis cache so you don't lose track of order IDs across restarts), a heartbeat monitor that alerts you if the WebSocket drops and reconnects fail, and position reconciliation that compares your bot's internal state against the actual Bybit API state at startup and periodically during operation. These aren't exotic requirements — they're the difference between a demo bot and one you can run unattended.
Pair your execution bot with quality signal sources. VoiceOfChain aggregates real-time order flow, large trade detection, and momentum signals across major markets — the kind of data that tells you whether the $65,000 BTC limit you just placed is sitting in front of accumulation pressure or about to get run through by a whale offloading. Technical execution on Bybit's Unified Account API handles the 'how to trade' — solid signal sourcing handles the 'when and which direction.' Both halves matter.