1use crate::error::{Result, ScopeError};
10use crate::market::descriptor::VenueDescriptor;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum VenueSource {
17 Catalog,
19 User,
21}
22
23#[derive(Debug, Clone, Default)]
29pub struct VenueRegistry {
30 venues: HashMap<String, VenueDescriptor>,
31 sources: HashMap<String, VenueSource>,
32 overridden: HashMap<String, VenueDescriptor>,
34 user_files: HashMap<String, PathBuf>,
36}
37
38impl VenueRegistry {
39 pub fn load() -> Result<Self> {
44 Self::load_dirs(&Self::catalog_dir(), &Self::user_venues_dir())
45 }
46
47 pub fn load_dirs(catalog: &Path, user: &Path) -> Result<Self> {
51 let mut registry = Self::default();
52 registry.read_dir(catalog, VenueSource::Catalog);
53 registry.read_dir(user, VenueSource::User);
54 Ok(registry)
55 }
56
57 fn read_dir(&mut self, dir: &Path, source: VenueSource) {
59 let Ok(entries) = std::fs::read_dir(dir) else {
60 return;
61 };
62 let mut paths: Vec<PathBuf> = entries
63 .flatten()
64 .map(|e| e.path())
65 .filter(|p| {
66 p.is_file()
67 && p.extension()
68 .is_some_and(|ext| ext == "yaml" || ext == "yml")
69 })
70 .collect();
71 paths.sort();
73 for path in paths {
74 match std::fs::read_to_string(&path) {
75 Ok(contents) => match serde_saphyr::from_str::<VenueDescriptor>(&contents) {
76 Ok(desc) => {
77 let id = desc.id.clone();
78 if source == VenueSource::User {
79 self.user_files.insert(id.clone(), path.clone());
80 }
81 let previous = self.sources.insert(id.clone(), source);
82 if let Some(replaced) = self.venues.insert(id.clone(), desc)
83 && previous == Some(VenueSource::Catalog)
84 && source == VenueSource::User
85 {
86 self.overridden.insert(id, replaced);
87 }
88 }
89 Err(e) => {
90 crate::notice!(
91 "Warning: failed to parse venue file {}: {}",
92 path.display(),
93 e
94 );
95 }
96 },
97 Err(e) => {
98 crate::notice!(
99 "Warning: failed to read venue file {}: {}",
100 path.display(),
101 e
102 );
103 }
104 }
105 }
106 }
107
108 pub fn source(&self, id: &str) -> Option<VenueSource> {
110 self.sources.get(id).copied()
111 }
112
113 pub fn hidden_capabilities(&self, id: &str) -> Vec<&'static str> {
117 let (Some(catalog), Some(yours)) = (self.overridden.get(id), self.venues.get(id)) else {
118 return Vec::new();
119 };
120 let have = yours.capability_names();
121 catalog
122 .capability_names()
123 .into_iter()
124 .filter(|c| !have.contains(c))
125 .collect()
126 }
127
128 pub fn user_file(&self, id: &str) -> Option<&std::path::Path> {
130 self.user_files.get(id).map(PathBuf::as_path)
131 }
132
133 pub fn override_hint(&self, id: &str) -> Option<String> {
136 let hidden = self.hidden_capabilities(id);
137 if hidden.is_empty() {
138 return None;
139 }
140 let file = self
141 .user_file(id)
142 .map(|p| p.display().to_string())
143 .unwrap_or_else(|| format!("{id}.yaml"));
144 Some(format!(
145 "Your venue file {file} overrides the catalog's {id}, which also has {}. \
146 Move your file out of the venues folder to use the catalog's.",
147 hidden.join(", ")
148 ))
149 }
150
151 pub fn list(&self) -> Vec<&str> {
153 let mut ids: Vec<&str> = self.venues.keys().map(String::as_str).collect();
154 ids.sort();
155 ids
156 }
157
158 pub fn get(&self, id: &str) -> Option<&VenueDescriptor> {
160 self.venues.get(id)
161 }
162
163 pub fn len(&self) -> usize {
165 self.venues.len()
166 }
167
168 pub fn is_empty(&self) -> bool {
170 self.venues.is_empty()
171 }
172
173 pub fn contains(&self, id: &str) -> bool {
175 self.venues.contains_key(id)
176 }
177
178 pub fn suggest(&self, input: &str) -> Option<&str> {
180 closest(input, self.list())
181 }
182
183 pub fn user_venues_dir() -> PathBuf {
185 crate::paths::venues_dir()
186 }
187
188 pub fn catalog_dir() -> PathBuf {
190 crate::paths::venues_dir().join("catalog")
191 }
192
193 #[deprecated(
199 since = "0.8.0",
200 note = "pass the transport: use `VenueRegistry::create_exchange_client_with_http`"
201 )]
202 pub fn create_exchange_client(
203 &self,
204 venue_id: &str,
205 ) -> Result<crate::market::exchange::ExchangeClient> {
206 self.create_exchange_client_with_http(venue_id, crate::http::shared::fallback())
207 }
208
209 pub fn create_exchange_client_with_http(
212 &self,
213 venue_id: &str,
214 http: std::sync::Arc<dyn crate::http::HttpClient>,
215 ) -> Result<crate::market::exchange::ExchangeClient> {
216 let desc = self
217 .get(venue_id)
218 .ok_or_else(|| unknown_venue(venue_id, &self.list()))?;
219 Ok(crate::market::exchange::ExchangeClient::from_descriptor_with_http(desc, http))
220 }
221
222 pub fn validate_yaml(yaml: &str) -> Result<VenueDescriptor> {
224 serde_saphyr::from_str(yaml)
225 .map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))
226 }
227}
228
229pub(crate) fn unknown_venue<S: AsRef<str>>(input: &str, ids: &[S]) -> ScopeError {
233 let ids: Vec<&str> = ids.iter().map(AsRef::as_ref).collect();
234 if ids
235 .iter()
236 .all(|id| crate::market::venue::DexVenue::parse(id).is_some())
237 {
238 return no_venues(input);
239 }
240 let available = format!(
241 "\n\n Available venues: {}\n Run `scope venues list` for details.",
242 ids.join(", ")
243 );
244 let hint = match closest(input, ids) {
245 Some(s) => format!("\n\n Did you mean \"{s}\"?{available}"),
246 None => available,
247 };
248 ScopeError::InvalidInput(format!("Unknown venue \"{input}\"{hint}"))
249}
250
251pub(crate) fn no_venues(input: &str) -> ScopeError {
254 ScopeError::InvalidInput(format!(
255 "Unknown venue \"{input}\": no exchange venues are set up yet.\n\n \
256 Run `scope venues update` to fetch the venue catalog (needs the network),\n \
257 or describe an exchange yourself: see {}",
258 crate::market::catalog::example_path().display()
259 ))
260}
261
262fn closest<'a>(input: &str, ids: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
265 let input_lower = input.to_lowercase();
266 ids.into_iter()
267 .map(|k| (k, strsim_distance(&input_lower, &k.to_lowercase())))
268 .filter(|(k, dist)| {
270 *dist * SUGGEST_LENGTH_PER_EDIT <= input_lower.chars().count().max(k.len())
271 })
272 .min_by_key(|(_, dist)| *dist)
273 .map(|(k, _)| k)
274}
275
276const SUGGEST_LENGTH_PER_EDIT: usize = 3;
279
280fn strsim_distance(a: &str, b: &str) -> usize {
282 let a_chars: Vec<char> = a.chars().collect();
283 let b_chars: Vec<char> = b.chars().collect();
284 let m = a_chars.len();
285 let n = b_chars.len();
286
287 let mut dp = vec![vec![0usize; n + 1]; m + 1];
288 for (i, row) in dp.iter_mut().enumerate().take(m + 1) {
289 row[0] = i;
290 }
291 for (j, val) in dp[0].iter_mut().enumerate().take(n + 1) {
292 *val = j;
293 }
294 for i in 1..=m {
295 for j in 1..=n {
296 let cost = if a_chars[i - 1] == b_chars[j - 1] {
297 0
298 } else {
299 1
300 };
301 dp[i][j] = (dp[i - 1][j] + 1)
302 .min(dp[i][j - 1] + 1)
303 .min(dp[i - 1][j - 1] + cost);
304 }
305 }
306 dp[m][n]
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn catalog() -> VenueRegistry {
315 crate::market::testing::catalog()
316 }
317
318 #[test]
319 fn test_nothing_is_compiled_in() {
320 assert!(VenueRegistry::default().is_empty());
321 let dir = tempfile::tempdir().unwrap();
322 let none = VenueRegistry::load_dirs(&dir.path().join("a"), &dir.path().join("b")).unwrap();
323 assert!(none.is_empty());
324 }
325
326 #[test]
327 fn test_user_venue_overrides_the_catalog_and_bad_files_are_skipped() {
328 let catalog_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("venues");
329 let user = tempfile::tempdir().unwrap();
330 let binance = std::fs::read_to_string(catalog_dir.join("binance.yaml"))
331 .unwrap()
332 .replace("name: Binance Spot", "name: My Binance");
333 std::fs::write(user.path().join("binance.yaml"), binance).unwrap();
334 std::fs::write(user.path().join("broken.yml"), "id: [unclosed").unwrap();
335 std::fs::write(user.path().join("notes.txt"), "not a venue").unwrap();
336 std::fs::write(user.path().join("example.yaml.sample"), "id: sample").unwrap();
337
338 let registry = VenueRegistry::load_dirs(&catalog_dir, user.path()).unwrap();
339 assert_eq!(registry.len(), catalog().len());
340 assert_eq!(registry.get("binance").unwrap().name, "My Binance");
341 assert_eq!(registry.source("binance"), Some(VenueSource::User));
342 assert_eq!(registry.source("kraken"), Some(VenueSource::Catalog));
343 assert!(!registry.contains("sample"));
344 }
345
346 #[test]
347 fn test_the_catalog_has_the_known_venues() {
348 let registry = catalog();
349 for id in [
350 "binance",
351 "biconomy",
352 "mexc",
353 "bitget",
354 "gateio",
355 "bybit",
356 "okx",
357 "coinbase",
358 "kraken",
359 "htx",
360 "crypto_com",
361 ] {
362 assert!(registry.contains(id), "{id} missing from the catalog");
363 }
364 }
365
366 #[test]
367 fn test_registry_list_sorted() {
368 let list = catalog()
369 .list()
370 .iter()
371 .map(|s| s.to_string())
372 .collect::<Vec<_>>();
373 let mut sorted = list.clone();
374 sorted.sort();
375 assert_eq!(list, sorted);
376 }
377
378 #[test]
379 fn test_registry_get() {
380 let registry = catalog();
381 let binance = registry.get("binance").unwrap();
382 assert_eq!(binance.name, "Binance Spot");
383 assert_eq!(binance.base_url, "https://api.binance.com");
384 }
385
386 #[test]
387 fn test_registry_suggest_typo() {
388 let registry = catalog();
389 assert_eq!(registry.suggest("binace"), Some("binance"));
390 assert_eq!(registry.suggest("gate"), Some("gateio"));
391 assert_eq!(registry.suggest("krakn"), Some("kraken"));
392 assert_eq!(registry.suggest("bybi"), Some("bybit"));
393 assert!(registry.suggest("zzzzzzzzz").is_none());
394 }
395
396 #[test]
397 fn test_an_unknown_venue_with_no_venues_says_how_to_get_them() {
398 let err = VenueRegistry::default()
399 .create_exchange_client_with_http("binance", crate::http::test_transport())
400 .unwrap_err()
401 .to_string();
402 assert!(err.contains("no exchange venues are set up yet"), "{err}");
403 assert!(err.contains("scope venues update"), "{err}");
404 assert!(err.contains("example.yaml.sample"), "{err}");
405 let err = catalog()
407 .create_exchange_client_with_http("binace", crate::http::test_transport())
408 .unwrap_err()
409 .to_string();
410 assert!(err.contains("Did you mean \"binance\"?"), "{err}");
411 }
412
413 #[test]
414 fn test_strsim_distance() {
415 assert_eq!(strsim_distance("binance", "binance"), 0);
416 assert_eq!(strsim_distance("binace", "binance"), 1);
417 assert_eq!(strsim_distance("", "abc"), 3);
418 assert_eq!(strsim_distance("abc", ""), 3);
419 assert_eq!(strsim_distance("abc", "xyz"), 3);
420 assert_eq!(strsim_distance("hello", "world"), 4);
421 }
422
423 #[test]
424 fn test_validate_yaml() {
425 let yaml = r#"
426id: test
427name: Test Exchange
428base_url: https://example.com
429symbol:
430 template: "{base}{quote}"
431 default_quote: USDT
432"#;
433 assert_eq!(VenueRegistry::validate_yaml(yaml).unwrap().id, "test");
434 assert!(VenueRegistry::validate_yaml("not: valid: yaml: [[[").is_err());
435 }
436
437 #[test]
438 fn test_venue_dirs_are_under_the_scope_home() {
439 let user = VenueRegistry::user_venues_dir();
440 assert!(user.ends_with("venues"), "{}", user.display());
441 assert_eq!(VenueRegistry::catalog_dir(), user.join("catalog"));
442 }
443
444 #[test]
445 fn test_every_catalog_venue_has_capabilities_and_formats_a_pair() {
446 let registry = catalog();
447 for id in registry.list() {
448 let desc = registry.get(id).unwrap();
449 assert!(
450 !desc.capability_names().is_empty(),
451 "{id} has no capabilities"
452 );
453 assert!(
454 !desc.format_pair("BTC", None).is_empty(),
455 "{id} formats no pair"
456 );
457 }
458 }
459
460 #[test]
461 fn test_a_user_file_that_hides_catalog_capabilities_says_so() {
462 let catalog_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("venues");
463 let user = tempfile::tempdir().unwrap();
464 let weex = std::fs::read_to_string(catalog_dir.join("weex.yaml")).unwrap();
466 let without_ohlc = &weex[..weex.find("\n ohlc:").unwrap()];
467 std::fs::write(user.path().join("weex.yaml"), without_ohlc).unwrap();
468 std::fs::copy(
470 catalog_dir.join("binance.yaml"),
471 user.path().join("binance.yaml"),
472 )
473 .unwrap();
474
475 let registry = VenueRegistry::load_dirs(&catalog_dir, user.path()).unwrap();
476 assert_eq!(registry.hidden_capabilities("weex"), ["ohlc"]);
477 let hint = registry.override_hint("weex").unwrap();
478 assert!(
479 hint.contains("overrides the catalog's weex, which also has ohlc"),
480 "{hint}"
481 );
482 assert!(hint.contains("weex.yaml"), "{hint}");
483 assert!(registry.hidden_capabilities("binance").is_empty());
484 assert!(registry.override_hint("binance").is_none());
485 assert!(registry.override_hint("kraken").is_none());
487 assert!(registry.override_hint("nope").is_none());
488 assert_eq!(
489 registry.user_file("weex"),
490 Some(user.path().join("weex.yaml").as_path())
491 );
492 }
493
494 #[test]
495 fn test_an_unreadable_venue_file_is_skipped() {
496 let user = tempfile::tempdir().unwrap();
497 std::fs::write(user.path().join("bad.yaml"), [0xff, 0xfe, 0x00, 0x80]).unwrap();
499 let registry = VenueRegistry::load_dirs(&user.path().join("none"), user.path()).unwrap();
500 assert!(registry.is_empty());
501 }
502
503 #[test]
504 #[allow(deprecated)]
505 fn test_the_deprecated_constructor_still_resolves() {
506 assert!(catalog().create_exchange_client("binance").is_ok());
507 assert!(catalog().create_exchange_client("nope").is_err());
508 }
509}