A client for the routing service, which holds live order books across roughly 60 venues and answers “what is the cheapest way to turn asset X into asset Y right now, and on which venues?” — book-walked to your actual size, fee-adjusted, and split across venues when that beats any single one.
A multi-venue execution engine for plans you build yourself. It never asks where a plan came from, so your own strategy gets the notional cap, reconciliation between hops, resting-order cleanup and the unwind plan. This half needs no service at all.
It is not an exchange. It does not extend Exchange, has no unified methods, and is constructed directly.
const venues = { mexc: new ccxt.mexc ({ apiKey: '…', secret: '…' }) };
const router = new ccxt.OrderRouter ({ venues });
const route = await router.fetchRoute ('USDT', 'BTC', { amountIn: 20 });
const report = await router.execute (route);
Execute places orders. Calling execute is the instruction. There is no permission flag beside it, exactly as createOrder has none. Pass dryRun: true to rehearse instead — that makes not one call against a venue, not even a read.
Every endpoint is public. No API key, no signup, no login. It rate-limits by client IP instead: 5 requests per second on a fixed one-second window. Every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset, and a 429 carries retry-after — trust those headers over any figure written down.
The client still accepts an apiKey and sends it as x-api-key when you pass one, so a deployment that fronts the service with its own authentication keeps working. With no key the header is omitted entirely rather than sent empty.
The full contract is published as OpenAPI 3.1 at docs.ccxt.com/router/openapi.yaml.
When nothing can be routed, fetchRoute returns a normal result carrying unroutableReason — it does not throw. Refusing to quote is a deliberate outcome.
Handing the router your venues says where you can trade: routes are filtered to them, and execute sends orders to those same instances. That filter costs nothing, touches no venue and cannot go stale, so it is always on.
Whether the router also reads your wallets is a separate mode, because it is a separate decision with a real price — one authenticated call per venue, holdings that go stale the moment anything moves, and a bad key on any one venue failing the whole quote.
| Constructed with | fetchRoute costs | You get |
|---|---|---|
{ venues } | one HTTP request, no venue touched | the best price on venues you can trade |
{ venues, trackBalances: true } | each wallet read once, then one request | the best price you can actually fund |
There is deliberately nothing in between, and no expiry to tune. A router that half-knows your balances is worse than one that knows none of them.
Under trackBalances the wallets are read once and cached, the way loadMarkets caches, so a quote stays a single HTTP request however often you call it. loadBalances() primes that cache at start-up and loadBalances(true) refreshes it — but you rarely need either, because a live execute drops the cache itself. Placing an order is precisely what makes cached holdings wrong, and it is dropped before dispatch: a run that throws half way through has still moved money. A rehearsal reaches no venue and keeps the cache.
For real-time holdings, drive it yourself — watchBalance() on your own pro instances, then router.invalidateBalances(). The router never opens a socket you did not open.
execute accepts a route as well as a plan, and does the rest itself: builds the plan, loads each venue's markets, and runs the safety check against that venue's real market rules. It throws on a blocking violation rather than returning it — a refusal a caller can forget to read is not a refusal.
| Code | Meaning | |
|---|---|---|
invalid_step | blocks | non-positive amount or price, or a side that is neither buy nor sell |
unknown_symbol | blocks | the symbol is not listed on that venue |
market_mismatch | blocks | the venue's market trades a different pair than the route hop claims |
amount_below_minimum | blocks | under the venue's minimum size |
amount_above_maximum | blocks | over the venue's maximum size |
cost_below_minimum | blocks | notional under the venue's minimum cost |
price_out_of_range | blocks | limit price outside the venue's price band |
notional_exceeds_cap | blocks | over your maxNotionalUsd |
notional_unvaluable | blocks | cannot be valued in USD, so the cap cannot be enforced |
amount_precision | advisory | the amount needs rounding to the venue's tick |
price_precision | advisory | the limit price needs rounding to the venue's tick |
The two advisory codes fire on essentially every real route and are not failures. Plan-level codes — empty_plan, route_unroutable, partial_fill — behave the same way.
The check is pure and public, so you can run it yourself without the throw: checkExecutionPlanSafety(plan, markets, options), with marketsOf(venues) building the markets argument.
| Option | Default | Notes |
|---|---|---|
strategy | sequential | how orders go out; also parallel_within_hop, limit_protected, best_effort, atomic_ish |
dryRun | false | it trades; only an exact true rehearses |
slippageBps | 25 | the limit sits 0.25% on the side that costs you |
reconcileToleranceRatio | 0.02 | a hop 2% short halts the route |
maxNotionalUsd | no cap | opt-in; needs usdRates to be evaluated at all |
maxPlanAgeMs | no limit | an age that cannot be determined blocks under an active limit |
retryFailedSteps | 0 | retries only definite rejections |
trackBalances | false | constructor option |
timeoutMs | 30000 | constructor option |
Set these before real money. maxNotionalUsd and usdRates — the cap is one guardrail in two parts and neither alone does anything. Then maxPlanAgeMs, or a stale quote executes at any age.
allowReexecution to override deliberately.outcome_unknown step is never retried, at any setting. It may already be a live position, and re-placing it is the double-fill this class exists to prevent.report.halted, haltStepIndex and haltReason say where and why a run stopped; openOrders says what was left resting and why.options.orderParams travels untouched.{ balances: { mexc: { USDT: 100, BTC: 0.5 } } } // per venue
{ balances: { USDT: 100 } } // wherever you hold it
{ balances: 'mexc.USDT:100,mexc.BTC:0.5' } // already rendered
Anything that cannot be rendered is refused locally, naming both accepted forms, rather than travelling to the service to be rejected there.
| Status | Raises | Meaning |
|---|---|---|
400 | BadRequest | malformed request |
403 | PermissionDenied | not authentication. The service has no credentials to reject — its only 403 is the IPv4-only refusal. Reach it over IPv4. |
429 | RateLimitExceeded | the retry interval is folded into the message |
503 | ExchangeNotAvailable | cache cold — alive, too few fresh books to rank on. Retry. |
404 / 501 | — | returned as a result carrying unroutableReason, not thrown |
Over a websocket the same vocabulary applies: close code 1008 maps to BadRequest and 1013 to ExchangeNotAvailable, so there is no second set of names to learn.
/stream/route accepts no balances: a socket outlives the holdings it was opened with.Reference for ccxt.OrderRouter, available in TypeScript, JavaScript, Python, PHP, C#, Go and Rust with identical behaviour.
Rust installs venues and the step hook through set_venues / set_on_step rather than constructor keys, because its Value type cannot carry them. The behaviour is the same.