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