1use anyhow::Context;
19use nautilus_core::{UUID4, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND};
20use nautilus_model::{
21 enums::{LiquiditySide, OrderType, PositionSideSpecified},
22 identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
23 reports::{FillReport, OrderStatusReport, PositionStatusReport},
24 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
25};
26use rust_decimal::Decimal;
27
28use crate::{
29 common::{
30 consts::DERIVE_VENUE,
31 enums::{
32 DeriveLiquidityRole, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
33 DeriveTimeInForce, DeriveTriggerType, DeriveTxStatus,
34 },
35 parse::{
36 derive_order_side_to_nautilus, derive_order_type_to_nautilus_for_order,
37 derive_rejection_due_post_only, derive_status_to_nautilus, derive_tif_to_nautilus,
38 derive_trigger_price_type_to_nautilus,
39 },
40 },
41 http::models::{DeriveOrder, DerivePosition, DeriveSubaccount, DeriveTrade},
42};
43
44pub fn parse_derive_order_to_report(
55 order: &DeriveOrder,
56 account_id: AccountId,
57 ts_init: UnixNanos,
58) -> anyhow::Result<OrderStatusReport> {
59 let instrument_id =
60 InstrumentId::new(Symbol::new(order.instrument_name.as_str()), *DERIVE_VENUE);
61 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
62 let order_side = derive_order_side_to_nautilus(order.direction);
63 let order_type = derive_order_type_to_nautilus_for_report(order);
64 let post_only = matches!(order.time_in_force, DeriveTimeInForce::PostOnly);
65 let time_in_force = derive_tif_to_nautilus(order.time_in_force);
66 let order_status =
67 derive_status_to_nautilus(order.order_status, order.filled_amount, order.amount);
68 let quantity = quantity_from_decimal(order.amount, "amount")?;
69 let filled_qty = quantity_from_decimal(order.filled_amount, "filled_amount")?;
70
71 let ts_accepted = ms_to_nanos(order.creation_timestamp);
72 let ts_last = ms_to_nanos(order.last_update_timestamp);
73
74 let mut report = OrderStatusReport::new(
75 account_id,
76 instrument_id,
77 None,
78 venue_order_id,
79 order_side,
80 order_type,
81 time_in_force,
82 order_status,
83 quantity,
84 filled_qty,
85 ts_accepted,
86 ts_last,
87 ts_init,
88 Some(UUID4::new()),
89 );
90
91 if !order.label.as_str().is_empty() {
92 let client_order_id = ClientOrderId::new(order.label.as_str());
93 report = report.with_client_order_id(client_order_id);
94 }
95
96 if order.limit_price > Decimal::ZERO
97 && order_type_has_limit_price(order_type)
98 && let Ok(price) = Price::from_decimal(order.limit_price.normalize())
99 {
100 report = report.with_price(price);
101 }
102
103 if let Some(trigger_price) = order.trigger_price
104 && trigger_price > Decimal::ZERO
105 && let Ok(price) = Price::from_decimal(trigger_price.normalize())
106 {
107 report = report.with_trigger_price(price);
108 }
109
110 if let Some(trigger_price_type) = order.trigger_price_type {
111 report =
112 report.with_trigger_type(derive_trigger_price_type_to_nautilus(trigger_price_type));
113 }
114
115 if order.average_price > Decimal::ZERO {
116 report.avg_px = Some(order.average_price);
117 }
118 report.post_only = post_only;
119 let trigger_reject_message = order
120 .trigger_reject_message
121 .as_deref()
122 .filter(|message| !message.is_empty())
123 .map(str::to_string);
124 let cancel_reason = trigger_reject_message
125 .clone()
126 .unwrap_or_else(|| order.cancel_reason.to_string());
127 if order.order_status == DeriveOrderStatus::Cancelled
128 || (order.order_status == DeriveOrderStatus::Rejected
129 && (trigger_reject_message.is_some()
130 || derive_rejection_due_post_only(None, &cancel_reason)))
131 {
132 report.cancel_reason = Some(cancel_reason);
133 }
134 Ok(report)
135}
136
137fn order_type_has_limit_price(order_type: OrderType) -> bool {
138 matches!(
139 order_type,
140 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
141 )
142}
143
144fn derive_order_type_to_nautilus_for_report(order: &DeriveOrder) -> OrderType {
145 let order_type = derive_order_type_to_nautilus_for_order(order.order_type, order.trigger_type);
146 if order_type != OrderType::LimitIfTouched {
147 return order_type;
148 }
149
150 match (order.order_type, order.trigger_type, order.trigger_price) {
151 (DeriveOrderType::Limit, Some(DeriveTriggerType::Takeprofit), Some(trigger_price))
152 if !limit_if_touched_prices_are_valid(
153 order.direction,
154 order.limit_price,
155 trigger_price,
156 ) =>
157 {
158 OrderType::StopLimit
159 }
160 _ => order_type,
161 }
162}
163
164fn limit_if_touched_prices_are_valid(
165 direction: DeriveOrderSide,
166 limit_price: Decimal,
167 trigger_price: Decimal,
168) -> bool {
169 match direction {
170 DeriveOrderSide::Buy => trigger_price <= limit_price,
171 DeriveOrderSide::Sell => trigger_price >= limit_price,
172 }
173}
174
175pub fn parse_derive_trade_to_fill_report(
186 trade: &DeriveTrade,
187 account_id: AccountId,
188 fee_currency: Currency,
189 ts_init: UnixNanos,
190) -> anyhow::Result<Option<FillReport>> {
191 if trade.trade_id.is_empty() || trade.tx_status == DeriveTxStatus::Reverted {
195 return Ok(None);
196 }
197
198 let instrument_id =
199 InstrumentId::new(Symbol::new(trade.instrument_name.as_str()), *DERIVE_VENUE);
200 let venue_order_id = VenueOrderId::new(trade.order_id.as_str());
201 let trade_id = TradeId::new(trade.trade_id.as_str());
202 let order_side = derive_order_side_to_nautilus(trade.direction);
203 let last_qty = quantity_from_decimal(trade.trade_amount, "trade_amount")?;
204 let last_px = price_from_decimal(trade.trade_price, "trade_price")?;
205 let commission = Money::new(
206 trade
207 .trade_fee
208 .try_into()
209 .with_context(|| format!("trade_fee {} out of f64 range", trade.trade_fee))?,
210 fee_currency,
211 );
212 let liquidity_side = match trade.liquidity_role {
213 DeriveLiquidityRole::Maker => LiquiditySide::Maker,
214 DeriveLiquidityRole::Taker => LiquiditySide::Taker,
215 };
216
217 let client_order_id = if trade.label.as_str().is_empty() {
218 None
219 } else {
220 Some(ClientOrderId::new(trade.label.as_str()))
221 };
222
223 let ts_event = ms_to_nanos(trade.timestamp);
224
225 Ok(Some(FillReport::new(
226 account_id,
227 instrument_id,
228 venue_order_id,
229 trade_id,
230 order_side,
231 last_qty,
232 last_px,
233 commission,
234 liquidity_side,
235 client_order_id,
236 None,
237 ts_event,
238 ts_init,
239 Some(UUID4::new()),
240 )))
241}
242
243pub fn parse_derive_position_to_report(
253 position: &DerivePosition,
254 account_id: AccountId,
255 ts_init: UnixNanos,
256) -> anyhow::Result<PositionStatusReport> {
257 let instrument_id = InstrumentId::new(
258 Symbol::new(position.instrument_name.as_str()),
259 *DERIVE_VENUE,
260 );
261 let signed_amount = position.amount;
262 let side = if signed_amount > Decimal::ZERO {
263 PositionSideSpecified::Long
264 } else if signed_amount < Decimal::ZERO {
265 PositionSideSpecified::Short
266 } else {
267 PositionSideSpecified::Flat
268 };
269 let abs_amount = signed_amount.abs();
270 let quantity = quantity_from_decimal(abs_amount, "position.amount")?;
271
272 Ok(PositionStatusReport::new(
273 account_id,
274 instrument_id,
275 side,
276 quantity,
277 ts_init,
278 ts_init,
279 Some(UUID4::new()),
280 None,
281 Some(position.average_price),
282 ))
283}
284
285pub fn parse_derive_subaccount_to_balances(
297 subaccount: &DeriveSubaccount,
298) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
299 let mut balances = Vec::with_capacity(subaccount.collaterals.len());
300 for collateral in &subaccount.collaterals {
301 let currency = Currency::get_or_create_crypto(collateral.asset_name.as_str());
302 let total_dec = collateral.amount;
310 let locked_dec = if collateral.mark_price > Decimal::ZERO {
311 (collateral.initial_margin.max(Decimal::ZERO) / collateral.mark_price)
312 .max(Decimal::ZERO)
313 } else {
314 Decimal::ZERO
315 };
316 let balance = AccountBalance::from_total_and_locked(total_dec, locked_dec, currency)
321 .map_err(|e| {
322 anyhow::anyhow!(
323 "failed to build collateral balance for {} (total={total_dec}, locked={locked_dec}): {e}",
324 collateral.asset_name,
325 )
326 })?;
327 balances.push(balance);
328 }
329
330 let currency = Currency::get_or_create_crypto(subaccount.currency.as_str());
331 let initial = Money::from_decimal(subaccount.initial_margin, currency).with_context(|| {
332 format!(
333 "initial_margin {} cannot be represented at {} precision",
334 subaccount.initial_margin, currency,
335 )
336 })?;
337 let maintenance =
338 Money::from_decimal(subaccount.maintenance_margin, currency).with_context(|| {
339 format!(
340 "maintenance_margin {} cannot be represented at {} precision",
341 subaccount.maintenance_margin, currency,
342 )
343 })?;
344 let margins = vec![MarginBalance::new(initial, maintenance, None)];
345
346 Ok((balances, margins))
347}
348
349fn price_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Price> {
350 Price::from_decimal(value.normalize()).with_context(|| format!("invalid Derive {field}"))
351}
352
353fn quantity_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Quantity> {
354 Quantity::from_decimal(value.normalize()).with_context(|| format!("invalid Derive {field}"))
355}
356
357fn ms_to_nanos(value: i64) -> UnixNanos {
358 let clamped = u64::try_from(value.max(0)).unwrap_or(0);
359 UnixNanos::from(clamped.saturating_mul(NANOSECONDS_IN_MILLISECOND))
360}
361
362#[cfg(test)]
363mod tests {
364 use nautilus_model::enums::{OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType};
365 use rstest::rstest;
366 use rust_decimal_macros::dec;
367
368 use super::*;
369 use crate::{
370 common::{
371 enums::{
372 DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveMarginType,
373 DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
374 DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType, DeriveTxStatus,
375 },
376 parse::{
377 derive_status_to_nautilus, order_side_to_derive, order_type_to_derive,
378 time_in_force_to_derive,
379 },
380 },
381 http::models::DeriveCollateral,
382 };
383
384 fn sample_order() -> DeriveOrder {
385 DeriveOrder {
386 amount: dec!(10),
387 average_price: dec!(3500),
388 cancel_reason: DeriveOrderCancelReason::Empty,
389 creation_timestamp: 1_700_000_000_000,
390 direction: DeriveOrderSide::Buy,
391 filled_amount: dec!(4),
392 instrument_name: "ETH-PERP".into(),
393 is_transfer: false,
394 label: "STRATEGY-1-O-1".into(),
395 last_update_timestamp: 1_700_000_001_000,
396 limit_price: dec!(3500),
397 max_fee: dec!(1),
398 mmp: false,
399 nonce: 1,
400 order_fee: dec!(0),
401 order_id: "ord-1".to_string(),
402 order_status: DeriveOrderStatus::Open,
403 order_type: DeriveOrderType::Limit,
404 quote_id: None,
405 replaced_order_id: None,
406 signature: "0x00".to_string(),
407 signature_expiry_sec: 1_700_000_999,
408 signer: "0xsigner".into(),
409 subaccount_id: 30769,
410 time_in_force: DeriveTimeInForce::Gtc,
411 trigger_price: None,
412 trigger_price_type: None,
413 trigger_reject_message: None,
414 trigger_type: None,
415 }
416 }
417
418 fn sample_trade() -> DeriveTrade {
419 DeriveTrade {
420 direction: DeriveOrderSide::Sell,
421 index_price: dec!(3500),
422 instrument_name: "ETH-PERP".into(),
423 is_transfer: false,
424 label: "STRATEGY-1-O-2".into(),
425 liquidity_role: DeriveLiquidityRole::Taker,
426 mark_price: dec!(3500),
427 order_id: "ord-2".to_string(),
428 quote_id: None,
429 realized_pnl: dec!(0),
430 subaccount_id: 30769,
431 timestamp: 1_700_000_002_000,
432 trade_amount: dec!(2),
433 trade_fee: dec!(0.5),
434 trade_id: "tr-1".to_string(),
435 trade_price: dec!(3505),
436 tx_hash: Some("0xabc".to_string()),
437 tx_status: DeriveTxStatus::Settled,
438 wallet: Some("0xwallet".into()),
439 }
440 }
441
442 #[rstest]
443 fn test_order_side_round_trip() {
444 assert_eq!(
445 order_side_to_derive(OrderSide::Buy).unwrap(),
446 DeriveOrderSide::Buy,
447 );
448 assert_eq!(
449 order_side_to_derive(OrderSide::Sell).unwrap(),
450 DeriveOrderSide::Sell,
451 );
452 assert!(order_side_to_derive(OrderSide::NoOrderSide).is_err());
453 }
454
455 #[rstest]
456 fn test_order_type_rejects_unsupported() {
457 assert_eq!(
458 order_type_to_derive(OrderType::Limit).unwrap(),
459 DeriveOrderType::Limit,
460 );
461 assert_eq!(
462 order_type_to_derive(OrderType::Market).unwrap(),
463 DeriveOrderType::Market,
464 );
465 assert!(order_type_to_derive(OrderType::StopMarket).is_err());
466 }
467
468 #[rstest]
469 #[case(TimeInForce::Gtc, false, DeriveTimeInForce::Gtc)]
470 #[case(TimeInForce::Gtc, true, DeriveTimeInForce::PostOnly)]
471 #[case(TimeInForce::Ioc, false, DeriveTimeInForce::Ioc)]
472 #[case(TimeInForce::Fok, false, DeriveTimeInForce::Fok)]
473 fn test_time_in_force_maps_supported_values(
474 #[case] tif: TimeInForce,
475 #[case] post_only: bool,
476 #[case] expected: DeriveTimeInForce,
477 ) {
478 assert_eq!(time_in_force_to_derive(tif, post_only).unwrap(), expected);
479 }
480
481 #[rstest]
482 #[case(TimeInForce::Ioc)]
483 #[case(TimeInForce::Fok)]
484 fn test_time_in_force_rejects_post_only_immediate_values(#[case] tif: TimeInForce) {
485 let err = time_in_force_to_derive(tif, true)
486 .expect_err("post-only immediate TIF must be rejected");
487
488 assert!(
489 err.to_string()
490 .contains("post-only Derive orders only support GTC"),
491 "unexpected error: {err}",
492 );
493 }
494
495 #[rstest]
496 #[case(TimeInForce::Gtd, false)]
497 #[case(TimeInForce::Gtd, true)]
498 #[case(TimeInForce::Day, false)]
499 #[case(TimeInForce::Day, true)]
500 #[case(TimeInForce::AtTheOpen, false)]
501 #[case(TimeInForce::AtTheOpen, true)]
502 #[case(TimeInForce::AtTheClose, false)]
503 #[case(TimeInForce::AtTheClose, true)]
504 fn test_time_in_force_rejects_unsupported(#[case] tif: TimeInForce, #[case] post_only: bool) {
505 let err = time_in_force_to_derive(tif, post_only).expect_err("must reject unsupported TIF");
506
507 assert!(
508 err.to_string().contains("unsupported time in force"),
509 "unexpected error: {err}",
510 );
511 }
512
513 #[rstest]
514 fn test_derive_status_partial_fill_classification() {
515 assert_eq!(
516 derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(0), dec!(10)),
517 OrderStatus::Accepted,
518 );
519 assert_eq!(
520 derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(4), dec!(10)),
521 OrderStatus::PartiallyFilled,
522 );
523 assert_eq!(
524 derive_status_to_nautilus(DeriveOrderStatus::Filled, dec!(10), dec!(10)),
525 OrderStatus::Filled,
526 );
527 assert_eq!(
528 derive_status_to_nautilus(DeriveOrderStatus::Cancelled, dec!(0), dec!(10)),
529 OrderStatus::Canceled,
530 );
531 }
532
533 #[rstest]
534 fn test_parse_order_report_assigns_partial_fill_status() {
535 let account_id = AccountId::new("DERIVE-001");
536 let report =
537 parse_derive_order_to_report(&sample_order(), account_id, UnixNanos::from(1)).unwrap();
538 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
539 assert_eq!(report.quantity, Quantity::from("10"));
540 assert_eq!(report.filled_qty, Quantity::from("4"));
541 assert_eq!(report.client_order_id.unwrap().as_str(), "STRATEGY-1-O-1");
542 assert_eq!(report.venue_order_id.as_str(), "ord-1");
543 }
544
545 #[rstest]
546 fn test_parse_order_report_normalizes_trailing_decimal_zeros() {
547 let mut order = sample_order();
548 order.amount = Decimal::from_str_exact("0.100000000000000000").unwrap();
549 order.filled_amount = Decimal::from_str_exact("0.000000000000000000").unwrap();
550 order.limit_price = Decimal::from_str_exact("0.100000000000000000").unwrap();
551 order.average_price = Decimal::ZERO;
552 order.order_status = DeriveOrderStatus::Cancelled;
553 let account_id = AccountId::new("DERIVE-001");
554
555 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
556
557 assert_eq!(report.quantity, Quantity::from("0.1"));
558 assert_eq!(report.filled_qty, Quantity::from("0"));
559 assert_eq!(report.price, Some(Price::from("0.1")));
560 }
561
562 #[rstest]
563 fn test_parse_order_report_maps_untriggered_stop_market() {
564 let mut order = sample_order();
565 order.average_price = Decimal::ZERO;
566 order.filled_amount = Decimal::ZERO;
567 order.limit_price = dec!(3400);
568 order.order_status = DeriveOrderStatus::Untriggered;
569 order.order_type = DeriveOrderType::Market;
570 order.trigger_price = Some(dec!(3450));
571 order.trigger_price_type = Some(DeriveTriggerPriceType::Mark);
572 order.trigger_type = Some(DeriveTriggerType::Stoploss);
573 let account_id = AccountId::new("DERIVE-001");
574
575 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
576
577 assert_eq!(report.order_type, OrderType::StopMarket);
578 assert_eq!(report.order_status, OrderStatus::Accepted);
579 assert_eq!(report.price, None);
580 assert_eq!(report.trigger_price, Some(Price::from("3450")));
581 assert_eq!(report.trigger_type, Some(TriggerType::MarkPrice));
582 }
583
584 #[rstest]
585 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3600))]
586 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3700))]
587 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3800))]
588 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3700))]
589 fn test_parse_order_report_maps_limit_if_touched_trigger(
590 #[case] direction: DeriveOrderSide,
591 #[case] limit_price: Decimal,
592 #[case] trigger_price: Decimal,
593 ) {
594 let mut order = sample_order();
595 order.average_price = Decimal::ZERO;
596 order.direction = direction;
597 order.filled_amount = Decimal::ZERO;
598 order.limit_price = limit_price;
599 order.order_status = DeriveOrderStatus::Untriggered;
600 order.order_type = DeriveOrderType::Limit;
601 order.trigger_price = Some(trigger_price);
602 order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
603 order.trigger_type = Some(DeriveTriggerType::Takeprofit);
604 let account_id = AccountId::new("DERIVE-001");
605
606 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
607
608 assert_eq!(report.order_type, OrderType::LimitIfTouched);
609 assert_eq!(
610 report.price,
611 Some(Price::from_decimal(limit_price.normalize()).unwrap())
612 );
613 assert_eq!(
614 report.trigger_price,
615 Some(Price::from_decimal(trigger_price.normalize()).unwrap())
616 );
617 assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
618 }
619
620 #[rstest]
621 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3800))]
622 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3600))]
623 fn test_parse_order_report_maps_take_profit_limit_with_stop_shape(
624 #[case] direction: DeriveOrderSide,
625 #[case] limit_price: Decimal,
626 #[case] trigger_price: Decimal,
627 ) {
628 let mut order = sample_order();
629 order.average_price = Decimal::ZERO;
630 order.direction = direction;
631 order.filled_amount = Decimal::ZERO;
632 order.limit_price = limit_price;
633 order.order_status = DeriveOrderStatus::Untriggered;
634 order.order_type = DeriveOrderType::Limit;
635 order.trigger_price = Some(trigger_price);
636 order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
637 order.trigger_type = Some(DeriveTriggerType::Takeprofit);
638 let account_id = AccountId::new("DERIVE-001");
639
640 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
641
642 assert_eq!(report.order_type, OrderType::StopLimit);
643 assert_eq!(
644 report.price,
645 Some(Price::from_decimal(limit_price.normalize()).unwrap())
646 );
647 assert_eq!(
648 report.trigger_price,
649 Some(Price::from_decimal(trigger_price.normalize()).unwrap())
650 );
651 assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
652 }
653
654 #[rstest]
655 fn test_parse_rejected_post_only_report_keeps_cross_market_reason() {
656 let mut order = sample_order();
657 order.cancel_reason = DeriveOrderCancelReason::PostOnlyCrossMarket;
658 order.order_status = DeriveOrderStatus::Rejected;
659 order.time_in_force = DeriveTimeInForce::PostOnly;
660 let account_id = AccountId::new("DERIVE-001");
661
662 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
663
664 assert_eq!(report.order_status, OrderStatus::Rejected);
665 assert!(report.post_only);
666 assert_eq!(
667 report.cancel_reason.as_deref(),
668 Some("Post only order cannot cross the market")
669 );
670 }
671
672 #[rstest]
673 fn test_parse_rejected_trigger_report_uses_trigger_message() {
674 let mut order = sample_order();
675 order.cancel_reason = DeriveOrderCancelReason::TriggerFailed;
676 order.order_status = DeriveOrderStatus::Rejected;
677 order.trigger_reject_message = Some("trigger price moved through limit".to_string());
678 let account_id = AccountId::new("DERIVE-001");
679
680 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
681
682 assert_eq!(report.order_status, OrderStatus::Rejected);
683 assert_eq!(
684 report.cancel_reason.as_deref(),
685 Some("trigger price moved through limit")
686 );
687 }
688
689 #[rstest]
690 fn test_parse_trade_report_emits_taker_fill() {
691 let account_id = AccountId::new("DERIVE-001");
692 let usdc = Currency::USDC();
693 let report = parse_derive_trade_to_fill_report(
694 &sample_trade(),
695 account_id,
696 usdc,
697 UnixNanos::from(2),
698 )
699 .unwrap()
700 .unwrap();
701 assert_eq!(report.order_side, OrderSide::Sell);
702 assert_eq!(report.last_qty, Quantity::from("2"));
703 assert_eq!(report.last_px, Price::from("3505"));
704 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
705 assert_eq!(report.commission.as_decimal(), dec!(0.5));
706 }
707
708 #[rstest]
709 fn test_parse_trade_report_skips_reverted_settlement() {
710 let mut trade = sample_trade();
711 trade.tx_status = DeriveTxStatus::Reverted;
712 let account_id = AccountId::new("DERIVE-001");
713 let usdc = Currency::USDC();
714 let report =
715 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
716 .unwrap();
717 assert!(report.is_none());
718 }
719
720 #[rstest]
721 fn test_parse_position_long_short_flat() {
722 let account_id = AccountId::new("DERIVE-001");
723
724 let mut long_pos = sample_position();
725 long_pos.amount = dec!(3);
726 let report =
727 parse_derive_position_to_report(&long_pos, account_id, UnixNanos::from(3)).unwrap();
728 assert_eq!(report.position_side, PositionSideSpecified::Long);
729 assert_eq!(report.quantity, Quantity::from("3"));
730
731 let mut short_pos = sample_position();
732 short_pos.amount = dec!(-2);
733 let report =
734 parse_derive_position_to_report(&short_pos, account_id, UnixNanos::from(3)).unwrap();
735 assert_eq!(report.position_side, PositionSideSpecified::Short);
736 assert_eq!(report.quantity, Quantity::from("2"));
737
738 let mut flat_pos = sample_position();
739 flat_pos.amount = dec!(0);
740 let report =
741 parse_derive_position_to_report(&flat_pos, account_id, UnixNanos::from(3)).unwrap();
742 assert_eq!(report.position_side, PositionSideSpecified::Flat);
743 }
744
745 fn sample_position() -> DerivePosition {
746 DerivePosition {
747 amount: dec!(0),
748 average_price: dec!(3500),
749 creation_timestamp: 0,
750 cumulative_funding: dec!(0),
751 delta: dec!(0),
752 gamma: dec!(0),
753 index_price: dec!(3500),
754 initial_margin: dec!(0),
755 instrument_name: "ETH-PERP".into(),
756 instrument_type: DeriveInstrumentType::Perp,
757 leverage: None,
758 liquidation_price: None,
759 maintenance_margin: dec!(0),
760 mark_price: dec!(3500),
761 mark_value: dec!(0),
762 net_settlements: dec!(0),
763 open_orders_margin: dec!(0),
764 pending_funding: dec!(0),
765 realized_pnl: dec!(0),
766 theta: dec!(0),
767 unrealized_pnl: dec!(0),
768 vega: dec!(0),
769 }
770 }
771
772 #[rstest]
773 fn test_parse_subaccount_emits_balances_and_margins() {
774 let subaccount = sample_subaccount();
775 let (balances, margins) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
776 assert_eq!(balances.len(), 1);
777 assert_eq!(balances[0].total.as_decimal(), dec!(1000));
778 assert_eq!(balances[0].locked.as_decimal(), dec!(100));
779 assert_eq!(balances[0].free.as_decimal(), dec!(900));
780 assert_eq!(margins.len(), 1);
781 assert_eq!(margins[0].initial.as_decimal(), dec!(100));
782 assert_eq!(margins[0].maintenance.as_decimal(), dec!(50));
783 }
784
785 #[rstest]
786 fn test_parse_subaccount_converts_non_usdc_locked_to_collateral_units() {
787 let mut subaccount = sample_subaccount();
791 subaccount.collaterals = vec![DeriveCollateral {
792 amount: dec!(2.5),
793 asset_name: "ETH".into(),
794 asset_type: DeriveAssetType::Erc20,
795 cumulative_interest: dec!(0),
796 currency: "ETH".into(),
797 initial_margin: dec!(1000),
798 maintenance_margin: dec!(500),
799 mark_price: dec!(3500),
800 mark_value: dec!(8750),
801 pending_interest: dec!(0),
802 }];
803
804 let (balances, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
805 assert_eq!(balances[0].total.as_decimal(), dec!(2.5));
806 let locked = balances[0].locked.as_decimal();
808 let expected_locked = dec!(1000) / dec!(3500);
809 assert!(
810 (locked - expected_locked).abs() < dec!(0.000001),
811 "locked {locked} should be near {expected_locked} ETH"
812 );
813 let free = balances[0].free.as_decimal();
814 let expected_free = dec!(2.5) - expected_locked;
815 assert!(
816 (free - expected_free).abs() < dec!(0.000001),
817 "free {free} should be near {expected_free} ETH"
818 );
819 }
820
821 #[rstest]
822 fn test_parse_subaccount_reports_zero_locked_when_mark_price_non_positive() {
823 let mut subaccount = sample_subaccount();
827 subaccount.collaterals[0].mark_price = dec!(0);
828 subaccount.collaterals[0].initial_margin = dec!(50);
829
830 let (balances, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
831 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
832 assert_eq!(
833 balances[0].free.as_decimal(),
834 balances[0].total.as_decimal()
835 );
836 }
837
838 fn sample_subaccount() -> DeriveSubaccount {
839 DeriveSubaccount {
840 collaterals: vec![DeriveCollateral {
841 amount: dec!(1000),
842 asset_name: "USDC".into(),
843 asset_type: DeriveAssetType::Erc20,
844 cumulative_interest: dec!(0),
845 currency: "USDC".into(),
846 initial_margin: dec!(100),
847 maintenance_margin: dec!(50),
848 mark_price: dec!(1),
849 mark_value: dec!(1000),
850 pending_interest: dec!(0),
851 }],
852 collaterals_initial_margin: dec!(100),
853 collaterals_maintenance_margin: dec!(50),
854 collaterals_value: dec!(1000),
855 currency: "USDC".into(),
856 initial_margin: dec!(100),
857 is_under_liquidation: false,
858 label: None,
859 maintenance_margin: dec!(50),
860 margin_type: DeriveMarginType::Sm,
861 open_orders: vec![],
862 open_orders_margin: dec!(0),
863 positions: vec![],
864 positions_initial_margin: dec!(0),
865 positions_maintenance_margin: dec!(0),
866 positions_value: dec!(0),
867 subaccount_id: 30769,
868 subaccount_value: dec!(1000),
869 }
870 }
871}