Infrastructure
OKX, Bybit, and Deribit: A Comparative Engineering Guide to Their APIs and Failure Modes
What the docs don't tell you about OKX unified accounts, Bybit session management, and Deribit's options infrastructure - with the production failure modes for each.
Every crypto exchange API looks similar in the documentation. WebSocket connections, REST fallback, JSON messages, rate limits, API keys. The illusion of uniformity breaks down the moment you run real strategies against multiple venues simultaneously. OKX’s unified account model changes risk calculations in ways the docs undersell. Bybit’s session management is brutally unforgiving on timing. Deribit’s options stream sizing has physical limits that will surprise you if you’ve only worked with perpetuals.
This post documents what you actually need to know to operate production trading systems on each of these venues.
OKX: Unified Account, Classic Account, and the Split That Breaks Your Assumptions
OKX migrated most accounts to their “Unified Account” model in 2022-2023. If your account was created recently, you almost certainly have a Unified Account. If you’re using an older account, you might be on Classic. The two have fundamentally different API structures.
Classic Account: Separate balances and endpoints for spot, futures, options, and swap (perpetuals). Separate WebSocket channels per product type. Risk is calculated per-portfolio independently.
Unified Account: Single margin pool across all instruments. One WebSocket connection with unified balance, positions, and P&L. Cross-margin and portfolio margin available.
The API base path differs:
Classic: GET /api/v5/account/balance (same path, different response schema)
Unified: GET /api/v5/account/balance (same path, different response schema)
Yes, same path, different response. The difference is visible in the response body - Classic returns per-product balances, Unified returns a cross-margin balance with adjEq (adjusted equity) that accounts for all open positions.
Authentication: login before private channels
This is the most common OKX WebSocket mistake. You must send a login frame before subscribing to any private channel (account, positions, orders). If you subscribe before login, you get a silent failure - the subscription appears to succeed (OKX sends a success response), but no data arrives.
Login frame:
import hmac
import hashlib
import base64
import time
import json
def build_okx_login_frame(api_key: str, passphrase: str, secret_key: str) -> str:
timestamp = str(int(time.time()))
message = timestamp + "GET" + "/users/self/verify"
signature = base64.b64encode(
hmac.new(secret_key.encode(), message.encode(), hashlib.sha256).digest()
).decode()
return json.dumps({
"op": "login",
"args": [{
"apiKey": api_key,
"passphrase": passphrase,
"timestamp": timestamp,
"sign": signature,
}]
})
# After login succeeds (wait for {"event": "login", "code": "0"}):
def build_subscribe_frame(channels: list[dict]) -> str:
return json.dumps({
"op": "subscribe",
"args": channels
})
# Subscribe to orders channel:
orders_channel = {
"channel": "orders",
"instType": "SWAP", # or SPOT, FUTURES, OPTION
"instFamily": "BTC-USDT",
"instId": "BTC-USDT-SWAP",
}
The login flow:
1. Open WebSocket
2. Send login frame
3. Wait for {"event": "login", "code": "0"}
4. Then send subscribe frames for private channels
If you see no data arriving on private channels, check that login completed before subscribe. Add a timeout - if login doesn’t complete in 5 seconds, disconnect and retry.
Rate Limits: IP vs UID - they stack independently
OKX has two rate limit axes:
IP-level limits: 20 req/2s for public endpoints
UID-level limits: 20 req/2s for private order placement, 60 req/2s for other private
Both can trigger independently:
- Your IP hits limit: HTTP 429, no new requests from this IP
- Your UID hits limit: HTTP 429, no new orders from this UID (but other IPs can still query public)
For high-frequency strategies that use multiple server IPs but a single account, you can be UID-limited even when not IP-limited. Track both.
Simulated Trading Environment
OKX has a simulated trading environment that’s notably more production-realistic than Binance testnet:
Simulated trading header: "x-simulated-trading: 1"
Add this header to any REST request or include it in the WebSocket login frame to route to simulated trading:
# REST
headers = {
"OK-ACCESS-KEY": api_key,
"OK-ACCESS-SIGN": signature,
"OK-ACCESS-TIMESTAMP": timestamp,
"OK-ACCESS-PASSPHRASE": passphrase,
"x-simulated-trading": "1", # Simulated trading
}
# WebSocket - include in login args
{
"op": "login",
"args": [{
"apiKey": api_key,
"passphrase": passphrase,
"timestamp": timestamp,
"sign": signature,
"simulate": "1", # Simulated trading for WS
}]
}
The simulated environment uses real market prices with paper balances. Significantly more useful than Binance testnet for behavioral testing.
Bybit: Session Management, Order States, and Linear vs Inverse
The 20-second ping requirement
Bybit disconnects any WebSocket connection that doesn’t send a ping message within 20 seconds. This is strict - exactly 20 seconds, not 25, not 30. Your ping must be the JSON frame {"op":"ping"} (not a WebSocket protocol-level ping).
import asyncio
async def bybit_ws_keepalive(ws) -> None:
"""Send Bybit ping every 15 seconds to prevent 20s disconnect."""
while True:
await asyncio.sleep(15)
try:
await ws.send_str('{"op":"ping"}')
except Exception:
return # Connection is dead, let the main loop handle it
The server responds with {"op":"pong","ret_msg":"","conn_id":"...","ret_code":0,"success":true}. If you don’t receive a pong within 5 seconds of your ping, treat the connection as dead.
Order Status Enum: PartiallyFilled vs New
Bybit’s order status transitions are more granular than most exchanges. The critical distinction:
Created → Order received but not yet in orderbook
New → Order is in orderbook, awaiting fill
PartiallyFilled → Order has been partially filled, remainder in orderbook
Filled → Order fully filled
Cancelled → Order cancelled (by user or by time-in-force)
Rejected → Order rejected by exchange (insufficient margin, etc.)
Deactivated → Conditional order not yet triggered
Do not conflate PartiallyFilled with Filled. A PartiallyFilled order still has quantity remaining in the orderbook. If you cancel your working orders and see a PartiallyFilled status, you have a fill you need to account for and a cancelled remainder.
This is the trap: engineers check status == "Filled" to confirm completion. A PartiallyFilled order that gets cancelled will show final status as Cancelled, but you still have a filled quantity you need to reconcile. The filled quantity is in cumExecQty.
def is_order_done(order: dict) -> bool:
"""Returns True if order is no longer working (for any reason)."""
terminal_states = {"Filled", "Cancelled", "Rejected", "Deactivated"}
return order['orderStatus'] in terminal_states
def get_filled_qty(order: dict) -> float:
"""Always use cumExecQty, not leavesQty, for fill accounting."""
return float(order.get('cumExecQty', 0))
Linear vs Inverse Contracts
Bybit has two contract families:
Linear (e.g., BTCUSDT):
- Margin and settlement in USDT
- P&L = qty × (exit_price - entry_price)
- Position size in BTC, value in USDT
Inverse (e.g., BTCUSD):
- Margin and settlement in BTC
- P&L = qty × (1/entry_price - 1/exit_price) × contract_size
- Position size in USD contracts (1 contract = 1 USD)
The API endpoint structure differs:
Linear base URL: https://api.bybit.com/v5/
Inverse base URL: https://api.bybit.com/v5/ (SAME base, different 'category' param)
Linear order: POST /v5/order/create with {"category": "linear", ...}
Inverse order: POST /v5/order/create with {"category": "inverse", ...}
Do not reuse parsers between linear and inverse - the P&L calculation is fundamentally different and your risk system will be wrong.
WebSocket channel authentication for Bybit V5:
def bybit_generate_signature(api_key: str, api_secret: str, expires: int) -> str:
val = f"GET/realtime{expires}"
return hmac.new(api_secret.encode(), val.encode(), hashlib.sha256).hexdigest()
expires = int((time.time() + 1) * 1000) # Milliseconds + 1s buffer
auth_frame = {
"op": "auth",
"args": [api_key, expires, bybit_generate_signature(api_key, api_secret, expires)]
}
Deribit: Options Infrastructure at Scale
Deribit is the dominant BTC and ETH options exchange. If you’re building options infrastructure, you need Deribit. Its API has significant structural differences from perpetuals-focused exchanges.
JSON-RPC over WebSocket
Deribit uses JSON-RPC 2.0 as the message protocol, not ad-hoc JSON. Every request has an id and every response correlates to that id. This is actually more rigorous than Binance or Bybit - you can match responses to requests unambiguously.
import asyncio
import json
import itertools
class DeribitRPCClient:
def __init__(self):
self._request_id = itertools.count(1)
self._pending: dict[int, asyncio.Future] = {}
async def call(self, method: str, params: dict) -> dict:
req_id = next(self._request_id)
future = asyncio.get_event_loop().create_future()
self._pending[req_id] = future
await self._ws.send_str(json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"method": method,
"params": params,
}))
return await asyncio.wait_for(future, timeout=10.0)
async def _handle_message(self, msg: dict) -> None:
# Responses have "id"
if "id" in msg:
req_id = msg["id"]
if req_id in self._pending:
if "error" in msg:
self._pending.pop(req_id).set_exception(
Exception(f"RPC error: {msg['error']}")
)
else:
self._pending.pop(req_id).set_result(msg["result"])
# Subscription notifications have "method": "subscription"
elif msg.get("method") == "subscription":
await self._dispatch_subscription(msg["params"])
Authentication
Deribit uses a timestamp-based HMAC, similar to other exchanges, but the signature input format differs:
def deribit_authenticate(api_key: str, api_secret: str) -> dict:
timestamp = str(int(time.time() * 1000)) # Milliseconds
nonce = str(int(time.time() * 1000000)) # Microseconds as nonce
data = "" # Empty for auth
string_to_sign = "\n".join([timestamp, nonce, data])
signature = hmac.new(
api_secret.encode(),
string_to_sign.encode(),
hashlib.sha256,
).hexdigest()
return {
"method": "public/auth",
"params": {
"grant_type": "client_signature",
"client_id": api_key,
"timestamp": timestamp,
"signature": signature,
"nonce": nonce,
"data": data,
}
}
Options Stream Sizing: The 100+ Concurrent Streams Problem
BTC has weekly, monthly, and quarterly option expirations with strike prices every $1,000 (or less near the money), times two (calls and puts), times multiple expirations. A full BTC options chain at any given moment might have 300-500 active instruments.
If you’re subscribing to Greeks (delta, gamma, vega, theta) for the full chain, you need one subscription per instrument. At 500 instruments, that’s 500 concurrent subscription channels.
Deribit allows up to 1,000 subscriptions per WebSocket connection, which sounds like it handles this. The real constraint is the message rate - during a volatile market, 500 instruments each sending 1 update/second = 500 messages/second on a single WebSocket connection. Your Python event loop needs to process all of them without falling behind.
In practice, you don’t need Greeks for all 500 instruments simultaneously. Subscribe lazily:
class DeribitOptionsSubscriber:
def __init__(self, client: DeribitRPCClient):
self._client = client
self._subscribed: set[str] = set()
async def subscribe_if_needed(self, instruments: list[str]) -> None:
"""Subscribe only to instruments we don't already track."""
new_instruments = [i for i in instruments if i not in self._subscribed]
if not new_instruments:
return
# Subscribe in batches of 50 to avoid large subscription frames
for i in range(0, len(new_instruments), 50):
batch = new_instruments[i:i+50]
channels = [f"ticker.{inst}.100ms" for inst in batch]
await self._client.call("private/subscribe", {"channels": channels})
self._subscribed.update(batch)
await asyncio.sleep(0.1) # Avoid rate limiting subscriptions
async def unsubscribe_expired(self, expired_instruments: list[str]) -> None:
"""Unsubscribe from expired instruments to free connection capacity."""
to_unsub = [i for i in expired_instruments if i in self._subscribed]
if not to_unsub:
return
channels = [f"ticker.{inst}.100ms" for inst in to_unsub]
await self._client.call("private/unsubscribe", {"channels": channels})
self._subscribed -= set(to_unsub)
Taker Fee for Options: It’s Not What You Expect
Deribit charges options taker fees as a percentage of the option premium, with a floor:
Options taker fee: max(0.03% × underlying notional, 12.5% × option value)
At low option values (deep OTM), the 12.5% × option value floor is binding. A 1.25 minimum taker fee. This dramatically changes the economics of trading cheap OTM options at high frequency.
For delta hedging in the underlying perpetual:
Perpetual taker fee: 0.05% of notional
Your strategy’s profitability calculation must incorporate both.
Instrument Name Format
Deribit instrument names have a specific format you must parse correctly:
BTC-31MAY24-70000-C → BTC call option, expires 31 May 2024, $70,000 strike
ETH-29MAR24-3500-P → ETH put option, expires 29 March 2024, $3,500 strike
BTC-PERPETUAL → BTC perpetual swap
BTC-31MAY24 → BTC futures expiring 31 May 2024 (no strike = not options)
Parse these programmatically - do not hardcode instrument names:
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class DeribitInstrument:
base: str # BTC, ETH
expiry: Optional[datetime]
strike: Optional[int]
option_type: Optional[str] # 'C' or 'P'
is_perpetual: bool
is_future: bool
def parse_deribit_instrument(name: str) -> DeribitInstrument:
# Perpetual
if name.endswith("-PERPETUAL"):
base = name.replace("-PERPETUAL", "")
return DeribitInstrument(base=base, expiry=None, strike=None,
option_type=None, is_perpetual=True, is_future=False)
parts = name.split("-")
# Option: BASE-DDMMMYY-STRIKE-TYPE (4 parts)
if len(parts) == 4:
base, expiry_str, strike_str, opt_type = parts
expiry = datetime.strptime(expiry_str, "%d%b%y")
return DeribitInstrument(base=base, expiry=expiry, strike=int(strike_str),
option_type=opt_type, is_perpetual=False, is_future=False)
# Future: BASE-DDMMMYY (2 parts after split on -)
if len(parts) == 2:
base, expiry_str = parts
expiry = datetime.strptime(expiry_str, "%d%b%y")
return DeribitInstrument(base=base, expiry=expiry, strike=None,
option_type=None, is_perpetual=False, is_future=True)
raise ValueError(f"Unknown instrument format: {name}")
How This Breaks in Production
1. OKX private channel subscription before login
Symptom: Your OKX position updates and order fills never arrive via WebSocket. REST calls to get positions work fine.
Root cause: Your WebSocket subscribe frame was sent before the login response was received. OKX silently accepts the subscription and sends a success response, but no data flows until authenticated.
Fix: Implement a login_event: asyncio.Event that is set when {"event": "login", "code": "0"} arrives. Block all subscribe calls on this event.
2. Bybit 20-second disconnect during strategy startup Symptom: Your strategy connects to Bybit and immediately starts processing - but 20 seconds later the connection drops. The first 20 seconds of data look fine, then silence. Root cause: Strategy startup takes longer than 20 seconds (loading models, warming up caches). The WebSocket connection is opened at startup but the keepalive ping loop doesn’t start until initialization completes. Fix: Start the keepalive coroutine immediately on WebSocket open, before any other initialization. Don’t gate it on strategy readiness.
3. Bybit PartiallyFilled order treated as incomplete
Symptom: Reconciliation reports position discrepancies. Strategy thinks it has no fill, but exchange shows partial fill that was then cancelled.
Root cause: Strategy checks order.status == "Filled" to confirm execution. Order was partially filled then cancelled (market moved away). Final status is “Cancelled”, so strategy ignores cumExecQty.
Fix: Always reconcile cumExecQty at order terminal state regardless of whether status is “Filled” or “Cancelled”. The fill happened - it must be booked.
4. Deribit subscription accumulation across expiries Symptom: Over several weeks, Deribit WebSocket connection becomes progressively slower. Message processing latency increases. Eventually connection drops due to subscription overflow. Root cause: Options expire every week. You’re adding subscriptions for new expirations but never unsubscribing from expired instruments. After 8 weeks you have subscriptions for 8x the number of active instruments. Fix: Implement subscription lifecycle management. On each weekly expiration, unsubscribe from all expired instruments before subscribing to the new expiry.
5. OKX UID rate limit hit on burst order placement Symptom: During a fast-moving market, order placement returns HTTP 429 even though IP weight is well below limits. Other non-order REST calls continue to work. Root cause: UID-level order rate limit (20 orders/2s) was exceeded by a burst placement strategy. IP limit and UID limit are independent - you hit UID limit while being fine on IP. Fix: Implement separate tracking for IP weight and UID order count. Throttle order placement specifically against the UID limit.
6. Deribit JSON-RPC timeout on slow response
Symptom: Intermittently, strategy stalls for 10 seconds then throws a timeout exception. Occurs more frequently during high volatility.
Root cause: Deribit RPC response time exceeds your 10-second timeout during heavy load. The pending future times out, but the response arrives shortly after - causing your correlation map to have stale entries.
Fix: On timeout, clean up the pending future from self._pending. When a late response arrives for an ID that no longer exists in pending, discard it rather than logging an error. Also consider shorter timeouts (3-5s) with explicit retry logic.
For managing connections to Binance alongside these venues, see Binance Connectivity Deep Dive. For the reconnection and resynchronization machinery these connections need, see WebSocket at HFT Scale. For routing orders across these venues intelligently, see Building a Smart Order Router for Fragmented Crypto Liquidity.
Continue Reading
Enjoyed this?
Get one deep infrastructure insight per week.
Free forever. Unsubscribe anytime.
You're in. Check your inbox.