1use dashmap::DashMap;
24use nautilus_model::identifiers::{InstrumentId, Symbol};
25use ustr::Ustr;
26
27use super::{consts::LIGHTER_VENUE, enums::LighterProductType};
28
29pub const PERP_SUFFIX: &str = "-PERP";
31
32pub const SPOT_SUFFIX: &str = "-SPOT";
34
35#[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#[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#[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#[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 #[must_use]
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 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 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 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 #[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 #[must_use]
169 pub fn all_market_indices(&self) -> Vec<i16> {
170 self.by_index.iter().map(|e| *e.key()).collect()
171 }
172
173 #[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 #[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 pub fn clear(&self) {
195 self.by_index.clear();
196 self.by_id.clear();
197 self.by_raw_symbol.clear();
198 }
199
200 #[must_use]
202 pub fn len(&self) -> usize {
203 self.by_index.len()
204 }
205
206 #[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 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 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 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 #[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}