use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
pub struct SymbolConfig {
pub template: String,
pub default_quote: String,
#[serde(default)]
pub case: SymbolCase,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SymbolCase {
#[default]
Upper,
Lower,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "UPPERCASE")]
pub enum HttpMethod {
#[default]
#[serde(alias = "get")]
GET,
#[serde(alias = "post")]
POST,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EndpointDescriptor {
#[serde(default)]
pub method: HttpMethod,
pub path: String,
#[serde(default)]
pub params: HashMap<String, String>,
pub request_body: Option<serde_json::Value>,
pub response_root: Option<String>,
#[serde(default)]
pub interval_map: HashMap<String, String>,
pub response: ResponseMapping,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ResponseMapping {
pub asks_key: Option<String>,
pub bids_key: Option<String>,
pub level_format: Option<String>,
pub level_price_field: Option<String>,
pub level_size_field: Option<String>,
pub last_price: Option<String>,
pub high_24h: Option<String>,
pub low_24h: Option<String>,
pub volume_24h: Option<String>,
pub quote_volume_24h: Option<String>,
pub price_change_pct_24h: Option<String>,
pub best_bid: Option<String>,
pub best_ask: Option<String>,
pub items_key: Option<String>,
pub filter: Option<FilterConfig>,
pub price: Option<String>,
pub quantity: Option<String>,
pub quote_quantity: Option<String>,
pub timestamp_ms: Option<String>,
pub id: Option<String>,
pub side: Option<SideMapping>,
pub ohlc_format: Option<String>,
pub ohlc_fields: Option<Vec<String>>,
pub open_time: Option<String>,
pub open: Option<String>,
pub high: Option<String>,
pub low: Option<String>,
pub close: Option<String>,
pub ohlc_volume: Option<String>,
pub close_time: Option<String>,
pub asset: Option<String>,
pub networks_key: Option<String>,
pub network: Option<String>,
pub contract: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SideMapping {
pub field: String,
pub mapping: HashMap<String, String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct FilterConfig {
pub field: String,
pub value: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CapabilitySet {
pub order_book: Option<EndpointDescriptor>,
pub ticker: Option<EndpointDescriptor>,
pub trades: Option<EndpointDescriptor>,
pub ohlc: Option<EndpointDescriptor>,
pub assets: Option<EndpointDescriptor>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VenueDescriptor {
pub id: String,
pub name: String,
pub base_url: String,
pub timeout_secs: Option<u64>,
pub rate_limit_per_sec: Option<u32>,
pub symbol: SymbolConfig,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub capabilities: CapabilitySet,
#[serde(default)]
pub error_envelope: Option<ErrorEnvelope>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ErrorEnvelope {
#[serde(default)]
pub code_key: Option<String>,
#[serde(default)]
pub ok_codes: Vec<String>,
pub message_key: String,
}
impl ErrorEnvelope {
pub fn error_message(&self, reply: &serde_json::Value) -> Option<String> {
let message = reply.get(&self.message_key);
let text = || match message {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(items)) => items
.iter()
.map(|v| v.as_str().map_or_else(|| v.to_string(), str::to_string))
.collect::<Vec<_>>()
.join("; "),
Some(other) => other.to_string(),
None => String::new(),
};
match &self.code_key {
Some(key) => {
let code = reply.get(key)?;
let code = code
.as_str()
.map_or_else(|| code.to_string(), str::to_string);
if self.ok_codes.contains(&code) {
return None;
}
let msg = text();
Some(if msg.is_empty() {
format!("error code {code}")
} else {
format!("{msg} (code {code})")
})
}
None => {
let failed = match message {
Some(serde_json::Value::String(s)) => !s.is_empty(),
Some(serde_json::Value::Array(items)) => !items.is_empty(),
_ => false,
};
failed.then(text)
}
}
}
}
impl VenueDescriptor {
pub fn format_pair(&self, base: &str, quote: Option<&str>) -> String {
let q = quote.unwrap_or(&self.symbol.default_quote);
let raw = self
.symbol
.template
.replace("{base}", base)
.replace("{quote}", q);
match self.symbol.case {
SymbolCase::Upper => raw.to_uppercase(),
SymbolCase::Lower => raw.to_lowercase(),
}
}
pub fn has_order_book(&self) -> bool {
self.capabilities.order_book.is_some()
}
pub fn has_ticker(&self) -> bool {
self.capabilities.ticker.is_some()
}
pub fn has_trades(&self) -> bool {
self.capabilities.trades.is_some()
}
pub fn has_ohlc(&self) -> bool {
self.capabilities.ohlc.is_some()
}
pub fn capability_names(&self) -> Vec<&'static str> {
let mut caps = Vec::new();
if self.has_order_book() {
caps.push("order_book");
}
if self.has_ticker() {
caps.push("ticker");
}
if self.has_trades() {
caps.push("trades");
}
if self.has_ohlc() {
caps.push("ohlc");
}
caps
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_symbol_case_default_is_upper() {
let case = SymbolCase::default();
assert_eq!(case, SymbolCase::Upper);
}
#[test]
fn test_http_method_default_is_get() {
let method = HttpMethod::default();
assert_eq!(method, HttpMethod::GET);
}
#[test]
fn test_format_pair_upper() {
let desc = VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("BTC", None), "BTCUSDT");
assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
}
#[test]
fn test_format_pair_lower() {
let desc = VenueDescriptor {
id: "htx".to_string(),
name: "HTX".to_string(),
base_url: "https://api.huobi.pro".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Lower,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("BTC", None), "btcusdt");
}
#[test]
fn test_format_pair_underscore() {
let desc = VenueDescriptor {
id: "biconomy".to_string(),
name: "Biconomy".to_string(),
base_url: "https://api.biconomy.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}_{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("DAI", None), "DAI_USDT");
}
#[test]
fn test_format_pair_dash() {
let desc = VenueDescriptor {
id: "okx".to_string(),
name: "OKX".to_string(),
base_url: "https://www.okx.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}-{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("BTC", None), "BTC-USDT");
}
#[test]
fn test_capability_names_all() {
let desc = VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/depth".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
ticker: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/ticker".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
trades: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/trades".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
ohlc: None,
},
};
let caps = desc.capability_names();
assert_eq!(caps, vec!["order_book", "ticker", "trades"]);
}
#[test]
fn test_capability_names_partial() {
let desc = VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/depth".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
ticker: None,
trades: None,
ohlc: None,
},
};
assert_eq!(desc.capability_names(), vec!["order_book"]);
}
#[test]
fn test_deserialize_binance_yaml() {
let yaml = r#"
id: binance
name: Binance Spot
base_url: https://api.binance.com
timeout_secs: 15
rate_limit_per_sec: 10
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities:
order_book:
path: /api/v3/depth
params:
symbol: "{pair}"
limit: "100"
response:
asks_key: asks
bids_key: bids
level_format: positional
ticker:
path: /api/v3/ticker/24hr
params:
symbol: "{pair}"
response:
last_price: lastPrice
high_24h: highPrice
low_24h: lowPrice
volume_24h: volume
quote_volume_24h: quoteVolume
best_bid: bidPrice
best_ask: askPrice
trades:
path: /api/v3/trades
params:
symbol: "{pair}"
limit: "{limit}"
response:
price: price
quantity: qty
quote_quantity: quoteQty
timestamp_ms: time
id: id
side:
field: isBuyerMaker
mapping:
"true": sell
"false": buy
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(desc.id, "binance");
assert_eq!(desc.name, "Binance Spot");
assert_eq!(desc.base_url, "https://api.binance.com");
assert_eq!(desc.timeout_secs, Some(15));
assert_eq!(desc.symbol.template, "{base}{quote}");
assert_eq!(desc.symbol.default_quote, "USDT");
assert_eq!(desc.symbol.case, SymbolCase::Upper);
let ob = desc.capabilities.order_book.as_ref().unwrap();
assert_eq!(ob.path, "/api/v3/depth");
assert_eq!(ob.params.get("symbol"), Some(&"{pair}".to_string()));
assert_eq!(ob.response.asks_key, Some("asks".to_string()));
assert_eq!(ob.response.level_format, Some("positional".to_string()));
let ticker = desc.capabilities.ticker.as_ref().unwrap();
assert_eq!(ticker.response.last_price, Some("lastPrice".to_string()));
assert_eq!(ticker.response.volume_24h, Some("volume".to_string()));
let trades = desc.capabilities.trades.as_ref().unwrap();
assert_eq!(trades.response.price, Some("price".to_string()));
let side = trades.response.side.as_ref().unwrap();
assert_eq!(side.field, "isBuyerMaker");
assert_eq!(side.mapping.get("true"), Some(&"sell".to_string()));
}
#[test]
fn test_deserialize_htx_lowercase() {
let yaml = r#"
id: htx
name: HTX
base_url: https://api.huobi.pro
symbol:
template: "{base}{quote}"
default_quote: USDT
case: lower
capabilities:
order_book:
path: /market/depth
params:
symbol: "{pair}"
type: step0
response_root: tick
response:
asks_key: asks
bids_key: bids
level_format: positional
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(desc.symbol.case, SymbolCase::Lower);
assert_eq!(desc.format_pair("BTC", None), "btcusdt");
let ob = desc.capabilities.order_book.as_ref().unwrap();
assert_eq!(ob.response_root, Some("tick".to_string()));
}
#[test]
fn test_deserialize_post_method() {
let yaml = r#"
id: crypto_com
name: Crypto.com
base_url: https://api.crypto.com/exchange/v1
symbol:
template: "{base}_{quote}"
default_quote: USDT
capabilities:
order_book:
method: POST
path: /public/get-book
request_body:
method: "public/get-book"
params:
instrument_name: "{pair}"
depth: "100"
response_root: "result.data.0"
response:
asks_key: asks
bids_key: bids
level_format: positional
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
let ob = desc.capabilities.order_book.as_ref().unwrap();
assert_eq!(ob.method, HttpMethod::POST);
assert!(ob.request_body.is_some());
assert_eq!(ob.response_root, Some("result.data.0".to_string()));
}
#[test]
fn test_deserialize_object_level_format() {
let yaml = r#"
id: coinbase
name: Coinbase
base_url: https://api.coinbase.com
symbol:
template: "{base}-{quote}"
default_quote: USD
capabilities:
order_book:
path: /api/v3/brokerage/market/product_book
params:
product_id: "{pair}"
limit: "100"
response_root: pricebook
response:
asks_key: asks
bids_key: bids
level_format: object
level_price_field: price
level_size_field: size
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
let ob = desc.capabilities.order_book.as_ref().unwrap();
assert_eq!(ob.response.level_format, Some("object".to_string()));
assert_eq!(ob.response.level_price_field, Some("price".to_string()));
assert_eq!(ob.response.level_size_field, Some("size".to_string()));
assert_eq!(ob.response_root, Some("pricebook".to_string()));
}
#[test]
fn test_venue_descriptor_format_pair_upper() {
let desc = VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("btc", None), "BTCUSDT");
assert_eq!(desc.format_pair("ETH", Some("USD")), "ETHUSD");
}
#[test]
fn test_venue_descriptor_format_pair_lower() {
let desc = VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Lower,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("BTC", None), "btcusdt");
assert_eq!(desc.format_pair("ETH", Some("usd")), "ethusd");
}
#[test]
fn test_venue_descriptor_format_pair_with_separator() {
let desc = VenueDescriptor {
id: "test".to_string(),
name: "Test".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}_{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert_eq!(desc.format_pair("BTC", None), "BTC_USDT");
assert_eq!(desc.format_pair("DAI", Some("USD")), "DAI_USD");
}
#[test]
fn test_capability_names() {
let desc = VenueDescriptor {
id: "empty".to_string(),
name: "Empty".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet::default(),
};
assert!(desc.capability_names().is_empty());
let desc_trades = VenueDescriptor {
id: "trades_only".to_string(),
name: "Trades Only".to_string(),
base_url: "https://example.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/trades".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
ohlc: None,
},
};
assert_eq!(desc_trades.capability_names(), vec!["trades"]);
}
#[test]
fn test_has_order_book() {
let with_ob = VenueDescriptor {
id: "x".to_string(),
name: "X".to_string(),
base_url: "https://x.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/depth".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
ticker: None,
trades: None,
ohlc: None,
},
};
assert!(with_ob.has_order_book());
let without = VenueDescriptor {
error_envelope: None,
capabilities: CapabilitySet::default(),
..with_ob
};
assert!(!without.has_order_book());
}
#[test]
fn test_has_ticker() {
let with_ticker = VenueDescriptor {
id: "x".to_string(),
name: "X".to_string(),
base_url: "https://x.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/ticker".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
trades: None,
ohlc: None,
},
};
assert!(with_ticker.has_ticker());
let without = VenueDescriptor {
error_envelope: None,
capabilities: CapabilitySet::default(),
..with_ticker
};
assert!(!without.has_ticker());
}
#[test]
fn test_has_trades() {
let with_trades = VenueDescriptor {
id: "x".to_string(),
name: "X".to_string(),
base_url: "https://x.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/trades".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
ohlc: None,
},
};
assert!(with_trades.has_trades());
let without = VenueDescriptor {
error_envelope: None,
capabilities: CapabilitySet::default(),
..with_trades
};
assert!(!without.has_trades());
}
#[test]
fn test_has_ohlc() {
let with_ohlc = VenueDescriptor {
id: "x".to_string(),
name: "X".to_string(),
base_url: "https://x.com".to_string(),
timeout_secs: None,
rate_limit_per_sec: None,
symbol: SymbolConfig {
template: "{base}{quote}".to_string(),
default_quote: "USDT".to_string(),
case: SymbolCase::Upper,
},
headers: HashMap::new(),
error_envelope: None,
capabilities: CapabilitySet {
assets: None,
order_book: None,
ticker: None,
trades: None,
ohlc: Some(EndpointDescriptor {
method: HttpMethod::GET,
path: "/klines".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
}),
},
};
assert!(with_ohlc.has_ohlc());
assert!(with_ohlc.capability_names().contains(&"ohlc"));
let without = VenueDescriptor {
error_envelope: None,
capabilities: CapabilitySet::default(),
..with_ohlc
};
assert!(!without.has_ohlc());
assert!(!without.capability_names().contains(&"ohlc"));
}
#[test]
fn test_response_mapping_default() {
let m = ResponseMapping::default();
assert!(m.asks_key.is_none());
assert!(m.bids_key.is_none());
assert!(m.level_format.is_none());
assert!(m.side.is_none());
}
#[test]
fn test_capability_set_default() {
let c = CapabilitySet::default();
assert!(c.order_book.is_none());
assert!(c.ticker.is_none());
assert!(c.trades.is_none());
assert!(c.ohlc.is_none());
}
#[test]
fn test_deserialize_ohlc_capability() {
let yaml = r#"
id: ohlc_venue
name: OHLC Venue Test
base_url: https://api.example.com
symbol:
template: "{base}{quote}"
default_quote: USDT
capabilities:
order_book:
path: /depth
params:
symbol: "{pair}"
response:
asks_key: asks
bids_key: bids
level_format: positional
ohlc:
path: /api/v3/klines
params:
symbol: "{pair}"
interval: "{interval}"
limit: "{limit}"
response:
ohlc_format: array_of_arrays
ohlc_fields: [open_time, open, high, low, close, volume, close_time]
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
assert!(desc.has_ohlc());
assert!(desc.capability_names().contains(&"ohlc"));
let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
assert_eq!(ohlc.path, "/api/v3/klines");
assert_eq!(ohlc.params.get("interval"), Some(&"{interval}".to_string()));
assert!(ohlc.interval_map.is_empty());
}
#[test]
fn test_deserialize_ohlc_with_interval_map() {
let yaml = r#"
id: biconomy_test
name: Biconomy Test
base_url: https://api.biconomy.com
symbol:
template: "{base}_{quote}"
default_quote: USDT
capabilities:
ohlc:
path: /api/v1/kline
params:
symbol: "{pair}"
type: "{interval}"
size: "{limit}"
interval_map:
1m: 1min
5m: 5min
15m: 15min
30m: 30min
1h: hour
4h: hour
1d: day
response:
ohlc_format: array_of_arrays
ohlc_fields: [open_time, open, high, low, close, volume]
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
assert!(desc.has_ohlc());
let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
assert_eq!(ohlc.interval_map.len(), 7);
assert_eq!(ohlc.interval_map.get("1m"), Some(&"1min".to_string()));
assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
assert_eq!(ohlc.interval_map.get("4h"), Some(&"hour".to_string()));
assert_eq!(ohlc.interval_map.get("1d"), Some(&"day".to_string()));
assert!(!ohlc.interval_map.contains_key("1w"));
}
#[test]
fn test_deserialize_biconomy_venue_yaml() {
let yaml = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("venues/biconomy.yaml"),
)
.expect("biconomy.yaml should exist");
let desc: VenueDescriptor = serde_saphyr::from_str(&yaml).unwrap();
assert_eq!(desc.id, "biconomy");
assert_eq!(desc.name, "Biconomy");
assert!(desc.has_order_book());
assert!(desc.has_ticker());
assert!(desc.has_trades());
assert!(desc.has_ohlc());
let ohlc = desc.capabilities.ohlc.as_ref().unwrap();
assert!(
!ohlc.interval_map.is_empty(),
"biconomy should have interval_map"
);
assert_eq!(ohlc.interval_map.get("1h"), Some(&"hour".to_string()));
}
#[test]
fn test_endpoint_descriptor_interval_map_defaults_empty() {
let ep = EndpointDescriptor {
method: HttpMethod::GET,
path: "/test".to_string(),
params: HashMap::new(),
request_body: None,
response_root: None,
interval_map: HashMap::new(),
response: ResponseMapping::default(),
};
assert!(ep.interval_map.is_empty());
}
#[test]
fn test_deserialize_full_descriptor_yaml() {
let yaml = r#"
id: test_venue
name: Test Venue Full
base_url: https://api.test.com
timeout_secs: 30
rate_limit_per_sec: 5
symbol:
template: "{base}-{quote}"
default_quote: USD
case: upper
headers:
X-API-KEY: "placeholder"
capabilities:
order_book:
path: /book
params:
pair: "{pair}"
response:
asks_key: asks
bids_key: bids
ticker:
path: /ticker
response:
last_price: last
trades:
path: /trades
response:
price: p
quantity: q
"#;
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(desc.id, "test_venue");
assert_eq!(desc.name, "Test Venue Full");
assert_eq!(desc.base_url, "https://api.test.com");
assert_eq!(desc.timeout_secs, Some(30));
assert_eq!(desc.rate_limit_per_sec, Some(5));
assert_eq!(desc.symbol.template, "{base}-{quote}");
assert_eq!(desc.symbol.default_quote, "USD");
assert_eq!(
desc.headers.get("X-API-KEY"),
Some(&"placeholder".to_string())
);
assert!(desc.has_order_book());
assert!(desc.has_ticker());
assert!(desc.has_trades());
assert_eq!(
desc.capability_names(),
vec!["order_book", "ticker", "trades"]
);
}
#[test]
fn test_builtin_error_envelopes_match_recorded_replies() {
let cases: [(&str, &str, &str, &str); 5] = [
(
include_str!("../../venues/bybit.yaml"),
r#"{"retCode":10001,"retMsg":"Not supported symbols","result":{}}"#,
r#"{"retCode":0,"retMsg":"OK","result":{"a":[],"b":[]}}"#,
"Not supported symbols (code 10001)",
),
(
include_str!("../../venues/okx.yaml"),
r#"{"code":"51001","msg":"Instrument ID does not exist."}"#,
r#"{"code":"0","msg":"","data":[]}"#,
"Instrument ID does not exist. (code 51001)",
),
(
include_str!("../../venues/htx.yaml"),
r#"{"status":"error","err-code":"invalid-parameter","err-msg":"invalid symbol"}"#,
r#"{"ch":"market.btcusdt.depth.step0","status":"ok","tick":{}}"#,
"invalid symbol (code error)",
),
(
include_str!("../../venues/kraken.yaml"),
r#"{"error":["EQuery:Unknown asset pair"]}"#,
r#"{"error":[],"result":{"XXBTZUSD":{}}}"#,
"EQuery:Unknown asset pair",
),
(
include_str!("../../venues/biconomy.yaml"),
r#"{"code":1,"message":"Illegal parameter","result":null}"#,
r#"{"asks":[["83528.01","0.18615"]],"bids":[["83528","0.17478"]]}"#,
"Illegal parameter (code 1)",
),
];
for (yaml, error_reply, ok_reply, expected) in cases {
let desc: VenueDescriptor = serde_saphyr::from_str(yaml).unwrap();
let env = desc.error_envelope.as_ref().expect("envelope");
let error: serde_json::Value = serde_json::from_str(error_reply).unwrap();
let ok: serde_json::Value = serde_json::from_str(ok_reply).unwrap();
assert_eq!(
env.error_message(&error).as_deref(),
Some(expected),
"{}",
desc.id
);
assert_eq!(env.error_message(&ok), None, "{}", desc.id);
}
}
#[test]
fn test_error_envelope_code_without_message() {
let env = ErrorEnvelope {
code_key: Some("code".into()),
ok_codes: vec!["0".into()],
message_key: "msg".into(),
};
let reply = serde_json::json!({"code": 7});
assert_eq!(env.error_message(&reply).as_deref(), Some("error code 7"));
}
}