Skip to main content

nautilus_lighter/common/
symbol.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Bidirectional mapping between Nautilus `InstrumentId` and Lighter `market_index`.
17//!
18//! Lighter identifies markets by a 16-bit `market_index` (perpetuals occupy
19//! `0..=254`, spot markets `2048..=4094`). The mapping is populated at
20//! bootstrap from `GET /api/v1/orderBookDetails` and subsequently consulted
21//! on every WebSocket frame and outbound transaction.
22
23use dashmap::DashMap;
24use nautilus_model::identifiers::{InstrumentId, Symbol};
25use ustr::Ustr;
26
27use super::{consts::LIGHTER_VENUE, enums::LighterProductType};
28
29/// Suffix applied to perpetual instrument symbols on the Nautilus side.
30pub const PERP_SUFFIX: &str = "-PERP";
31
32/// Suffix applied to spot instrument symbols on the Nautilus side.
33pub const SPOT_SUFFIX: &str = "-SPOT";
34
35/// Builds a Nautilus [`InstrumentId`] from a venue symbol and product type.
36///
37/// The venue symbol is upper-cased and combined with the product suffix
38/// (`-PERP` or `-SPOT`) before being qualified by the Lighter venue.
39#[must_use]
40pub fn format_instrument_id(venue_symbol: &str, product_type: LighterProductType) -> InstrumentId {
41    let suffix = product_suffix(product_type);
42    let trimmed = venue_symbol.trim();
43    let upper = trimmed.to_ascii_uppercase();
44    let symbol = format!("{upper}{suffix}");
45    InstrumentId::new(Symbol::from_str_unchecked(&symbol), *LIGHTER_VENUE)
46}
47
48/// Returns the venue-native symbol for an instrument id by stripping any
49/// known product suffix. Returns the raw symbol unchanged when no suffix
50/// is present.
51#[must_use]
52pub fn format_venue_symbol(instrument_id: &InstrumentId) -> &str {
53    let s = instrument_id.symbol.as_str();
54    s.strip_suffix(PERP_SUFFIX)
55        .or_else(|| s.strip_suffix(SPOT_SUFFIX))
56        .unwrap_or(s)
57}
58
59/// Returns the [`LighterProductType`] implied by the instrument id's suffix,
60/// or `None` if the symbol carries neither suffix.
61#[must_use]
62pub fn product_type_from_instrument_id(instrument_id: &InstrumentId) -> Option<LighterProductType> {
63    let s = instrument_id.symbol.as_str();
64    if s.ends_with(PERP_SUFFIX) {
65        Some(LighterProductType::Perp)
66    } else if s.ends_with(SPOT_SUFFIX) {
67        Some(LighterProductType::Spot)
68    } else {
69        None
70    }
71}
72
73const fn product_suffix(product_type: LighterProductType) -> &'static str {
74    match product_type {
75        LighterProductType::Perp => PERP_SUFFIX,
76        LighterProductType::Spot => SPOT_SUFFIX,
77    }
78}
79
80fn canonical_symbol_key(venue_symbol: &str) -> Ustr {
81    Ustr::from(&venue_symbol.trim().to_ascii_uppercase())
82}
83
84/// Registry mapping `market_index` to `InstrumentId` and back.
85///
86/// Indexed for `O(1)` lookup by all three keys the adapter switches between:
87/// the venue's numeric `market_index` (used in transaction encoding and
88/// WebSocket subscriptions), the Nautilus [`InstrumentId`] (used by the
89/// engine), and the raw venue symbol scoped by product type (used when
90/// parsing REST list responses). Designed to be shared across the HTTP and
91/// WebSocket clients via `Arc`.
92///
93/// The registry is intended for write-once bootstrap followed by read-only
94/// consumption: each individual lookup is lock-free, but a single `insert`
95/// is not transactional across the three indexes. Rare write events such
96/// as relists must be coordinated by the caller (e.g. quiesce consumers
97/// before reinserting) to avoid concurrent readers observing partial
98/// state.
99#[derive(Debug, Default)]
100pub struct MarketRegistry {
101    by_index: DashMap<i16, InstrumentId>,
102    by_id: DashMap<InstrumentId, i16>,
103    by_raw_symbol: DashMap<(Ustr, LighterProductType), InstrumentId>,
104}
105
106impl MarketRegistry {
107    /// Returns a new empty registry.
108    #[must_use]
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Registers a market and returns the resulting [`InstrumentId`].
114    ///
115    /// Re-inserting the same `market_index` overwrites the previous mapping;
116    /// callers may use this to handle venue rename or relisting events. Stale
117    /// entries in the inverse indexes are evicted before the new mapping is
118    /// installed so all three lookups stay consistent.
119    pub fn insert(
120        &self,
121        market_index: i16,
122        venue_symbol: &str,
123        product_type: LighterProductType,
124    ) -> InstrumentId {
125        let instrument_id = format_instrument_id(venue_symbol, product_type);
126        let canonical = canonical_symbol_key(venue_symbol);
127
128        // Evict any prior mapping that shared this market_index but pointed
129        // at a different InstrumentId.
130        if let Some((_, prior_id)) = self.by_index.remove(&market_index)
131            && prior_id != instrument_id
132        {
133            self.by_id
134                .remove_if(&prior_id, |_, idx| *idx == market_index);
135            if let Some(prior_pt) = product_type_from_instrument_id(&prior_id) {
136                let prior_key = Ustr::from(format_venue_symbol(&prior_id));
137                self.by_raw_symbol
138                    .remove_if(&(prior_key, prior_pt), |_, id| *id == prior_id);
139            }
140        }
141
142        // Evict any prior mapping that shared this InstrumentId but pointed
143        // at a different market_index.
144        if let Some((_, prior_index)) = self.by_id.remove(&instrument_id)
145            && prior_index != market_index
146        {
147            self.by_index
148                .remove_if(&prior_index, |_, id| *id == instrument_id);
149        }
150
151        self.by_index.insert(market_index, instrument_id);
152        self.by_id.insert(instrument_id, market_index);
153        self.by_raw_symbol
154            .insert((canonical, product_type), instrument_id);
155        instrument_id
156    }
157
158    /// Returns the [`InstrumentId`] for a given `market_index`.
159    #[must_use]
160    pub fn instrument_id(&self, market_index: i16) -> Option<InstrumentId> {
161        self.by_index.get(&market_index).map(|e| *e)
162    }
163
164    /// Returns every registered `market_index`.
165    ///
166    /// Callers iterating across all venue markets (e.g. the mass-status
167    /// reconciliation path) use this to bound the per-market REST fan-out.
168    #[must_use]
169    pub fn all_market_indices(&self) -> Vec<i16> {
170        self.by_index.iter().map(|e| *e.key()).collect()
171    }
172
173    /// Returns the venue `market_index` for a given [`InstrumentId`].
174    #[must_use]
175    pub fn market_index(&self, instrument_id: &InstrumentId) -> Option<i16> {
176        self.by_id.get(instrument_id).map(|e| *e)
177    }
178
179    /// Returns the [`InstrumentId`] for a raw venue symbol scoped by product.
180    ///
181    /// The raw symbol on its own is ambiguous when the venue lists the same
182    /// asset on both perpetual and spot; the product discriminant resolves it.
183    #[must_use]
184    pub fn instrument_id_by_symbol(
185        &self,
186        venue_symbol: &str,
187        product_type: LighterProductType,
188    ) -> Option<InstrumentId> {
189        let key = canonical_symbol_key(venue_symbol);
190        self.by_raw_symbol.get(&(key, product_type)).map(|e| *e)
191    }
192
193    /// Removes all registered markets.
194    pub fn clear(&self) {
195        self.by_index.clear();
196        self.by_id.clear();
197        self.by_raw_symbol.clear();
198    }
199
200    /// Returns the number of registered markets.
201    #[must_use]
202    pub fn len(&self) -> usize {
203        self.by_index.len()
204    }
205
206    /// Returns whether the registry has no entries.
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        self.by_index.is_empty()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use proptest::prelude::*;
216    use rstest::rstest;
217
218    use super::*;
219
220    #[rstest]
221    fn format_instrument_id_perp_uppercases_and_suffixes() {
222        let id = format_instrument_id("eth", LighterProductType::Perp);
223        assert_eq!(id.symbol.as_str(), "ETH-PERP");
224        assert_eq!(id.venue, *LIGHTER_VENUE);
225    }
226
227    #[rstest]
228    fn format_instrument_id_spot_uppercases_and_suffixes() {
229        let id = format_instrument_id("usdc", LighterProductType::Spot);
230        assert_eq!(id.symbol.as_str(), "USDC-SPOT");
231    }
232
233    #[rstest]
234    #[case::leading("  ETH", "ETH-PERP")]
235    #[case::trailing("ETH  ", "ETH-PERP")]
236    #[case::both_sides("  eth  ", "ETH-PERP")]
237    #[case::tab("\tBTC\n", "BTC-PERP")]
238    fn format_instrument_id_trims_whitespace(#[case] input: &str, #[case] expected_symbol: &str) {
239        let id = format_instrument_id(input, LighterProductType::Perp);
240        assert_eq!(id.symbol.as_str(), expected_symbol);
241        assert_eq!(id.venue, *LIGHTER_VENUE);
242    }
243
244    #[rstest]
245    fn format_venue_symbol_strips_known_suffixes() {
246        let perp = format_instrument_id("BTC", LighterProductType::Perp);
247        assert_eq!(format_venue_symbol(&perp), "BTC");
248        let spot = format_instrument_id("SOL", LighterProductType::Spot);
249        assert_eq!(format_venue_symbol(&spot), "SOL");
250    }
251
252    #[rstest]
253    fn format_venue_symbol_returns_unsuffixed_unchanged() {
254        let id = InstrumentId::new(Symbol::from_str_unchecked("ETH"), *LIGHTER_VENUE);
255        assert_eq!(format_venue_symbol(&id), "ETH");
256    }
257
258    #[rstest]
259    fn product_type_from_instrument_id_dispatches_on_suffix() {
260        let perp = format_instrument_id("BTC", LighterProductType::Perp);
261        let spot = format_instrument_id("BTC", LighterProductType::Spot);
262        let none = InstrumentId::new(Symbol::from_str_unchecked("BTC"), *LIGHTER_VENUE);
263        assert_eq!(
264            product_type_from_instrument_id(&perp),
265            Some(LighterProductType::Perp),
266        );
267        assert_eq!(
268            product_type_from_instrument_id(&spot),
269            Some(LighterProductType::Spot),
270        );
271        assert_eq!(product_type_from_instrument_id(&none), None);
272    }
273
274    #[rstest]
275    fn registry_round_trip_by_all_keys() {
276        let registry = MarketRegistry::new();
277        let id = registry.insert(0, "ETH", LighterProductType::Perp);
278
279        assert_eq!(registry.instrument_id(0), Some(id));
280        assert_eq!(registry.market_index(&id), Some(0));
281        assert_eq!(
282            registry.instrument_id_by_symbol("ETH", LighterProductType::Perp),
283            Some(id),
284        );
285        assert_eq!(registry.len(), 1);
286        assert!(!registry.is_empty());
287    }
288
289    #[rstest]
290    fn registry_idempotent_reinsert_is_stable() {
291        let registry = MarketRegistry::new();
292        let first = registry.insert(0, "ETH", LighterProductType::Perp);
293        let second = registry.insert(0, "ETH", LighterProductType::Perp);
294
295        assert_eq!(first, second);
296        assert_eq!(registry.instrument_id(0), Some(first));
297        assert_eq!(registry.market_index(&first), Some(0));
298        assert_eq!(
299            registry.instrument_id_by_symbol("ETH", LighterProductType::Perp),
300            Some(first),
301        );
302        assert_eq!(registry.len(), 1);
303    }
304
305    #[rstest]
306    fn registry_disambiguates_perp_and_spot_for_same_symbol() {
307        let registry = MarketRegistry::new();
308        let perp = registry.insert(1, "BTC", LighterProductType::Perp);
309        let spot = registry.insert(2049, "BTC", LighterProductType::Spot);
310
311        assert_ne!(perp, spot);
312        assert_eq!(
313            registry.instrument_id_by_symbol("BTC", LighterProductType::Perp),
314            Some(perp),
315        );
316        assert_eq!(
317            registry.instrument_id_by_symbol("BTC", LighterProductType::Spot),
318            Some(spot),
319        );
320        assert_eq!(registry.market_index(&perp), Some(1));
321        assert_eq!(registry.market_index(&spot), Some(2049));
322    }
323
324    #[rstest]
325    fn registry_insert_overwrites_existing_index() {
326        let registry = MarketRegistry::new();
327        let old_id = registry.insert(5, "OLD", LighterProductType::Perp);
328        let new_id = registry.insert(5, "NEW", LighterProductType::Perp);
329
330        assert_eq!(registry.instrument_id(5), Some(new_id));
331        assert_eq!(
332            registry.instrument_id_by_symbol("NEW", LighterProductType::Perp),
333            Some(new_id),
334        );
335
336        // Stale entries for the displaced InstrumentId must not survive.
337        assert_eq!(registry.market_index(&old_id), None);
338        assert_eq!(
339            registry.instrument_id_by_symbol("OLD", LighterProductType::Perp),
340            None,
341        );
342        assert_eq!(registry.len(), 1);
343    }
344
345    #[rstest]
346    fn registry_insert_canonicalizes_symbol_case() {
347        let registry = MarketRegistry::new();
348        let lower = registry.insert(5, "eth", LighterProductType::Perp);
349        let _new_id = registry.insert(5, "NEW", LighterProductType::Perp);
350
351        // Lookup with the lowercased form must miss because the prior entry
352        // was evicted from the canonical (uppercased) by_raw_symbol slot.
353        assert_eq!(
354            registry.instrument_id_by_symbol("eth", LighterProductType::Perp),
355            None,
356        );
357        assert_eq!(
358            registry.instrument_id_by_symbol("ETH", LighterProductType::Perp),
359            None,
360        );
361        assert_eq!(registry.market_index(&lower), None);
362        assert_eq!(registry.len(), 1);
363    }
364
365    #[rstest]
366    fn registry_lookup_is_case_insensitive() {
367        let registry = MarketRegistry::new();
368        let id = registry.insert(0, "btc", LighterProductType::Perp);
369        assert_eq!(
370            registry.instrument_id_by_symbol("BTC", LighterProductType::Perp),
371            Some(id),
372        );
373        assert_eq!(
374            registry.instrument_id_by_symbol("  btc ", LighterProductType::Perp),
375            Some(id),
376        );
377    }
378
379    #[rstest]
380    fn registry_insert_remaps_symbol_to_new_index() {
381        let registry = MarketRegistry::new();
382        let original = registry.insert(0, "ETH", LighterProductType::Perp);
383        let remapped = registry.insert(7, "ETH", LighterProductType::Perp);
384
385        assert_eq!(original, remapped);
386        assert_eq!(registry.market_index(&original), Some(7));
387        assert_eq!(registry.instrument_id(7), Some(original));
388
389        // The previous market_index slot must no longer point at this id.
390        assert_eq!(registry.instrument_id(0), None);
391        assert_eq!(registry.len(), 1);
392    }
393
394    #[rstest]
395    fn registry_clear_empties_all_indices() {
396        let registry = MarketRegistry::new();
397        registry.insert(0, "ETH", LighterProductType::Perp);
398        registry.insert(2048, "USDC", LighterProductType::Spot);
399        assert_eq!(registry.len(), 2);
400
401        registry.clear();
402        assert!(registry.is_empty());
403        assert_eq!(registry.instrument_id(0), None);
404    }
405
406    #[rstest]
407    fn registry_lookup_misses_return_none() {
408        let registry = MarketRegistry::new();
409        let unknown = InstrumentId::new(Symbol::from_str_unchecked("XYZ-PERP"), *LIGHTER_VENUE);
410        assert_eq!(registry.instrument_id(99), None);
411        assert_eq!(registry.market_index(&unknown), None);
412        assert_eq!(
413            registry.instrument_id_by_symbol("XYZ", LighterProductType::Perp),
414            None,
415        );
416    }
417
418    proptest! {
419        /// Round-tripping a canonical (uppercase) venue symbol through
420        /// `format_instrument_id` and `format_venue_symbol` returns the
421        /// same string, and the suffix encodes the original product type.
422        #[rstest]
423        fn prop_instrument_id_roundtrips_through_venue_symbol(
424            symbol in "[A-Z][A-Z0-9]{0,7}",
425            is_perp in any::<bool>(),
426        ) {
427            let product = if is_perp {
428                LighterProductType::Perp
429            } else {
430                LighterProductType::Spot
431            };
432            let id = format_instrument_id(&symbol, product);
433            prop_assert_eq!(format_venue_symbol(&id), symbol.as_str());
434            prop_assert_eq!(product_type_from_instrument_id(&id), Some(product));
435        }
436    }
437}