1use 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
32pub const PERP_SUFFIX: &str = "-PERP";
34
35pub const SPOT_SUFFIX: &str = "-SPOT";
37
38#[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#[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#[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#[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#[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 #[must_use]
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 #[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 #[must_use]
154 pub const fn venue(&self) -> Venue {
155 self.venue
156 }
157
158 #[must_use]
160 pub const fn settlement_currency(&self) -> Currency {
161 self.settlement_currency
162 }
163
164 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 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 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 #[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 #[must_use]
220 pub fn all_market_indices(&self) -> Vec<i16> {
221 self.by_index.iter().map(|e| *e.key()).collect()
222 }
223
224 #[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 #[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 pub fn clear(&self) {
246 self.by_index.clear();
247 self.by_id.clear();
248 self.by_raw_symbol.clear();
249 }
250
251 #[must_use]
253 pub fn len(&self) -> usize {
254 self.by_index.len()
255 }
256
257 #[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 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 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 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 #[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}