website/content/blog/seven-mistakes-crypto-exchange-api-developers-make.mdx
Trading code fails in predictable ways. After a decade of maintaining integrations with 100+ exchanges, we've seen the same seven mistakes take down bots over and over — many of CCXT's design decisions exist precisely because of them. Here they are, roughly in the order they'll bite you.
console.log(0.1 + 0.2);
// 0.30000000000000004
print(0.1 + 0.2)
# 0.30000000000000004
var_dump(0.1 + 0.2 == 0.3);
// bool(false)
Console.WriteLine(0.1 + 0.2);
// 0.30000000000000004
fmt.Println(0.1 + 0.2)
// 0.30000000000000004
System.out.println(0.1 + 0.2);
// 0.30000000000000004
Binary floats cannot represent most decimal fractions — in every language. In trading code that's not a trivia point: it produces order amounts the exchange rejects, or worse, quietly wrong position sizes that compound over thousands of trades.
Internally, all CCXT arithmetic runs through Precise — decimal string math with no float
in sight, in every language. For your own code the important tools are the precision
helpers, which round any value to exactly what the exchange accepts:
const amount = exchange.amountToPrecision('BTC/USDT', rawAmount);
const price = exchange.priceToPrecision('BTC/USDT', rawPrice);
amount = exchange.amount_to_precision('BTC/USDT', raw_amount)
price = exchange.price_to_precision('BTC/USDT', raw_price)
$amount = $exchange->amount_to_precision('BTC/USDT', $rawAmount);
$price = $exchange->price_to_precision('BTC/USDT', $rawPrice);
var amount = exchange.amountToPrecision("BTC/USDT", rawAmount);
var price = exchange.priceToPrecision("BTC/USDT", rawPrice);
amount := exchange.AmountToPrecision("BTC/USDT", rawAmount)
price := exchange.PriceToPrecision("BTC/USDT", rawPrice)
var amount = exchange.amountToPrecision("BTC/USDT", rawAmount);
var price = exchange.priceToPrecision("BTC/USDT", rawPrice);
Every market also carries its precision and limits (minimum amount, minimum cost) in
the market structure — check them before ordering instead of parsing rejection errors after.
Every exchange enforces request-rate limits; exceed them and you get HTTP 429s, then a temporary IP ban — usually mid-trade, when your loop is at its busiest.
CCXT's built-in token-bucket rate limiter is enabled by default and knows each
exchange's per-endpoint costs: heavy calls consume a bigger share of the budget than light
ones. Keep it on, and design for it: cache loadMarkets() at startup instead of re-fetching,
batch with fetchTickers(symbols) instead of many fetchTicker calls, and use
WebSockets for anything you poll more than a
few times a minute.
BTCUSDT, XBTUSDT, BTC-USDT, tBTCUSD — every exchange names the same market
differently, and code with raw IDs sprinkled through it dies the day you add a second
exchange.
CCXT's rule: unified symbols in your code, exchange IDs at the wire, never mixed. You
write BTC/USDT everywhere (derivatives have unified forms too: BTC/USDT:USDT for the
linear perpetual swap), and the library maps to the exchange's ID internally. If you ever
need the raw ID — say, for an exchange-specific endpoint — derive it, don't hardcode it:
const market = exchange.market('BTC/USDT');
console.log(market.id); // 'BTCUSDT' on binance, 'XBTUSDT' elsewhere
market = exchange.market('BTC/USDT')
print(market['id']) # 'BTCUSDT' on binance, 'XBTUSDT' elsewhere
$market = $exchange->market('BTC/USDT');
echo $market['id']; // 'BTCUSDT' on binance, 'XBTUSDT' elsewhere
var markets = await exchange.LoadMarkets();
Console.WriteLine(markets["BTC/USDT"].id); // 'BTCUSDT' on binance
markets, _ := exchange.LoadMarkets()
fmt.Println(*markets["BTC/USDT"].Id) // 'BTCUSDT' on binance
var markets = exchange.loadMarkets(false);
System.out.println(markets.get("BTC/USDT").id); // 'BTCUSDT' on binance
The two deadly variants: crash on any error (bot dies at the first hiccup at 2 a.m.), or blindly retry everything (bot re-submits an order the exchange already rejected — or worse, already accepted).
CCXT normalizes every exchange's error zoo into one typed hierarchy, so your handler can be policy, not string matching:
NetworkError family — RequestTimeout, ExchangeNotAvailable, DDoSProtection,
RateLimitExceeded. Transient. Back off and retry.ExchangeError family — InvalidOrder, InsufficientFunds, AuthenticationError,
BadSymbol. Your request is wrong or your state is. Retrying identical input gives an
identical failure — fix the cause instead.try {
const order = await exchange.createOrder(symbol, 'limit', side, amount, price);
} catch (e) {
if (e instanceof ccxt.NetworkError) {
checkIfItWentThrough(); // see below, then maybe retry
} else if (e instanceof ccxt.ExchangeError) {
logAndStop(e); // do NOT blind-retry these
}
}
import ccxt
try:
order = exchange.create_order(symbol, 'limit', side, amount, price)
except ccxt.NetworkError:
check_if_it_went_through() # see below, then maybe retry
except ccxt.ExchangeError as e:
log_and_stop(e) # do NOT blind-retry these
try {
$order = $exchange->create_order($symbol, 'limit', $side, $amount, $price);
} catch (\ccxt\NetworkError $e) {
check_if_it_went_through(); // see below, then maybe retry
} catch (\ccxt\ExchangeError $e) {
log_and_stop($e); // do NOT blind-retry these
}
try
{
var order = await exchange.CreateOrder(symbol, "limit", side, amount, price);
}
catch (NetworkError e)
{
CheckIfItWentThrough(); // see below, then maybe retry
}
catch (ExchangeError e)
{
LogAndStop(e); // do NOT blind-retry these
}
order, err := exchange.CreateOrder(symbol, "limit", side, amount,
ccxt.WithCreateOrderPrice(price))
if err != nil {
if ccxtErr, ok := err.(*ccxt.Error); ok {
switch ccxtErr.Type {
case ccxt.RequestTimeoutErrType, ccxt.RateLimitExceededErrType,
ccxt.ExchangeNotAvailableErrType:
checkIfItWentThrough() // transient — then maybe retry
default:
logAndStop(ccxtErr) // do NOT blind-retry these
}
}
}
try {
var order = exchange.createOrder(symbol, "limit", side, amount, price, null);
} catch (io.github.ccxt.errors.NetworkError e) {
checkIfItWentThrough(); // see below, then maybe retry
} catch (io.github.ccxt.errors.ExchangeError e) {
logAndStop(e); // do NOT blind-retry these
}
The subtle case is a timeout on createOrder: the exchange may have accepted the order
even though your request timed out. Retrying naively doubles your position. The fix is
idempotency — send your own clientOrderId in params, and on timeout query for it before
retrying.
fetchOHLCV, fetchTrades, and fetchMyTrades return one page, capped at an
exchange-specific maximum (often 500–1000 rows). Code that calls them once and assumes it got
"the history" silently computes indicators on partial data.
Fetching a range means looping: request with since, take the last row's timestamp, request
again from there, stop when nothing new arrives. Respect each exchange's page cap (the
limit argument), and remember the rate limiter is spending budget on every page.
Signed requests embed a timestamp, and exchanges reject requests whose timestamp is too far from their server time. A clock a few seconds off produces baffling authentication failures that come and go — classically on a VPS or Raspberry Pi without NTP.
Run NTP. And most exchanges wired for it in CCXT accept an option that measures and corrects the offset for you:
const exchange = new ccxt.binance({
apiKey: KEY,
secret: SECRET,
options: {
adjustForTimeDifference: true,
},
});
exchange = ccxt.binance({
'apiKey': KEY,
'secret': SECRET,
'options': {
'adjustForTimeDifference': True,
},
})
$exchange = new \ccxt\binance([
'apiKey' => $key,
'secret' => $secret,
'options' => [
'adjustForTimeDifference' => true,
],
]);
var exchange = new Binance(new Dictionary<string, object>() {
{ "apiKey", key },
{ "secret", secret },
{ "options", new Dictionary<string, object>() {
{ "adjustForTimeDifference", true },
} },
});
exchange := ccxt.NewBinance(map[string]interface{}{
"apiKey": key,
"secret": secret,
"options": map[string]interface{}{
"adjustForTimeDifference": true,
},
})
var config = new java.util.HashMap<String, Object>();
config.put("apiKey", key);
config.put("secret", secret);
config.put("options", java.util.Map.of("adjustForTimeDifference", true));
var exchange = new Binance(config);
Nobody plans to test in production; people just don't know the alternatives exist:
exchange.setSandboxMode(true) switches CCXT to the exchange's testnet or demo
environment where one exists (Binance, Bybit, OKX, and many others). Play money, real API.None of these mistakes is exotic — that's the point. They're the standard tax every trading-bot developer pays once. CCXT can't stop you from paying it, but it can make the correct thing the default thing: string math, rate limiting on by default, unified symbols, typed errors, sandbox mode one call away.
New to the library? Start with Build your first crypto trading bot.