Skip to main content

rustrade_execution/
map.rs

1use crate::error::KeyError;
2use fnv::FnvHashMap;
3use rustrade_instrument::{
4    Keyed,
5    asset::{Asset, AssetIndex, ExchangeAsset, name::AssetNameExchange},
6    exchange::{ExchangeId, ExchangeIndex},
7    index::{IndexedInstruments, error::IndexError},
8    instrument::{Instrument, InstrumentIndex, name::InstrumentNameExchange},
9};
10use rustrade_integration::collection::FnvIndexSet;
11use smol_str::{SmolStr, StrExt};
12use tracing::{debug, warn};
13
14/// Indexed instrument map used to associate the internal Barter representation of instruments and
15/// assets with the [`ExecutionClient`](super::client::ExecutionClient) representation.
16///
17/// Similarly, when the execution manager received an [`AccountEvent`](super::AccountEvent)
18/// from the execution API, it needs to determine the internal representation of the associated
19/// assets and instruments.
20///
21/// eg/ `InstrumentNameExchange("XBT-USDT")` <--> `InstrumentIndex(1)` <br>
22/// eg/ `AssetNameExchange("XBT")` <--> `AssetIndex(1)`
23///
24/// # Names are matched ignoring ASCII case
25/// [`Self::find_asset_index`] and [`Self::find_instrument_index`] resolve a name the way it was
26/// registered first, and failing that ignoring ASCII case. Venues and clients do not always spell a
27/// name the way it was registered (`usd` against `USD`), and a name that fails to resolve loses the
28/// event carrying it. Ignoring case cannot merge two registered names, because
29/// [`IndexedInstruments`] rejects two on one exchange that differ only in case. The lookups in the
30/// other direction, from an index to a name, return the registered spelling.
31#[derive(Debug, Clone, Eq, PartialEq)]
32pub struct ExecutionInstrumentMap {
33    /// The exchange associated with this execution map.
34    pub exchange: Keyed<ExchangeIndex, ExchangeId>,
35    /// Collection of assets available by the engine with their
36    /// exchange-specific representations. This holds all indexed assets.
37    pub assets: FnvIndexSet<ExchangeAsset<Asset>>,
38    /// Collection of instruments available by the engine. This holds all
39    /// indexed instruments.
40    pub instruments: FnvIndexSet<Instrument<Keyed<ExchangeIndex, ExchangeId>, AssetIndex>>,
41    /// Map from exchange-specific asset names, as registered, to internal asset indices.
42    ///
43    /// Private, with [`Self::asset_names_folded`], because that index is derived from this map: a
44    /// change to one alone would make the exact and case-insensitive lookups disagree.
45    asset_names: FnvHashMap<AssetNameExchange, AssetIndex>,
46    /// Map from exchange-specific instrument names, as registered, to internal instrument indices.
47    ///
48    /// Private for the reason [`Self::asset_names`] is.
49    instrument_names: FnvHashMap<InstrumentNameExchange, InstrumentIndex>,
50    /// [`Self::asset_names`] keyed by the ASCII-lowercased name, for the case-insensitive lookup.
51    asset_names_folded: FnvHashMap<SmolStr, AssetIndex>,
52    /// [`Self::instrument_names`] keyed by the ASCII-lowercased name, for the case-insensitive
53    /// lookup.
54    instrument_names_folded: FnvHashMap<SmolStr, InstrumentIndex>,
55}
56
57impl ExecutionInstrumentMap {
58    /// Construct a new [`Self`] using the provided indexed assets and instruments.
59    pub fn new(
60        exchange: Keyed<ExchangeIndex, ExchangeId>,
61        instruments: &IndexedInstruments,
62    ) -> Self {
63        let asset_names: FnvHashMap<AssetNameExchange, AssetIndex> = instruments
64            .assets()
65            .iter()
66            .filter_map(|Keyed { key, value }| {
67                (value.exchange == exchange.value)
68                    .then_some((value.asset.name_exchange.clone(), *key))
69            })
70            .collect();
71
72        let assets = instruments
73            .assets()
74            .iter()
75            .map(|Keyed { value, .. }| value.clone())
76            .collect();
77
78        let instrument_names: FnvHashMap<InstrumentNameExchange, InstrumentIndex> = instruments
79            .instruments()
80            .iter()
81            .filter_map(|Keyed { key, value }| {
82                (value.exchange.value == exchange.value)
83                    .then_some((value.name_exchange.clone(), *key))
84            })
85            .collect();
86
87        let asset_names_folded = fold_names(
88            asset_names
89                .iter()
90                .map(|(name, index)| (name.name().as_str(), *index)),
91        );
92        let instrument_names_folded = fold_names(
93            instrument_names
94                .iter()
95                .map(|(name, index)| (name.name().as_str(), *index)),
96        );
97
98        let instruments = instruments
99            .instruments()
100            .iter()
101            .map(|Keyed { value, .. }| value.clone())
102            .collect();
103
104        Self {
105            exchange,
106            asset_names,
107            instrument_names,
108            assets,
109            instruments,
110            asset_names_folded,
111            instrument_names_folded,
112        }
113    }
114
115    /// The names of this exchange's assets, as registered.
116    pub fn exchange_assets(&self) -> impl Iterator<Item = &AssetNameExchange> {
117        self.asset_names.keys()
118    }
119
120    /// The names of this exchange's instruments, as registered.
121    pub fn exchange_instruments(&self) -> impl Iterator<Item = &InstrumentNameExchange> {
122        self.instrument_names.keys()
123    }
124
125    pub fn find_exchange_id(&self, exchange: ExchangeIndex) -> Result<ExchangeId, KeyError> {
126        if self.exchange.key == exchange {
127            Ok(self.exchange.value)
128        } else {
129            Err(KeyError::ExchangeId(format!(
130                "ExecutionInstrumentMap does not contain {exchange}"
131            )))
132        }
133    }
134
135    pub fn find_exchange_index(&self, exchange: ExchangeId) -> Result<ExchangeIndex, IndexError> {
136        if self.exchange.value == exchange {
137            Ok(self.exchange.key)
138        } else {
139            Err(IndexError::ExchangeIndex(format!(
140                "ExecutionInstrumentMap does not contain {exchange}"
141            )))
142        }
143    }
144
145    pub fn find_asset_name_exchange(
146        &self,
147        asset: AssetIndex,
148    ) -> Result<&AssetNameExchange, KeyError> {
149        self.assets
150            .get_index(asset.index())
151            .ok_or_else(|| {
152                KeyError::AssetKey(format!("ExecutionInstrumentMap does not contain: {asset}"))
153            })
154            .map(|asset| &asset.asset.name_exchange)
155    }
156
157    /// Find the [`AssetIndex`] of the asset this exchange reports as `asset`.
158    ///
159    /// Matches the registered spelling first, and failing that ignores ASCII case, logging the
160    /// case-insensitive match at `debug!` with both spellings. See the
161    /// [type-level note](Self#names-are-matched-ignoring-ascii-case).
162    pub fn find_asset_index(&self, asset: &AssetNameExchange) -> Result<AssetIndex, IndexError> {
163        if let Some(index) = self.asset_names.get(asset) {
164            return Ok(*index);
165        }
166
167        let index = find_folded(&self.asset_names_folded, asset.name()).ok_or_else(|| {
168            IndexError::AssetIndex(format!("ExecutionInstrumentMap does not contain: {asset}"))
169        })?;
170
171        debug!(
172            exchange = %self.exchange.value,
173            reported = %asset,
174            registered = ?self.find_asset_name_exchange(index).ok(),
175            "ExecutionInstrumentMap matched an asset name ignoring case"
176        );
177        Ok(index)
178    }
179
180    pub fn find_instrument_name_exchange(
181        &self,
182        instrument: InstrumentIndex,
183    ) -> Result<&InstrumentNameExchange, KeyError> {
184        self.instruments
185            .get_index(instrument.index())
186            .ok_or_else(|| {
187                KeyError::InstrumentKey(format!(
188                    "ExecutionInstrumentMap does not contain: {instrument}"
189                ))
190            })
191            .map(|instrument| &instrument.name_exchange)
192    }
193
194    /// Find the [`InstrumentIndex`] of the instrument this exchange reports as `instrument`.
195    ///
196    /// Matches the registered spelling first, and failing that ignores ASCII case, logging the
197    /// case-insensitive match at `debug!` with both spellings. See the
198    /// [type-level note](Self#names-are-matched-ignoring-ascii-case).
199    pub fn find_instrument_index(
200        &self,
201        instrument: &InstrumentNameExchange,
202    ) -> Result<InstrumentIndex, IndexError> {
203        if let Some(index) = self.instrument_names.get(instrument) {
204            return Ok(*index);
205        }
206
207        let index =
208            find_folded(&self.instrument_names_folded, instrument.name()).ok_or_else(|| {
209                IndexError::InstrumentIndex(format!(
210                    "ExecutionInstrumentMap does not contain: {instrument}"
211                ))
212            })?;
213
214        debug!(
215            exchange = %self.exchange.value,
216            reported = %instrument,
217            registered = ?self.find_instrument_name_exchange(index).ok(),
218            "ExecutionInstrumentMap matched an instrument name ignoring case"
219        );
220        Ok(index)
221    }
222}
223
224/// Key each name by its ASCII-lowercased form.
225///
226/// A key two names share is left out, with a `warn!`, so a lookup by it misses rather than picks
227/// one of them. [`IndexedInstruments`] rejects such a pair on one exchange, so this guards only an
228/// invariant.
229fn fold_names<'a, Index>(
230    names: impl Iterator<Item = (&'a str, Index)>,
231) -> FnvHashMap<SmolStr, Index> {
232    let mut folded = FnvHashMap::default();
233    let mut shared = Vec::new();
234    for (name, index) in names {
235        let key = name.to_ascii_lowercase_smolstr();
236        if folded.insert(key.clone(), index).is_some() {
237            shared.push(key);
238        }
239    }
240    shared.sort_unstable();
241    shared.dedup();
242    for key in shared {
243        warn!(
244            name = %key,
245            "ExecutionInstrumentMap holds names that differ only in case - none resolves \
246             ignoring case"
247        );
248        folded.remove(&key);
249    }
250    folded
251}
252
253/// Look `name` up in a map built by [`fold_names`].
254fn find_folded<Index: Copy>(folded: &FnvHashMap<SmolStr, Index>, name: &str) -> Option<Index> {
255    folded.get(&name.to_ascii_lowercase_smolstr()).copied()
256}
257
258pub fn generate_execution_instrument_map(
259    instruments: &IndexedInstruments,
260    exchange: ExchangeId,
261) -> Result<ExecutionInstrumentMap, IndexError> {
262    let exchange_index = instruments
263        .exchanges()
264        .iter()
265        .find_map(|keyed_exchange| (keyed_exchange.value == exchange).then_some(keyed_exchange.key))
266        .ok_or_else(|| {
267            IndexError::ExchangeIndex(format!(
268                "IndexedInstrument does not contain index for: {exchange}"
269            ))
270        })?;
271
272    Ok(ExecutionInstrumentMap::new(
273        Keyed::new(exchange_index, exchange),
274        instruments,
275    ))
276}
277
278#[cfg(test)]
279#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
280mod tests {
281    use super::*;
282    use rustrade_instrument::{exchange::ExchangeId, test_utils};
283
284    fn indexed_instruments() -> IndexedInstruments {
285        let instruments = vec![
286            test_utils::instrument(ExchangeId::BinanceSpot, "BTC", "ETH"),
287            test_utils::instrument(ExchangeId::Coinbase, "BTC", "ETH"),
288            test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT"),
289        ];
290
291        IndexedInstruments::new(instruments)
292    }
293
294    #[test]
295    fn test_find_exchange_id() {
296        let instruments = indexed_instruments();
297        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
298
299        let exchange_id = kraken.find_exchange_id(kraken.exchange.key).unwrap();
300        assert_eq!(exchange_id, ExchangeId::Kraken);
301    }
302
303    #[test]
304    fn test_find_exchange_index() {
305        let instruments = indexed_instruments();
306        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
307
308        let exchange_index = kraken.find_exchange_index(ExchangeId::Kraken).unwrap();
309        assert_eq!(exchange_index, kraken.exchange.key);
310    }
311
312    #[test]
313    fn test_find_exchange_id_error() {
314        let instruments = indexed_instruments();
315        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
316
317        // Create a different exchange index that doesn't match
318        let binance_index = instruments
319            .exchanges()
320            .iter()
321            .find(|ex| ex.value == ExchangeId::BinanceSpot)
322            .map(|ex| ex.key)
323            .unwrap();
324
325        let result = kraken.find_exchange_id(binance_index);
326        assert!(result.is_err());
327        assert!(matches!(result, Err(KeyError::ExchangeId(_))));
328    }
329
330    #[test]
331    fn test_find_exchange_index_error() {
332        let instruments = indexed_instruments();
333        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
334
335        let result = kraken.find_exchange_index(ExchangeId::BinanceSpot);
336        assert!(result.is_err());
337        assert!(matches!(result, Err(IndexError::ExchangeIndex(_))));
338    }
339
340    #[test]
341    fn test_find_asset_name_exchange() {
342        let instruments = indexed_instruments();
343        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
344
345        let usdt = test_utils::asset("USDT");
346        let usdt_index = instruments
347            .find_asset_index(ExchangeId::Kraken, &usdt.name_internal)
348            .unwrap();
349
350        let usdt_exchange_name = kraken.find_asset_name_exchange(usdt_index).unwrap();
351        assert_eq!(usdt_exchange_name, &usdt.name_exchange);
352    }
353
354    #[test]
355    fn test_find_asset_index() {
356        let instruments = indexed_instruments();
357        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
358
359        let usdc = test_utils::asset("USDC");
360        let asset_index = kraken.find_asset_index(&usdc.name_exchange).unwrap();
361
362        let expected_index = instruments
363            .find_asset_index(ExchangeId::Kraken, &usdc.name_internal)
364            .unwrap();
365        assert_eq!(asset_index, expected_index);
366    }
367
368    #[test]
369    fn test_find_asset_index_error() {
370        let instruments = indexed_instruments();
371        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
372
373        let btc = test_utils::asset("BTC");
374        let result = kraken.find_asset_index(&btc.name_exchange);
375        assert!(result.is_err());
376        assert!(matches!(result, Err(IndexError::AssetIndex(_))));
377    }
378
379    #[test]
380    fn test_find_asset_name_exchange_error() {
381        let instruments = indexed_instruments();
382        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
383
384        // Try to find asset with invalid index
385        let invalid_index = AssetIndex::new(999);
386        let result = kraken.find_asset_name_exchange(invalid_index);
387        assert!(result.is_err());
388        assert!(matches!(result, Err(KeyError::AssetKey(_))));
389    }
390
391    #[test]
392    fn test_find_instrument_name_exchange() {
393        let instruments = indexed_instruments();
394        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
395        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
396
397        let usdc_usdt_index = instruments
398            .find_instrument_index(ExchangeId::Kraken, &usdc_usdt.name_internal)
399            .unwrap();
400
401        let usdc_usdt_exchange_name = kraken
402            .find_instrument_name_exchange(usdc_usdt_index)
403            .unwrap();
404
405        assert_eq!(usdc_usdt_exchange_name, &usdc_usdt.name_exchange);
406    }
407
408    #[test]
409    fn test_find_instrument_index() {
410        let instruments = indexed_instruments();
411        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
412
413        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
414        let instrument_index = kraken
415            .find_instrument_index(&usdc_usdt.name_exchange)
416            .unwrap();
417
418        let expected_index = instruments
419            .find_instrument_index(ExchangeId::Kraken, &usdc_usdt.name_internal)
420            .unwrap();
421        assert_eq!(instrument_index, expected_index);
422    }
423
424    #[test]
425    fn test_find_instrument_index_error() {
426        let instruments = indexed_instruments();
427        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
428
429        let btc_eth = test_utils::instrument(ExchangeId::Kraken, "BTC", "ETH");
430        let result = kraken.find_instrument_index(&btc_eth.name_exchange);
431        assert!(result.is_err());
432        assert!(matches!(result, Err(IndexError::InstrumentIndex(_))));
433    }
434
435    #[test]
436    fn test_find_instrument_name_exchange_error() {
437        let instruments = indexed_instruments();
438        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
439
440        // Try to find instrument with invalid index
441        let invalid_index = InstrumentIndex::new(999);
442        let result = kraken.find_instrument_name_exchange(invalid_index);
443        assert!(result.is_err());
444        assert!(matches!(result, Err(KeyError::InstrumentKey(_))));
445    }
446
447    #[test]
448    fn test_exchange_assets_iterator() {
449        let instruments = indexed_instruments();
450        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
451
452        let exchange_assets: Vec<&AssetNameExchange> = kraken.exchange_assets().collect();
453
454        // Verify that the iterator returns the expected assets for Kraken
455        let expected_assets = vec!["USDC", "USDT"];
456        for expected in &expected_assets {
457            assert!(
458                exchange_assets
459                    .iter()
460                    .any(|asset| asset.as_ref() == *expected)
461            );
462        }
463    }
464
465    #[test]
466    fn test_exchange_instruments_iterator() {
467        let instruments = indexed_instruments();
468        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
469
470        let exchange_instruments: Vec<&InstrumentNameExchange> =
471            kraken.exchange_instruments().collect();
472
473        // Should have exactly one instrument for Kraken
474        assert_eq!(exchange_instruments.len(), 1);
475
476        // Verify it contains the USDC-USDT instrument
477        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
478        assert!(exchange_instruments.contains(&&usdc_usdt.name_exchange));
479    }
480
481    #[test]
482    fn test_generate_execution_instrument_map_error() {
483        let instruments = indexed_instruments();
484
485        // Try to generate map for exchange not in indexed instruments
486        let result = generate_execution_instrument_map(&instruments, ExchangeId::Bitstamp);
487        assert!(result.is_err());
488        assert!(matches!(result, Err(IndexError::ExchangeIndex(_))));
489    }
490
491    #[test]
492    fn an_asset_name_in_another_case_resolves_to_the_registered_asset() {
493        let instruments = indexed_instruments();
494        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
495
496        let registered = kraken
497            .find_asset_index(&AssetNameExchange::new("USDT"))
498            .unwrap();
499
500        for reported in ["usdt", "Usdt", "uSdT"] {
501            assert_eq!(
502                kraken.find_asset_index(&AssetNameExchange::new(reported)),
503                Ok(registered),
504                "{reported}"
505            );
506        }
507        // The other direction keeps the registered spelling.
508        assert_eq!(
509            kraken.find_asset_name_exchange(registered).unwrap().name(),
510            "USDT"
511        );
512    }
513
514    #[test]
515    fn an_instrument_name_in_another_case_resolves_to_the_registered_instrument() {
516        let instruments = indexed_instruments();
517        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
518
519        let registered = kraken
520            .find_instrument_index(&InstrumentNameExchange::new("USDC_USDT"))
521            .unwrap();
522
523        assert_eq!(
524            kraken.find_instrument_index(&InstrumentNameExchange::new("usdc_usdt")),
525            Ok(registered)
526        );
527        assert_eq!(
528            kraken
529                .find_instrument_name_exchange(registered)
530                .unwrap()
531                .name(),
532            "USDC_USDT"
533        );
534    }
535
536    #[test]
537    fn ignoring_case_does_not_reach_another_exchanges_names() {
538        let instruments = indexed_instruments();
539        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
540
541        // `BTC` and `BTC_ETH` are registered, but on Binance and Coinbase only.
542        assert!(matches!(
543            kraken.find_asset_index(&AssetNameExchange::new("btc")),
544            Err(IndexError::AssetIndex(_))
545        ));
546        assert!(matches!(
547            kraken.find_instrument_index(&InstrumentNameExchange::new("btc_eth")),
548            Err(IndexError::InstrumentIndex(_))
549        ));
550    }
551
552    #[test]
553    fn a_folded_name_two_names_share_resolves_to_neither() {
554        let folded = fold_names([("USD", 0), ("usd", 1), ("BTC", 2)].into_iter());
555
556        assert_eq!(find_folded(&folded, "Usd"), None);
557        assert_eq!(find_folded(&folded, "btc"), Some(2));
558    }
559}