1use 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
44pub const DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE: i64 = 11008;
46
47pub 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
58pub 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
72pub 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
88pub 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
134pub fn order_side_to_derive(side: OrderSide) -> DeriveOrderSide {
136 match side {
137 OrderSide::Buy => DeriveOrderSide::Buy,
138 OrderSide::Sell => DeriveOrderSide::Sell,
139 }
140}
141
142pub 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
155pub 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
170pub 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
185pub 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
208pub 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#[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
238pub 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
251pub 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
274pub 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
289pub 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
303pub 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#[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
345pub 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 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
512fn 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 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 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 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 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 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}