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    /// The name this exchange knows `instrument` by, or `None` when the instrument is listed on a
195    /// different exchange or `instrument` is not indexed. The two are not told apart.
196    ///
197    /// Unlike [`Self::find_instrument_name_exchange`], which resolves any indexed instrument
198    /// whatever its exchange, this answers "does this exchange trade it?". Use it to decide which
199    /// venue an instrument-keyed event belongs to.
200    pub fn find_own_instrument_name_exchange(
201        &self,
202        instrument: InstrumentIndex,
203    ) -> Option<&InstrumentNameExchange> {
204        self.instruments
205            .get_index(instrument.index())
206            .filter(|listed| listed.exchange.key == self.exchange.key)
207            .map(|listed| &listed.name_exchange)
208    }
209
210    /// Find the [`InstrumentIndex`] of the instrument this exchange reports as `instrument`.
211    ///
212    /// Matches the registered spelling first, and failing that ignores ASCII case, logging the
213    /// case-insensitive match at `debug!` with both spellings. See the
214    /// [type-level note](Self#names-are-matched-ignoring-ascii-case).
215    pub fn find_instrument_index(
216        &self,
217        instrument: &InstrumentNameExchange,
218    ) -> Result<InstrumentIndex, IndexError> {
219        if let Some(index) = self.instrument_names.get(instrument) {
220            return Ok(*index);
221        }
222
223        let index =
224            find_folded(&self.instrument_names_folded, instrument.name()).ok_or_else(|| {
225                IndexError::InstrumentIndex(format!(
226                    "ExecutionInstrumentMap does not contain: {instrument}"
227                ))
228            })?;
229
230        debug!(
231            exchange = %self.exchange.value,
232            reported = %instrument,
233            registered = ?self.find_instrument_name_exchange(index).ok(),
234            "ExecutionInstrumentMap matched an instrument name ignoring case"
235        );
236        Ok(index)
237    }
238}
239
240/// Key each name by its ASCII-lowercased form.
241///
242/// A key two names share is left out, with a `warn!`, so a lookup by it misses rather than picks
243/// one of them. [`IndexedInstruments`] rejects such a pair on one exchange, so this guards only an
244/// invariant.
245fn fold_names<'a, Index>(
246    names: impl Iterator<Item = (&'a str, Index)>,
247) -> FnvHashMap<SmolStr, Index> {
248    let mut folded = FnvHashMap::default();
249    let mut shared = Vec::new();
250    for (name, index) in names {
251        let key = name.to_ascii_lowercase_smolstr();
252        if folded.insert(key.clone(), index).is_some() {
253            shared.push(key);
254        }
255    }
256    shared.sort_unstable();
257    shared.dedup();
258    for key in shared {
259        warn!(
260            name = %key,
261            "ExecutionInstrumentMap holds names that differ only in case - none resolves \
262             ignoring case"
263        );
264        folded.remove(&key);
265    }
266    folded
267}
268
269/// Look `name` up in a map built by [`fold_names`].
270fn find_folded<Index: Copy>(folded: &FnvHashMap<SmolStr, Index>, name: &str) -> Option<Index> {
271    folded.get(&name.to_ascii_lowercase_smolstr()).copied()
272}
273
274pub fn generate_execution_instrument_map(
275    instruments: &IndexedInstruments,
276    exchange: ExchangeId,
277) -> Result<ExecutionInstrumentMap, IndexError> {
278    let exchange_index = instruments
279        .exchanges()
280        .iter()
281        .find_map(|keyed_exchange| (keyed_exchange.value == exchange).then_some(keyed_exchange.key))
282        .ok_or_else(|| {
283            IndexError::ExchangeIndex(format!(
284                "IndexedInstrument does not contain index for: {exchange}"
285            ))
286        })?;
287
288    Ok(ExecutionInstrumentMap::new(
289        Keyed::new(exchange_index, exchange),
290        instruments,
291    ))
292}
293
294#[cfg(test)]
295#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
296mod tests {
297    use super::*;
298    use rustrade_instrument::{exchange::ExchangeId, test_utils};
299
300    fn indexed_instruments() -> IndexedInstruments {
301        let instruments = vec![
302            test_utils::instrument(ExchangeId::BinanceSpot, "BTC", "ETH"),
303            test_utils::instrument(ExchangeId::Coinbase, "BTC", "ETH"),
304            test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT"),
305        ];
306
307        IndexedInstruments::new(instruments)
308    }
309
310    #[test]
311    fn test_find_exchange_id() {
312        let instruments = indexed_instruments();
313        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
314
315        let exchange_id = kraken.find_exchange_id(kraken.exchange.key).unwrap();
316        assert_eq!(exchange_id, ExchangeId::Kraken);
317    }
318
319    #[test]
320    fn test_find_exchange_index() {
321        let instruments = indexed_instruments();
322        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
323
324        let exchange_index = kraken.find_exchange_index(ExchangeId::Kraken).unwrap();
325        assert_eq!(exchange_index, kraken.exchange.key);
326    }
327
328    #[test]
329    fn test_find_exchange_id_error() {
330        let instruments = indexed_instruments();
331        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
332
333        // Create a different exchange index that doesn't match
334        let binance_index = instruments
335            .exchanges()
336            .iter()
337            .find(|ex| ex.value == ExchangeId::BinanceSpot)
338            .map(|ex| ex.key)
339            .unwrap();
340
341        let result = kraken.find_exchange_id(binance_index);
342        assert!(result.is_err());
343        assert!(matches!(result, Err(KeyError::ExchangeId(_))));
344    }
345
346    #[test]
347    fn test_find_exchange_index_error() {
348        let instruments = indexed_instruments();
349        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
350
351        let result = kraken.find_exchange_index(ExchangeId::BinanceSpot);
352        assert!(result.is_err());
353        assert!(matches!(result, Err(IndexError::ExchangeIndex(_))));
354    }
355
356    #[test]
357    fn test_find_asset_name_exchange() {
358        let instruments = indexed_instruments();
359        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
360
361        let usdt = test_utils::asset("USDT");
362        let usdt_index = instruments
363            .find_asset_index(ExchangeId::Kraken, &usdt.name_internal)
364            .unwrap();
365
366        let usdt_exchange_name = kraken.find_asset_name_exchange(usdt_index).unwrap();
367        assert_eq!(usdt_exchange_name, &usdt.name_exchange);
368    }
369
370    #[test]
371    fn test_find_asset_index() {
372        let instruments = indexed_instruments();
373        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
374
375        let usdc = test_utils::asset("USDC");
376        let asset_index = kraken.find_asset_index(&usdc.name_exchange).unwrap();
377
378        let expected_index = instruments
379            .find_asset_index(ExchangeId::Kraken, &usdc.name_internal)
380            .unwrap();
381        assert_eq!(asset_index, expected_index);
382    }
383
384    #[test]
385    fn test_find_asset_index_error() {
386        let instruments = indexed_instruments();
387        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
388
389        let btc = test_utils::asset("BTC");
390        let result = kraken.find_asset_index(&btc.name_exchange);
391        assert!(result.is_err());
392        assert!(matches!(result, Err(IndexError::AssetIndex(_))));
393    }
394
395    #[test]
396    fn test_find_asset_name_exchange_error() {
397        let instruments = indexed_instruments();
398        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
399
400        // Try to find asset with invalid index
401        let invalid_index = AssetIndex::new(999);
402        let result = kraken.find_asset_name_exchange(invalid_index);
403        assert!(result.is_err());
404        assert!(matches!(result, Err(KeyError::AssetKey(_))));
405    }
406
407    #[test]
408    fn test_find_instrument_name_exchange() {
409        let instruments = indexed_instruments();
410        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
411        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
412
413        let usdc_usdt_index = instruments
414            .find_instrument_index(ExchangeId::Kraken, &usdc_usdt.name_internal)
415            .unwrap();
416
417        let usdc_usdt_exchange_name = kraken
418            .find_instrument_name_exchange(usdc_usdt_index)
419            .unwrap();
420
421        assert_eq!(usdc_usdt_exchange_name, &usdc_usdt.name_exchange);
422    }
423
424    #[test]
425    fn test_find_own_instrument_name_exchange() {
426        let instruments = indexed_instruments();
427        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
428        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
429        let own = instruments
430            .find_instrument_index(ExchangeId::Kraken, &usdc_usdt.name_internal)
431            .unwrap();
432        let binance = test_utils::instrument(ExchangeId::BinanceSpot, "BTC", "ETH");
433        let other = instruments
434            .find_instrument_index(ExchangeId::BinanceSpot, &binance.name_internal)
435            .unwrap();
436
437        assert_eq!(
438            kraken.find_own_instrument_name_exchange(own),
439            Some(&usdc_usdt.name_exchange)
440        );
441        // Indexed, so the plain lookup resolves it, but it is not this exchange's to trade.
442        assert!(kraken.find_instrument_name_exchange(other).is_ok());
443        assert_eq!(kraken.find_own_instrument_name_exchange(other), None);
444        assert_eq!(
445            kraken.find_own_instrument_name_exchange(InstrumentIndex::new(99)),
446            None
447        );
448    }
449
450    #[test]
451    fn test_find_instrument_index() {
452        let instruments = indexed_instruments();
453        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
454
455        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
456        let instrument_index = kraken
457            .find_instrument_index(&usdc_usdt.name_exchange)
458            .unwrap();
459
460        let expected_index = instruments
461            .find_instrument_index(ExchangeId::Kraken, &usdc_usdt.name_internal)
462            .unwrap();
463        assert_eq!(instrument_index, expected_index);
464    }
465
466    #[test]
467    fn test_find_instrument_index_error() {
468        let instruments = indexed_instruments();
469        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
470
471        let btc_eth = test_utils::instrument(ExchangeId::Kraken, "BTC", "ETH");
472        let result = kraken.find_instrument_index(&btc_eth.name_exchange);
473        assert!(result.is_err());
474        assert!(matches!(result, Err(IndexError::InstrumentIndex(_))));
475    }
476
477    #[test]
478    fn test_find_instrument_name_exchange_error() {
479        let instruments = indexed_instruments();
480        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
481
482        // Try to find instrument with invalid index
483        let invalid_index = InstrumentIndex::new(999);
484        let result = kraken.find_instrument_name_exchange(invalid_index);
485        assert!(result.is_err());
486        assert!(matches!(result, Err(KeyError::InstrumentKey(_))));
487    }
488
489    #[test]
490    fn test_exchange_assets_iterator() {
491        let instruments = indexed_instruments();
492        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
493
494        let exchange_assets: Vec<&AssetNameExchange> = kraken.exchange_assets().collect();
495
496        // Verify that the iterator returns the expected assets for Kraken
497        let expected_assets = vec!["USDC", "USDT"];
498        for expected in &expected_assets {
499            assert!(
500                exchange_assets
501                    .iter()
502                    .any(|asset| asset.as_ref() == *expected)
503            );
504        }
505    }
506
507    #[test]
508    fn test_exchange_instruments_iterator() {
509        let instruments = indexed_instruments();
510        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
511
512        let exchange_instruments: Vec<&InstrumentNameExchange> =
513            kraken.exchange_instruments().collect();
514
515        // Should have exactly one instrument for Kraken
516        assert_eq!(exchange_instruments.len(), 1);
517
518        // Verify it contains the USDC-USDT instrument
519        let usdc_usdt = test_utils::instrument(ExchangeId::Kraken, "USDC", "USDT");
520        assert!(exchange_instruments.contains(&&usdc_usdt.name_exchange));
521    }
522
523    #[test]
524    fn test_generate_execution_instrument_map_error() {
525        let instruments = indexed_instruments();
526
527        // Try to generate map for exchange not in indexed instruments
528        let result = generate_execution_instrument_map(&instruments, ExchangeId::Bitstamp);
529        assert!(result.is_err());
530        assert!(matches!(result, Err(IndexError::ExchangeIndex(_))));
531    }
532
533    #[test]
534    fn an_asset_name_in_another_case_resolves_to_the_registered_asset() {
535        let instruments = indexed_instruments();
536        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
537
538        let registered = kraken
539            .find_asset_index(&AssetNameExchange::new("USDT"))
540            .unwrap();
541
542        for reported in ["usdt", "Usdt", "uSdT"] {
543            assert_eq!(
544                kraken.find_asset_index(&AssetNameExchange::new(reported)),
545                Ok(registered),
546                "{reported}"
547            );
548        }
549        // The other direction keeps the registered spelling.
550        assert_eq!(
551            kraken.find_asset_name_exchange(registered).unwrap().name(),
552            "USDT"
553        );
554    }
555
556    #[test]
557    fn an_instrument_name_in_another_case_resolves_to_the_registered_instrument() {
558        let instruments = indexed_instruments();
559        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
560
561        let registered = kraken
562            .find_instrument_index(&InstrumentNameExchange::new("USDC_USDT"))
563            .unwrap();
564
565        assert_eq!(
566            kraken.find_instrument_index(&InstrumentNameExchange::new("usdc_usdt")),
567            Ok(registered)
568        );
569        assert_eq!(
570            kraken
571                .find_instrument_name_exchange(registered)
572                .unwrap()
573                .name(),
574            "USDC_USDT"
575        );
576    }
577
578    #[test]
579    fn ignoring_case_does_not_reach_another_exchanges_names() {
580        let instruments = indexed_instruments();
581        let kraken = generate_execution_instrument_map(&instruments, ExchangeId::Kraken).unwrap();
582
583        // `BTC` and `BTC_ETH` are registered, but on Binance and Coinbase only.
584        assert!(matches!(
585            kraken.find_asset_index(&AssetNameExchange::new("btc")),
586            Err(IndexError::AssetIndex(_))
587        ));
588        assert!(matches!(
589            kraken.find_instrument_index(&InstrumentNameExchange::new("btc_eth")),
590            Err(IndexError::InstrumentIndex(_))
591        ));
592    }
593
594    #[test]
595    fn a_folded_name_two_names_share_resolves_to_neither() {
596        let folded = fold_names([("USD", 0), ("usd", 1), ("BTC", 2)].into_iter());
597
598        assert_eq!(find_folded(&folded, "Usd"), None);
599        assert_eq!(find_folded(&folded, "btc"), Some(2));
600    }
601}