Skip to main content

scope/market/
registry.rs

1//! Venue registry: loads and manages venue descriptors.
2//!
3//! No venue is compiled in. The registry reads the venue catalog that
4//! `scope venues update` keeps in `~/.scope-bca/venues/catalog/`
5//! ([`crate::market::catalog`]), then the user's own descriptors in
6//! `~/.scope-bca/venues/*.yaml`, which override a catalog venue with the
7//! same `id`.
8
9use crate::error::{Result, ScopeError};
10use crate::market::descriptor::VenueDescriptor;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// Where a venue's descriptor came from.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum VenueSource {
17    /// The venue catalog, synced by `scope venues update`.
18    Catalog,
19    /// A file of the user's in `~/.scope-bca/venues/`.
20    User,
21}
22
23// =============================================================================
24// VenueRegistry
25// =============================================================================
26
27/// Registry of the venue descriptors: the catalog's, then the user's.
28#[derive(Debug, Clone, Default)]
29pub struct VenueRegistry {
30    venues: HashMap<String, VenueDescriptor>,
31    sources: HashMap<String, VenueSource>,
32    /// The catalog descriptors that a user file replaced, by id.
33    overridden: HashMap<String, VenueDescriptor>,
34    /// The user file each user venue came from, by id.
35    user_files: HashMap<String, PathBuf>,
36}
37
38impl VenueRegistry {
39    /// Load the catalog and the user's venues from the scope home.
40    ///
41    /// User venues with the same `id` as a catalog venue override it, so
42    /// endpoints can be customized without waiting for a catalog update.
43    pub fn load() -> Result<Self> {
44        Self::load_dirs(&Self::catalog_dir(), &Self::user_venues_dir())
45    }
46
47    /// The venues in `catalog`, then those in `user` (which win). A missing
48    /// folder adds nothing; a file that cannot be read or parsed is skipped
49    /// with a warning.
50    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    /// Adds the `*.yaml` / `*.yml` descriptors in `dir` as `source`.
58    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        // A fixed order, so two files with one id resolve the same way.
72        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    /// Where `id`'s descriptor came from, if it is loaded.
109    pub fn source(&self, id: &str) -> Option<VenueSource> {
110        self.sources.get(id).copied()
111    }
112
113    /// The capabilities of the catalog's `id` that the user file replacing
114    /// it lacks: what the override hides (`["ohlc"]`). Empty when no user
115    /// file replaces `id`, or when it has them all.
116    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    /// The user file `id` came from, when it is a user venue.
129    pub fn user_file(&self, id: &str) -> Option<&std::path::Path> {
130        self.user_files.get(id).map(PathBuf::as_path)
131    }
132
133    /// How to get the catalog's `id` back when a user file hides some of
134    /// its capabilities: `None` when nothing is hidden.
135    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    /// List all available venue IDs, sorted alphabetically.
152    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    /// Get a venue descriptor by ID.
159    pub fn get(&self, id: &str) -> Option<&VenueDescriptor> {
160        self.venues.get(id)
161    }
162
163    /// Number of loaded venues.
164    pub fn len(&self) -> usize {
165        self.venues.len()
166    }
167
168    /// Whether the registry is empty.
169    pub fn is_empty(&self) -> bool {
170        self.venues.is_empty()
171    }
172
173    /// Check if a venue ID exists in the registry.
174    pub fn contains(&self, id: &str) -> bool {
175        self.venues.contains_key(id)
176    }
177
178    /// Find the closest venue name to a given input (for typo suggestions).
179    pub fn suggest(&self, input: &str) -> Option<&str> {
180        closest(input, self.list())
181    }
182
183    /// Path to the user venues directory (`~/.scope-bca/venues/`).
184    pub fn user_venues_dir() -> PathBuf {
185        crate::paths::venues_dir()
186    }
187
188    /// Path to the synced catalog (`~/.scope-bca/venues/catalog/`).
189    pub fn catalog_dir() -> PathBuf {
190        crate::paths::venues_dir().join("catalog")
191    }
192
193    /// Create an ExchangeClient for the given venue ID.
194    ///
195    /// An unknown ID is [`ScopeError::InvalidInput`] that lists the registry
196    /// venues and names the closest one. DEX venues are not listed: this
197    /// lookup cannot serve them (see `MarketVenue::resolve`).
198    #[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    /// [`VenueRegistry::create_exchange_client`], sending the client's
210    /// requests through `http`.
211    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    /// Validate a venue descriptor YAML string.
223    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
229/// The unknown-venue error: names the closest of `ids` and lists them all.
230/// Each lookup passes the ids it can serve, so it never suggests a venue it
231/// would reject.
232pub(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
251/// The error for a venue when no exchange venue is loaded: how to get the
252/// catalog, and how to add one by hand.
253pub(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
262/// The id nearest to `input`, ignoring case, within one edit for each
263/// [`SUGGEST_LENGTH_PER_EDIT`] characters of the longer name.
264fn 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        // A fixed limit suggested `okx` for `weex` (#69). Scale it to the name.
269        .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
276/// A suggestion may differ by one edit for each this many characters of the
277/// longer name.
278const SUGGEST_LENGTH_PER_EDIT: usize = 3;
279
280/// Simple Levenshtein distance for typo suggestions.
281fn 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    /// The repo's catalog, as a synced one.
314    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        // With venues, the usual suggestion.
406        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        // The user's weex with no ohlc, as written before the catalog had one.
465        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        // A full copy of binance hides nothing.
469        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        // A catalog venue nobody overrides, and an unknown one.
486        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        // Not UTF-8: reading it as text fails, on every platform.
498        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}