CCXT
Rust Examples

Binance Order Error Handling

Binance Order Error Handling — CCXT Rust code example.

// binance-order-error-handling — placing orders and reacting to what goes wrong.
//
// CCXT normalises every venue's error strings into one class hierarchy
// (`ts/src/base/errorHierarchy.ts`). In Rust that arrives as `ExchangeError`
// with a `.kind` and an `.is("...")` test that walks the hierarchy, so
// `OrderNotFound.is("InvalidOrder")` and `RequestTimeout.is("NetworkError")`
// are both true. That is what lets you write ONE handler that works across
// exchanges instead of grepping venue-specific messages.
//
// This example walks a series of order attempts, each one provoking a
// different failure, and routes every outcome through a single
// `classify()` policy function — the part worth copying.
//
//   cargo run --release --bin binance-order-error-handling
//
// Runs with no credentials by default: the client-side failures (missing keys,
// unknown symbol, below-minimum amount) need no account, and the rest are
// reported as skipped. With credentials it also exercises InsufficientFunds
// and OrderNotFound.
//
//   BINANCE_APIKEY=... BINANCE_SECRET=... cargo run --release \
//       --bin binance-order-error-handling
//
// Credentials go to the **testnet** unless BINANCE_LIVE=1 is set. Every order
// this example would actually place is checked against a 25 USD notional cap
// first and any order that does get created is cancelled before exit.
use ccxt::types::{Market, Order};
use ccxt::{Binance, Config, ExchangeError, Params};

/// Hard cap on the notional of any order this example is willing to send.
const MAX_NOTIONAL_USD: f64 = 25.0;

/// What a caller should actually DO about an error. This is the reusable part:
/// it is written entirely against the unified hierarchy, so the same function
/// works for any ccxt exchange.
#[derive(Debug, PartialEq)]
enum Policy {
    /// Transient. Back off and try the same request again.
    Retry { after_ms: u64 },
    /// The request is wrong but fixable — round the amount, top up, re-price.
    Fix(&'static str),
    /// Nothing to retry: credentials, permissions, or an unsupported call.
    Abort(&'static str),
    /// Order is already gone / already done. Usually benign.
    Ignore(&'static str),
}

fn classify(e: &ExchangeError) -> Policy {
    // Order matters: test the most specific classes first, then fall back to
    // the parent classes via `is()`.
    if e.is("InsufficientFunds") {
        return Policy::Fix("not enough balance — reduce size or fund the account");
    }
    if e.is("OrderNotFound") {
        return Policy::Ignore("order already filled, cancelled, or never existed");
    }
    if e.is("DuplicateOrderId") {
        return Policy::Ignore("this clientOrderId was already accepted");
    }
    if e.is("InvalidOrder") {
        // Parent of OrderNotFound/DuplicateOrderId, so it must come after them.
        return Policy::Fix("order parameters rejected — check amount/price/limits");
    }
    if e.is("BadSymbol") {
        return Policy::Abort("symbol not listed on this venue");
    }
    if e.is("AuthenticationError") {
        // Also covers PermissionDenied / AccountSuspended.
        return Policy::Abort("credentials missing, wrong, or lacking permission");
    }
    if e.is("RateLimitExceeded") || e.is("DDoSProtection") {
        return Policy::Retry { after_ms: 2_000 };
    }
    if e.is("OnMaintenance") {
        return Policy::Retry { after_ms: 30_000 };
    }
    if e.is("NetworkError") {
        // RequestTimeout, ExchangeNotAvailable, InvalidNonce, ChecksumError…
        return Policy::Retry { after_ms: 500 };
    }
    if e.is("NotSupported") {
        return Policy::Abort("this exchange does not implement that call");
    }
    if e.is("BadRequest") || e.is("ArgumentsRequired") {
        return Policy::Fix("malformed request — check the arguments");
    }
    Policy::Abort("unclassified — inspect .kind and the raw message")
}

fn report(case: &str, outcome: Result<Order, ExchangeError>) {
    println!("\n── {case}");
    match outcome {
        Ok(o) => println!(
            "   OK       id={} status={} {} {:?} @ {:?}",
            o.id.clone().unwrap_or_default(),
            o.status.clone().unwrap_or_default(),
            o.symbol,
            o.amount,
            o.price
        ),
        Err(e) => {
            // `.kind` is the leaf class; the chain shows what it inherits from.
            println!("   kind     {}", e.kind);
            println!("   chain    {}", chain(&e));
            println!("   message  {}", first_line(&e.message));
            println!("   policy   {:?}", classify(&e));
        }
    }
}

/// Walk the hierarchy so the output shows *why* a generic handler catches it.
fn chain(e: &ExchangeError) -> String {
    const LADDER: &[&str] = &[
        "InsufficientFunds",
        "OrderNotFound",
        "DuplicateOrderId",
        "InvalidOrder",
        "BadSymbol",
        "BadRequest",
        "ArgumentsRequired",
        "PermissionDenied",
        "AuthenticationError",
        "NotSupported",
        "OperationRejected",
        "ExchangeError",
        "RateLimitExceeded",
        "DDoSProtection",
        "OnMaintenance",
        "RequestTimeout",
        "ExchangeNotAvailable",
        "NetworkError",
        "OperationFailed",
        "BaseError",
    ];
    let hits: Vec<&str> = LADDER
        .iter()
        .copied()
        .filter(|k| *k != e.kind && e.is(k))
        .collect();
    if hits.is_empty() {
        e.kind.clone()
    } else {
        format!("{} -> {}", e.kind, hits.join(" -> "))
    }
}

fn first_line(m: &str) -> String {
    m.lines().next().unwrap_or("").chars().take(160).collect()
}

/// Refuse to send anything worth `MAX_NOTIONAL_USD` or more. Called before
/// every `create_order` that could actually rest on the book.
fn guard_notional(amount: f64, price: f64) -> Result<(), String> {
    let notional = amount * price;
    if notional >= MAX_NOTIONAL_USD {
        return Err(format!(
            "refusing to send {notional:.2} USD notional (cap {MAX_NOTIONAL_USD:.0})"
        ));
    }
    Ok(())
}

/// Declared minimums, straight off the typed market — no `Value` digging.
fn mins(m: &Market) -> (f64, f64) {
    (
        m.limits.amount.min.unwrap_or(0.0),
        m.limits.cost.min.unwrap_or(0.0),
    )
}

/// Smallest amount that clears BOTH minimums *after* precision rounding.
/// ccxt truncates to the market's amount step on the way out, so sizing to
/// exactly the minimum lands under it once truncated and binance answers
/// `Filter failure: NOTIONAL`. Round UP to the step, then verify.
fn min_amount_at(m: &Market, price: f64) -> f64 {
    let (min_amount, min_cost) = mins(m);
    let min_amount = if min_amount > 0.0 { min_amount } else { 0.0001 };
    let min_cost = if min_cost > 0.0 { min_cost } else { 5.0 };
    let step = m.precision.amount.filter(|s| *s > 0.0).unwrap_or(0.0);
    let mut amount = f64::max(min_amount, min_cost / price);
    if step > 0.0 {
        amount = ((amount / step) + 1e-9).ceil() * step;
        for _ in 0..8 {
            if amount * price >= min_cost && amount >= min_amount {
                break;
            }
            amount += step;
        }
    }
    amount
}

/// A price below the book so a limit buy rests instead of filling, but inside
/// binance's PERCENT_PRICE_BY_SIDE band — that filter bounds how far a limit
/// price may sit from the reference price, and a 50%-below bid trips it.
fn resting_price(last: f64) -> f64 {
    (last * 0.90).floor()
}

fn creds() -> Option<(String, String)> {
    match (
        std::env::var("BINANCE_APIKEY"),
        std::env::var("BINANCE_SECRET"),
    ) {
        (Ok(k), Ok(s)) if !k.is_empty() && !s.is_empty() => Some((k, s)),
        _ => None,
    }
}

fn build(with_creds: bool) -> Binance {
    // Spot only — keeps loadMarkets small and the symbols unambiguous.
    let mut cfg = Config::new().option("fetchMarkets", Params::new().with_strs("types", &["spot"]));
    if with_creds {
        if let Some((k, s)) = creds() {
            cfg = cfg.api_key(&k).secret(&s);
        }
    }
    Binance::with_config(cfg)
}

async fn run() {
    let has_creds = creds().is_some();
    let live = std::env::var("BINANCE_LIVE").is_ok();
    println!("binance-order-error-handling");
    println!(
        "  credentials: {}   target: {}",
        if has_creds {
            "present"
        } else {
            "none (client-side cases only)"
        },
        if !has_creds {
            "public endpoints"
        } else if live {
            "LIVE"
        } else {
            "testnet (sandbox)"
        }
    );
    println!("  notional cap: {MAX_NOTIONAL_USD:.0} USD per order");

    // ── 1. No credentials: caught before any HTTP request goes out ──────────
    {
        let mut ex = build(false);
        // No credentials here, so the (authenticated) currency load is skipped
        // and this cannot fail — but check anyway rather than assume.
        if let Err(e) = ex.try_load_markets(false).await {
            println!(
                "load_markets failed [{}]: {}",
                e.kind,
                first_line(&e.message)
            );
            return;
        }
        let out = ex
            .create_order("BTC/USDT", "limit", "buy", 0.001, Some(1.0), Params::none())
            .await;
        report("1. create_order with no API keys", out);
    }

    let mut ex = build(has_creds);
    if let Err(e) = ex.try_load_markets(false).await {
        println!("\n── load_markets");
        println!("   kind     {}", e.kind);
        println!("   chain    {}", chain(&e));
        println!("   message  {}", first_line(&e.message));
        println!("   policy   {:?}", classify(&e));
        println!("\ncannot continue without a market table.");
        return;
    }

    // A real reference price, so the guard and the limits checks mean something.
    let last = match ex.fetch_ticker("BTC/USDT", Params::none()).await {
        Ok(t) => t.last.unwrap_or(0.0),
        Err(e) => {
            println!("\ncannot fetch a reference price: {}", e.kind);
            return;
        }
    };
    println!("\n  BTC/USDT last = {last:.2}");

    // ── 2. Unknown symbol: rejected locally by market() ─────────────────────
    {
        let out = ex
            .create_order(
                "NOTACOIN/USDT",
                "limit",
                "buy",
                1.0,
                Some(1.0),
                Params::none(),
            )
            .await;
        report("2. create_order on an unlisted symbol", out);
    }

    // ── 3. Below the venue minimum ──────────────────────────────────────────
    // Check the declared limits FIRST — a local check costs no request and no
    // rate-limit weight. Then send it anyway to show what the venue returns.
    {
        let market = match ex.market("BTC/USDT") {
            Ok(m) => m,
            Err(e) => {
                println!("\ncannot read the market: {}", e.kind);
                return;
            }
        };
        let (min_amount, min_cost) = mins(&market);
        println!(
            "\n   (declared limits: min amount {min_amount}, min cost {min_cost} — \
             checking these before sending avoids a wasted request)"
        );
        let tiny = if min_amount > 0.0 {
            min_amount / 10.0
        } else {
            0.000_001
        };
        let price = resting_price(last); // rejection should be about the amount
        let out = ex
            .create_order(
                "BTC/USDT",
                "limit",
                "buy",
                tiny,
                Some(price),
                Params::none(),
            )
            .await;
        report("3. create_order below the minimum amount", out);
    }

    // Everything past here needs an account. Without credentials these would
    // just return AuthenticationError, which case 1 already demonstrated.
    if !has_creds {
        println!("\n── 4. InsufficientFunds          — skipped (needs credentials)");
        println!("── 5. cancel_order OrderNotFound — skipped (needs credentials)");
        println!("── 6. a real resting order       — skipped (needs credentials)");
        summary();
        return;
    }

    // ── 4. Insufficient funds ───────────────────────────────────────────────
    // Deliberately huge, and priced below the book so it could not fill even
    // if it were somehow accepted. The notional guard is not applied here on
    // purpose: this order is designed to be rejected, and seeing the venue
    // reject it is the point. Priced inside the filter band so the rejection
    // is about FUNDS rather than about an implausible price.
    {
        let price = resting_price(last);
        let out = ex
            .create_order(
                "BTC/USDT",
                "limit",
                "buy",
                10_000.0,
                Some(price),
                Params::none(),
            )
            .await;
        report(
            "4. create_order for 10 000 BTC (expect InsufficientFunds)",
            out,
        );
    }

    // ── 5. Cancelling something that is not there ───────────────────────────
    {
        let out = ex.cancel_order("1", Some("BTC/USDT"), Params::none()).await;
        report("5. cancel_order with a bogus id", out);
    }

    // ── 6. A real order, inside the cap, then cleaned up ────────────────────
    {
        let market = ex.market("BTC/USDT").expect("BTC/USDT is listed");
        let price = resting_price(last);
        // Clears both minimums after precision rounding; still under the cap.
        let amount = min_amount_at(&market, price);
        match guard_notional(amount, price) {
            Err(why) => println!("\n── 6. resting order — skipped: {why}"),
            Ok(()) => {
                println!(
                    "\n   (sending {amount:.6} BTC @ {price:.2} = {:.2} USD notional, under the cap)",
                    amount * price
                );
                let created = ex
                    .create_order(
                        "BTC/USDT",
                        "limit",
                        "buy",
                        amount,
                        Some(price),
                        Params::none(),
                    )
                    .await;
                let id = created.as_ref().ok().and_then(|o| o.id.clone());
                report("6. a valid resting order", created);
                // Always clean up, whatever happened above.
                if let Some(id) = id {
                    let out = ex.cancel_order(&id, Some("BTC/USDT"), Params::none()).await;
                    report("6b. cleanup: cancel_order", out);
                }
            }
        }
    }

    summary();
}

fn summary() {
    println!("\n────────────────────────────────────────────────────────────");
    println!("Takeaway: match on the hierarchy, not on message text.");
    println!("  err.is(\"InvalidOrder\")      catches OrderNotFound, DuplicateOrderId,");
    println!("                               OrderNotFillable, ContractUnavailable, …");
    println!("  err.is(\"NetworkError\")      catches RequestTimeout, RateLimitExceeded,");
    println!("                               DDoSProtection, ExchangeNotAvailable, OnMaintenance");
    println!("  err.is(\"AuthenticationError\") catches PermissionDenied, AccountSuspended");
    println!("Retry only the OperationFailed subtree; everything under ExchangeError");
    println!("is a bug in the request or the account state and will fail again.");
}

fn main() {
    // The transpiled core signals errors by panicking across an internal
    // catch_unwind; the typed layer turns that back into `Result`. Silence the
    // default hook so the caught panics do not double-print above each case.
    if std::env::var("CCXT_SHOW_PANICS").is_err() {
        std::panic::set_hook(Box::new(|_| {}));
    }
    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());
}