1use 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
35pub const PERP_SUFFIX: &str = "-PERP";
37
38pub const SPOT_SUFFIX: &str = "-SPOT";
40
41#[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#[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#[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#[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#[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 #[must_use]
136 pub fn new() -> Self {
137 Self::default()
138 }
139
140 #[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 #[must_use]
157 pub const fn venue(&self) -> Venue {
158 self.venue
159 }
160
161 #[must_use]
163 pub const fn settlement_currency(&self) -> Currency {
164 self.settlement_currency
165 }
166
167 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 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 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 #[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 #[must_use]
223 pub fn all_market_indices(&self) -> Vec<i64> {
224 self.by_index.iter().map(|e| *e.key()).collect()
225 }
226
227 #[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 #[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 pub fn clear(&self) {
249 self.by_index.clear();
250 self.by_id.clear();
251 self.by_raw_symbol.clear();
252 }
253
254 #[must_use]
256 pub fn len(&self) -> usize {
257 self.by_index.len()
258 }
259
260 #[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 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 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 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 #[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}