bitget_rs 0.1.0

Bitget V2 futures SDK
Documentation
use bitget_rs::BitgetClient;
use bitget_rs::api::market::{BitgetMarket, TickerRequest};
use bitget_rs::api::trade::{BitgetTrade, CancelOrderRequest, NewOrderRequest};
use serde_json::Value;
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};

const CONFIRM_VALUE: &str = "I_UNDERSTAND_THIS_USES_REAL_FUNDS";

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    require_live_confirmation()?;

    let symbol = env::var("BITGET_LIVE_SYMBOL").unwrap_or_else(|_| "BTCUSDT".to_string());
    let product_type =
        env::var("BITGET_LIVE_PRODUCT_TYPE").unwrap_or_else(|_| "USDT-FUTURES".to_string());
    let margin_mode = env::var("BITGET_LIVE_MARGIN_MODE").unwrap_or_else(|_| "crossed".to_string());
    let margin_coin = env::var("BITGET_LIVE_MARGIN_COIN").unwrap_or_else(|_| "USDT".to_string());
    let side = env::var("BITGET_LIVE_SIDE").unwrap_or_else(|_| "buy".to_string());
    let trade_side = env::var("BITGET_LIVE_TRADE_SIDE").unwrap_or_else(|_| "open".to_string());
    let offset_bps = env::var("BITGET_LIVE_PRICE_OFFSET_BPS")
        .ok()
        .and_then(|value| value.parse::<f64>().ok())
        .unwrap_or(500.0);

    let market = BitgetMarket::new(BitgetClient::new_public()?);
    let trade = BitgetTrade::from_env()?;

    let contracts = market
        .get_contracts(&product_type, Some(&symbol))
        .await?
        .as_array()
        .cloned()
        .ok_or("contract response data must be an array")?;
    let contract = contracts
        .first()
        .ok_or_else(|| format!("contract not found for {symbol}"))?;
    let tickers = market
        .get_ticker(TickerRequest::new(&symbol, &product_type))
        .await?;
    let ticker = tickers
        .first()
        .ok_or_else(|| format!("ticker not found for {symbol}"))?;
    let ticker = serde_json::to_value(ticker)?;
    let plan = build_post_only_plan(contract, &ticker, &symbol, &side, offset_bps)?;

    let client_oid = format!(
        "sdk_bg_{}",
        SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
    );
    let order = NewOrderRequest::limit(
        &symbol,
        &product_type,
        &margin_mode,
        &margin_coin,
        plan.size.clone(),
        &side,
        plan.price.clone(),
    )
    .with_trade_side(trade_side.clone())
    .with_force("post_only")
    .with_client_oid(client_oid.clone());

    println!(
        "placing bitget live post-only order: symbol={} productType={} side={} tradeSide={} size={} price={} clientOid={}",
        symbol, product_type, side, trade_side, plan.size, plan.price, client_oid
    );

    let placed = trade.place_order(order).await?;
    println!("placed: {}", serde_json::to_string_pretty(&placed)?);

    if env::var("BITGET_LIVE_SKIP_CANCEL").as_deref() == Ok("true") {
        println!("skip cancel requested by BITGET_LIVE_SKIP_CANCEL=true");
        return Ok(());
    }

    let mut cancel_request =
        CancelOrderRequest::new(&symbol, &product_type).with_margin_coin(margin_coin);
    if let Some(order_id) = placed.get("orderId").and_then(Value::as_str) {
        cancel_request = cancel_request.with_order_id(order_id);
    } else {
        cancel_request = cancel_request.with_client_oid(client_oid);
    }
    let canceled = trade.cancel_order(cancel_request).await?;
    println!("canceled: {}", serde_json::to_string_pretty(&canceled)?);

    Ok(())
}

fn require_live_confirmation() -> Result<(), Box<dyn std::error::Error>> {
    match env::var("BITGET_LIVE_ORDER_CONFIRM").as_deref() {
        Ok(CONFIRM_VALUE) => Ok(()),
        _ => Err(format!(
            "set BITGET_LIVE_ORDER_CONFIRM={CONFIRM_VALUE} to place a real futures order"
        )
        .into()),
    }
}

#[derive(Debug, Clone, PartialEq)]
struct PostOnlyPlan {
    price: String,
    size: String,
}

fn build_post_only_plan(
    contract: &Value,
    ticker: &Value,
    symbol: &str,
    side: &str,
    offset_bps: f64,
) -> Result<PostOnlyPlan, Box<dyn std::error::Error>> {
    if contract.get("symbol").and_then(Value::as_str) != Some(symbol) {
        return Err(format!("contract symbol mismatch for {symbol}").into());
    }
    if contract.get("symbolStatus").and_then(Value::as_str) != Some("normal") {
        return Err(format!("{symbol} is not normal").into());
    }

    let price_place = decimal_field(contract, "pricePlace")? as i32;
    let price_end_step = decimal_field(contract, "priceEndStep")?;
    let tick_size = price_end_step * 10_f64.powi(-price_place);
    let size_step = decimal_field(contract, "sizeMultiplier")?;
    let min_size = decimal_field(contract, "minTradeNum")?;
    let min_notional = decimal_field(contract, "minTradeUSDT").unwrap_or(5.0);
    let last_price = decimal_field(ticker, "lastPr")?;
    let bid_price = decimal_field(ticker, "bidPr").unwrap_or(last_price);
    let ask_price = decimal_field(ticker, "askPr").unwrap_or(last_price);
    let offset = (offset_bps / 10_000.0).clamp(0.0001, 0.5);

    let raw_price = match side {
        "buy" => round_down(bid_price * (1.0 - offset), tick_size),
        "sell" => round_up(ask_price * (1.0 + offset), tick_size),
        other => return Err(format!("unsupported side: {other}").into()),
    };
    if raw_price <= 0.0 {
        return Err("computed price must be positive".into());
    }

    let min_notional_size = round_up(min_notional / raw_price, size_step);
    let size = round_up(min_size.max(min_notional_size), size_step);

    Ok(PostOnlyPlan {
        price: format_decimal(raw_price, decimals_from_step(tick_size)),
        size: format_decimal(size, decimals_from_step(size_step)),
    })
}

fn decimal_field(value: &Value, field: &str) -> Result<f64, String> {
    value
        .get(field)
        .and_then(Value::as_str)
        .ok_or_else(|| format!("missing decimal field: {field}"))?
        .parse::<f64>()
        .map_err(|err| format!("invalid decimal field {field}: {err}"))
}

fn round_down(value: f64, step: f64) -> f64 {
    ((value / step) + 1e-9).floor() * step
}

fn round_up(value: f64, step: f64) -> f64 {
    ((value / step) - 1e-9).ceil() * step
}

fn decimals_from_step(step: f64) -> usize {
    let formatted = format!("{step:.12}");
    formatted
        .trim_end_matches('0')
        .split_once('.')
        .map(|(_, decimals)| decimals.len())
        .unwrap_or(0)
}

fn format_decimal(value: f64, decimals: usize) -> String {
    format!("{value:.decimals$}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn builds_buy_post_only_plan_from_contract_config() {
        let contract = json!({
            "symbol": "BTCUSDT",
            "symbolStatus": "normal",
            "minTradeNum": "0.0001",
            "sizeMultiplier": "0.0001",
            "minTradeUSDT": "5",
            "pricePlace": "1",
            "priceEndStep": "1"
        });
        let ticker = json!({
            "lastPr": "100000.0",
            "bidPr": "99999.9",
            "askPr": "100000.1"
        });

        let plan = build_post_only_plan(&contract, &ticker, "BTCUSDT", "buy", 1_000.0).unwrap();

        assert_eq!(plan.price, "89999.9");
        assert_eq!(plan.size, "0.0001");
    }
}