use std::cmp::Reverse;
use rust_decimal::Decimal;
use serde_json::Value;
use crate::error::{Error, Result};
use crate::types::{
AccountEvent, Balance, Candle, Exchange, Interval, Level, Market, MarketEvent, MarketInfo,
MarketKind, MarketStatus, Order, OrderBook, OrderStatus, Side, Ticker, Timestamp, Trade,
WithdrawalFee,
};
use super::{
BithumbAlertStep, BithumbApiKey, BithumbAssetFee, BithumbMarketAlert, BithumbNetworkFee,
BithumbNotice, network_from_provider,
};
pub(crate) const EXCHANGE: &str = Exchange::Bithumb.id();
pub(crate) fn native_symbol(market: &Market) -> Result<String> {
if market.exchange != Exchange::Bithumb {
return Err(Error::invalid_request(
"market.exchange",
format!("expected a Bithumb market, got {}", market.exchange),
));
}
if market.kind != MarketKind::Spot {
return Err(Error::invalid_request(
"market.kind",
"Bithumb lists spot markets only",
));
}
asset("market.quote", &market.quote)?;
asset("market.base", &market.base)?;
Ok(format!("{}-{}", market.quote, market.base))
}
pub(crate) fn market_field(value: &Value, name: &'static str) -> Result<Market> {
let symbol = text(value, name)?;
split_symbol(symbol)
.ok_or_else(|| Error::decode(format!("`{name}` is not a market code: `{symbol}`")))
}
fn split_symbol(symbol: &str) -> Option<Market> {
let (quote, base) = symbol.split_once('-')?;
let market = Market::spot(Exchange::Bithumb, base, quote);
native_symbol(&market).ok().filter(|code| code == symbol)?;
Some(market)
}
fn asset(field: &'static str, value: &str) -> Result<()> {
if value.is_empty()
|| !value
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
{
return Err(Error::invalid_request(
field,
format!("`{value}` is not a Bithumb asset code"),
));
}
Ok(())
}
pub(crate) fn exchange_error(status: u16, body: &str) -> Error {
match serde_json::from_str::<Value>(body)
.ok()
.as_ref()
.and_then(|value| value.get("error").cloned())
{
Some(error) => {
let code = match error.get("name") {
Some(Value::String(name)) => name.clone(),
Some(Value::Number(name)) => name.to_string(),
_ => "bithumb_error".to_string(),
};
let mut message = error
.get("message")
.and_then(Value::as_str)
.unwrap_or(body)
.trim()
.to_string();
if code == "travel_rule_consent_required" {
if let Some(exchange) = error.get("exchange_name").and_then(Value::as_str) {
message.push_str(&format!("; exchange_name={exchange}"));
}
if let Some(url) = error.get("consent_url").and_then(Value::as_str) {
message.push_str(&format!("; consent_url={url}"));
}
}
Error::exchange_http(EXCHANGE, status, code, message)
}
None => Error::exchange_http(EXCHANGE, status, "bithumb_error", body.trim().to_string()),
}
}
pub(crate) fn body(raw: &str) -> Result<Value> {
serde_json::from_str(raw).map_err(|err| Error::decode(format!("response is not JSON: {err}")))
}
fn entries(value: &Value) -> Result<&Vec<Value>> {
value
.as_array()
.ok_or_else(|| Error::decode("expected a JSON array"))
}
fn field<'a>(value: &'a Value, name: &'static str) -> Result<&'a Value> {
value
.get(name)
.filter(|found| !found.is_null())
.ok_or_else(|| Error::decode(format!("missing `{name}`")))
}
fn text<'a>(value: &'a Value, name: &'static str) -> Result<&'a str> {
field(value, name)?
.as_str()
.ok_or_else(|| Error::decode(format!("`{name}` is not a string")))
}
pub(crate) fn dec(value: &Value, name: &'static str) -> Result<Decimal> {
match field(value, name)? {
Value::Number(number) => decimal(&number.to_string(), name),
Value::String(raw) => decimal(raw, name),
_ => Err(Error::decode(format!("`{name}` is not a number"))),
}
}
fn dec_opt(value: &Value, name: &'static str) -> Result<Option<Decimal>> {
match value.get(name) {
None | Some(Value::Null) => Ok(None),
Some(_) => dec(value, name).map(Some),
}
}
pub(crate) fn decimal(raw: &str, name: &'static str) -> Result<Decimal> {
crate::adapters::decimal::exact(raw)
.map_err(|err| Error::decode(format!("`{name}` is not an exact decimal `{raw}`: {err}")))
}
pub(crate) fn millis(value: &Value, name: &'static str) -> Result<Timestamp> {
epoch(value, name, 1_000_000, "millisecond")
}
fn micros(value: &Value, name: &'static str) -> Result<Timestamp> {
epoch(value, name, 1_000, "microsecond")
}
fn epoch(
value: &Value,
name: &'static str,
nanos_per_unit: i64,
unit: &'static str,
) -> Result<Timestamp> {
const EARLIEST: i64 = 1_000_000_000_000_000_000;
let raw = field(value, name)?
.as_i64()
.ok_or_else(|| Error::decode(format!("`{name}` is not a whole-number timestamp")))?;
raw.checked_mul(nanos_per_unit)
.filter(|nanos| *nanos >= EARLIEST)
.map(Timestamp::from_nanos)
.ok_or_else(|| Error::decode(format!("`{name}` is not a {unit} timestamp: {raw}")))
}
fn millis_opt(value: &Value, name: &'static str) -> Result<Option<Timestamp>> {
match value.get(name) {
None | Some(Value::Null) => Ok(None),
Some(_) => millis(value, name).map(Some),
}
}
pub(crate) fn side(value: &Value, name: &'static str) -> Result<Side> {
match text(value, name)? {
"BID" | "bid" | "buy" => Ok(Side::Buy),
"ASK" | "ask" | "sell" => Ok(Side::Sell),
other => Err(Error::decode(format!(
"`{name}` is neither side: `{other}`"
))),
}
}
fn trade_id(value: &Value) -> Option<String> {
match value.get("sequential_id")? {
Value::Number(number) => Some(number.to_string()),
Value::String(raw) => Some(raw.clone()),
_ => None,
}
}
pub(crate) fn markets(value: &Value) -> Result<Vec<MarketInfo>> {
entries(value)?
.iter()
.map(|entry| {
Ok(MarketInfo {
market: market_field(entry, "market")?,
native_symbol: text(entry, "market")?.to_string(),
status: match entry.get("market_warning").and_then(Value::as_str) {
None | Some("NONE") => MarketStatus::Active,
Some(_) => MarketStatus::Unknown,
},
korean_name: entry
.get("korean_name")
.and_then(Value::as_str)
.map(str::to_string),
english_name: entry
.get("english_name")
.and_then(Value::as_str)
.map(str::to_string),
})
})
.collect()
}
pub(crate) fn market_warnings(value: &Value) -> Result<Vec<(Market, String)>> {
entries(value)?
.iter()
.map(|entry| {
Ok((
market_field(entry, "market")?,
entry
.get("market_warning")
.and_then(Value::as_str)
.unwrap_or("NONE")
.to_string(),
))
})
.collect()
}
pub(crate) fn market_alerts(value: &Value) -> Result<Vec<(Market, BithumbMarketAlert)>> {
entries(value)?
.iter()
.map(|entry| {
Ok((
market_field(entry, "market")?,
BithumbMarketAlert {
kind: text(entry, "warning_type")?.to_string(),
step: match text(entry, "warning_step")? {
"CAUTION" => BithumbAlertStep::Caution,
"WARNING" => BithumbAlertStep::Warning,
"DANGER" => BithumbAlertStep::Danger,
_ => BithumbAlertStep::Unknown,
},
ends_at: kst_timestamp(text(entry, "end_date")?, "end_date")?,
},
))
})
.collect()
}
fn kst_timestamp(raw: &str, field: &'static str) -> Result<Timestamp> {
const KST_OFFSET_SECS: i64 = 9 * 3_600;
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S")
.map(|naive| {
Timestamp::from_secs(naive.and_utc().timestamp().saturating_sub(KST_OFFSET_SECS))
})
.map_err(|err| Error::decode(format!("`{field}` is not a Korean wall clock: {err}")))
}
pub(crate) fn notices(value: &Value) -> Result<Vec<BithumbNotice>> {
entries(value)?
.iter()
.map(|entry| {
let categories = entries(field(entry, "categories")?)?
.iter()
.map(|category| {
category
.as_str()
.map(str::to_owned)
.ok_or_else(|| Error::decode("`categories` contains a non-string"))
})
.collect::<Result<Vec<_>>>()?;
Ok(BithumbNotice {
categories,
title: text(entry, "title")?.to_owned(),
url: text(entry, "pc_url")?.to_owned(),
published_at: kst_timestamp(text(entry, "published_at")?, "published_at")?,
modified_at: kst_timestamp(text(entry, "modified_at")?, "modified_at")?,
})
})
.collect()
}
pub(crate) fn api_keys(value: &Value) -> Result<Vec<BithumbApiKey>> {
entries(value)?
.iter()
.map(|entry| {
let access_key = text(entry, "access_key")?.to_owned();
if access_key.trim().is_empty() {
return Err(Error::decode("`access_key` must not be empty"));
}
let expires_at = offset_time(text(entry, "expire_at")?).ok_or_else(|| {
Error::decode("`expire_at` is not an RFC 3339 timestamp with an offset")
})?;
Ok(BithumbApiKey {
access_key,
expires_at,
})
})
.collect()
}
pub(crate) fn transfer_fees(value: &Value) -> Result<Vec<BithumbAssetFee>> {
entries(value)?
.iter()
.map(|entry| {
let asset = text(entry, "currency")?.trim().to_ascii_uppercase();
if asset.is_empty()
|| !asset
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
{
return Err(Error::decode("`currency` is not a Bithumb asset code"));
}
let networks = entries(field(entry, "networks")?)?
.iter()
.map(|network| {
let provider_name = text(network, "net_name")?.trim().to_owned();
if provider_name.is_empty() {
return Err(Error::decode("`net_name` must not be empty"));
}
Ok(BithumbNetworkFee {
network: network_from_provider(&provider_name),
provider_name,
deposit_fee: dec(network, "deposit_fee_quantity")?,
minimum_deposit: dec(network, "deposit_minimum_quantity")?,
withdrawal_fee: withdrawal_fee(network)?,
minimum_withdrawal: dec(network, "withdraw_minimum_quantity")?,
})
})
.collect::<Result<Vec<_>>>()?;
Ok(BithumbAssetFee {
display_name: text(entry, "name")?.to_owned(),
asset,
networks,
})
})
.collect()
}
fn withdrawal_fee(value: &Value) -> Result<WithdrawalFee> {
let fixed = dec_opt(value, "withdraw_fee_quantity")?;
let rate = dec_opt(value, "withdraw_rate")?;
match (fixed, rate) {
(Some(value), None) => Ok(WithdrawalFee::Fixed(value)),
(None, Some(rate)) => Ok(WithdrawalFee::Rate {
rate,
minimum: dec_opt(value, "withdraw_fee_min")?,
maximum: dec_opt(value, "withdraw_fee_max")?,
}),
(None, None) => Err(Error::decode(
"Bithumb transfer fee has neither a fixed amount nor a rate",
)),
(Some(_), Some(_)) => Err(Error::decode(
"Bithumb transfer fee has both a fixed amount and a rate",
)),
}
}
pub(crate) fn trades(value: &Value) -> Result<Vec<Trade>> {
let mut trades = entries(value)?
.iter()
.map(|entry| {
Ok(Trade {
market: market_field(entry, "market")?,
timestamp: millis(entry, "timestamp")?,
price: dec(entry, "trade_price")?,
quantity: dec(entry, "trade_volume")?,
taker_side: side(entry, "ask_bid")?,
id: trade_id(entry),
})
})
.collect::<Result<Vec<_>>>()?;
trades.sort_by_key(|trade| Reverse(trade.timestamp));
Ok(trades)
}
pub(crate) fn order_book(
entry: &Value,
market: Market,
timestamp: Timestamp,
depth: Option<u32>,
) -> Result<OrderBook> {
let units = entries(field(entry, "orderbook_units")?)?;
let mut bids = Vec::with_capacity(units.len());
let mut asks = Vec::with_capacity(units.len());
for unit in units {
let bid = Level {
price: dec(unit, "bid_price")?,
quantity: dec(unit, "bid_size")?,
};
if !bid.quantity.is_zero() {
bids.push(bid);
}
let ask = Level {
price: dec(unit, "ask_price")?,
quantity: dec(unit, "ask_size")?,
};
if !ask.quantity.is_zero() {
asks.push(ask);
}
}
bids.sort_by_key(|level| Reverse(level.price));
asks.sort_by_key(|level| level.price);
if let Some(depth) = depth {
let depth = usize::try_from(depth).unwrap_or(usize::MAX);
bids.truncate(depth);
asks.truncate(depth);
}
Ok(OrderBook {
market,
timestamp,
bids,
asks,
})
}
pub(crate) fn ticker(entry: &Value, market: Market) -> Result<Ticker> {
let shift = ticker_clock_shift(entry)?;
let onto_utc = |stamp: Timestamp| Timestamp::from_nanos(stamp.as_nanos().saturating_add(shift));
Ok(Ticker {
market,
timestamp: onto_utc(millis(entry, "timestamp")?),
last_trade_time: millis_opt(entry, "trade_timestamp")?.map(onto_utc),
last_price: dec(entry, "trade_price")?,
change: dec_opt(entry, "signed_change_price")?,
change_rate: dec_opt(entry, "signed_change_rate")?,
high: dec_opt(entry, "high_price")?,
low: dec_opt(entry, "low_price")?,
volume: dec_opt(entry, "acc_trade_volume_24h")?,
quote_volume: dec_opt(entry, "acc_trade_price_24h")?,
})
}
fn ticker_clock_shift(entry: &Value) -> Result<i64> {
const KST_OFFSET_SECS: i64 = 9 * 3_600;
if entry.get("trade_date_kst").is_none() {
return Ok(0);
}
let stated = chrono::NaiveDateTime::parse_from_str(
&format!(
"{}{}",
text(entry, "trade_date")?,
text(entry, "trade_time")?
),
"%Y%m%d%H%M%S",
)
.map_err(|err| {
Error::decode(format!(
"`trade_date`/`trade_time` is not a UTC time: {err}"
))
})?
.and_utc()
.timestamp();
match millis(entry, "trade_timestamp")?.as_secs() - stated {
0 => Ok(0),
KST_OFFSET_SECS => Ok(-KST_OFFSET_SECS * 1_000_000_000),
other => Err(Error::decode(format!(
"`trade_timestamp` is {other}s from the `trade_time` beside it"
))),
}
}
pub(crate) fn candles(value: &Value, interval: Interval, now: Timestamp) -> Result<Vec<Candle>> {
entries(value)?
.iter()
.map(|entry| {
let open_time = candle_open_time(text(entry, "candle_date_time_utc")?)?;
let closed = advance_open(interval, open_time, 1)
.is_some_and(|end_of_window| end_of_window <= now);
Ok(Candle {
market: market_field(entry, "market")?,
interval,
open_time,
open: dec(entry, "opening_price")?,
high: dec(entry, "high_price")?,
low: dec(entry, "low_price")?,
close: dec(entry, "trade_price")?,
volume: dec(entry, "candle_acc_trade_volume")?,
quote_volume: dec_opt(entry, "candle_acc_trade_price")?,
closed,
})
})
.collect()
}
pub(super) fn advance_open(
interval: Interval,
open_time: Timestamp,
count: i64,
) -> Option<Timestamp> {
const KST_OFFSET_NANOS: i64 = 9 * 3_600 * 1_000_000_000;
let in_kst = Timestamp::from_nanos(open_time.as_nanos().checked_add(KST_OFFSET_NANOS)?);
interval
.advance(in_kst, count)?
.as_nanos()
.checked_sub(KST_OFFSET_NANOS)
.map(Timestamp::from_nanos)
}
fn candle_open_time(raw: &str) -> Result<Timestamp> {
chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
.map(|naive| Timestamp::from_secs(naive.and_utc().timestamp()))
.map_err(|err| Error::decode(format!("`candle_date_time_utc` is not a UTC time: {err}")))
}
pub(crate) fn balances(value: &Value) -> Result<Vec<Balance>> {
entries(value)?
.iter()
.map(|entry| {
Ok(Balance {
asset: text(entry, "currency")?.to_ascii_uppercase(),
available: dec(entry, "balance")?,
locked: dec(entry, "locked")?,
})
})
.collect()
}
pub(crate) fn orders(value: &Value) -> Result<Vec<Order>> {
entries(value)?.iter().map(order).collect()
}
pub(crate) fn order(entry: &Value) -> Result<Order> {
let filled_quantity = dec(entry, "executed_volume")?;
let remaining_quantity = dec(entry, "remaining_volume")?;
Ok(Order {
id: entry
.get("uuid")
.or_else(|| entry.get("order_id"))
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| Error::decode("missing `uuid` or `order_id`"))?
.to_string(),
market: market_field(entry, "market")?,
side: side(entry, "side")?,
status: rest_order_status(text(entry, "state")?, filled_quantity),
filled_quantity,
remaining_quantity,
price: dec_opt(entry, "price")?,
created_at: entry
.get("created_at")
.and_then(Value::as_str)
.and_then(offset_time),
})
}
fn rest_order_status(state: &str, filled: Decimal) -> OrderStatus {
match state {
"wait" | "watch" if filled.is_zero() => OrderStatus::Open,
"wait" | "watch" => OrderStatus::PartiallyFilled,
"done" => OrderStatus::Filled,
"cancel" => OrderStatus::Cancelled,
_ => OrderStatus::Unknown,
}
}
pub(crate) fn offset_time(raw: &str) -> Option<Timestamp> {
chrono::DateTime::parse_from_rfc3339(raw)
.ok()
.and_then(|parsed| parsed.timestamp_nanos_opt())
.map(Timestamp::from_nanos)
}
pub(crate) fn order_ack(
entry: &Value,
market: Market,
side: Side,
status: OrderStatus,
remaining_quantity: Decimal,
price: Option<Decimal>,
) -> Result<Order> {
Ok(Order {
id: text(entry, "order_id")?.to_string(),
market,
side,
status,
filled_quantity: Decimal::ZERO,
remaining_quantity,
price,
created_at: entry
.get("created_at")
.and_then(Value::as_str)
.and_then(offset_time),
})
}
pub(crate) fn market_event(frame: &Value) -> Result<Option<MarketEvent>> {
if let Some(error) = frame.get("error") {
return Err(frame_error(error));
}
let Some(kind) = frame.get("type").and_then(Value::as_str) else {
return Ok(None);
};
let event = match kind {
"trade" => MarketEvent::Trade(Trade {
market: market_field(frame, "code")?,
timestamp: millis(frame, "trade_timestamp")?,
price: dec(frame, "trade_price")?,
quantity: dec(frame, "trade_volume")?,
taker_side: side(frame, "ask_bid")?,
id: trade_id(frame),
}),
"orderbook" => {
let market = market_field(frame, "code")?;
let timestamp = micros(frame, "timestamp")?;
MarketEvent::OrderBook(order_book(frame, market, timestamp, None)?)
}
"ticker" => {
let market = market_field(frame, "code")?;
MarketEvent::Ticker(ticker(frame, market)?)
}
_ => return Ok(None),
};
Ok(Some(event))
}
pub(crate) fn account_events(frame: &Value) -> Result<Vec<AccountEvent>> {
if let Some(error) = frame.get("error") {
return Err(frame_error(error));
}
match frame.get("type").and_then(Value::as_str) {
Some("myAsset") => Ok(balances(field(frame, "assets")?)?
.into_iter()
.map(AccountEvent::Balance)
.collect()),
Some("myOrder") => Ok(vec![AccountEvent::Order(my_order(frame)?)]),
_ => Ok(Vec::new()),
}
}
fn my_order(frame: &Value) -> Result<Order> {
let state = text(frame, "state")?;
let filled_quantity = match dec_opt(frame, "executed_quantity")? {
Some(quantity) => quantity,
None if state == "wait" => Decimal::ZERO,
None => {
return Err(Error::decode(
"`executed_quantity` is missing on a filled order",
));
}
};
let remaining_quantity = match dec_opt(frame, "remaining_quantity")? {
Some(quantity) => quantity,
None if state == "wait" => dec(frame, "order_quantity")?,
None => {
return Err(Error::decode(
"`remaining_quantity` is missing on a filled order",
));
}
};
Ok(Order {
id: text(frame, "order_id")?.to_string(),
market: market_field(frame, "code")?,
side: side(frame, "side")?,
status: stream_order_status(state, remaining_quantity),
filled_quantity,
remaining_quantity,
price: dec_opt(frame, "order_price")?,
created_at: Some(millis(frame, "order_timestamp")?),
})
}
fn stream_order_status(state: &str, remaining: Decimal) -> OrderStatus {
match state {
"wait" => OrderStatus::Open,
"trade" if remaining.is_zero() => OrderStatus::Filled,
"trade" => OrderStatus::PartiallyFilled,
"done" if remaining.is_zero() => OrderStatus::Filled,
"done" | "cancel" => OrderStatus::Cancelled,
_ => OrderStatus::Unknown,
}
}
fn frame_error(error: &Value) -> Error {
Error::exchange(
EXCHANGE,
error
.get("name")
.and_then(Value::as_str)
.unwrap_or("bithumb_websocket_error"),
error
.get("message")
.and_then(Value::as_str)
.unwrap_or("the WebSocket returned an error frame")
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Network;
const MARKET_LIST: &str = r#"[
{
"market": "KRW-BTC",
"korean_name": "비트코인",
"english_name": "Bitcoin",
"market_warning": "NONE"
},
{
"market": "KRW-ZIL",
"korean_name": "질리카",
"english_name": "Zilliqa",
"market_warning": "CAUTION"
},
{
"market": "KRW-ACS",
"korean_name": "액세스프로토콜",
"english_name": "Access Protocol",
"market_warning": "NONE"
}
]"#;
const MARKET_ALERTS: &str = r#"[
{
"market": "KRW-ZIL",
"warning_type": "TRADING_VOLUME_SUDDEN_FLUCTUATION",
"warning_step": "DANGER",
"end_date": "2026-07-31 06:59:59"
},
{
"market": "KRW-OBSR",
"warning_type": "TRADING_VOLUME_SUDDEN_FLUCTUATION",
"warning_step": "DANGER",
"end_date": "2026-07-31 06:59:59"
},
{
"market": "KRW-OBSR",
"warning_type": "DEPOSIT_AMOUNT_SUDDEN_FLUCTUATION",
"warning_step": "WARNING",
"end_date": "2026-07-31 07:04:59"
},
{
"market": "KRW-KAIA",
"warning_type": "SPECIFIC_ACCOUNT_HIGH_TRANSACTION",
"warning_step": "CAUTION",
"end_date": "2026-07-31 07:09:59"
},
{
"market": "KRW-ACS",
"warning_type": "DEPOSIT_AMOUNT_SUDDEN_FLUCTUATION",
"warning_step": "DANGER",
"end_date": "2026-07-31 07:04:59"
}
]"#;
const NOTICES: &str = r#"[
{
"categories": ["입출금", "점검"],
"title": "네트워크 점검 안내",
"pc_url": "https://feed.bithumb.com/notice/1654458",
"published_at": "2026-08-11 18:00:00",
"modified_at": "2026-08-11 16:24:36"
}
]"#;
const TRANSFER_FEES: &str = r#"[
{
"name": "비트코인",
"currency": "BTC",
"networks": [
{
"net_name": "Bitcoin",
"deposit_fee_quantity": "0",
"deposit_minimum_quantity": "0",
"withdraw_fee_quantity": "0.0002",
"withdraw_rate": null,
"withdraw_fee_min": null,
"withdraw_fee_max": null,
"withdraw_minimum_quantity": "0.001"
}
]
},
{
"name": "토큰",
"currency": "TOKEN",
"networks": [
{
"net_name": "Arbitrum One",
"deposit_fee_quantity": "0.01",
"deposit_minimum_quantity": "2",
"withdraw_fee_quantity": null,
"withdraw_rate": "0.01",
"withdraw_fee_min": "1",
"withdraw_fee_max": "100",
"withdraw_minimum_quantity": "10"
}
]
}
]"#;
const API_KEYS: &str = r#"[
{
"access_key": "example-access-key-1",
"expire_at": "2027-06-11T09:00:00+09:00"
}
]"#;
const TICKER: &str = r#"[
{
"market": "KRW-BTC",
"trade_date": "20260730",
"trade_time": "074830",
"trade_date_kst": "20260730",
"trade_time_kst": "164830",
"trade_timestamp": 1785430110156,
"opening_price": 92112000,
"high_price": 92770000,
"low_price": 90904000,
"trade_price": 91171000,
"prev_closing_price": 92144000,
"change": "FALL",
"change_price": 973000,
"change_rate": 0.0106,
"signed_change_price": -973000,
"signed_change_rate": -0.0106,
"trade_volume": 0.02582544,
"acc_trade_price": 19833795210.02728,
"acc_trade_price_24h": 27001571740.8207,
"acc_trade_volume": 216.55225961,
"acc_trade_volume_24h": 294.0810771,
"highest_52_week_price": 179734000,
"highest_52_week_date": "2025-10-10",
"lowest_52_week_price": 81110000,
"lowest_52_week_date": "2026-02-07",
"timestamp": 1785430110156
}
]"#;
const TRADES: &str = r#"[
{
"market": "KRW-ETC",
"trade_date_utc": "2026-07-30",
"trade_time_utc": "07:47:41",
"timestamp": 1785397661964,
"trade_price": 9620,
"trade_volume": 10.07,
"prev_closing_price": 9590,
"change_price": 30,
"ask_bid": "BID",
"sequential_id": 17853976619640000
},
{
"market": "KRW-ETC",
"trade_date_utc": "2026-07-30",
"trade_time_utc": "07:45:54",
"timestamp": 1785397554763,
"trade_price": 9620,
"trade_volume": 0.51975052,
"prev_closing_price": 9590,
"change_price": 30,
"ask_bid": "BID",
"sequential_id": 17853975547630000
},
{
"market": "KRW-ETC",
"trade_date_utc": "2026-07-30",
"trade_time_utc": "07:40:04",
"timestamp": 1785397204627,
"trade_price": 9625,
"trade_volume": 4.8,
"prev_closing_price": 9590,
"change_price": 35,
"ask_bid": "BID",
"sequential_id": 17853972046270000
}
]"#;
const ORDER_BOOK: &str = r#"[
{
"market": "KRW-BTC",
"timestamp": 1785397720965,
"total_ask_size": 3.7055,
"total_bid_size": 5.622,
"orderbook_units": [
{ "ask_price": 91226000, "bid_price": 91196000, "ask_size": 0.0469, "bid_size": 0.0002 },
{ "ask_price": 91233000, "bid_price": 91172000, "ask_size": 0.0396, "bid_size": 0.0031 },
{ "ask_price": 91234000, "bid_price": 91171000, "ask_size": 0, "bid_size": 0.0496 },
{ "ask_price": 91235000, "bid_price": 91170000, "ask_size": 0.2324, "bid_size": 0.0004 },
{ "ask_price": 91238000, "bid_price": 91166000, "ask_size": 0.0491, "bid_size": 0.0065 },
{ "ask_price": 91239000, "bid_price": 91164000, "ask_size": 0.0197, "bid_size": 0.0001 },
{ "ask_price": 91244000, "bid_price": 91163000, "ask_size": 0.0224, "bid_size": 4.7357 },
{ "ask_price": 91245000, "bid_price": 91162000, "ask_size": 0.0258, "bid_size": 0.1104 },
{ "ask_price": 91249000, "bid_price": 91161000, "ask_size": 0.1065, "bid_size": 0.0088 },
{ "ask_price": 91255000, "bid_price": 91160000, "ask_size": 0.0142, "bid_size": 0.1 },
{ "ask_price": 91270000, "bid_price": 91159000, "ask_size": 0.0192, "bid_size": 0.0775 },
{ "ask_price": 91274000, "bid_price": 91157000, "ask_size": 0.0002, "bid_size": 0.137 },
{ "ask_price": 91276000, "bid_price": 91156000, "ask_size": 0.0088, "bid_size": 0.0003 },
{ "ask_price": 91277000, "bid_price": 91154000, "ask_size": 0.0665, "bid_size": 0.011 },
{ "ask_price": 91280000, "bid_price": 91150000, "ask_size": 0.0294, "bid_size": 0.0104 },
{ "ask_price": 91282000, "bid_price": 91149000, "ask_size": 0.0019, "bid_size": 0.0127 },
{ "ask_price": 91283000, "bid_price": 91148000, "ask_size": 0.0037, "bid_size": 0.0179 },
{ "ask_price": 91285000, "bid_price": 91147000, "ask_size": 0.137, "bid_size": 0.1376 },
{ "ask_price": 91286000, "bid_price": 91146000, "ask_size": 2.198, "bid_size": 0.0327 },
{ "ask_price": 91288000, "bid_price": 91145000, "ask_size": 0.0066, "bid_size": 0.0001 },
{ "ask_price": 91291000, "bid_price": 91144000, "ask_size": 0.137, "bid_size": 0.0001 },
{ "ask_price": 91292000, "bid_price": 91143000, "ask_size": 0.0667, "bid_size": 0.0109 },
{ "ask_price": 91293000, "bid_price": 91142000, "ask_size": 0.0002, "bid_size": 0 },
{ "ask_price": 91297000, "bid_price": 91141000, "ask_size": 0.0002, "bid_size": 0.0004 },
{ "ask_price": 91300000, "bid_price": 91140000, "ask_size": 0.0103, "bid_size": 0.0002 },
{ "ask_price": 91303000, "bid_price": 91139000, "ask_size": 0.0043, "bid_size": 0.05 },
{ "ask_price": 91307000, "bid_price": 91138000, "ask_size": 0.0636, "bid_size": 0.0307 },
{ "ask_price": 91311000, "bid_price": 91137000, "ask_size": 0.3549, "bid_size": 0.0001 },
{ "ask_price": 91312000, "bid_price": 91136000, "ask_size": 0.0009, "bid_size": 0.0071 },
{ "ask_price": 91314000, "bid_price": 91134000, "ask_size": 0.0395, "bid_size": 0.0705 }
]
}
]"#;
const MINUTE_CANDLES: &str = r#"[
{
"market": "KRW-BTC",
"candle_date_time_utc": "2026-07-30T07:48:00",
"candle_date_time_kst": "2026-07-30T16:48:00",
"opening_price": 91226000,
"high_price": 91233000,
"low_price": 91171000,
"trade_price": 91171000,
"timestamp": 1785397710000,
"candle_acc_trade_price": 7044404.52985,
"candle_acc_trade_volume": 0.07725993,
"unit": 1
}
]"#;
const WEEK_CANDLES: &str = r#"[
{
"market": "KRW-BTC",
"candle_date_time_utc": "2026-06-21T15:00:00",
"candle_date_time_kst": "2026-06-22T00:00:00",
"opening_price": 96700000,
"high_price": 98632000,
"low_price": 88888000,
"trade_price": 91022000,
"timestamp": 1782658793854,
"candle_acc_trade_price": 313245537093.97363,
"candle_acc_trade_volume": 3378.18477138,
"first_day_of_period": "2026-06-22"
}
]"#;
const MONTH_CANDLES: &str = r#"[
{
"market": "KRW-BTC",
"candle_date_time_utc": "2026-02-28T15:00:00",
"candle_date_time_kst": "2026-03-01T00:00:00",
"opening_price": 94301000,
"high_price": 112300000,
"low_price": 94050000,
"trade_price": 101614000,
"timestamp": 1774969194120,
"candle_acc_trade_price": 2555582225932.368,
"candle_acc_trade_volume": 24722.59544281,
"first_day_of_period": "2026-03-01"
},
{
"market": "KRW-BTC",
"candle_date_time_utc": "2026-01-31T15:00:00",
"candle_date_time_kst": "2026-02-01T00:00:00",
"opening_price": 121216000,
"high_price": 121960000,
"low_price": 81110000,
"trade_price": 94300000,
"timestamp": 1772290797407,
"candle_acc_trade_price": 4649384858247.525,
"candle_acc_trade_volume": 46020.33836068,
"first_day_of_period": "2026-02-01"
},
{
"market": "KRW-BTC",
"candle_date_time_utc": "2025-12-31T15:00:00",
"candle_date_time_kst": "2026-01-01T00:00:00",
"opening_price": 128474000,
"high_price": 143100000,
"low_price": 119124000,
"trade_price": 121228000,
"timestamp": 1769871599231,
"candle_acc_trade_price": 2293055672758.656,
"candle_acc_trade_volume": 17306.1272608,
"first_day_of_period": "2026-01-01"
}
]"#;
const WS_TRADE: &str = r#"{
"type": "trade",
"code": "KRW-BTC",
"trade_price": 91196000,
"trade_volume": 0.00021931,
"ask_bid": "ASK",
"prev_closing_price": 92144000,
"change": "FALL",
"change_price": 948000,
"trade_date": "2026-07-30",
"trade_time": "16:48:55",
"trade_timestamp": 1785397735004,
"timestamp": 1785397735274,
"sequential_id": 921046844841158138,
"stream_type": "SNAPSHOT"
}"#;
const WS_ORDER_BOOK: &str = r#"{
"type": "orderbook",
"code": "KRW-BTC",
"total_ask_size": 3.4039,
"total_bid_size": 0.0852,
"orderbook_units": [
{ "ask_price": 91196000, "bid_price": 91175000, "ask_size": 0.1995, "bid_size": 0.0000 },
{ "ask_price": 91209000, "bid_price": 91171000, "ask_size": 0.0224, "bid_size": 0.0289 },
{ "ask_price": 91210000, "bid_price": 91170000, "ask_size": 0.0224, "bid_size": 0.0005 },
{ "ask_price": 91211000, "bid_price": 91166000, "ask_size": 0.0688, "bid_size": 0.0065 },
{ "ask_price": 91215000, "bid_price": 91164000, "ask_size": 0.0301, "bid_size": 0.0001 },
{ "ask_price": 91226000, "bid_price": 91163000, "ask_size": 0.1535, "bid_size": 0.0010 },
{ "ask_price": 91231000, "bid_price": 91161000, "ask_size": 0.0192, "bid_size": 0.0021 },
{ "ask_price": 91234000, "bid_price": 91160000, "ask_size": 0.0000, "bid_size": 0.0000 },
{ "ask_price": 91235000, "bid_price": 91156000, "ask_size": 0.2324, "bid_size": 0.0003 },
{ "ask_price": 91251000, "bid_price": 91154000, "ask_size": 0.0626, "bid_size": 0.0110 },
{ "ask_price": 91255000, "bid_price": 91150000, "ask_size": 0.1513, "bid_size": 0.0104 },
{ "ask_price": 91256000, "bid_price": 91149000, "ask_size": 0.0313, "bid_size": 0.0127 },
{ "ask_price": 91258000, "bid_price": 91148000, "ask_size": 2.1980, "bid_size": 0.0111 },
{ "ask_price": 91259000, "bid_price": 91147000, "ask_size": 0.1370, "bid_size": 0.0006 },
{ "ask_price": 91262000, "bid_price": 91146000, "ask_size": 0.0754, "bid_size": 0.0000 }
],
"level": 1,
"timestamp": 1785397747576054,
"stream_type": "SNAPSHOT"
}"#;
const WS_TICKER: &str = r#"{
"type": "ticker",
"code": "KRW-BTC",
"opening_price": 92112000,
"high_price": 92770000,
"low_price": 90904000,
"trade_price": 91196000,
"prev_closing_price": 92144000,
"change": "FALL",
"change_price": 948000,
"signed_change_price": -948000,
"change_rate": 0.01028824,
"signed_change_rate": -0.01028824,
"trade_volume": 0.00021931,
"acc_trade_volume": 216.56322055,
"acc_trade_volume_24h": 294.09163804,
"acc_trade_price": 19834795209.4663,
"acc_trade_price_24h": 27002534842.65972,
"trade_date": "20260730",
"trade_time": "164855",
"trade_timestamp": 1785397735004,
"ask_bid": "ASK",
"acc_ask_volume": 115.83894934,
"acc_bid_volume": 100.72427121,
"highest_52_week_price": 179734000,
"highest_52_week_date": "2025-10-09",
"lowest_52_week_price": 81110000,
"lowest_52_week_date": "2026-02-06",
"market_state": "ACTIVE",
"is_trading_suspended": false,
"market_warning": "NONE",
"timestamp": 1785397735276,
"stream_type": "SNAPSHOT"
}"#;
const WS_MY_ASSET: &str = r#"{
"type": "myAsset",
"assets": [
{
"currency": "KRW",
"balance": "2061832.35",
"locked": "3824127.3"
}
],
"asset_timestamp": 1727052537592,
"timestamp": 1727052537687
}"#;
const WS_MY_ORDER: &str = r#"{
"type": "myOrder",
"code": "KRW-BTC",
"order_id": "C0101000000001818113",
"client_order_id": "my-client-order-id-1",
"side": "buy",
"order_type": "limit",
"state": "trade",
"time_in_force": "post_only",
"order_price": 1927000,
"order_quantity": 0.55,
"order_amount": 1059850,
"order_timestamp": 1727052318074,
"timestamp": 1727052318369,
"trade_id": "C0101000000001744207",
"trade_price": 1927000,
"trade_quantity": 0.4697,
"trade_amount": 905111.9,
"trade_timestamp": 1727052318148,
"executed_quantity": 0.4697,
"remaining_quantity": 0.0803,
"executed_amount": 905111.9,
"paid_fee": 0,
"remaining_fee": 0,
"reserved_fee": 0
}"#;
fn btc_krw() -> Market {
Market::spot(Exchange::Bithumb, "BTC", "KRW")
}
fn etc_krw() -> Market {
Market::spot(Exchange::Bithumb, "ETC", "KRW")
}
fn acs_krw() -> Market {
Market::spot(Exchange::Bithumb, "ACS", "KRW")
}
fn zil_krw() -> Market {
Market::spot(Exchange::Bithumb, "ZIL", "KRW")
}
fn obsr_krw() -> Market {
Market::spot(Exchange::Bithumb, "OBSR", "KRW")
}
fn parsed(raw: &str) -> Value {
body(raw).expect("fixture is JSON")
}
fn exact(raw: &str) -> Decimal {
Decimal::from_str_exact(raw).expect("test literal is a decimal")
}
#[test]
fn a_market_survives_a_round_trip_through_bithumbs_own_code() {
let code = native_symbol(&btc_krw()).expect("BTC/KRW is a Bithumb market");
assert_eq!(code, "KRW-BTC");
assert_eq!(split_symbol(&code).expect("round trip"), btc_krw());
}
#[test]
fn the_two_directions_agree_on_every_shape_bithumb_lists() {
for symbol in ["KRW-BTC", "BTC-ETH", "USDT-XRP", "KRW-1INCH"] {
let market = split_symbol(symbol).expect("a listed code");
assert_eq!(native_symbol(&market).expect("and back"), symbol);
}
}
#[test]
fn the_quote_asset_comes_first_in_a_bithumb_code() {
let market = split_symbol("KRW-BTC").expect("a listed code");
assert_eq!(market.base, "BTC");
assert_eq!(market.quote, "KRW");
assert_eq!(
native_symbol(&Market::spot(Exchange::Bithumb, "ETH", "BTC")).expect("listed pair"),
"BTC-ETH"
);
}
#[test]
fn a_market_that_is_not_bithumbs_never_becomes_a_symbol() {
let upbit = Market::spot(Exchange::Upbit, "BTC", "KRW");
let perpetual = Market::perpetual(Exchange::Bithumb, "BTC", "KRW");
assert!(matches!(
native_symbol(&upbit),
Err(Error::InvalidRequest { field, .. }) if field == "market.exchange"
));
assert!(matches!(
native_symbol(&perpetual),
Err(Error::InvalidRequest { field, .. }) if field == "market.kind"
));
}
#[test]
fn a_malformed_symbol_is_rejected_rather_than_guessed_at() {
for symbol in ["KRWBTC", "krw-btc", "KRW-", "-BTC", "KRW-BTC-PERP", ""] {
assert!(split_symbol(symbol).is_none(), "{symbol}");
}
}
#[test]
fn a_market_code_that_could_smuggle_a_query_parameter_is_rejected() {
let injected = Market::spot(Exchange::Bithumb, "BTC&count=500", "KRW");
assert!(matches!(
native_symbol(&injected),
Err(Error::InvalidRequest { field, .. }) if field == "market.base"
));
assert!(split_symbol("KRW-BTC&count=500").is_none());
}
#[test]
fn money_keeps_every_digit_bithumb_sent() {
let candles = candles(
&parsed(WEEK_CANDLES),
Interval::Week1,
Timestamp::from_millis(1_782_658_793_854),
)
.expect("week candles parse");
assert_eq!(
candles[0].quote_volume.expect("quote volume"),
exact("313245537093.97363")
);
assert_eq!(candles[0].volume, exact("3378.18477138"));
assert_eq!(candles[0].volume.scale(), 8);
}
#[test]
fn a_decimal_string_and_a_decimal_number_read_the_same() {
let from_number = dec(&parsed(r#"{"v": 0.01010101}"#), "v").expect("number");
let from_string = dec(&parsed(r#"{"v": "0.01010101"}"#), "v").expect("string");
assert_eq!(from_number, from_string);
assert_eq!(from_number.scale(), 8);
}
#[test]
fn scientific_notation_is_read_rather_than_refused() {
let tiny = dec(&parsed(r#"{"v": 8.428e-05}"#), "v").expect("exponent form");
assert_eq!(tiny, exact("0.00008428"));
}
#[test]
fn a_number_too_precise_to_hold_is_a_decode_error_not_a_rounded_price() {
assert!(matches!(
decimal("0.000000000000000000000000000001", "price"),
Err(Error::Decode { .. })
));
}
#[test]
fn a_market_list_keeps_both_names_and_flags_a_warning_as_not_plainly_active() {
let markets = markets(&parsed(MARKET_LIST)).expect("market list parses");
assert_eq!(markets.len(), 3);
assert_eq!(markets[0].market, btc_krw());
assert_eq!(markets[0].native_symbol, "KRW-BTC");
assert_eq!(markets[0].korean_name.as_deref(), Some("비트코인"));
assert_eq!(markets[0].status, MarketStatus::Active);
assert_eq!(markets[1].status, MarketStatus::Unknown);
assert_eq!(
market_warnings(&parsed(MARKET_LIST)).expect("warnings parse")[1].1,
"CAUTION"
);
}
#[test]
fn an_alerted_market_stays_active_because_the_two_designations_are_separate() {
let markets = markets(&parsed(MARKET_LIST)).expect("market list parses");
let alerts = market_alerts(&parsed(MARKET_ALERTS)).expect("alerts parse");
assert_eq!(markets[2].market, acs_krw());
assert_eq!(markets[2].status, MarketStatus::Active);
assert!(
alerts
.iter()
.any(|(market, alert)| *market == acs_krw()
&& alert.step == BithumbAlertStep::Danger)
);
assert_eq!(markets[1].status, MarketStatus::Unknown);
}
#[test]
fn an_alert_carries_its_criterion_its_step_and_a_korean_expiry_read_as_utc() {
let alerts = market_alerts(&parsed(MARKET_ALERTS)).expect("alerts parse");
assert_eq!(alerts.len(), 5);
assert_eq!(alerts[0].0, zil_krw());
assert_eq!(alerts[0].1.kind, "TRADING_VOLUME_SUDDEN_FLUCTUATION");
assert_eq!(alerts[0].1.step, BithumbAlertStep::Danger);
assert_eq!(alerts[0].1.ends_at, Timestamp::from_secs(1_785_448_799));
assert_eq!(alerts[3].1.step, BithumbAlertStep::Caution);
assert_eq!(alerts[3].1.ends_at, Timestamp::from_secs(1_785_449_399));
}
#[test]
fn notices_keep_categories_urls_and_korean_wall_clock_timestamps() {
let notices = notices(&parsed(NOTICES)).expect("notices parse");
assert_eq!(notices.len(), 1);
assert_eq!(notices[0].categories, ["입출금", "점검"]);
assert_eq!(notices[0].title, "네트워크 점검 안내");
assert_eq!(notices[0].url, "https://feed.bithumb.com/notice/1654458");
assert_eq!(notices[0].published_at, Timestamp::from_secs(1_786_438_800));
assert_eq!(notices[0].modified_at, Timestamp::from_secs(1_786_433_076));
}
#[test]
fn api_keys_keep_the_identifier_and_offset_expiry() {
let keys = api_keys(&parsed(API_KEYS)).expect("API keys parse");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].access_key, "example-access-key-1");
assert_eq!(keys[0].expires_at, Timestamp::from_secs(1_812_672_000));
}
#[test]
fn transfer_fees_keep_fixed_and_rate_formulae_per_network() {
let fees = transfer_fees(&parsed(TRANSFER_FEES)).expect("fee catalog parses");
assert_eq!(fees.len(), 2);
assert_eq!(fees[0].display_name, "비트코인");
assert_eq!(fees[0].asset, "BTC");
assert_eq!(fees[0].networks[0].network, Network::Bitcoin);
assert_eq!(fees[0].networks[0].provider_name, "Bitcoin");
assert_eq!(fees[0].networks[0].deposit_fee, Decimal::ZERO);
assert_eq!(fees[0].networks[0].minimum_withdrawal, exact("0.001"));
assert_eq!(
fees[0].networks[0].withdrawal_fee,
WithdrawalFee::Fixed(exact("0.0002"))
);
assert_eq!(fees[1].networks[0].network, Network::Arbitrum);
assert_eq!(
fees[1].networks[0].withdrawal_fee,
WithdrawalFee::Rate {
rate: exact("0.01"),
minimum: Some(Decimal::ONE),
maximum: Some(Decimal::from(100)),
}
);
}
#[test]
fn a_market_under_two_criteria_keeps_both_rows_at_their_own_steps() {
let alerts = market_alerts(&parsed(MARKET_ALERTS)).expect("alerts parse");
let obsr: Vec<_> = alerts
.iter()
.filter(|(market, _)| *market == obsr_krw())
.map(|(_, alert)| alert)
.collect();
assert_eq!(obsr.len(), 2);
assert_eq!(obsr[0].step, BithumbAlertStep::Danger);
assert_eq!(obsr[1].step, BithumbAlertStep::Warning);
assert_ne!(obsr[0].kind, obsr[1].kind);
}
#[test]
fn steps_compare_by_severity_and_an_unfamiliar_one_outranks_the_gravest() {
assert!(BithumbAlertStep::Caution < BithumbAlertStep::Warning);
assert!(BithumbAlertStep::Warning < BithumbAlertStep::Danger);
assert!(BithumbAlertStep::Unknown > BithumbAlertStep::Danger);
let odd = market_alerts(&parsed(
r#"[{"market":"KRW-BTC","warning_type":"NEW_CRITERION","warning_step":"SEVERE","end_date":"2026-07-31 06:59:59"}]"#,
))
.expect("an unfamiliar step is not a decode failure");
assert_eq!(odd[0].1.step, BithumbAlertStep::Unknown);
assert_eq!(odd[0].1.kind, "NEW_CRITERION");
}
#[test]
fn an_expiry_bithumb_cannot_have_sent_is_a_decode_error_not_a_guess() {
assert!(matches!(
market_alerts(&parsed(
r#"[{"market":"KRW-BTC","warning_type":"PRICE_SUDDEN_FLUCTUATION","warning_step":"CAUTION","end_date":"2026-07-31T06:59:59Z"}]"#,
)),
Err(Error::Decode { .. })
));
}
#[test]
fn a_rest_trade_reports_the_taker_side_and_bithumbs_own_identifier() {
let trades = trades(&parsed(TRADES)).expect("trades parse");
assert_eq!(trades[0].market, etc_krw());
assert_eq!(trades[0].taker_side, Side::Buy);
assert_eq!(
trades[0].timestamp,
Timestamp::from_millis(1_785_397_661_964)
);
assert_eq!(trades[0].price, Decimal::from(9_620));
assert_eq!(trades[0].id.as_deref(), Some("17853976619640000"));
}
#[test]
fn recent_trades_come_back_newest_first() {
let trades = trades(&parsed(TRADES)).expect("trades parse");
assert_eq!(
trades
.iter()
.map(|trade| trade.timestamp.as_millis())
.collect::<Vec<_>>(),
vec![1_785_397_661_964, 1_785_397_554_763, 1_785_397_204_627]
);
}
#[test]
fn trades_are_reordered_rather_than_trusted_to_arrive_sorted() {
let mut shuffled = parsed(TRADES);
shuffled
.as_array_mut()
.expect("the fixture is an array")
.rotate_left(1);
let trades = trades(&shuffled).expect("trades parse");
assert!(
trades
.windows(2)
.all(|pair| pair[0].timestamp > pair[1].timestamp),
"trades came back {:?}",
trades
.iter()
.map(|trade| trade.timestamp.as_millis())
.collect::<Vec<_>>()
);
}
#[test]
fn a_stream_trade_is_stamped_with_the_match_time_not_the_publish_time() {
let event = market_event(&parsed(WS_TRADE)).expect("frame parses");
let Some(MarketEvent::Trade(trade)) = event else {
panic!("expected a trade event");
};
assert_eq!(trade.taker_side, Side::Sell);
assert_eq!(trade.timestamp, Timestamp::from_millis(1_785_397_735_004));
assert_eq!(trade.id.as_deref(), Some("921046844841158138"));
}
#[test]
fn both_sides_of_a_book_come_back_best_first() {
let entry = &parsed(ORDER_BOOK)[0];
let book = order_book(
entry,
btc_krw(),
Timestamp::from_millis(1_785_397_720_965),
None,
)
.expect("book parses");
assert_eq!(book.bids.len(), 29);
assert_eq!(book.asks.len(), 29);
assert_eq!(book.bids[0].price, Decimal::from(91_196_000));
assert_eq!(book.bids[1].price, Decimal::from(91_172_000));
assert_eq!(book.asks[0].price, Decimal::from(91_226_000));
assert_eq!(book.asks[1].price, Decimal::from(91_233_000));
assert_eq!(book.spread().expect("two sides"), Decimal::from(30_000));
}
#[test]
fn a_book_is_reordered_rather_than_trusted_to_arrive_sorted() {
let mut entry = parsed(ORDER_BOOK)[0].clone();
entry["orderbook_units"]
.as_array_mut()
.expect("the fixture has units")
.reverse();
let book = order_book(
&entry,
btc_krw(),
Timestamp::from_millis(1_785_397_720_965),
None,
)
.expect("book parses");
assert!(
book.bids
.windows(2)
.all(|pair| pair[0].price > pair[1].price)
);
assert!(
book.asks
.windows(2)
.all(|pair| pair[0].price < pair[1].price)
);
assert_eq!(book.bids[0].price, Decimal::from(91_196_000));
assert_eq!(book.asks[0].price, Decimal::from(91_226_000));
}
#[test]
fn depth_takes_the_best_levels_not_the_first_ones_bithumb_listed() {
let mut entry = parsed(ORDER_BOOK)[0].clone();
entry["orderbook_units"]
.as_array_mut()
.expect("the fixture has units")
.reverse();
let book = order_book(
&entry,
btc_krw(),
Timestamp::from_millis(1_785_397_720_965),
Some(1),
)
.expect("book parses");
assert_eq!(book.bids.len(), 1);
assert_eq!(book.asks.len(), 1);
assert_eq!(book.bids[0].price, Decimal::from(91_196_000));
assert_eq!(book.asks[0].price, Decimal::from(91_226_000));
}
#[test]
fn zero_quantity_levels_are_removed_before_depth_is_applied() {
let entry = parsed(
r#"{
"orderbook_units": [
{ "ask_price": 100, "ask_size": 0, "bid_price": 99, "bid_size": 0 },
{ "ask_price": 101, "ask_size": 2, "bid_price": 98, "bid_size": 3 },
{ "ask_price": 102, "ask_size": 4, "bid_price": 97, "bid_size": 5 }
]
}"#,
);
let book = order_book(
&entry,
btc_krw(),
Timestamp::from_millis(1_785_397_720_965),
Some(1),
)
.expect("book parses");
assert_eq!(book.bids.len(), 1);
assert_eq!(book.asks.len(), 1);
assert_eq!(book.bids[0].price, Decimal::from(98));
assert_eq!(book.bids[0].quantity, Decimal::from(3));
assert_eq!(book.asks[0].price, Decimal::from(101));
assert_eq!(book.asks[0].quantity, Decimal::from(2));
}
#[test]
fn a_stream_book_is_sorted_and_timestamped_in_microseconds() {
let event = market_event(&parsed(WS_ORDER_BOOK)).expect("frame parses");
let Some(MarketEvent::OrderBook(book)) = event else {
panic!("expected an order book event");
};
assert_eq!(
book.timestamp,
Timestamp::from_micros(1_785_397_747_576_054)
);
assert_eq!(book.timestamp.as_secs(), 1_785_397_747);
assert_eq!(book.bids.len(), 12);
assert_eq!(book.asks.len(), 14);
assert_eq!(
book.best_bid().expect("a bid").price,
Decimal::from(91_171_000)
);
}
#[test]
fn a_millisecond_clock_offered_as_microseconds_is_refused() {
let mut frame = parsed(WS_ORDER_BOOK);
frame["timestamp"] = serde_json::json!(1_785_397_747_576i64);
let err = market_event(&frame).expect_err("a millisecond book clock is refused");
assert!(
err.to_string().contains("not a microsecond timestamp"),
"got {err}"
);
}
#[test]
fn a_rest_ticker_is_pulled_back_off_the_korean_wall_clock() {
let rest = ticker(&parsed(TICKER)[0], btc_krw()).expect("ticker parses");
assert_eq!(
rest.timestamp,
Timestamp::from_millis(1_785_397_710_156),
"the ticker clock was not pulled back nine hours"
);
assert_eq!(rest.timestamp.to_string(), "2026-07-30T07:48:30.156Z");
assert_eq!(rest.last_trade_time, Some(rest.timestamp));
assert_eq!(rest.last_price, Decimal::from(91_171_000));
assert_eq!(rest.change, Some(Decimal::from(-973_000)));
assert_eq!(rest.change_rate, Some(exact("-0.0106")));
assert_eq!(rest.volume, Some(exact("294.0810771")));
}
#[test]
fn a_ticker_whose_clocks_already_agree_is_left_alone() {
let mut entry = parsed(TICKER)[0].clone();
entry["trade_timestamp"] = serde_json::json!(1_785_397_710_156i64);
entry["timestamp"] = serde_json::json!(1_785_397_710_156i64);
let rest = ticker(&entry, btc_krw()).expect("ticker parses");
assert_eq!(rest.timestamp, Timestamp::from_millis(1_785_397_710_156));
}
#[test]
fn a_ticker_clock_that_is_neither_utc_nor_korean_is_refused() {
let mut entry = parsed(TICKER)[0].clone();
entry["trade_timestamp"] = serde_json::json!(1_785_401_310_156i64);
let err = ticker(&entry, btc_krw()).expect_err("an unknown clock is refused");
assert!(err.to_string().contains("3600s from the"), "got {err}");
}
#[test]
fn a_stream_ticker_needs_no_clock_correction() {
let stream = market_event(&parsed(WS_TICKER)).expect("frame parses");
let Some(MarketEvent::Ticker(stream)) = stream else {
panic!("expected a ticker event");
};
assert_eq!(stream.timestamp, Timestamp::from_millis(1_785_397_735_276));
assert_eq!(
stream.last_trade_time,
Some(Timestamp::from_millis(1_785_397_735_004))
);
assert!(stream.last_trade_time < Some(stream.timestamp));
assert_eq!(stream.last_price, Decimal::from(91_196_000));
}
#[test]
fn a_candle_opens_at_its_utc_wall_clock_not_at_its_timestamp_field() {
let after_the_minute = Timestamp::from_secs(1_785_397_800);
let candles = candles(&parsed(MINUTE_CANDLES), Interval::Min1, after_the_minute)
.expect("candles parse");
assert_eq!(candles[0].open_time, Timestamp::from_secs(1_785_397_680));
assert_eq!(candles[0].open, Decimal::from(91_226_000));
assert_eq!(candles[0].close, Decimal::from(91_171_000));
assert!(candles[0].closed);
}
#[test]
fn the_running_candle_is_not_reported_as_closed() {
let inside_the_minute = Timestamp::from_secs(1_785_397_710);
let candles = candles(&parsed(MINUTE_CANDLES), Interval::Min1, inside_the_minute)
.expect("candles parse");
assert!(!candles[0].closed);
}
#[test]
fn the_month_in_progress_is_running_like_any_other_interval() {
let mid_march = Timestamp::from_secs(1_773_500_000); let april = Timestamp::from_secs(1_774_969_200);
let running =
candles(&parsed(MONTH_CANDLES), Interval::Month1, mid_march).expect("candles parse");
let settled =
candles(&parsed(MONTH_CANDLES), Interval::Month1, april).expect("candles parse");
assert!(!running[0].closed);
assert!(settled[0].closed);
}
#[test]
fn a_monthly_candle_closes_on_the_korean_month_boundary_it_was_cut_on() {
let utc_month_step = Timestamp::from_secs(1_774_710_000); let korean_month_step = Timestamp::from_secs(1_774_969_200);
let mid_window = candles(&parsed(MONTH_CANDLES), Interval::Month1, utc_month_step)
.expect("candles parse");
let at_the_boundary = candles(&parsed(MONTH_CANDLES), Interval::Month1, korean_month_step)
.expect("candles parse");
assert!(
!mid_window[0].closed,
"the March candle still has three days to run on 28 March"
);
assert!(at_the_boundary[0].closed);
assert!(mid_window[1].closed);
}
#[test]
fn an_error_body_keeps_bithumbs_own_code_and_message() {
let error = exchange_error(
401,
r#"{"error":{"name":"invalid_access_key","message":"잘못된 액세스 키"}}"#,
);
let Error::Exchange {
exchange,
code,
message,
status,
..
} = &error
else {
panic!("expected an exchange error");
};
assert_eq!(*exchange, "bithumb");
assert_eq!(code, "invalid_access_key");
assert_eq!(message, "잘못된 액세스 키");
assert_eq!(*status, Some(401));
assert!(!error.is_retryable());
}
#[test]
fn a_numeric_error_name_is_kept_as_the_exchange_code() {
let error = exchange_error(200, r#"{"error":{"name":404,"message":"Code not found"}}"#);
let Error::Exchange { code, message, .. } = &error else {
panic!("expected an exchange error");
};
assert_eq!(code, "404");
assert_eq!(message, "Code not found");
}
#[test]
fn travel_rule_consent_error_preserves_the_provider_url_and_exchange() {
let error = exchange_error(
422,
r#"{"error":{"name":"travel_rule_consent_required","message":"동의 필요","consent_url":"https://example.bithumb.com/consent/abc","exchange_name":"Upbit"}}"#,
);
let Error::Exchange { code, message, .. } = error else {
panic!("expected an exchange error");
};
assert_eq!(code, "travel_rule_consent_required");
assert!(message.contains("exchange_name=Upbit"));
assert!(message.contains("consent_url=https://example.bithumb.com/consent/abc"));
}
#[test]
fn an_unreadable_error_body_is_still_reported_verbatim() {
let error = exchange_error(502, " <html>bad gateway</html> ");
let Error::Exchange { code, message, .. } = &error else {
panic!("expected an exchange error");
};
assert_eq!(code, "bithumb_error");
assert_eq!(message, "<html>bad gateway</html>");
assert!(error.is_retryable());
}
#[test]
fn a_websocket_error_frame_becomes_an_exchange_error() {
let frame = parsed(r#"{"error":{"name":"WRONG_FORMAT","message":"Format is wrong"}}"#);
let error = market_event(&frame).expect_err("an error frame is not data");
assert!(matches!(error, Error::Exchange { ref code, .. } if code == "WRONG_FORMAT"));
}
#[test]
fn frames_that_are_not_market_data_are_skipped_rather_than_failed() {
for frame in [r#"{"status":"UP"}"#, r#"{"type":"unknown"}"#] {
assert!(
market_event(&parsed(frame))
.expect("a control frame is not an error")
.is_none(),
"{frame}"
);
}
}
#[test]
fn a_balance_frame_yields_one_event_per_asset_with_exact_amounts() {
let events = account_events(&parsed(WS_MY_ASSET)).expect("frame parses");
assert_eq!(events.len(), 1);
let AccountEvent::Balance(balance) = &events[0] else {
panic!("expected a balance event");
};
assert_eq!(balance.asset, "KRW");
assert_eq!(balance.available, exact("2061832.35"));
assert_eq!(balance.total(), exact("5885959.65"));
}
#[test]
fn a_partly_filled_order_frame_is_not_reported_as_finished() {
let events = account_events(&parsed(WS_MY_ORDER)).expect("frame parses");
let [AccountEvent::Order(order)] = events.as_slice() else {
panic!("expected one order event");
};
assert_eq!(order.id, "C0101000000001818113");
assert_eq!(order.side, Side::Buy);
assert_eq!(order.status, OrderStatus::PartiallyFilled);
assert!(order.status.is_live());
assert_eq!(order.filled_quantity, exact("0.4697"));
assert_eq!(order.remaining_quantity, exact("0.0803"));
}
#[test]
fn an_order_that_left_the_book_unfilled_is_cancelled_not_filled() {
let mut frame = parsed(WS_MY_ORDER);
frame["state"] = Value::String("done".to_string());
let events = account_events(&frame).expect("frame parses");
let [AccountEvent::Order(order)] = events.as_slice() else {
panic!("expected one order event");
};
assert_eq!(order.status, OrderStatus::Cancelled);
}
#[test]
fn a_finished_order_without_its_quantities_is_refused_rather_than_guessed() {
let mut frame = parsed(WS_MY_ORDER);
frame["state"] = Value::String("done".to_string());
frame["executed_quantity"] = Value::Null;
frame["remaining_quantity"] = Value::Null;
assert!(matches!(account_events(&frame), Err(Error::Decode { .. })));
}
#[test]
fn a_resting_order_frame_may_omit_its_quantities() {
let mut frame = parsed(WS_MY_ORDER);
frame["state"] = Value::String("wait".to_string());
frame["executed_quantity"] = Value::Null;
frame["remaining_quantity"] = Value::Null;
let events = account_events(&frame).expect("frame parses");
let [AccountEvent::Order(order)] = events.as_slice() else {
panic!("expected one order event");
};
assert_eq!(order.status, OrderStatus::Open);
assert!(order.filled_quantity.is_zero());
assert_eq!(order.remaining_quantity, exact("0.55"));
}
#[test]
fn private_rest_orders_read_bid_and_ask_as_buy_and_sell() {
let raw = r#"[{
"market": "KRW-BTC",
"uuid": "C0661000000000760010",
"side": "ask",
"ord_type": "limit",
"state": "wait",
"price": "1055",
"volume": "16",
"remaining_volume": "11",
"executed_volume": "5",
"created_at": "2024-07-14T13:35:41+09:00"
}]"#;
let orders = orders(&parsed(raw)).expect("orders parse");
assert_eq!(orders[0].side, Side::Sell);
assert_eq!(orders[0].status, OrderStatus::PartiallyFilled);
assert_eq!(orders[0].filled_quantity, Decimal::from(5));
assert_eq!(orders[0].price, Some(Decimal::from(1055)));
assert_eq!(
orders[0].created_at,
Some(Timestamp::from_secs(1_720_931_741))
);
}
#[test]
fn a_balance_response_uppercases_the_asset_bithumb_lowercased() {
let balances = balances(&parsed(
r#"[{"currency":"btc","balance":"1.25","locked":"0.5"}]"#,
))
.expect("balances parse");
assert_eq!(balances[0].asset, "BTC");
assert_eq!(balances[0].total(), exact("1.75"));
}
}