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