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