Skip to main content

nautilus_tardis/http/
instruments.rs

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