The first time a bot of mine sent an order to the wrong market, nothing looked broken. The code ran clean, the fill came back, the logs were green. What I had actually done was buy a USDC pair on one venue while my model thought it was tracking a USDT pair on another, and the two had drifted just enough that my nice tight spread trade was underwater from the first second. No exception, no alert, just a slow bleed that I chased for an afternoon before I realized the ticker strings were lying to me. That afternoon is the reason I now treat symbol normalization as a real subsystem and not a string format function.
Why the same coin has four names
Take Bitcoin against the dollar. On one exchange you will see BTC-USD, on another BTCUSDT, on a third BTC/USDT, and on Kraken you will see XBTUSD or XXBTZUSD depending on which endpoint you hit. A naive normalizer strips the punctuation, uppercases everything, and calls it a day. Now BTC-USD and BTCUSDT collapse to the same key, and you are treating a coin priced in actual US dollars as if it were the same thing as a coin priced in Tether. Most days they trade within a hair of each other, which is exactly what makes this bug so patient. It waits for the one day USDT wobbles, or the one venue where USD means a fiat rail with withdrawal limits and USDT means an instant on-chain move, and then it costs you.
The quote asset is the part people underweight. BTC, USDT, USDC, and USD are four distinct settlement assets with different redemption mechanics, different counterparty risk, and different fees to get in and out. A spread that looks like free money between BTCUSDT on one venue and BTCUSDC on another is often just the market pricing the cost of converting one stable to the other. If your registry flattens the quote, you will keep finding these fake edges and keep giving the money back.
Build a registry, not a parser
The fix that finally stuck for me was to stop parsing symbols at the point of use and instead maintain a canonical instrument registry. Every real instrument gets one internal identity that I control, and every exchange symbol maps into it. I like a tuple as the canonical key: base asset, quote asset, and instrument type. So BTC spot against Tether becomes something like (BTC, USDT, SPOT), and a Bitcoin perpetual quoted in USD becomes (BTC, USD, PERP). The exchange-native string, BTCUSDT or XBTUSD or whatever it is, becomes just an attribute hanging off that identity, not the identity itself.
The important discipline is that the mapping is explicit and data-driven, not inferred by a clever regex. Regexes feel great until Kraken hands you XXBTZUSD, or a venue lists a token whose ticker happens to be a substring of a stablecoin, or someone launches a coin literally called USD. I pull each exchange's instrument list from its own reference endpoint, which almost always gives you the base and quote broken out as separate fields, and I build the map from that. The exchange already knows the answer. Your job is to record it, not re-derive it.
Here is the rough shape of the workflow I run:
- On startup, and then on a schedule, fetch the full instrument or market list from each exchange's reference endpoint.
- For each listing, read the exchange-provided base and quote fields directly. Do not slice the combined symbol string yourself unless the exchange gives you nothing else.
- Normalize a handful of known aliases at the asset level, not the pair level. XBT becomes BTC. Any wrapped or venue-specific ticker for the same underlying resolves to one base.
- Record the instrument type from the exchange metadata. Spot, perpetual, dated future, and margin are different instruments even when the base and quote match.
- Store the mapping both directions. Canonical to native for placing orders, native to canonical for ingesting fills, trades, and websocket updates.
The quirks that will actually bite you
Kraken's XBT is the famous one, but it is the tame version of the problem because at least it is documented and consistent. The nastier cases are the ones where the same three letters mean different things. USD on a pure-crypto venue is very often a synonym for a stablecoin under the hood, while USD on a regulated venue is real fiat. If you map both to the same quote asset you will merge two order books that settle in completely different assets. I keep them separate and let a higher layer decide whether they are close enough to arb, rather than hard-coding that decision into the registry.
Instrument type is the other silent killer. BTC-USD as a spot pair and BTC-USD as a perpetual can share a display string on some venues, and if your key is just base plus quote you will happily route a spot order into a perp market or size a perp position as if it were spot. Contract multipliers make this worse. A single perp contract might represent a fixed dollar amount rather than one coin, so even once you have the right market, your quantity math has to come from the instrument metadata, not from an assumption.
A few rules of thumb I hold to now. Never let an order reach an exchange adapter as a native symbol string that some other part of the code guessed at. It should arrive as a canonical instrument identity and get translated once, at the boundary, by the same registry that ingested the market list. Fail loudly when a canonical instrument has no mapping on a target venue, because a missing map is a signal that the instrument does not exist there, and silently picking the closest match is how you end up long the wrong thing. And reconcile the registry on a schedule, because exchanges relist, rename, and delist, and a map you built last month is a map that has quietly gone stale.
Where this pays off
When you are routing across many venues at once, as we do inside Blockcircle for non-custodial execution, this registry becomes the thing that lets every other module speak one language. A signal fires against a canonical instrument, the position tracker reasons in canonical terms, and only the adapter at the very edge cares that this particular exchange spells it XBTUSD. The mapping layer is boring infrastructure, and boring is the point. You want the interesting behavior to live in your strategy, not in the question of which Bitcoin you just bought.
If you take one thing from this, make it the tuple. Base, quote, and type, mapped explicitly from each exchange's own reference data, with fiat and stablecoin quotes kept distinct until something above the registry decides otherwise. It is a couple of hundred lines and a scheduled refresh, and it quietly removes an entire category of loss that never shows up as an error.