Adding an exchange to tardis-node requires three things: mappers (transform raw exchange messages into normalized types), a real-time feed (WebSocket connection), and constant definitions.
In src/consts.ts:
- Add exchange ID to the exchanges array
- Add every channel exposed by the Exchanges API. For hosted exchanges, use
https://api.tardis.dev/v1/exchanges/{exchange}. If the exchange is not hosted yet, check the exchange definition in../tardis-api/src/routes/exchanges/{exchange}.ts.
Create src/mappers/{exchange}.ts. Each mapper class implements the Mapper interface — look at existing mapper implementations to find an exchange with a similar message format.
Before coding, inspect the contract in this order:
- Hosted Exchanges API:
https://api.tardis.dev/v1/exchanges/{exchange}owns the current channels, symbol ids, and instrument classification for hosted exchanges. - If the exchange is not hosted yet, use
../tardis-api/src/routes/exchanges/{exchange}.ts. src/types.tsowns normalized TypeScript shapes.src/mappers/index.tsowns mapper registration, normalizer coverage, and date-based API version selection.- Official exchange docs and captured raw messages own upstream payload meaning.
Make a coverage table from the channels exposed by the Exchanges API, exchange docs, and captured WebSocket messages. sourceFor is supporting context: use it to understand why a channel sources a normalized type, not as a replacement for inspecting the channel payload. For each channel and message variant, record the message role and the exact mapper action. Do not infer the role from the channel name alone; exchanges use different conventions for snapshots, deltas, events, subscription acknowledgements, cached payloads, and status messages.
Use this shape in the PR description or implementation notes:
| Channel | Message variants | Role | Normalizer | Mapper action | Test payload |
|---|---|---|---|---|---|
Mappers to implement depend on what the exchange provides: trades, book changes, tickers, derivative tickers, liquidations, book tickers, options summaries, etc. Do not stop at the channel list — inspect the fields each channel carries and map every supported normalized type. For example, a native ticker channel may produce BookTicker, while market stats may produce DerivativeTicker.
Mapper decisions to make explicit:
- Symbols — use the same exchange symbol value across mapper output, replay filters, real-time subscription filters, and customer-facing filters. If the exchange exposes more than one identifier, choose the identifier used by the Exchanges API and keep conversions explicit.
- Filters — implement
getFilters()for each mapper to request the channels needed by that normalizer inreplayNormalized()andstreamNormalized(). Return only channels defined for that exchange insrc/consts.ts. - IDs — preserve exchange identifiers without losing precision. Prefer string identifiers when the exchange provides them.
- Timestamps — use the exchange event timestamp for
timestamp. UselocalTimestamponly when the exchange does not provide a usable event time. Never replacelocalTimestamp; it is the Tardis receive timestamp for replay and streaming. - Message roles — map snapshots, deltas, trades, ticker updates, status messages, and acknowledgements according to the exchange contract. For order book data, make the
isSnapshotdecision from the actual message role, not from the channel name alone. - Normalized field semantics — map a field only when the exchange field has the same meaning as the normalized type. Leave ambiguous fields unmapped until the exchange meaning is verified from docs or captured data.
- Optional numeric fields — missing, empty, null, or non-finite exchange values must normalize to
undefined, notNaNor an invalidDate. See EXCHANGE_NUMERIC_FIELDS.md before choosing betweenNumber,asNumberOrUndefined, andasNonZeroNumberOrUndefined. - Stateful output — when normalized output is built from multiple partial messages, use the existing state helper patterns and emit only when the normalized value changes.
- Partial price feeds — standalone index, mark, oracle, or underlying price messages usually update cached mapper state only. Do not emit a
derivative_tickeroroption_summaryfrom a price-only message unless that message carries the full normalized contract for that type. Merge the cached price into the next ticker or option summary payload that owns the output timestamp.
Normalized type semantics:
- Trades —
sideis liquidity taker side:buymeans the aggressor bought,sellmeans the aggressor sold. Invert maker-side flags when needed. Skip off-book maintenance events such as insurance fund or ADL unless the product contract explicitly requires them. If a trade channel usessnapshotfollowed byupdate, map onlyupdate; the initialsnapshotis recent-trade backfill and must have a test that emits nothing to avoid duplicate or stale trades after reconnect. Map tradesnapshotonly when the exchange sends trades exclusively as snapshots and there is no incremental update variant. - Book changes —
book_changeis L2 market-by-price data.isSnapshot=truemeans consumers discard prior book state.isSnapshot=falsemeans consumers apply absolute price-level amounts to the current book.amount=0removes the level. - Book tickers —
book_tickercomes from native top-of-book or BBO feeds. It is notquotes, which are computed from reconstructed L2 books. - Derivative tickers — keep
lastPrice,openInterest,indexPrice,markPrice, funding fields, and predicted funding fields aligned with exchange meaning.fundingTimestampis the next funding event timestamp. When price fields come from separate channels, cache them and emit only from the ticker or stats payload that represents the derivative ticker update. - Liquidations —
sideis liquidation side:buymeans a short position was liquidated,sellmeans a long position was liquidated. Do not copy an exchange order side unless it has that meaning. - Option summaries — parse option type, strike, expiration, greeks, IV, underlying, bid/ask, mark, last price, and open interest from the exchange contract. Prefer explicit instrument metadata such as
indexAssetor underlying asset fields over symbol parsing when the exchange provides it.
For normalizeBookChanges, first identify where the initial book snapshot comes from:
- Native snapshot plus deltas: map the exchange snapshot as
isSnapshot=true, then map later deltas asisSnapshot=false. - Snapshot-only feed, such as a full L2 book pushed repeatedly: map each full book message as
isSnapshot=true. - Delta feed with a snapshot channel, such as Binance
depthSnapshotplusdepth:getFilters()must request both channels, buffer deltas until the snapshot arrives, emit one snapshot, then emit deltas. - Delta-only feed without a snapshot channel: do not mark a delta as a snapshot. Add a snapshot source first or leave the channel out of
normalizeBookChanges.
Register mapper factory in src/mappers/index.ts.
Create src/realtimefeeds/{exchange}.ts. Extend RealTimeFeedBase with:
- WebSocket URL
- Subscription message format
- Any exchange-specific hooks (decompression, heartbeat handling, error filtering)
Register in src/realtimefeeds/index.ts.
Add mapper tests in test/mappers.test.ts using real exchange payloads copied from docs or captured WebSocket messages. Keep them in the shared mapper snapshot test unless the exchange needs unusual replay behavior.
Mapper tests should cover:
- every mapper registered for the exchange
- each normalized data type the exchange supports
- representative message variants for each mapped channel
- order book snapshot and delta behavior, when the exchange provides both
- message variants that should intentionally emit nothing
- optional, missing, empty, and otherwise invalid values for fields that can be absent
- valid zero values for optional numeric fields, especially values cached through
PendingTickerInfoHelper - separate price/index/underlying messages that update mapper state without directly emitting normalized output
Run tests and validation — see AGENTS.md for the full checklist.
- Date-based mapper versioning — If the exchange changed its API format at some point, you may need different mapper implementations for different time periods. Look at existing examples in
src/mappers/index.tsfor the pattern. - Multi-connection feeds — Some exchanges need multiple WebSocket connections. The base class supports this via
MultiConnectionRealTimeFeedBase. - Decompression — Some exchanges compress WebSocket messages. Override the decompress hook if needed.
- Filter optimization — The base class has
optimizeFilters()for normalizing subscription filters. Override if the exchange needs special handling.