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::{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    maker_fee: Decimal,
69    taker_fee: Decimal,
70    ts_event: UnixNanos,
71    ts_init: UnixNanos,
72) -> InstrumentAny {
73    InstrumentAny::CurrencyPair(
74        CurrencyPair::builder()
75            .instrument_id(instrument_id)
76            .raw_symbol(raw_symbol)
77            .base_currency(get_currency(info.base_currency.to_uppercase().as_str()))
78            .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
79            .price_precision(price_increment.precision)
80            .size_precision(size_increment.precision)
81            .price_increment(price_increment)
82            .size_increment(size_increment)
83            .maybe_multiplier(multiplier)
84            .lot_size(size_increment)
85            .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
86            .margin_init(margin_init)
87            .margin_maint(margin_maint)
88            .maker_fee(maker_fee)
89            .taker_fee(taker_fee)
90            .maybe_info(build_info_params(info))
91            .ts_event(ts_event)
92            .ts_init(ts_init)
93            .build()
94            .unwrap(),
95    )
96}
97
98/// Creates a crypto perpetual instrument definition.
99///
100/// # Panics
101///
102/// Panics if the constructed instrument fails validation.
103#[expect(clippy::too_many_arguments)]
104#[must_use]
105pub fn create_crypto_perpetual(
106    info: &TardisInstrumentInfo,
107    instrument_id: InstrumentId,
108    raw_symbol: Symbol,
109    price_increment: Price,
110    size_increment: Quantity,
111    multiplier: Option<Quantity>,
112    margin_init: Decimal,
113    margin_maint: Decimal,
114    maker_fee: Decimal,
115    taker_fee: Decimal,
116    ts_event: UnixNanos,
117    ts_init: UnixNanos,
118) -> InstrumentAny {
119    let is_inverse = info.inverse.unwrap_or(false);
120
121    InstrumentAny::CryptoPerpetual(
122        CryptoPerpetual::builder()
123            .instrument_id(instrument_id)
124            .raw_symbol(raw_symbol)
125            .base_currency(get_currency(info.base_currency.to_uppercase().as_str()))
126            .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
127            .settlement_currency(get_currency(
128                parse_settlement_currency(info, is_inverse).as_str(),
129            ))
130            .is_inverse(is_inverse)
131            .price_precision(price_increment.precision)
132            .size_precision(size_increment.precision)
133            .price_increment(price_increment)
134            .size_increment(size_increment)
135            .maybe_multiplier(multiplier)
136            .lot_size(size_increment)
137            .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
138            .margin_init(margin_init)
139            .margin_maint(margin_maint)
140            .maker_fee(maker_fee)
141            .taker_fee(taker_fee)
142            .maybe_info(build_info_params(info))
143            .ts_event(ts_event)
144            .ts_init(ts_init)
145            .build()
146            .unwrap(),
147    )
148}
149
150/// Creates a crypto future instrument definition.
151///
152/// # Panics
153///
154/// Panics if the constructed instrument fails validation.
155#[expect(clippy::too_many_arguments)]
156#[must_use]
157pub fn create_crypto_future(
158    info: &TardisInstrumentInfo,
159    instrument_id: InstrumentId,
160    raw_symbol: Symbol,
161    activation: UnixNanos,
162    expiration: UnixNanos,
163    price_increment: Price,
164    size_increment: Quantity,
165    multiplier: Option<Quantity>,
166    margin_init: Decimal,
167    margin_maint: Decimal,
168    maker_fee: Decimal,
169    taker_fee: Decimal,
170    ts_event: UnixNanos,
171    ts_init: UnixNanos,
172) -> InstrumentAny {
173    let is_inverse = info.inverse.unwrap_or(false);
174
175    InstrumentAny::CryptoFuture(
176        CryptoFuture::builder()
177            .instrument_id(instrument_id)
178            .raw_symbol(raw_symbol)
179            .underlying(get_currency(info.base_currency.to_uppercase().as_str()))
180            .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
181            .settlement_currency(get_currency(
182                parse_settlement_currency(info, is_inverse).as_str(),
183            ))
184            .is_inverse(is_inverse)
185            .activation_ns(activation)
186            .expiration_ns(expiration)
187            .price_precision(price_increment.precision)
188            .size_precision(size_increment.precision)
189            .price_increment(price_increment)
190            .size_increment(size_increment)
191            .maybe_multiplier(multiplier)
192            .lot_size(size_increment)
193            .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
194            .margin_init(margin_init)
195            .margin_maint(margin_maint)
196            .maker_fee(maker_fee)
197            .taker_fee(taker_fee)
198            .maybe_info(build_info_params(info))
199            .ts_event(ts_event)
200            .ts_init(ts_init)
201            .build()
202            .unwrap(),
203    )
204}
205
206#[expect(clippy::too_many_arguments)]
207/// Create a crypto option instrument definition.
208///
209/// # Errors
210///
211/// Returns an error if the `option_type` or `strike_price` field of `InstrumentInfo` is `None`.
212///
213/// # Panics
214///
215/// Panics if the constructed instrument fails validation.
216pub fn create_crypto_option(
217    info: &TardisInstrumentInfo,
218    instrument_id: InstrumentId,
219    raw_symbol: Symbol,
220    activation: UnixNanos,
221    expiration: UnixNanos,
222    price_increment: Price,
223    size_increment: Quantity,
224    multiplier: Option<Quantity>,
225    margin_init: Decimal,
226    margin_maint: Decimal,
227    maker_fee: Decimal,
228    taker_fee: Decimal,
229    ts_event: UnixNanos,
230    ts_init: UnixNanos,
231) -> anyhow::Result<InstrumentAny> {
232    let is_inverse = info.inverse.unwrap_or(false);
233
234    let option_type = info.option_type.ok_or_else(|| {
235        anyhow::anyhow!(
236            "CryptoOption missing `option_type` field for instrument: {}",
237            info.id
238        )
239    })?;
240
241    let strike_price = info.strike_price.ok_or_else(|| {
242        anyhow::anyhow!(
243            "CryptoOption missing `strike_price` field for instrument: {}",
244            info.id
245        )
246    })?;
247
248    Ok(InstrumentAny::CryptoOption(
249        CryptoOption::builder()
250            .instrument_id(instrument_id)
251            .raw_symbol(raw_symbol)
252            .underlying(get_currency(info.base_currency.to_uppercase().as_str()))
253            .quote_currency(get_currency(info.quote_currency.to_uppercase().as_str()))
254            .settlement_currency(get_currency(
255                parse_settlement_currency(info, is_inverse).as_str(),
256            ))
257            .is_inverse(is_inverse)
258            .option_kind(parse_option_kind(option_type))
259            .strike_price(Price::new(strike_price, price_increment.precision))
260            .activation_ns(activation)
261            .expiration_ns(expiration)
262            .price_precision(price_increment.precision)
263            .size_precision(size_increment.precision)
264            .price_increment(price_increment)
265            .size_increment(size_increment)
266            .maybe_multiplier(multiplier)
267            .lot_size(size_increment)
268            .min_quantity(Quantity::from(info.min_trade_amount.to_string()))
269            .margin_init(margin_init)
270            .margin_maint(margin_maint)
271            .maker_fee(maker_fee)
272            .taker_fee(taker_fee)
273            .maybe_info(build_info_params(info))
274            .ts_event(ts_event)
275            .ts_init(ts_init)
276            .build()
277            .unwrap(),
278    ))
279}
280
281/// Checks if an instrument is available and valid based on time constraints.
282pub fn is_available(
283    info: &TardisInstrumentInfo,
284    start: Option<UnixNanos>,
285    end: Option<UnixNanos>,
286    available_offset: Option<UnixNanos>,
287    effective: Option<UnixNanos>,
288) -> bool {
289    let available_since =
290        UnixNanos::from(info.available_since) + available_offset.unwrap_or_default();
291    let available_to = info.available_to.map_or(UnixNanos::max(), UnixNanos::from);
292
293    if let Some(effective_date) = effective {
294        // Effective date must be within availability period
295        if available_since >= effective_date || available_to <= effective_date {
296            return false;
297        }
298
299        // Effective date must be within requested [start, end] if provided
300        if start.is_some_and(|s| effective_date < s) || end.is_some_and(|e| effective_date > e) {
301            return false;
302        }
303    } else {
304        // Otherwise check for overlap between [available_since, available_to] and [start, end]
305        if start.is_some_and(|s| available_to < s) || end.is_some_and(|e| available_since > e) {
306            return false;
307        }
308    }
309
310    true
311}
312
313#[cfg(test)]
314mod tests {
315    use rstest::rstest;
316
317    use super::*;
318    use crate::common::testing::load_test_json;
319
320    // Helper to create a basic instrument info for testing
321    fn create_test_instrument(
322        available_since: u64,
323        available_to: Option<u64>,
324    ) -> TardisInstrumentInfo {
325        let json_data = load_test_json("instrument_spot.json");
326        let mut info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
327        info.available_since = UnixNanos::from(available_since).to_datetime_utc();
328        info.available_to = available_to.map(|a| UnixNanos::from(a).to_datetime_utc());
329        info
330    }
331
332    #[rstest]
333    #[case::no_constraints(None, None, None, None, true)]
334    #[case::within_start_end(Some(100), Some(300), None, None, true)]
335    #[case::before_start(Some(200), Some(300), None, None, true)]
336    #[case::after_end(Some(100), Some(150), None, None, true)]
337    #[case::with_offset_within_range(Some(200), Some(300), Some(50), None, true)]
338    #[case::with_offset_adjusted_within_range(Some(150), Some(300), Some(50), None, true)]
339    #[case::effective_within_availability(None, None, None, Some(150), true)]
340    #[case::effective_before_availability(None, None, None, Some(50), false)]
341    #[case::effective_after_availability(None, None, None, Some(250), false)]
342    #[case::effective_within_start_end(Some(100), Some(200), None, Some(150), true)]
343    #[case::effective_before_start(Some(150), Some(200), None, Some(120), false)]
344    #[case::effective_after_end(Some(100), Some(150), None, Some(180), false)]
345    #[case::effective_equals_available_since(None, None, None, Some(100), false)]
346    #[case::effective_equals_available_to(None, None, None, Some(200), false)]
347    fn test_is_available(
348        #[case] start: Option<u64>,
349        #[case] end: Option<u64>,
350        #[case] available_offset: Option<u64>,
351        #[case] effective: Option<u64>,
352        #[case] expected: bool,
353    ) {
354        // Create instrument with fixed availability 100-200
355        let info = create_test_instrument(100, Some(200));
356
357        // Convert all u64 values to UnixNanos
358        let start_nanos = start.map(UnixNanos::from);
359        let end_nanos = end.map(UnixNanos::from);
360        let offset_nanos = available_offset.map(UnixNanos::from);
361        let effective_nanos = effective.map(UnixNanos::from);
362
363        // Run the test
364        let result = is_available(&info, start_nanos, end_nanos, offset_nanos, effective_nanos);
365
366        assert_eq!(
367            result, expected,
368            "Test failed with start={start:?}, end={end:?}, offset={available_offset:?}, effective={effective:?}"
369        );
370    }
371
372    #[rstest]
373    fn test_infinite_available_to() {
374        // Create instrument with infinite availability (no end date)
375        let info = create_test_instrument(100, None);
376
377        // Should be available for any end date
378        assert!(is_available(
379            &info,
380            None,
381            Some(UnixNanos::from(1000000)),
382            None,
383            None
384        ));
385
386        // Should be available for any effective date after available_since
387        assert!(is_available(
388            &info,
389            None,
390            None,
391            None,
392            Some(UnixNanos::from(101))
393        ));
394
395        // Should not be available for effective date before or equal to available_since
396        assert!(!is_available(
397            &info,
398            None,
399            None,
400            None,
401            Some(UnixNanos::from(100))
402        ));
403        assert!(!is_available(
404            &info,
405            None,
406            None,
407            None,
408            Some(UnixNanos::from(99))
409        ));
410    }
411
412    #[rstest]
413    fn test_available_offset_effects() {
414        // Create instrument with fixed availability 100-200
415        let info = create_test_instrument(100, Some(200));
416
417        // Without offset, effective date of 100 is invalid (boundary condition)
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 still be invalid (since available_since becomes 110)
427        assert!(!is_available(
428            &info,
429            None,
430            None,
431            Some(UnixNanos::from(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(UnixNanos::from(20)),
441            Some(UnixNanos::from(119))
442        ));
443        assert!(is_available(
444            &info,
445            None,
446            None,
447            Some(UnixNanos::from(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        // Test with offset (1 day = 86400000 ms)
476        let offset = UnixNanos::from(86400000); // 1 day
477
478        // Now the instrument is available 1 day later
479        let day_after_start = UnixNanos::from(1682294400000 + 86400000);
480        assert!(!is_available(
481            &info,
482            None,
483            None,
484            Some(offset),
485            Some(day_after_start)
486        ));
487
488        // Effective date at exactly the start should fail
489        let start_date = UnixNanos::from(1682294400000);
490        assert!(!is_available(&info, None, None, None, Some(start_date)));
491
492        // Effective date at exactly the end should fail
493        let end_date = UnixNanos::from(1712061000000);
494        assert!(!is_available(&info, None, None, None, Some(end_date)));
495    }
496
497    #[rstest]
498    fn test_complex_scenarios() {
499        // Create instrument with fixed availability 100-200
500        let info = create_test_instrument(100, Some(200));
501
502        // Scenario: Start and end window partially overlaps with availability
503        assert!(is_available(
504            &info,
505            Some(UnixNanos::from(150)),
506            Some(UnixNanos::from(250)),
507            None,
508            None
509        ));
510        assert!(is_available(
511            &info,
512            Some(UnixNanos::from(50)),
513            Some(UnixNanos::from(150)),
514            None,
515            None
516        ));
517
518        // Scenario: Start and end window completely contains availability
519        assert!(is_available(
520            &info,
521            Some(UnixNanos::from(50)),
522            Some(UnixNanos::from(250)),
523            None,
524            None
525        ));
526
527        // Scenario: Start and end window completely within availability
528        assert!(is_available(
529            &info,
530            Some(UnixNanos::from(120)),
531            Some(UnixNanos::from(180)),
532            None,
533            None
534        ));
535
536        // Scenario: Effective date with start/end constraints
537        assert!(is_available(
538            &info,
539            Some(UnixNanos::from(120)),
540            Some(UnixNanos::from(180)),
541            None,
542            Some(UnixNanos::from(150))
543        ));
544
545        // Scenario: Effective date outside start/end constraints but within availability
546        assert!(!is_available(
547            &info,
548            Some(UnixNanos::from(120)),
549            Some(UnixNanos::from(140)),
550            None,
551            Some(UnixNanos::from(150))
552        ));
553    }
554
555    #[rstest]
556    fn test_edge_cases() {
557        // Test with empty "changes" array
558        let mut info = create_test_instrument(100, Some(200));
559        info.changes = Some(vec![]);
560        assert!(is_available(
561            &info,
562            None,
563            None,
564            None,
565            Some(UnixNanos::from(150))
566        ));
567
568        // Test with very large timestamps (near u64::MAX)
569        let far_future_info = create_test_instrument(100, None); // No end date = indefinite future
570        let far_future_date = UnixNanos::from(u64::MAX - 1000);
571        assert!(is_available(
572            &far_future_info,
573            None,
574            None,
575            None,
576            Some(UnixNanos::from(101))
577        ));
578        assert!(is_available(
579            &far_future_info,
580            None,
581            Some(far_future_date),
582            None,
583            None
584        ));
585
586        // Test with offset that increases available_since
587        let info = create_test_instrument(100, Some(200));
588
589        // Adding offset of 50 to available_since (100) makes it 150
590        let offset = UnixNanos::from(50);
591        assert!(!is_available(
592            &info,
593            None,
594            None,
595            Some(offset),
596            Some(UnixNanos::from(149))
597        ));
598        assert!(is_available(
599            &info,
600            None,
601            None,
602            Some(offset),
603            Some(UnixNanos::from(151))
604        ));
605
606        // Test with offset equal to zero (no effect)
607        let zero_offset = UnixNanos::from(0);
608        assert!(!is_available(
609            &info,
610            None,
611            None,
612            Some(zero_offset),
613            Some(UnixNanos::from(100))
614        ));
615        assert!(is_available(
616            &info,
617            None,
618            None,
619            Some(zero_offset),
620            Some(UnixNanos::from(101))
621        ));
622    }
623}