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