ccxt-rust

v2026.09.24

CCXT cryptocurrency exchange library for Rust developers. Covers both REST API (standard) and WebSocket API (real-time). Helps install CCXT, connect to exchanges, fetch market data, place orders, stream live tickers/orderbooks, handle authentication, and manage errors in Rust projects. Use when working with crypto exchanges in Rust applications, trading bots, or low-latency services. Async (tokio), typed wrappers returning Result<T, ExchangeError>.

GitHub
安装命令
npx skhub add ccxt/ccxt-rust
Markdown
SKILL.md

CCXT for Rust

A comprehensive guide to using CCXT in Rust projects for cryptocurrency exchange integration.

Every exchange has a typed wrapper (ccxt::Binance, ccxt::Kraken, …) exposing the unified CCXT API with native Rust return types — Ticker, Order, OrderBook, Market — instead of a dynamic value. All methods are async and return Result<T, ExchangeError>.

Installation

REST API

cargo add ccxt tokio --features tokio/full

WebSocket API (ccxt.pro)

cargo add ccxt-pro

Prediction markets

cargo add ccxt-prediction

Cargo.toml

[dependencies]
ccxt = "4.5.75"                 # REST exchanges (typed) — required
ccxt-pro = "4.5.75"             # WebSocket (watch*) exchanges — only if you stream
ccxt-prediction = "4.5.75"      # prediction markets — only if you trade them
tokio = { version = "1", features = ["full"] }

Requirements

  • Rust stable, edition 2021 or later.
  • A tokio runtime — every unified method is async. There is no sync API.
  • ccxt alone is enough for REST. Add ccxt-pro only when you need watch_*; it is a separate crate so a REST-only build does not compile the whole WebSocket surface.

Quick Start

REST API

use ccxt::{Binance, Params};

#[tokio::main]
async fn main() -> Result<(), ccxt::ExchangeError> {
    let mut exchange = Binance::new(None);
    exchange.load_markets(false).await;

    let ticker = exchange.fetch_ticker("BTC/USDT", Params::none()).await?;
    println!("{} last={:?} bid={:?} ask={:?}", ticker.symbol, ticker.last, ticker.bid, ticker.ask);
    Ok(())
}

WebSocket API — real-time updates

use ccxt::Params;
use ccxt_pro::Binance;

#[tokio::main]
async fn main() -> Result<(), ccxt::ExchangeError> {
    let mut exchange = Binance::new(None);
    exchange.try_load_markets(false).await?;

    loop {
        let ticker = exchange.watch_ticker("BTC/USDT", Params::none()).await?;
        println!("{:?}", ticker.last); // live updates
    }
}

Each watch_* call resolves to one decoded update, so consuming a stream is just calling it in a loop.

Crate layout

CrateContainsUse when
ccxtTyped REST wrappers (ccxt::Binance, …), Params, Config, types::*, TypedExchange. Re-exports the whole engine at its root.Always
ccxt-proTyped WebSocket wrappers (ccxt_pro::Binance, …) with watch_*Streaming
ccxt-predictionTyped prediction-market wrappers (ccxt_prediction::Kalshi, …)Prediction markets
ccxt-baseThe untyped engine (Value, HTTP, crypto, rate limiter, Cores, WS infra)Rarely direct — ccxt re-exports it

ccxt re-exports ccxt-base at its root, so ccxt::Value, ccxt::runtime::…, ccxt::exchanges::binance::BinanceCore resolve alongside the typed ccxt::Binance.

Coverage today: 105 typed REST venues, 76 typed WebSocket venues, 7 prediction venues.

REST vs WebSocket

FeatureREST APIWebSocket API
Use forOne-time queries, placing ordersReal-time monitoring, live price feeds
Crateccxtccxt-pro
Importuse ccxt::Binance;use ccxt_pro::Binance;
Methodsfetch_* (fetch_ticker, fetch_order_book)watch_* (watch_ticker, watch_order_book)
SpeedSlower (HTTP request/response)Faster (persistent connection)
Rate limitsStrict (1–2 req/sec)More lenient (continuous stream)
Best forTrading, account managementPrice monitoring, arbitrage detection

Both crates expose a type named Binance. When you use both in one file, alias one of them:

use ccxt::Binance as BinanceRest;
use ccxt_pro::Binance as BinanceWs;

Runtime setup

Two settings matter in real programs and are easy to miss:

fn main() {
    // 1. The transpiled core signals errors by panicking across an internal
    //    catch_unwind; the typed layer turns that back into `Result`. Silencing
    //    the default hook stops caught panics from printing to stderr.
    //    Set CCXT_SHOW_PANICS=1 to see them while debugging.
    if std::env::var("CCXT_SHOW_PANICS").is_err() {
        std::panic::set_hook(Box::new(|_| {}));
    }

    // 2. The generated exchange code is deeply nested — give worker threads a
    //    large stack. The default 2 MB can overflow on some venues.
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .thread_stack_size(64 * 1024 * 1024)
        .enable_all()
        .build()
        .unwrap();

    rt.block_on(run());
}

#[tokio::main] is fine for short examples and scripts; use the explicit builder for anything long-running.

Creating an Exchange Instance

Public (no authentication)

use ccxt::Binance;

let mut exchange = Binance::new(None);

Private (with credentials), via the Config builder

use ccxt::{Binance, Config, Params};

let mut exchange = Binance::with_config(
    Config::new()
        .api_key("YOUR_API_KEY")
        .secret("YOUR_SECRET")
        .enable_rate_limit(true)       // on by default
        .timeout_ms(10_000)
        .option_str("defaultType", "swap")
        .option("fetchMarkets", Params::new().with_strs("types", &["spot", "linear"])),
);

Config covers every credential (api_key, secret, password, uid, wallet_address, private_key, token), plus sandbox, verbose, enable_rate_limit, rate_limit_ms, timeout_ms, and arbitrary properties via set_str / set_int / set_float / set_bool. options nests exactly as in the other bindings: option_str/option_int/option_bool/ option_strs for a flat key, option(key, Params) for a nested object, options(Params) for a whole block. Repeated calls deep-merge rather than replace.

Settings after construction

The core's fields are dynamic, and the wrapper derefs read-only, so exchange.verbose = true does not compile. Use the setters — they chain:

let mut exchange = Binance::new(None);
exchange.set_api_key("YOUR_API_KEY");
exchange.set_secret("YOUR_SECRET");
exchange.set_verbose(true).set_timeout_ms(10_000);
exchange.set_enable_rate_limit(true);
exchange.set_options(Params::new().with_str("defaultType", "spot"));

println!("{} {} {}", exchange.id(), exchange.is_verbose(), exchange.is_sandbox_mode_enabled());

Sandbox / testnet

// at construction
let mut a = Binance::with_config(Config::new().sandbox(true));

// or afterwards — returns Err(NotSupported) when the venue has no testnet
let mut b = Binance::new(None);
b.set_sandbox_mode(true)?;

Choosing an exchange at runtime

from_id / from_id_with_config build any supported venue from its id and hand back a trait object; the typed API stays available through TypedExchangeExt.

use ccxt::{from_id_with_config, Config, Params, TypedExchange, TypedExchangeExt};

for id in ["binance", "bybit", "okx"] {
    let Some(mut exchange) = from_id_with_config(id, Config::new().enable_rate_limit(true)) else {
        continue; // unknown id
    };
    exchange.load_markets(false).await;
    let ticker = exchange.fetch_ticker("BTC/USDT", Params::none()).await?;
    println!("{id} {:?}", ticker.last);
}

Box<dyn TypedExchange> works because TypedExchange is object-safe; the ergonomic typed methods live on the blanket TypedExchangeExt, so both traits must be in scope. The same pair exists in ccxt_pro (for watch_*) and ccxt_prediction.

Write generic code the same way:

async fn best_bid<E: TypedExchange + TypedExchangeExt>(ex: &mut E) -> Result<Option<f64>, ccxt::ExchangeError> {
    Ok(ex.fetch_ticker("BTC/USDT", Params::none()).await?.bid)
}

The params argument

Every unified method ends with params — the venue-specific knobs. On the typed layer that is a Params builder over Rust primitives, never a dynamic value:

use ccxt::Params;

// nothing extra — these three are equivalent
exchange.fetch_ticker("BTC/USDT", Params::none()).await?;
exchange.fetch_ticker("BTC/USDT", ()).await?;
exchange.fetch_ticker("BTC/USDT", [("recvWindow", "5000")]).await?;

// venue-specific extras
exchange.create_order(
    "BTC/USDT", "limit", "buy", 0.001, Some(50_000.0),
    Params::new()
        .with_str("clientOrderId", "my-order-1")
        .with_bool("postOnly", true)
        .with_str("timeInForce", "GTC")
        .with_float("triggerPrice", 49_000.0)
        .with_int("recvWindow", 5_000)
        .with_strs("clientOrderIds", &["a", "b"]),
).await?;

Builders: with_str, with_int, with_float, with_bool, with_strs, with_json, with_params (nested object). Entries keep insertion order, which some signing routines depend on. Unified params (clientOrderId, postOnly, timeInForce, reduceOnly, triggerPrice, …) mean the same thing on every venue — CCXT translates them into whatever the exchange wants on the wire.

Common REST Operations

Loading markets

// Untyped, panics on failure — fine for scripts
exchange.load_markets(false).await;

// Preferred: fallible and typed
let markets: Vec<ccxt::types::Market> = exchange.try_load_markets(false).await?;

Loading markets is required before any call that resolves a unified symbol.

Market metadata

Trading rules can be checked locally, before sending an order and without an extra request:

let market = exchange.market("BTC/USDT")?;          // Err(BadSymbol) when not listed
println!("{} {} active={}", market.symbol, market.market_type, market.active);
println!("min amount {:?}  min cost {:?}", market.limits.amount.min, market.limits.cost.min);
println!("amount step {:?}  price tick {:?}", market.precision.amount, market.precision.price);

let swaps: Vec<_> = exchange.markets().into_iter().filter(|m| m.swap && m.active).collect();
let symbols: Vec<String> = exchange.symbols();
let currencies = exchange.currencies();

Market fields: id, symbol, base, quote, settle, base_id, quote_id, market_type, spot, margin, swap, future, option, active, contract, linear, inverse, taker, maker, limits, precision, raw.

Fetching a ticker

let ticker = exchange.fetch_ticker("BTC/USDT", Params::none()).await?;
println!("{:?}", ticker.last);           // last price
println!("{:?}", ticker.bid);            // best bid
println!("{:?}", ticker.ask);            // best ask
println!("{:?}", ticker.base_volume);    // 24h volume
println!("{:?}", ticker.timestamp);

// Multiple tickers (if supported) -> HashMap<String, Ticker>
let tickers = exchange
    .fetch_tickers(Some(vec!["BTC/USDT".into(), "ETH/USDT".into()]), Params::none())
    .await?;
for (symbol, t) in &tickers {
    println!("{symbol} {:?}", t.last);
}

Numeric fields are Option<f64> — a venue that does not publish a field yields None, not 0.0.

Fetching an order book

let book = exchange.fetch_order_book("BTC/USDT", Some(5), Params::none()).await?;
if let Some(bid) = book.bids.first() {
    println!("top bid price={} amount={}", bid[0], bid[1]);
}
if let Some(ask) = book.asks.first() {
    println!("top ask price={} amount={}", ask[0], ask[1]);
}

bids / asks are Vec<[f64; 2]> — [price, amount], best first. Pass None for full depth.

Fetching OHLCV (candlesticks)

use ccxt::types::OHLCV;   // = [f64; 6]

let candles: Vec<OHLCV> = exchange
    .fetch_ohlcv("BTC/USDT", Some("1h"), None, Some(100), Params::none())
    .await?;

for c in &candles {
    println!("ts={} o={} h={} l={} c={} v={}", c[0], c[1], c[2], c[3], c[4], c[5]);
}

Fetching trades

// Recent public trades
let trades = exchange.fetch_trades("BTC/USDT", None, Some(50), Params::none()).await?;

// Your trades (requires authentication)
let my_trades = exchange.fetch_my_trades(Some("BTC/USDT"), None, Some(50), Params::none()).await?;

Fetching balance

let balance = exchange.fetch_balance(()).await?;
println!("{:?}", balance.free.get("USDT"));    // available
println!("{:?}", balance.used.get("USDT"));    // held in orders
println!("{:?}", balance.total.get("USDT"));   // free + used

free / used / total are HashMap<String, f64> keyed by currency code; balance.info holds the raw venue payload.

Creating orders

// Generic
let order = exchange
    .create_order("BTC/USDT", "limit", "buy", 0.001, Some(50_000.0), Params::none())
    .await?;
println!("{:?} {:?} {:?}", order.id, order.status, order.filled);

// Market orders take `None` for price
let order = exchange
    .create_order("BTC/USDT", "market", "sell", 0.001, None, Params::none())
    .await?;

// Convenience constructors
let o = exchange.create_limit_buy_order("BTC/USDT", 0.001, 50_000.0, Params::none()).await?;
let o = exchange.create_limit_sell_order("BTC/USDT", 0.001, 60_000.0, Params::none()).await?;
let o = exchange.create_market_buy_order("BTC/USDT", 0.001, Params::none()).await?;
let o = exchange.create_market_sell_order("BTC/USDT", 0.001, Params::none()).await?;
let o = exchange.create_market_buy_order_with_cost("BTC/USDT", 100.0, Params::none()).await?;

// Trigger / conditional
//                                       symbol      side    amount  price     trigger
let o = exchange.create_stop_limit_order("BTC/USDT", "sell", 0.001, 47_900.0, 48_000.0, Params::none()).await?;
//                                        symbol      side    amount  trigger
let o = exchange.create_stop_market_order("BTC/USDT", "sell", 0.001, 48_000.0, Params::none()).await?;
//                                    symbol      type     side    amount  price           trigger
let o = exchange.create_trigger_order("BTC/USDT", "limit", "sell", 0.001, Some(47_900.0), Some(48_000.0), Params::none()).await?;

Managing orders

let open = exchange.fetch_open_orders(Some("BTC/USDT"), None, None, Params::none()).await?;
let closed = exchange.fetch_closed_orders(Some("BTC/USDT"), None, None, Params::none()).await?;
let all = exchange.fetch_orders(Some("BTC/USDT"), None, None, Params::none()).await?;
let one = exchange.fetch_order("12345", Some("BTC/USDT"), Params::none()).await?;

let edited = exchange
    .edit_order("12345", "BTC/USDT", "limit", "buy", Some(0.002), Some(49_000.0), Params::none())
    .await?;

let canceled = exchange.cancel_order("12345", Some("BTC/USDT"), Params::none()).await?;
let batch = exchange.cancel_orders(vec!["1".into(), "2".into()], Some("BTC/USDT"), Params::none()).await?;
let everything = exchange.cancel_all_orders(Some("BTC/USDT"), Params::none()).await?;

Order fields: id, client_order_id, symbol, timestamp, datetime, status ("open" | "closed" | "canceled" | "expired"), order_type, side, price, amount, filled, remaining, cost, fee, raw. Note order_type — type is a Rust keyword.

Positions (derivatives)

let positions = exchange.fetch_positions(None, Params::none()).await?;
for p in &positions {
    println!("{} {:?} contracts={:?} entry={:?} upnl={:?}",
        p.symbol, p.side, p.contracts, p.entry_price, p.unrealized_pnl);
}

let one = exchange.fetch_position("BTC/USDT:USDT", Params::none()).await?;
let closed = exchange.close_position("BTC/USDT:USDT", Some("long"), Params::none()).await?;

WebSocket Operations (Real-time)

All examples use ccxt_pro. Load markets once before watching.

Watching a ticker

use ccxt::Params;
use ccxt_pro::Binance;

let mut exchange = Binance::new(None);
exchange.try_load_markets(false).await?;

loop {
    match exchange.watch_ticker("BTC/USDT", Params::none()).await {
        Ok(t) => println!("{:?} {:?}", t.last, t.timestamp),
        Err(e) => { eprintln!("[{}] {}", e.kind, e.message); break; }
    }
}

Watching an order book

loop {
    let book = exchange.watch_order_book("BTC/USDT", Some(20), Params::none()).await?;
    println!("{:?} {:?}", book.bids.first(), book.asks.first());
}

limit is best-effort — some venues only publish a full book, so expect more levels than requested.

Watching trades

loop {
    let trades = exchange.watch_trades("BTC/USDT", None, Some(50), Params::none()).await?;
    for t in &trades {
        println!("{:?} {:?} {:?} {:?}", t.datetime, t.side, t.price, t.amount);
    }
}

One update carries the batch of trades the venue published, not a single trade.

Watching OHLCV

loop {
    let candles = exchange.watch_ohlcv("BTC/USDT", Some("1m"), None, None, Params::none()).await?;
    if let Some(c) = candles.last() {
        println!("close={} volume={}", c[4], c[5]);
    }
}

Watching multiple symbols on one connection

let symbols = vec!["BTC/USDT".to_string(), "ETH/USDT".to_string()];

let trades = exchange.watch_trades_for_symbols(symbols.clone(), None, None, Params::none()).await?;
let book = exchange.watch_order_book_for_symbols(symbols, Some(10), Params::none()).await?;
let tickers = exchange.watch_tickers(Some(vec!["BTC/USDT".into()]), Params::none()).await?;

These multiplex over a single WebSocket connection instead of opening one per symbol.

Watching your orders / trades / balance / positions (auth required)

use ccxt::{Config, Params};
use ccxt_pro::Binance;

let mut exchange = Binance::with_config(Config::new().api_key("KEY").secret("SECRET"));
exchange.try_load_markets(false).await?;

loop {
    let orders = exchange.watch_orders(Some("BTC/USDT"), None, None, Params::none()).await?;
    for o in &orders {
        println!("{:?} {:?} {:?}", o.id, o.status, o.filled);
    }
}
let my_trades = exchange.watch_my_trades(Some("BTC/USDT"), None, None, Params::none()).await?;
let balance = exchange.watch_balance(Params::none()).await?;
let positions = exchange.watch_positions(None, None, None, Params::none()).await?;

watch_orders and watch_my_trades share one user-data stream and one authentication, so subscribing to both costs a single connection.

Concurrent subscriptions

A watch_* call needs &mut self, so two streams from the same instance cannot be awaited concurrently. Either interleave them in one loop, or give each stream its own instance:

let mut a = ccxt_pro::Binance::new(None);
let mut b = ccxt_pro::Binance::new(None);
a.try_load_markets(false).await?;
b.try_load_markets(false).await?;

let (btc, eth) = tokio::join!(
    a.watch_ticker("BTC/USDT", Params::none()),
    b.watch_ticker("ETH/USDT", Params::none()),
);

For many symbols on one venue, prefer the *_for_symbols variants above — one connection, one task.

Closing connections

There is no typed close() and no typed un_watch_* yet. WebSocket clients live in a global registry keyed by URL, so dropping the exchange value does not by itself disconnect. To force a disconnect, drop the client for that URL:

ccxt::pro::ws_client::drop_client("wss://stream.binance.com:9443/ws");

For a process that streams until exit, doing nothing is fine.

Complete Method Reference

Rust method names are the snake_case form of the unified CCXT names — fetchOHLCV is fetch_ohlcv, createOrder is create_order, watchOrderBook is watch_order_book.

124 unified REST methods are available as typed wrappers (ccxt), and 25 watch_* methods on top of those in ccxt-pro. Anything outside that list is still reachable untyped — see Untyped escape hatch.

Market data

  • fetch_markets(params) — all markets
  • fetch_currencies(params) — all currencies
  • fetch_ticker(symbol, params) / fetch_tickers(symbols, params)
  • fetch_spot_tickers(symbols, params) / fetch_contract_tickers(symbols, params)
  • fetch_bids_asks(symbols, params) — best bid/ask for many symbols
  • fetch_mark_price(symbol, params) / fetch_mark_prices(symbols, params)
  • fetch_order_book(symbol, limit, params) / fetch_order_books(symbols, limit, params)
  • fetch_l3_order_book(symbol, limit, params)
  • fetch_trades(symbol, since, limit, params)
  • fetch_ohlcv(symbol, timeframe, since, limit, params)
  • fetch_spot_ohlcv / fetch_contract_ohlcv / fetch_index_ohlcv / fetch_mark_ohlcv / fetch_premium_index_ohlcv
  • fetch_time(params) — server time
  • fetch_status(params) — exchange status

Account & balance

  • fetch_balance(params) 🔒
  • fetch_free_balance / fetch_used_balance / fetch_total_balance / fetch_partial_balance 🔒
  • fetch_ledger(code, since, limit, params) / fetch_ledger_entry(id, code, params) 🔒
  • fetch_transactions / fetch_deposits / fetch_withdrawals / fetch_deposits_withdrawals 🔒
  • fetch_borrow_interest(code, symbol, since, limit, params) 🔒
  • fetch_cross_borrow_rate(code, params) / fetch_isolated_borrow_rate(symbol, params)

Trading

Creating:

  • create_order(symbol, type, side, amount, price, params) 🔒
  • create_limit_order / create_market_order 🔒
  • create_limit_buy_order / create_limit_sell_order 🔒
  • create_market_buy_order / create_market_sell_order 🔒
  • create_market_order_with_cost / create_market_buy_order_with_cost / create_market_sell_order_with_cost 🔒
  • create_stop_order / create_stop_limit_order / create_stop_market_order 🔒
  • create_stop_loss_order / create_take_profit_order / create_trigger_order 🔒
  • create_trailing_amount_order / create_trailing_percent_order 🔒
  • create_post_only_order / create_reduce_only_order / create_twap_order 🔒
  • create_order_with_take_profit_and_stop_loss(...) 🔒
  • create_orders(orders, params) — batch 🔒

Managing:

  • fetch_order(id, symbol, params) / fetch_order_with_client_order_id 🔒
  • fetch_orders / fetch_open_orders / fetch_closed_orders / fetch_canceled_orders / fetch_canceled_and_closed_orders 🔒
  • fetch_order_status(id, symbol, params) / fetch_order_trades(id, symbol, since, limit, params) 🔒
  • fetch_my_trades(symbol, since, limit, params) 🔒
  • edit_order(id, symbol, type, side, amount, price, params) / edit_orders / edit_order_with_client_order_id / edit_limit_order / edit_limit_buy_order / edit_limit_sell_order 🔒
  • cancel_order(id, symbol, params) / cancel_orders(ids, symbol, params) / cancel_all_orders(symbol, params) / cancel_orders_for_symbols(orders, params) / cancel_order_with_client_order_id / cancel_orders_with_client_order_ids 🔒

Derivatives & futures

  • fetch_position(symbol, params) / fetch_positions(symbols, params) / fetch_positions_for_symbol / fetch_positions_risk 🔒
  • fetch_position_history / fetch_positions_history 🔒
  • close_position(symbol, side, params) / close_all_positions(params) 🔒
  • fetch_leverage(symbol, params) / fetch_leverages(symbols, params) / fetch_market_leverage_tiers(symbol, params)
  • fetch_margin_mode(symbol, params) / fetch_margin_modes(symbols, params)
  • fetch_funding_rate(symbol, params) / fetch_funding_rates(symbols, params) / fetch_funding_interval / fetch_funding_intervals
  • fetch_open_interest(symbol, params) / fetch_open_interests / fetch_open_interest_history
  • fetch_liquidations(symbol, since, limit, params) / fetch_my_liquidations 🔒
  • fetch_greeks(symbol, params) / fetch_all_greeks(symbols, params) — options

Fees, deposits, withdrawals, transfers

  • fetch_trading_fee(symbol, params) / fetch_trading_fees(params) 🔒
  • fetch_deposit_address(code, params) / fetch_deposit_addresses(codes, params) / fetch_deposit_addresses_by_network(code, params) / create_deposit_address(code, params) 🔒
  • withdraw(code, amount, address, tag, params) 🔒
  • transfer(code, amount, from_account, to_account, params) / fetch_transfer / fetch_transfers 🔒
  • fetch_convert_currencies(params)

WebSocket (ccxt-pro)

Public:

  • watch_ticker(symbol, params) / watch_tickers(symbols, params)
  • watch_bids_asks(symbols, params)
  • watch_mark_price(symbol, params) / watch_mark_prices(symbols, params)
  • watch_order_book(symbol, limit, params) / watch_order_book_for_symbols(symbols, limit, params)
  • watch_trades(symbol, since, limit, params) / watch_trades_for_symbols(symbols, since, limit, params)
  • watch_ohlcv(symbol, timeframe, since, limit, params)
  • watch_funding_rate(symbol, params) / watch_funding_rates(symbols, params) / watch_funding_rates_for_symbols
  • watch_liquidations(symbol, since, limit, params) / watch_liquidations_for_symbols

Private 🔒:

  • watch_balance(params)
  • watch_orders(symbol, since, limit, params) / watch_orders_for_symbols
  • watch_my_trades(symbol, since, limit, params) / watch_my_trades_for_symbols
  • watch_positions(symbols, since, limit, params) / watch_position / watch_position_for_symbols
  • watch_my_liquidations(symbol, since, limit, params) / watch_my_liquidations_for_symbols

🔒 = requires API credentials.

Optional parameters

Optional scalars are Option<T> and nullable string arguments are Option<&str>:

exchange.fetch_ohlcv("BTC/USDT", Some("1h"), None, Some(100), Params::none()).await?;
//                                timeframe  since  limit

exchange.fetch_open_orders(None, None, None, Params::none()).await?;  // all symbols

Symbol lists are Option<Vec<String>> — None means "all":

exchange.fetch_tickers(None, Params::none()).await?;
exchange.fetch_positions(Some(vec!["BTC/USDT:USDT".into()]), Params::none()).await?;

Checking method availability

Not every exchange implements every method. has is reachable through the wrapper's Deref:

if ccxt::safe_bool(&exchange.has, "fetchOHLCV", Some(false)).unwrap_or(false) {
    let candles = exchange.fetch_ohlcv("BTC/USDT", Some("1h"), None, Some(100), Params::none()).await?;
}

The keys are the camelCase unified names (fetchOHLCV, createOrder), matching every other CCXT binding. A ccxt_pro instance carries the watch* and *Ws keys (watchOrderBook, createOrderWs) on its own has; a REST instance does not. Calling a method the venue does not implement returns Err(NotSupported), so checking first is an optimisation, not a requirement.

Authentication

Setting API keys

use ccxt::{Binance, Config};

let mut exchange = Binance::with_config(
    Config::new()
        .api_key(&std::env::var("BINANCE_APIKEY").unwrap())
        .secret(&std::env::var("BINANCE_SECRET").unwrap()),
);

Other credentials, when a venue needs them:

Config::new()
    .api_key("...")
    .secret("...")
    .password("passphrase")      // okx, kucoin, …
    .uid("...")                  // kraken, …
    .wallet_address("0x...")     // hyperliquid, …
    .private_key("0x...")        // hyperliquid, dydx, …
    .token("...");

Never hard-code credentials — read them from the environment or a secrets store.

Testing authentication

match exchange.fetch_balance(()).await {
    Ok(b) => println!("authenticated, {} currencies", b.total.len()),
    Err(e) if e.is("AuthenticationError") => println!("invalid credentials: {}", e.message),
    Err(e) => println!("[{}] {}", e.kind, e.message),
}

Error Handling

Every typed method returns Result<T, ExchangeError>. ExchangeError carries a kind (the leaf class name) and a message, plus is() / is_a(), which walk the unified CCXT class hierarchy — so one handler covers a whole family.

Hierarchy

BaseError
├─ ExchangeError                 (non-recoverable — the request or account state is wrong)
│  ├─ AuthenticationError
│  │  ├─ PermissionDenied → AccountNotEnabled
│  │  └─ AccountSuspended
│  ├─ ArgumentsRequired
│  ├─ BadRequest → BadSymbol
│  ├─ OperationRejected          (NoChange, MarginModeAlreadySet, MarketClosed, …)
│  ├─ InsufficientFunds
│  ├─ InvalidAddress → AddressPending
│  ├─ InvalidOrder
│  │  ├─ OrderNotFound
│  │  ├─ DuplicateOrderId
│  │  ├─ OrderNotFillable / OrderImmediatelyFillable
│  │  └─ ContractUnavailable
│  └─ NotSupported
└─ OperationFailed               (transient — retry)
   ├─ NetworkError
   │  ├─ RequestTimeout
   │  ├─ RateLimitExceeded
   │  ├─ DDoSProtection
   │  ├─ ExchangeNotAvailable → OnMaintenance
   │  └─ InvalidNonce → ChecksumError
   ├─ BadResponse → NullResponse
   └─ CancelPending

Matching on the hierarchy

match exchange.create_order("BTC/USDT", "limit", "buy", 0.001, Some(50_000.0), Params::none()).await {
    Ok(order) => println!("{:?}", order.id),
    Err(e) if e.is("InsufficientFunds")     => println!("not enough balance"),
    Err(e) if e.is("OrderNotFound")         => println!("already filled or cancelled"),
    Err(e) if e.is("InvalidOrder")          => println!("bad parameters: {}", e.message),
    Err(e) if e.is("AuthenticationError")   => println!("check credentials"),
    Err(e) if e.is("RateLimitExceeded")     => println!("back off"),
    Err(e) if e.is("NetworkError")          => println!("transient: {}", e.kind),
    Err(e)                                  => println!("[{}] {}", e.kind, e.message),
}

Order the arms most-specific first. OrderNotFound.is("InvalidOrder") is true, so an InvalidOrder arm placed above it swallows the more specific case.

Match on the hierarchy, never on message text — messages are venue-specific and change.

Retry with backoff

Retry only the OperationFailed subtree. Everything under ExchangeError is a bug in the request or the account state and will fail again.

use std::time::Duration;

async fn fetch_with_retry(ex: &mut ccxt::Binance, symbol: &str) -> Result<ccxt::types::Ticker, ccxt::ExchangeError> {
    let mut delay = Duration::from_millis(500);
    for attempt in 0..5 {
        match ex.fetch_ticker(symbol, Params::none()).await {
            Ok(t) => return Ok(t),
            Err(e) if e.is("NetworkError") && attempt < 4 => {
                eprintln!("retry {} after [{}]", attempt + 1, e.kind);
                tokio::time::sleep(delay).await;
                delay *= 2;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}

Errors from load_markets

load_markets returns a Value and panics on failure. Use try_load_markets, which returns Result<Vec<Market>, ExchangeError> — loading really can fail (a venue outage, or a venue whose currency load is authenticated rejecting bad credentials).

if let Err(e) = exchange.try_load_markets(false).await {
    eprintln!("cannot load markets: [{}] {}", e.kind, e.message);
    return;
}

Rate Limiting

The built-in leaky-bucket limiter is on by default — requests are spaced by rateLimit milliseconds, weighted per endpoint.

let mut exchange = Binance::with_config(Config::new().enable_rate_limit(true));

// or afterwards
exchange.set_enable_rate_limit(true);
exchange.set_rate_limit_ms(50);

Turn it off only if you throttle externally. Note that each exchange instance has its own limiter — several instances hitting the same venue with the same API key can still trip a ban, so share one instance per venue per key.

Proxy Configuration

// at construction
let cfg = Config::new().set_str("httpsProxy", "http://127.0.0.1:8080");
let mut exchange = ccxt::Binance::with_config(cfg);

// or afterwards — set at most ONE of these; conflicting settings are rejected
exchange.set_http_proxy("http://user:pass@127.0.0.1:8080");
exchange.set_https_proxy("http://127.0.0.1:8080");
exchange.set_socks_proxy("socks5://127.0.0.1:1080");

WebSocket traffic uses a separate setting — the REST proxies do not apply to watch_*:

let mut ws = ccxt_pro::Binance::new(None);
ws.set_ws_proxy("http://127.0.0.1:8080");            // dialled with an HTTP CONNECT tunnel
// or: Config::new().set_str("wsProxy", "http://127.0.0.1:8080")

Untyped escape hatch

The typed layer covers the unified API. Three ways down to the dynamic layer when you need more:

1. raw on any returned struct — the full venue payload:

let ticker = exchange.fetch_ticker("BTC/USDT", Params::none()).await?;
let info = ccxt::get_value(&ticker.raw, &ccxt::Value::Str("info".to_string()));
let count = ccxt::safe_string(&info, "count", None);

Helpers re-exported at the crate root: get_value, safe_string, safe_number, safe_integer, safe_bool.

2. Deref to the core — read-only access to dynamic fields (has, id, markets, urls, options, rateLimit, timeout, …):

let supports_ws_orders = ccxt::safe_bool(&exchange.has, "createOrderWs", Some(false));

3. call_raw — dispatch any method on the core by name, including ones with no typed wrapper (set_leverage, set_margin_mode, fetch_funding_rate_history, exchange-specific implicit endpoints). Names are snake_case; the result is a Value:

use ccxt::{TypedExchange, Value};

let mut ex: Box<dyn TypedExchange> = Box::new(ccxt::Binance::new(None));
let result: Value = ex
    .call_raw("set_leverage", vec![Value::Int(5), Value::Str("BTC/USDT:USDT".to_string())])
    .await?;

call_raw is on TypedExchange, which every typed wrapper implements, so it also works on a concrete Binance with the trait in scope.

Prediction Markets

CCXT supports prediction-market venues (Polymarket, Kalshi, Limitless, Myriad, Opinion, and the prediction flavours of Hyperliquid and Binance) in the ccxt-prediction crate. They use the same unified API, but prices are quoted 0–1 (USDC per outcome share) and the tradeable unit is an outcome (a market's YES/NO token), not a regular market symbol.

use ccxt::Params;
use ccxt_prediction::{Kalshi, Polymarket, TypedExchange, TypedExchangeExt};

let mut ex = Polymarket::new(None);
ex.load_markets(false).await;   // outcomes load automatically

// An outcome handle looks like 'TRUMP_OUT_PRESIDENT_2027:YES'
let handle = "TRUMP_OUT_PRESIDENT_2027:YES";

let ticker = ex.fetch_ticker(handle, Params::none()).await?;
let book = ex.fetch_order_book(handle, Some(10), Params::none()).await?;

// limit buy 5 YES shares @ 0.40 USDC (price is 0..1 per share)
let order = ex.create_order(handle, "limit", "buy", 5.0, Some(0.40), Params::none()).await?;
if let Some(id) = order.id {
    ex.cancel_order(&id, Some(handle), Params::none()).await?;
}
  • Price/trade methods (fetch_ticker, fetch_order_book, fetch_ohlcv, fetch_trades, create_order, cancel_order, …) take an outcome handle where a REST venue takes a symbol.

  • Event discovery (fetch_events, fetch_event, fetch_outcomes, fetch_outcome) has no typed wrapper yet — reach it via call_raw:

    let events = ex.call_raw("fetch_events", vec![ccxt::Value::Null]).await?;
    
  • Venues are also driven generically through ccxt_prediction's own TypedExchange / TypedExchangeExt (distinct from the ones in ccxt).

Order Router

Two things, either usable without the other. A client for the CCXT order-router service, which holds live books across many venues and answers "what is the cheapest way to turn asset A into asset B right now?", including bridges (SOL -> USDT -> BTC when no SOL/BTC market exists). And an execution engine for plans you build yourself, which needs no router service and no API key. It is not an exchange: it does not implement ExchangeBase, has no unified methods, and is constructed directly.

use ccxt_base::order_router::{OrderRouter, RouterVenue};
use ccxt_base::Value;

let router = OrderRouter::new(&Value::Map(config))?;
let route = router.fetch_route("USDT", "BTC", &Value::Map(params)).await?;   // exactly one of amountIn / amountOut
// execute takes the route directly: it builds the plan, loads each venue's markets and
// runs the safety check itself, refusing to place anything on a blocking violation
let report = router.execute(&route, &venues, &options).await?;
// want to see or change the plan first? the steps in between are public and PURE (no I/O):
// build_execution_plan(&route, ...) then check_execution_plan_safety(&plan, &markets, ...)

Rust differs from the other five ports in two places, both forced by the language:

  • Fallible methods return Result<_, ExchangeError> where the others throw. The error's kind carries the same class name, so err.is("NetworkError") asks the question the other ports ask of an exception class.
  • execute takes BTreeMap<String, Box<dyn RouterVenue>> rather than your exchange objects directly. ExchangeBase's methods return impl Future, which is not object-safe, so a map of exchanges cannot exist. RouterVenue is that map's element type, narrowed to the operations the money path performs; implement it for whatever exchange type you hold.

execute defaults to dry_run, and anything other than an explicit live flag forces dry_run — a call that looks live but forgot the flag places nothing.

Executing your own plans

execute takes a plan, not a route, and never checks where the plan came from, so your own strategy can supply its own trades and still get the notional cap, halt-and-reconcile between hops, resting-order cleanup and the unwind plan. A step is one order on one venue; required fields are exchangeId, symbol, side, amount, base, quote.

A live execute requires an identity and refuses without one — supply it as the plan's requestId or as options.idempotencyKey. It keys an in-process ledger so a second execute of the same plan is refused before any venue is contacted. The ledger is capped and evicts oldest-first, so the guarantee is "recent duplicates are refused", not "duplicates are impossible", and it does not survive a restart.

The service, and what it costs

https://docs.ccxt.com/router/api. Every endpoint is public: there is no API key, no signup and no login. The service rate-limits by client IP address instead.

The client still accepts an apiKey and still 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 https://docs.ccxt.com/router/openapi.yaml. curl -O https://docs.ccxt.com/router/openapi.yaml and point codegen at it, import it into Postman/Insomnia, or diff it between deploys. It is the authority on every field this client reads; where the two disagree, the spec is right. Rendered prose version: /router/docs and /router/docs/api.

Free to use for now, up to the published rate limit — not a permanent commitment, so expect a paid tier eventually. Your existing key is how that would be billed; nothing in the client changes. Read the limit off the response headers (x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset) rather than hardcoding a number. A 429 raises RateLimitExceeded with the retry interval folded into the message.

A router that has restarted is alive long before it can price anything. Asked to route in that window it refuses with 503 cache_cold, and the client raises ExchangeNotAvailable — a retry, distinct from the ExchangeError that means something is actually wrong.

Holdings are POSTed, never put in a URL. fetch_route normally sends a GET, but when you pass balances the client switches to POST /route and puts every parameter in the body: the service scrubs holdings from its own logs, but a reverse proxy, an ALB, a CDN, browser history and a Referer all see the full request line, and no in-process redaction reaches them. fetch_route_with_balances does this for you.

Two flags the client verifies for you, because one silently lost in transit looks identical to one never sent. balances: the service ignores them entirely if it predates the feature and answers byte-identically, so fetch_route throws unless the router echoes balancesApplied (or balanceEntryCount, which is how an empty wallet is confirmed) — pass requireBalancesApplied: false to opt out. requireFullFill: the one flag that fails open, so the client stamps what you asked for and the safety check makes partial_fill blocking when you asked for a full fill and did not get one.

An empty value is not an omitted one. Omit bridges and you get the default bridge set; send bridges= and you have asked for no bridging at all. Same for exchanges= (no venues) and balances= (you hold nothing). The client forwards an empty value rather than dropping it.

requestId is sent as the x-request-id header, so your log and the router's decision log can be joined; the service mints one when absent.

Asking the service about itself

MethodEndpointKey?Answers
fetch_health()/healthnois the process alive — 200 from the first millisecond of boot
fetch_readiness()/readynocan it route yet: book counts, and how many are fresh
fetch_version()/versionyeswhich commit is deployed
fetch_symbols()/symbolsyesthe symbols it holds a book for
fetch_exchanges_status()/exchanges/statusyesper-venue connection health
fetch_cached_order_book(exchange_id, symbol)/orderbook/{exchange}/{symbol}yesthe exact book a route was ranked on

Gate deploys on readiness, not health — /health is 200 before a single websocket has connected. fetch_readiness() does not raise when the answer is no: the service replies 503 carrying the same body it returns on 200, and you need those counts to know why.

let readiness = router.fetch_readiness().await?;
if router.string_at(&readiness, "status", "") != "ready" {
    println!(
        "{} of {} books are fresh",
        router.number_at(&readiness, "freshCount", 0.0),
        router.number_at(&readiness, "bookCount", 0.0)
    );
}

/metrics (Prometheus) has no client method — it answers text/plain and this class parses every response as JSON.

Watching a route. watch_route holds a WebSocket open and calls your hook with each RouteResult as the books move; return 'stop' to close cleanly and get the last route back. Every frame is stamped exactly as fetch_route stamps its answer, so it can go straight into the plan builder. Three endpoint rules differ from fetch_route: balances and balanceMode are refused (a socket outlives the holdings it was opened with — refused client-side, before anything opens), includeQuotes defaults to false, and refusals arrive as close codes rather than statuses — 1008 raises BadRequest and 1013 raises ExchangeNotAvailable, the same classes the REST path uses. A hook that throws stops the stream and reaches you, unlike execute's step hook.

Watching a run, and stopping it — set_on_step

This is where Rust's API diverges most. The other five ports pass the hook in options['onStep']; Rust installs it on the router. Value is a closed enum deriving Debug, Clone and PartialEq and answering to_json, so it cannot carry a closure without redefining what closure equality and serialisation mean at every Value site in the crate.

use std::sync::Arc;

router.set_on_step(Arc::new(|event: &Value| {
    // return "halt" to stop the route; anything else continues
    if router_str(event, "status") == "partial" { "halt".to_string() } else { String::new() }
}));
let report = router.execute(&plan, &venues, &options).await?;
router.clear_on_step();

OnStepHook is Arc<dyn Fn(&Value) -> String + Send + Sync>. The hook is called after each step completes AND after its reconciliation, never mid-order, with an event carrying planId, stepIndex, hopIndex, status, filledAmount, outAmount, attempt, reconciliation, haltReason, stepsRemaining and more.

  • It can only narrow. "halt" stops the route and sets haltReason to halted_by_on_step; nothing it returns resumes a route the reconciliation already halted.
  • Do no I/O in it — it sits between orders on the money path.
  • A panicking hook cannot take the run down. It is called inside catch_unwind, the failure is recorded as on_step_hook_failed:panic, and execution continues as if the hook had no opinion — losing the report would destroy the only account of orders already live. This does not hold under panic = "abort", where no construct in any language would help.

options.retryFailedSteps (default 0, retryDelayMs default 1000) re-places a step the venue definitively rejected. An 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. The winning attempt is reported as attempt. The router sets no client order id of its own — venues disagree on length and charset, so whatever you pass in orderParams travels untouched.

Common Pitfalls

Not loading markets first

// Wrong — symbol resolution fails
let mut ex = Binance::new(None);
let ticker = ex.fetch_ticker("BTC/USDT", Params::none()).await?;   // BadSymbol

// Correct
let mut ex = Binance::new(None);
ex.try_load_markets(false).await?;
let ticker = ex.fetch_ticker("BTC/USDT", Params::none()).await?;

Assigning to fields instead of using setters

// Wrong — the wrapper derefs read-only, this does not compile
exchange.verbose = true;
exchange.apiKey = "...".into();

// Correct
exchange.set_verbose(true);
exchange.set_api_key("...");

Using load_markets where failure matters

// Risky — panics on a venue outage or bad credentials
exchange.load_markets(false).await;

// Correct
exchange.try_load_markets(false).await?;

Ordering error arms from general to specific

// Wrong — InvalidOrder swallows OrderNotFound
Err(e) if e.is("InvalidOrder")  => ...,
Err(e) if e.is("OrderNotFound") => ...,   // unreachable

// Correct — specific first
Err(e) if e.is("OrderNotFound") => ...,
Err(e) if e.is("InvalidOrder")  => ...,

Treating Option<f64> as a number

// Wrong — `last` is Option<f64>, not f64
let value = ticker.last * amount;

// Correct — decide what a missing price means
let Some(last) = ticker.last else { return Err(...) };
let value = last * amount;

Polling REST for real-time data

// Wrong — burns rate limit, seconds of latency
loop {
    let t = exchange.fetch_ticker("BTC/USDT", Params::none()).await?;
    tokio::time::sleep(Duration::from_secs(1)).await;
}

// Correct — one connection, push updates
let mut ws = ccxt_pro::Binance::new(None);
ws.try_load_markets(false).await?;
loop {
    let t = ws.watch_ticker("BTC/USDT", Params::none()).await?;
}

Wrong symbol format

"BTCUSDT"        // wrong — no separator (that's the exchange-specific id)
"BTC-USDT"       // wrong — dash separator
"btc/usdt"       // wrong — lowercase

"BTC/USDT"       // correct — unified spot symbol
"BTC/USDT:USDT"  // correct — linear perpetual swap
"BTC/USD:BTC"    // correct — inverse swap

Default thread stack size

A stack overflow inside a fetch_* call usually means the runtime's worker threads are on the 2 MB default. Build the runtime with .thread_stack_size(64 * 1024 * 1024) — see Runtime setup.

Sharing one instance across tasks

Every unified method takes &mut self, so a single instance cannot be used from two tasks at once. Wrap it in a tokio::sync::Mutex (which serialises the calls) or give each task its own instance.

Troubleshooting

error: no matching package named 'ccxt-pro' found cargo add ccxt-pro — the WebSocket venues are a separate crate from ccxt.

cannot borrow as mutable on a watch_* call Two watch_* calls from the same instance cannot be awaited concurrently. Use *_for_symbols, or a second instance.

the trait bound ...: TypedExchangeExt is not satisfied Bring both traits into scope: use ccxt::{TypedExchange, TypedExchangeExt}; (or the ccxt_pro / ccxt_prediction pair for those crates).

no method named 'fetch_ticker' found for struct 'Box<dyn TypedExchange>' Same cause — TypedExchangeExt is not imported.

Err(BadSymbol) Markets were not loaded, or the symbol is not listed. Check exchange.symbols().

Err(NotSupported) The venue does not implement that method, or the signer is not ported yet (see Known limitations). Check exchange.has first.

Err(AuthenticationError) Verify the key and secret, the key's permissions on the exchange, any IP allowlist, and that the system clock is synced.

Err(InvalidNonce) Sync the system clock; use one exchange instance per API key.

Err(RateLimitExceeded) Leave enable_rate_limit on, and don't run several instances against one key.

Stack overflow Increase the worker-thread stack size — see Runtime setup.

A panic message printed but the call returned Ok/Err anyway Expected: the core signals errors by panicking across an internal catch_unwind. Install the no-op panic hook shown in Runtime setup; set CCXT_SHOW_PANICS=1 to see them while debugging.

Long compile times The generated crates are large. Drop debug info in dev builds and keep ccxt-pro out of the dependency list unless you stream:

[profile.dev]
debug = 0

Debugging

exchange.set_verbose(true);   // logs every HTTP request and response to stderr

For a WebSocket venue, set_verbose(true) on the ccxt_pro instance logs the frames.

Known limitations

The Rust port is newer than the other bindings. Current gaps:

  • Unimplemented signers — a few exchange-specific signing schemes are not ported yet (StarkNet for paradex, lighter zk-proofs, dydx protobuf transactions, apex StarkEx, curve25519). They fail loudly with NotSupported rather than emitting an invalid signature, so those venues' private endpoints are unusable; public endpoints work.
  • No typed un_watch_* and no close() — see Closing connections.
  • Fewer typed methods than the full unified API — 124 REST methods have typed wrappers; everything else goes through call_raw.
  • Integer precision — JSON integers above u64::MAX fall back to f64; values up to u64::MAX are preserved losslessly.

Learn More

发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

Sep 24, 2026

分类

未分类

许可证

MIT

源路径

.claude/skills/ccxt-rust

默认分支

master

最新提交

1259174

Tree SHA

663c755