nautilus_tardis/http/
instruments.rs

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