use crate::error::{Result, ScopeError};
use crate::market::descriptor::VenueDescriptor;
use std::collections::HashMap;
use std::path::PathBuf;
const BUILT_IN_VENUES: &[(&str, &str)] = &[
("binance", include_str!("../../venues/binance.yaml")),
("biconomy", include_str!("../../venues/biconomy.yaml")),
("mexc", include_str!("../../venues/mexc.yaml")),
("bitget", include_str!("../../venues/bitget.yaml")),
("gateio", include_str!("../../venues/gateio.yaml")),
("bybit", include_str!("../../venues/bybit.yaml")),
("okx", include_str!("../../venues/okx.yaml")),
("coinbase", include_str!("../../venues/coinbase.yaml")),
("kraken", include_str!("../../venues/kraken.yaml")),
("htx", include_str!("../../venues/htx.yaml")),
("crypto_com", include_str!("../../venues/crypto_com.yaml")),
];
#[derive(Debug, Clone)]
pub struct VenueRegistry {
venues: HashMap<String, VenueDescriptor>,
}
impl Default for VenueRegistry {
fn default() -> Self {
let mut registry = Self {
venues: HashMap::new(),
};
for (_id, yaml) in BUILT_IN_VENUES {
if let Ok(desc) = serde_saphyr::from_str::<VenueDescriptor>(yaml) {
registry.venues.insert(desc.id.clone(), desc);
}
}
registry
}
}
impl VenueRegistry {
pub fn load() -> Result<Self> {
Self::load_from(&Self::user_venues_dir())
}
pub fn load_from(user_dir: &std::path::Path) -> Result<Self> {
let mut registry = Self::default();
if user_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(user_dir)
{
for entry in entries.flatten() {
let path = entry.path();
if path
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
{
match std::fs::read_to_string(&path) {
Ok(contents) => {
match serde_saphyr::from_str::<VenueDescriptor>(&contents) {
Ok(desc) => {
registry.venues.insert(desc.id.clone(), desc);
}
Err(e) => {
crate::notice!(
"Warning: failed to parse venue file {}: {}",
path.display(),
e
);
}
}
}
Err(e) => {
crate::notice!(
"Warning: failed to read venue file {}: {}",
path.display(),
e
);
}
}
}
}
}
Ok(registry)
}
pub fn list(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self.venues.keys().map(String::as_str).collect();
ids.sort();
ids
}
pub fn get(&self, id: &str) -> Option<&VenueDescriptor> {
self.venues.get(id)
}
pub fn len(&self) -> usize {
self.venues.len()
}
pub fn is_empty(&self) -> bool {
self.venues.is_empty()
}
pub fn contains(&self, id: &str) -> bool {
self.venues.contains_key(id)
}
pub fn suggest(&self, input: &str) -> Option<&str> {
closest(input, self.list())
}
pub fn user_venues_dir() -> PathBuf {
crate::paths::venues_dir()
}
#[deprecated(
since = "0.8.0",
note = "pass the transport: use `VenueRegistry::create_exchange_client_with_http`"
)]
pub fn create_exchange_client(
&self,
venue_id: &str,
) -> Result<crate::market::exchange::ExchangeClient> {
self.create_exchange_client_with_http(venue_id, crate::http::shared::fallback())
}
pub fn create_exchange_client_with_http(
&self,
venue_id: &str,
http: std::sync::Arc<dyn crate::http::HttpClient>,
) -> Result<crate::market::exchange::ExchangeClient> {
let desc = self
.get(venue_id)
.ok_or_else(|| unknown_venue(venue_id, &self.list()))?;
Ok(crate::market::exchange::ExchangeClient::from_descriptor_with_http(desc, http))
}
pub fn validate_yaml(yaml: &str) -> Result<VenueDescriptor> {
serde_saphyr::from_str(yaml)
.map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))
}
}
pub(crate) fn unknown_venue<S: AsRef<str>>(input: &str, ids: &[S]) -> ScopeError {
let ids: Vec<&str> = ids.iter().map(AsRef::as_ref).collect();
let available = format!(
"\n\n Available venues: {}\n Run `scope venues list` for details.",
ids.join(", ")
);
let hint = match closest(input, ids) {
Some(s) => format!("\n\n Did you mean \"{s}\"?{available}"),
None => available,
};
ScopeError::InvalidInput(format!("Unknown venue \"{input}\"{hint}"))
}
fn closest<'a>(input: &str, ids: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
let input_lower = input.to_lowercase();
ids.into_iter()
.map(|k| (k, strsim_distance(&input_lower, &k.to_lowercase())))
.filter(|(k, dist)| {
*dist * SUGGEST_LENGTH_PER_EDIT <= input_lower.chars().count().max(k.len())
})
.min_by_key(|(_, dist)| *dist)
.map(|(k, _)| k)
}
const SUGGEST_LENGTH_PER_EDIT: usize = 3;
fn strsim_distance(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let m = a_chars.len();
let n = b_chars.len();
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
row[0] = i;
}
for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
*val = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if a_chars[i - 1] == b_chars[j - 1] {
0
} else {
1
};
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_user_venue_overrides_built_in_and_bad_files_are_skipped() {
let dir = tempfile::tempdir().unwrap();
let binance = include_str!("../../venues/binance.yaml")
.replace("name: Binance Spot", "name: My Binance");
std::fs::write(dir.path().join("binance.yaml"), binance).unwrap();
std::fs::write(dir.path().join("broken.yml"), "id: [unclosed").unwrap();
std::fs::write(dir.path().join("notes.txt"), "not a venue").unwrap();
let registry = VenueRegistry::load_from(dir.path()).unwrap();
assert_eq!(registry.len(), VenueRegistry::default().len());
assert_eq!(registry.get("binance").unwrap().name, "My Binance");
}
#[test]
fn test_missing_user_dir_gives_the_built_ins() {
let dir = tempfile::tempdir().unwrap();
let registry = VenueRegistry::load_from(&dir.path().join("none")).unwrap();
assert_eq!(registry.len(), VenueRegistry::default().len());
}
#[test]
fn test_registry_default_loads_built_in() {
let registry = VenueRegistry::default();
assert!(
registry.len() >= 11,
"Expected at least 11 built-in venues, got {}",
registry.len()
);
assert!(registry.contains("binance"));
assert!(registry.contains("biconomy"));
assert!(registry.contains("mexc"));
assert!(registry.contains("bitget"));
assert!(registry.contains("gateio"));
assert!(registry.contains("bybit"));
assert!(registry.contains("okx"));
assert!(registry.contains("coinbase"));
assert!(registry.contains("kraken"));
assert!(registry.contains("htx"));
assert!(registry.contains("crypto_com"));
}
#[test]
fn test_registry_list_sorted() {
let registry = VenueRegistry::default();
let list = registry.list();
let mut sorted = list.clone();
sorted.sort();
assert_eq!(list, sorted);
}
#[test]
fn test_registry_get() {
let registry = VenueRegistry::default();
let binance = registry.get("binance").unwrap();
assert_eq!(binance.name, "Binance Spot");
assert_eq!(binance.base_url, "https://api.binance.com");
}
#[test]
fn test_registry_suggest_typo() {
let registry = VenueRegistry::default();
assert_eq!(registry.suggest("binace"), Some("binance"));
assert_eq!(registry.suggest("gate"), Some("gateio"));
assert_eq!(registry.suggest("weex"), None);
assert_eq!(registry.suggest("krakn"), Some("kraken"));
assert_eq!(registry.suggest("bybi"), Some("bybit"));
}
#[test]
fn test_registry_suggest_no_match() {
let registry = VenueRegistry::default();
assert!(registry.suggest("zzzzzzzzz").is_none());
}
#[test]
fn test_strsim_distance() {
assert_eq!(strsim_distance("binance", "binance"), 0);
assert_eq!(strsim_distance("binace", "binance"), 1);
assert_eq!(strsim_distance("", "abc"), 3);
assert_eq!(strsim_distance("abc", ""), 3);
}
#[test]
fn test_strsim_distance_completely_different() {
assert_eq!(strsim_distance("abc", "xyz"), 3);
assert_eq!(strsim_distance("hello", "world"), 4);
}
#[test]
fn test_validate_yaml_valid() {
let yaml = r#"
id: test
name: Test Exchange
base_url: https://example.com
symbol:
template: "{base}{quote}"
default_quote: USDT
"#;
let result = VenueRegistry::validate_yaml(yaml);
assert!(result.is_ok());
assert_eq!(result.unwrap().id, "test");
}
#[test]
fn test_validate_yaml_invalid() {
let yaml = "not: valid: yaml: [[[";
let result = VenueRegistry::validate_yaml(yaml);
assert!(result.is_err());
}
#[test]
fn test_user_venues_dir_is_reasonable() {
let dir = VenueRegistry::user_venues_dir();
let path_str = dir.to_string_lossy();
assert!(path_str.contains("scope"));
assert!(path_str.contains("venues"));
}
#[test]
fn test_all_built_in_venues_have_capabilities() {
let registry = VenueRegistry::default();
for id in registry.list() {
let desc = registry.get(id).unwrap();
let caps = desc.capability_names();
assert!(!caps.is_empty(), "Venue {} has no capabilities", id);
}
}
#[test]
fn test_all_built_in_format_pair() {
let registry = VenueRegistry::default();
for id in registry.list() {
let desc = registry.get(id).unwrap();
let pair = desc.format_pair("BTC", None);
assert!(!pair.is_empty(), "Venue {} produced empty pair", id);
}
}
#[test]
fn test_registry_contains_existing_and_non_existing() {
let registry = VenueRegistry::default();
assert!(registry.contains("binance"));
assert!(registry.contains("kraken"));
assert!(!registry.contains("nonexistent"));
assert!(!registry.contains("binace")); }
#[test]
fn test_registry_is_empty_returns_false_for_default() {
let registry = VenueRegistry::default();
assert!(!registry.is_empty());
}
#[test]
fn test_create_exchange_client_error_unknown_venue() {
let registry = VenueRegistry::default();
let err = registry
.create_exchange_client_with_http(
"nonexistent_venue_xyz",
crate::http::test_transport(),
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown venue"));
assert!(msg.contains("nonexistent_venue_xyz"));
}
#[test]
fn test_create_exchange_client_error_includes_did_you_mean() {
let registry = VenueRegistry::default();
let err = registry
.create_exchange_client_with_http("binace", crate::http::test_transport())
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Unknown venue"));
assert!(msg.contains("Did you mean"));
assert!(msg.contains("binance"));
}
#[test]
fn test_create_exchange_client_success() {
let registry = VenueRegistry::default();
let client = registry
.create_exchange_client_with_http("binance", crate::http::test_transport())
.unwrap();
assert_eq!(client.venue_name(), "Binance Spot");
}
#[test]
fn test_registry_load_returns_ok() {
let result = VenueRegistry::load();
assert!(result.is_ok());
let registry = result.unwrap();
assert!(registry.len() >= 11);
}
#[test]
fn test_strsim_distance_single_char_diff() {
assert_eq!(strsim_distance("a", "b"), 1);
assert_eq!(strsim_distance("test", "best"), 1);
}
#[test]
fn test_suggest_exact_match_returns_same() {
let registry = VenueRegistry::default();
assert_eq!(registry.suggest("binance"), Some("binance"));
}
#[test]
fn test_registry_get_nonexistent() {
let registry = VenueRegistry::default();
assert!(registry.get("nonexistent_xyz").is_none());
}
}