Skip to main content

nautilus_tardis/http/
instruments.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
16use 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
27/// Returns a currency from the internal map or creates a new crypto currency.
28///
29/// Uses [`Currency::get_or_create_crypto`] to handle unknown currency codes,
30/// which automatically registers newly listed exchange assets.
31pub(crate) fn get_currency(code: &str) -> Currency {
32    Currency::get_or_create_crypto(code)
33}
34
35/// Builds an `Option<Params>` from raw Tardis instrument metadata.
36fn 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/// Creates a currency pair instrument definition.
53///
54/// # Panics
55///
56/// Panics if the constructed instrument fails validation.
57#[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/// Creates a crypto perpetual instrument definition.
95///
96/// # Panics
97///
98/// Panics if the constructed instrument fails validation.
99#[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/// Creates a crypto future instrument definition.
143///
144/// # Panics
145///
146/// Panics if the constructed instrument fails validation.
147#[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)]
195/// Create a crypto option instrument definition.
196///
197/// # Errors
198///
199/// Returns an error if the `option_type` or `strike_price` field of `InstrumentInfo` is `None`.
200///
201/// # Panics
202///
203/// Panics if the constructed instrument fails validation.
204pub 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
265/// Checks if an instrument is available and valid based on time constraints.
266pub 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        // Effective date must be within availability period
279        if available_since > effective_date || available_to <= effective_date {
280            return false;
281        }
282
283        // Effective date must be within requested [start, end] if provided
284        if start.is_some_and(|s| effective_date < s) || end.is_some_and(|e| effective_date > e) {
285            return false;
286        }
287    } else {
288        // Otherwise check for overlap between [available_since, available_to] and [start, end]
289        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        // Create instrument with fixed availability 100-200
339        let info = create_test_instrument(100, Some(200));
340
341        // Convert all u64 values to UnixNanos
342        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        // Run the test
348        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        // Create instrument with infinite availability (no end date)
377        let info = create_test_instrument(100, None);
378
379        // Should be available for any end date
380        assert!(is_available(
381            &info,
382            None,
383            Some(UnixNanos::from(1000000)),
384            None,
385            None
386        ));
387
388        // Should be available for any effective date after available_since
389        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        // Create instrument with fixed availability 100-200
416        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        // With offset of 10, effective date of 100 should be invalid (since available_since becomes 110)
427        assert!(!is_available(
428            &info,
429            None,
430            None,
431            Some(DurationNanos::new(10)),
432            Some(UnixNanos::from(100))
433        ));
434
435        // Test with larger offset
436        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        // Using realistic Unix timestamps (milliseconds since epoch)
455        // April 24, 2023 00:00:00 UTC = 1682294400000
456        // April 2, 2024 12:10:00 UTC = 1712061000000
457
458        let info = create_test_instrument(1682294400000, Some(1712061000000));
459
460        // Test effective date is within range
461        let mid_date = UnixNanos::from(1695000000000); // Sept 2023
462        assert!(is_available(&info, None, None, None, Some(mid_date)));
463
464        // Test with start/end constraints
465        let start = UnixNanos::from(1690000000000); // July 2023
466        let end = UnixNanos::from(1700000000000); // Nov 2023
467        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        // Effective date at exactly the end should fail
490        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        // Create instrument with fixed availability 100-200
497        let info = create_test_instrument(100, Some(200));
498
499        // Scenario: Start and end window partially overlaps with availability
500        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        // Scenario: Start and end window completely contains availability
516        assert!(is_available(
517            &info,
518            Some(UnixNanos::from(50)),
519            Some(UnixNanos::from(250)),
520            None,
521            None
522        ));
523
524        // Scenario: Start and end window completely within availability
525        assert!(is_available(
526            &info,
527            Some(UnixNanos::from(120)),
528            Some(UnixNanos::from(180)),
529            None,
530            None
531        ));
532
533        // Scenario: Effective date with start/end constraints
534        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        // Scenario: Effective date outside start/end constraints but within availability
543        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        // Test with empty "changes" array
555        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        // Test with very large timestamps (near u64::MAX)
566        let far_future_info = create_test_instrument(100, None); // No end date = indefinite future
567        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        // Test with offset that increases available_since
584        let info = create_test_instrument(100, Some(200));
585
586        // Adding offset of 50 to available_since (100) makes it 150
587        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        // Test with offset equal to zero (no effect)
604        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}