Back to Ccxt

Seven mistakes that blow up crypto trading bots (you're probably making #1 right now)

website/content/blog/seven-mistakes-crypto-exchange-api-developers-make.mdx

4.5.6510.4 KB
Original Source

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.

1. Doing money math with floats

javascript
console.log(0.1 + 0.2);
// 0.30000000000000004
python
print(0.1 + 0.2)
# 0.30000000000000004
php
var_dump(0.1 + 0.2 == 0.3);
// bool(false)
csharp
Console.WriteLine(0.1 + 0.2);
// 0.30000000000000004
go
fmt.Println(0.1 + 0.2)
// 0.30000000000000004
java
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:

javascript
const amount = exchange.amountToPrecision('BTC/USDT', rawAmount);
const price = exchange.priceToPrecision('BTC/USDT', rawPrice);
python
amount = exchange.amount_to_precision('BTC/USDT', raw_amount)
price = exchange.price_to_precision('BTC/USDT', raw_price)
php
$amount = $exchange->amount_to_precision('BTC/USDT', $rawAmount);
$price = $exchange->price_to_precision('BTC/USDT', $rawPrice);
csharp
var amount = exchange.amountToPrecision("BTC/USDT", rawAmount);
var price = exchange.priceToPrecision("BTC/USDT", rawPrice);
go
amount := exchange.AmountToPrecision("BTC/USDT", rawAmount)
price := exchange.PriceToPrecision("BTC/USDT", rawPrice)
java
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.

2. Ignoring rate limits until the ban

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.

3. Hardcoding exchange-specific symbols

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:

javascript
const market = exchange.market('BTC/USDT');
console.log(market.id);   // 'BTCUSDT' on binance, 'XBTUSDT' elsewhere
python
market = exchange.market('BTC/USDT')
print(market['id'])   # 'BTCUSDT' on binance, 'XBTUSDT' elsewhere
php
$market = $exchange->market('BTC/USDT');
echo $market['id'];   // 'BTCUSDT' on binance, 'XBTUSDT' elsewhere
csharp
var markets = await exchange.LoadMarkets();
Console.WriteLine(markets["BTC/USDT"].id);   // 'BTCUSDT' on binance
go
markets, _ := exchange.LoadMarkets()
fmt.Println(*markets["BTC/USDT"].Id)   // 'BTCUSDT' on binance
java
var markets = exchange.loadMarkets(false);
System.out.println(markets.get("BTC/USDT").id);   // 'BTCUSDT' on binance

4. Treating all errors the same

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 familyRequestTimeout, ExchangeNotAvailable, DDoSProtection, RateLimitExceeded. Transient. Back off and retry.
  • ExchangeError familyInvalidOrder, InsufficientFunds, AuthenticationError, BadSymbol. Your request is wrong or your state is. Retrying identical input gives an identical failure — fix the cause instead.
javascript
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
    }
}
python
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
php
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
}
csharp
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
}
go
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
        }
    }
}
java
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.

5. Assuming pagination just works

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.

6. Letting your clock drift

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:

javascript
const exchange = new ccxt.binance({
    apiKey: KEY,
    secret: SECRET,
    options: {
        adjustForTimeDifference: true,
    },
});
python
exchange = ccxt.binance({
    'apiKey': KEY,
    'secret': SECRET,
    'options': {
        'adjustForTimeDifference': True,
    },
})
php
$exchange = new \ccxt\binance([
    'apiKey' => $key,
    'secret' => $secret,
    'options' => [
        'adjustForTimeDifference' => true,
    ],
]);
csharp
var exchange = new Binance(new Dictionary<string, object>() {
    { "apiKey", key },
    { "secret", secret },
    { "options", new Dictionary<string, object>() {
        { "adjustForTimeDifference", true },
    } },
});
go
exchange := ccxt.NewBinance(map[string]interface{}{
    "apiKey": key,
    "secret": secret,
    "options": map[string]interface{}{
        "adjustForTimeDifference": true,
    },
})
java
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);

7. Testing with real money

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.
  • Keys with withdrawals disabled + IP allowlist — a trading bot never needs withdrawal permission. This turns a leaked key from a catastrophe into an inconvenience.
  • Minimum order sizes on the first live runs, with a kill switch you've actually tested.

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.