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