nautilus_tardis/http/
instruments.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::UnixNanos;
17use nautilus_model::{
18    currencies::CURRENCY_MAP,
19    enums::CurrencyType,
20    identifiers::{InstrumentId, Symbol},
21    instruments::{CryptoFuture, CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
22    types::{Currency, Price, Quantity},
23};
24use rust_decimal::Decimal;
25
26use super::{models::TardisInstrumentInfo, parse::parse_settlement_currency};
27use crate::parse::parse_option_kind;
28
29/// Returns the currency either from the internal currency map or creates a default crypto.
30pub(crate) fn get_currency(code: &str) -> Currency {
31    // SAFETY: Mutex should not be poisoned in normal operation
32    CURRENCY_MAP
33        .lock()
34        .expect("Failed to acquire CURRENCY_MAP lock")
35        .get(code)
36        .copied()
37        .unwrap_or(Currency::new(code, 8, 0, code, CurrencyType::Crypto))
38}
39
40#[allow(clippy::too_many_arguments)]
41#[must_use]
42pub fn create_currency_pair(
43    info: &TardisInstrumentInfo,
44    instrument_id: InstrumentId,
45    raw_symbol: Symbol,
46    price_increment: Price,
47    size_increment: Quantity,
48    multiplier: Option<Quantity>,
49    margin_init: Decimal,
50    margin_maint: Decimal,
51    maker_fee: Decimal,
52    taker_fee: Decimal,
53    ts_event: UnixNanos,
54    ts_init: UnixNanos,
55) -> InstrumentAny {
56    InstrumentAny::CurrencyPair(CurrencyPair::new(
57        instrument_id,
58        raw_symbol,
59        get_currency(info.base_currency.to_uppercase().as_str()),
60        get_currency(info.quote_currency.to_uppercase().as_str()),
61        price_increment.precision,
62        size_increment.precision,
63        price_increment,
64        size_increment,
65        multiplier,
66        Some(size_increment),
67        None,
68        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
69        None,
70        None,
71        None,
72        None,
73        Some(margin_init),
74        Some(margin_maint),
75        Some(maker_fee),
76        Some(taker_fee),
77        ts_event,
78        ts_init,
79    ))
80}
81
82#[allow(clippy::too_many_arguments)]
83#[must_use]
84pub fn create_crypto_perpetual(
85    info: &TardisInstrumentInfo,
86    instrument_id: InstrumentId,
87    raw_symbol: Symbol,
88    price_increment: Price,
89    size_increment: Quantity,
90    multiplier: Option<Quantity>,
91    margin_init: Decimal,
92    margin_maint: Decimal,
93    maker_fee: Decimal,
94    taker_fee: Decimal,
95    ts_event: UnixNanos,
96    ts_init: UnixNanos,
97) -> InstrumentAny {
98    let is_inverse = info.inverse.unwrap_or(false);
99
100    InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
101        instrument_id,
102        raw_symbol,
103        get_currency(info.base_currency.to_uppercase().as_str()),
104        get_currency(info.quote_currency.to_uppercase().as_str()),
105        get_currency(parse_settlement_currency(info, is_inverse).as_str()),
106        is_inverse,
107        price_increment.precision,
108        size_increment.precision,
109        price_increment,
110        size_increment,
111        multiplier,
112        Some(size_increment),
113        None,
114        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
115        None,
116        None,
117        None,
118        None,
119        Some(margin_init),
120        Some(margin_maint),
121        Some(maker_fee),
122        Some(taker_fee),
123        ts_event,
124        ts_init,
125    ))
126}
127
128#[allow(clippy::too_many_arguments)]
129#[must_use]
130pub fn create_crypto_future(
131    info: &TardisInstrumentInfo,
132    instrument_id: InstrumentId,
133    raw_symbol: Symbol,
134    activation: UnixNanos,
135    expiration: UnixNanos,
136    price_increment: Price,
137    size_increment: Quantity,
138    multiplier: Option<Quantity>,
139    margin_init: Decimal,
140    margin_maint: Decimal,
141    maker_fee: Decimal,
142    taker_fee: Decimal,
143    ts_event: UnixNanos,
144    ts_init: UnixNanos,
145) -> InstrumentAny {
146    let is_inverse = info.inverse.unwrap_or(false);
147
148    InstrumentAny::CryptoFuture(CryptoFuture::new(
149        instrument_id,
150        raw_symbol,
151        get_currency(info.base_currency.to_uppercase().as_str()),
152        get_currency(info.quote_currency.to_uppercase().as_str()),
153        get_currency(parse_settlement_currency(info, is_inverse).as_str()),
154        is_inverse,
155        activation,
156        expiration,
157        price_increment.precision,
158        size_increment.precision,
159        price_increment,
160        size_increment,
161        multiplier,
162        Some(size_increment),
163        None,
164        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
165        None,
166        None,
167        None,
168        None,
169        Some(margin_init),
170        Some(margin_maint),
171        Some(maker_fee),
172        Some(taker_fee),
173        ts_event,
174        ts_init,
175    ))
176}
177
178#[allow(clippy::too_many_arguments)]
179/// Create a crypto option instrument definition.
180///
181/// # Panics
182///
183/// Panics if the `option_type` field of `InstrumentInfo` is `None`.
184#[must_use]
185pub fn create_crypto_option(
186    info: &TardisInstrumentInfo,
187    instrument_id: InstrumentId,
188    raw_symbol: Symbol,
189    activation: UnixNanos,
190    expiration: UnixNanos,
191    price_increment: Price,
192    size_increment: Quantity,
193    multiplier: Option<Quantity>,
194    margin_init: Decimal,
195    margin_maint: Decimal,
196    maker_fee: Decimal,
197    taker_fee: Decimal,
198    ts_event: UnixNanos,
199    ts_init: UnixNanos,
200) -> InstrumentAny {
201    let is_inverse = info.inverse.unwrap_or(false);
202
203    InstrumentAny::CryptoOption(CryptoOption::new(
204        instrument_id,
205        raw_symbol,
206        get_currency(info.base_currency.to_uppercase().as_str()),
207        get_currency(info.quote_currency.to_uppercase().as_str()),
208        get_currency(parse_settlement_currency(info, is_inverse).as_str()),
209        is_inverse,
210        parse_option_kind(
211            info.option_type
212                .expect("CryptoOption should have `option_type` field"),
213        ),
214        Price::new(
215            info.strike_price
216                .expect("CryptoOption should have `strike_price` field"),
217            price_increment.precision,
218        ),
219        activation,
220        expiration,
221        price_increment.precision,
222        size_increment.precision,
223        price_increment,
224        size_increment,
225        multiplier,
226        Some(size_increment),
227        None,
228        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
229        None,
230        None,
231        None,
232        None,
233        Some(margin_init),
234        Some(margin_maint),
235        Some(maker_fee),
236        Some(taker_fee),
237        ts_event,
238        ts_init,
239    ))
240}
241
242/// Checks if an instrument is available and valid based on time constraints.
243pub fn is_available(
244    info: &TardisInstrumentInfo,
245    start: Option<UnixNanos>,
246    end: Option<UnixNanos>,
247    available_offset: Option<UnixNanos>,
248    effective: Option<UnixNanos>,
249) -> bool {
250    let available_since =
251        UnixNanos::from(info.available_since) + available_offset.unwrap_or_default();
252    let available_to = info.available_to.map_or(UnixNanos::max(), UnixNanos::from);
253
254    if let Some(effective_date) = effective {
255        // Effective date must be within availability period
256        if available_since >= effective_date || available_to <= effective_date {
257            return false;
258        }
259
260        // Effective date must be within requested [start, end] if provided
261        if start.is_some_and(|s| effective_date < s) || end.is_some_and(|e| effective_date > e) {
262            return false;
263        }
264    } else {
265        // Otherwise check for overlap between [available_since, available_to] and [start, end]
266        if start.is_some_and(|s| available_to < s) || end.is_some_and(|e| available_since > e) {
267            return false;
268        }
269    }
270
271    true
272}
273
274#[cfg(test)]
275mod tests {
276    use rstest::rstest;
277
278    use super::*;
279    use crate::tests::load_test_json;
280
281    // Helper to create a basic instrument info for testing
282    fn create_test_instrument(
283        available_since: u64,
284        available_to: Option<u64>,
285    ) -> TardisInstrumentInfo {
286        let json_data = load_test_json("instrument_spot.json");
287        let mut info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
288        info.available_since = UnixNanos::from(available_since).to_datetime_utc();
289        info.available_to = available_to.map(|a| UnixNanos::from(a).to_datetime_utc());
290        info
291    }
292
293    #[rstest]
294    #[case::no_constraints(None, None, None, None, true)]
295    #[case::within_start_end(Some(100), Some(300), None, None, true)]
296    #[case::before_start(Some(200), Some(300), None, None, true)]
297    #[case::after_end(Some(100), Some(150), None, None, true)]
298    #[case::with_offset_within_range(Some(200), Some(300), Some(50), None, true)]
299    #[case::with_offset_adjusted_within_range(Some(150), Some(300), Some(50), None, true)]
300    #[case::effective_within_availability(None, None, None, Some(150), true)]
301    #[case::effective_before_availability(None, None, None, Some(50), false)]
302    #[case::effective_after_availability(None, None, None, Some(250), false)]
303    #[case::effective_within_start_end(Some(100), Some(200), None, Some(150), true)]
304    #[case::effective_before_start(Some(150), Some(200), None, Some(120), false)]
305    #[case::effective_after_end(Some(100), Some(150), None, Some(180), false)]
306    #[case::effective_equals_available_since(None, None, None, Some(100), false)]
307    #[case::effective_equals_available_to(None, None, None, Some(200), false)]
308    fn test_is_available(
309        #[case] start: Option<u64>,
310        #[case] end: Option<u64>,
311        #[case] available_offset: Option<u64>,
312        #[case] effective: Option<u64>,
313        #[case] expected: bool,
314    ) {
315        // Create instrument with fixed availability 100-200
316        let info = create_test_instrument(100, Some(200));
317
318        // Convert all u64 values to UnixNanos
319        let start_nanos = start.map(UnixNanos::from);
320        let end_nanos = end.map(UnixNanos::from);
321        let offset_nanos = available_offset.map(UnixNanos::from);
322        let effective_nanos = effective.map(UnixNanos::from);
323
324        // Run the test
325        let result = is_available(&info, start_nanos, end_nanos, offset_nanos, effective_nanos);
326
327        assert_eq!(
328            result, expected,
329            "Test failed with start={start:?}, end={end:?}, offset={available_offset:?}, effective={effective:?}"
330        );
331    }
332
333    #[rstest]
334    fn test_infinite_available_to() {
335        // Create instrument with infinite availability (no end date)
336        let info = create_test_instrument(100, None);
337
338        // Should be available for any end date
339        assert!(is_available(
340            &info,
341            None,
342            Some(UnixNanos::from(1000000)),
343            None,
344            None
345        ));
346
347        // Should be available for any effective date after available_since
348        assert!(is_available(
349            &info,
350            None,
351            None,
352            None,
353            Some(UnixNanos::from(101))
354        ));
355
356        // Should not be available for effective date before or equal to available_since
357        assert!(!is_available(
358            &info,
359            None,
360            None,
361            None,
362            Some(UnixNanos::from(100))
363        ));
364        assert!(!is_available(
365            &info,
366            None,
367            None,
368            None,
369            Some(UnixNanos::from(99))
370        ));
371    }
372
373    #[rstest]
374    fn test_available_offset_effects() {
375        // Create instrument with fixed availability 100-200
376        let info = create_test_instrument(100, Some(200));
377
378        // Without offset, effective date of 100 is invalid (boundary condition)
379        assert!(!is_available(
380            &info,
381            None,
382            None,
383            None,
384            Some(UnixNanos::from(100))
385        ));
386
387        // With offset of 10, effective date of 100 should still be invalid (since available_since becomes 110)
388        assert!(!is_available(
389            &info,
390            None,
391            None,
392            Some(UnixNanos::from(10)),
393            Some(UnixNanos::from(100))
394        ));
395
396        // Test with larger offset
397        assert!(!is_available(
398            &info,
399            None,
400            None,
401            Some(UnixNanos::from(20)),
402            Some(UnixNanos::from(119))
403        ));
404        assert!(is_available(
405            &info,
406            None,
407            None,
408            Some(UnixNanos::from(20)),
409            Some(UnixNanos::from(121))
410        ));
411    }
412
413    #[rstest]
414    fn test_with_real_dates() {
415        // Using realistic Unix timestamps (milliseconds since epoch)
416        // April 24, 2023 00:00:00 UTC = 1682294400000
417        // April 2, 2024 12:10:00 UTC = 1712061000000
418
419        let info = create_test_instrument(1682294400000, Some(1712061000000));
420
421        // Test effective date is within range
422        let mid_date = UnixNanos::from(1695000000000); // Sept 2023
423        assert!(is_available(&info, None, None, None, Some(mid_date)));
424
425        // Test with start/end constraints
426        let start = UnixNanos::from(1690000000000); // July 2023
427        let end = UnixNanos::from(1700000000000); // Nov 2023
428        assert!(is_available(
429            &info,
430            Some(start),
431            Some(end),
432            None,
433            Some(mid_date)
434        ));
435
436        // Test with offset (1 day = 86400000 ms)
437        let offset = UnixNanos::from(86400000); // 1 day
438
439        // Now the instrument is available 1 day later
440        let day_after_start = UnixNanos::from(1682294400000 + 86400000);
441        assert!(!is_available(
442            &info,
443            None,
444            None,
445            Some(offset),
446            Some(day_after_start)
447        ));
448
449        // Effective date at exactly the start should fail
450        let start_date = UnixNanos::from(1682294400000);
451        assert!(!is_available(&info, None, None, None, Some(start_date)));
452
453        // Effective date at exactly the end should fail
454        let end_date = UnixNanos::from(1712061000000);
455        assert!(!is_available(&info, None, None, None, Some(end_date)));
456    }
457
458    #[rstest]
459    fn test_complex_scenarios() {
460        // Create instrument with fixed availability 100-200
461        let info = create_test_instrument(100, Some(200));
462
463        // Scenario: Start and end window partially overlaps with availability
464        assert!(is_available(
465            &info,
466            Some(UnixNanos::from(150)),
467            Some(UnixNanos::from(250)),
468            None,
469            None
470        ));
471        assert!(is_available(
472            &info,
473            Some(UnixNanos::from(50)),
474            Some(UnixNanos::from(150)),
475            None,
476            None
477        ));
478
479        // Scenario: Start and end window completely contains availability
480        assert!(is_available(
481            &info,
482            Some(UnixNanos::from(50)),
483            Some(UnixNanos::from(250)),
484            None,
485            None
486        ));
487
488        // Scenario: Start and end window completely within availability
489        assert!(is_available(
490            &info,
491            Some(UnixNanos::from(120)),
492            Some(UnixNanos::from(180)),
493            None,
494            None
495        ));
496
497        // Scenario: Effective date with start/end constraints
498        assert!(is_available(
499            &info,
500            Some(UnixNanos::from(120)),
501            Some(UnixNanos::from(180)),
502            None,
503            Some(UnixNanos::from(150))
504        ));
505
506        // Scenario: Effective date outside start/end constraints but within availability
507        assert!(!is_available(
508            &info,
509            Some(UnixNanos::from(120)),
510            Some(UnixNanos::from(140)),
511            None,
512            Some(UnixNanos::from(150))
513        ));
514    }
515
516    #[rstest]
517    fn test_edge_cases() {
518        // Test with empty "changes" array
519        let mut info = create_test_instrument(100, Some(200));
520        info.changes = Some(vec![]);
521        assert!(is_available(
522            &info,
523            None,
524            None,
525            None,
526            Some(UnixNanos::from(150))
527        ));
528
529        // Test with very large timestamps (near u64::MAX)
530        let far_future_info = create_test_instrument(100, None); // No end date = indefinite future
531        let far_future_date = UnixNanos::from(u64::MAX - 1000);
532        assert!(is_available(
533            &far_future_info,
534            None,
535            None,
536            None,
537            Some(UnixNanos::from(101))
538        ));
539        assert!(is_available(
540            &far_future_info,
541            None,
542            Some(far_future_date),
543            None,
544            None
545        ));
546
547        // Test with offset that increases available_since
548        let info = create_test_instrument(100, Some(200));
549
550        // Adding offset of 50 to available_since (100) makes it 150
551        let offset = UnixNanos::from(50);
552        assert!(!is_available(
553            &info,
554            None,
555            None,
556            Some(offset),
557            Some(UnixNanos::from(149))
558        ));
559        assert!(is_available(
560            &info,
561            None,
562            None,
563            Some(offset),
564            Some(UnixNanos::from(151))
565        ));
566
567        // Test with offset equal to zero (no effect)
568        let zero_offset = UnixNanos::from(0);
569        assert!(!is_available(
570            &info,
571            None,
572            None,
573            Some(zero_offset),
574            Some(UnixNanos::from(100))
575        ));
576        assert!(is_available(
577            &info,
578            None,
579            None,
580            Some(zero_offset),
581            Some(UnixNanos::from(101))
582        ));
583    }
584}