1use nautilus_core::{DurationNanos, Params, UnixNanos};
17use nautilus_model::{
18 identifiers::{InstrumentId, Symbol},
19 instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
20 types::{Currency, Price, Quantity},
21};
22use rust_decimal::Decimal;
23
24use super::{models::TardisInstrumentInfo, parse::parse_settlement_currency};
25use crate::common::parse::parse_option_kind;
26
27pub(crate) fn get_currency(code: &str) -> Currency {
32 Currency::get_or_create_crypto(code)
33}
34
35fn build_info_params(info: &TardisInstrumentInfo) -> Option<Params> {
37 match serde_json::to_value(info) {
38 Ok(value) => match serde_json::from_value(value) {
39 Ok(params) => Some(params),
40 Err(e) => {
41 log::warn!("Failed to convert instrument info to Params: {e}");
42 None
43 }
44 },
45 Err(e) => {
46 log::warn!("Failed to serialize instrument info: {e}");
47 None
48 }
49 }
50}
51
52#[expect(clippy::too_many_arguments)]
58#[must_use]
59pub fn create_currency_pair(
60 info: &TardisInstrumentInfo,
61 instrument_id: InstrumentId,
62 raw_symbol: Symbol,
63 price_increment: Price,
64 size_increment: Quantity,
65 multiplier: Option<Quantity>,
66 margin_init: Decimal,
67 margin_maint: Decimal,
68 ts_event: UnixNanos,
69 ts_init: UnixNanos,
70) -> InstrumentAny {
71 InstrumentAny::CurrencyPair(
72 CurrencyPair::builder()
73 .instrument_id(instrument_id)
74 .raw_symbol(raw_symbol)
75 .base_currency(get_currency(info.base_currency.to_uppercase().as_str()))
76 .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
77 .price_precision(price_increment.precision)
78 .size_precision(size_increment.precision)
79 .price_increment(price_increment)
80 .size_increment(size_increment)
81 .maybe_multiplier(multiplier)
82 .lot_size(size_increment)
83 .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
84 .margin_init(margin_init)
85 .margin_maint(margin_maint)
86 .maybe_info(build_info_params(info))
87 .ts_event(ts_event)
88 .ts_init(ts_init)
89 .build()
90 .unwrap(),
91 )
92}
93
94#[expect(clippy::too_many_arguments)]
100#[must_use]
101pub fn create_crypto_perpetual(
102 info: &TardisInstrumentInfo,
103 instrument_id: InstrumentId,
104 raw_symbol: Symbol,
105 price_increment: Price,
106 size_increment: Quantity,
107 multiplier: Option<Quantity>,
108 margin_init: Decimal,
109 margin_maint: Decimal,
110 ts_event: UnixNanos,
111 ts_init: UnixNanos,
112) -> InstrumentAny {
113 let is_inverse = info.inverse.unwrap_or(false);
114
115 InstrumentAny::CryptoPerpetual(
116 CryptoPerpetual::builder()
117 .instrument_id(instrument_id)
118 .raw_symbol(raw_symbol)
119 .base_currency(get_currency(info.base_currency.to_uppercase().as_str()))
120 .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
121 .settlement_currency(get_currency(
122 parse_settlement_currency(info, is_inverse).as_str(),
123 ))
124 .is_inverse(is_inverse)
125 .price_precision(price_increment.precision)
126 .size_precision(size_increment.precision)
127 .price_increment(price_increment)
128 .size_increment(size_increment)
129 .maybe_multiplier(multiplier)
130 .lot_size(size_increment)
131 .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
132 .margin_init(margin_init)
133 .margin_maint(margin_maint)
134 .maybe_info(build_info_params(info))
135 .ts_event(ts_event)
136 .ts_init(ts_init)
137 .build()
138 .unwrap(),
139 )
140}
141
142#[expect(clippy::too_many_arguments)]
148#[must_use]
149pub fn create_crypto_future(
150 info: &TardisInstrumentInfo,
151 instrument_id: InstrumentId,
152 raw_symbol: Symbol,
153 activation: UnixNanos,
154 expiration: UnixNanos,
155 price_increment: Price,
156 size_increment: Quantity,
157 multiplier: Option<Quantity>,
158 margin_init: Decimal,
159 margin_maint: Decimal,
160 ts_event: UnixNanos,
161 ts_init: UnixNanos,
162) -> InstrumentAny {
163 let is_inverse = info.inverse.unwrap_or(false);
164
165 InstrumentAny::CryptoFuture(
166 CryptoFuture::builder()
167 .instrument_id(instrument_id)
168 .raw_symbol(raw_symbol)
169 .underlying(get_currency(info.base_currency.to_uppercase().as_str()))
170 .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
171 .settlement_currency(get_currency(
172 parse_settlement_currency(info, is_inverse).as_str(),
173 ))
174 .is_inverse(is_inverse)
175 .activation_ns(activation)
176 .expiration_ns(expiration)
177 .price_precision(price_increment.precision)
178 .size_precision(size_increment.precision)
179 .price_increment(price_increment)
180 .size_increment(size_increment)
181 .maybe_multiplier(multiplier)
182 .lot_size(size_increment)
183 .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
184 .margin_init(margin_init)
185 .margin_maint(margin_maint)
186 .maybe_info(build_info_params(info))
187 .ts_event(ts_event)
188 .ts_init(ts_init)
189 .build()
190 .unwrap(),
191 )
192}
193
194#[expect(clippy::too_many_arguments)]
195pub fn create_crypto_option(
205 info: &TardisInstrumentInfo,
206 instrument_id: InstrumentId,
207 raw_symbol: Symbol,
208 activation: UnixNanos,
209 expiration: UnixNanos,
210 price_increment: Price,
211 size_increment: Quantity,
212 multiplier: Option<Quantity>,
213 margin_init: Decimal,
214 margin_maint: Decimal,
215 ts_event: UnixNanos,
216 ts_init: UnixNanos,
217) -> anyhow::Result<InstrumentAny> {
218 let is_inverse = info.inverse.unwrap_or(false);
219
220 let option_type = info.option_type.ok_or_else(|| {
221 anyhow::anyhow!(
222 "CryptoOption missing `option_type` field for instrument: {}",
223 info.id
224 )
225 })?;
226
227 let strike_price = info.strike_price.ok_or_else(|| {
228 anyhow::anyhow!(
229 "CryptoOption missing `strike_price` field for instrument: {}",
230 info.id
231 )
232 })?;
233
234 Ok(InstrumentAny::CryptoOption(
235 CryptoOption::builder()
236 .instrument_id(instrument_id)
237 .raw_symbol(raw_symbol)
238 .underlying(get_currency(info.base_currency.to_uppercase().as_str()))
239 .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
240 .settlement_currency(get_currency(
241 parse_settlement_currency(info, is_inverse).as_str(),
242 ))
243 .is_inverse(is_inverse)
244 .option_kind(parse_option_kind(option_type))
245 .strike_price(Price::new(strike_price, price_increment.precision))
246 .activation_ns(activation)
247 .expiration_ns(expiration)
248 .price_precision(price_increment.precision)
249 .size_precision(size_increment.precision)
250 .price_increment(price_increment)
251 .size_increment(size_increment)
252 .maybe_multiplier(multiplier)
253 .lot_size(size_increment)
254 .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
255 .margin_init(margin_init)
256 .margin_maint(margin_maint)
257 .maybe_info(build_info_params(info))
258 .ts_event(ts_event)
259 .ts_init(ts_init)
260 .build()
261 .unwrap(),
262 ))
263}
264
265pub fn is_available(
267 info: &TardisInstrumentInfo,
268 start: Option<UnixNanos>,
269 end: Option<UnixNanos>,
270 available_offset: Option<DurationNanos>,
271 effective: Option<UnixNanos>,
272) -> bool {
273 let available_since =
274 UnixNanos::from(info.available_since) + available_offset.unwrap_or_default();
275 let available_to = info.available_to.map_or(UnixNanos::max(), UnixNanos::from);
276
277 if let Some(effective_date) = effective {
278 if available_since > effective_date || available_to <= effective_date {
280 return false;
281 }
282
283 if start.is_some_and(|s| effective_date < s) || end.is_some_and(|e| effective_date > e) {
285 return false;
286 }
287 } else {
288 if start.is_some_and(|s| available_to < s) || end.is_some_and(|e| available_since > e) {
290 return false;
291 }
292 }
293
294 true
295}
296
297#[cfg(test)]
298mod tests {
299 use rstest::rstest;
300
301 use super::*;
302 use crate::common::testing::load_test_json;
303
304 fn create_test_instrument(
305 available_since: u64,
306 available_to: Option<u64>,
307 ) -> TardisInstrumentInfo {
308 let json_data = load_test_json("instrument_spot.json");
309 let mut info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
310 info.available_since = UnixNanos::from(available_since).to_datetime_utc();
311 info.available_to = available_to.map(|a| UnixNanos::from(a).to_datetime_utc());
312 info
313 }
314
315 #[rstest]
316 #[case::no_constraints(None, None, None, None, true)]
317 #[case::within_start_end(Some(100), Some(300), None, None, true)]
318 #[case::before_start(Some(200), Some(300), None, None, true)]
319 #[case::after_end(Some(100), Some(150), None, None, true)]
320 #[case::with_offset_within_range(Some(200), Some(300), Some(50), None, true)]
321 #[case::with_offset_adjusted_within_range(Some(150), Some(300), Some(50), None, true)]
322 #[case::effective_within_availability(None, None, None, Some(150), true)]
323 #[case::effective_before_availability(None, None, None, Some(50), false)]
324 #[case::effective_after_availability(None, None, None, Some(250), false)]
325 #[case::effective_within_start_end(Some(100), Some(200), None, Some(150), true)]
326 #[case::effective_before_start(Some(150), Some(200), None, Some(120), false)]
327 #[case::effective_after_end(Some(100), Some(150), None, Some(180), false)]
328 #[case::effective_equals_available_since(None, None, None, Some(100), true)]
329 #[case::effective_equals_available_since_with_offset(None, None, Some(10), Some(110), true)]
330 #[case::effective_equals_available_to(None, None, None, Some(200), false)]
331 fn test_is_available(
332 #[case] start: Option<u64>,
333 #[case] end: Option<u64>,
334 #[case] available_offset: Option<u64>,
335 #[case] effective: Option<u64>,
336 #[case] expected: bool,
337 ) {
338 let info = create_test_instrument(100, Some(200));
340
341 let start_nanos = start.map(UnixNanos::from);
343 let end_nanos = end.map(UnixNanos::from);
344 let offset_nanos = available_offset.map(DurationNanos::new);
345 let effective_nanos = effective.map(UnixNanos::from);
346
347 let result = is_available(&info, start_nanos, end_nanos, offset_nanos, effective_nanos);
349
350 assert_eq!(
351 result, expected,
352 "Test failed with start={start:?}, end={end:?}, offset={available_offset:?}, effective={effective:?}"
353 );
354 }
355
356 #[rstest]
357 #[case::before_rename(199, "XBT/USDT")]
358 #[case::at_rename(200, "BTC/USDT")]
359 #[case::after_rename(201, "BTC/USDT")]
360 fn test_renamed_instrument_availability(#[case] effective: u64, #[case] expected: &str) {
361 let mut previous = create_test_instrument(100, Some(200));
362 previous.id = "XBT/USDT".into();
363 let mut current = create_test_instrument(200, None);
364 current.id = "BTC/USDT".into();
365 let available = [previous, current]
366 .into_iter()
367 .filter(|info| is_available(info, None, None, None, Some(UnixNanos::from(effective))))
368 .map(|info| info.id.to_string())
369 .collect::<Vec<_>>();
370
371 assert_eq!(available, vec![expected]);
372 }
373
374 #[rstest]
375 fn test_infinite_available_to() {
376 let info = create_test_instrument(100, None);
378
379 assert!(is_available(
381 &info,
382 None,
383 Some(UnixNanos::from(1000000)),
384 None,
385 None
386 ));
387
388 assert!(is_available(
390 &info,
391 None,
392 None,
393 None,
394 Some(UnixNanos::from(101))
395 ));
396
397 assert!(is_available(
398 &info,
399 None,
400 None,
401 None,
402 Some(UnixNanos::from(100))
403 ));
404 assert!(!is_available(
405 &info,
406 None,
407 None,
408 None,
409 Some(UnixNanos::from(99))
410 ));
411 }
412
413 #[rstest]
414 fn test_available_offset_effects() {
415 let info = create_test_instrument(100, Some(200));
417
418 assert!(is_available(
419 &info,
420 None,
421 None,
422 None,
423 Some(UnixNanos::from(100))
424 ));
425
426 assert!(!is_available(
428 &info,
429 None,
430 None,
431 Some(DurationNanos::new(10)),
432 Some(UnixNanos::from(100))
433 ));
434
435 assert!(!is_available(
437 &info,
438 None,
439 None,
440 Some(DurationNanos::new(20)),
441 Some(UnixNanos::from(119))
442 ));
443 assert!(is_available(
444 &info,
445 None,
446 None,
447 Some(DurationNanos::new(20)),
448 Some(UnixNanos::from(121))
449 ));
450 }
451
452 #[rstest]
453 fn test_with_real_dates() {
454 let info = create_test_instrument(1682294400000, Some(1712061000000));
459
460 let mid_date = UnixNanos::from(1695000000000); assert!(is_available(&info, None, None, None, Some(mid_date)));
463
464 let start = UnixNanos::from(1690000000000); let end = UnixNanos::from(1700000000000); assert!(is_available(
468 &info,
469 Some(start),
470 Some(end),
471 None,
472 Some(mid_date)
473 ));
474
475 let offset = DurationNanos::new(86_400_000);
476
477 let offset_boundary = UnixNanos::from(1_682_294_400_000 + 86_400_000);
478 assert!(is_available(
479 &info,
480 None,
481 None,
482 Some(offset),
483 Some(offset_boundary)
484 ));
485
486 let start_date = UnixNanos::from(1682294400000);
487 assert!(is_available(&info, None, None, None, Some(start_date)));
488
489 let end_date = UnixNanos::from(1712061000000);
491 assert!(!is_available(&info, None, None, None, Some(end_date)));
492 }
493
494 #[rstest]
495 fn test_complex_scenarios() {
496 let info = create_test_instrument(100, Some(200));
498
499 assert!(is_available(
501 &info,
502 Some(UnixNanos::from(150)),
503 Some(UnixNanos::from(250)),
504 None,
505 None
506 ));
507 assert!(is_available(
508 &info,
509 Some(UnixNanos::from(50)),
510 Some(UnixNanos::from(150)),
511 None,
512 None
513 ));
514
515 assert!(is_available(
517 &info,
518 Some(UnixNanos::from(50)),
519 Some(UnixNanos::from(250)),
520 None,
521 None
522 ));
523
524 assert!(is_available(
526 &info,
527 Some(UnixNanos::from(120)),
528 Some(UnixNanos::from(180)),
529 None,
530 None
531 ));
532
533 assert!(is_available(
535 &info,
536 Some(UnixNanos::from(120)),
537 Some(UnixNanos::from(180)),
538 None,
539 Some(UnixNanos::from(150))
540 ));
541
542 assert!(!is_available(
544 &info,
545 Some(UnixNanos::from(120)),
546 Some(UnixNanos::from(140)),
547 None,
548 Some(UnixNanos::from(150))
549 ));
550 }
551
552 #[rstest]
553 fn test_edge_cases() {
554 let mut info = create_test_instrument(100, Some(200));
556 info.changes = Some(vec![]);
557 assert!(is_available(
558 &info,
559 None,
560 None,
561 None,
562 Some(UnixNanos::from(150))
563 ));
564
565 let far_future_info = create_test_instrument(100, None); let far_future_date = UnixNanos::from(u64::MAX - 1000);
568 assert!(is_available(
569 &far_future_info,
570 None,
571 None,
572 None,
573 Some(UnixNanos::from(101))
574 ));
575 assert!(is_available(
576 &far_future_info,
577 None,
578 Some(far_future_date),
579 None,
580 None
581 ));
582
583 let info = create_test_instrument(100, Some(200));
585
586 let offset = DurationNanos::new(50);
588 assert!(!is_available(
589 &info,
590 None,
591 None,
592 Some(offset),
593 Some(UnixNanos::from(149))
594 ));
595 assert!(is_available(
596 &info,
597 None,
598 None,
599 Some(offset),
600 Some(UnixNanos::from(151))
601 ));
602
603 let zero_offset = DurationNanos::ZERO;
605 assert!(is_available(
606 &info,
607 None,
608 None,
609 Some(zero_offset),
610 Some(UnixNanos::from(100))
611 ));
612 assert!(is_available(
613 &info,
614 None,
615 None,
616 Some(zero_offset),
617 Some(UnixNanos::from(101))
618 ));
619 }
620}