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