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    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<DurationNanos>,
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    fn create_test_instrument(
321        available_since: u64,
322        available_to: Option<u64>,
323    ) -> TardisInstrumentInfo {
324        let json_data = load_test_json("instrument_spot.json");
325        let mut info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
326        info.available_since = UnixNanos::from(available_since).to_datetime_utc();
327        info.available_to = available_to.map(|a| UnixNanos::from(a).to_datetime_utc());
328        info
329    }
330
331    #[rstest]
332    #[case::no_constraints(None, None, None, None, true)]
333    #[case::within_start_end(Some(100), Some(300), None, None, true)]
334    #[case::before_start(Some(200), Some(300), None, None, true)]
335    #[case::after_end(Some(100), Some(150), None, None, true)]
336    #[case::with_offset_within_range(Some(200), Some(300), Some(50), None, true)]
337    #[case::with_offset_adjusted_within_range(Some(150), Some(300), Some(50), None, true)]
338    #[case::effective_within_availability(None, None, None, Some(150), true)]
339    #[case::effective_before_availability(None, None, None, Some(50), false)]
340    #[case::effective_after_availability(None, None, None, Some(250), false)]
341    #[case::effective_within_start_end(Some(100), Some(200), None, Some(150), true)]
342    #[case::effective_before_start(Some(150), Some(200), None, Some(120), false)]
343    #[case::effective_after_end(Some(100), Some(150), None, Some(180), false)]
344    #[case::effective_equals_available_since(None, None, None, Some(100), false)]
345    #[case::effective_equals_available_to(None, None, None, Some(200), false)]
346    fn test_is_available(
347        #[case] start: Option<u64>,
348        #[case] end: Option<u64>,
349        #[case] available_offset: Option<u64>,
350        #[case] effective: Option<u64>,
351        #[case] expected: bool,
352    ) {
353        // Create instrument with fixed availability 100-200
354        let info = create_test_instrument(100, Some(200));
355
356        // Convert all u64 values to UnixNanos
357        let start_nanos = start.map(UnixNanos::from);
358        let end_nanos = end.map(UnixNanos::from);
359        let offset_nanos = available_offset.map(DurationNanos::new);
360        let effective_nanos = effective.map(UnixNanos::from);
361
362        // Run the test
363        let result = is_available(&info, start_nanos, end_nanos, offset_nanos, effective_nanos);
364
365        assert_eq!(
366            result, expected,
367            "Test failed with start={start:?}, end={end:?}, offset={available_offset:?}, effective={effective:?}"
368        );
369    }
370
371    #[rstest]
372    fn test_infinite_available_to() {
373        // Create instrument with infinite availability (no end date)
374        let info = create_test_instrument(100, None);
375
376        // Should be available for any end date
377        assert!(is_available(
378            &info,
379            None,
380            Some(UnixNanos::from(1000000)),
381            None,
382            None
383        ));
384
385        // Should be available for any effective date after available_since
386        assert!(is_available(
387            &info,
388            None,
389            None,
390            None,
391            Some(UnixNanos::from(101))
392        ));
393
394        // Should not be available for effective date before or equal to available_since
395        assert!(!is_available(
396            &info,
397            None,
398            None,
399            None,
400            Some(UnixNanos::from(100))
401        ));
402        assert!(!is_available(
403            &info,
404            None,
405            None,
406            None,
407            Some(UnixNanos::from(99))
408        ));
409    }
410
411    #[rstest]
412    fn test_available_offset_effects() {
413        // Create instrument with fixed availability 100-200
414        let info = create_test_instrument(100, Some(200));
415
416        // Without offset, effective date of 100 is invalid (boundary condition)
417        assert!(!is_available(
418            &info,
419            None,
420            None,
421            None,
422            Some(UnixNanos::from(100))
423        ));
424
425        // With offset of 10, effective date of 100 should still be invalid (since available_since becomes 110)
426        assert!(!is_available(
427            &info,
428            None,
429            None,
430            Some(DurationNanos::new(10)),
431            Some(UnixNanos::from(100))
432        ));
433
434        // Test with larger offset
435        assert!(!is_available(
436            &info,
437            None,
438            None,
439            Some(DurationNanos::new(20)),
440            Some(UnixNanos::from(119))
441        ));
442        assert!(is_available(
443            &info,
444            None,
445            None,
446            Some(DurationNanos::new(20)),
447            Some(UnixNanos::from(121))
448        ));
449    }
450
451    #[rstest]
452    fn test_with_real_dates() {
453        // Using realistic Unix timestamps (milliseconds since epoch)
454        // April 24, 2023 00:00:00 UTC = 1682294400000
455        // April 2, 2024 12:10:00 UTC = 1712061000000
456
457        let info = create_test_instrument(1682294400000, Some(1712061000000));
458
459        // Test effective date is within range
460        let mid_date = UnixNanos::from(1695000000000); // Sept 2023
461        assert!(is_available(&info, None, None, None, Some(mid_date)));
462
463        // Test with start/end constraints
464        let start = UnixNanos::from(1690000000000); // July 2023
465        let end = UnixNanos::from(1700000000000); // Nov 2023
466        assert!(is_available(
467            &info,
468            Some(start),
469            Some(end),
470            None,
471            Some(mid_date)
472        ));
473
474        let offset = DurationNanos::new(86_400_000);
475
476        let offset_boundary = UnixNanos::from(1_682_294_400_000 + 86_400_000);
477        assert!(!is_available(
478            &info,
479            None,
480            None,
481            Some(offset),
482            Some(offset_boundary)
483        ));
484
485        // Effective date at exactly the start should fail
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}