Skip to main content

nautilus_tardis/http/
parse.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 jiff::Timestamp;
17use nautilus_core::UnixNanos;
18use nautilus_model::{
19    identifiers::Symbol,
20    instruments::InstrumentAny,
21    types::{Currency, Price, Quantity},
22};
23use rust_decimal_macros::dec;
24
25use super::{
26    instruments::{
27        create_crypto_future, create_crypto_option, create_crypto_perpetual, create_currency_pair,
28        get_currency,
29    },
30    models::TardisInstrumentInfo,
31};
32use crate::common::{
33    enums::TardisInstrumentType,
34    parse::{normalize_instrument_id, parse_instrument_id},
35};
36
37#[must_use]
38pub fn parse_instrument_any(
39    info: &TardisInstrumentInfo,
40    effective: Option<UnixNanos>,
41    ts_init: Option<UnixNanos>,
42    normalize_symbols: bool,
43) -> Vec<InstrumentAny> {
44    match info.instrument_type {
45        TardisInstrumentType::Spot => {
46            parse_spot_instrument(info, effective, ts_init, normalize_symbols)
47        }
48        TardisInstrumentType::Perpetual => {
49            parse_perp_instrument(info, effective, ts_init, normalize_symbols)
50        }
51        TardisInstrumentType::Future | TardisInstrumentType::Combo => {
52            parse_future_instrument(info, effective, ts_init, normalize_symbols)
53        }
54        TardisInstrumentType::Option => {
55            match parse_option_instrument(info, effective, ts_init, normalize_symbols) {
56                Ok(instruments) => instruments,
57                Err(e) => {
58                    log::error!("Failed to parse option instrument: {e}");
59                    vec![]
60                }
61            }
62        }
63    }
64}
65
66fn parse_spot_instrument(
67    info: &TardisInstrumentInfo,
68    effective: Option<UnixNanos>,
69    ts_init: Option<UnixNanos>,
70    normalize_symbols: bool,
71) -> Vec<InstrumentAny> {
72    let instrument_id = if normalize_symbols {
73        normalize_instrument_id(&info.exchange, info.id, &info.instrument_type, info.inverse)
74    } else {
75        parse_instrument_id(&info.exchange, info.id)
76    };
77    let raw_symbol = Symbol::new(info.id);
78    let margin_init = dec!(0); // TBD
79    let margin_maint = dec!(0); // TBD
80
81    let mut price_increment = parse_price_increment(info.price_increment);
82    let base_currency = get_currency(info.base_currency.to_uppercase().as_str());
83    let mut size_increment = parse_spot_size_increment(info.amount_increment, base_currency);
84    let mut multiplier = parse_multiplier(info.contract_multiplier);
85    let mut ts_event = info
86        .changes
87        .as_ref()
88        .and_then(|changes| changes.last().map(|c| UnixNanos::from(c.until)))
89        .unwrap_or_else(|| UnixNanos::from(info.available_since));
90
91    // Current instrument definition
92    let mut instruments = vec![create_currency_pair(
93        info,
94        instrument_id,
95        raw_symbol,
96        price_increment,
97        size_increment,
98        multiplier,
99        margin_init,
100        margin_maint,
101        ts_event,
102        ts_init.unwrap_or(ts_event),
103    )];
104
105    if let Some(changes) = &info.changes {
106        // Sort changes newest to oldest
107        let mut sorted_changes = changes.clone();
108        sorted_changes.sort_by_key(|b| std::cmp::Reverse(b.until));
109
110        if let Some(effective_time) = effective {
111            // Apply changes where change.until >= effective_time
112            for (i, change) in sorted_changes.iter().enumerate() {
113                if change.price_increment.is_none()
114                    && change.amount_increment.is_none()
115                    && change.contract_multiplier.is_none()
116                {
117                    continue; // No changes to apply (already pushed current definition)
118                }
119
120                ts_event = UnixNanos::from(change.until);
121
122                if ts_event < effective_time {
123                    break; // Early exit since changes are sorted newest to oldest
124                } else if i == sorted_changes.len() - 1 {
125                    ts_event = UnixNanos::from(info.available_since);
126                }
127
128                price_increment = change
129                    .price_increment
130                    .map_or(price_increment, parse_price_increment);
131                size_increment = change.amount_increment.map_or(size_increment, |value| {
132                    parse_spot_size_increment(value, base_currency)
133                });
134                multiplier = match change.contract_multiplier {
135                    Some(value) => Some(Quantity::from(value.to_string())),
136                    None => multiplier,
137                };
138            }
139
140            // Replace with single instrument reflecting effective state
141            instruments = vec![create_currency_pair(
142                info,
143                instrument_id,
144                raw_symbol,
145                price_increment,
146                size_increment,
147                multiplier,
148                margin_init,
149                margin_maint,
150                ts_event,
151                ts_init.unwrap_or(ts_event),
152            )];
153        } else {
154            // Historical sequence with all states
155            for (i, change) in sorted_changes.iter().enumerate() {
156                if change.price_increment.is_none()
157                    && change.amount_increment.is_none()
158                    && change.contract_multiplier.is_none()
159                {
160                    continue; // No changes to apply (already pushed current definition)
161                }
162
163                price_increment = change
164                    .price_increment
165                    .map_or(price_increment, parse_price_increment);
166                size_increment = change.amount_increment.map_or(size_increment, |value| {
167                    parse_spot_size_increment(value, base_currency)
168                });
169                multiplier = match change.contract_multiplier {
170                    Some(value) => Some(Quantity::from(value.to_string())),
171                    None => multiplier,
172                };
173
174                // Get the timestamp for when the change occurred
175                ts_event = if i == sorted_changes.len() - 1 {
176                    UnixNanos::from(info.available_since)
177                } else {
178                    UnixNanos::from(change.until)
179                };
180
181                instruments.push(create_currency_pair(
182                    info,
183                    instrument_id,
184                    raw_symbol,
185                    price_increment,
186                    size_increment,
187                    multiplier,
188                    margin_init,
189                    margin_maint,
190                    ts_event,
191                    ts_init.unwrap_or(ts_event),
192                ));
193            }
194
195            // Sort in ascending (chronological) order
196            instruments.reverse();
197        }
198    }
199
200    instruments
201}
202
203fn parse_perp_instrument(
204    info: &TardisInstrumentInfo,
205    effective: Option<UnixNanos>,
206    ts_init: Option<UnixNanos>,
207    normalize_symbols: bool,
208) -> Vec<InstrumentAny> {
209    let instrument_id = if normalize_symbols {
210        normalize_instrument_id(&info.exchange, info.id, &info.instrument_type, info.inverse)
211    } else {
212        parse_instrument_id(&info.exchange, info.id)
213    };
214    let raw_symbol = Symbol::new(info.id);
215    let margin_init = dec!(0); // TBD
216    let margin_maint = dec!(0); // TBD
217
218    let mut price_increment = parse_price_increment(info.price_increment);
219    let mut size_increment = parse_size_increment(info.amount_increment);
220    let mut multiplier = parse_multiplier(info.contract_multiplier);
221    let mut ts_event = info
222        .changes
223        .as_ref()
224        .and_then(|changes| changes.last().map(|c| UnixNanos::from(c.until)))
225        .unwrap_or_else(|| UnixNanos::from(info.available_since));
226
227    // Current instrument definition
228    let mut instruments = vec![create_crypto_perpetual(
229        info,
230        instrument_id,
231        raw_symbol,
232        price_increment,
233        size_increment,
234        multiplier,
235        margin_init,
236        margin_maint,
237        ts_event,
238        ts_init.unwrap_or(ts_event),
239    )];
240
241    if let Some(changes) = &info.changes {
242        // Sort changes newest to oldest
243        let mut sorted_changes = changes.clone();
244        sorted_changes.sort_by_key(|b| std::cmp::Reverse(b.until));
245
246        if let Some(effective_time) = effective {
247            // Apply changes where change.until >= effective_time
248            for (i, change) in sorted_changes.iter().enumerate() {
249                if change.price_increment.is_none()
250                    && change.amount_increment.is_none()
251                    && change.contract_multiplier.is_none()
252                {
253                    continue; // No changes to apply (already pushed current definition)
254                }
255
256                ts_event = UnixNanos::from(change.until);
257
258                if ts_event < effective_time {
259                    break; // Early exit since changes are sorted newest to oldest
260                } else if i == sorted_changes.len() - 1 {
261                    ts_event = UnixNanos::from(info.available_since);
262                }
263
264                price_increment = change
265                    .price_increment
266                    .map_or(price_increment, parse_price_increment);
267                size_increment = change
268                    .amount_increment
269                    .map_or(size_increment, parse_size_increment);
270                multiplier = match change.contract_multiplier {
271                    Some(value) => Some(Quantity::from(value.to_string())),
272                    None => multiplier,
273                };
274            }
275
276            // Replace with single instrument reflecting effective state
277            instruments = vec![create_crypto_perpetual(
278                info,
279                instrument_id,
280                raw_symbol,
281                price_increment,
282                size_increment,
283                multiplier,
284                margin_init,
285                margin_maint,
286                ts_event,
287                ts_init.unwrap_or(ts_event),
288            )];
289        } else {
290            // Historical view with all states
291            for (i, change) in sorted_changes.iter().enumerate() {
292                if change.price_increment.is_none()
293                    && change.amount_increment.is_none()
294                    && change.contract_multiplier.is_none()
295                {
296                    continue; // No changes to apply (already pushed current definition)
297                }
298
299                price_increment = change
300                    .price_increment
301                    .map_or(price_increment, parse_price_increment);
302                size_increment = change
303                    .amount_increment
304                    .map_or(size_increment, parse_size_increment);
305                multiplier = match change.contract_multiplier {
306                    Some(value) => Some(Quantity::from(value.to_string())),
307                    None => multiplier,
308                };
309
310                // Get the timestamp for when the change occurred
311                ts_event = if i == sorted_changes.len() - 1 {
312                    UnixNanos::from(info.available_since)
313                } else {
314                    UnixNanos::from(change.until)
315                };
316
317                instruments.push(create_crypto_perpetual(
318                    info,
319                    instrument_id,
320                    raw_symbol,
321                    price_increment,
322                    size_increment,
323                    multiplier,
324                    margin_init,
325                    margin_maint,
326                    ts_event,
327                    ts_init.unwrap_or(ts_event),
328                ));
329            }
330
331            // Sort in ascending (chronological) order
332            instruments.reverse();
333        }
334    }
335
336    instruments
337}
338
339fn parse_future_instrument(
340    info: &TardisInstrumentInfo,
341    effective: Option<UnixNanos>,
342    ts_init: Option<UnixNanos>,
343    normalize_symbols: bool,
344) -> Vec<InstrumentAny> {
345    let instrument_id = if normalize_symbols {
346        normalize_instrument_id(&info.exchange, info.id, &info.instrument_type, info.inverse)
347    } else {
348        parse_instrument_id(&info.exchange, info.id)
349    };
350    let raw_symbol = Symbol::new(info.id);
351    let activation = parse_datetime_to_unix_nanos(Some(info.available_since));
352    let expiration = parse_datetime_to_unix_nanos(info.expiry);
353    let margin_init = dec!(0); // TBD
354    let margin_maint = dec!(0); // TBD
355
356    let mut price_increment = parse_price_increment(info.price_increment);
357    let mut size_increment = parse_size_increment(info.amount_increment);
358    let mut multiplier = parse_multiplier(info.contract_multiplier);
359    let mut ts_event = info
360        .changes
361        .as_ref()
362        .and_then(|changes| changes.last().map(|c| UnixNanos::from(c.until)))
363        .unwrap_or_else(|| UnixNanos::from(info.available_since));
364
365    // Current instrument definition
366    let mut instruments = vec![create_crypto_future(
367        info,
368        instrument_id,
369        raw_symbol,
370        activation,
371        expiration,
372        price_increment,
373        size_increment,
374        multiplier,
375        margin_init,
376        margin_maint,
377        ts_event,
378        ts_init.unwrap_or(ts_event),
379    )];
380
381    if let Some(changes) = &info.changes {
382        // Sort changes newest to oldest
383        let mut sorted_changes = changes.clone();
384        sorted_changes.sort_by_key(|b| std::cmp::Reverse(b.until));
385
386        if let Some(effective_time) = effective {
387            // Apply changes where change.until >= effective_time
388            for (i, change) in sorted_changes.iter().enumerate() {
389                if change.price_increment.is_none()
390                    && change.amount_increment.is_none()
391                    && change.contract_multiplier.is_none()
392                {
393                    continue; // No changes to apply (already pushed current definition)
394                }
395
396                ts_event = UnixNanos::from(change.until);
397
398                if ts_event < effective_time {
399                    break; // Early exit since changes are sorted newest to oldest
400                } else if i == sorted_changes.len() - 1 {
401                    ts_event = UnixNanos::from(info.available_since);
402                }
403
404                price_increment = change
405                    .price_increment
406                    .map_or(price_increment, parse_price_increment);
407                size_increment = change
408                    .amount_increment
409                    .map_or(size_increment, parse_size_increment);
410                multiplier = match change.contract_multiplier {
411                    Some(value) => Some(Quantity::from(value.to_string())),
412                    None => multiplier,
413                };
414            }
415
416            // Replace with single instrument reflecting effective state
417            instruments = vec![create_crypto_future(
418                info,
419                instrument_id,
420                raw_symbol,
421                activation,
422                expiration,
423                price_increment,
424                size_increment,
425                multiplier,
426                margin_init,
427                margin_maint,
428                ts_event,
429                ts_init.unwrap_or(ts_event),
430            )];
431        } else {
432            // Historical view with all states
433            for (i, change) in sorted_changes.iter().enumerate() {
434                if change.price_increment.is_none()
435                    && change.amount_increment.is_none()
436                    && change.contract_multiplier.is_none()
437                {
438                    continue; // No changes to apply (already pushed current definition)
439                }
440
441                price_increment = change
442                    .price_increment
443                    .map_or(price_increment, parse_price_increment);
444                size_increment = change
445                    .amount_increment
446                    .map_or(size_increment, parse_size_increment);
447                multiplier = match change.contract_multiplier {
448                    Some(value) => Some(Quantity::from(value.to_string())),
449                    None => multiplier,
450                };
451
452                // Get the timestamp for when the change occurred
453                ts_event = if i == sorted_changes.len() - 1 {
454                    UnixNanos::from(info.available_since)
455                } else {
456                    UnixNanos::from(change.until)
457                };
458
459                instruments.push(create_crypto_future(
460                    info,
461                    instrument_id,
462                    raw_symbol,
463                    activation,
464                    expiration,
465                    price_increment,
466                    size_increment,
467                    multiplier,
468                    margin_init,
469                    margin_maint,
470                    ts_event,
471                    ts_init.unwrap_or(ts_event),
472                ));
473            }
474
475            // Sort in ascending (chronological) order
476            instruments.reverse();
477        }
478    }
479
480    instruments
481}
482
483fn parse_option_instrument(
484    info: &TardisInstrumentInfo,
485    effective: Option<UnixNanos>,
486    ts_init: Option<UnixNanos>,
487    normalize_symbols: bool,
488) -> anyhow::Result<Vec<InstrumentAny>> {
489    let instrument_id = if normalize_symbols {
490        normalize_instrument_id(&info.exchange, info.id, &info.instrument_type, info.inverse)
491    } else {
492        parse_instrument_id(&info.exchange, info.id)
493    };
494    let raw_symbol = Symbol::new(info.id);
495    let activation = parse_datetime_to_unix_nanos(Some(info.available_since));
496    let expiration = parse_datetime_to_unix_nanos(info.expiry);
497    let margin_init = dec!(0); // TBD
498    let margin_maint = dec!(0); // TBD
499
500    let mut price_increment = parse_price_increment(info.price_increment);
501    let mut size_increment = parse_size_increment(info.amount_increment);
502    let mut multiplier = parse_multiplier(info.contract_multiplier);
503    let mut ts_event = info
504        .changes
505        .as_ref()
506        .and_then(|changes| changes.last().map(|c| UnixNanos::from(c.until)))
507        .unwrap_or_else(|| UnixNanos::from(info.available_since));
508
509    // Current instrument definition
510    let mut instruments = vec![create_crypto_option(
511        info,
512        instrument_id,
513        raw_symbol,
514        activation,
515        expiration,
516        price_increment,
517        size_increment,
518        multiplier,
519        margin_init,
520        margin_maint,
521        ts_event,
522        ts_init.unwrap_or(ts_event),
523    )?];
524
525    if let Some(changes) = &info.changes {
526        // Sort changes newest to oldest
527        let mut sorted_changes = changes.clone();
528        sorted_changes.sort_by_key(|b| std::cmp::Reverse(b.until));
529
530        if let Some(effective_time) = effective {
531            // Apply changes where change.until >= effective_time
532            for (i, change) in sorted_changes.iter().enumerate() {
533                if change.price_increment.is_none()
534                    && change.amount_increment.is_none()
535                    && change.contract_multiplier.is_none()
536                {
537                    continue; // No changes to apply (already pushed current definition)
538                }
539
540                ts_event = UnixNanos::from(change.until);
541
542                if ts_event < effective_time {
543                    break; // Early exit since changes are sorted newest to oldest
544                } else if i == sorted_changes.len() - 1 {
545                    ts_event = UnixNanos::from(info.available_since);
546                }
547
548                price_increment = change
549                    .price_increment
550                    .map_or(price_increment, parse_price_increment);
551                size_increment = change
552                    .amount_increment
553                    .map_or(size_increment, parse_size_increment);
554                multiplier = match change.contract_multiplier {
555                    Some(value) => Some(Quantity::from(value.to_string())),
556                    None => multiplier,
557                };
558            }
559
560            // Replace with single instrument reflecting effective state
561            instruments = vec![create_crypto_option(
562                info,
563                instrument_id,
564                raw_symbol,
565                activation,
566                expiration,
567                price_increment,
568                size_increment,
569                multiplier,
570                margin_init,
571                margin_maint,
572                ts_event,
573                ts_init.unwrap_or(ts_event),
574            )?];
575        } else {
576            // Historical view with all states
577            for (i, change) in sorted_changes.iter().enumerate() {
578                if change.price_increment.is_none()
579                    && change.amount_increment.is_none()
580                    && change.contract_multiplier.is_none()
581                {
582                    continue; // No changes to apply (already pushed current definition)
583                }
584
585                price_increment = change
586                    .price_increment
587                    .map_or(price_increment, parse_price_increment);
588                size_increment = change
589                    .amount_increment
590                    .map_or(size_increment, parse_size_increment);
591                multiplier = match change.contract_multiplier {
592                    Some(value) => Some(Quantity::from(value.to_string())),
593                    None => multiplier,
594                };
595
596                // Get the timestamp for when the change occurred
597                ts_event = if i == sorted_changes.len() - 1 {
598                    UnixNanos::from(info.available_since)
599                } else {
600                    UnixNanos::from(change.until)
601                };
602
603                instruments.push(create_crypto_option(
604                    info,
605                    instrument_id,
606                    raw_symbol,
607                    activation,
608                    expiration,
609                    price_increment,
610                    size_increment,
611                    multiplier,
612                    margin_init,
613                    margin_maint,
614                    ts_event,
615                    ts_init.unwrap_or(ts_event),
616                )?);
617            }
618
619            // Sort in ascending (chronological) order
620            instruments.reverse();
621        }
622    }
623
624    Ok(instruments)
625}
626
627/// Parses the price increment from the given `value`.
628fn parse_price_increment(value: f64) -> Price {
629    Price::from(value.to_string())
630}
631
632/// Parses the size increment from the given `value`.
633fn parse_size_increment(value: f64) -> Quantity {
634    Quantity::from(value.to_string())
635}
636
637/// Parses the spot size increment from the given `value`.
638fn parse_spot_size_increment(value: f64, currency: Currency) -> Quantity {
639    if value == 0.0 {
640        let exponent = -i32::from(currency.precision);
641        Quantity::from(format!("{}", 10.0_f64.powi(exponent)))
642    } else {
643        Quantity::from(value.to_string())
644    }
645}
646
647/// Parses the multiplier from the given `value`.
648fn parse_multiplier(value: Option<f64>) -> Option<Quantity> {
649    value.map(|x| Quantity::from(x.to_string()))
650}
651
652/// Parses the given RFC 3339 datetime string (UTC) into a `UnixNanos` timestamp.
653/// If `value` is `None`, then defaults to the UNIX epoch (0 nanoseconds).
654/// Timestamps before UNIX epoch (negative values) default to 0.
655fn parse_datetime_to_unix_nanos(value: Option<Timestamp>) -> UnixNanos {
656    value
657        .map(|dt| match u64::try_from(dt.as_nanosecond()) {
658            Ok(nanos) => UnixNanos::from(nanos),
659            Err(_) => {
660                log::warn!("Timestamp {dt} is outside the UnixNanos range, defaulting to 0");
661                UnixNanos::default()
662            }
663        })
664        .unwrap_or_default()
665}
666
667/// Parses the settlement currency for the given Tardis instrument definition.
668#[must_use]
669pub fn parse_settlement_currency(info: &TardisInstrumentInfo, is_inverse: bool) -> String {
670    info.settlement_currency
671        .unwrap_or({
672            if is_inverse {
673                info.base_currency
674            } else {
675                info.quote_currency
676            }
677        })
678        .to_uppercase()
679}
680
681#[cfg(test)]
682mod tests {
683    use nautilus_model::{identifiers::InstrumentId, instruments::Instrument};
684    use rstest::rstest;
685
686    use super::*;
687    use crate::common::testing::load_test_json;
688
689    #[rstest]
690    fn test_parse_instrument_spot() {
691        let json_data = load_test_json("instrument_spot.json");
692        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
693
694        let instruments = parse_instrument_any(&info, None, None, false);
695        let inst0 = instruments[0].clone();
696        let inst1 = instruments[1].clone();
697
698        assert_eq!(inst0.id(), InstrumentId::from("BTC_USDC.DERIBIT"));
699        assert_eq!(inst0.raw_symbol(), Symbol::from("BTC_USDC"));
700        assert_eq!(inst0.underlying(), None);
701        assert_eq!(inst0.base_currency(), Some(Currency::BTC()));
702        assert_eq!(inst0.quote_currency(), Currency::USDC());
703        assert_eq!(inst0.settlement_currency(), Currency::USDC());
704        assert!(!inst0.is_inverse());
705        assert_eq!(inst0.price_precision(), 2);
706        assert_eq!(inst0.size_precision(), 4);
707        assert_eq!(inst0.price_increment(), Price::from("0.01"));
708        assert_eq!(inst0.size_increment(), Quantity::from("0.0001"));
709        assert_eq!(inst0.multiplier(), Quantity::from(1));
710        assert_eq!(inst0.activation_ns(), None);
711        assert_eq!(inst0.expiration_ns(), None);
712        assert_eq!(inst0.lot_size(), Some(Quantity::from("0.0001")));
713        assert_eq!(inst0.min_quantity(), Some(Quantity::from("0.0001")));
714        assert_eq!(inst0.max_quantity(), None);
715        assert_eq!(inst0.min_notional(), None);
716        assert_eq!(inst0.max_notional(), None);
717        assert_eq!(inst0.ts_event().to_rfc3339(), "2023-04-24T00:00:00+00:00");
718        assert_eq!(inst0.ts_init().to_rfc3339(), "2023-04-24T00:00:00+00:00");
719
720        assert_eq!(inst1.id(), InstrumentId::from("BTC_USDC.DERIBIT"));
721        assert_eq!(inst1.raw_symbol(), Symbol::from("BTC_USDC"));
722        assert_eq!(inst1.underlying(), None);
723        assert_eq!(inst1.base_currency(), Some(Currency::BTC()));
724        assert_eq!(inst1.quote_currency(), Currency::USDC());
725        assert_eq!(inst1.settlement_currency(), Currency::USDC());
726        assert!(!inst1.is_inverse());
727        assert_eq!(inst1.price_precision(), 0); // Changed
728        assert_eq!(inst1.size_precision(), 4);
729        assert_eq!(inst1.price_increment(), Price::from("1")); // <-- Changed
730        assert_eq!(inst1.size_increment(), Quantity::from("0.0001"));
731        assert_eq!(inst1.multiplier(), Quantity::from(1));
732        assert_eq!(inst1.activation_ns(), None);
733        assert_eq!(inst1.expiration_ns(), None);
734        assert_eq!(inst1.lot_size(), Some(Quantity::from("0.0001")));
735        assert_eq!(inst1.min_quantity(), Some(Quantity::from("0.0001")));
736        assert_eq!(inst1.max_quantity(), None);
737        assert_eq!(inst1.min_notional(), None);
738        assert_eq!(inst1.max_notional(), None);
739        assert_eq!(inst1.ts_event().to_rfc3339(), "2024-04-02T12:10:00+00:00");
740        assert_eq!(inst1.ts_init().to_rfc3339(), "2024-04-02T12:10:00+00:00");
741    }
742
743    #[rstest]
744    fn test_parse_instrument_perpetual() {
745        let json_data = load_test_json("instrument_perpetual.json");
746        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
747
748        let effective = UnixNanos::from("2020-08-01T08:00:00+00:00");
749        let instrument =
750            parse_instrument_any(&info, Some(effective), Some(UnixNanos::default()), false)
751                .first()
752                .unwrap()
753                .clone();
754
755        assert_eq!(instrument.id(), InstrumentId::from("XBTUSD.BITMEX"));
756        assert_eq!(instrument.raw_symbol(), Symbol::from("XBTUSD"));
757        assert_eq!(instrument.underlying(), None);
758        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
759        assert_eq!(instrument.quote_currency(), Currency::USD());
760        assert_eq!(instrument.settlement_currency(), Currency::BTC());
761        assert!(instrument.is_inverse());
762        assert_eq!(instrument.price_precision(), 1);
763        assert_eq!(instrument.size_precision(), 0);
764        assert_eq!(instrument.price_increment(), Price::from("0.5"));
765        assert_eq!(instrument.size_increment(), Quantity::from(1));
766        assert_eq!(instrument.multiplier(), Quantity::from(1));
767        assert_eq!(instrument.activation_ns(), None);
768        assert_eq!(instrument.expiration_ns(), None);
769        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
770        assert_eq!(instrument.min_quantity(), Some(Quantity::from(100)));
771        assert_eq!(instrument.max_quantity(), None);
772        assert_eq!(instrument.min_notional(), None);
773        assert_eq!(instrument.max_notional(), None);
774    }
775
776    #[rstest]
777    fn test_parse_instrument_future() {
778        let json_data = load_test_json("instrument_future.json");
779        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
780
781        let instrument = parse_instrument_any(&info, None, Some(UnixNanos::default()), false)
782            .first()
783            .unwrap()
784            .clone();
785
786        assert_eq!(instrument.id(), InstrumentId::from("BTC-14FEB25.DERIBIT"));
787        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-14FEB25"));
788        assert_eq!(instrument.underlying().unwrap().as_str(), "BTC");
789        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
790        assert_eq!(instrument.quote_currency(), Currency::USD());
791        assert_eq!(instrument.settlement_currency(), Currency::BTC());
792        assert!(instrument.is_inverse());
793        assert_eq!(instrument.price_precision(), 1); // from priceIncrement 2.5
794        assert_eq!(instrument.size_precision(), 0); // from amountIncrement 10
795        assert_eq!(instrument.price_increment(), Price::from("2.5"));
796        assert_eq!(instrument.size_increment(), Quantity::from(10));
797        assert_eq!(instrument.multiplier(), Quantity::from(1));
798        assert_eq!(
799            instrument.activation_ns(),
800            Some(UnixNanos::from(1_738_281_600_000_000_000))
801        );
802        assert_eq!(
803            instrument.expiration_ns(),
804            Some(UnixNanos::from(1_739_520_000_000_000_000))
805        );
806        assert_eq!(instrument.lot_size(), Some(Quantity::from(10)));
807        assert_eq!(instrument.min_quantity(), Some(Quantity::from(10)));
808        assert_eq!(instrument.max_quantity(), None);
809        assert_eq!(instrument.min_notional(), None);
810        assert_eq!(instrument.max_notional(), None);
811    }
812
813    #[rstest]
814    fn test_parse_instrument_perpetual_current() {
815        let json_data = load_test_json("instrument_perpetual.json");
816        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
817
818        let instrument = parse_instrument_any(&info, None, Some(UnixNanos::default()), false)
819            .last()
820            .unwrap()
821            .clone();
822
823        assert_eq!(instrument.id(), InstrumentId::from("XBTUSD.BITMEX"));
824        assert_eq!(instrument.raw_symbol(), Symbol::from("XBTUSD"));
825        assert_eq!(instrument.size_increment(), Quantity::from(100));
826        assert_eq!(instrument.lot_size(), Some(Quantity::from(100)));
827        assert_eq!(instrument.min_quantity(), Some(Quantity::from(100)));
828    }
829
830    #[rstest]
831    fn test_parse_instrument_combo() {
832        let json_data = load_test_json("instrument_combo.json");
833        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
834
835        let instrument = parse_instrument_any(&info, None, Some(UnixNanos::default()), false)
836            .first()
837            .unwrap()
838            .clone();
839
840        assert_eq!(
841            instrument.id(),
842            InstrumentId::from("BTC-FS-28MAR25_PERP.DERIBIT")
843        );
844        assert_eq!(instrument.raw_symbol(), Symbol::from("BTC-FS-28MAR25_PERP"));
845        assert_eq!(instrument.underlying().unwrap().as_str(), "BTC");
846        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
847        assert_eq!(instrument.quote_currency(), Currency::USD());
848        assert_eq!(instrument.settlement_currency(), Currency::BTC());
849        assert!(instrument.is_inverse());
850        assert_eq!(instrument.price_precision(), 1); // from priceIncrement 0.5
851        assert_eq!(instrument.size_precision(), 0); // from amountIncrement 10
852        assert_eq!(instrument.price_increment(), Price::from("0.5"));
853        assert_eq!(instrument.size_increment(), Quantity::from(10));
854        assert_eq!(instrument.multiplier(), Quantity::from(1));
855        assert_eq!(
856            instrument.activation_ns(),
857            Some(UnixNanos::from(1_711_670_400_000_000_000))
858        );
859        assert_eq!(
860            instrument.expiration_ns(),
861            Some(UnixNanos::from(1_743_148_800_000_000_000))
862        );
863        assert_eq!(instrument.lot_size(), Some(Quantity::from(10)));
864        assert_eq!(instrument.min_quantity(), Some(Quantity::from(10)));
865        assert_eq!(instrument.max_quantity(), None);
866        assert_eq!(instrument.min_notional(), None);
867        assert_eq!(instrument.max_notional(), None);
868    }
869
870    #[rstest]
871    fn test_parse_instrument_option() {
872        let json_data = load_test_json("instrument_option.json");
873        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
874
875        let instrument = parse_instrument_any(&info, None, Some(UnixNanos::default()), false)
876            .first()
877            .unwrap()
878            .clone();
879
880        assert_eq!(
881            instrument.id(),
882            InstrumentId::from("BTC-25APR25-200000-P.DERIBIT")
883        );
884        assert_eq!(
885            instrument.raw_symbol(),
886            Symbol::from("BTC-25APR25-200000-P")
887        );
888        assert_eq!(instrument.underlying().unwrap().as_str(), "BTC");
889        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
890        assert_eq!(instrument.quote_currency(), Currency::BTC());
891        assert_eq!(instrument.settlement_currency(), Currency::BTC());
892        assert!(instrument.is_inverse());
893        assert_eq!(instrument.price_precision(), 4);
894        assert_eq!(instrument.size_precision(), 1); // from amountIncrement 0.1
895        assert_eq!(instrument.price_increment(), Price::from("0.0001"));
896        assert_eq!(instrument.size_increment(), Quantity::from("0.1"));
897        assert_eq!(instrument.multiplier(), Quantity::from(1));
898        assert_eq!(
899            instrument.activation_ns(),
900            Some(UnixNanos::from(1_738_281_600_000_000_000))
901        );
902        assert_eq!(
903            instrument.expiration_ns(),
904            Some(UnixNanos::from(1_745_568_000_000_000_000))
905        );
906        assert_eq!(instrument.lot_size(), Some(Quantity::from("0.1")));
907        assert_eq!(instrument.min_quantity(), Some(Quantity::from("0.1")));
908        assert_eq!(instrument.max_quantity(), None);
909        assert_eq!(instrument.min_notional(), None);
910        assert_eq!(instrument.max_notional(), None);
911    }
912
913    #[rstest]
914    fn test_parse_instrument_info_populated() {
915        let json_data = load_test_json("instrument_perpetual.json");
916        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
917
918        let instrument = parse_instrument_any(&info, None, Some(UnixNanos::default()), false)
919            .last()
920            .unwrap()
921            .clone();
922
923        let InstrumentAny::CryptoPerpetual(perp) = instrument else {
924            panic!("Expected CryptoPerpetual variant");
925        };
926
927        let info_params = perp.info.expect("info should be populated");
928
929        // Modeled fields present
930        assert!(info_params.get("exchange").is_some());
931        assert!(info_params.get("baseCurrency").is_some());
932        assert!(info_params.get("quoteCurrency").is_some());
933
934        // Extra (unmodeled) fields preserved via #[serde(flatten)]
935        assert_eq!(info_params.get_str("datasetId"), Some("XBTUSD"),);
936        assert_eq!(
937            info_params.get_str("contractType"),
938            Some("inverse_perpetual"),
939        );
940        assert_eq!(info_params.get_str("underlyingIndex"), Some(".BXBT"),);
941
942        // Absent optional fields omitted (not null)
943        assert!(info_params.get("listing").is_none());
944        assert!(info_params.get("expiry").is_none());
945    }
946
947    #[rstest]
948    fn test_parse_okex_xperp_instrument() {
949        let json_data = load_test_json("okex_futures_xperp_instrument.json");
950        let info: TardisInstrumentInfo = serde_json::from_str(&json_data).unwrap();
951
952        let instruments = parse_instrument_any(&info, None, Some(UnixNanos::default()), false);
953        let instrument = instruments.first().unwrap().clone();
954
955        assert_eq!(instruments.len(), 1);
956        assert_eq!(info.instrument_type, TardisInstrumentType::Future);
957        assert_eq!(
958            instrument.id(),
959            InstrumentId::from("BTC-USD_UM_XPERP-310404.OKEX")
960        );
961        assert_eq!(
962            instrument.raw_symbol(),
963            Symbol::from("BTC-USD_UM_XPERP-310404")
964        );
965        assert_eq!(instrument.underlying().unwrap().as_str(), "BTC");
966        assert_eq!(instrument.base_currency(), Some(Currency::BTC()));
967        assert_eq!(instrument.quote_currency().code, "USD_UM_XPERP");
968        assert_eq!(instrument.settlement_currency().code, "USD_UM_XPERP");
969        assert!(!instrument.is_inverse());
970        assert_eq!(instrument.price_precision(), 1);
971        assert_eq!(instrument.size_precision(), 0);
972        assert_eq!(instrument.price_increment(), Price::from("0.1"));
973        assert_eq!(instrument.size_increment(), Quantity::from(1));
974        assert_eq!(instrument.multiplier(), Quantity::from("0.0001"));
975        assert_eq!(
976            instrument.activation_ns(),
977            Some(UnixNanos::from("2026-03-30T00:00:00Z"))
978        );
979        assert_eq!(
980            instrument.expiration_ns(),
981            Some(UnixNanos::from("2031-04-04T08:00:00Z"))
982        );
983        assert_eq!(instrument.lot_size(), Some(Quantity::from(1)));
984        assert_eq!(instrument.min_quantity(), Some(Quantity::from(1)));
985        assert_eq!(instrument.max_quantity(), None);
986        assert_eq!(
987            instrument.ts_event(),
988            UnixNanos::from("2026-03-30T00:00:00Z")
989        );
990
991        let InstrumentAny::CryptoFuture(future) = instrument else {
992            panic!("Expected CryptoFuture variant");
993        };
994
995        let info_params = future.info.expect("info should be populated");
996
997        // X-Perp contract metadata added by tardis-node 17.2.0 is unmodeled and preserved
998        assert_eq!(info_params.get_str("contractType"), Some("linear_xperp"));
999        assert_eq!(info_params.get_str("underlyingType"), Some("native"));
1000        assert_eq!(info_params.get_str("underlyingIndex"), Some("BTC-USD"));
1001    }
1002}