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        None,
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        None, // lot_size TBD
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        None, // lot_size TBD
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        None,
227        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
228        None,
229        None,
230        None,
231        None,
232        Some(margin_init),
233        Some(margin_maint),
234        Some(maker_fee),
235        Some(taker_fee),
236        ts_event,
237        ts_init,
238    ))
239}
240
241/// Checks if an instrument is available and valid based on time constraints.
242pub fn is_available(
243    info: &TardisInstrumentInfo,
244    start: Option<UnixNanos>,
245    end: Option<UnixNanos>,
246    available_offset: Option<UnixNanos>,
247    effective: Option<UnixNanos>,
248) -> bool {
249    let available_since =
250        UnixNanos::from(info.available_since) + available_offset.unwrap_or_default();
251    let available_to = info.available_to.map_or(UnixNanos::max(), UnixNanos::from);
252
253    if let Some(effective_date) = effective {
254        // Effective date must be within availability period
255        if available_since >= effective_date || available_to <= effective_date {
256            return false;
257        }
258
259        // Effective date must be within requested [start, end] if provided
260        if start.is_some_and(|s| effective_date < s) || end.is_some_and(|e| effective_date > e) {
261            return false;
262        }
263    } else {
264        // Otherwise check for overlap between [available_since, available_to] and [start, end]
265        if start.is_some_and(|s| available_to < s) || end.is_some_and(|e| available_since > e) {
266            return false;
267        }
268    }
269
270    true
271}
272
273#[cfg(test)]
274mod tests {
275    use rstest::rstest;
276
277    use super::*;
278    use crate::tests::load_test_json;
279
280    // Helper to create a basic instrument info for testing
281    fn create_test_instrument(
282        available_since: u64,
283        available_to: Option<u64>,
284    ) -> TardisInstrumentInfo {
285        let json_data = load_test_json("instrument_spot.json");
286        let mut info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
287        info.available_since = UnixNanos::from(available_since).to_datetime_utc();
288        info.available_to = available_to.map(|a| UnixNanos::from(a).to_datetime_utc());
289        info
290    }
291
292    #[rstest]
293    #[case::no_constraints(None, None, None, None, true)]
294    #[case::within_start_end(Some(100), Some(300), None, None, true)]
295    #[case::before_start(Some(200), Some(300), None, None, true)]
296    #[case::after_end(Some(100), Some(150), None, None, true)]
297    #[case::with_offset_within_range(Some(200), Some(300), Some(50), None, true)]
298    #[case::with_offset_adjusted_within_range(Some(150), Some(300), Some(50), None, true)]
299    #[case::effective_within_availability(None, None, None, Some(150), true)]
300    #[case::effective_before_availability(None, None, None, Some(50), false)]
301    #[case::effective_after_availability(None, None, None, Some(250), false)]
302    #[case::effective_within_start_end(Some(100), Some(200), None, Some(150), true)]
303    #[case::effective_before_start(Some(150), Some(200), None, Some(120), false)]
304    #[case::effective_after_end(Some(100), Some(150), None, Some(180), false)]
305    #[case::effective_equals_available_since(None, None, None, Some(100), false)]
306    #[case::effective_equals_available_to(None, None, None, Some(200), false)]
307    fn test_is_available(
308        #[case] start: Option<u64>,
309        #[case] end: Option<u64>,
310        #[case] available_offset: Option<u64>,
311        #[case] effective: Option<u64>,
312        #[case] expected: bool,
313    ) {
314        // Create instrument with fixed availability 100-200
315        let info = create_test_instrument(100, Some(200));
316
317        // Convert all u64 values to UnixNanos
318        let start_nanos = start.map(UnixNanos::from);
319        let end_nanos = end.map(UnixNanos::from);
320        let offset_nanos = available_offset.map(UnixNanos::from);
321        let effective_nanos = effective.map(UnixNanos::from);
322
323        // Run the test
324        let result = is_available(&info, start_nanos, end_nanos, offset_nanos, effective_nanos);
325
326        assert_eq!(
327            result, expected,
328            "Test failed with start={start:?}, end={end:?}, offset={available_offset:?}, effective={effective:?}"
329        );
330    }
331
332    #[rstest]
333    fn test_infinite_available_to() {
334        // Create instrument with infinite availability (no end date)
335        let info = create_test_instrument(100, None);
336
337        // Should be available for any end date
338        assert!(is_available(
339            &info,
340            None,
341            Some(UnixNanos::from(1000000)),
342            None,
343            None
344        ));
345
346        // Should be available for any effective date after available_since
347        assert!(is_available(
348            &info,
349            None,
350            None,
351            None,
352            Some(UnixNanos::from(101))
353        ));
354
355        // Should not be available for effective date before or equal to available_since
356        assert!(!is_available(
357            &info,
358            None,
359            None,
360            None,
361            Some(UnixNanos::from(100))
362        ));
363        assert!(!is_available(
364            &info,
365            None,
366            None,
367            None,
368            Some(UnixNanos::from(99))
369        ));
370    }
371
372    #[rstest]
373    fn test_available_offset_effects() {
374        // Create instrument with fixed availability 100-200
375        let info = create_test_instrument(100, Some(200));
376
377        // Without offset, effective date of 100 is invalid (boundary condition)
378        assert!(!is_available(
379            &info,
380            None,
381            None,
382            None,
383            Some(UnixNanos::from(100))
384        ));
385
386        // With offset of 10, effective date of 100 should still be invalid (since available_since becomes 110)
387        assert!(!is_available(
388            &info,
389            None,
390            None,
391            Some(UnixNanos::from(10)),
392            Some(UnixNanos::from(100))
393        ));
394
395        // Test with larger offset
396        assert!(!is_available(
397            &info,
398            None,
399            None,
400            Some(UnixNanos::from(20)),
401            Some(UnixNanos::from(119))
402        ));
403        assert!(is_available(
404            &info,
405            None,
406            None,
407            Some(UnixNanos::from(20)),
408            Some(UnixNanos::from(121))
409        ));
410    }
411
412    #[rstest]
413    fn test_with_real_dates() {
414        // Using realistic Unix timestamps (milliseconds since epoch)
415        // April 24, 2023 00:00:00 UTC = 1682294400000
416        // April 2, 2024 12:10:00 UTC = 1712061000000
417
418        let info = create_test_instrument(1682294400000, Some(1712061000000));
419
420        // Test effective date is within range
421        let mid_date = UnixNanos::from(1695000000000); // Sept 2023
422        assert!(is_available(&info, None, None, None, Some(mid_date)));
423
424        // Test with start/end constraints
425        let start = UnixNanos::from(1690000000000); // July 2023
426        let end = UnixNanos::from(1700000000000); // Nov 2023
427        assert!(is_available(
428            &info,
429            Some(start),
430            Some(end),
431            None,
432            Some(mid_date)
433        ));
434
435        // Test with offset (1 day = 86400000 ms)
436        let offset = UnixNanos::from(86400000); // 1 day
437
438        // Now the instrument is available 1 day later
439        let day_after_start = UnixNanos::from(1682294400000 + 86400000);
440        assert!(!is_available(
441            &info,
442            None,
443            None,
444            Some(offset),
445            Some(day_after_start)
446        ));
447
448        // Effective date at exactly the start should fail
449        let start_date = UnixNanos::from(1682294400000);
450        assert!(!is_available(&info, None, None, None, Some(start_date)));
451
452        // Effective date at exactly the end should fail
453        let end_date = UnixNanos::from(1712061000000);
454        assert!(!is_available(&info, None, None, None, Some(end_date)));
455    }
456
457    #[rstest]
458    fn test_complex_scenarios() {
459        // Create instrument with fixed availability 100-200
460        let info = create_test_instrument(100, Some(200));
461
462        // Scenario: Start and end window partially overlaps with availability
463        assert!(is_available(
464            &info,
465            Some(UnixNanos::from(150)),
466            Some(UnixNanos::from(250)),
467            None,
468            None
469        ));
470        assert!(is_available(
471            &info,
472            Some(UnixNanos::from(50)),
473            Some(UnixNanos::from(150)),
474            None,
475            None
476        ));
477
478        // Scenario: Start and end window completely contains availability
479        assert!(is_available(
480            &info,
481            Some(UnixNanos::from(50)),
482            Some(UnixNanos::from(250)),
483            None,
484            None
485        ));
486
487        // Scenario: Start and end window completely within availability
488        assert!(is_available(
489            &info,
490            Some(UnixNanos::from(120)),
491            Some(UnixNanos::from(180)),
492            None,
493            None
494        ));
495
496        // Scenario: Effective date with start/end constraints
497        assert!(is_available(
498            &info,
499            Some(UnixNanos::from(120)),
500            Some(UnixNanos::from(180)),
501            None,
502            Some(UnixNanos::from(150))
503        ));
504
505        // Scenario: Effective date outside start/end constraints but within availability
506        assert!(!is_available(
507            &info,
508            Some(UnixNanos::from(120)),
509            Some(UnixNanos::from(140)),
510            None,
511            Some(UnixNanos::from(150))
512        ));
513    }
514
515    #[rstest]
516    fn test_edge_cases() {
517        // Test with empty "changes" array
518        let mut info = create_test_instrument(100, Some(200));
519        info.changes = Some(vec![]);
520        assert!(is_available(
521            &info,
522            None,
523            None,
524            None,
525            Some(UnixNanos::from(150))
526        ));
527
528        // Test with very large timestamps (near u64::MAX)
529        let far_future_info = create_test_instrument(100, None); // No end date = indefinite future
530        let far_future_date = UnixNanos::from(u64::MAX - 1000);
531        assert!(is_available(
532            &far_future_info,
533            None,
534            None,
535            None,
536            Some(UnixNanos::from(101))
537        ));
538        assert!(is_available(
539            &far_future_info,
540            None,
541            Some(far_future_date),
542            None,
543            None
544        ));
545
546        // Test with offset that increases available_since
547        let info = create_test_instrument(100, Some(200));
548
549        // Adding offset of 50 to available_since (100) makes it 150
550        let offset = UnixNanos::from(50);
551        assert!(!is_available(
552            &info,
553            None,
554            None,
555            Some(offset),
556            Some(UnixNanos::from(149))
557        ));
558        assert!(is_available(
559            &info,
560            None,
561            None,
562            Some(offset),
563            Some(UnixNanos::from(151))
564        ));
565
566        // Test with offset equal to zero (no effect)
567        let zero_offset = UnixNanos::from(0);
568        assert!(!is_available(
569            &info,
570            None,
571            None,
572            Some(zero_offset),
573            Some(UnixNanos::from(100))
574        ));
575        assert!(is_available(
576            &info,
577            None,
578            None,
579            Some(zero_offset),
580            Some(UnixNanos::from(101))
581        ));
582    }
583}