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    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::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#[allow(clippy::too_many_arguments)]
36#[must_use]
37pub fn create_currency_pair(
38    info: &TardisInstrumentInfo,
39    instrument_id: InstrumentId,
40    raw_symbol: Symbol,
41    price_increment: Price,
42    size_increment: Quantity,
43    multiplier: Option<Quantity>,
44    margin_init: Decimal,
45    margin_maint: Decimal,
46    maker_fee: Decimal,
47    taker_fee: Decimal,
48    ts_event: UnixNanos,
49    ts_init: UnixNanos,
50) -> InstrumentAny {
51    InstrumentAny::CurrencyPair(CurrencyPair::new(
52        instrument_id,
53        raw_symbol,
54        get_currency(info.base_currency.to_uppercase().as_str()),
55        get_currency(info.quote_currency.to_uppercase().as_str()),
56        price_increment.precision,
57        size_increment.precision,
58        price_increment,
59        size_increment,
60        multiplier,
61        Some(size_increment),
62        None,
63        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
64        None,
65        None,
66        None,
67        None,
68        Some(margin_init),
69        Some(margin_maint),
70        Some(maker_fee),
71        Some(taker_fee),
72        ts_event,
73        ts_init,
74    ))
75}
76
77#[allow(clippy::too_many_arguments)]
78#[must_use]
79pub fn create_crypto_perpetual(
80    info: &TardisInstrumentInfo,
81    instrument_id: InstrumentId,
82    raw_symbol: Symbol,
83    price_increment: Price,
84    size_increment: Quantity,
85    multiplier: Option<Quantity>,
86    margin_init: Decimal,
87    margin_maint: Decimal,
88    maker_fee: Decimal,
89    taker_fee: Decimal,
90    ts_event: UnixNanos,
91    ts_init: UnixNanos,
92) -> InstrumentAny {
93    let is_inverse = info.inverse.unwrap_or(false);
94
95    InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
96        instrument_id,
97        raw_symbol,
98        get_currency(info.base_currency.to_uppercase().as_str()),
99        get_currency(info.quote_currency.to_uppercase().as_str()),
100        get_currency(parse_settlement_currency(info, is_inverse).as_str()),
101        is_inverse,
102        price_increment.precision,
103        size_increment.precision,
104        price_increment,
105        size_increment,
106        multiplier,
107        Some(size_increment),
108        None,
109        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
110        None,
111        None,
112        None,
113        None,
114        Some(margin_init),
115        Some(margin_maint),
116        Some(maker_fee),
117        Some(taker_fee),
118        ts_event,
119        ts_init,
120    ))
121}
122
123#[allow(clippy::too_many_arguments)]
124#[must_use]
125pub fn create_crypto_future(
126    info: &TardisInstrumentInfo,
127    instrument_id: InstrumentId,
128    raw_symbol: Symbol,
129    activation: UnixNanos,
130    expiration: UnixNanos,
131    price_increment: Price,
132    size_increment: Quantity,
133    multiplier: Option<Quantity>,
134    margin_init: Decimal,
135    margin_maint: Decimal,
136    maker_fee: Decimal,
137    taker_fee: Decimal,
138    ts_event: UnixNanos,
139    ts_init: UnixNanos,
140) -> InstrumentAny {
141    let is_inverse = info.inverse.unwrap_or(false);
142
143    InstrumentAny::CryptoFuture(CryptoFuture::new(
144        instrument_id,
145        raw_symbol,
146        get_currency(info.base_currency.to_uppercase().as_str()),
147        get_currency(info.quote_currency.to_uppercase().as_str()),
148        get_currency(parse_settlement_currency(info, is_inverse).as_str()),
149        is_inverse,
150        activation,
151        expiration,
152        price_increment.precision,
153        size_increment.precision,
154        price_increment,
155        size_increment,
156        multiplier,
157        Some(size_increment),
158        None,
159        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
160        None,
161        None,
162        None,
163        None,
164        Some(margin_init),
165        Some(margin_maint),
166        Some(maker_fee),
167        Some(taker_fee),
168        ts_event,
169        ts_init,
170    ))
171}
172
173#[allow(clippy::too_many_arguments)]
174/// Create a crypto option instrument definition.
175///
176/// # Panics
177///
178/// Panics if the `option_type` field of `InstrumentInfo` is `None`.
179#[must_use]
180pub fn create_crypto_option(
181    info: &TardisInstrumentInfo,
182    instrument_id: InstrumentId,
183    raw_symbol: Symbol,
184    activation: UnixNanos,
185    expiration: UnixNanos,
186    price_increment: Price,
187    size_increment: Quantity,
188    multiplier: Option<Quantity>,
189    margin_init: Decimal,
190    margin_maint: Decimal,
191    maker_fee: Decimal,
192    taker_fee: Decimal,
193    ts_event: UnixNanos,
194    ts_init: UnixNanos,
195) -> InstrumentAny {
196    let is_inverse = info.inverse.unwrap_or(false);
197
198    InstrumentAny::CryptoOption(CryptoOption::new(
199        instrument_id,
200        raw_symbol,
201        get_currency(info.base_currency.to_uppercase().as_str()),
202        get_currency(info.quote_currency.to_uppercase().as_str()),
203        get_currency(parse_settlement_currency(info, is_inverse).as_str()),
204        is_inverse,
205        parse_option_kind(
206            info.option_type
207                .expect("CryptoOption should have `option_type` field"),
208        ),
209        Price::new(
210            info.strike_price
211                .expect("CryptoOption should have `strike_price` field"),
212            price_increment.precision,
213        ),
214        activation,
215        expiration,
216        price_increment.precision,
217        size_increment.precision,
218        price_increment,
219        size_increment,
220        multiplier,
221        Some(size_increment),
222        None,
223        Some(Quantity::from(info.min_trade_amount.to_string().as_str())),
224        None,
225        None,
226        None,
227        None,
228        Some(margin_init),
229        Some(margin_maint),
230        Some(maker_fee),
231        Some(taker_fee),
232        ts_event,
233        ts_init,
234    ))
235}
236
237/// Checks if an instrument is available and valid based on time constraints.
238pub fn is_available(
239    info: &TardisInstrumentInfo,
240    start: Option<UnixNanos>,
241    end: Option<UnixNanos>,
242    available_offset: Option<UnixNanos>,
243    effective: Option<UnixNanos>,
244) -> bool {
245    let available_since =
246        UnixNanos::from(info.available_since) + available_offset.unwrap_or_default();
247    let available_to = info.available_to.map_or(UnixNanos::max(), UnixNanos::from);
248
249    if let Some(effective_date) = effective {
250        // Effective date must be within availability period
251        if available_since >= effective_date || available_to <= effective_date {
252            return false;
253        }
254
255        // Effective date must be within requested [start, end] if provided
256        if start.is_some_and(|s| effective_date < s) || end.is_some_and(|e| effective_date > e) {
257            return false;
258        }
259    } else {
260        // Otherwise check for overlap between [available_since, available_to] and [start, end]
261        if start.is_some_and(|s| available_to < s) || end.is_some_and(|e| available_since > e) {
262            return false;
263        }
264    }
265
266    true
267}
268
269#[cfg(test)]
270mod tests {
271    use rstest::rstest;
272
273    use super::*;
274    use crate::tests::load_test_json;
275
276    // Helper to create a basic instrument info for testing
277    fn create_test_instrument(
278        available_since: u64,
279        available_to: Option<u64>,
280    ) -> TardisInstrumentInfo {
281        let json_data = load_test_json("instrument_spot.json");
282        let mut info: TardisInstrumentInfo = 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    #[rstest]
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    #[rstest]
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    #[rstest]
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    #[rstest]
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    #[rstest]
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}