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::from_index_map(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 mut params = Params::new();
610        params.insert("integer".to_owned(), json!(42));
611        params.insert("fraction".to_owned(), json!(0.125));
612        let encoded = NumericParams(params).to_msgpack_bytes().unwrap();
613
614        assert_eq!(
615            encoded.as_ref(),
616            &[
617                0x82, 0xa7, b'i', b'n', b't', b'e', b'g', b'e', b'r', 42, 0xa8, b'f', b'r', b'a',
618                b'c', b't', b'i', b'o', b'n', 0xcb, 0x3f, 0xc0, 0, 0, 0, 0, 0, 0,
619            ],
620        );
621    }
622
623    fn data_path() -> PathBuf {
624        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
625    }
626
627    fn load_json(filename: &str) -> Value {
628        let content = std::fs::read_to_string(data_path().join(filename))
629            .unwrap_or_else(|_| panic!("failed to read {filename}"));
630        serde_json::from_str(&content).expect("invalid json")
631    }
632
633    fn perp_fixture() -> DeriveInstrument {
634        serde_json::from_value(load_json("perps/instrument_eth.json")).unwrap()
635    }
636
637    fn option_fixture() -> DeriveInstrument {
638        serde_json::from_value(load_json("options/instrument_eth.json")).unwrap()
639    }
640
641    fn spot_fixture() -> DeriveInstrument {
642        serde_json::from_value(load_json("spot/instrument_eth.json")).unwrap()
643    }
644
645    #[rstest]
646    #[case(OrderType::StopMarket, DeriveOrderType::Market)]
647    #[case(OrderType::MarketIfTouched, DeriveOrderType::Market)]
648    #[case(OrderType::StopLimit, DeriveOrderType::Limit)]
649    #[case(OrderType::LimitIfTouched, DeriveOrderType::Limit)]
650    fn test_trigger_order_type_to_derive(
651        #[case] order_type: OrderType,
652        #[case] expected: DeriveOrderType,
653    ) {
654        assert_eq!(trigger_order_type_to_derive(order_type).unwrap(), expected);
655    }
656
657    #[rstest]
658    fn test_trigger_order_type_to_derive_rejects_unsupported() {
659        let err = trigger_order_type_to_derive(OrderType::TrailingStopMarket)
660            .expect_err("trailing stops must be rejected");
661
662        assert!(
663            err.to_string()
664                .contains("unsupported trigger order type for Derive"),
665            "unexpected error: {err}",
666        );
667    }
668
669    #[rstest]
670    #[case(OrderType::StopMarket, DeriveTriggerType::Stoploss)]
671    #[case(OrderType::StopLimit, DeriveTriggerType::Stoploss)]
672    #[case(OrderType::MarketIfTouched, DeriveTriggerType::Takeprofit)]
673    #[case(OrderType::LimitIfTouched, DeriveTriggerType::Takeprofit)]
674    fn test_trigger_type_to_derive(
675        #[case] order_type: OrderType,
676        #[case] expected: DeriveTriggerType,
677    ) {
678        assert_eq!(trigger_type_to_derive(order_type).unwrap(), expected);
679    }
680
681    #[rstest]
682    fn test_trigger_price_type_to_derive_accepts_only_mark_price() {
683        assert_eq!(
684            trigger_price_type_to_derive(Some(TriggerType::MarkPrice)).unwrap(),
685            DeriveTriggerPriceType::Mark,
686        );
687        assert_eq!(
688            trigger_price_type_to_derive(Some(TriggerType::Default)).unwrap(),
689            DeriveTriggerPriceType::Mark,
690        );
691
692        for trigger_type in [
693            TriggerType::IndexPrice,
694            TriggerType::LastPrice,
695            TriggerType::BidAsk,
696        ] {
697            let err = trigger_price_type_to_derive(Some(trigger_type))
698                .expect_err("unsupported trigger price type must fail");
699            assert!(
700                err.to_string().contains("unsupported trigger price type"),
701                "unexpected error for {trigger_type:?}: {err}",
702            );
703        }
704    }
705
706    #[rstest]
707    #[case(
708        DeriveOrderType::Market,
709        Some(DeriveTriggerType::Stoploss),
710        OrderType::StopMarket
711    )]
712    #[case(
713        DeriveOrderType::Limit,
714        Some(DeriveTriggerType::Stoploss),
715        OrderType::StopLimit
716    )]
717    #[case(
718        DeriveOrderType::Market,
719        Some(DeriveTriggerType::Takeprofit),
720        OrderType::MarketIfTouched
721    )]
722    #[case(
723        DeriveOrderType::Limit,
724        Some(DeriveTriggerType::Takeprofit),
725        OrderType::LimitIfTouched
726    )]
727    #[case(DeriveOrderType::Limit, None, OrderType::Limit)]
728    fn test_derive_order_type_to_nautilus_for_order(
729        #[case] order_type: DeriveOrderType,
730        #[case] trigger_type: Option<DeriveTriggerType>,
731        #[case] expected: OrderType,
732    ) {
733        assert_eq!(
734            derive_order_type_to_nautilus_for_order(order_type, trigger_type).unwrap(),
735            expected,
736        );
737    }
738
739    #[rstest]
740    fn test_unknown_wire_variants_reject_domain_conversion() {
741        assert!(derive_order_type_to_nautilus(DeriveOrderType::Unknown).is_err());
742        assert!(
743            derive_order_type_to_nautilus_for_order(
744                DeriveOrderType::Limit,
745                Some(DeriveTriggerType::Unknown)
746            )
747            .is_err()
748        );
749        assert!(derive_tif_to_nautilus(DeriveTimeInForce::Unknown).is_err());
750        assert!(derive_trigger_price_type_to_nautilus(DeriveTriggerPriceType::Unknown).is_err());
751        assert!(derive_status_to_nautilus(DeriveOrderStatus::Unknown, dec!(0), dec!(1)).is_err());
752    }
753
754    #[rstest]
755    fn test_salvage_elements_skips_undecodable_rows() {
756        let values = vec![json!(1), json!("not a number"), json!(2)];
757
758        let salvaged: Vec<i64> = salvage_elements(values);
759
760        assert_eq!(salvaged, vec![1, 2]);
761    }
762
763    #[rstest]
764    fn test_derive_status_to_nautilus_maps_untriggered_to_accepted() {
765        assert_eq!(
766            derive_status_to_nautilus(DeriveOrderStatus::Untriggered, dec!(0), dec!(1)).unwrap(),
767            OrderStatus::Accepted,
768        );
769    }
770
771    #[rstest]
772    #[case(
773        Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE),
774        "Post only order cannot cross the market",
775        true
776    )]
777    #[case(
778        Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE),
779        "post only order cannot cross the market",
780        true
781    )]
782    #[case(None, "Post only order cannot cross the market", true)]
783    #[case(Some(-32602), "Post only order cannot cross the market", false)]
784    #[case(Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE), "Invalid params", true)]
785    fn test_derive_rejection_due_post_only(
786        #[case] code: Option<i64>,
787        #[case] reason: &str,
788        #[case] expected: bool,
789    ) {
790        assert_eq!(derive_rejection_due_post_only(code, reason), expected);
791    }
792
793    #[rstest]
794    #[case("perps/instrument_eth.json")]
795    #[case("options/instrument_eth.json")]
796    #[case("spot/instrument_eth.json")]
797    fn test_instrument_parser_rejects_blank_symbol_without_panic(
798        #[case] filename: &str,
799        #[values("", " ", "\t\n")] symbol: &str,
800    ) {
801        let mut instrument: DeriveInstrument = serde_json::from_value(load_json(filename)).unwrap();
802        instrument.instrument_name = symbol.into();
803        let result = std::panic::catch_unwind(|| {
804            parse_derive_instrument_any(&instrument, UnixNanos::from(17))
805        });
806
807        assert!(result.is_ok(), "untrusted symbols must not panic");
808        assert!(result.unwrap().is_err());
809    }
810
811    #[rstest]
812    fn test_parse_perp_instrument() {
813        let instrument = parse_derive_instrument_any(&perp_fixture(), UnixNanos::from(123))
814            .unwrap()
815            .unwrap();
816
817        let InstrumentAny::CryptoPerpetual(perp) = instrument else {
818            panic!("expected CryptoPerpetual");
819        };
820
821        assert_eq!(perp.id(), InstrumentId::from("ETH-PERP.DERIVE"));
822        assert_eq!(perp.raw_symbol().as_str(), "ETH-PERP");
823        assert_eq!(perp.base_currency(), Some(Currency::ETH()));
824        // Fixture carries the live wire quote "USD"; parser normalizes to USDC
825        assert_eq!(perp.quote_currency(), Currency::USDC());
826        assert_eq!(perp.settlement_currency(), Currency::USDC());
827        assert_eq!(perp.price_increment(), Price::from("0.01"));
828        assert_eq!(perp.size_increment(), Quantity::from("0.001"));
829        assert_eq!(perp.max_quantity(), Some(Quantity::from("10000")));
830        assert_eq!(perp.min_quantity(), None);
831        assert!(!perp.is_inverse());
832
833        // `info` mirrors the raw venue payload so downstream consumers can read
834        // fields the core model does not expose (asset address, sub-id, perp
835        // funding details, etc.).
836        let info = perp.info.as_ref().expect("info populated");
837        assert_eq!(info.get_str("instrument_name"), Some("ETH-PERP"));
838        assert_eq!(info.get_str("instrument_type"), Some("perp"));
839        assert_eq!(info.get_str("base_asset_sub_id"), Some("0"));
840        // Normalization must not rewrite the raw venue payload.
841        assert_eq!(info.get_str("quote_currency"), Some("USD"));
842        assert_eq!(info.get_str("minimum_amount"), Some("0.1"));
843        assert!(info.get("perp_details").is_some_and(|v| v.is_object()));
844    }
845
846    #[rstest]
847    fn test_parse_perp_instrument_money_flows_settle_in_usdc() {
848        // Linear notional and PnL come out in cost_currency (= quote), which
849        // must match the USDC-only account
850        let instrument = parse_derive_instrument_any(&perp_fixture(), UnixNanos::from(123))
851            .unwrap()
852            .unwrap();
853
854        let InstrumentAny::CryptoPerpetual(perp) = instrument else {
855            panic!("expected CryptoPerpetual");
856        };
857
858        let notional =
859            perp.calculate_notional_value(Quantity::from("2"), Price::from("3000.00"), None);
860
861        assert!(!perp.is_quanto());
862        assert_eq!(perp.cost_currency(), Currency::USDC());
863        assert_eq!(notional.currency, Currency::USDC());
864        assert_eq!(notional.as_decimal(), dec!(6000));
865    }
866
867    #[rstest]
868    fn test_parse_perp_instrument_pins_usdc_for_any_wire_quote() {
869        // The USDC pin is unconditional, not gated on the wire saying "USD".
870        let mut instrument = perp_fixture();
871        instrument.quote_currency = "XUSD".into();
872
873        let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
874            .unwrap()
875            .unwrap();
876
877        let InstrumentAny::CryptoPerpetual(perp) = parsed else {
878            panic!("expected CryptoPerpetual");
879        };
880
881        assert_eq!(perp.quote_currency(), Currency::USDC());
882        assert_eq!(perp.settlement_currency(), Currency::USDC());
883    }
884
885    #[rstest]
886    fn test_parse_option_instrument() {
887        let instrument = parse_derive_instrument_any(&option_fixture(), UnixNanos::from(456))
888            .unwrap()
889            .unwrap();
890
891        let InstrumentAny::CryptoOption(option) = instrument else {
892            panic!("expected CryptoOption");
893        };
894
895        assert_eq!(
896            option.id(),
897            InstrumentId::from("ETH-20261225-3500-C.DERIVE")
898        );
899        assert_eq!(option.raw_symbol().as_str(), "ETH-20261225-3500-C");
900        assert_eq!(option.base_currency(), Some(Currency::ETH()));
901        assert_eq!(option.quote_currency(), Currency::USDC());
902        assert_eq!(option.settlement_currency(), Currency::USDC());
903        assert_eq!(option.option_kind(), Some(OptionKind::Call));
904        assert_eq!(option.strike_price(), Some(Price::from("3500")));
905        assert_eq!(
906            option.activation_ns(),
907            Some(UnixNanos::from(1_774_598_400_000_000_000)),
908        );
909        assert_eq!(
910            option.expiration_ns(),
911            Some(UnixNanos::from(1_798_185_600_000_000_000)),
912        );
913        assert_eq!(option.price_increment(), Price::from("0.1"));
914        assert_eq!(option.size_increment(), Quantity::from("0.01"));
915        assert_eq!(option.max_quantity(), Some(Quantity::from("10000")));
916        assert_eq!(option.min_quantity(), None);
917
918        let info = option.info.as_ref().expect("info populated");
919        assert_eq!(info.get_str("instrument_name"), Some("ETH-20261225-3500-C"));
920        assert_eq!(info.get_str("instrument_type"), Some("option"));
921        assert_eq!(info.get_str("minimum_amount"), Some("0.1"));
922        let option_details = info.get("option_details").expect("option_details present");
923        assert_eq!(
924            option_details.get("option_type").and_then(|v| v.as_str()),
925            Some("C")
926        );
927        assert_eq!(
928            option_details.get("strike").and_then(|v| v.as_str()),
929            Some("3500")
930        );
931    }
932
933    #[rstest]
934    fn test_symbol_instrument_id_mapping() {
935        let instrument_id = format_instrument_id("ETH-20260627-3500-C").unwrap();
936        let venue_symbol = format_venue_symbol(&instrument_id).unwrap();
937
938        assert_eq!(
939            instrument_id,
940            InstrumentId::from("ETH-20260627-3500-C.DERIVE")
941        );
942        assert_eq!(venue_symbol, "ETH-20260627-3500-C");
943    }
944
945    #[rstest]
946    fn test_format_venue_symbol_rejects_non_derive_venue() {
947        let instrument_id = InstrumentId::from("ETH-PERP.BINANCE");
948
949        let err = format_venue_symbol(&instrument_id).expect_err("must reject non-Derive venue");
950
951        assert!(err.to_string().contains("not for venue DERIVE"));
952    }
953
954    #[rstest]
955    fn test_parse_spot_instrument() {
956        let instrument = parse_derive_instrument_any(&spot_fixture(), UnixNanos::from(789))
957            .unwrap()
958            .unwrap();
959
960        let InstrumentAny::CurrencyPair(pair) = instrument else {
961            panic!("expected CurrencyPair");
962        };
963
964        assert_eq!(pair.id(), InstrumentId::from("ETH-USDC.DERIVE"));
965        assert_eq!(pair.raw_symbol().as_str(), "ETH-USDC");
966        assert_eq!(pair.base_currency(), Some(Currency::ETH()));
967        assert_eq!(pair.quote_currency(), Currency::USDC());
968        assert_eq!(pair.price_increment(), Price::from("0.1"));
969        assert_eq!(pair.size_increment(), Quantity::from("0.01"));
970        assert_eq!(pair.max_quantity(), Some(Quantity::from("10000")));
971        assert_eq!(pair.min_quantity(), None);
972
973        let info = pair.info.as_ref().expect("info populated");
974        assert_eq!(info.get_str("instrument_name"), Some("ETH-USDC"));
975        assert_eq!(info.get_str("instrument_type"), Some("erc20"));
976        assert_eq!(info.get_str("minimum_amount"), Some("0.1"));
977        assert_eq!(info.get_str("base_asset_sub_id"), Some("0"));
978        assert_eq!(
979            info.get_str("base_asset_address"),
980            Some("0x41675b7746AE0E464f2594d258CF399c392A179C"),
981        );
982    }
983
984    #[rstest]
985    fn test_parse_instrument_without_response_uses_typed_metadata() {
986        let mut instrument = perp_fixture();
987        instrument.raw = None;
988        instrument.minimum_amount = dec!(0.03);
989
990        let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
991            .unwrap()
992            .unwrap();
993        let info = parsed.info().unwrap();
994
995        assert_eq!(info.get_str("instrument_name"), Some("ETH-PERP"));
996        assert_eq!(info.get_str("quote_currency"), Some("USD"));
997        assert_eq!(info.get_str("minimum_amount"), Some("0.03"));
998        assert_eq!(info.get("raw"), None);
999    }
1000
1001    #[rstest]
1002    #[case::perp("perps/instrument_eth.json")]
1003    #[case::option("options/instrument_eth.json")]
1004    #[case::spot("spot/instrument_eth_mainnet.json")]
1005    fn test_parse_instrument_preserves_complete_response(#[case] filename: &str) {
1006        let mut response = load_json(filename);
1007        response["base_fee"] = json!(0.125);
1008        response["pro_rata_fraction"] = json!("0.8");
1009        response["fifo_min_allocation"] = json!("10");
1010        response["pro_rata_amount_step"] = json!("1");
1011        response["erc20_details"] = json!({
1012            "decimals": 18,
1013            "underlying_erc20_address": "0x15CEcd5190A43C7798dD2058308781D0662e678E",
1014            "borrow_index": "1.000000000000000001",
1015            "supply_index": "1.000000000000000002",
1016        });
1017        response["additional_data"] = json!([null, true, {"value": "0.000000000000000001"}]);
1018        for field in ["option_details", "perp_details"] {
1019            if let Some(details) = response[field].as_object_mut() {
1020                details.insert("additional_data".to_string(), json!({"value": 42}));
1021            }
1022        }
1023
1024        let instrument: DeriveInstrument = serde_json::from_value(response.clone()).unwrap();
1025
1026        let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1027            .unwrap()
1028            .unwrap();
1029
1030        assert_eq!(
1031            serde_json::to_value(parsed.info().unwrap()).unwrap(),
1032            response
1033        );
1034    }
1035
1036    #[rstest]
1037    #[case::perp("perps/instrument_eth.json")]
1038    #[case::option("options/instrument_eth.json")]
1039    #[case::spot("spot/instrument_eth.json")]
1040    fn test_parse_instrument_preserves_trading_parameters(#[case] filename: &str) {
1041        let mut response = load_json(filename);
1042        response["tick_size"] = json!("0.005");
1043        response["amount_step"] = json!("0.0002");
1044        response["maximum_amount"] = json!("1234.5678");
1045        response["minimum_amount"] = json!("0.03");
1046        let instrument: DeriveInstrument = serde_json::from_value(response).unwrap();
1047
1048        let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(987))
1049            .unwrap()
1050            .unwrap();
1051
1052        assert_eq!(parsed.price_increment(), Price::from("0.005"));
1053        assert_eq!(parsed.size_increment(), Quantity::from("0.0002"));
1054        assert_eq!(parsed.price_precision(), 3);
1055        assert_eq!(parsed.size_precision(), 4);
1056        assert_eq!(parsed.multiplier(), Quantity::from("1"));
1057        assert_eq!(parsed.lot_size(), Some(Quantity::from("0.0002")));
1058        assert_eq!(parsed.max_quantity(), Some(Quantity::from("1234.5678")));
1059        assert_eq!(parsed.min_quantity(), None);
1060        assert_eq!(parsed.ts_event(), UnixNanos::from(987));
1061        assert_eq!(parsed.ts_init(), UnixNanos::from(987));
1062        assert_eq!(
1063            parsed.info().unwrap().get_str("minimum_amount"),
1064            Some("0.03")
1065        );
1066    }
1067
1068    #[rstest]
1069    #[case::perp(DeriveInstrumentType::Perp)]
1070    #[case::option(DeriveInstrumentType::Option)]
1071    #[case::spot(DeriveInstrumentType::Erc20)]
1072    fn test_parse_instrument_rejects_non_positive_tick_size(
1073        #[case] instrument_type: DeriveInstrumentType,
1074    ) {
1075        let mut instrument = match instrument_type {
1076            DeriveInstrumentType::Perp => perp_fixture(),
1077            DeriveInstrumentType::Option => option_fixture(),
1078            DeriveInstrumentType::Erc20 => spot_fixture(),
1079            DeriveInstrumentType::Unknown => unreachable!(),
1080        };
1081
1082        instrument.tick_size = Decimal::ZERO;
1083
1084        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1085            .expect_err("must reject non-positive tick size");
1086        let message = err.to_string();
1087
1088        assert!(message.contains("price_increment"), "{message}");
1089        assert!(message.contains("not positive"), "{message}");
1090    }
1091
1092    #[rstest]
1093    fn test_parse_perp_instrument_rejects_missing_perp_details() {
1094        let mut instrument = perp_fixture();
1095        instrument.perp_details = None;
1096
1097        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1098            .expect_err("must reject missing perp details");
1099
1100        assert!(err.to_string().contains("missing perp_details"));
1101    }
1102
1103    #[rstest]
1104    fn test_parse_derive_instrument_any_skips_unknown_instrument_type() {
1105        let mut instrument = perp_fixture();
1106        instrument.instrument_type = DeriveInstrumentType::Unknown;
1107
1108        let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1109            .expect("unknown instrument type must not error");
1110
1111        assert!(parsed.is_none());
1112    }
1113
1114    #[rstest]
1115    fn test_parse_option_instrument_rejects_missing_option_details() {
1116        let mut instrument = option_fixture();
1117        instrument.option_details = None;
1118
1119        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1120            .expect_err("must reject missing option details");
1121
1122        assert!(err.to_string().contains("missing option_details"));
1123    }
1124
1125    #[rstest]
1126    fn test_parse_option_instrument_rejects_negative_activation() {
1127        let mut instrument = option_fixture();
1128        instrument.scheduled_activation = -1;
1129
1130        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1131            .expect_err("must reject negative activation timestamp");
1132
1133        assert!(
1134            err.to_string()
1135                .contains("negative Derive scheduled_activation")
1136        );
1137    }
1138
1139    #[rstest]
1140    fn test_parse_option_instrument_rejects_negative_expiry() {
1141        let mut instrument = option_fixture();
1142        instrument.option_details.as_mut().unwrap().expiry = -1;
1143
1144        let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1145            .expect_err("must reject negative expiry timestamp");
1146
1147        assert!(
1148            err.to_string()
1149                .contains("negative Derive option_details.expiry")
1150        );
1151    }
1152}