CCXT
Rust Examples

Cli

Cli — CCXT Rust code example.

// CCXT Rust CLI — mirrors `cli.go` / `cli.cs`, built on the TYPED crate.
// `ccxt::from_id(id, config)` builds a boxed `TypedExchange`; the method name
// is dispatched dynamically through `TypedExchange::call_raw` (the typed crate's
// runtime escape hatch), so this file needs no per-method signatures.
//
//   npm run cli.rs -- binance fetchTicker BTC/USDT
//   npm run cli.rs -- bybit  fetchOHLCV  BTC/USDT 1h
//   npm run cli.rs -- okx    fetchMarkets --verbose
//   npm run cli.rs -- gate   fetchTrades BTC/USDT null 5
//
// `watch*` methods stream over WebSocket (via the `ccxt_pro` crate) and keep
// printing each update until Ctrl-C. They need the `ws` feature — the
// `npm run cli.rs` script already passes it:
//   npm run cli.rs -- binance watchOrderBook BTC/USDT
//   npm run cli.rs -- bybit   watchTrades    BTC/USDT
//
// Args: bare `null`/`true`/`false` and JSON `{...}`/`[...]` parse to
//   Value::{Null,Bool,Map,Array}; everything else is a string.
// Credentials: `keys.local.json` (fallback `keys.json`) at the repo root, then
//   `<EXCHANGE_ID>_<CRED>` env vars override. Passed via the `config` map.
// Flags: --verbose --testnet --demo --no-keys
//        --httpProxy=URL --httpsProxy=URL --socksProxy=URL --proxy=URL
use ccxt::value::HashMap;
use ccxt::Value;
use std::env;
use std::panic;
use futures::FutureExt;

// ── color codes ───────────────────────────────────────────────────────────────
const RED: &str = "\x1b[31m";
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RESET: &str = "\x1b[0m";

// ── pretty printer (JS-console style) ────────────────────────────────────────
fn is_ident(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}

fn pretty(v: &Value, indent: usize) -> String {
    let pad = "  ".repeat(indent);
    let pad_next = "  ".repeat(indent + 1);
    match v {
        Value::Null => "null".to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Int(n) => n.to_string(),
        Value::Float(f) => {
            if f.is_nan() { "NaN".into() }
            else if f.is_infinite() { (if *f < 0.0 { "-Infinity" } else { "Infinity" }).into() }
            else { format!("{f}") }
        }
        Value::Str(s) => format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")),
        Value::Arr(a) => {
            if a.is_empty() { return "[]".into(); }
            let items: Vec<String> = a.iter().map(|x| format!("{pad_next}{}", pretty(x, indent + 1))).collect();
            format!("[\n{}\n{pad}]", items.join(",\n"))
        }
        Value::Dict(m) => {
            if m.is_empty() { return "{}".into(); }
            let items: Vec<String> = m.iter().map(|(k, val)| {
                let key = if is_ident(k) { k.clone() } else { format!("'{}'", k.replace('\'', "\\'")) };
                format!("{pad_next}{key}: {}", pretty(val, indent + 1))
            }).collect();
            format!("{{\n{}\n{pad}}}", items.join(",\n"))
        }
    }
}

// A WS order book is returned as an internal marker Dict — its bids/asks live
// in a global side store keyed by `__side_id`, not inline — so printing it raw
// exposes `{ __bookKind, __side_id, … }` instead of the levels. Resolve it into
// the plain JS-like shape (`{ symbol, bids: [[p,a],…], asks: […], timestamp,
// datetime, nonce }`) so `cli watchOrderBook` prints like ccxt-js. Non-book
// values (trades, tickers, REST books) pass through unchanged.
fn materialize_book(v: &Value) -> Value {
    let fields = match ccxt::value::book_fields_as_value(v) {
        Some(f) => f,
        None => return v.clone(),
    };
    let d = match &fields { Value::Dict(d) => d, _ => return fields };
    let mut out: HashMap<String, Value> = HashMap::new();
    for (k, val) in d.iter() {
        // drop the internal scaffolding keys (`__bookKind`, `_depth`, …)
        if k.starts_with('_') { continue; }
        // resolve a side marker (`bids`/`asks`) into `[[price, amount], …]`
        match ccxt::value::side_entries_as_value(val) {
            Some(entries) => { out.insert(k.clone(), entries); }
            None => { out.insert(k.clone(), val.clone()); }
        }
    }
    Value::Map(out)
}

// ── arg parsing ──────────────────────────────────────────────────────────────
fn parse_arg(s: &str) -> Value {
    if s == "null" { return Value::Null; }
    if s == "true" { return Value::Bool(true); }
    if s == "false" { return Value::Bool(false); }
    if let Ok(n) = s.parse::<i64>() { return Value::Int(n); }
    if let Ok(f) = s.parse::<f64>() { return Value::Float(f); }
    if s.starts_with('{') || s.starts_with('[') {
        return ccxt::runtime::json_parse(&Value::Str(s.to_string()));
    }
    Value::Str(s.to_string())
}

// Camel → snake matching the transpiler's `toSnakeCase` (fetchOHLCV → fetch_ohlcv).
fn snake(name: &str) -> String {
    let bytes: Vec<char> = name.chars().collect();
    let mut out = String::with_capacity(name.len() + 4);
    for i in 0..bytes.len() {
        let c = bytes[i];
        let prev = if i > 0 { Some(bytes[i - 1]) } else { None };
        let next = if i + 1 < bytes.len() { Some(bytes[i + 1]) } else { None };
        if c.is_ascii_uppercase() {
            let prev_lower_or_digit = prev.map(|p| p.is_ascii_lowercase() || p.is_ascii_digit()).unwrap_or(false);
            let prev_upper = prev.map(|p| p.is_ascii_uppercase()).unwrap_or(false);
            let next_lower = next.map(|n| n.is_ascii_lowercase()).unwrap_or(false);
            if prev_lower_or_digit { out.push('_'); }
            else if prev_upper && next_lower { out.push('_'); }
        }
        out.push(c.to_ascii_lowercase());
    }
    out
}

// ── credentials (keys.local.json / keys.json + env vars) ─────────────────────
const CRED_KEYS: &[&str] = &[
    "apiKey", "secret", "password", "uid",
    "walletAddress", "privateKey", "token", "twofa", "login", "accountId",
];

fn load_credentials(id: &str) -> HashMap<String, String> {
    let mut creds: HashMap<String, String> = HashMap::new();
    'outer: for fname in ["keys.local.json", "keys.json"] {
        let mut dir = env::current_dir().unwrap_or_default();
        for _ in 0..5 {
            let path = dir.join(fname);
            if path.is_file() {
                if let Ok(text) = std::fs::read_to_string(&path) {
                    let parsed = ccxt::runtime::json_parse(&Value::Str(text));
                    if let Value::Dict(top) = &parsed {
                        if let Some(Value::Dict(ex_obj)) = top.get(id) {
                            for (k, v) in ex_obj.iter() {
                                if let Value::Str(s) = v { creds.insert(k.clone(), s.clone()); }
                            }
                        }
                    }
                }
                break 'outer;
            }
            if !dir.pop() { break; }
        }
    }
    for &cred in CRED_KEYS {
        let env_name = format!("{}_{}", id.to_uppercase(), cred.to_uppercase());
        if let Ok(v) = env::var(&env_name) {
            if !v.is_empty() { creds.insert(cred.to_string(), v); }
        }
    }
    creds
}

fn flag_value(flags: &[String], name: &str) -> Option<String> {
    let key_eq = format!("--{name}=");
    flags.iter().find_map(|f| f.strip_prefix(&key_eq).map(|s| s.to_string()))
}

fn usage() -> ! {
    eprintln!("{RED}usage: cli <exchange> <method> [args...] [flags]{RESET}");
    eprintln!("examples:");
    eprintln!("  npm run cli.rs -- binance fetchTicker BTC/USDT");
    eprintln!("  npm run cli.rs -- bybit fetchOHLCV BTC/USDT 1h null 10");
    eprintln!("  npm run cli.rs -- okx fetchMarkets --verbose");
    eprintln!("  npm run cli.rs -- binance fetchBalance --testnet");
    eprintln!("  npm run cli.rs -- binance watchOrderBook BTC/USDT   (streams; Ctrl-C to stop)");
    eprintln!("flags: --verbose --testnet --demo --no-keys");
    eprintln!("       --httpProxy=URL --httpsProxy=URL --socksProxy=URL --proxy=URL");
    eprintln!("credentials: keys.local.json / keys.json or <ID>_<CRED> env vars");
    std::process::exit(2);
}

#[tokio::main]
async fn main() {
    let argv: Vec<String> = env::args().collect();
    if argv.len() < 3 { usage(); }

    let id = argv[1].clone();
    let method = argv[2].clone();
    let m_snake = snake(&method);

    let flags: Vec<String> = argv.iter().skip(3).filter(|a| a.starts_with("--")).cloned().collect();
    let positional: Vec<String> = argv.iter().skip(3).filter(|a| !a.starts_with("--")).cloned().collect();
    let verbose = flags.iter().any(|f| f == "--verbose");
    let testnet = flags.iter().any(|f| f == "--testnet");
    let demo = flags.iter().any(|f| f == "--demo");
    let no_keys = flags.iter().any(|f| f == "--no-keys");

    // Build the settings map `<Exchange>::new` takes: credentials + proxies +
    // verbose all go in here so the typed wrapper (whose Core is private) is
    // configured entirely at construction.
    let mut config: HashMap<String, Value> = HashMap::new();
    if !no_keys {
        for (k, v) in load_credentials(&id) { config.insert(k, Value::Str(v)); }
    }
    for (flag, key) in [("httpProxy", "httpProxy"), ("httpsProxy", "httpsProxy"), ("socksProxy", "socksProxy"), ("proxy", "proxy")] {
        if let Some(s) = flag_value(&flags, flag) { config.insert(key.to_string(), Value::Str(s)); }
    }
    if verbose { config.insert("verbose".to_string(), Value::Bool(true)); }

    let args: Vec<Value> = positional.iter().map(|s| parse_arg(s)).collect();

    println!("{GREEN}exchange:{RESET} {id}");
    println!("{GREEN}method:{RESET}   {method} ({m_snake})");
    if !args.is_empty() { println!("{GREEN}args:{RESET}     {args:?}"); }
    if verbose { println!("{YELLOW}verbose mode{RESET}"); }
    if testnet { println!("{YELLOW}testnet mode{RESET}"); }
    if demo { println!("{YELLOW}demo trading mode{RESET}"); }
    let cred_names: Vec<&str> = config.keys().map(|k| k.as_str())
        .filter(|k| CRED_KEYS.contains(k)).collect();
    if !cred_names.is_empty() { println!("{GREEN}credentials:{RESET} {}", cred_names.join(", ")); }
    println!();

    let m = m_snake.clone();
    // `watch*` methods stream over WebSocket and live in the pro crate; every
    // other method is a one-shot REST call on the typed `ccxt` crate.
    if method.starts_with("watch") {
        #[cfg(feature = "ws")]
        {
            run_ws(&id, &m, args, testnet, demo, config).await;
        }
        #[cfg(not(feature = "ws"))]
        {
            let _ = (testnet, demo, args, config);
            eprintln!("\n{RED}error:{RESET} {method} is a WebSocket method — the pro venues \
                       aren't compiled in.\nrebuild with the `ws` feature, e.g.\n  \
                       cargo run --manifest-path examples/rust/Cargo.toml --features ws --bin cli -- {id} {method} …\n\
                       (the `npm run cli.rs` script already passes --features ws)");
            std::process::exit(1);
        }
    } else {
        run_rest(&id, &m, args, testnet, demo, config).await;
    }
}

// One-shot REST dispatch on the typed `ccxt` crate (the original CLI path).
async fn run_rest(id: &str, m: &str, args: Vec<Value>, testnet: bool, demo: bool, config: HashMap<String, Value>) {
    use ccxt::TypedExchange;
    let result = panic::AssertUnwindSafe(async move {
        let mut ex: Box<dyn TypedExchange> = match ccxt::from_id(id, Some(Value::Map(config))) {
            Some(e) => e,
            None => panic!("{RED}exchange not transpiled yet: {id}{RESET}"),
        };
        // --testnet / --demo route through the runtime dispatch, same as any method.
        if testnet { let _ = ex.call_raw("set_sandbox_mode", vec![Value::Bool(true)]).await; }
        if demo { let _ = ex.call_raw("enable_demo_trading", vec![Value::Bool(true)]).await; }
        // Most unified methods need markets loaded first.
        let skip_load = ["load_markets", "fetch_markets", "fetch_currencies", "fetch_time", "fetch_status", "describe"].contains(&m);
        if !skip_load { let _ = ex.load_markets(false).await; }
        ex.call_raw(m, args).await
    }).catch_unwind().await;

    match result {
        Ok(Ok(v)) => println!("\n{GREEN}result:{RESET} {}", pretty(&materialize_book(&v), 0)),
        Ok(Err(e)) => { eprintln!("\n{RED}error:{RESET} {e}"); std::process::exit(1); }
        Err(payload) => {
            let msg = payload.downcast_ref::<String>().map(|s| s.as_str())
                .or_else(|| payload.downcast_ref::<&str>().copied())
                .unwrap_or("(unknown error)");
            eprintln!("\n{RED}error:{RESET} {msg}");
            std::process::exit(1);
        }
    }
}

// Streaming dispatch for `watch*` methods on the pro (`ccxt_pro`) crate. Mirrors
// `cli.ts`: after the first resolution it keeps re-invoking the same watch method
// in a loop, printing each update until Ctrl-C (or the first error).
#[cfg(feature = "ws")]
async fn run_ws(id: &str, m: &str, args: Vec<Value>, testnet: bool, demo: bool, config: HashMap<String, Value>) {
    use ccxt_pro::TypedExchange;
    let mut ex: Box<dyn TypedExchange> = match ccxt_pro::from_id(id, Some(Value::Map(config))) {
        Some(e) => e,
        None => {
            eprintln!("\n{RED}error:{RESET} no WebSocket (pro) venue for exchange: {id}");
            std::process::exit(1);
        }
    };
    if testnet { let _ = ex.call_raw("set_sandbox_mode", vec![Value::Bool(true)]).await; }
    if demo { let _ = ex.call_raw("enable_demo_trading", vec![Value::Bool(true)]).await; }
    let _ = ex.load_markets(false).await;
    println!("{YELLOW}streaming {m} — press Ctrl-C to stop{RESET}");

    let mut i: u64 = 0;
    loop {
        // The pro wrappers surface their panic-based errors as `Result`, so a
        // plain match suffices — no per-iteration unwind guard needed.
        match ex.call_raw(m, args.clone()).await {
            Ok(v) => println!("\n{GREEN}[{i}]{RESET} {}", pretty(&materialize_book(&v), 0)),
            Err(e) => { eprintln!("\n{RED}error:{RESET} {e}"); std::process::exit(1); }
        }
        i += 1;
    }
}