1use anyhow::Context;
19#[cfg(test)]
20use nautilus_core::string::secret::SecretString;
21use nautilus_core::{Params, UUID4, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND};
22use nautilus_model::{
23 enums::{LiquiditySide, OrderType, PositionSide},
24 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
25 reports::{FillReport, OrderStatusReport, PositionStatusReport},
26 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde_json::Value;
30
31use crate::{
32 common::{
33 consts::DERIVE_VENUE,
34 enums::{
35 DeriveLiquidityRole, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
36 DeriveTimeInForce, DeriveTriggerType, DeriveTxStatus,
37 },
38 parse::{
39 derive_order_side_to_nautilus, derive_order_type_to_nautilus_for_order,
40 derive_rejection_due_post_only, derive_status_to_nautilus, derive_tif_to_nautilus,
41 derive_trigger_price_type_to_nautilus,
42 },
43 },
44 http::models::{DeriveOrder, DerivePosition, DeriveSubaccount, DeriveTrade},
45};
46
47pub fn parse_derive_order_to_report(
59 order: &DeriveOrder,
60 account_id: AccountId,
61 ts_init: UnixNanos,
62) -> anyhow::Result<OrderStatusReport> {
63 parse_derive_order_to_report_with_precision(order, account_id, None, None, ts_init)
64}
65
66pub(crate) fn parse_derive_order_to_report_with_precision(
67 order: &DeriveOrder,
68 account_id: AccountId,
69 price_precision: Option<u8>,
70 size_precision: Option<u8>,
71 ts_init: UnixNanos,
72) -> anyhow::Result<OrderStatusReport> {
73 let instrument_id = InstrumentId::new(Symbol::new(order.instrument_name), *DERIVE_VENUE);
74 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
75 let order_side = derive_order_side_to_nautilus(order.direction);
76 let order_type = derive_order_type_to_nautilus_for_report(order);
77 let post_only = matches!(order.time_in_force, DeriveTimeInForce::PostOnly);
78 let time_in_force = derive_tif_to_nautilus(order.time_in_force);
79 let order_status =
80 derive_status_to_nautilus(order.order_status, order.filled_amount, order.amount);
81 let quantity = quantity_from_decimal(order.amount, size_precision, "amount")?;
82 let filled_qty = quantity_from_decimal(order.filled_amount, size_precision, "filled_amount")?;
83
84 let ts_accepted = ms_to_nanos(order.creation_timestamp);
85 let ts_last = ms_to_nanos(order.last_update_timestamp);
86
87 let mut report = OrderStatusReport::new(
88 account_id,
89 instrument_id,
90 None,
91 venue_order_id,
92 order_side.into(),
93 order_type,
94 time_in_force,
95 order_status,
96 quantity,
97 filled_qty,
98 ts_accepted,
99 ts_last,
100 ts_init,
101 Some(UUID4::new()),
102 );
103
104 if !order.label.is_empty() {
105 let client_order_id = ClientOrderId::new(order.label);
106 report = report.with_client_order_id(client_order_id);
107 }
108
109 if order.limit_price > Decimal::ZERO
110 && order_type_has_limit_price(order_type)
111 && let Ok(price) = price_from_decimal(order.limit_price, price_precision, "limit_price")
112 {
113 report = report.with_price(price);
114 }
115
116 if let Some(trigger_price) = order.trigger_price
117 && trigger_price > Decimal::ZERO
118 && let Ok(price) = price_from_decimal(trigger_price, price_precision, "trigger_price")
119 {
120 report = report.with_trigger_price(price);
121 }
122
123 if let Some(trigger_price_type) = order.trigger_price_type {
124 report =
125 report.with_trigger_type(derive_trigger_price_type_to_nautilus(trigger_price_type));
126 }
127
128 if order.average_price > Decimal::ZERO {
129 report.avg_px = Some(order.average_price);
130 }
131 report.post_only = post_only;
132 let trigger_reject_message = order
133 .trigger_reject_message
134 .as_deref()
135 .filter(|message| !message.is_empty())
136 .map(str::to_string);
137 let cancel_reason = trigger_reject_message
138 .clone()
139 .unwrap_or_else(|| order.cancel_reason.to_string());
140 if order.order_status == DeriveOrderStatus::Cancelled
141 || (order.order_status == DeriveOrderStatus::Rejected
142 && (trigger_reject_message.is_some()
143 || derive_rejection_due_post_only(None, &cancel_reason)))
144 {
145 report.cancel_reason = Some(cancel_reason);
146 }
147 Ok(report)
148}
149
150fn order_type_has_limit_price(order_type: OrderType) -> bool {
151 matches!(
152 order_type,
153 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
154 )
155}
156
157fn derive_order_type_to_nautilus_for_report(order: &DeriveOrder) -> OrderType {
158 let order_type = derive_order_type_to_nautilus_for_order(order.order_type, order.trigger_type);
159 if order_type != OrderType::LimitIfTouched {
160 return order_type;
161 }
162
163 match (order.order_type, order.trigger_type, order.trigger_price) {
164 (DeriveOrderType::Limit, Some(DeriveTriggerType::Takeprofit), Some(trigger_price))
165 if !limit_if_touched_prices_are_valid(
166 order.direction,
167 order.limit_price,
168 trigger_price,
169 ) =>
170 {
171 OrderType::StopLimit
172 }
173 _ => order_type,
174 }
175}
176
177fn limit_if_touched_prices_are_valid(
178 direction: DeriveOrderSide,
179 limit_price: Decimal,
180 trigger_price: Decimal,
181) -> bool {
182 match direction {
183 DeriveOrderSide::Buy => trigger_price <= limit_price,
184 DeriveOrderSide::Sell => trigger_price >= limit_price,
185 }
186}
187
188pub fn parse_derive_trade_to_fill_report(
200 trade: &DeriveTrade,
201 account_id: AccountId,
202 fee_currency: Currency,
203 ts_init: UnixNanos,
204) -> anyhow::Result<Option<FillReport>> {
205 parse_derive_trade_to_fill_report_with_precision(
206 trade,
207 account_id,
208 fee_currency,
209 None,
210 None,
211 ts_init,
212 )
213}
214
215pub(crate) fn parse_derive_trade_to_fill_report_with_precision(
216 trade: &DeriveTrade,
217 account_id: AccountId,
218 fee_currency: Currency,
219 price_precision: Option<u8>,
220 size_precision: Option<u8>,
221 ts_init: UnixNanos,
222) -> anyhow::Result<Option<FillReport>> {
223 if trade.trade_id.is_empty() || trade.tx_status == DeriveTxStatus::Reverted {
227 return Ok(None);
228 }
229
230 let instrument_id = InstrumentId::new(Symbol::new(trade.instrument_name), *DERIVE_VENUE);
231 let venue_order_id = VenueOrderId::new(trade.order_id.as_str());
232 let trade_id = TradeId::new(trade.trade_id.as_str());
233 let order_side = derive_order_side_to_nautilus(trade.direction);
234 let last_qty = quantity_from_decimal(trade.trade_amount, size_precision, "trade_amount")?;
235 anyhow::ensure!(
236 !last_qty.is_zero(),
237 "invalid Derive trade_amount: zero fill quantity after conversion (trade_id={trade_id}, instrument_id={instrument_id}, trade_amount={}, size_precision={})",
238 trade.trade_amount,
239 last_qty.precision,
240 );
241 let last_px = price_from_decimal(trade.trade_price, price_precision, "trade_price")?;
242 let commission = commission_from_decimal(trade.trade_fee, fee_currency)?;
243 let liquidity_side = match trade.liquidity_role {
244 DeriveLiquidityRole::Maker => LiquiditySide::Maker,
245 DeriveLiquidityRole::Taker => LiquiditySide::Taker,
246 DeriveLiquidityRole::Unknown => LiquiditySide::NoLiquiditySide,
247 };
248
249 let client_order_id = if trade.label.is_empty() {
250 None
251 } else {
252 Some(ClientOrderId::new(trade.label))
253 };
254
255 let ts_event = ms_to_nanos(trade.timestamp);
256
257 Ok(Some(FillReport::new(
258 account_id,
259 instrument_id,
260 venue_order_id,
261 trade_id,
262 order_side,
263 last_qty,
264 last_px,
265 commission,
266 liquidity_side,
267 client_order_id,
268 None,
269 ts_event,
270 ts_init,
271 Some(UUID4::new()),
272 )))
273}
274
275pub fn parse_derive_position_to_report(
284 position: &DerivePosition,
285 account_id: AccountId,
286 ts_init: UnixNanos,
287) -> anyhow::Result<PositionStatusReport> {
288 parse_derive_position_to_report_with_precision(position, account_id, None, ts_init)
289}
290
291pub(crate) fn parse_derive_position_to_report_with_precision(
292 position: &DerivePosition,
293 account_id: AccountId,
294 size_precision: Option<u8>,
295 ts_init: UnixNanos,
296) -> anyhow::Result<PositionStatusReport> {
297 let instrument_id = InstrumentId::new(Symbol::new(position.instrument_name), *DERIVE_VENUE);
298 let signed_amount = position.amount;
299 let side = if signed_amount > Decimal::ZERO {
300 PositionSide::Long
301 } else if signed_amount < Decimal::ZERO {
302 PositionSide::Short
303 } else {
304 PositionSide::Flat
305 };
306 let abs_amount = signed_amount.abs();
307 let quantity = quantity_from_decimal(abs_amount, size_precision, "position.amount")?;
308
309 Ok(PositionStatusReport::new(
310 account_id,
311 instrument_id,
312 side,
313 quantity,
314 ts_init,
315 ts_init,
316 Some(UUID4::new()),
317 None,
318 Some(position.average_price),
319 ))
320}
321
322pub fn parse_derive_subaccount_to_balances(
343 subaccount: &DeriveSubaccount,
344) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>, Params)> {
345 let mut balances = Vec::with_capacity(subaccount.collaterals.len());
346 for collateral in &subaccount.collaterals {
347 let currency = Currency::get_or_create_crypto(collateral.asset_name);
348 let balance =
349 AccountBalance::from_total_and_locked(collateral.amount, Decimal::ZERO, currency)
350 .map_err(|e| {
351 anyhow::anyhow!(
352 "failed to build collateral balance for {} (total={}): {e}",
353 collateral.asset_name,
354 collateral.amount,
355 )
356 })?;
357 balances.push(balance);
358 }
359
360 let currency = Currency::get_or_create_crypto(subaccount.currency);
361 let initial_dec = subaccount.positions_initial_margin + subaccount.open_orders_margin;
362 let maintenance_dec = subaccount.positions_maintenance_margin;
363 let initial = Money::from_decimal(initial_dec, currency).with_context(|| {
364 format!(
365 "initial margin requirement {initial_dec} cannot be represented at {currency} precision",
366 )
367 })?;
368 let maintenance =
369 Money::from_decimal(maintenance_dec, currency).with_context(|| {
370 format!(
371 "maintenance margin requirement {maintenance_dec} cannot be represented at {currency} precision",
372 )
373 })?;
374 let margins = vec![MarginBalance::new(initial, maintenance, None)];
375
376 let mut info = Params::new();
377 info.insert(
378 "net_initial_margin".to_string(),
379 Value::String(subaccount.initial_margin.to_string()),
380 );
381 info.insert(
382 "net_maintenance_margin".to_string(),
383 Value::String(subaccount.maintenance_margin.to_string()),
384 );
385 info.insert(
386 "positions_initial_margin".to_string(),
387 Value::String(subaccount.positions_initial_margin.to_string()),
388 );
389 info.insert(
390 "positions_maintenance_margin".to_string(),
391 Value::String(subaccount.positions_maintenance_margin.to_string()),
392 );
393 info.insert(
394 "open_orders_margin".to_string(),
395 Value::String(subaccount.open_orders_margin.to_string()),
396 );
397 info.insert(
398 "is_under_liquidation".to_string(),
399 Value::Bool(subaccount.is_under_liquidation),
400 );
401
402 Ok((balances, margins, info))
403}
404
405fn price_from_decimal(value: Decimal, precision: Option<u8>, field: &str) -> anyhow::Result<Price> {
406 match precision {
407 Some(precision) => Price::from_decimal_dp(value, precision),
408 None => Price::from_decimal(value.normalize()),
409 }
410 .with_context(|| format!("invalid Derive {field}"))
411}
412
413fn quantity_from_decimal(
414 value: Decimal,
415 precision: Option<u8>,
416 field: &str,
417) -> anyhow::Result<Quantity> {
418 match precision {
419 Some(precision) => Quantity::from_decimal_dp(value, precision),
420 None => Quantity::from_decimal(value.normalize()),
421 }
422 .with_context(|| format!("invalid Derive {field}"))
423}
424
425fn commission_from_decimal(value: Decimal, currency: Currency) -> anyhow::Result<Money> {
426 Money::from_decimal(value, currency)
427 .with_context(|| format!("trade_fee {value} cannot be represented at {currency} precision"))
428}
429
430fn ms_to_nanos(value: i64) -> UnixNanos {
431 let clamped = u64::try_from(value.max(0)).unwrap_or(0);
432 UnixNanos::from(clamped.saturating_mul(NANOSECONDS_IN_MILLISECOND))
433}
434
435#[cfg(test)]
436mod tests {
437 use nautilus_model::enums::{OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType};
438 use rstest::rstest;
439 use rust_decimal_macros::dec;
440
441 use super::*;
442 use crate::{
443 common::{
444 enums::{
445 DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveMarginType,
446 DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
447 DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType, DeriveTxStatus,
448 },
449 parse::{
450 derive_status_to_nautilus, order_side_to_derive, order_type_to_derive,
451 time_in_force_to_derive,
452 },
453 },
454 http::models::DeriveCollateral,
455 };
456
457 fn sample_order() -> DeriveOrder {
458 DeriveOrder {
459 amount: dec!(10),
460 average_price: dec!(3500),
461 cancel_reason: DeriveOrderCancelReason::Empty,
462 creation_timestamp: 1_700_000_000_000,
463 direction: DeriveOrderSide::Buy,
464 filled_amount: dec!(4),
465 instrument_name: "ETH-PERP".into(),
466 is_transfer: false,
467 label: "STRATEGY-1-O-1".into(),
468 last_update_timestamp: 1_700_000_001_000,
469 limit_price: dec!(3500),
470 max_fee: dec!(1),
471 mmp: false,
472 nonce: 1,
473 order_fee: dec!(0),
474 order_id: "ord-1".to_string(),
475 order_status: DeriveOrderStatus::Open,
476 order_type: DeriveOrderType::Limit,
477 quote_id: None,
478 replaced_order_id: None,
479 signature: SecretString::from("0x00"),
480 signature_expiry_sec: 1_700_000_999,
481 signer: "0xsigner".into(),
482 subaccount_id: 30769,
483 time_in_force: DeriveTimeInForce::Gtc,
484 trigger_price: None,
485 trigger_price_type: None,
486 trigger_reject_message: None,
487 trigger_type: None,
488 }
489 }
490
491 fn sample_trade() -> DeriveTrade {
492 DeriveTrade {
493 direction: DeriveOrderSide::Sell,
494 index_price: dec!(3500),
495 instrument_name: "ETH-PERP".into(),
496 is_transfer: false,
497 label: "STRATEGY-1-O-2".into(),
498 liquidity_role: DeriveLiquidityRole::Taker,
499 mark_price: dec!(3500),
500 order_id: "ord-2".to_string(),
501 quote_id: None,
502 realized_pnl: dec!(0),
503 subaccount_id: 30769,
504 timestamp: 1_700_000_002_000,
505 trade_amount: dec!(2),
506 trade_fee: dec!(0.5),
507 trade_id: "tr-1".to_string(),
508 trade_price: dec!(3505),
509 tx_hash: Some("0xabc".to_string()),
510 tx_status: DeriveTxStatus::Settled,
511 wallet: Some("0xwallet".into()),
512 }
513 }
514
515 #[rstest]
516 fn test_order_side_round_trip() {
517 assert_eq!(order_side_to_derive(OrderSide::Buy), DeriveOrderSide::Buy,);
518 assert_eq!(order_side_to_derive(OrderSide::Sell), DeriveOrderSide::Sell,);
519 }
520
521 #[rstest]
522 fn test_order_type_rejects_unsupported() {
523 assert_eq!(
524 order_type_to_derive(OrderType::Limit).unwrap(),
525 DeriveOrderType::Limit,
526 );
527 assert_eq!(
528 order_type_to_derive(OrderType::Market).unwrap(),
529 DeriveOrderType::Market,
530 );
531 assert!(order_type_to_derive(OrderType::StopMarket).is_err());
532 }
533
534 #[rstest]
535 #[case(TimeInForce::Gtc, false, DeriveTimeInForce::Gtc)]
536 #[case(TimeInForce::Gtc, true, DeriveTimeInForce::PostOnly)]
537 #[case(TimeInForce::Ioc, false, DeriveTimeInForce::Ioc)]
538 #[case(TimeInForce::Fok, false, DeriveTimeInForce::Fok)]
539 fn test_time_in_force_maps_supported_values(
540 #[case] tif: TimeInForce,
541 #[case] post_only: bool,
542 #[case] expected: DeriveTimeInForce,
543 ) {
544 assert_eq!(time_in_force_to_derive(tif, post_only).unwrap(), expected);
545 }
546
547 #[rstest]
548 #[case(TimeInForce::Ioc)]
549 #[case(TimeInForce::Fok)]
550 fn test_time_in_force_rejects_post_only_immediate_values(#[case] tif: TimeInForce) {
551 let err = time_in_force_to_derive(tif, true)
552 .expect_err("post-only immediate TIF must be rejected");
553
554 assert!(
555 err.to_string()
556 .contains("post-only Derive orders only support GTC"),
557 "unexpected error: {err}",
558 );
559 }
560
561 #[rstest]
562 #[case(TimeInForce::Gtd, false)]
563 #[case(TimeInForce::Gtd, true)]
564 #[case(TimeInForce::Day, false)]
565 #[case(TimeInForce::Day, true)]
566 #[case(TimeInForce::AtTheOpen, false)]
567 #[case(TimeInForce::AtTheOpen, true)]
568 #[case(TimeInForce::AtTheClose, false)]
569 #[case(TimeInForce::AtTheClose, true)]
570 fn test_time_in_force_rejects_unsupported(#[case] tif: TimeInForce, #[case] post_only: bool) {
571 let err = time_in_force_to_derive(tif, post_only).expect_err("must reject unsupported TIF");
572
573 assert!(
574 err.to_string().contains("unsupported time in force"),
575 "unexpected error: {err}",
576 );
577 }
578
579 #[rstest]
580 fn test_derive_status_partial_fill_classification() {
581 assert_eq!(
582 derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(0), dec!(10)),
583 OrderStatus::Accepted,
584 );
585 assert_eq!(
586 derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(4), dec!(10)),
587 OrderStatus::PartiallyFilled,
588 );
589 assert_eq!(
590 derive_status_to_nautilus(DeriveOrderStatus::Filled, dec!(10), dec!(10)),
591 OrderStatus::Filled,
592 );
593 assert_eq!(
594 derive_status_to_nautilus(DeriveOrderStatus::Cancelled, dec!(0), dec!(10)),
595 OrderStatus::Canceled,
596 );
597 }
598
599 #[rstest]
600 fn test_parse_order_report_assigns_partial_fill_status() {
601 let account_id = AccountId::new("DERIVE-001");
602 let report =
603 parse_derive_order_to_report(&sample_order(), account_id, UnixNanos::from(1)).unwrap();
604 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
605 assert_eq!(report.quantity, Quantity::from("10"));
606 assert_eq!(report.filled_qty, Quantity::from("4"));
607 assert_eq!(report.client_order_id.unwrap().as_str(), "STRATEGY-1-O-1");
608 assert_eq!(report.venue_order_id.as_str(), "ord-1");
609 }
610
611 #[rstest]
612 fn test_parse_order_report_normalizes_without_instrument_precision() {
613 let mut order = sample_order();
614 order.amount = Decimal::from_str_exact("0.100000000000000000").unwrap();
615 order.filled_amount = Decimal::from_str_exact("0.000000000000000000").unwrap();
616 order.limit_price = Decimal::from_str_exact("0.100000000000000000").unwrap();
617 order.average_price = Decimal::ZERO;
618 order.order_status = DeriveOrderStatus::Cancelled;
619 let account_id = AccountId::new("DERIVE-001");
620
621 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
622
623 assert_eq!(report.quantity, Quantity::from("0.1"));
624 assert_eq!(report.filled_qty, Quantity::from("0"));
625 assert_eq!(report.price, Some(Price::from("0.1")));
626 }
627
628 #[rstest]
629 fn test_parse_order_report_uses_instrument_precision() {
630 let mut order = sample_order();
631 order.amount = Decimal::from_str_exact("25.000").unwrap();
632 order.filled_amount = Decimal::from_str_exact("5.000").unwrap();
633 order.limit_price = Decimal::from_str_exact("25.000").unwrap();
634
635 let report = parse_derive_order_to_report_with_precision(
636 &order,
637 AccountId::new("DERIVE-001"),
638 Some(2),
639 Some(2),
640 UnixNanos::from(1),
641 )
642 .unwrap();
643
644 assert_eq!(report.quantity, Quantity::from("25.00"));
645 assert_eq!(report.quantity.precision, 2);
646 assert_eq!(report.filled_qty, Quantity::from("5.00"));
647 assert_eq!(report.filled_qty.precision, 2);
648 assert_eq!(report.price, Some(Price::from("25.00")));
649 assert_eq!(report.price.unwrap().precision, 2);
650 }
651
652 #[rstest]
653 fn test_parse_order_report_maps_untriggered_stop_market() {
654 let mut order = sample_order();
655 order.average_price = Decimal::ZERO;
656 order.filled_amount = Decimal::ZERO;
657 order.limit_price = dec!(3400);
658 order.order_status = DeriveOrderStatus::Untriggered;
659 order.order_type = DeriveOrderType::Market;
660 order.trigger_price = Some(dec!(3450));
661 order.trigger_price_type = Some(DeriveTriggerPriceType::Mark);
662 order.trigger_type = Some(DeriveTriggerType::Stoploss);
663 let account_id = AccountId::new("DERIVE-001");
664
665 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
666
667 assert_eq!(report.order_type, OrderType::StopMarket);
668 assert_eq!(report.order_status, OrderStatus::Accepted);
669 assert_eq!(report.price, None);
670 assert_eq!(report.trigger_price, Some(Price::from("3450")));
671 assert_eq!(report.trigger_type, Some(TriggerType::MarkPrice));
672 }
673
674 #[rstest]
675 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3600))]
676 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3700))]
677 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3800))]
678 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3700))]
679 fn test_parse_order_report_maps_limit_if_touched_trigger(
680 #[case] direction: DeriveOrderSide,
681 #[case] limit_price: Decimal,
682 #[case] trigger_price: Decimal,
683 ) {
684 let mut order = sample_order();
685 order.average_price = Decimal::ZERO;
686 order.direction = direction;
687 order.filled_amount = Decimal::ZERO;
688 order.limit_price = limit_price;
689 order.order_status = DeriveOrderStatus::Untriggered;
690 order.order_type = DeriveOrderType::Limit;
691 order.trigger_price = Some(trigger_price);
692 order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
693 order.trigger_type = Some(DeriveTriggerType::Takeprofit);
694 let account_id = AccountId::new("DERIVE-001");
695
696 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
697
698 assert_eq!(report.order_type, OrderType::LimitIfTouched);
699 assert_eq!(
700 report.price,
701 Some(Price::from_decimal(limit_price.normalize()).unwrap())
702 );
703 assert_eq!(
704 report.trigger_price,
705 Some(Price::from_decimal(trigger_price.normalize()).unwrap())
706 );
707 assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
708 }
709
710 #[rstest]
711 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3800))]
712 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3600))]
713 fn test_parse_order_report_maps_take_profit_limit_with_stop_shape(
714 #[case] direction: DeriveOrderSide,
715 #[case] limit_price: Decimal,
716 #[case] trigger_price: Decimal,
717 ) {
718 let mut order = sample_order();
719 order.average_price = Decimal::ZERO;
720 order.direction = direction;
721 order.filled_amount = Decimal::ZERO;
722 order.limit_price = limit_price;
723 order.order_status = DeriveOrderStatus::Untriggered;
724 order.order_type = DeriveOrderType::Limit;
725 order.trigger_price = Some(trigger_price);
726 order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
727 order.trigger_type = Some(DeriveTriggerType::Takeprofit);
728 let account_id = AccountId::new("DERIVE-001");
729
730 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
731
732 assert_eq!(report.order_type, OrderType::StopLimit);
733 assert_eq!(
734 report.price,
735 Some(Price::from_decimal(limit_price.normalize()).unwrap())
736 );
737 assert_eq!(
738 report.trigger_price,
739 Some(Price::from_decimal(trigger_price.normalize()).unwrap())
740 );
741 assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
742 }
743
744 #[rstest]
745 fn test_parse_rejected_post_only_report_keeps_cross_market_reason() {
746 let mut order = sample_order();
747 order.cancel_reason = DeriveOrderCancelReason::PostOnlyCrossMarket;
748 order.order_status = DeriveOrderStatus::Rejected;
749 order.time_in_force = DeriveTimeInForce::PostOnly;
750 let account_id = AccountId::new("DERIVE-001");
751
752 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
753
754 assert_eq!(report.order_status, OrderStatus::Rejected);
755 assert!(report.post_only);
756 assert_eq!(
757 report.cancel_reason.as_deref(),
758 Some("Post only order cannot cross the market")
759 );
760 }
761
762 #[rstest]
763 fn test_parse_rejected_trigger_report_uses_trigger_message() {
764 let mut order = sample_order();
765 order.cancel_reason = DeriveOrderCancelReason::TriggerFailed;
766 order.order_status = DeriveOrderStatus::Rejected;
767 order.trigger_reject_message = Some("trigger price moved through limit".to_string());
768 let account_id = AccountId::new("DERIVE-001");
769
770 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
771
772 assert_eq!(report.order_status, OrderStatus::Rejected);
773 assert_eq!(
774 report.cancel_reason.as_deref(),
775 Some("trigger price moved through limit")
776 );
777 }
778
779 #[rstest]
780 fn test_parse_trade_report_emits_taker_fill() {
781 let account_id = AccountId::new("DERIVE-001");
782 let usdc = Currency::USDC();
783 let report = parse_derive_trade_to_fill_report(
784 &sample_trade(),
785 account_id,
786 usdc,
787 UnixNanos::from(2),
788 )
789 .unwrap()
790 .unwrap();
791 assert_eq!(report.order_side, OrderSide::Sell);
792 assert_eq!(report.last_qty, Quantity::from("2"));
793 assert_eq!(report.last_px, Price::from("3505"));
794 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
795 assert_eq!(report.commission.as_decimal(), dec!(0.5));
796 }
797
798 #[rstest]
799 #[case::literal_zero(dec!(0), Some(2), true)]
800 #[case::unconfigured_zero(dec!(0), None, true)]
801 #[case::rounded_zero(dec!(0.004), Some(2), true)]
802 #[case::half_even_zero(dec!(0.005), Some(2), true)]
803 #[case::positive(dec!(0.006), Some(2), false)]
804 fn test_parse_trade_report_zero_quantity(
805 #[case] amount: Decimal,
806 #[case] precision: Option<u8>,
807 #[case] rejected: bool,
808 ) {
809 let mut trade = sample_trade();
810 trade.trade_amount = amount;
811
812 let result = parse_derive_trade_to_fill_report_with_precision(
813 &trade,
814 AccountId::new("DERIVE-001"),
815 Currency::USDC(),
816 None,
817 precision,
818 UnixNanos::from(2),
819 );
820
821 if rejected {
822 let message = result.unwrap_err().to_string();
823 assert!(message.contains(&format!("trade_id={}", trade.trade_id)));
824 assert!(message.contains(&format!("instrument_id={}.DERIVE", trade.instrument_name)));
825 assert!(message.contains(&format!("trade_amount={amount}")));
826 assert!(message.contains(&format!("size_precision={}", precision.unwrap_or(0))));
827 } else {
828 assert_eq!(result.unwrap().unwrap().last_qty, Quantity::from("0.01"));
829 }
830 }
831
832 #[rstest]
833 fn test_parse_trade_report_uses_instrument_precision() {
834 let mut trade = sample_trade();
835 trade.trade_amount = Decimal::from_str_exact("25.000").unwrap();
836 trade.trade_price = Decimal::from_str_exact("25.000").unwrap();
837
838 let report = parse_derive_trade_to_fill_report_with_precision(
839 &trade,
840 AccountId::new("DERIVE-001"),
841 Currency::USDC(),
842 Some(2),
843 Some(3),
844 UnixNanos::from(2),
845 )
846 .unwrap()
847 .unwrap();
848
849 assert_eq!(report.last_px, Price::from("25.00"));
850 assert_eq!(report.last_px.precision, 2);
851 assert_eq!(report.last_qty, Quantity::from("25.000"));
852 assert_eq!(report.last_qty.precision, 3);
853 }
854
855 #[rstest]
856 #[case(DeriveLiquidityRole::Taker, LiquiditySide::Taker)]
857 #[case(DeriveLiquidityRole::Maker, LiquiditySide::Maker)]
858 fn test_parse_trade_report_preserves_exact_decimal_commission(
859 #[case] liquidity_role: DeriveLiquidityRole,
860 #[case] expected_liquidity_side: LiquiditySide,
861 ) {
862 let mut trade = sample_trade();
863 trade.trade_fee = dec!(0.12345678);
864 trade.liquidity_role = liquidity_role;
865 let account_id = AccountId::new("DERIVE-001");
866 let usdc = Currency::USDC();
867 let report =
868 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
869 .unwrap()
870 .expect("exact USDC-precision fee must emit the fill");
871 assert_eq!(report.commission.as_decimal(), dec!(0.12345678));
872 assert_eq!(report.commission.currency, usdc);
873 assert_eq!(report.liquidity_side, expected_liquidity_side);
874 }
875
876 #[rstest]
877 #[case(dec!(0.000000025), dec!(0.00000002))]
878 #[case(dec!(0.000000015), dec!(0.00000002))]
879 fn test_parse_trade_report_rounds_half_unit_commission_from_decimal(
880 #[case] trade_fee: Decimal,
881 #[case] expected: Decimal,
882 ) {
883 let mut trade = sample_trade();
885 trade.trade_fee = trade_fee;
886 let account_id = AccountId::new("DERIVE-001");
887 let report = parse_derive_trade_to_fill_report(
888 &trade,
889 account_id,
890 Currency::USDC(),
891 UnixNanos::from(2),
892 )
893 .unwrap()
894 .expect("sub-precision fee must still emit the fill");
895 assert_eq!(report.commission.as_decimal(), expected);
896 }
897
898 #[rstest]
899 fn test_parse_trade_report_errors_on_out_of_range_commission() {
900 let mut trade = sample_trade();
901 trade.trade_fee = Decimal::MAX;
902 let account_id = AccountId::new("DERIVE-001");
903 let err = parse_derive_trade_to_fill_report(
904 &trade,
905 account_id,
906 Currency::USDC(),
907 UnixNanos::from(2),
908 )
909 .expect_err("out-of-range fee must error instead of panicking");
910 assert!(
911 err.to_string().contains("trade_fee"),
912 "unexpected error: {err}",
913 );
914 }
915
916 #[rstest]
917 fn test_parse_trade_report_skips_reverted_settlement() {
918 let mut trade = sample_trade();
919 trade.tx_status = DeriveTxStatus::Reverted;
920 let account_id = AccountId::new("DERIVE-001");
921 let usdc = Currency::USDC();
922 let report =
923 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
924 .unwrap();
925 assert!(report.is_none());
926 }
927
928 #[rstest]
929 fn test_parse_trade_report_degrades_unknown_liquidity_role() {
930 let mut trade = sample_trade();
931 trade.liquidity_role = DeriveLiquidityRole::Unknown;
932 let account_id = AccountId::new("DERIVE-001");
933 let usdc = Currency::USDC();
934
935 let report =
936 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
937 .unwrap()
938 .expect("unknown liquidity role must still emit the fill");
939
940 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
941 }
942
943 #[rstest]
944 fn test_parse_position_long_short_flat() {
945 let account_id = AccountId::new("DERIVE-001");
946
947 let mut long_pos = sample_position();
948 long_pos.amount = dec!(3);
949 let report =
950 parse_derive_position_to_report(&long_pos, account_id, UnixNanos::from(3)).unwrap();
951 assert_eq!(report.position_side, PositionSide::Long);
952 assert_eq!(report.quantity, Quantity::from("3"));
953
954 let mut short_pos = sample_position();
955 short_pos.amount = dec!(-2);
956 let report =
957 parse_derive_position_to_report(&short_pos, account_id, UnixNanos::from(3)).unwrap();
958 assert_eq!(report.position_side, PositionSide::Short);
959 assert_eq!(report.quantity, Quantity::from("2"));
960
961 let mut flat_pos = sample_position();
962 flat_pos.amount = dec!(0);
963 let report =
964 parse_derive_position_to_report(&flat_pos, account_id, UnixNanos::from(3)).unwrap();
965 assert_eq!(report.position_side, PositionSide::Flat);
966 }
967
968 #[rstest]
969 fn test_parse_position_report_uses_instrument_precision() {
970 let mut position = sample_position();
971 position.amount = Decimal::from_str_exact("25.000").unwrap();
972
973 let report = parse_derive_position_to_report_with_precision(
974 &position,
975 AccountId::new("DERIVE-001"),
976 Some(3),
977 UnixNanos::from(3),
978 )
979 .unwrap();
980
981 assert_eq!(report.quantity, Quantity::from("25.000"));
982 assert_eq!(report.quantity.precision, 3);
983 }
984
985 fn sample_position() -> DerivePosition {
986 DerivePosition {
987 amount: dec!(0),
988 average_price: dec!(3500),
989 creation_timestamp: 0,
990 cumulative_funding: dec!(0),
991 delta: dec!(0),
992 gamma: dec!(0),
993 index_price: dec!(3500),
994 initial_margin: dec!(0),
995 instrument_name: "ETH-PERP".into(),
996 instrument_type: DeriveInstrumentType::Perp,
997 leverage: None,
998 liquidation_price: None,
999 maintenance_margin: dec!(0),
1000 mark_price: dec!(3500),
1001 mark_value: dec!(0),
1002 net_settlements: dec!(0),
1003 open_orders_margin: dec!(0),
1004 pending_funding: dec!(0),
1005 realized_pnl: dec!(0),
1006 theta: dec!(0),
1007 unrealized_pnl: dec!(0),
1008 vega: dec!(0),
1009 }
1010 }
1011
1012 #[rstest]
1013 fn test_parse_subaccount_emits_balances_margins_and_info() {
1014 let subaccount = sample_subaccount();
1015 let (balances, margins, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1016 assert_eq!(balances.len(), 1);
1017 assert_eq!(balances[0].total.as_decimal(), dec!(1000));
1018 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1019 assert_eq!(balances[0].free.as_decimal(), dec!(1000));
1020 assert_eq!(margins.len(), 1);
1021 assert_eq!(margins[0].initial.as_decimal(), dec!(0));
1022 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0));
1023 assert_eq!(
1024 info.get("net_initial_margin"),
1025 Some(&serde_json::json!("100")),
1026 );
1027 assert_eq!(
1028 info.get("net_maintenance_margin"),
1029 Some(&serde_json::json!("50")),
1030 );
1031 assert_eq!(
1032 info.get("is_under_liquidation"),
1033 Some(&serde_json::json!(false)),
1034 );
1035 }
1036
1037 #[rstest]
1038 fn test_parse_subaccount_preserves_multi_collateral_units() {
1039 let mut subaccount = sample_subaccount();
1043 subaccount.collaterals = vec![
1044 DeriveCollateral {
1045 amount: dec!(2.5),
1046 asset_name: "ETH".into(),
1047 asset_type: DeriveAssetType::Erc20,
1048 cumulative_interest: dec!(0),
1049 currency: "ETH".into(),
1050 initial_margin: dec!(1000),
1051 maintenance_margin: dec!(500),
1052 mark_price: dec!(3500),
1053 mark_value: dec!(8750),
1054 pending_interest: dec!(0),
1055 },
1056 DeriveCollateral {
1057 amount: dec!(1000),
1058 asset_name: "USDC".into(),
1059 asset_type: DeriveAssetType::Erc20,
1060 cumulative_interest: dec!(0),
1061 currency: "USDC".into(),
1062 initial_margin: dec!(1000),
1063 maintenance_margin: dec!(1000),
1064 mark_price: dec!(1),
1065 mark_value: dec!(1000),
1066 pending_interest: dec!(0),
1067 },
1068 ];
1069
1070 let (balances, _, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1071 assert_eq!(balances.len(), 2);
1072 assert_eq!(balances[0].total.as_decimal(), dec!(2.5));
1073 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1074 assert_eq!(balances[0].free.as_decimal(), dec!(2.5));
1075 assert_eq!(balances[1].total.as_decimal(), dec!(1000));
1076 assert_eq!(balances[1].locked.as_decimal(), dec!(0));
1077 assert_eq!(balances[1].free.as_decimal(), dec!(1000));
1078 }
1079
1080 #[rstest]
1081 fn test_parse_subaccount_aggregates_requirements_and_keeps_health_in_info() {
1082 let mut subaccount = sample_subaccount();
1085 subaccount.positions_initial_margin = dec!(350);
1086 subaccount.positions_maintenance_margin = dec!(175);
1087 subaccount.open_orders_margin = dec!(40);
1088 subaccount.initial_margin = dec!(610);
1089 subaccount.maintenance_margin = dec!(825);
1090
1091 let (balances, margins, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1092 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1093 assert_eq!(margins.len(), 1);
1094 assert_eq!(margins[0].initial.as_decimal(), dec!(390));
1095 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1096 assert_eq!(
1097 info.get("positions_initial_margin"),
1098 Some(&serde_json::json!("350")),
1099 );
1100 assert_eq!(
1101 info.get("positions_maintenance_margin"),
1102 Some(&serde_json::json!("175")),
1103 );
1104 assert_eq!(
1105 info.get("open_orders_margin"),
1106 Some(&serde_json::json!("40")),
1107 );
1108 assert_eq!(
1109 info.get("net_initial_margin"),
1110 Some(&serde_json::json!("610")),
1111 );
1112 assert_eq!(
1113 info.get("net_maintenance_margin"),
1114 Some(&serde_json::json!("825")),
1115 );
1116 }
1117
1118 #[rstest]
1119 fn test_parse_subaccount_funded_positionless_fixture_reports_no_locked() {
1120 let (balances, margins, info) =
1121 parse_subaccount_fixture("common/http_subaccount_usdc.json");
1122 assert_eq!(balances.len(), 1);
1123 assert_eq!(balances[0].total.as_decimal(), dec!(1000));
1124 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1125 assert_eq!(balances[0].free.as_decimal(), dec!(1000));
1126 assert_eq!(margins.len(), 1);
1127 assert_eq!(margins[0].initial.as_decimal(), dec!(0));
1128 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0));
1129 assert_eq!(
1132 info.get("net_initial_margin"),
1133 Some(&serde_json::json!("1000")),
1134 );
1135 assert_eq!(
1136 info.get("net_maintenance_margin"),
1137 Some(&serde_json::json!("1000")),
1138 );
1139 }
1140
1141 #[rstest]
1142 fn test_parse_subaccount_positions_margin_fixture_maps_requirements() {
1143 let (balances, margins, info) =
1144 parse_subaccount_fixture("common/http_subaccount_positions_margin.json");
1145 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1146 assert_eq!(margins[0].initial.as_decimal(), dec!(350));
1147 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1148 assert_eq!(
1149 info.get("net_initial_margin"),
1150 Some(&serde_json::json!("650")),
1151 );
1152 assert_eq!(
1153 info.get("net_maintenance_margin"),
1154 Some(&serde_json::json!("825")),
1155 );
1156 }
1157
1158 #[rstest]
1159 fn test_parse_subaccount_open_orders_margin_fixture_maps_reservation() {
1160 let (_, margins, info) =
1161 parse_subaccount_fixture("common/http_subaccount_open_orders_margin.json");
1162 assert_eq!(margins[0].initial.as_decimal(), dec!(40));
1163 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0));
1164 assert_eq!(
1165 info.get("open_orders_margin"),
1166 Some(&serde_json::json!("40")),
1167 );
1168 }
1169
1170 #[rstest]
1171 fn test_parse_subaccount_negative_health_fixture_preserves_signs() {
1172 let (balances, margins, info) =
1173 parse_subaccount_fixture("common/http_subaccount_negative_health.json");
1174 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1175 assert_eq!(margins[0].initial.as_decimal(), dec!(390));
1176 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1177 assert_eq!(
1178 info.get("net_initial_margin"),
1179 Some(&serde_json::json!("-50")),
1180 );
1181 assert_eq!(
1182 info.get("net_maintenance_margin"),
1183 Some(&serde_json::json!("-20")),
1184 );
1185 assert_eq!(
1186 info.get("is_under_liquidation"),
1187 Some(&serde_json::json!(true)),
1188 );
1189 }
1190
1191 #[rstest]
1192 fn test_parse_subaccount_with_no_collateral_emits_margins_only() {
1193 let mut subaccount = sample_subaccount();
1194 subaccount.collaterals = vec![];
1195 subaccount.positions_initial_margin = dec!(350);
1196 subaccount.positions_maintenance_margin = dec!(175);
1197
1198 let (balances, margins, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1199 assert!(balances.is_empty());
1200 assert_eq!(margins.len(), 1);
1201 assert_eq!(margins[0].initial.as_decimal(), dec!(350));
1202 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1203 }
1204
1205 #[rstest]
1206 fn test_parse_subaccount_errors_on_unrepresentable_amount() {
1207 let mut subaccount = sample_subaccount();
1208 subaccount.collaterals[0].amount = Decimal::MAX;
1209
1210 let err = parse_derive_subaccount_to_balances(&subaccount)
1211 .expect_err("out-of-range collateral amount must error instead of panicking");
1212 assert!(
1213 err.to_string().contains("collateral balance"),
1214 "unexpected error: {err}",
1215 );
1216 }
1217
1218 fn parse_subaccount_fixture(
1219 filename: &str,
1220 ) -> (Vec<AccountBalance>, Vec<MarginBalance>, Params) {
1221 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1222 .join("test_data")
1223 .join(filename);
1224 let content =
1225 std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("failed to read {filename}"));
1226 let subaccount: DeriveSubaccount = serde_json::from_str(&content)
1227 .unwrap_or_else(|e| panic!("failed to parse {filename}: {e}"));
1228 parse_derive_subaccount_to_balances(&subaccount).expect("subaccount maps")
1229 }
1230
1231 fn sample_subaccount() -> DeriveSubaccount {
1232 DeriveSubaccount {
1233 collaterals: vec![DeriveCollateral {
1234 amount: dec!(1000),
1235 asset_name: "USDC".into(),
1236 asset_type: DeriveAssetType::Erc20,
1237 cumulative_interest: dec!(0),
1238 currency: "USDC".into(),
1239 initial_margin: dec!(100),
1240 maintenance_margin: dec!(50),
1241 mark_price: dec!(1),
1242 mark_value: dec!(1000),
1243 pending_interest: dec!(0),
1244 }],
1245 collaterals_initial_margin: dec!(100),
1246 collaterals_maintenance_margin: dec!(50),
1247 collaterals_value: dec!(1000),
1248 currency: "USDC".into(),
1249 initial_margin: dec!(100),
1250 is_under_liquidation: false,
1251 label: None,
1252 maintenance_margin: dec!(50),
1253 margin_type: DeriveMarginType::Sm,
1254 open_orders: vec![],
1255 open_orders_margin: dec!(0),
1256 positions: vec![],
1257 positions_initial_margin: dec!(0),
1258 positions_maintenance_margin: dec!(0),
1259 positions_value: dec!(0),
1260 subaccount_id: 30769,
1261 subaccount_value: dec!(1000),
1262 }
1263 }
1264}