Crypto Trading Systems Bug Report

ccxt orders went to spot instead of perpetual futures: BTC/USDT vs BTC/USDT:USDT

A hedging bot meant to short perpetual futures placed spot sell orders instead, selling inventory it was supposed to hedge. In ccxt's unified symbols, BTC/USDT is spot and BTC/USDT:USDT is the linear perpetual. Explicit market types and assertions on market metadata fixed it.

Ryan Pham Ryan Pham @ryan_bugs · August 07, 2025 · 6 min read · 9 Views
Banner: ccxt symbol mix-up sending futures orders to the spot market

We run a hedging service: when our inventory of BTC grows, it opens a short position in the linear perpetual contract to neutralise price exposure. After a refactor, the morning risk report showed inventory down and no futures position. The service had sold spot BTC instead of shorting the perpetual.

09:12:44 INFO hedge: create_order('BTC/USDT', 'market', 'sell', 0.75) -> id=38812001 status=closed

Reproducing it

import ccxt
ex = ccxt.binance({"apiKey": KEY, "secret": SECRET})
ex.load_markets()
m = ex.market("BTC/USDT")
print(m["type"], m["spot"], m["swap"], m["linear"])
# spot True False None

The refactor had removed options={"defaultType": "swap"} from the exchange constructor, moving it into a config file that this service did not load. With the default type, BTC/USDT resolves to the spot market.

What we ruled out

  • ccxt bug. The behaviour matches ccxt's documented unified symbol conventions.
  • Exchange-side routing. The order was submitted to the spot endpoint by our code.

Root cause

ccxt uses unified symbols that encode market type:

  • BTC/USDT is spot.
  • BTC/USDT:USDT is a linear perpetual swap settled in USDT.
  • BTC/USD:BTC is an inverse perpetual settled in BTC.
  • Dated futures add an expiry, such as BTC/USDT:USDT-250926.

Our hedging code used BTC/USDT and relied on the exchange instance's defaultType option to route to futures. When that option disappeared, the same symbol meant a different market, silently.

What is happening under the hood

Exchanges like Binance have separate APIs for spot, USD-M futures and COIN-M futures, with overlapping symbol names (BTCUSDT exists on spot and on USD-M futures). ccxt unifies them and disambiguates through the :settle suffix. For backwards compatibility, some exchange classes also look at options["defaultType"] to decide which market type to use for ambiguous calls, such as balance queries.

The market structure returned by ex.market(symbol) includes type (spot, swap, future, option), boolean flags (spot, swap, future, contract), linear, inverse, settle, contractSize and the exchange-specific id. These fields are the reliable way to know what you are trading.

Environment

Component Version / value
Library ccxt 4.4 (Python)
Exchange Binance spot and USD-M futures
Service Hedging service, Python 3.12
Change Exchange options moved to a shared config file

Minimal reproduction

spot = ccxt.binance(); spot.load_markets()
print(spot.market("BTC/USDT")["type"])           # spot
print(spot.market("BTC/USDT:USDT")["type"])      # swap

The fix

1. Use fully qualified unified symbols for derivatives everywhere: BTC/USDT:USDT for the linear perpetual. The hedging config now stores the full symbol.

2. Assert market metadata before every order. The order manager declares the expected market type per strategy and checks it:

EXPECTED = {"hedger": {"type": "swap", "linear": True, "settle": "USDT"}}

def check_market(ex, strategy: str, symbol: str):
    m = ex.market(symbol)
    exp = EXPECTED[strategy]
    for k, v in exp.items():
        if m.get(k) != v:
            raise WrongMarket(f"{strategy}: {symbol} has {k}={m.get(k)!r}, expected {v!r}")
    return m

3. Contract size awareness. For derivatives, quantity is in contracts. On Binance USD-M linear perpetuals contract size is 1 (quantity in BTC), but other venues and inverse contracts differ. The order manager converts the hedge amount using market["contractSize"].

4. Separate exchange instances per market type, constructed in one place with explicit options and tested, instead of passing options around.

Alternatives and trade-offs

  • Rely on defaultType only. Works until configuration changes; it is invisible at the call site.
  • Exchange-native SDKs per market. Makes market type explicit by client, but loses ccxt's unified layer.

How to test the fix

Tests load markets from recorded load_markets() fixtures and check that the hedger's symbols resolve to linear swaps, and that check_market raises for spot symbols. On the futures testnet, a dry run opened and closed a small short position.

Confirming in production

  • Hedge orders go to the USD-M perpetual market; the risk report shows inventory and hedge positions matching.
  • We bought back the spot inventory sold by mistake; the cost was a small spread and fees.

Regression guard

  • check_market is mandatory in the order manager for every order.
  • An alert if any service places an order on a market type not declared in its config.
  • Daily position report compares intended hedge ratio to actual positions across spot and futures.

What we would do differently

Configuration that changes the meaning of identifiers should never be implicit. The defaultType option changed what BTC/USDT meant for every call through that exchange instance, and the refactor that moved it looked harmless in review because the call sites did not change. We now prefer identifiers that are unambiguous on their own, such as fully qualified symbols, and we review configuration moves with the same care as code changes. Our code review checklist has a line for it: "does this change alter what any identifier means at runtime?"

Timeline

The wrong orders were placed at 09:12. The morning risk report flagged the missing hedge at 09:40, the service was halted at 09:44, and the spot inventory was bought back and the hedge opened manually by 10:05. The fix was deployed the same afternoon, with the market assertion running in shadow mode for a day before enforcing.

Security implications

Trading the wrong instrument is a risk-control failure that can create exposure in the opposite direction to what was intended. The hedging service's API key has futures trading permission only; spot trading permission was removed after this incident, so a symbol mix-up now fails at the exchange instead of executing. Least-privilege keys turn some logic bugs into harmless errors.

Prevention checklist

  • Use fully qualified ccxt symbols with settle currency for derivatives.
  • Assert market type and settlement before placing orders.
  • Account for contract size in derivatives.
  • Give each key only the market permissions its service needs.
  • Construct exchange instances in one place with explicit options.

References

Add a label

#python #trading-bot #ccxt #futures #market-types
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.