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