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.
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/USDTis spot.BTC/USDT:USDTis a linear perpetual swap settled in USDT.BTC/USD:BTCis 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
defaultTypeonly. 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_marketis 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
- ccxt manual, unified symbols and market structure — https://docs.ccxt.com/#/README?id=market-structure
- ccxt manual, contract naming conventions — https://docs.ccxt.com/#/README?id=contract-naming-conventions
- Binance USD-M futures API — https://developers.binance.com/docs/derivatives/usds-margined-futures/general-info
Add a label
Embed this post
<iframe src="https://hibug.me/ccxt-spot-vs-perpetual-symbol-mismatch" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Related Stories
Order state machine crashes with unknown order: the fill arrived before the REST response
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.