1use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::{
22 UnixNanos,
23 datetime::{NANOSECONDS_IN_MILLISECOND, NANOSECONDS_IN_SECOND},
24 params::Params,
25};
26use nautilus_model::{
27 enums::{OptionKind, OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType},
28 identifiers::{InstrumentId, Symbol},
29 instruments::{CryptoOption, CryptoPerpetual, CurrencyPair, InstrumentAny},
30 types::{Currency, Price, Quantity},
31};
32use rust_decimal::Decimal;
33use serde::{
34 Deserializer,
35 de::{Error as DeError, Unexpected, Visitor},
36};
37use ustr::Ustr;
38
39use crate::{
40 common::{
41 consts::DERIVE_VENUE,
42 enums::{
43 DeriveInstrumentType, DeriveOptionKind, DeriveOrderSide, DeriveOrderStatus,
44 DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType,
45 },
46 },
47 http::models::DeriveInstrument,
48};
49
50const DERIVE_DECIMAL_MAX_SCALE: usize = 28;
51const DERIVE_POST_ONLY_CROSS_MARKET_MESSAGE: &str = "post only order cannot cross the market";
52
53pub const DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE: i64 = 11008;
55
56#[must_use]
58pub fn format_instrument_id(venue_symbol: impl AsRef<str>) -> InstrumentId {
59 InstrumentId::new(Symbol::new(venue_symbol.as_ref()), *DERIVE_VENUE)
60}
61
62pub fn format_venue_symbol(instrument_id: &InstrumentId) -> anyhow::Result<Ustr> {
68 anyhow::ensure!(
69 instrument_id.venue == *DERIVE_VENUE,
70 "instrument ID `{instrument_id}` is not for venue {}",
71 DERIVE_VENUE.as_str(),
72 );
73 Ok(Ustr::from(instrument_id.symbol.as_str()))
74}
75
76pub fn deserialize_derive_decimal<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
83where
84 D: Deserializer<'de>,
85{
86 deserializer.deserialize_any(DeriveDecimalVisitor)
87}
88
89pub fn deserialize_optional_derive_decimal<'de, D>(
96 deserializer: D,
97) -> Result<Option<Decimal>, D::Error>
98where
99 D: Deserializer<'de>,
100{
101 deserializer.deserialize_any(OptionalDeriveDecimalVisitor)
102}
103
104struct DeriveDecimalVisitor;
105
106impl Visitor<'_> for DeriveDecimalVisitor {
107 type Value = Decimal;
108
109 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
110 formatter.write_str("a Derive decimal number as string, integer, float, or null")
111 }
112
113 fn visit_str<E: DeError>(self, value: &str) -> Result<Self::Value, E> {
114 if value.is_empty() {
115 return Ok(Decimal::ZERO);
116 }
117 parse_derive_decimal_str(value).map_err(E::custom)
118 }
119
120 fn visit_string<E: DeError>(self, value: String) -> Result<Self::Value, E> {
121 self.visit_str(&value)
122 }
123
124 fn visit_i64<E: DeError>(self, value: i64) -> Result<Self::Value, E> {
125 Ok(Decimal::from(value))
126 }
127
128 fn visit_u64<E: DeError>(self, value: u64) -> Result<Self::Value, E> {
129 Ok(Decimal::from(value))
130 }
131
132 fn visit_i128<E: DeError>(self, value: i128) -> Result<Self::Value, E> {
133 Ok(Decimal::from(value))
134 }
135
136 fn visit_u128<E: DeError>(self, value: u128) -> Result<Self::Value, E> {
137 Ok(Decimal::from(value))
138 }
139
140 fn visit_f64<E: DeError>(self, value: f64) -> Result<Self::Value, E> {
141 if value.is_nan() || value.is_infinite() {
142 return Err(E::invalid_value(Unexpected::Float(value), &self));
143 }
144 Decimal::try_from(value).map_err(E::custom)
145 }
146
147 fn visit_unit<E: DeError>(self) -> Result<Self::Value, E> {
148 Ok(Decimal::ZERO)
149 }
150
151 fn visit_none<E: DeError>(self) -> Result<Self::Value, E> {
152 Ok(Decimal::ZERO)
153 }
154}
155
156struct OptionalDeriveDecimalVisitor;
157
158impl Visitor<'_> for OptionalDeriveDecimalVisitor {
159 type Value = Option<Decimal>;
160
161 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
162 formatter.write_str("null or a Derive decimal number as string, integer, or float")
163 }
164
165 fn visit_str<E: DeError>(self, value: &str) -> Result<Self::Value, E> {
166 if value.is_empty() {
167 return Ok(None);
168 }
169 parse_derive_decimal_str(value).map(Some).map_err(E::custom)
170 }
171
172 fn visit_string<E: DeError>(self, value: String) -> Result<Self::Value, E> {
173 self.visit_str(&value)
174 }
175
176 fn visit_i64<E: DeError>(self, value: i64) -> Result<Self::Value, E> {
177 DeriveDecimalVisitor.visit_i64(value).map(Some)
178 }
179
180 fn visit_u64<E: DeError>(self, value: u64) -> Result<Self::Value, E> {
181 DeriveDecimalVisitor.visit_u64(value).map(Some)
182 }
183
184 fn visit_i128<E: DeError>(self, value: i128) -> Result<Self::Value, E> {
185 DeriveDecimalVisitor.visit_i128(value).map(Some)
186 }
187
188 fn visit_u128<E: DeError>(self, value: u128) -> Result<Self::Value, E> {
189 DeriveDecimalVisitor.visit_u128(value).map(Some)
190 }
191
192 fn visit_f64<E: DeError>(self, value: f64) -> Result<Self::Value, E> {
193 DeriveDecimalVisitor.visit_f64(value).map(Some)
194 }
195
196 fn visit_unit<E: DeError>(self) -> Result<Self::Value, E> {
197 Ok(None)
198 }
199
200 fn visit_none<E: DeError>(self) -> Result<Self::Value, E> {
201 Ok(None)
202 }
203}
204
205fn parse_derive_decimal_str(value: &str) -> Result<Decimal, String> {
206 let parsed = if value.contains('e') || value.contains('E') {
207 Decimal::from_scientific(value)
208 } else {
209 Decimal::from_str(value)
210 };
211
212 match parsed {
213 Ok(decimal) => Ok(decimal),
214 Err(e) => {
215 for scale in (0..=DERIVE_DECIMAL_MAX_SCALE).rev() {
216 let clamped =
217 decimal_string_clamped_to_scale(value, scale).ok_or_else(|| e.to_string())?;
218
219 if let Ok(decimal) = Decimal::from_str(&clamped) {
220 return Ok(decimal);
221 }
222 }
223 Err(e.to_string())
224 }
225 }
226}
227
228fn decimal_string_clamped_to_scale(value: &str, max_scale: usize) -> Option<String> {
229 let (coefficient, exponent) = match value.find(['e', 'E']) {
230 Some(index) => {
231 let exponent = value[index + 1..].parse::<i32>().ok()?;
232 (&value[..index], exponent)
233 }
234 None => (value, 0),
235 };
236
237 let (sign, unsigned) = match coefficient.as_bytes().first()? {
238 b'+' => ("", &coefficient[1..]),
239 b'-' => ("-", &coefficient[1..]),
240 _ => ("", coefficient),
241 };
242 let (integer, fractional) = decimal_components(unsigned)?;
243 let digits = format!("{integer}{fractional}");
244 let point = i32::try_from(integer.len()).ok()?.checked_add(exponent)?;
245
246 let (integer, fractional) = if point <= 0 {
247 let zero_count = usize::try_from(-point).ok()?;
248 (
249 "0".to_string(),
250 format!("{}{digits}", "0".repeat(zero_count)),
251 )
252 } else {
253 let point = usize::try_from(point).ok()?;
254 if point >= digits.len() {
255 (
256 format!("{}{}", digits, "0".repeat(point - digits.len())),
257 String::new(),
258 )
259 } else {
260 (digits[..point].to_string(), digits[point..].to_string())
261 }
262 };
263
264 let (integer, fractional) = round_decimal_components(integer, fractional, max_scale);
265 let sign = if sign == "-" && decimal_digits_are_zero(&integer, &fractional) {
266 ""
267 } else {
268 sign
269 };
270
271 if fractional.is_empty() {
272 Some(format!("{sign}{integer}"))
273 } else {
274 Some(format!("{sign}{integer}.{fractional}"))
275 }
276}
277
278fn decimal_components(value: &str) -> Option<(&str, &str)> {
279 let mut split = value.split('.');
280 let integer = split.next()?;
281 let fractional = split.next().unwrap_or("");
282 if split.next().is_some()
283 || (integer.is_empty() && fractional.is_empty())
284 || !integer.chars().all(|c| c.is_ascii_digit())
285 || !fractional.chars().all(|c| c.is_ascii_digit())
286 {
287 return None;
288 }
289 Some((integer, fractional))
290}
291
292fn round_decimal_components(
293 mut integer: String,
294 fractional: String,
295 max_scale: usize,
296) -> (String, String) {
297 if fractional.len() <= max_scale {
298 return (integer, fractional);
299 }
300
301 let mut rounded = fractional.as_bytes()[..max_scale].to_vec();
302 if fractional.as_bytes()[max_scale] >= b'5' {
303 increment_decimal_digits(&mut integer, &mut rounded);
304 }
305
306 (
307 integer,
308 String::from_utf8(rounded).expect("decimal digits are ASCII"),
309 )
310}
311
312fn increment_decimal_digits(integer: &mut String, fractional: &mut [u8]) {
313 for digit in fractional.iter_mut().rev() {
314 if *digit < b'9' {
315 *digit += 1;
316 return;
317 }
318 *digit = b'0';
319 }
320
321 let mut integer_digits = integer.as_bytes().to_vec();
322 for digit in integer_digits.iter_mut().rev() {
323 if *digit < b'9' {
324 *digit += 1;
325 *integer = String::from_utf8(integer_digits).expect("decimal digits are ASCII");
326 return;
327 }
328 *digit = b'0';
329 }
330 integer_digits.insert(0, b'1');
331 *integer = String::from_utf8(integer_digits).expect("decimal digits are ASCII");
332}
333
334fn decimal_digits_are_zero(integer: &str, fractional: &str) -> bool {
335 integer
336 .bytes()
337 .chain(fractional.bytes())
338 .all(|digit| digit == b'0')
339}
340
341pub fn order_side_to_derive(side: OrderSide) -> anyhow::Result<DeriveOrderSide> {
348 match side {
349 OrderSide::Buy => Ok(DeriveOrderSide::Buy),
350 OrderSide::Sell => Ok(DeriveOrderSide::Sell),
351 OrderSide::NoOrderSide => anyhow::bail!("unsupported order side for Derive: {side:?}"),
352 }
353}
354
355pub fn order_type_to_derive(order_type: OrderType) -> anyhow::Result<DeriveOrderType> {
361 match order_type {
362 OrderType::Limit => Ok(DeriveOrderType::Limit),
363 OrderType::Market => Ok(DeriveOrderType::Market),
364 other => anyhow::bail!("unsupported order type for Derive: {other:?}"),
365 }
366}
367
368pub fn trigger_order_type_to_derive(order_type: OrderType) -> anyhow::Result<DeriveOrderType> {
374 match order_type {
375 OrderType::StopMarket | OrderType::MarketIfTouched => Ok(DeriveOrderType::Market),
376 OrderType::StopLimit | OrderType::LimitIfTouched => Ok(DeriveOrderType::Limit),
377 other => anyhow::bail!(
378 "unsupported trigger order type for Derive: {other:?}; supported types are StopMarket, StopLimit, MarketIfTouched, and LimitIfTouched"
379 ),
380 }
381}
382
383pub fn trigger_type_to_derive(order_type: OrderType) -> anyhow::Result<DeriveTriggerType> {
389 match order_type {
390 OrderType::StopMarket | OrderType::StopLimit => Ok(DeriveTriggerType::Stoploss),
391 OrderType::MarketIfTouched | OrderType::LimitIfTouched => Ok(DeriveTriggerType::Takeprofit),
392 other => anyhow::bail!(
393 "unsupported trigger order type for Derive: {other:?}; supported types are StopMarket, StopLimit, MarketIfTouched, and LimitIfTouched"
394 ),
395 }
396}
397
398pub fn trigger_price_type_to_derive(
405 trigger_type: Option<TriggerType>,
406) -> anyhow::Result<DeriveTriggerPriceType> {
407 match trigger_type {
408 Some(TriggerType::Default | TriggerType::MarkPrice) => Ok(DeriveTriggerPriceType::Mark),
409 Some(TriggerType::IndexPrice) => anyhow::bail!(
410 "unsupported trigger price type for Derive: IndexPrice; Derive currently accepts only MarkPrice for trigger orders"
411 ),
412 Some(other) => anyhow::bail!(
413 "unsupported trigger price type for Derive: {other:?}; Derive trigger orders support only MarkPrice"
414 ),
415 None => anyhow::bail!(
416 "missing trigger price type for Derive trigger order; Derive trigger orders support only MarkPrice"
417 ),
418 }
419}
420
421pub fn time_in_force_to_derive(
427 tif: TimeInForce,
428 post_only: bool,
429) -> anyhow::Result<DeriveTimeInForce> {
430 match tif {
431 TimeInForce::Gtc if post_only => Ok(DeriveTimeInForce::PostOnly),
432 TimeInForce::Ioc | TimeInForce::Fok if post_only => anyhow::bail!(
433 "post-only Derive orders only support GTC time in force; received {tif:?}"
434 ),
435 TimeInForce::Gtc => Ok(DeriveTimeInForce::Gtc),
436 TimeInForce::Ioc => Ok(DeriveTimeInForce::Ioc),
437 TimeInForce::Fok => Ok(DeriveTimeInForce::Fok),
438 other => anyhow::bail!("unsupported time in force for Derive: {other:?}"),
439 }
440}
441
442#[must_use]
444pub fn derive_order_side_to_nautilus(side: DeriveOrderSide) -> OrderSide {
445 match side {
446 DeriveOrderSide::Buy => OrderSide::Buy,
447 DeriveOrderSide::Sell => OrderSide::Sell,
448 }
449}
450
451#[must_use]
453pub fn derive_order_type_to_nautilus(order_type: DeriveOrderType) -> OrderType {
454 match order_type {
455 DeriveOrderType::Limit => OrderType::Limit,
456 DeriveOrderType::Market => OrderType::Market,
457 }
458}
459
460#[must_use]
462pub fn derive_order_type_to_nautilus_for_order(
463 order_type: DeriveOrderType,
464 trigger_type: Option<DeriveTriggerType>,
465) -> OrderType {
466 match (order_type, trigger_type) {
467 (DeriveOrderType::Market, Some(DeriveTriggerType::Stoploss)) => OrderType::StopMarket,
468 (DeriveOrderType::Limit, Some(DeriveTriggerType::Stoploss)) => OrderType::StopLimit,
469 (DeriveOrderType::Market, Some(DeriveTriggerType::Takeprofit)) => {
470 OrderType::MarketIfTouched
471 }
472 (DeriveOrderType::Limit, Some(DeriveTriggerType::Takeprofit)) => OrderType::LimitIfTouched,
473 (order_type, None) => derive_order_type_to_nautilus(order_type),
474 }
475}
476
477#[must_use]
479pub const fn derive_trigger_price_type_to_nautilus(
480 trigger_price_type: DeriveTriggerPriceType,
481) -> TriggerType {
482 match trigger_price_type {
483 DeriveTriggerPriceType::Mark => TriggerType::MarkPrice,
484 DeriveTriggerPriceType::Index => TriggerType::IndexPrice,
485 }
486}
487
488#[must_use]
490pub fn derive_tif_to_nautilus(tif: DeriveTimeInForce) -> TimeInForce {
491 match tif {
492 DeriveTimeInForce::Gtc | DeriveTimeInForce::PostOnly => TimeInForce::Gtc,
493 DeriveTimeInForce::Ioc => TimeInForce::Ioc,
494 DeriveTimeInForce::Fok => TimeInForce::Fok,
495 }
496}
497
498#[must_use]
501pub fn derive_status_to_nautilus(
502 status: DeriveOrderStatus,
503 filled_qty: Decimal,
504 quantity: Decimal,
505) -> OrderStatus {
506 match status {
507 DeriveOrderStatus::Open => {
508 if filled_qty > Decimal::ZERO && filled_qty < quantity {
509 OrderStatus::PartiallyFilled
510 } else {
511 OrderStatus::Accepted
512 }
513 }
514 DeriveOrderStatus::Filled => OrderStatus::Filled,
515 DeriveOrderStatus::Rejected => OrderStatus::Rejected,
516 DeriveOrderStatus::Cancelled => OrderStatus::Canceled,
517 DeriveOrderStatus::Expired => OrderStatus::Expired,
518 DeriveOrderStatus::Untriggered | DeriveOrderStatus::AlgoActive => OrderStatus::Accepted,
519 }
520}
521
522#[must_use]
524pub fn derive_rejection_due_post_only(code: Option<i64>, reason: &str) -> bool {
525 match code {
526 Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE) => true,
527 Some(_) => false,
528 None => reason
529 .to_ascii_lowercase()
530 .contains(DERIVE_POST_ONLY_CROSS_MARKET_MESSAGE),
531 }
532}
533
534pub fn parse_derive_instrument_any(
541 instrument: &DeriveInstrument,
542 ts_init: UnixNanos,
543) -> anyhow::Result<Option<InstrumentAny>> {
544 match instrument.instrument_type {
545 DeriveInstrumentType::Perp => parse_perp_instrument(instrument, ts_init).map(Some),
546 DeriveInstrumentType::Option => parse_option_instrument(instrument, ts_init).map(Some),
547 DeriveInstrumentType::Erc20 => parse_spot_instrument(instrument, ts_init).map(Some),
548 }
549}
550
551fn parse_perp_instrument(
552 instrument: &DeriveInstrument,
553 ts_init: UnixNanos,
554) -> anyhow::Result<InstrumentAny> {
555 instrument
556 .perp_details
557 .as_ref()
558 .context("missing perp_details for Derive perp instrument")?;
559
560 let instrument_id = format_instrument_id(instrument.instrument_name.as_str());
561 let raw_symbol = Symbol::new(instrument.instrument_name.as_str());
562 let base_currency = Currency::get_or_create_crypto(instrument.base_currency.as_str());
563 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency.as_str());
564 let settlement_currency = quote_currency;
565 let price_increment = price_from_decimal(instrument.tick_size, "tick_size")?;
566 let size_increment = quantity_from_decimal(instrument.amount_step, "amount_step")?;
567 let multiplier = quantity_from_decimal(Decimal::ONE, "multiplier")?;
568 let max_quantity = quantity_from_decimal(instrument.maximum_amount, "maximum_amount")?;
569 let min_quantity = quantity_from_decimal(instrument.minimum_amount, "minimum_amount")?;
570 let info = derive_instrument_info(instrument)?;
571
572 let perp = CryptoPerpetual::new(
573 instrument_id,
574 raw_symbol,
575 base_currency,
576 quote_currency,
577 settlement_currency,
578 false,
579 price_increment.precision,
580 size_increment.precision,
581 price_increment,
582 size_increment,
583 Some(multiplier),
584 Some(size_increment),
585 Some(max_quantity),
586 Some(min_quantity),
587 None,
588 None,
589 None,
590 None,
591 None,
592 None,
593 Some(instrument.maker_fee_rate),
594 Some(instrument.taker_fee_rate),
595 None,
596 Some(info),
597 ts_init,
598 ts_init,
599 );
600
601 Ok(InstrumentAny::CryptoPerpetual(perp))
602}
603
604fn parse_option_instrument(
605 instrument: &DeriveInstrument,
606 ts_init: UnixNanos,
607) -> anyhow::Result<InstrumentAny> {
608 let details = instrument
609 .option_details
610 .as_ref()
611 .context("missing option_details for Derive option instrument")?;
612
613 let instrument_id = format_instrument_id(instrument.instrument_name.as_str());
614 let raw_symbol = Symbol::new(instrument.instrument_name.as_str());
615 let underlying = Currency::get_or_create_crypto(instrument.base_currency.as_str());
616 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency.as_str());
617 let settlement_currency = quote_currency;
618 let option_kind = parse_option_kind(details.option_type);
619 let strike_price = price_from_decimal(details.strike, "option_details.strike")?;
620 let activation_ns =
621 timestamp_millis_to_nanos(instrument.scheduled_activation, "scheduled_activation")?;
622 let expiration_ns = timestamp_seconds_to_nanos(details.expiry, "option_details.expiry")?;
623 let price_increment = price_from_decimal(instrument.tick_size, "tick_size")?;
624 let size_increment = quantity_from_decimal(instrument.amount_step, "amount_step")?;
625 let multiplier = quantity_from_decimal(Decimal::ONE, "multiplier")?;
626 let max_quantity = quantity_from_decimal(instrument.maximum_amount, "maximum_amount")?;
627 let min_quantity = quantity_from_decimal(instrument.minimum_amount, "minimum_amount")?;
628 let info = derive_instrument_info(instrument)?;
629
630 let option = CryptoOption::new(
631 instrument_id,
632 raw_symbol,
633 underlying,
634 quote_currency,
635 settlement_currency,
636 false,
637 option_kind,
638 strike_price,
639 activation_ns,
640 expiration_ns,
641 price_increment.precision,
642 size_increment.precision,
643 price_increment,
644 size_increment,
645 Some(multiplier),
646 Some(size_increment),
647 Some(max_quantity),
648 Some(min_quantity),
649 None,
650 None,
651 None,
652 None,
653 None,
654 None,
655 Some(instrument.maker_fee_rate),
656 Some(instrument.taker_fee_rate),
657 None,
658 Some(info),
659 ts_init,
660 ts_init,
661 );
662
663 Ok(InstrumentAny::CryptoOption(option))
664}
665
666fn parse_spot_instrument(
667 instrument: &DeriveInstrument,
668 ts_init: UnixNanos,
669) -> anyhow::Result<InstrumentAny> {
670 let instrument_id = format_instrument_id(instrument.instrument_name.as_str());
671 let raw_symbol = Symbol::new(instrument.instrument_name.as_str());
672 let base_currency = Currency::get_or_create_crypto(instrument.base_currency.as_str());
673 let quote_currency = Currency::get_or_create_crypto(instrument.quote_currency.as_str());
674 let price_increment = price_from_decimal(instrument.tick_size, "tick_size")?;
675 let size_increment = quantity_from_decimal(instrument.amount_step, "amount_step")?;
676 let multiplier = quantity_from_decimal(Decimal::ONE, "multiplier")?;
677 let max_quantity = quantity_from_decimal(instrument.maximum_amount, "maximum_amount")?;
678 let min_quantity = quantity_from_decimal(instrument.minimum_amount, "minimum_amount")?;
679 let info = derive_instrument_info(instrument)?;
680
681 let pair = CurrencyPair::new(
682 instrument_id,
683 raw_symbol,
684 base_currency,
685 quote_currency,
686 price_increment.precision,
687 size_increment.precision,
688 price_increment,
689 size_increment,
690 Some(multiplier),
691 Some(size_increment),
692 Some(max_quantity),
693 Some(min_quantity),
694 None,
695 None,
696 None,
697 None,
698 None,
699 None,
700 Some(instrument.maker_fee_rate),
701 Some(instrument.taker_fee_rate),
702 None,
703 Some(info),
704 ts_init,
705 ts_init,
706 );
707
708 Ok(InstrumentAny::CurrencyPair(pair))
709}
710
711fn parse_option_kind(kind: DeriveOptionKind) -> OptionKind {
712 match kind {
713 DeriveOptionKind::Call => OptionKind::Call,
714 DeriveOptionKind::Put => OptionKind::Put,
715 }
716}
717
718fn derive_instrument_info(instrument: &DeriveInstrument) -> anyhow::Result<Params> {
723 let value = serde_json::to_value(instrument)
724 .context("failed to serialize DeriveInstrument for info field")?;
725 let object = value
726 .as_object()
727 .context("DeriveInstrument did not serialize to a JSON object")?
728 .clone();
729 Ok(Params::from_index_map(object.into_iter().collect()))
730}
731
732fn price_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Price> {
733 Price::from_decimal(value).with_context(|| format!("invalid Derive {field}"))
734}
735
736fn quantity_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Quantity> {
737 Quantity::from_decimal(value).with_context(|| format!("invalid Derive {field}"))
738}
739
740fn timestamp_seconds_to_nanos(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
741 timestamp_to_nanos(value, NANOSECONDS_IN_SECOND, field)
742}
743
744fn timestamp_millis_to_nanos(value: i64, field: &str) -> anyhow::Result<UnixNanos> {
745 timestamp_to_nanos(value, NANOSECONDS_IN_MILLISECOND, field)
746}
747
748fn timestamp_to_nanos(value: i64, multiplier: u64, field: &str) -> anyhow::Result<UnixNanos> {
749 let value = u64::try_from(value).with_context(|| format!("negative Derive {field}"))?;
750 let nanos = value
751 .checked_mul(multiplier)
752 .with_context(|| format!("Derive {field} overflows nanoseconds"))?;
753 Ok(UnixNanos::from(nanos))
754}
755
756#[cfg(test)]
757mod tests {
758 use std::path::PathBuf;
759
760 use nautilus_core::UnixNanos;
761 use nautilus_model::{
762 enums::{OptionKind, OrderStatus, OrderType, TriggerType},
763 identifiers::InstrumentId,
764 instruments::{Instrument, InstrumentAny},
765 types::{Currency, Price, Quantity},
766 };
767 use rstest::rstest;
768 use rust_decimal_macros::dec;
769 use serde::Deserialize;
770 use serde_json::{Value, json};
771
772 use super::*;
773
774 fn data_path() -> PathBuf {
775 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
776 }
777
778 fn load_json(filename: &str) -> Value {
779 let content = std::fs::read_to_string(data_path().join(filename))
780 .unwrap_or_else(|_| panic!("failed to read {filename}"));
781 serde_json::from_str(&content).expect("invalid json")
782 }
783
784 fn perp_fixture() -> DeriveInstrument {
785 serde_json::from_value(load_json("perps/instrument_eth.json")).unwrap()
786 }
787
788 fn option_fixture() -> DeriveInstrument {
789 serde_json::from_value(load_json("options/instrument_eth.json")).unwrap()
790 }
791
792 fn spot_fixture() -> DeriveInstrument {
793 serde_json::from_value(load_json("spot/instrument_eth.json")).unwrap()
794 }
795
796 #[derive(Deserialize)]
797 struct DeriveDecimalProbe {
798 #[serde(deserialize_with = "deserialize_derive_decimal")]
799 rounded: Decimal,
800 #[serde(deserialize_with = "deserialize_optional_derive_decimal")]
801 optional: Option<Decimal>,
802 #[serde(deserialize_with = "deserialize_derive_decimal")]
803 carry: Decimal,
804 #[serde(deserialize_with = "deserialize_derive_decimal")]
805 negative_zero: Decimal,
806 }
807
808 #[rstest]
809 fn test_deserialize_derive_decimal_rounds_high_scale_scientific_values() {
810 let carry_input = "999999999999999999999999999995e-29";
811 assert!(Decimal::from_scientific(carry_input).is_err());
812
813 let probe: DeriveDecimalProbe = serde_json::from_value(json!({
814 "rounded": "1.234567890123456789012345678912345e-1",
815 "optional": "51.234567890123456789012345678912345e-1",
816 "carry": carry_input,
817 "negative_zero": "-4e-29"
818 }))
819 .unwrap();
820
821 assert_eq!(probe.rounded.to_string(), "0.1234567890123456789012345679",);
822 assert_eq!(
823 probe.optional.as_ref().map(ToString::to_string),
824 Some("5.1234567890123456789012345679".into()),
825 );
826 assert_eq!(probe.carry.to_string(), "10.000000000000000000000000000",);
827 assert_eq!(
828 probe.negative_zero.to_string(),
829 "0.0000000000000000000000000000"
830 );
831 }
832
833 #[rstest]
834 #[case(OrderType::StopMarket, DeriveOrderType::Market)]
835 #[case(OrderType::MarketIfTouched, DeriveOrderType::Market)]
836 #[case(OrderType::StopLimit, DeriveOrderType::Limit)]
837 #[case(OrderType::LimitIfTouched, DeriveOrderType::Limit)]
838 fn test_trigger_order_type_to_derive(
839 #[case] order_type: OrderType,
840 #[case] expected: DeriveOrderType,
841 ) {
842 assert_eq!(trigger_order_type_to_derive(order_type).unwrap(), expected);
843 }
844
845 #[rstest]
846 fn test_trigger_order_type_to_derive_rejects_unsupported() {
847 let err = trigger_order_type_to_derive(OrderType::TrailingStopMarket)
848 .expect_err("trailing stops must be rejected");
849
850 assert!(
851 err.to_string()
852 .contains("unsupported trigger order type for Derive"),
853 "unexpected error: {err}",
854 );
855 }
856
857 #[rstest]
858 #[case(OrderType::StopMarket, DeriveTriggerType::Stoploss)]
859 #[case(OrderType::StopLimit, DeriveTriggerType::Stoploss)]
860 #[case(OrderType::MarketIfTouched, DeriveTriggerType::Takeprofit)]
861 #[case(OrderType::LimitIfTouched, DeriveTriggerType::Takeprofit)]
862 fn test_trigger_type_to_derive(
863 #[case] order_type: OrderType,
864 #[case] expected: DeriveTriggerType,
865 ) {
866 assert_eq!(trigger_type_to_derive(order_type).unwrap(), expected);
867 }
868
869 #[rstest]
870 fn test_trigger_price_type_to_derive_accepts_only_mark_price() {
871 assert_eq!(
872 trigger_price_type_to_derive(Some(TriggerType::MarkPrice)).unwrap(),
873 DeriveTriggerPriceType::Mark,
874 );
875 assert_eq!(
876 trigger_price_type_to_derive(Some(TriggerType::Default)).unwrap(),
877 DeriveTriggerPriceType::Mark,
878 );
879
880 for trigger_type in [
881 TriggerType::IndexPrice,
882 TriggerType::LastPrice,
883 TriggerType::BidAsk,
884 TriggerType::NoTrigger,
885 ] {
886 let err = trigger_price_type_to_derive(Some(trigger_type))
887 .expect_err("unsupported trigger price type must fail");
888 assert!(
889 err.to_string().contains("unsupported trigger price type"),
890 "unexpected error for {trigger_type:?}: {err}",
891 );
892 }
893 }
894
895 #[rstest]
896 #[case(
897 DeriveOrderType::Market,
898 Some(DeriveTriggerType::Stoploss),
899 OrderType::StopMarket
900 )]
901 #[case(
902 DeriveOrderType::Limit,
903 Some(DeriveTriggerType::Stoploss),
904 OrderType::StopLimit
905 )]
906 #[case(
907 DeriveOrderType::Market,
908 Some(DeriveTriggerType::Takeprofit),
909 OrderType::MarketIfTouched
910 )]
911 #[case(
912 DeriveOrderType::Limit,
913 Some(DeriveTriggerType::Takeprofit),
914 OrderType::LimitIfTouched
915 )]
916 #[case(DeriveOrderType::Limit, None, OrderType::Limit)]
917 fn test_derive_order_type_to_nautilus_for_order(
918 #[case] order_type: DeriveOrderType,
919 #[case] trigger_type: Option<DeriveTriggerType>,
920 #[case] expected: OrderType,
921 ) {
922 assert_eq!(
923 derive_order_type_to_nautilus_for_order(order_type, trigger_type),
924 expected,
925 );
926 }
927
928 #[rstest]
929 fn test_derive_status_to_nautilus_maps_untriggered_to_accepted() {
930 assert_eq!(
931 derive_status_to_nautilus(DeriveOrderStatus::Untriggered, dec!(0), dec!(1)),
932 OrderStatus::Accepted,
933 );
934 }
935
936 #[rstest]
937 #[case(
938 Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE),
939 "Post only order cannot cross the market",
940 true
941 )]
942 #[case(
943 Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE),
944 "post only order cannot cross the market",
945 true
946 )]
947 #[case(None, "Post only order cannot cross the market", true)]
948 #[case(Some(-32602), "Post only order cannot cross the market", false)]
949 #[case(Some(DERIVE_POST_ONLY_CROSS_MARKET_ERROR_CODE), "Invalid params", true)]
950 fn test_derive_rejection_due_post_only(
951 #[case] code: Option<i64>,
952 #[case] reason: &str,
953 #[case] expected: bool,
954 ) {
955 assert_eq!(derive_rejection_due_post_only(code, reason), expected);
956 }
957
958 #[rstest]
959 fn test_parse_perp_instrument() {
960 let instrument = parse_derive_instrument_any(&perp_fixture(), UnixNanos::from(123))
961 .unwrap()
962 .unwrap();
963
964 let InstrumentAny::CryptoPerpetual(perp) = instrument else {
965 panic!("expected CryptoPerpetual");
966 };
967
968 assert_eq!(perp.id(), InstrumentId::from("ETH-PERP.DERIVE"));
969 assert_eq!(perp.raw_symbol().as_str(), "ETH-PERP");
970 assert_eq!(perp.base_currency(), Some(Currency::ETH()));
971 assert_eq!(perp.quote_currency(), Currency::USDC());
972 assert_eq!(perp.settlement_currency(), Currency::USDC());
973 assert_eq!(perp.price_increment(), Price::from("0.01"));
974 assert_eq!(perp.size_increment(), Quantity::from("0.001"));
975 assert_eq!(perp.max_quantity(), Some(Quantity::from("1000")));
976 assert_eq!(perp.min_quantity(), Some(Quantity::from("0.001")));
977 assert_eq!(perp.maker_fee(), dec!(0.0001));
978 assert_eq!(perp.taker_fee(), dec!(0.0005));
979 assert!(!perp.is_inverse());
980
981 let info = perp.info.as_ref().expect("info populated");
985 assert_eq!(info.get_str("instrument_name"), Some("ETH-PERP"));
986 assert_eq!(info.get_str("instrument_type"), Some("perp"));
987 assert_eq!(info.get_str("base_asset_sub_id"), Some("0"));
988 assert!(info.get("perp_details").is_some_and(|v| v.is_object()));
989 }
990
991 #[rstest]
992 fn test_parse_option_instrument() {
993 let instrument = parse_derive_instrument_any(&option_fixture(), UnixNanos::from(456))
994 .unwrap()
995 .unwrap();
996
997 let InstrumentAny::CryptoOption(option) = instrument else {
998 panic!("expected CryptoOption");
999 };
1000
1001 assert_eq!(
1002 option.id(),
1003 InstrumentId::from("ETH-20260627-3500-C.DERIVE")
1004 );
1005 assert_eq!(option.raw_symbol().as_str(), "ETH-20260627-3500-C");
1006 assert_eq!(option.base_currency(), Some(Currency::ETH()));
1007 assert_eq!(option.quote_currency(), Currency::USDC());
1008 assert_eq!(option.settlement_currency(), Currency::USDC());
1009 assert_eq!(option.option_kind(), Some(OptionKind::Call));
1010 assert_eq!(option.strike_price(), Some(Price::from("3500")));
1011 assert_eq!(
1012 option.activation_ns(),
1013 Some(UnixNanos::from(1_700_000_000_000_000_000)),
1014 );
1015 assert_eq!(
1016 option.expiration_ns(),
1017 Some(UnixNanos::from(1_782_000_000_000_000_000)),
1018 );
1019 assert_eq!(option.price_increment(), Price::from("1"));
1020 assert_eq!(option.size_increment(), Quantity::from("0.01"));
1021 assert_eq!(option.max_quantity(), Some(Quantity::from("100")));
1022 assert_eq!(option.min_quantity(), Some(Quantity::from("0.01")));
1023 assert_eq!(option.taker_fee(), dec!(0.001));
1024
1025 let info = option.info.as_ref().expect("info populated");
1026 assert_eq!(info.get_str("instrument_name"), Some("ETH-20260627-3500-C"));
1027 assert_eq!(info.get_str("instrument_type"), Some("option"));
1028 let option_details = info.get("option_details").expect("option_details present");
1029 assert_eq!(
1030 option_details.get("option_type").and_then(|v| v.as_str()),
1031 Some("C")
1032 );
1033 assert_eq!(
1034 option_details.get("strike").and_then(|v| v.as_str()),
1035 Some("3500")
1036 );
1037 }
1038
1039 #[rstest]
1040 fn test_symbol_instrument_id_mapping() {
1041 let instrument_id = format_instrument_id("ETH-20260627-3500-C");
1042 let venue_symbol = format_venue_symbol(&instrument_id).unwrap();
1043
1044 assert_eq!(
1045 instrument_id,
1046 InstrumentId::from("ETH-20260627-3500-C.DERIVE")
1047 );
1048 assert_eq!(venue_symbol, "ETH-20260627-3500-C");
1049 }
1050
1051 #[rstest]
1052 fn test_format_venue_symbol_rejects_non_derive_venue() {
1053 let instrument_id = InstrumentId::from("ETH-PERP.BINANCE");
1054
1055 let err = format_venue_symbol(&instrument_id).expect_err("must reject non-Derive venue");
1056
1057 assert!(err.to_string().contains("not for venue DERIVE"));
1058 }
1059
1060 #[rstest]
1061 fn test_parse_spot_instrument() {
1062 let instrument = parse_derive_instrument_any(&spot_fixture(), UnixNanos::from(789))
1063 .unwrap()
1064 .unwrap();
1065
1066 let InstrumentAny::CurrencyPair(pair) = instrument else {
1067 panic!("expected CurrencyPair");
1068 };
1069
1070 assert_eq!(pair.id(), InstrumentId::from("ETH-USDC.DERIVE"));
1071 assert_eq!(pair.raw_symbol().as_str(), "ETH-USDC");
1072 assert_eq!(pair.base_currency(), Some(Currency::ETH()));
1073 assert_eq!(pair.quote_currency(), Currency::USDC());
1074 assert_eq!(pair.price_increment(), Price::from("0.1"));
1075 assert_eq!(pair.size_increment(), Quantity::from("0.01"));
1076 assert_eq!(pair.max_quantity(), Some(Quantity::from("10000")));
1077 assert_eq!(pair.min_quantity(), Some(Quantity::from("0.1")));
1078 assert_eq!(pair.maker_fee(), dec!(0));
1079 assert_eq!(pair.taker_fee(), dec!(0));
1080
1081 let info = pair.info.as_ref().expect("info populated");
1082 assert_eq!(info.get_str("instrument_name"), Some("ETH-USDC"));
1083 assert_eq!(info.get_str("instrument_type"), Some("erc20"));
1084 assert_eq!(info.get_str("base_asset_sub_id"), Some("0"));
1085 assert_eq!(
1086 info.get_str("base_asset_address"),
1087 Some("0x41675b7746AE0E464f2594d258CF399c392A179C"),
1088 );
1089 }
1090
1091 #[rstest]
1092 fn test_parse_spot_instrument_maps_fee_slots_distinctly() {
1093 let mut instrument = spot_fixture();
1097 instrument.maker_fee_rate = dec!(0.0001);
1098 instrument.taker_fee_rate = dec!(0.0005);
1099
1100 let parsed = parse_derive_instrument_any(&instrument, UnixNanos::from(0))
1101 .unwrap()
1102 .unwrap();
1103 let InstrumentAny::CurrencyPair(pair) = parsed else {
1104 panic!("expected CurrencyPair");
1105 };
1106
1107 assert_eq!(pair.maker_fee(), dec!(0.0001));
1108 assert_eq!(pair.taker_fee(), dec!(0.0005));
1109 }
1110
1111 #[rstest]
1112 fn test_parse_perp_instrument_rejects_missing_perp_details() {
1113 let mut instrument = perp_fixture();
1114 instrument.perp_details = None;
1115
1116 let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1117 .expect_err("must reject missing perp details");
1118
1119 assert!(err.to_string().contains("missing perp_details"));
1120 }
1121
1122 #[rstest]
1123 fn test_parse_option_instrument_rejects_missing_option_details() {
1124 let mut instrument = option_fixture();
1125 instrument.option_details = None;
1126
1127 let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1128 .expect_err("must reject missing option details");
1129
1130 assert!(err.to_string().contains("missing option_details"));
1131 }
1132
1133 #[rstest]
1134 fn test_parse_option_instrument_rejects_negative_activation() {
1135 let mut instrument = option_fixture();
1136 instrument.scheduled_activation = -1;
1137
1138 let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1139 .expect_err("must reject negative activation timestamp");
1140
1141 assert!(
1142 err.to_string()
1143 .contains("negative Derive scheduled_activation")
1144 );
1145 }
1146
1147 #[rstest]
1148 fn test_parse_option_instrument_rejects_negative_expiry() {
1149 let mut instrument = option_fixture();
1150 instrument.option_details.as_mut().unwrap().expiry = -1;
1151
1152 let err = parse_derive_instrument_any(&instrument, UnixNanos::from(123))
1153 .expect_err("must reject negative expiry timestamp");
1154
1155 assert!(
1156 err.to_string()
1157 .contains("negative Derive option_details.expiry")
1158 );
1159 }
1160}