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