use crate::error::{Result, ScopeError};
use crate::http::{HttpClient, Request};
use crate::market::descriptor::{EndpointDescriptor, HttpMethod, ResponseMapping, VenueDescriptor};
use crate::market::orderbook::{
Candle, OhlcClient, OrderBook, OrderBookClient, OrderBookLevel, Ticker, TickerClient, Trade,
TradeHistoryClient, TradeSide,
};
use crate::market::pair::Pair;
use async_trait::async_trait;
use serde_json::Value;
use std::sync::Arc;
use std::time::Duration;
fn format_http_api_error(venue: &str, status: u16, body: &str) -> ScopeError {
let status = reqwest::StatusCode::from_u16(status)
.map(|s| s.to_string())
.unwrap_or_else(|_| status.to_string());
let body = body.trim();
let binance_invalid_symbol = venue.to_lowercase().contains("binance")
&& (body.contains("\"code\":-1121")
|| body.contains("\"code\": -1121")
|| body.to_lowercase().contains("invalid symbol"));
let message = if body.is_empty() {
format!("API error: HTTP {status}")
} else if binance_invalid_symbol {
let preview: String = body.chars().take(200).collect();
format!(
"API error: HTTP {status} — response: {preview}\n\
Hint: Binance returned 'Invalid symbol'. Verify pair ordering and venue format \
(e.g. BASE/QUOTE -> BASEQUOTE), or try another venue if the market is unavailable."
)
} else {
let preview: String = body.chars().take(200).collect();
format!("API error: HTTP {status} — response: {preview}")
};
venue_error(venue, message)
}
pub(crate) fn venue_error(venue: &str, message: impl Into<String>) -> ScopeError {
ScopeError::Venue {
venue: venue.to_string(),
message: message.into(),
}
}
#[derive(Clone)]
pub struct ConfigurableExchangeClient {
descriptor: VenueDescriptor,
http: Arc<dyn HttpClient>,
}
impl std::fmt::Debug for ConfigurableExchangeClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigurableExchangeClient")
.field("descriptor", &self.descriptor)
.finish_non_exhaustive()
}
}
impl ConfigurableExchangeClient {
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `ConfigurableExchangeClient::with_http`"
)]
pub fn new(descriptor: VenueDescriptor) -> Self {
Self::with_http(descriptor, crate::http::shared::fallback())
}
pub fn with_http(descriptor: VenueDescriptor, http: Arc<dyn HttpClient>) -> Self {
Self { descriptor, http }
}
fn timeout(&self) -> Duration {
Duration::from_secs(self.descriptor.timeout_secs.unwrap_or(15))
}
pub fn descriptor(&self) -> &VenueDescriptor {
&self.descriptor
}
pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
self.descriptor.format_pair(base, quote)
}
pub async fn fetch_assets(&self) -> Result<Vec<super::assets::AssetInfo>> {
use super::assets::{AssetInfo, AssetNetwork, contract_of};
let endpoint = self
.descriptor
.capabilities
.assets
.as_ref()
.ok_or_else(|| venue_error(&self.descriptor.id, "does not list assets"))?;
let json = self.fetch_endpoint(endpoint, "", None).await?;
let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
let r = &endpoint.response;
let items = match r.items_key.as_deref() {
Some(k) if !k.is_empty() => data.get(k).unwrap_or(data),
_ => data,
}
.as_array()
.ok_or_else(|| venue_error(&self.descriptor.id, "expected array for assets"))?;
let field = |f: &Option<String>, default: &str| f.clone().unwrap_or_else(|| default.into());
let (asset, networks_key) = (
field(&r.asset, "coin"),
field(&r.networks_key, "networkList"),
);
let (network, contract) = (
field(&r.network, "network"),
field(&r.contract, "contractAddress"),
);
Ok(items
.iter()
.filter_map(|item| {
let symbol = self.extract_string(item, &asset)?;
let networks = item
.get(&networks_key)
.and_then(Value::as_array)
.map(|ns| {
ns.iter()
.filter_map(|n| {
Some(AssetNetwork {
network: self.extract_string(n, &network)?,
contract: self
.extract_string(n, &contract)
.and_then(|c| contract_of(&c)),
})
})
.collect()
})
.unwrap_or_default();
Some(AssetInfo { symbol, networks })
})
.collect())
}
async fn fetch_endpoint(
&self,
endpoint: &EndpointDescriptor,
pair: &str,
limit: Option<u32>,
) -> Result<Value> {
self.fetch_endpoint_with_interval(endpoint, pair, limit, "")
.await
}
fn interpolate_value_full(
&self,
template: &str,
pair: &str,
limit: &str,
interval: &str,
) -> String {
template
.replace("{pair}", pair)
.replace("{limit}", limit)
.replace("{interval}", interval)
}
#[cfg(test)]
fn interpolate_json(&self, value: &Value, pair: &str, limit: &str) -> Value {
self.interpolate_json_full(value, pair, limit, "")
}
fn interpolate_json_full(
&self,
value: &Value,
pair: &str,
limit: &str,
interval: &str,
) -> Value {
match value {
Value::String(s) => {
Value::String(self.interpolate_value_full(s, pair, limit, interval))
}
Value::Object(map) => {
let mut new_map = serde_json::Map::new();
for (k, v) in map {
new_map.insert(
k.clone(),
self.interpolate_json_full(v, pair, limit, interval),
);
}
Value::Object(new_map)
}
Value::Array(arr) => Value::Array(
arr.iter()
.map(|v| self.interpolate_json_full(v, pair, limit, interval))
.collect(),
),
other => other.clone(),
}
}
async fn fetch_endpoint_with_interval(
&self,
endpoint: &EndpointDescriptor,
pair: &str,
limit: Option<u32>,
interval: &str,
) -> Result<Value> {
let limit = limit.map(|l| endpoint.max_limit.map_or(l, |max| l.min(max)));
let limit_str = limit.unwrap_or(100).to_string();
let url = format!(
"{}{}",
self.descriptor.base_url,
self.interpolate_value_full(&endpoint.path, pair, &limit_str, interval)
);
let mut req = match endpoint.method {
HttpMethod::GET => {
let mut full_url = reqwest::Url::parse(&url).map_err(|e| {
venue_error(&self.descriptor.id, format!("invalid URL {url}: {e}"))
})?;
let params: Vec<(String, String)> = endpoint
.params
.iter()
.map(|(k, v)| {
(
k.clone(),
self.interpolate_value_full(v, pair, &limit_str, interval),
)
})
.collect();
if !params.is_empty() {
full_url.query_pairs_mut().extend_pairs(params.iter());
}
Request::get(full_url.as_str())
}
HttpMethod::POST => match &endpoint.request_body {
Some(template) => {
let body = self.interpolate_json_full(template, pair, &limit_str, interval);
Request::post_json(&url, body.to_string())
}
None => Request {
method: "POST".into(),
body: None,
..Request::get(&url)
},
},
}
.with_timeout(self.timeout());
for (k, v) in &self.descriptor.headers {
req = req.with_header(k, v);
}
let resp = self.http.send(req).await?;
if !resp.is_success() {
return Err(format_http_api_error(
&self.descriptor.id,
resp.status_code,
&resp.body,
));
}
let reply = serde_json::from_str::<Value>(&resp.body)
.map_err(|e| venue_error(&self.descriptor.id, format!("JSON parse error: {e}")))?;
if let Some(message) = self
.descriptor
.error_envelope
.as_ref()
.and_then(|env| env.error_message(&reply))
{
return Err(venue_error(&self.descriptor.id, message));
}
Ok(reply)
}
fn navigate_root<'a>(&self, root: &'a Value, path: Option<&str>) -> Result<&'a Value> {
let path = match path {
Some(p) if !p.is_empty() => p,
_ => return Ok(root),
};
let mut current = root;
for segment in path.split('.') {
if segment == "*" {
current = match current {
Value::Object(map) => map.values().next().ok_or_else(|| {
venue_error(&self.descriptor.id, "empty object at wildcard '*'")
})?,
_ => {
return Err(venue_error(
&self.descriptor.id,
format!(
"expected object at wildcard '*', got {:?}",
current_type(current)
),
));
}
};
} else if let Ok(idx) = segment.parse::<usize>() {
current = current.get(idx).ok_or_else(|| {
venue_error(&self.descriptor.id, format!("index {} out of bounds", idx))
})?;
} else {
current = current.get(segment).ok_or_else(|| {
venue_error(
&self.descriptor.id,
format!("missing key '{}' in response", segment),
)
})?;
}
}
Ok(current)
}
fn extract_f64(&self, data: &Value, field_path: &str) -> Option<f64> {
let val = self.navigate_field(data, field_path)?;
value_to_f64(val)
}
fn navigate_field<'a>(&self, data: &'a Value, path: &str) -> Option<&'a Value> {
let mut current = data;
for segment in path.split('.') {
if let Ok(idx) = segment.parse::<usize>() {
current = current.get(idx)?;
} else {
current = current.get(segment)?;
}
}
Some(current)
}
fn extract_ms(&self, data: &Value, field_path: &str) -> Option<u64> {
value_to_ms(self.navigate_field(data, field_path)?)
}
fn extract_string(&self, data: &Value, field_path: &str) -> Option<String> {
let val = self.navigate_field(data, field_path)?;
match val {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn parse_levels(&self, arr: &Value, mapping: &ResponseMapping) -> Result<Vec<OrderBookLevel>> {
let items = arr
.as_array()
.ok_or_else(|| venue_error(&self.descriptor.id, "expected array for levels"))?;
let level_format = mapping.level_format.as_deref().unwrap_or("positional");
let mut levels = Vec::with_capacity(items.len());
for item in items {
let (price, quantity) = match level_format {
"object" => {
let price_field = mapping.level_price_field.as_deref().unwrap_or("price");
let size_field = mapping.level_size_field.as_deref().unwrap_or("size");
let p = self
.navigate_field(item, price_field)
.and_then(value_to_f64);
let q = self.navigate_field(item, size_field).and_then(value_to_f64);
(p, q)
}
_ => {
let p = item.get(0).and_then(value_to_f64);
let q = item.get(1).and_then(value_to_f64);
(p, q)
}
};
if let (Some(price), Some(quantity)) = (price, quantity) {
let level = OrderBookLevel { price, quantity };
if level.is_valid() {
levels.push(level);
}
}
}
Ok(levels)
}
fn parse_side(&self, data: &Value, mapping: &ResponseMapping) -> TradeSide {
if let Some(side_mapping) = &mapping.side
&& let Some(val) = self.navigate_field(data, &side_mapping.field)
{
let val_str = match val {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => return TradeSide::Buy,
};
if let Some(canonical) = side_mapping.mapping.get(&val_str) {
return match canonical.as_str() {
"sell" => TradeSide::Sell,
_ => TradeSide::Buy,
};
}
}
TradeSide::Buy
}
}
#[async_trait]
impl OrderBookClient for ConfigurableExchangeClient {
async fn fetch_order_book(&self, pair_symbol: &str) -> Result<OrderBook> {
let endpoint = self
.descriptor
.capabilities
.order_book
.as_ref()
.ok_or_else(|| venue_error(&self.descriptor.id, "does not support order book"))?;
let json = self.fetch_endpoint(endpoint, pair_symbol, None).await?;
let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
let asks_key = endpoint.response.asks_key.as_deref().unwrap_or("asks");
let bids_key = endpoint.response.bids_key.as_deref().unwrap_or("bids");
let asks_arr = data.get(asks_key).ok_or_else(|| {
venue_error(
&self.descriptor.id,
format!("missing '{}' in order book response", asks_key),
)
})?;
let bids_arr = data.get(bids_key).ok_or_else(|| {
venue_error(
&self.descriptor.id,
format!("missing '{}' in order book response", bids_key),
)
})?;
let mut asks = self.parse_levels(asks_arr, &endpoint.response)?;
let mut bids = self.parse_levels(bids_arr, &endpoint.response)?;
asks.sort_by(|a, b| {
a.price
.partial_cmp(&b.price)
.unwrap_or(std::cmp::Ordering::Equal)
});
bids.sort_by(|a, b| {
b.price
.partial_cmp(&a.price)
.unwrap_or(std::cmp::Ordering::Equal)
});
let pair = Pair::parse(pair_symbol).to_string();
Ok(OrderBook {
pair,
source: super::types::BookSource::Exchange,
bids,
asks,
})
}
}
#[async_trait]
impl TickerClient for ConfigurableExchangeClient {
async fn fetch_ticker(&self, pair_symbol: &str) -> Result<Ticker> {
let endpoint = self
.descriptor
.capabilities
.ticker
.as_ref()
.ok_or_else(|| venue_error(&self.descriptor.id, "does not support ticker"))?;
let json = self.fetch_endpoint(endpoint, pair_symbol, None).await?;
let data = if let Some(filter) = &endpoint.response.filter {
let root = self.navigate_root(&json, endpoint.response_root.as_deref())?;
let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
let items = if items_key.is_empty() {
root
} else {
root.get(items_key).unwrap_or(root)
};
let arr = items.as_array().ok_or_else(|| {
venue_error(&self.descriptor.id, "expected array for ticker filter")
})?;
let filter_value = filter.value.replace("{pair}", pair_symbol);
arr.iter()
.find(|item| {
item.get(&filter.field)
.and_then(|v| v.as_str())
.is_some_and(|s| s == filter_value)
})
.ok_or_else(|| {
venue_error(
&self.descriptor.id,
format!("no ticker found for pair {}", pair_symbol),
)
})?
.clone()
} else {
self.navigate_root(&json, endpoint.response_root.as_deref())?
.clone()
};
let r = &endpoint.response;
let pair = Pair::parse(pair_symbol).to_string();
Ok(Ticker {
pair,
last_price: r
.last_price
.as_ref()
.and_then(|f| self.extract_f64(&data, f)),
high_24h: r.high_24h.as_ref().and_then(|f| self.extract_f64(&data, f)),
low_24h: r.low_24h.as_ref().and_then(|f| self.extract_f64(&data, f)),
volume_24h: r
.volume_24h
.as_ref()
.and_then(|f| self.extract_f64(&data, f)),
quote_volume_24h: r
.quote_volume_24h
.as_ref()
.and_then(|f| self.extract_f64(&data, f)),
price_change_pct_24h: r
.price_change_pct_24h
.as_ref()
.and_then(|f| self.extract_f64(&data, f)),
best_bid: r.best_bid.as_ref().and_then(|f| self.extract_f64(&data, f)),
best_ask: r.best_ask.as_ref().and_then(|f| self.extract_f64(&data, f)),
})
}
}
#[async_trait]
impl TradeHistoryClient for ConfigurableExchangeClient {
async fn fetch_recent_trades(&self, pair_symbol: &str, limit: u32) -> Result<Vec<Trade>> {
let endpoint = self
.descriptor
.capabilities
.trades
.as_ref()
.ok_or_else(|| venue_error(&self.descriptor.id, "does not support trades"))?;
let json = self
.fetch_endpoint(endpoint, pair_symbol, Some(limit))
.await?;
let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
let arr = if items_key.is_empty() {
data
} else {
data.get(items_key).unwrap_or(data)
};
let items = arr
.as_array()
.ok_or_else(|| venue_error(&self.descriptor.id, "expected array for trades"))?;
let r = &endpoint.response;
let grouped: Vec<Value>;
let items = match r.nested_items_key.as_deref() {
Some(key) => {
grouped = items
.iter()
.filter_map(|group| group.get(key).and_then(Value::as_array))
.flatten()
.take(limit as usize)
.cloned()
.collect();
&grouped
}
None => items,
};
let mut trades = Vec::with_capacity(items.len());
for item in items {
let price = r.price.as_ref().and_then(|f| self.extract_f64(item, f));
let quantity = r.quantity.as_ref().and_then(|f| self.extract_f64(item, f));
if let (Some(price), Some(quantity)) = (price, quantity) {
let quote_quantity = r
.quote_quantity
.as_ref()
.and_then(|f| self.extract_f64(item, f));
let timestamp_ms = r
.timestamp_ms
.as_ref()
.and_then(|f| self.extract_ms(item, f))
.unwrap_or(0);
let id = r.id.as_ref().and_then(|f| self.extract_string(item, f));
let side = self.parse_side(item, r);
trades.push(Trade {
price,
quantity,
quote_quantity,
timestamp_ms,
side,
id,
});
}
}
if trades.len() > limit as usize {
trades.sort_by_key(|t| std::cmp::Reverse(t.timestamp_ms));
trades.truncate(limit as usize);
}
Ok(trades)
}
}
#[async_trait]
impl OhlcClient for ConfigurableExchangeClient {
async fn fetch_ohlc(
&self,
pair_symbol: &str,
interval: &str,
limit: u32,
) -> Result<Vec<Candle>> {
let endpoint = self
.descriptor
.capabilities
.ohlc
.as_ref()
.ok_or_else(|| venue_error(&self.descriptor.id, "does not support OHLC"))?;
let mapped_interval = endpoint
.interval_map
.get(interval)
.map(|s| s.as_str())
.unwrap_or(interval);
let json = self
.fetch_endpoint_with_interval(endpoint, pair_symbol, Some(limit), mapped_interval)
.await?;
let data = self.navigate_root(&json, endpoint.response_root.as_deref())?;
let items_key = endpoint.response.items_key.as_deref().unwrap_or("");
let arr = if items_key.is_empty() {
data
} else {
data.get(items_key).unwrap_or(data)
};
let items = arr
.as_array()
.ok_or_else(|| venue_error(&self.descriptor.id, "expected array for OHLC data"))?;
let r = &endpoint.response;
let format = r.ohlc_format.as_deref().unwrap_or("objects");
let mut candles = Vec::with_capacity(items.len());
if format == "array_of_arrays" {
let default_fields = vec![
"open_time".to_string(),
"open".to_string(),
"high".to_string(),
"low".to_string(),
"close".to_string(),
"volume".to_string(),
"close_time".to_string(),
];
let fields = r.ohlc_fields.as_ref().unwrap_or(&default_fields);
let idx = |name: &str| -> Option<usize> { fields.iter().position(|f| f == name) };
for item in items {
let arr = match item.as_array() {
Some(a) => a,
None => continue,
};
let get_f64 = |i: Option<usize>| -> Option<f64> {
i.and_then(|idx| arr.get(idx)).and_then(value_to_f64)
};
let get_ms = |i: Option<usize>| -> Option<u64> {
i.and_then(|idx| arr.get(idx)).and_then(value_to_ms)
};
if let (Some(open), Some(high), Some(low), Some(close)) = (
get_f64(idx("open")),
get_f64(idx("high")),
get_f64(idx("low")),
get_f64(idx("close")),
) {
candles.push(Candle {
open_time: get_ms(idx("open_time")).unwrap_or(0),
open,
high,
low,
close,
volume: get_f64(idx("volume")).unwrap_or(0.0),
close_time: get_ms(idx("close_time")).unwrap_or(0),
});
}
}
} else {
for item in items {
let open = r.open.as_ref().and_then(|f| self.extract_f64(item, f));
let high = r.high.as_ref().and_then(|f| self.extract_f64(item, f));
let low = r.low.as_ref().and_then(|f| self.extract_f64(item, f));
let close = r.close.as_ref().and_then(|f| self.extract_f64(item, f));
if let (Some(open), Some(high), Some(low), Some(close)) = (open, high, low, close) {
let open_time = r
.open_time
.as_ref()
.and_then(|f| self.extract_ms(item, f))
.unwrap_or(0);
let volume = r
.ohlc_volume
.as_ref()
.and_then(|f| self.extract_f64(item, f))
.unwrap_or(0.0);
let close_time = r
.close_time
.as_ref()
.and_then(|f| self.extract_ms(item, f))
.unwrap_or(0);
candles.push(Candle {
open_time,
open,
high,
low,
close,
volume,
close_time,
});
}
}
}
candles.sort_by_key(|c| c.open_time);
let keep = (limit as usize).max(1);
if candles.len() > keep {
candles.drain(..candles.len() - keep);
}
Ok(candles)
}
}
pub(crate) fn value_to_ms(val: &Value) -> Option<u64> {
if let Value::String(s) = val
&& s.parse::<f64>().is_err()
{
return chrono::DateTime::parse_from_rfc3339(s)
.ok()
.and_then(|d| u64::try_from(d.timestamp_millis()).ok());
}
let v = value_to_f64(val)?;
if v.is_nan() || v <= 0.0 {
return None;
}
let ms = if v < 1e11 {
v * 1e3
} else if v < 1e14 {
v
} else if v < 1e17 {
v / 1e3
} else {
v / 1e6
};
Some(ms.round() as u64)
}
fn value_to_f64(val: &Value) -> Option<f64> {
match val {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.parse::<f64>().ok(),
_ => None,
}
}
fn current_type(val: &Value) -> &'static str {
match val {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timestamps_become_milliseconds_whatever_the_unit() {
use serde_json::json;
let ms = 1_793_017_629_123u64;
for v in [
json!(1_793_017_629.123),
json!("1793017629.123"),
json!(1_793_017_629_123u64),
json!("1793017629123"),
json!(1_793_017_629_123_000u64),
json!(1_793_017_629_123_000_000u64),
json!("2026-10-26T12:27:09.123Z"),
json!("2026-10-26T14:27:09.123+02:00"),
] {
assert_eq!(value_to_ms(&v), Some(ms), "{v}");
}
for v in [json!(0), json!(-5), json!("soon"), json!(null), json!([1])] {
assert_eq!(value_to_ms(&v), None, "{v}");
}
}
#[test]
fn test_format_display_pair_underscore() {
assert_eq!(Pair::parse("BTC_USDT").to_string(), "BTC/USDT");
}
#[test]
fn test_format_display_pair_dash() {
assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
}
#[test]
fn test_format_display_pair_concatenated() {
assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
}
#[test]
fn test_format_display_pair_eur_concatenated() {
assert_eq!(Pair::parse("XBTEUR").to_string(), "XBT/EUR");
}
#[test]
fn test_format_display_pair_gbp_concatenated() {
assert_eq!(Pair::parse("XBTGBP").to_string(), "XBT/GBP");
}
#[test]
fn test_format_display_pair_usdc_concatenated() {
assert_eq!(Pair::parse("ETHUSDC").to_string(), "ETH/USDC");
}
#[test]
fn test_format_display_pair_no_quote_match_returns_raw() {
assert_eq!(Pair::parse("XYZABC").to_string(), "XYZABC");
}
#[test]
fn test_format_display_pair_base_zero_len_returns_raw() {
assert_eq!(Pair::parse("USDT").to_string(), "USDT");
}
#[test]
fn test_value_to_f64_number() {
let val = serde_json::json!(42.5);
assert_eq!(value_to_f64(&val), Some(42.5));
}
#[test]
fn test_value_to_f64_string() {
let val = serde_json::json!("42.5");
assert_eq!(value_to_f64(&val), Some(42.5));
}
#[test]
fn test_value_to_f64_invalid() {
let val = serde_json::json!(null);
assert_eq!(value_to_f64(&val), None);
}
#[test]
fn test_navigate_root_empty() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"price": 42});
let result = client.navigate_root(&json, None).unwrap();
assert_eq!(result, &json);
}
#[test]
fn test_navigate_root_single_key() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": {"price": 42}});
let result = client.navigate_root(&json, Some("result")).unwrap();
assert_eq!(result, &serde_json::json!({"price": 42}));
}
#[test]
fn test_navigate_root_nested_with_index() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"data": [{"price": 42}, {"price": 43}]});
let result = client.navigate_root(&json, Some("data.0")).unwrap();
assert_eq!(result, &serde_json::json!({"price": 42}));
}
#[test]
fn test_navigate_root_wildcard() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": {"XXBTZUSD": {"a": ["42000.0"]}}});
let result = client.navigate_root(&json, Some("result.*")).unwrap();
assert_eq!(result, &serde_json::json!({"a": ["42000.0"]}));
}
#[test]
fn test_extract_f64_nested() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"c": ["42000.5", "1.5"]});
assert_eq!(client.extract_f64(&data, "c.0"), Some(42000.5));
assert_eq!(client.extract_f64(&data, "c.1"), Some(1.5));
}
#[test]
fn test_parse_positional_levels() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let arr = serde_json::json!([["42000.0", "1.5"], ["42001.0", "2.0"]]);
let mapping = ResponseMapping {
level_format: Some("positional".to_string()),
..Default::default()
};
let levels = client.parse_levels(&arr, &mapping).unwrap();
assert_eq!(levels.len(), 2);
assert_eq!(levels[0].price, 42000.0);
assert_eq!(levels[0].quantity, 1.5);
}
#[test]
fn test_parse_object_levels() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let arr = serde_json::json!([
{"price": "42000.0", "size": "1.5"},
{"price": "42001.0", "size": "2.0"}
]);
let mapping = ResponseMapping {
level_format: Some("object".to_string()),
level_price_field: Some("price".to_string()),
level_size_field: Some("size".to_string()),
..Default::default()
};
let levels = client.parse_levels(&arr, &mapping).unwrap();
assert_eq!(levels.len(), 2);
assert_eq!(levels[0].price, 42000.0);
assert_eq!(levels[0].quantity, 1.5);
}
#[test]
fn test_parse_side_mapping() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"isBuyerMaker": true});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "isBuyerMaker".to_string(),
mapping: [
("true".to_string(), "sell".to_string()),
("false".to_string(), "buy".to_string()),
]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
}
#[test]
fn test_interpolate_json() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let template = serde_json::json!({
"method": "get-book",
"params": {"instrument": "{pair}", "depth": "{limit}"}
});
let result = client.interpolate_json(&template, "BTC_USDT", "100");
assert_eq!(
result,
serde_json::json!({
"method": "get-book",
"params": {"instrument": "BTC_USDT", "depth": "100"}
})
);
}
#[test]
fn test_interpolate_json_array_with_placeholders() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let template = serde_json::json!({
"pairs": ["{pair}", "limit:{limit}"]
});
let result = client.interpolate_json(&template, "BTCUSDT", "50");
assert_eq!(result["pairs"][0], "BTCUSDT");
assert_eq!(result["pairs"][1], "limit:50");
}
#[test]
fn test_interpolate_json_preserves_primitive_types() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let template = serde_json::json!({
"flag": true,
"count": 42,
"name": "static"
});
let result = client.interpolate_json(&template, "BTC", "10");
assert_eq!(result["flag"], true);
assert_eq!(result["count"], 42);
assert_eq!(result["name"], "static");
}
fn make_test_descriptor() -> VenueDescriptor {
use crate::market::descriptor::*;
VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
}
}
#[test]
fn test_descriptor_accessor() {
let desc = make_test_descriptor();
let client =
ConfigurableExchangeClient::with_http(desc.clone(), crate::http::test_transport());
assert_eq!(client.descriptor().id, "test");
assert_eq!(client.descriptor().name, "Test");
}
#[test]
fn test_format_pair_accessor() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
assert_eq!(client.format_pair("BTC", None), "BTCUSDT");
assert_eq!(client.format_pair("ETH", Some("USD")), "ETHUSD");
}
#[test]
fn test_navigate_root_empty_string_path() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"price": 42});
let result = client.navigate_root(&json, Some("")).unwrap();
assert_eq!(result, &json);
}
#[test]
fn test_navigate_root_wildcard_on_non_object_null() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": null});
let result = client.navigate_root(&json, Some("result.*"));
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(err_msg.contains("null"), "error should mention null type");
}
#[test]
fn test_navigate_root_wildcard_on_non_object_bool() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": true});
let result = client.navigate_root(&json, Some("result.*"));
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(err_msg.contains("bool"), "error should mention bool type");
}
#[test]
fn test_navigate_root_wildcard_on_non_object_number() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": 42});
let result = client.navigate_root(&json, Some("result.*"));
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("number"),
"error should mention number type"
);
}
#[test]
fn test_navigate_root_wildcard_on_non_object_string() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": "not_an_object"});
let result = client.navigate_root(&json, Some("result.*"));
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("string"),
"error should mention string type"
);
}
#[test]
fn test_navigate_root_wildcard_on_non_object_array() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": [1, 2, 3]});
let result = client.navigate_root(&json, Some("result.*"));
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(err_msg.contains("array"), "error should mention array type");
}
#[test]
fn test_navigate_root_wildcard_empty_object() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"result": {}});
let result = client.navigate_root(&json, Some("result.*"));
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("empty object"),
"error should mention empty object"
);
}
#[test]
fn test_extract_string_from_string() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"id": "abc123"});
assert_eq!(
client.extract_string(&data, "id").as_deref(),
Some("abc123")
);
}
#[test]
fn test_extract_string_from_number() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"id": 12345});
assert_eq!(client.extract_string(&data, "id").as_deref(), Some("12345"));
}
#[test]
fn test_extract_string_from_nested_path() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"a": {"b": {"c": ["x", "value"]}}});
assert_eq!(
client.extract_string(&data, "a.b.c.1").as_deref(),
Some("value")
);
}
#[test]
fn test_extract_string_returns_none_for_object() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"id": {"nested": "obj"}});
assert_eq!(client.extract_string(&data, "id"), None);
}
#[test]
fn test_extract_string_returns_none_for_array() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"id": [1, 2, 3]});
assert_eq!(client.extract_string(&data, "id"), None);
}
#[test]
fn test_navigate_field_deeper_path() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"level1": {"level2": {"level3": [0, 99.5]}}});
assert_eq!(
client.extract_f64(&data, "level1.level2.level3.1"),
Some(99.5)
);
}
#[test]
fn test_parse_levels_empty_array() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let arr = serde_json::json!([]);
let mapping = ResponseMapping::default();
let levels = client.parse_levels(&arr, &mapping).unwrap();
assert!(levels.is_empty());
}
#[test]
fn test_parse_levels_not_array_err() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let not_arr = serde_json::json!({"not": "array"});
let mapping = ResponseMapping::default();
let result = client.parse_levels(¬_arr, &mapping);
assert!(result.is_err());
let err_msg = format!("{:?}", result.unwrap_err());
assert!(err_msg.contains("expected array"));
}
#[test]
fn test_parse_levels_filters_zero_price_and_quantity() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let arr = serde_json::json!([
["42000.0", "1.5"],
["0.0", "1.0"],
["42001.0", "0.0"],
["42002.0", ""]
]);
let mapping = ResponseMapping {
level_format: Some("positional".to_string()),
..Default::default()
};
let levels = client.parse_levels(&arr, &mapping).unwrap();
assert_eq!(levels.len(), 1);
assert_eq!(levels[0].price, 42000.0);
assert_eq!(levels[0].quantity, 1.5);
}
#[test]
fn test_parse_levels_object_format_default_fields() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let arr = serde_json::json!([
{"price": "100.0", "size": "2.0"},
{"price": "101.0", "size": "3.0"}
]);
let mapping = ResponseMapping {
level_format: Some("object".to_string()),
level_price_field: None,
level_size_field: None,
..Default::default()
};
let levels = client.parse_levels(&arr, &mapping).unwrap();
assert_eq!(levels.len(), 2);
assert_eq!(levels[0].price, 100.0);
assert_eq!(levels[0].quantity, 2.0);
}
#[test]
fn test_parse_side_no_mapping_returns_buy() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"side": "sell"});
let mapping = ResponseMapping {
side: None,
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
}
#[test]
fn test_parse_side_field_missing_returns_buy() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "nonexistent".to_string(),
mapping: [("sell".to_string(), "sell".to_string())]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
}
#[test]
fn test_parse_side_string_mapped_to_sell() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"side": "ask"});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "side".to_string(),
mapping: [
("ask".to_string(), "sell".to_string()),
("bid".to_string(), "buy".to_string()),
]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
}
#[test]
fn test_parse_side_string_mapped_to_buy() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"side": "bid"});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "side".to_string(),
mapping: [
("ask".to_string(), "sell".to_string()),
("bid".to_string(), "buy".to_string()),
]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
}
#[test]
fn test_parse_side_numeric_value() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"side": 1});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "side".to_string(),
mapping: [
("1".to_string(), "sell".to_string()),
("0".to_string(), "buy".to_string()),
]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Sell);
}
#[test]
fn test_parse_side_unknown_value_returns_buy() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"side": "unknown"});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "side".to_string(),
mapping: [
("ask".to_string(), "sell".to_string()),
("bid".to_string(), "buy".to_string()),
]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
}
#[test]
fn test_parse_side_non_string_number_bool_returns_buy() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let data = serde_json::json!({"side": [1, 2, 3]});
let mapping = ResponseMapping {
side: Some(crate::market::descriptor::SideMapping {
field: "side".to_string(),
mapping: [("ask".to_string(), "sell".to_string())]
.into_iter()
.collect(),
}),
..Default::default()
};
assert_eq!(client.parse_side(&data, &mapping), TradeSide::Buy);
}
#[test]
fn test_format_display_pair_unknown_quote() {
assert_eq!(Pair::parse("XYZABC").to_string(), "XYZABC");
}
#[test]
fn test_format_display_pair_single_char() {
assert_eq!(Pair::parse("A").to_string(), "A");
}
#[test]
fn test_format_display_pair_quote_only_no_split() {
assert_eq!(Pair::parse("USDT").to_string(), "USDT");
}
fn make_http_test_descriptor(base_url: &str) -> VenueDescriptor {
use crate::market::descriptor::*;
VenueDescriptor {
id: "mock_test".to_string(),
name: "Mock Test".to_string(),
base_url: base_url.to_string(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: Some(EndpointDescriptor {
path: "/api/v1/depth".to_string(),
method: HttpMethod::GET,
params: [("symbol".to_string(), "{pair}".to_string())]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
asks_key: Some("asks".to_string()),
bids_key: Some("bids".to_string()),
level_format: Some("positional".to_string()),
..Default::default()
},
}),
ticker: Some(EndpointDescriptor {
path: "/api/v1/ticker".to_string(),
method: HttpMethod::GET,
params: [("symbol".to_string(), "{pair}".to_string())]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
last_price: Some("lastPrice".to_string()),
high_24h: Some("highPrice".to_string()),
low_24h: Some("lowPrice".to_string()),
volume_24h: Some("volume".to_string()),
quote_volume_24h: Some("quoteVolume".to_string()),
best_bid: Some("bidPrice".to_string()),
best_ask: Some("askPrice".to_string()),
..Default::default()
},
}),
trades: Some(EndpointDescriptor {
path: "/api/v1/trades".to_string(),
method: HttpMethod::GET,
params: [
("symbol".to_string(), "{pair}".to_string()),
("limit".to_string(), "{limit}".to_string()),
]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
price: Some("price".to_string()),
quantity: Some("qty".to_string()),
quote_quantity: Some("quoteQty".to_string()),
timestamp_ms: Some("time".to_string()),
id: Some("id".to_string()),
side: Some(SideMapping {
field: "isBuyerMaker".to_string(),
mapping: [
("true".to_string(), "sell".to_string()),
("false".to_string(), "buy".to_string()),
]
.into_iter()
.collect(),
}),
..Default::default()
},
}),
ohlc: None,
},
}
}
#[tokio::test]
async fn test_fetch_order_book_via_http() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/depth")
.match_query(mockito::Matcher::AllOf(vec![mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
)]))
.with_status(200)
.with_body(
serde_json::json!({
"asks": [["50010.0", "1.5"], ["50020.0", "2.0"]],
"bids": [["50000.0", "1.0"], ["49990.0", "3.0"]]
})
.to_string(),
)
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let book = client.fetch_order_book("BTCUSDT").await.unwrap();
assert_eq!(book.asks.len(), 2);
assert_eq!(book.bids.len(), 2);
assert_eq!(book.asks[0].price, 50010.0);
assert_eq!(book.bids[0].price, 50000.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ticker_via_http() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/ticker")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(200)
.with_body(
serde_json::json!({
"lastPrice": "50100.5",
"highPrice": "51200.0",
"lowPrice": "48800.0",
"volume": "1234.56",
"quoteVolume": "62000000.0",
"bidPrice": "50095.0",
"askPrice": "50105.0"
})
.to_string(),
)
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
assert_eq!(ticker.pair, "BTC/USDT");
assert_eq!(ticker.last_price, Some(50100.5));
assert_eq!(ticker.high_24h, Some(51200.0));
assert_eq!(ticker.low_24h, Some(48800.0));
assert_eq!(ticker.volume_24h, Some(1234.56));
assert_eq!(ticker.quote_volume_24h, Some(62000000.0));
assert_eq!(ticker.best_bid, Some(50095.0));
assert_eq!(ticker.best_ask, Some(50105.0));
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_assets_reads_the_coin_list() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v3/coins")
.with_status(200)
.with_body(
serde_json::json!([
{"coin": "USDN", "networkList": [
{"network": "ETH", "contractAddress": "0xeC747DAf41A4Ca4ebE72356592F8301924132907"}
]},
{"coin": "BTC", "networkList": [{"network": "BTC", "contractAddress": "btc"}]},
{"name": "no symbol"}
])
.to_string(),
)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.capabilities.assets =
Some(serde_saphyr::from_str("path: /api/v3/coins\nresponse: {}\n").unwrap());
let client =
ConfigurableExchangeClient::with_http(desc.clone(), crate::http::test_transport());
let assets = client.fetch_assets().await.unwrap();
assert_eq!(assets.len(), 2, "an item without a symbol is skipped");
assert_eq!(
assets[0].networks[0].contract.as_deref(),
Some("0xeC747DAf41A4Ca4ebE72356592F8301924132907")
);
assert_eq!(assets[1].networks[0].contract, None);
mock.assert_async().await;
let exchange = super::super::ExchangeClient::from_descriptor_with_http(
&desc,
crate::http::test_transport(),
);
let pair = super::super::Pair::parse("USDN");
let tokens = exchange
.fetch_token_addresses(&pair)
.await
.unwrap()
.unwrap();
assert_eq!(tokens[0].symbol, "USDN");
desc.capabilities.assets = None;
let plain = super::super::ExchangeClient::from_descriptor_with_http(
&desc,
crate::http::test_transport(),
);
assert!(plain.fetch_token_addresses(&pair).await.is_none());
}
#[tokio::test]
async fn test_fetch_recent_trades_via_http() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/trades")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
{
"id": "trade-1",
"price": "50000.0",
"qty": "0.5",
"quoteQty": "25000.0",
"time": "1700000000000",
"isBuyerMaker": true
},
{
"id": "trade-2",
"price": "50001.0",
"qty": "1.0",
"quoteQty": "50001.0",
"time": "1700000001000",
"isBuyerMaker": false
}
])
.to_string(),
)
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let trades = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap();
assert_eq!(trades.len(), 2);
assert_eq!(trades[0].price, 50000.0);
assert_eq!(trades[0].quantity, 0.5);
assert_eq!(trades[0].quote_quantity, Some(25000.0));
assert_eq!(trades[0].timestamp_ms, 1700000000000);
assert_eq!(trades[0].id.as_deref(), Some("trade-1"));
assert_eq!(trades[0].side, TradeSide::Sell);
assert_eq!(trades[1].price, 50001.0);
assert_eq!(trades[1].quantity, 1.0);
assert_eq!(trades[1].side, TradeSide::Buy);
mock.assert_async().await;
}
fn yaml_client(base: &str, capabilities: &str) -> ConfigurableExchangeClient {
let yaml = format!(
"id: t\nname: T\nbase_url: {base}\nsymbol:\n template: \"{{base}}{{quote}}\"\n default_quote: USDT\ncapabilities:\n{capabilities}"
);
let desc: VenueDescriptor = serde_saphyr::from_str(&yaml).unwrap();
ConfigurableExchangeClient::with_http(desc, crate::http::test_transport())
}
#[tokio::test]
async fn test_max_limit_caps_the_request_and_grouped_trades_read_as_one_list() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/history")
.match_query(mockito::Matcher::UrlEncoded("size".into(), "2".into()))
.with_body(
serde_json::json!({"data": [
{"data": [
{"price": 1.0, "amount": 5.0, "ts": 1_700_000_000_000u64, "direction": "buy"},
{"price": 1.1, "amount": 6.0, "ts": 1_700_000_001_000u64, "direction": "sell"}
]},
{"data": [
{"price": 1.2, "amount": 7.0, "ts": 1_700_000_002_000u64, "direction": "buy"}
]}
]})
.to_string(),
)
.create_async()
.await;
let client = yaml_client(
&server.url(),
" trades:\n path: /history\n params:\n size: \"{limit}\"\n max_limit: 2\n response_root: data\n response:\n nested_items_key: data\n price: price\n quantity: amount\n timestamp_ms: ts\n side:\n field: direction\n mapping:\n buy: buy\n sell: sell\n",
);
let trades = client.fetch_recent_trades("BTCUSDT", 500).await.unwrap();
mock.assert_async().await;
assert_eq!(
trades.iter().map(|t| t.quantity).collect::<Vec<_>>(),
[5.0, 6.0, 7.0],
"{trades:?}"
);
let few = client.fetch_recent_trades("BTCUSDT", 2).await.unwrap();
assert_eq!(few.len(), 2);
}
#[tokio::test]
async fn test_a_venue_that_ignores_the_limit_gives_the_newest_trades() {
let mut server = mockito::Server::new_async().await;
let body: Vec<serde_json::Value> = (0..10u64)
.map(|i| serde_json::json!({"price": "1", "amount": i.to_string(), "date": (1_700_000_000 + i).to_string()}))
.collect();
server
.mock("GET", "/tx")
.with_body(serde_json::Value::Array(body).to_string())
.create_async()
.await;
let client = yaml_client(
&server.url(),
" trades:\n path: /tx\n response:\n price: price\n quantity: amount\n timestamp_ms: date\n",
);
let trades = client.fetch_recent_trades("BTCUSD", 3).await.unwrap();
assert_eq!(trades.len(), 3);
assert_eq!(
trades[0].timestamp_ms, 1_700_000_009_000,
"seconds become ms"
);
assert_eq!(trades[2].quantity, 7.0);
}
#[tokio::test]
async fn test_candles_take_placeholders_in_the_path_and_come_back_oldest_first_at_most_limit() {
let mut server = mockito::Server::new_async().await;
let rows: Vec<serde_json::Value> = (0..10u64)
.rev()
.map(|i| {
serde_json::json!([
1_700_000_000_000u64 + i * 3_600_000,
1.0,
2.0,
0.5,
1.5,
10.0
])
})
.collect();
let mock = server
.mock("GET", "/v2/candles/btcusd/1hr")
.with_body(serde_json::Value::Array(rows).to_string())
.create_async()
.await;
let client = yaml_client(
&server.url(),
" ohlc:\n path: /v2/candles/{pair}/{interval}\n interval_map:\n \"1h\": \"1hr\"\n response:\n ohlc_format: array_of_arrays\n ohlc_fields: [open_time, open, high, low, close, volume]\n",
);
let candles = client.fetch_ohlc("btcusd", "1h", 4).await.unwrap();
mock.assert_async().await;
assert_eq!(candles.len(), 4);
let times: Vec<u64> = candles.iter().map(|c| c.open_time).collect();
let mut sorted = times.clone();
sorted.sort();
assert_eq!(times, sorted, "oldest first");
assert_eq!(
*times.last().unwrap(),
1_700_000_000_000 + 9 * 3_600_000,
"the newest kept"
);
}
#[tokio::test]
async fn test_fetch_order_book_http_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/depth")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("API error: HTTP 500"),
"expected error message to contain 'API error: HTTP 500', got: {}",
err_msg
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_order_book_http_200_error_envelope() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/depth")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_body(r#"{"retCode":10001,"retMsg":"Not supported symbols","result":{}}"#)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.error_envelope = Some(crate::market::descriptor::ErrorEnvelope {
code_key: Some("retCode".into()),
ok_codes: vec!["0".into()],
message_key: "retMsg".into(),
});
let err = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport())
.fetch_order_book("ZZQXUSDT")
.await
.unwrap_err();
match err {
ScopeError::Venue { venue, message } => {
assert_eq!(venue, "mock_test");
assert_eq!(message, "Not supported symbols (code 10001)");
}
other => panic!("expected a venue error, got {other:?}"),
}
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_order_book_binance_invalid_symbol_hint() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/depth")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"USDTPUSD".into(),
))
.with_status(400)
.with_body(r#"{"code":-1121,"msg":"Invalid symbol."}"#)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.id = "binance".to_string();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_order_book("USDTPUSD").await.unwrap_err();
let err_msg = err.to_string();
assert!(err_msg.contains("API error: HTTP 400"));
assert!(err_msg.contains("Invalid symbol"));
assert!(err_msg.contains("Hint: Binance returned 'Invalid symbol'"));
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_order_book_no_capability() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("does not support order book"),
"expected error message to contain 'does not support order book', got: {}",
err_msg
);
}
#[tokio::test]
async fn test_fetch_order_book_via_post() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/depth")
.match_body(mockito::Matcher::Json(serde_json::json!({
"symbol": "BTCUSDT"
})))
.with_status(200)
.with_body(
serde_json::json!({
"asks": [["50100.0", "2.0"]],
"bids": [["50000.0", "1.0"]]
})
.to_string(),
)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.capabilities.order_book = Some(EndpointDescriptor {
path: "/api/v1/depth".to_string(),
method: HttpMethod::POST,
params: std::collections::HashMap::new(),
request_body: Some(serde_json::json!({
"symbol": "{pair}"
})),
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
asks_key: Some("asks".to_string()),
bids_key: Some("bids".to_string()),
level_format: Some("positional".to_string()),
..Default::default()
},
});
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let book = client.fetch_order_book("BTCUSDT").await.unwrap();
assert_eq!(book.asks.len(), 1);
assert_eq!(book.bids.len(), 1);
assert_eq!(book.asks[0].price, 50100.0);
assert_eq!(book.bids[0].price, 50000.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_order_book_missing_asks_key() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/depth")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(200)
.with_body(
serde_json::json!({
"bids": [["50000.0", "1.0"]]
})
.to_string(),
)
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("missing 'asks'"),
"expected error message to contain 'missing \\'asks\\'', got: {}",
err_msg
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_order_book_missing_bids_key() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/depth")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(200)
.with_body(
serde_json::json!({
"asks": [["50010.0", "1.5"]]
})
.to_string(),
)
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_order_book("BTCUSDT").await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("missing 'bids'"),
"expected error message to contain 'missing \\'bids\\'', got: {}",
err_msg
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ticker_with_filter() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/tickers")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(200)
.with_body(
serde_json::json!([
{"symbol": "ETHUSDT", "lastPrice": "3000.0"},
{"symbol": "BTCUSDT", "lastPrice": "50100.5", "highPrice": "51200.0"},
{"symbol": "BNBUSDT", "lastPrice": "400.0"}
])
.to_string(),
)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.capabilities.ticker = Some(EndpointDescriptor {
path: "/api/v1/tickers".to_string(),
method: HttpMethod::GET,
params: [("symbol".to_string(), "{pair}".to_string())]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
filter: Some(FilterConfig {
field: "symbol".to_string(),
value: "{pair}".to_string(),
}),
last_price: Some("lastPrice".to_string()),
high_24h: Some("highPrice".to_string()),
..Default::default()
},
});
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
assert_eq!(ticker.pair, "BTC/USDT");
assert_eq!(ticker.last_price, Some(50100.5));
assert_eq!(ticker.high_24h, Some(51200.0));
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ticker_filter_no_match() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/tickers")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(200)
.with_body(
serde_json::json!([
{"symbol": "ETHUSDT", "lastPrice": "3000.0"},
{"symbol": "BNBUSDT", "lastPrice": "400.0"}
])
.to_string(),
)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.capabilities.ticker = Some(EndpointDescriptor {
path: "/api/v1/tickers".to_string(),
method: HttpMethod::GET,
params: [("symbol".to_string(), "{pair}".to_string())]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
filter: Some(FilterConfig {
field: "symbol".to_string(),
value: "{pair}".to_string(),
}),
last_price: Some("lastPrice".to_string()),
..Default::default()
},
});
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_ticker("BTCUSDT").await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("no ticker found for pair"),
"expected error message to contain 'no ticker found for pair', got: {}",
err_msg
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_trades_non_array_response() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/trades")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
]))
.with_status(200)
.with_body(
serde_json::json!({
"trades": [{"price": "50000", "qty": "1"}]
})
.to_string(),
)
.create_async()
.await;
let desc = make_http_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("expected array for trades"),
"expected error message to contain 'expected array for trades', got: {}",
err_msg
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_with_custom_headers() {
let mut server = mockito::Server::new_async().await;
let mut headers = std::collections::HashMap::new();
headers.insert("X-Api-Key".to_string(), "test123".to_string());
let mock = server
.mock("GET", "/api/v1/ticker")
.match_header("x-api-key", "test123")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"BTCUSDT".into(),
))
.with_status(200)
.with_body(
serde_json::json!({
"lastPrice": "50100.5",
"highPrice": "51200.0",
"lowPrice": "48800.0",
"volume": "1234.56",
"quoteVolume": "62000000.0",
"bidPrice": "50095.0",
"askPrice": "50105.0"
})
.to_string(),
)
.create_async()
.await;
let mut desc = make_http_test_descriptor(&server.url());
desc.headers = headers;
desc.capabilities.ticker.as_mut().unwrap().response = ResponseMapping {
last_price: Some("lastPrice".to_string()),
high_24h: Some("highPrice".to_string()),
low_24h: Some("lowPrice".to_string()),
volume_24h: Some("volume".to_string()),
quote_volume_24h: Some("quoteVolume".to_string()),
best_bid: Some("bidPrice".to_string()),
best_ask: Some("askPrice".to_string()),
..Default::default()
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let ticker = client.fetch_ticker("BTCUSDT").await.unwrap();
assert_eq!(ticker.last_price, Some(50100.5));
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_trades_no_capability() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_recent_trades("BTCUSDT", 10).await.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("does not support trades"),
"expected error message to contain 'does not support trades', got: {}",
err_msg
);
}
#[test]
fn test_navigate_root_index_out_of_bounds() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"data": [1, 2]});
let result = client.navigate_root(&json, Some("data.5"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("out of bounds"));
}
#[test]
fn test_navigate_root_missing_key() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let json = serde_json::json!({"data": {"nested": 1}});
let result = client.navigate_root(&json, Some("data.missing_key"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("missing key"));
}
#[test]
fn test_interpolate_json_passthrough() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
assert_eq!(
client.interpolate_json(&serde_json::json!(42), "BTC", "100"),
serde_json::json!(42)
);
assert_eq!(
client.interpolate_json(&serde_json::json!(true), "BTC", "100"),
serde_json::json!(true)
);
assert_eq!(
client.interpolate_json(&serde_json::json!(null), "BTC", "100"),
serde_json::json!(null)
);
}
fn make_ohlc_test_descriptor(base_url: &str) -> VenueDescriptor {
use crate::market::descriptor::*;
VenueDescriptor {
id: "ohlc_mock".to_string(),
name: "OHLC Mock".to_string(),
base_url: base_url.to_string(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/klines".to_string(),
method: HttpMethod::GET,
params: [
("symbol".to_string(), "{pair}".to_string()),
("interval".to_string(), "{interval}".to_string()),
("limit".to_string(), "{limit}".to_string()),
]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
ohlc_format: Some("array_of_arrays".to_string()),
ohlc_fields: Some(vec![
"open_time".to_string(),
"open".to_string(),
"high".to_string(),
"low".to_string(),
"close".to_string(),
"volume".to_string(),
"close_time".to_string(),
]),
..Default::default()
},
}),
},
}
}
#[tokio::test]
async fn test_fetch_ohlc_array_of_arrays() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/klines")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
mockito::Matcher::UrlEncoded("limit".into(), "3".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
[
1700000000000u64,
"50000.0",
"50500.0",
"49800.0",
"50200.0",
"100.5",
1700003599999u64
],
[
1700003600000u64,
"50200.0",
"50800.0",
"50100.0",
"50700.0",
"120.3",
1700007199999u64
],
[
1700007200000u64,
"50700.0",
"51000.0",
"50600.0",
"50900.0",
"95.7",
1700010799999u64
]
])
.to_string(),
)
.create_async()
.await;
let desc = make_ohlc_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "1h", 3).await.unwrap();
assert_eq!(candles.len(), 3);
assert_eq!(candles[0].open_time, 1700000000000);
assert_eq!(candles[0].open, 50000.0);
assert_eq!(candles[0].high, 50500.0);
assert_eq!(candles[0].low, 49800.0);
assert_eq!(candles[0].close, 50200.0);
assert_eq!(candles[0].volume, 100.5);
assert_eq!(candles[0].close_time, 1700003599999);
assert_eq!(candles[2].open, 50700.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_object_format() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/candles")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "ETHUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "15m".into()),
mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
{
"ts": 1700000000000u64,
"o": "3000.0",
"h": "3050.0",
"l": "2980.0",
"c": "3020.0",
"vol": "500.0",
"ct": 1700000899999u64
},
{
"ts": 1700000900000u64,
"o": "3020.0",
"h": "3080.0",
"l": "3010.0",
"c": "3060.0",
"vol": "420.0",
"ct": 1700001799999u64
}
])
.to_string(),
)
.create_async()
.await;
let desc = VenueDescriptor {
id: "ohlc_obj_mock".to_string(),
name: "OHLC Obj Mock".to_string(),
base_url: server.url(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/candles".to_string(),
method: HttpMethod::GET,
params: [
("symbol".to_string(), "{pair}".to_string()),
("interval".to_string(), "{interval}".to_string()),
("limit".to_string(), "{limit}".to_string()),
]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
ohlc_format: Some("objects".to_string()),
open_time: Some("ts".to_string()),
open: Some("o".to_string()),
high: Some("h".to_string()),
low: Some("l".to_string()),
close: Some("c".to_string()),
ohlc_volume: Some("vol".to_string()),
close_time: Some("ct".to_string()),
..Default::default()
},
}),
},
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("ETHUSDT", "15m", 2).await.unwrap();
assert_eq!(candles.len(), 2);
assert_eq!(candles[0].open_time, 1700000000000);
assert_eq!(candles[0].open, 3000.0);
assert_eq!(candles[0].high, 3050.0);
assert_eq!(candles[0].low, 2980.0);
assert_eq!(candles[0].close, 3020.0);
assert_eq!(candles[0].volume, 500.0);
assert_eq!(candles[1].close, 3060.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_no_capability() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("does not support OHLC"),
"expected OHLC error, got: {}",
msg
);
}
#[tokio::test]
async fn test_fetch_ohlc_interval_map() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/kline")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("type".into(), "hour".into()),
mockito::Matcher::UrlEncoded("size".into(), "2".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
[
1700000000000u64,
"50000.0",
"50500.0",
"49800.0",
"50200.0",
"100.5"
],
[
1700003600000u64,
"50200.0",
"50800.0",
"50100.0",
"50700.0",
"120.3"
]
])
.to_string(),
)
.create_async()
.await;
let desc = VenueDescriptor {
id: "interval_map_test".to_string(),
name: "Interval Map Test".to_string(),
base_url: server.url(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/kline".to_string(),
method: HttpMethod::GET,
params: [
("symbol".to_string(), "{pair}".to_string()),
("type".to_string(), "{interval}".to_string()),
("size".to_string(), "{limit}".to_string()),
]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: [
("1m".to_string(), "1min".to_string()),
("5m".to_string(), "5min".to_string()),
("1h".to_string(), "hour".to_string()),
("1d".to_string(), "day".to_string()),
]
.into_iter()
.collect(),
max_limit: None,
response: ResponseMapping {
ohlc_format: Some("array_of_arrays".to_string()),
ohlc_fields: Some(vec![
"open_time".to_string(),
"open".to_string(),
"high".to_string(),
"low".to_string(),
"close".to_string(),
"volume".to_string(),
]),
..Default::default()
},
}),
},
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
assert_eq!(candles.len(), 2);
assert_eq!(candles[0].open, 50000.0);
assert_eq!(candles[1].close, 50700.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_interval_map_passthrough() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/kline")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("type".into(), "15m".into()),
mockito::Matcher::UrlEncoded("size".into(), "1".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([[
1700000000000u64,
"50000.0",
"50500.0",
"49800.0",
"50200.0",
"100.5"
]])
.to_string(),
)
.create_async()
.await;
let desc = VenueDescriptor {
id: "passthrough_test".to_string(),
name: "Passthrough Test".to_string(),
base_url: server.url(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/kline".to_string(),
method: HttpMethod::GET,
params: [
("symbol".to_string(), "{pair}".to_string()),
("type".to_string(), "{interval}".to_string()),
("size".to_string(), "{limit}".to_string()),
]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: [("1h".to_string(), "hour".to_string())]
.into_iter()
.collect(),
max_limit: None,
response: ResponseMapping {
ohlc_format: Some("array_of_arrays".to_string()),
ohlc_fields: Some(vec![
"open_time".to_string(),
"open".to_string(),
"high".to_string(),
"low".to_string(),
"close".to_string(),
"volume".to_string(),
]),
..Default::default()
},
}),
},
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "15m", 1).await.unwrap();
assert_eq!(candles.len(), 1);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_non_array_response() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/klines")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
]))
.with_status(200)
.with_body(serde_json::json!({"error": "not an array"}).to_string())
.create_async()
.await;
let desc = make_ohlc_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("expected array for OHLC"),
"expected array error, got: {}",
msg
);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_empty_array() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/klines")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "1d".into()),
mockito::Matcher::UrlEncoded("limit".into(), "10".into()),
]))
.with_status(200)
.with_body("[]")
.create_async()
.await;
let desc = make_ohlc_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "1d", 10).await.unwrap();
assert!(candles.is_empty());
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_skips_malformed_inner_items() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/klines")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
mockito::Matcher::UrlEncoded("limit".into(), "5".into()),
]))
.with_status(200)
.with_body(
serde_json::json!([
"not an array",
[
1700000000000u64,
"50000.0",
"50500.0",
"49800.0",
"50200.0",
"100.5",
1700003599999u64
],
42
])
.to_string(),
)
.create_async()
.await;
let desc = make_ohlc_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "1h", 5).await.unwrap();
assert_eq!(candles.len(), 1);
assert_eq!(candles[0].open, 50000.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_with_response_root() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/klines")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "4h".into()),
mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
]))
.with_status(200)
.with_body(
serde_json::json!({
"result": [
[1700000000000u64, "50000.0", "50500.0", "49800.0", "50200.0", "100.5", 1700003599999u64],
[1700003600000u64, "50200.0", "50800.0", "50100.0", "50700.0", "120.3", 1700007199999u64]
]
})
.to_string(),
)
.create_async()
.await;
let mut desc = make_ohlc_test_descriptor(&server.url());
desc.capabilities.ohlc.as_mut().unwrap().response_root = Some("result".to_string());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "4h", 2).await.unwrap();
assert_eq!(candles.len(), 2);
mock.assert_async().await;
}
#[test]
fn test_interpolate_value_full_with_interval() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let result =
client.interpolate_value_full("{pair}_{interval}_{limit}", "BTCUSDT", "50", "1h");
assert_eq!(result, "BTCUSDT_1h_50");
}
#[test]
fn test_interpolate_json_full_with_interval() {
let desc = make_test_descriptor();
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let template = serde_json::json!({
"symbol": "{pair}",
"interval": "{interval}",
"limit": "{limit}"
});
let result = client.interpolate_json_full(&template, "ETHUSDT", "100", "15m");
assert_eq!(result["symbol"], "ETHUSDT");
assert_eq!(result["interval"], "15m");
assert_eq!(result["limit"], "100");
}
#[tokio::test]
async fn test_fetch_ohlc_via_post_method() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/klines")
.with_status(200)
.with_body(
serde_json::json!([[
1700000000000u64,
"50000.0",
"50500.0",
"49800.0",
"50200.0",
"100.5",
1700003599999u64
]])
.to_string(),
)
.create_async()
.await;
let desc = VenueDescriptor {
id: "post_ohlc".to_string(),
name: "POST OHLC".to_string(),
base_url: server.url(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/klines".to_string(),
method: HttpMethod::POST,
params: std::collections::HashMap::new(),
request_body: Some(serde_json::json!({
"symbol": "{pair}",
"interval": "{interval}",
"limit": "{limit}"
})),
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
ohlc_format: Some("array_of_arrays".to_string()),
ohlc_fields: Some(vec![
"open_time".to_string(),
"open".to_string(),
"high".to_string(),
"low".to_string(),
"close".to_string(),
"volume".to_string(),
"close_time".to_string(),
]),
..Default::default()
},
}),
},
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "1h", 1).await.unwrap();
assert_eq!(candles.len(), 1);
assert_eq!(candles[0].open, 50000.0);
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_post_http_error() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/api/v1/klines")
.with_status(500)
.with_body("Internal Server Error")
.create_async()
.await;
let desc = VenueDescriptor {
id: "post_ohlc_err".to_string(),
name: "POST OHLC Err".to_string(),
base_url: server.url(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/klines".to_string(),
method: HttpMethod::POST,
params: std::collections::HashMap::new(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
ohlc_format: Some("array_of_arrays".to_string()),
..Default::default()
},
}),
},
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
assert!(err.to_string().contains("API error"));
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_get_http_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/klines")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
]))
.with_status(429)
.with_body("Rate limited")
.create_async()
.await;
let desc = make_ohlc_test_descriptor(&server.url());
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let err = client.fetch_ohlc("BTCUSDT", "1h", 100).await.unwrap_err();
assert!(err.to_string().contains("API error"));
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_ohlc_with_items_key() {
use crate::market::descriptor::*;
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/candles")
.match_query(mockito::Matcher::AllOf(vec![
mockito::Matcher::UrlEncoded("symbol".into(), "BTCUSDT".into()),
mockito::Matcher::UrlEncoded("interval".into(), "1h".into()),
mockito::Matcher::UrlEncoded("limit".into(), "2".into()),
]))
.with_status(200)
.with_body(
serde_json::json!({
"data": [
{"ts": 1700000000000u64, "o": "100.0", "h": "110.0", "l": "90.0", "c": "105.0", "vol": "1000.0", "ct": 1700003599999u64},
{"ts": 1700003600000u64, "o": "105.0", "h": "115.0", "l": "100.0", "c": "110.0", "vol": "800.0", "ct": 1700007199999u64}
]
})
.to_string(),
)
.create_async()
.await;
let desc = VenueDescriptor {
id: "items_key_ohlc".to_string(),
name: "Items Key OHLC".to_string(),
base_url: server.url(),
timeout_secs: Some(5),
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: std::collections::HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
path: "/api/v1/candles".to_string(),
method: HttpMethod::GET,
params: [
("symbol".to_string(), "{pair}".to_string()),
("interval".to_string(), "{interval}".to_string()),
("limit".to_string(), "{limit}".to_string()),
]
.into_iter()
.collect(),
request_body: None,
response_root: None,
interval_map: std::collections::HashMap::new(),
max_limit: None,
response: ResponseMapping {
items_key: Some("data".to_string()),
ohlc_format: Some("objects".to_string()),
open_time: Some("ts".to_string()),
open: Some("o".to_string()),
high: Some("h".to_string()),
low: Some("l".to_string()),
close: Some("c".to_string()),
ohlc_volume: Some("vol".to_string()),
close_time: Some("ct".to_string()),
..Default::default()
},
}),
},
};
let client = ConfigurableExchangeClient::with_http(desc, crate::http::test_transport());
let candles = client.fetch_ohlc("BTCUSDT", "1h", 2).await.unwrap();
assert_eq!(candles.len(), 2);
assert_eq!(candles[0].open, 100.0);
assert_eq!(candles[1].close, 110.0);
mock.assert_async().await;
}
mod transport_tests {
use super::*;
use crate::http::{HttpClient, Request, Response};
use std::sync::{Arc, Mutex};
struct Spy {
seen: Mutex<Vec<Request>>,
status: u16,
body: String,
}
#[async_trait::async_trait]
impl HttpClient for Spy {
async fn send(&self, req: Request) -> crate::error::Result<Response> {
self.seen.lock().unwrap().push(req);
Ok(Response {
status_code: self.status,
headers: Default::default(),
body: self.body.clone(),
})
}
}
fn binance_like() -> VenueDescriptor {
crate::market::VenueRegistry::load()
.unwrap()
.get("binance")
.unwrap()
.clone()
}
#[tokio::test]
async fn test_ticker_carries_the_24h_change_when_the_venue_gives_it() {
let body = r#"{"symbol":"USDCUSDT","lastPrice":"1.0011","priceChangePercent":"-0.250","volume":"10","quoteVolume":"10.01"}"#;
let spy = Arc::new(Spy {
seen: Mutex::new(vec![]),
status: 200,
body: body.to_string(),
});
let client = ConfigurableExchangeClient::with_http(binance_like(), spy.clone());
let ticker = client.fetch_ticker("USDCUSDT").await.unwrap();
assert_eq!(ticker.price_change_pct_24h, Some(-0.25));
let mut desc = binance_like();
if let Some(t) = desc.capabilities.ticker.as_mut() {
t.response.price_change_pct_24h = None;
}
let client = ConfigurableExchangeClient::with_http(desc, spy);
let ticker = client.fetch_ticker("USDCUSDT").await.unwrap();
assert_eq!(ticker.price_change_pct_24h, None);
}
#[tokio::test]
async fn test_venue_requests_go_through_the_given_transport() {
let spy = Arc::new(Spy {
seen: Mutex::new(vec![]),
status: 200,
body: r#"{"bids":[],"asks":[]}"#.into(),
});
let client = ConfigurableExchangeClient::with_http(binance_like(), spy.clone());
let _ = client.fetch_order_book("USDCUSDT").await;
let seen = spy.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert!(seen[0].url.contains("symbol=USDCUSDT"), "{}", seen[0].url);
assert_eq!(
seen[0].timeout,
Some(std::time::Duration::from_secs(
binance_like().timeout_secs.unwrap_or(15)
))
);
}
#[tokio::test]
async fn test_venue_http_400_names_the_venue_that_failed() {
let spy = Arc::new(Spy {
seen: Mutex::new(vec![]),
status: 400,
body: r#"{"msg":"bad"}"#.into(),
});
let client = ConfigurableExchangeClient::with_http(binance_like(), spy);
let err = client.fetch_order_book("BADPAIR").await.unwrap_err();
assert!(matches!(err, ScopeError::Venue { .. }), "{err:?}");
assert!(
err.to_string().starts_with("Venue error (binance):"),
"{err}"
);
}
#[tokio::test]
async fn test_each_venue_labels_errors_and_requests_with_its_own_id() {
let registry = crate::market::VenueRegistry::load().unwrap();
for id in ["bybit", "mexc", "okx", "gateio", "kraken"] {
let desc = registry.get(id).unwrap().clone();
let spy = Arc::new(Spy {
seen: Mutex::new(vec![]),
status: 500,
body: String::new(),
});
let client = ConfigurableExchangeClient::with_http(desc.clone(), spy.clone());
let pair = desc.format_pair("BTC", None);
let err = client.fetch_order_book(&pair).await.unwrap_err();
let text = err.to_string();
assert!(
text.starts_with(&format!("Venue error ({id}):")),
"{id}: {text}"
);
assert!(!text.to_lowercase().contains("binance"), "{id}: {text}");
let seen = spy.seen.lock().unwrap();
assert!(
seen[0].url.starts_with(&desc.base_url),
"{id}: request went to {}",
seen[0].url
);
assert!(!seen[0].url.contains("binance"), "{id}: {}", seen[0].url);
}
}
#[tokio::test]
async fn test_missing_response_key_names_the_venue() {
let spy = Arc::new(Spy {
seen: Mutex::new(vec![]),
status: 200,
body: r#"{"retCode":10001,"retMsg":"params error"}"#.into(),
});
let desc = crate::market::VenueRegistry::load()
.unwrap()
.get("bybit")
.unwrap()
.clone();
let client = ConfigurableExchangeClient::with_http(desc, spy);
let err = client.fetch_order_book("USDDUSDT").await.unwrap_err();
assert!(matches!(err, ScopeError::Venue { .. }), "{err:?}");
assert!(err.to_string().starts_with("Venue error (bybit):"), "{err}");
}
#[tokio::test]
async fn test_venue_http_error_text_is_unchanged() {
let spy = Arc::new(Spy {
seen: Mutex::new(vec![]),
status: 400,
body: r#"{"code":-1121,"msg":"Invalid symbol."}"#.into(),
});
let client = ConfigurableExchangeClient::with_http(binance_like(), spy);
let err = client
.fetch_order_book("BADPAIR")
.await
.unwrap_err()
.to_string();
assert!(err.contains("HTTP 400 Bad Request"), "{err}");
assert!(err.contains("Invalid symbol"), "{err}");
}
}
}