Skip to main content

nautilus_derive/common/
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
16//! Parsing utilities for the Derive adapter.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{
22    UnixNanos,
23    datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
24    params::Params,
25};
26use nautilus_model::{
27    enums::{OptionKind, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
28    identifiers::{InstrumentId, Symbol},
29    instruments::{CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
30    types::{Currency, Price, Quantity},
31};
32use rust_decimal::Decimal;
33use serde::{
34    Deserializer,
35    de::{Error as DeError, Unexpected, Visitor},
36};
37use ustr::Ustr;
38
39use crate::{
40    common::{
41        consts::DERIVE_VENUE,
42        enums::{
43            DeriveInstrumentType, DeriveOptionKind, DeriveOrderSide, DeriveOrderStatus,
44            DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType,
45        },
46    },
47    http::models::DeriveInstrument,
48};
49
50const DERIVE_DECIMAL_MAX_SCALE: usize = 28;
51const DERIVE_POST_ONLY_CROSS_MARKET_MESSAGE: &str = "post only order cannot cross the market";
52
53/// JSON-RPC error code returned when a post-only order crosses the market.
54pub const DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE: i64 = 11008;
55
56/// Converts a Derive venue symbol to a Nautilus instrument ID.
57#[must_use]
58pub fn format_instrument_id(venue_symbol: impl AsRef<str>) -> InstrumentId {
59    InstrumentId::new(Symbol::new(venue_symbol.as_ref()), *DERIVE_VENUE)
60}
61
62/// Converts a Nautilus Derive instrument ID back to the venue symbol.
63///
64/// # Errors
65///
66/// Returns an error when `instrument_id` is not for the Derive venue.
67pub fn format_venue_symbol(instrument_id: &InstrumentId) -> anyhow::Result<Ustr> {
68    anyhow::ensure!(
69        instrument_id.venue == *DERIVE_VENUE,
70        "instrument ID `{instrument_id}` is not for venue {}",
71        DERIVE_VENUE.as_str(),
72    );
73    Ok(Ustr::from(instrument_id.symbol.as_str()))
74}
75
76/// Deserializes a Derive decimal, rounding fractional scales above 28 digits.
77///
78/// # Errors
79///
80/// Returns an error when the value is not a valid decimal after Derive scale
81/// normalization.
82pub fn deserialize_derive_decimal<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
83where
84    D: Deserializer<'de>,
85{
86    deserializer.deserialize_any(DeriveDecimalVisitor)
87}
88
89/// Deserializes an optional Derive decimal, rounding fractional scales above 28 digits.
90///
91/// # Errors
92///
93/// Returns an error when the value is not a valid decimal after Derive scale
94/// normalization.
95pub fn deserialize_optional_derive_decimal<'de, D>(
96    deserializer: D,
97) -> Result<Option<Decimal>, D::Error>
98where
99    D: Deserializer<'de>,
100{
101    deserializer.deserialize_any(OptionalDeriveDecimalVisitor)
102}
103
104struct DeriveDecimalVisitor;
105
106impl Visitor<'_> for DeriveDecimalVisitor {
107    type Value = Decimal;
108
109    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
110        formatter.write_str("a Derive decimal number as string, integer, float, or null")
111    }
112
113    fn visit_str<E: DeError>(self, value: &str) -> Result<Self::Value, E> {
114        if value.is_empty() {
115            return Ok(Decimal::ZERO);
116        }
117        parse_derive_decimal_str(value).map_err(E::custom)
118    }
119
120    fn visit_string<E: DeError>(self, value: String) -> Result<Self::Value, E> {
121        self.visit_str(&value)
122    }
123
124    fn visit_i64<E: DeError>(self, value: i64) -> Result<Self::Value, E> {
125        Ok(Decimal::from(value))
126    }
127
128    fn visit_u64<E: DeError>(self, value: u64) -> Result<Self::Value, E> {
129        Ok(Decimal::from(value))
130    }
131
132    fn visit_i128<E: DeError>(self, value: i128) -> Result<Self::Value, E> {
133        Ok(Decimal::from(value))
134    }
135
136    fn visit_u128<E: DeError>(self, value: u128) -> Result<Self::Value, E> {
137        Ok(Decimal::from(value))
138    }
139
140    fn visit_f64<E: DeError>(self, value: f64) -> Result<Self::Value, E> {
141        if value.is_nan() || value.is_infinite() {
142            return Err(E::invalid_value(Unexpected::Float(value), &self));
143        }
144        Decimal::try_from(value).map_err(E::custom)
145    }
146
147    fn visit_unit<E: DeError>(self) -> Result<Self::Value, E> {
148        Ok(Decimal::ZERO)
149    }
150
151    fn visit_none<E: DeError>(self) -> Result<Self::Value, E> {
152        Ok(Decimal::ZERO)
153    }
154}
155
156struct OptionalDeriveDecimalVisitor;
157
158impl Visitor<'_> for OptionalDeriveDecimalVisitor {
159    type Value = Option<Decimal>;
160
161    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
162        formatter.write_str("null or a Derive decimal number as string, integer, or float")
163    }
164
165    fn visit_str<E: DeError>(self, value: &str) -> Result<Self::Value, E> {
166        if value.is_empty() {
167            return Ok(None);
168        }
169        parse_derive_decimal_str(value).map(Some).map_err(E::custom)
170    }
171
172    fn visit_string<E: DeError>(self, value: String) -> Result<Self::Value, E> {
173        self.visit_str(&value)
174    }
175
176    fn visit_i64<E: DeError>(self, value: i64) -> Result<Self::Value, E> {
177        DeriveDecimalVisitor.visit_i64(value).map(Some)
178    }
179
180    fn visit_u64<E: DeError>(self, value: u64) -> Result<Self::Value, E> {
181        DeriveDecimalVisitor.visit_u64(value).map(Some)
182    }
183
184    fn visit_i128<E: DeError>(self, value: i128) -> Result<Self::Value, E> {
185        DeriveDecimalVisitor.visit_i128(value).map(Some)
186    }
187
188    fn visit_u128<E: DeError>(self, value: u128) -> Result<Self::Value, E> {
189        DeriveDecimalVisitor.visit_u128(value).map(Some)
190    }
191
192    fn visit_f64<E: DeError>(self, value: f64) -> Result<Self::Value, E> {
193        DeriveDecimalVisitor.visit_f64(value).map(Some)
194    }
195
196    fn visit_unit<E: DeError>(self) -> Result<Self::Value, E> {
197        Ok(None)
198    }
199
200    fn visit_none<E: DeError>(self) -> Result<Self::Value, E> {
201        Ok(None)
202    }
203}
204
205fn parse_derive_decimal_str(value: &str) -> Result<Decimal, String> {
206    let parsed = if value.contains('e') || value.contains('E') {
207        Decimal::from_scientific(value)
208    } else {
209        Decimal::from_str(value)
210    };
211
212    match parsed {
213        Ok(decimal) => Ok(decimal),
214        Err(e) => {
215            for scale in (0..=DERIVE_DECIMAL_MAX_SCALE).rev() {
216                let clamped =
217                    decimal_string_clamped_to_scale(value, scale).ok_or_else(|| e.to_string())?;
218
219                if let Ok(decimal) = Decimal::from_str(&clamped) {
220                    return Ok(decimal);
221                }
222            }
223            Err(e.to_string())
224        }
225    }
226}
227
228fn decimal_string_clamped_to_scale(value: &str, max_scale: usize) -> Option<String> {
229    let (coefficient, exponent) = match value.find(['e', 'E']) {
230        Some(index) => {
231            let exponent = value[index + 1..].parse::<i32>().ok()?;
232            (&value[..index], exponent)
233        }
234        None => (value, 0),
235    };
236
237    let (sign, unsigned) = match coefficient.as_bytes().first()? {
238        b'+' => ("", &coefficient[1..]),
239        b'-' => ("-", &coefficient[1..]),
240        _ => ("", coefficient),
241    };
242    let (integer, fractional) = decimal_components(unsigned)?;
243    let digits = format!("{integer}{fractional}");
244    let point = i32::try_from(integer.len()).ok()?.checked_add(exponent)?;
245
246    let (integer, fractional) = if point <= 0 {
247        let zero_count = usize::try_from(-point).ok()?;
248        (
249            "0".to_string(),
250            format!("{}{digits}", "0".repeat(zero_count)),
251        )
252    } else {
253        let point = usize::try_from(point).ok()?;
254        if point >= digits.len() {
255            (
256                format!("{}{}", digits, "0".repeat(point - digits.len())),
257                String::new(),
258            )
259        } else {
260            (digits[..point].to_string(), digits[point..].to_string())
261        }
262    };
263
264    let (integer, fractional) = round_decimal_components(integer, fractional, max_scale);
265    let sign = if sign == "-" && decimal_digits_are_zero(&integer, &fractional) {
266        ""
267    } else {
268        sign
269    };
270
271    if fractional.is_empty() {
272        Some(format!("{sign}{integer}"))
273    } else {
274        Some(format!("{sign}{integer}.{fractional}"))
275    }
276}
277
278fn decimal_components(value: &str) -> Option<(&str, &str)> {
279    let mut split = value.split('.');
280    let integer = split.next()?;
281    let fractional = split.next().unwrap_or("");
282    if split.next().is_some()
283        || (integer.is_empty() && fractional.is_empty())
284        || !integer.chars().all(|c| c.is_ascii_digit())
285        || !fractional.chars().all(|c| c.is_ascii_digit())
286    {
287        return None;
288    }
289    Some((integer, fractional))
290}
291
292fn round_decimal_components(
293    mut integer: String,
294    fractional: String,
295    max_scale: usize,
296) -> (String, String) {
297    if fractional.len() <= max_scale {
298        return (integer, fractional);
299    }
300
301    let mut rounded = fractional.as_bytes()[..max_scale].to_vec();
302    if fractional.as_bytes()[max_scale] >= b'5' {
303        increment_decimal_digits(&mut integer, &mut rounded);
304    }
305
306    (
307        integer,
308        String::from_utf8(rounded).expect("decimal digits are ASCII"),
309    )
310}
311
312fn increment_decimal_digits(integer: &mut String, fractional: &mut [u8]) {
313    for digit in fractional.iter_mut().rev() {
314        if *digit < b'9' {
315            *digit += 1;
316            return;
317        }
318        *digit = b'0';
319    }
320
321    let mut integer_digits = integer.as_bytes().to_vec();
322    for digit in integer_digits.iter_mut().rev() {
323        if *digit < b'9' {
324            *digit += 1;
325            *integer = String::from_utf8(integer_digits).expect("decimal digits are ASCII");
326            return;
327        }
328        *digit = b'0';
329    }
330    integer_digits.insert(0, b'1');
331    *integer = String::from_utf8(integer_digits).expect("decimal digits are ASCII");
332}
333
334fn decimal_digits_are_zero(integer: &str, fractional: &str) -> bool {
335    integer
336        .bytes()
337        .chain(fractional.bytes())
338        .all(|digit| digit == b'0')
339}
340
341/// Maps a Nautilus order side to the Derive direction string.
342///
343/// # Errors
344///
345/// Returns an error for ambiguous Nautilus order sides
346/// ([`OrderSide::NoOrderSide`]).
347pub fn order_side_to_derive(side: OrderSide) -> anyhow::Result<DeriveOrderSide> {
348    match side {
349        OrderSide::Buy => Ok(DeriveOrderSide::Buy),
350        OrderSide::Sell => Ok(DeriveOrderSide::Sell),
351        OrderSide::NoOrderSide => anyhow::bail!("unsupported order side for Derive: {side:?}"),
352    }
353}
354
355/// Maps a Nautilus order type to the Derive order type string.
356///
357/// # Errors
358///
359/// Returns an error for order types Derive does not accept.
360pub fn order_type_to_derive(order_type: OrderType) -> anyhow::Result<DeriveOrderType> {
361    match order_type {
362        OrderType::Limit => Ok(DeriveOrderType::Limit),
363        OrderType::Market => Ok(DeriveOrderType::Market),
364        other => anyhow::bail!("unsupported order type for Derive: {other:?}"),
365    }
366}
367
368/// Maps a supported Nautilus trigger order type to the child Derive order type.
369///
370/// # Errors
371///
372/// Returns an error for order types not supported by Derive trigger orders.
373pub fn trigger_order_type_to_derive(order_type: OrderType) -> anyhow::Result<DeriveOrderType> {
374    match order_type {
375        OrderType::StopMarket | OrderType::MarketIfTouched => Ok(DeriveOrderType::Market),
376        OrderType::StopLimit | OrderType::LimitIfTouched => Ok(DeriveOrderType::Limit),
377        other => anyhow::bail!(
378            "unsupported trigger order type for Derive: {other:?}; supported types are StopMarket, StopLimit, MarketIfTouched, and LimitIfTouched"
379        ),
380    }
381}
382
383/// Maps a Nautilus trigger order type to Derive's stop-loss/take-profit flag.
384///
385/// # Errors
386///
387/// Returns an error for order types not supported by Derive trigger orders.
388pub fn trigger_type_to_derive(order_type: OrderType) -> anyhow::Result<DeriveTriggerType> {
389    match order_type {
390        OrderType::StopMarket | OrderType::StopLimit => Ok(DeriveTriggerType::Stoploss),
391        OrderType::MarketIfTouched | OrderType::LimitIfTouched => Ok(DeriveTriggerType::Takeprofit),
392        other => anyhow::bail!(
393            "unsupported trigger order type for Derive: {other:?}; supported types are StopMarket, StopLimit, MarketIfTouched, and LimitIfTouched"
394        ),
395    }
396}
397
398/// Maps Nautilus trigger price source to Derive.
399///
400/// # Errors
401///
402/// Returns an error unless the trigger source maps to mark price, which is the
403/// only source Derive currently accepts for trigger orders.
404pub fn trigger_price_type_to_derive(
405    trigger_type: Option<TriggerType>,
406) -> anyhow::Result<DeriveTriggerPriceType> {
407    match trigger_type {
408        Some(TriggerType::Default | TriggerType::MarkPrice) => Ok(DeriveTriggerPriceType::Mark),
409        Some(TriggerType::IndexPrice) => anyhow::bail!(
410            "unsupported trigger price type for Derive: IndexPrice; Derive currently accepts only MarkPrice for trigger orders"
411        ),
412        Some(other) => anyhow::bail!(
413            "unsupported trigger price type for Derive: {other:?}; Derive trigger orders support only MarkPrice"
414        ),
415        None => anyhow::bail!(
416            "missing trigger price type for Derive trigger order; Derive trigger orders support only MarkPrice"
417        ),
418    }
419}
420
421/// Maps a Nautilus time-in-force flag to the Derive TIF.
422///
423/// # Errors
424///
425/// Returns an error for time-in-force flags Derive does not accept.
426pub fn time_in_force_to_derive(
427    tif: TimeInForce,
428    post_only: bool,
429) -> anyhow::Result<DeriveTimeInForce> {
430    match tif {
431        TimeInForce::Gtc if post_only => Ok(DeriveTimeInForce::PostOnly),
432        TimeInForce::Ioc | TimeInForce::Fok if post_only => anyhow::bail!(
433            "post-only Derive orders only support GTC time in force; received {tif:?}"
434        ),
435        TimeInForce::Gtc => Ok(DeriveTimeInForce::Gtc),
436        TimeInForce::Ioc => Ok(DeriveTimeInForce::Ioc),
437        TimeInForce::Fok => Ok(DeriveTimeInForce::Fok),
438        other => anyhow::bail!("unsupported time in force for Derive: {other:?}"),
439    }
440}
441
442/// Maps a Derive order side back to Nautilus.
443#[must_use]
444pub fn derive_order_side_to_nautilus(side: DeriveOrderSide) -> OrderSide {
445    match side {
446        DeriveOrderSide::Buy => OrderSide::Buy,
447        DeriveOrderSide::Sell => OrderSide::Sell,
448    }
449}
450
451/// Maps a Derive order type back to Nautilus.
452#[must_use]
453pub fn derive_order_type_to_nautilus(order_type: DeriveOrderType) -> OrderType {
454    match order_type {
455        DeriveOrderType::Limit => OrderType::Limit,
456        DeriveOrderType::Market => OrderType::Market,
457    }
458}
459
460/// Maps a Derive trigger order record back to the Nautilus order type.
461#[must_use]
462pub fn derive_order_type_to_nautilus_for_order(
463    order_type: DeriveOrderType,
464    trigger_type: Option<DeriveTriggerType>,
465) -> OrderType {
466    match (order_type, trigger_type) {
467        (DeriveOrderType::Market, Some(DeriveTriggerType::Stoploss)) => OrderType::StopMarket,
468        (DeriveOrderType::Limit, Some(DeriveTriggerType::Stoploss)) => OrderType::StopLimit,
469        (DeriveOrderType::Market, Some(DeriveTriggerType::Takeprofit)) => {
470            OrderType::MarketIfTouched
471        }
472        (DeriveOrderType::Limit, Some(DeriveTriggerType::Takeprofit)) => OrderType::LimitIfTouched,
473        (order_type, None) => derive_order_type_to_nautilus(order_type),
474    }
475}
476
477/// Maps a Derive trigger price source back to Nautilus.
478#[must_use]
479pub const fn derive_trigger_price_type_to_nautilus(
480    trigger_price_type: DeriveTriggerPriceType,
481) -> TriggerType {
482    match trigger_price_type {
483        DeriveTriggerPriceType::Mark => TriggerType::MarkPrice,
484        DeriveTriggerPriceType::Index => TriggerType::IndexPrice,
485    }
486}
487
488/// Maps a Derive TIF back to Nautilus.
489#[must_use]
490pub fn derive_tif_to_nautilus(tif: DeriveTimeInForce) -> TimeInForce {
491    match tif {
492        DeriveTimeInForce::Gtc | DeriveTimeInForce::PostOnly => TimeInForce::Gtc,
493        DeriveTimeInForce::Ioc => TimeInForce::Ioc,
494        DeriveTimeInForce::Fok => TimeInForce::Fok,
495    }
496}
497
498/// Maps a Derive order status to the Nautilus equivalent, given the current
499/// filled quantity.
500#[must_use]
501pub fn derive_status_to_nautilus(
502    status: DeriveOrderStatus,
503    filled_qty: Decimal,
504    quantity: Decimal,
505) -> OrderStatus {
506    match status {
507        DeriveOrderStatus::Open => {
508            if filled_qty > Decimal::ZERO && filled_qty < quantity {
509                OrderStatus::PartiallyFilled
510            } else {
511                OrderStatus::Accepted
512            }
513        }
514        DeriveOrderStatus::Filled => OrderStatus::Filled,
515        DeriveOrderStatus::Rejected => OrderStatus::Rejected,
516        DeriveOrderStatus::Cancelled => OrderStatus::Canceled,
517        DeriveOrderStatus::Expired => OrderStatus::Expired,
518        DeriveOrderStatus::Untriggered | DeriveOrderStatus::AlgoActive => OrderStatus::Accepted,
519    }
520}
521
522/// Returns whether a Derive rejection means a post-only order crossed the market.
523#[must_use]
524pub fn derive_rejection_due_post_only(code: Option<i64>, reason: &str) -> bool {
525    match code {
526        Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE) => true,
527        Some(_) => false,
528        None => reason
529            .to_ascii_lowercase()
530            .contains(DERIVE_POST_ONLY_CROSS_MARKET_MESSAGE),
531    }
532}
533
534/// Parses a Derive instrument definition into a Nautilus instrument.
535///
536/// # Errors
537///
538/// Returns an error when a Derive instrument is missing required details or
539/// contains invalid price, quantity, or timestamp fields.
540pub fn parse_derive_instrument_any(
541    instrument: &DeriveInstrument,
542    ts_init: UnixNanos,
543) -> anyhow::Result<Option<InstrumentAny>> {
544    match instrument.instrument_type {
545        DeriveInstrumentType::Perp => parse_perp_instrument(instrument, ts_init).map(Some),
546        DeriveInstrumentType::Option => parse_option_instrument(instrument, ts_init).map(Some),
547        DeriveInstrumentType::Erc20 => parse_spot_instrument(instrument, ts_init).map(Some),
548    }
549}
550
551fn parse_perp_instrument(
552    instrument: &DeriveInstrument,
553    ts_init: UnixNanos,
554) -> anyhow::Result<InstrumentAny> {
555    instrument
556        .perp_details
557        .as_ref()
558        .context("missing perp_details for Derive perp instrument")?;
559
560    let instrument_id = format_instrument_id(instrument.instrument_name.as_str());
561    let raw_symbol = Symbol::new(instrument.instrument_name.as_str());
562    let base_currency = Currency::get_or_create_crypto(instrument.base_currency.as_str());
563    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency.as_str());
564    let settlement_currency = quote_currency;
565    let price_increment = price_from_decimal(instrument.tick_size, "tick_size")?;
566    let size_increment = quantity_from_decimal(instrument.amount_step, "amount_step")?;
567    let multiplier = quantity_from_decimal(Decimal::ONE, "multiplier")?;
568    let max_quantity = quantity_from_decimal(instrument.maximum_amount, "maximum_amount")?;
569    let min_quantity = quantity_from_decimal(instrument.minimum_amount, "minimum_amount")?;
570    let info = derive_instrument_info(instrument)?;
571
572    let perp = CryptoPerpetual::new(
573        instrument_id,
574        raw_symbol,
575        base_currency,
576        quote_currency,
577        settlement_currency,
578        false,
579        price_increment.precision,
580        size_increment.precision,
581        price_increment,
582        size_increment,
583        Some(multiplier),
584        Some(size_increment),
585        Some(max_quantity),
586        Some(min_quantity),
587        None,
588        None,
589        None,
590        None,
591        None,
592        None,
593        Some(instrument.maker_fee_rate),
594        Some(instrument.taker_fee_rate),
595        None,
596        Some(info),
597        ts_init,
598        ts_init,
599    );
600
601    Ok(InstrumentAny::CryptoPerpetual(perp))
602}
603
604fn parse_option_instrument(
605    instrument: &DeriveInstrument,
606    ts_init: UnixNanos,
607) -> anyhow::Result<InstrumentAny> {
608    let details = instrument
609        .option_details
610        .as_ref()
611        .context("missing option_details for Derive option instrument")?;
612
613    let instrument_id = format_instrument_id(instrument.instrument_name.as_str());
614    let raw_symbol = Symbol::new(instrument.instrument_name.as_str());
615    let underlying = Currency::get_or_create_crypto(instrument.base_currency.as_str());
616    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency.as_str());
617    let settlement_currency = quote_currency;
618    let option_kind = parse_option_kind(details.option_type);
619    let strike_price = price_from_decimal(details.strike, "option_details.strike")?;
620    let activation_ns =
621        timestamp_millis_to_nanos(instrument.scheduled_activation, "scheduled_activation")?;
622    let expiration_ns = timestamp_seconds_to_nanos(details.expiry, "option_details.expiry")?;
623    let price_increment = price_from_decimal(instrument.tick_size, "tick_size")?;
624    let size_increment = quantity_from_decimal(instrument.amount_step, "amount_step")?;
625    let multiplier = quantity_from_decimal(Decimal::ONE, "multiplier")?;
626    let max_quantity = quantity_from_decimal(instrument.maximum_amount, "maximum_amount")?;
627    let min_quantity = quantity_from_decimal(instrument.minimum_amount, "minimum_amount")?;
628    let info = derive_instrument_info(instrument)?;
629
630    let option = CryptoOption::new(
631        instrument_id,
632        raw_symbol,
633        underlying,
634        quote_currency,
635        settlement_currency,
636        false,
637        option_kind,
638        strike_price,
639        activation_ns,
640        expiration_ns,
641        price_increment.precision,
642        size_increment.precision,
643        price_increment,
644        size_increment,
645        Some(multiplier),
646        Some(size_increment),
647        Some(max_quantity),
648        Some(min_quantity),
649        None,
650        None,
651        None,
652        None,
653        None,
654        None,
655        Some(instrument.maker_fee_rate),
656        Some(instrument.taker_fee_rate),
657        None,
658        Some(info),
659        ts_init,
660        ts_init,
661    );
662
663    Ok(InstrumentAny::CryptoOption(option))
664}
665
666fn parse_spot_instrument(
667    instrument: &DeriveInstrument,
668    ts_init: UnixNanos,
669) -> anyhow::Result<InstrumentAny> {
670    let instrument_id = format_instrument_id(instrument.instrument_name.as_str());
671    let raw_symbol = Symbol::new(instrument.instrument_name.as_str());
672    let base_currency = Currency::get_or_create_crypto(instrument.base_currency.as_str());
673    let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency.as_str());
674    let price_increment = price_from_decimal(instrument.tick_size, "tick_size")?;
675    let size_increment = quantity_from_decimal(instrument.amount_step, "amount_step")?;
676    let multiplier = quantity_from_decimal(Decimal::ONE, "multiplier")?;
677    let max_quantity = quantity_from_decimal(instrument.maximum_amount, "maximum_amount")?;
678    let min_quantity = quantity_from_decimal(instrument.minimum_amount, "minimum_amount")?;
679    let info = derive_instrument_info(instrument)?;
680
681    let pair = CurrencyPair::new(
682        instrument_id,
683        raw_symbol,
684        base_currency,
685        quote_currency,
686        price_increment.precision,
687        size_increment.precision,
688        price_increment,
689        size_increment,
690        Some(multiplier),
691        Some(size_increment),
692        Some(max_quantity),
693        Some(min_quantity),
694        None,
695        None,
696        None,
697        None,
698        None,
699        None,
700        Some(instrument.maker_fee_rate),
701        Some(instrument.taker_fee_rate),
702        None,
703        Some(info),
704        ts_init,
705        ts_init,
706    );
707
708    Ok(InstrumentAny::CurrencyPair(pair))
709}
710
711fn parse_option_kind(kind: DeriveOptionKind) -> OptionKind {
712    match kind {
713        DeriveOptionKind::Call => OptionKind::Call,
714        DeriveOptionKind::Put => OptionKind::Put,
715    }
716}
717
718// Serializes the raw DeriveInstrument into the Nautilus `info` slot so
719// downstream consumers can read venue fields (base_asset_address,
720// base_asset_sub_id, base_fee, mark_price_fee_rate_cap, option_details,
721// perp_details, etc.) that the core instrument model does not expose.
722fn derive_instrument_info(instrument: &DeriveInstrument) -> anyhow::Result<Params> {
723    let value = serde_json::to_value(instrument)
724        .context("failed to serialize DeriveInstrument for info field")?;
725    let object = value
726        .as_object()
727        .context("DeriveInstrument did not serialize to a JSON object")?
728        .clone();
729    Ok(Params::from_index_map(object.into_iter().collect()))
730}
731
732fn price_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Price> {
733    Price::from_decimal(value).with_context(|| format!("invalid Derive {field}"))
734}
735
736fn quantity_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Quantity> {
737    Quantity::from_decimal(value).with_context(|| format!("invalid Derive {field}"))
738}
739
740fn timestamp_seconds_to_nanos(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
741    timestamp_to_nanos(value, NANOSECONDS_IN_SECOND, field)
742}
743
744fn timestamp_millis_to_nanos(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
745    timestamp_to_nanos(value, NANOSECONDS_IN_MILLISECOND, field)
746}
747
748fn timestamp_to_nanos(value: i64, multiplier: u64, field: &str) -> anyhow::Result<UnixNanos> {
749    let value = u64::try_from(value).with_context(|| format!("negative Derive {field}"))?;
750    let nanos = value
751        .checked_mul(multiplier)
752        .with_context(|| format!("Derive {field} overflows nanoseconds"))?;
753    Ok(UnixNanos::from(nanos))
754}
755
756#[cfg(test)]
757mod tests {
758    use std::path::PathBuf;
759
760    use nautilus_core::UnixNanos;
761    use nautilus_model::{
762        enums::{OptionKind, OrderStatus, OrderType, TriggerType},
763        identifiers::InstrumentId,
764        instruments::{Instrument, InstrumentAny},
765        types::{Currency, Price, Quantity},
766    };
767    use rstest::rstest;
768    use rust_decimal_macros::dec;
769    use serde::Deserialize;
770    use serde_json::{Value, json};
771
772    use super::*;
773
774    fn data_path() -> PathBuf {
775        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
776    }
777
778    fn load_json(filename: &str) -> Value {
779        let content = std::fs::read_to_string(data_path().join(filename))
780            .unwrap_or_else(|_| panic!("failed to read {filename}"));
781        serde_json::from_str(&content).expect("invalid json")
782    }
783
784    fn perp_fixture() -> DeriveInstrument {
785        serde_json::from_value(load_json("perps/instrument_eth.json")).unwrap()
786    }
787
788    fn option_fixture() -> DeriveInstrument {
789        serde_json::from_value(load_json("options/instrument_eth.json")).unwrap()
790    }
791
792    fn spot_fixture() -> DeriveInstrument {
793        serde_json::from_value(load_json("spot/instrument_eth.json")).unwrap()
794    }
795
796    #[derive(Deserialize)]
797    struct DeriveDecimalProbe {
798        #[serde(deserialize_with = "deserialize_derive_decimal")]
799        rounded: Decimal,
800        #[serde(deserialize_with = "deserialize_optional_derive_decimal")]
801        optional: Option<Decimal>,
802        #[serde(deserialize_with = "deserialize_derive_decimal")]
803        carry: Decimal,
804        #[serde(deserialize_with = "deserialize_derive_decimal")]
805        negative_zero: Decimal,
806    }
807
808    #[rstest]
809    fn test_deserialize_derive_decimal_rounds_high_scale_scientific_values() {
810        let carry_input = "999999999999999999999999999995e-29";
811        assert!(Decimal::from_scientific(carry_input).is_err());
812
813        let probe: DeriveDecimalProbe = serde_json::from_value(json!({
814            "rounded": "1.234567890123456789012345678912345e-1",
815            "optional": "51.234567890123456789012345678912345e-1",
816            "carry": carry_input,
817            "negative_zero": "-4e-29"
818        }))
819        .unwrap();
820
821        assert_eq!(probe.rounded.to_string(), "0.1234567890123456789012345679",);
822        assert_eq!(
823            probe.optional.as_ref().map(ToString::to_string),
824            Some("5.1234567890123456789012345679".into()),
825        );
826        assert_eq!(probe.carry.to_string(), "10.000000000000000000000000000",);
827        assert_eq!(
828            probe.negative_zero.to_string(),
829            "0.0000000000000000000000000000"
830        );
831    }
832
833    #[rstest]
834    #[case(OrderType::StopMarket, DeriveOrderType::Market)]
835    #[case(OrderType::MarketIfTouched, DeriveOrderType::Market)]
836    #[case(OrderType::StopLimit, DeriveOrderType::Limit)]
837    #[case(OrderType::LimitIfTouched, DeriveOrderType::Limit)]
838    fn test_trigger_order_type_to_derive(
839        #[case] order_type: OrderType,
840        #[case] expected: DeriveOrderType,
841    ) {
842        assert_eq!(trigger_order_type_to_derive(order_type).unwrap(), expected);
843    }
844
845    #[rstest]
846    fn test_trigger_order_type_to_derive_rejects_unsupported() {
847        let err = trigger_order_type_to_derive(OrderType::TrailingStopMarket)
848            .expect_err("trailing stops must be rejected");
849
850        assert!(
851            err.to_string()
852                .contains("unsupported trigger order type for Derive"),
853            "unexpected error: {err}",
854        );
855    }
856
857    #[rstest]
858    #[case(OrderType::StopMarket, DeriveTriggerType::Stoploss)]
859    #[case(OrderType::StopLimit, DeriveTriggerType::Stoploss)]
860    #[case(OrderType::MarketIfTouched, DeriveTriggerType::Takeprofit)]
861    #[case(OrderType::LimitIfTouched, DeriveTriggerType::Takeprofit)]
862    fn test_trigger_type_to_derive(
863        #[case] order_type: OrderType,
864        #[case] expected: DeriveTriggerType,
865    ) {
866        assert_eq!(trigger_type_to_derive(order_type).unwrap(), expected);
867    }
868
869    #[rstest]
870    fn test_trigger_price_type_to_derive_accepts_only_mark_price() {
871        assert_eq!(
872            trigger_price_type_to_derive(Some(TriggerType::MarkPrice)).unwrap(),
873            DeriveTriggerPriceType::Mark,
874        );
875        assert_eq!(
876            trigger_price_type_to_derive(Some(TriggerType::Default)).unwrap(),
877            DeriveTriggerPriceType::Mark,
878        );
879
880        for trigger_type in [
881            TriggerType::IndexPrice,
882            TriggerType::LastPrice,
883            TriggerType::BidAsk,
884            TriggerType::NoTrigger,
885        ] {
886            let err = trigger_price_type_to_derive(Some(trigger_type))
887                .expect_err("unsupported trigger price type must fail");
888            assert!(
889                err.to_string().contains("unsupported trigger price type"),
890                "unexpected error for {trigger_type:?}: {err}",
891            );
892        }
893    }
894
895    #[rstest]
896    #[case(
897        DeriveOrderType::Market,
898        Some(DeriveTriggerType::Stoploss),
899        OrderType::StopMarket
900    )]
901    #[case(
902        DeriveOrderType::Limit,
903        Some(DeriveTriggerType::Stoploss),
904        OrderType::StopLimit
905    )]
906    #[case(
907        DeriveOrderType::Market,
908        Some(DeriveTriggerType::Takeprofit),
909        OrderType::MarketIfTouched
910    )]
911    #[case(
912        DeriveOrderType::Limit,
913        Some(DeriveTriggerType::Takeprofit),
914        OrderType::LimitIfTouched
915    )]
916    #[case(DeriveOrderType::Limit, None, OrderType::Limit)]
917    fn test_derive_order_type_to_nautilus_for_order(
918        #[case] order_type: DeriveOrderType,
919        #[case] trigger_type: Option<DeriveTriggerType>,
920        #[case] expected: OrderType,
921    ) {
922        assert_eq!(
923            derive_order_type_to_nautilus_for_order(order_type, trigger_type),
924            expected,
925        );
926    }
927
928    #[rstest]
929    fn test_derive_status_to_nautilus_maps_untriggered_to_accepted() {
930        assert_eq!(
931            derive_status_to_nautilus(DeriveOrderStatus::Untriggered, dec!(0), dec!(1)),
932            OrderStatus::Accepted,
933        );
934    }
935
936    #[rstest]
937    #[case(
938        Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE),
939        "Post only order cannot cross the market",
940        true
941    )]
942    #[case(
943        Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE),
944        "post only order cannot cross the market",
945        true
946    )]
947    #[case(None, "Post only order cannot cross the market", true)]
948    #[case(Some(-32602), "Post only order cannot cross the market", false)]
949    #[case(Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE), "Invalid params", true)]
950    fn test_derive_rejection_due_post_only(
951        #[case] code: Option<i64>,
952        #[case] reason: &str,
953        #[case] expected: bool,
954    ) {
955        assert_eq!(derive_rejection_due_post_only(code, reason), expected);
956    }
957
958    #[rstest]
959    fn test_parse_perp_instrument() {
960        let instrument = parse_derive_instrument_any(&perp_fixture(), UnixNanos::from(123))
961            .unwrap()
962            .unwrap();
963
964        let InstrumentAny::CryptoPerpetual(perp) = instrument else {
965            panic!("expected CryptoPerpetual");
966        };
967
968        assert_eq!(perp.id(), InstrumentId::from("ETH-PERP.DERIVE"));
969        assert_eq!(perp.raw_symbol().as_str(), "ETH-PERP");
970        assert_eq!(perp.base_currency(), Some(Currency::ETH()));
971        assert_eq!(perp.quote_currency(), Currency::USDC());
972        assert_eq!(perp.settlement_currency(), Currency::USDC());
973        assert_eq!(perp.price_increment(), Price::from("0.01"));
974        assert_eq!(perp.size_increment(), Quantity::from("0.001"));
975        assert_eq!(perp.max_quantity(), Some(Quantity::from("1000")));
976        assert_eq!(perp.min_quantity(), Some(Quantity::from("0.001")));
977        assert_eq!(perp.maker_fee(), dec!(0.0001));
978        assert_eq!(perp.taker_fee(), dec!(0.0005));
979        assert!(!perp.is_inverse());
980
981        // `info` mirrors the raw venue payload so downstream consumers can read
982        // fields the core model does not expose (asset address, sub-id, perp
983        // funding details, etc.).
984        let info = perp.info.as_ref().expect("info populated");
985        assert_eq!(info.get_str("instrument_name"), Some("ETH-PERP"));
986        assert_eq!(info.get_str("instrument_type"), Some("perp"));
987        assert_eq!(info.get_str("base_asset_sub_id"), Some("0"));
988        assert!(info.get("perp_details").is_some_and(|v| v.is_object()));
989    }
990
991    #[rstest]
992    fn test_parse_option_instrument() {
993        let instrument = parse_derive_instrument_any(&option_fixture(), UnixNanos::from(456))
994            .unwrap()
995            .unwrap();
996
997        let InstrumentAny::CryptoOption(option) = instrument else {
998            panic!("expected CryptoOption");
999        };
1000
1001        assert_eq!(
1002            option.id(),
1003            InstrumentId::from("ETH-20260627-3500-C.DERIVE")
1004        );
1005        assert_eq!(option.raw_symbol().as_str(), "ETH-20260627-3500-C");
1006        assert_eq!(option.base_currency(), Some(Currency::ETH()));
1007        assert_eq!(option.quote_currency(), Currency::USDC());
1008        assert_eq!(option.settlement_currency(), Currency::USDC());
1009        assert_eq!(option.option_kind(), Some(OptionKind::Call));
1010        assert_eq!(option.strike_price(), Some(Price::from("3500")));
1011        assert_eq!(
1012            option.activation_ns(),
1013            Some(UnixNanos::from(1_700_000_000_000_000_000)),
1014        );
1015        assert_eq!(
1016            option.expiration_ns(),
1017            Some(UnixNanos::from(1_782_000_000_000_000_000)),
1018        );
1019        assert_eq!(option.price_increment(), Price::from("1"));
1020        assert_eq!(option.size_increment(), Quantity::from("0.01"));
1021        assert_eq!(option.max_quantity(), Some(Quantity::from("100")));
1022        assert_eq!(option.min_quantity(), Some(Quantity::from("0.01")));
1023        assert_eq!(option.taker_fee(), dec!(0.001));
1024
1025        let info = option.info.as_ref().expect("info populated");
1026        assert_eq!(info.get_str("instrument_name"), Some("ETH-20260627-3500-C"));
1027        assert_eq!(info.get_str("instrument_type"), Some("option"));
1028        let option_details = info.get("option_details").expect("option_details present");
1029        assert_eq!(
1030            option_details.get("option_type").and_then(|v| v.as_str()),
1031            Some("C")
1032        );
1033        assert_eq!(
1034            option_details.get("strike").and_then(|v| v.as_str()),
1035            Some("3500")
1036        );
1037    }
1038
1039    #[rstest]
1040    fn test_symbol_instrument_id_mapping() {
1041        let instrument_id = format_instrument_id("ETH-20260627-3500-C");
1042        let venue_symbol = format_venue_symbol(&instrument_id).unwrap();
1043
1044        assert_eq!(
1045            instrument_id,
1046            InstrumentId::from("ETH-20260627-3500-C.DERIVE")
1047        );
1048        assert_eq!(venue_symbol, "ETH-20260627-3500-C");
1049    }
1050
1051    #[rstest]
1052    fn test_format_venue_symbol_rejects_non_derive_venue() {
1053        let instrument_id = InstrumentId::from("ETH-PERP.BINANCE");
1054
1055        let err = format_venue_symbol(&instrument_id).expect_err("must reject non-Derive venue");
1056
1057        assert!(err.to_string().contains("not for venue DERIVE"));
1058    }
1059
1060    #[rstest]
1061    fn test_parse_spot_instrument() {
1062        let instrument = parse_derive_instrument_any(&spot_fixture(), UnixNanos::from(789))
1063            .unwrap()
1064            .unwrap();
1065
1066        let InstrumentAny::CurrencyPair(pair) = instrument else {
1067            panic!("expected CurrencyPair");
1068        };
1069
1070        assert_eq!(pair.id(), InstrumentId::from("ETH-USDC.DERIVE"));
1071        assert_eq!(pair.raw_symbol().as_str(), "ETH-USDC");
1072        assert_eq!(pair.base_currency(), Some(Currency::ETH()));
1073        assert_eq!(pair.quote_currency(), Currency::USDC());
1074        assert_eq!(pair.price_increment(), Price::from("0.1"));
1075        assert_eq!(pair.size_increment(), Quantity::from("0.01"));
1076        assert_eq!(pair.max_quantity(), Some(Quantity::from("10000")));
1077        assert_eq!(pair.min_quantity(), Some(Quantity::from("0.1")));
1078        assert_eq!(pair.maker_fee(), dec!(0));
1079        assert_eq!(pair.taker_fee(), dec!(0));
1080
1081        let info = pair.info.as_ref().expect("info populated");
1082        assert_eq!(info.get_str("instrument_name"), Some("ETH-USDC"));
1083        assert_eq!(info.get_str("instrument_type"), Some("erc20"));
1084        assert_eq!(info.get_str("base_asset_sub_id"), Some("0"));
1085        assert_eq!(
1086            info.get_str("base_asset_address"),
1087            Some("0x41675b7746AE0E464f2594d258CF399c392A179C"),
1088        );
1089    }
1090
1091    #[rstest]
1092    fn test_parse_spot_instrument_maps_fee_slots_distinctly() {
1093        // The shipped spot fixtures have maker_fee == taker_fee, so the
1094        // round-trip test above cannot catch a swap between the slots. Pin
1095        // the mapping with distinct values.
1096        let mut instrument = spot_fixture();
1097        instrument.maker_fee_rate = dec!(0.0001);
1098        instrument.taker_fee_rate = dec!(0.0005);
1099
1100        let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(0))
1101            .unwrap()
1102            .unwrap();
1103        let InstrumentAny::CurrencyPair(pair) = parsed else {
1104            panic!("expected CurrencyPair");
1105        };
1106
1107        assert_eq!(pair.maker_fee(), dec!(0.0001));
1108        assert_eq!(pair.taker_fee(), dec!(0.0005));
1109    }
1110
1111    #[rstest]
1112    fn test_parse_perp_instrument_rejects_missing_perp_details() {
1113        let mut instrument = perp_fixture();
1114        instrument.perp_details = None;
1115
1116        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1117            .expect_err("must reject missing perp details");
1118
1119        assert!(err.to_string().contains("missing perp_details"));
1120    }
1121
1122    #[rstest]
1123    fn test_parse_option_instrument_rejects_missing_option_details() {
1124        let mut instrument = option_fixture();
1125        instrument.option_details = None;
1126
1127        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1128            .expect_err("must reject missing option details");
1129
1130        assert!(err.to_string().contains("missing option_details"));
1131    }
1132
1133    #[rstest]
1134    fn test_parse_option_instrument_rejects_negative_activation() {
1135        let mut instrument = option_fixture();
1136        instrument.scheduled_activation = -1;
1137
1138        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1139            .expect_err("must reject negative activation timestamp");
1140
1141        assert!(
1142            err.to_string()
1143                .contains("negative Derive scheduled_activation")
1144        );
1145    }
1146
1147    #[rstest]
1148    fn test_parse_option_instrument_rejects_negative_expiry() {
1149        let mut instrument = option_fixture();
1150        instrument.option_details.as_mut().unwrap().expiry = -1;
1151
1152        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1153            .expect_err("must reject negative expiry timestamp");
1154
1155        assert!(
1156            err.to_string()
1157                .contains("negative Derive option_details.expiry")
1158        );
1159    }
1160}