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