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::{AvgPxReconciliation, LiquiditySide, OrderType, PositionSide},
24 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
25 reports::{FillReport, OrderStatusReport, PositionStatusReport},
26 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde_json::Value;
30use thiserror::Error;
31
32use crate::{
33 common::{
34 enums::{
35 DeriveInstrumentType, DeriveLiquidityRole, DeriveOrderSide, DeriveOrderStatus,
36 DeriveOrderType, DeriveTimeInForce, DeriveTriggerType,
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, format_instrument_id, strategy_rejection_reason,
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 = format_instrument_id(order.instrument_name)?;
74 let venue_order_id =
75 VenueOrderId::new_checked(order.order_id.as_str()).context("invalid Derive order_id")?;
76 let order_side = derive_order_side_to_nautilus(order.direction);
77 let order_type = derive_order_type_to_nautilus_for_report(order)?;
78 let post_only = matches!(order.time_in_force, DeriveTimeInForce::PostOnly);
79 let time_in_force = derive_tif_to_nautilus(order.time_in_force)?;
80 let order_status =
81 derive_status_to_nautilus(order.order_status, order.filled_amount, order.amount)?;
82 let quantity = quantity_from_decimal(order.amount, size_precision, "amount")?;
83 let filled_qty = quantity_from_decimal(order.filled_amount, size_precision, "filled_amount")?;
84
85 let ts_accepted = ms_to_nanos(order.creation_timestamp);
86 let ts_last = ms_to_nanos(order.last_update_timestamp);
87
88 let mut report = OrderStatusReport::new(
89 account_id,
90 instrument_id,
91 None,
92 venue_order_id,
93 order_side.into(),
94 order_type,
95 time_in_force,
96 order_status,
97 quantity,
98 filled_qty,
99 ts_accepted,
100 ts_last,
101 ts_init,
102 Some(UUID4::new()),
103 );
104
105 if let Some(client_order_id) =
106 parse_client_order_id(order.label.as_str(), venue_order_id, instrument_id)
107 {
108 report = report.with_client_order_id(client_order_id);
109 }
110
111 if order_type_has_limit_price(order_type) {
112 anyhow::ensure!(
113 order.limit_price > Decimal::ZERO,
114 "Derive limit_price must be positive"
115 );
116 report = report.with_price(price_from_decimal(
117 order.limit_price,
118 price_precision,
119 "limit_price",
120 )?);
121 }
122
123 if let Some(trigger_price) = order.trigger_price {
124 anyhow::ensure!(
125 trigger_price > Decimal::ZERO,
126 "Derive trigger_price must be positive"
127 );
128 report = report.with_trigger_price(price_from_decimal(
129 trigger_price,
130 price_precision,
131 "trigger_price",
132 )?);
133 }
134
135 if let Some(trigger_price_type) = order.trigger_price_type {
136 report =
137 report.with_trigger_type(derive_trigger_price_type_to_nautilus(trigger_price_type)?);
138 }
139
140 if order.average_price > Decimal::ZERO {
141 report.avg_px = Some(order.average_price);
142 }
143
144 report.post_only = post_only;
145 let trigger_reject_message = order
146 .trigger_reject_message
147 .as_deref()
148 .filter(|message| !message.is_empty())
149 .map(str::to_string);
150 let cancel_reason = trigger_reject_message
151 .clone()
152 .unwrap_or_else(|| order.cancel_reason.to_string());
153 if order.order_status == DeriveOrderStatus::Cancelled
154 || (order.order_status == DeriveOrderStatus::Rejected
155 && (trigger_reject_message.is_some()
156 || derive_rejection_due_post_only(None, &cancel_reason)))
157 {
158 report.cancel_reason = Some(if order.order_status == DeriveOrderStatus::Rejected {
159 log::debug!(
160 "Derive native rejection for {}: {cancel_reason}",
161 order.order_id
162 );
163 strategy_rejection_reason(&cancel_reason)
164 } else {
165 cancel_reason
166 });
167 }
168
169 Ok(report)
170}
171
172fn order_type_has_limit_price(order_type: OrderType) -> bool {
173 matches!(
174 order_type,
175 OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
176 )
177}
178
179fn derive_order_type_to_nautilus_for_report(order: &DeriveOrder) -> anyhow::Result<OrderType> {
180 let order_type = derive_order_type_to_nautilus_for_order(order.order_type, order.trigger_type)?;
181 if order_type != OrderType::LimitIfTouched {
182 return Ok(order_type);
183 }
184
185 Ok(
186 match (order.order_type, order.trigger_type, order.trigger_price) {
187 (DeriveOrderType::Limit, Some(DeriveTriggerType::Takeprofit), Some(trigger_price))
188 if !limit_if_touched_prices_are_valid(
189 order.direction,
190 order.limit_price,
191 trigger_price,
192 ) =>
193 {
194 OrderType::StopLimit
195 }
196 _ => order_type,
197 },
198 )
199}
200
201fn limit_if_touched_prices_are_valid(
202 direction: DeriveOrderSide,
203 limit_price: Decimal,
204 trigger_price: Decimal,
205) -> bool {
206 match direction {
207 DeriveOrderSide::Buy => trigger_price <= limit_price,
208 DeriveOrderSide::Sell => trigger_price >= limit_price,
209 }
210}
211
212pub fn parse_derive_trade_to_fill_report(
225 trade: &DeriveTrade,
226 account_id: AccountId,
227 fee_currency: Currency,
228 ts_init: UnixNanos,
229) -> anyhow::Result<Option<FillReport>> {
230 parse_derive_trade_to_fill_report_with_precision(
231 trade,
232 account_id,
233 fee_currency,
234 None,
235 None,
236 ts_init,
237 )
238}
239
240pub(crate) fn parse_derive_trade_to_fill_report_with_precision(
241 trade: &DeriveTrade,
242 account_id: AccountId,
243 fee_currency: Currency,
244 price_precision: Option<u8>,
245 size_precision: Option<u8>,
246 ts_init: UnixNanos,
247) -> anyhow::Result<Option<FillReport>> {
248 if trade.trade_id.is_empty() || trade.order_id.is_empty() {
249 log::warn!(
250 "Skipping Derive trade for {}: missing trade_id={}, missing order_id={}",
251 trade.instrument_name,
252 trade.trade_id.is_empty(),
253 trade.order_id.is_empty(),
254 );
255 return Ok(None);
256 }
257
258 let instrument_id = format_instrument_id(trade.instrument_name)?;
259 let venue_order_id =
260 VenueOrderId::new_checked(trade.order_id.as_str()).context("invalid Derive order_id")?;
261 let trade_id =
262 TradeId::new_checked(trade.trade_id.as_str()).context("invalid Derive trade_id")?;
263 let order_side = derive_order_side_to_nautilus(trade.direction);
264 let last_qty = quantity_from_decimal(trade.trade_amount, size_precision, "trade_amount")?;
265 anyhow::ensure!(
266 !last_qty.is_zero(),
267 "invalid Derive trade_amount: zero fill quantity after conversion (trade_id={trade_id}, instrument_id={instrument_id}, trade_amount={}, size_precision={})",
268 trade.trade_amount,
269 last_qty.precision,
270 );
271 let last_px = price_from_decimal(trade.trade_price, price_precision, "trade_price")?;
272 let commission = commission_from_decimal(trade.trade_fee, fee_currency)?;
273
274 let liquidity_side = match trade.liquidity_role {
275 DeriveLiquidityRole::Maker => LiquiditySide::Maker,
276 DeriveLiquidityRole::Taker => LiquiditySide::Taker,
277 DeriveLiquidityRole::Unknown => LiquiditySide::NoLiquiditySide,
278 };
279
280 let client_order_id =
281 parse_client_order_id(trade.label.as_str(), venue_order_id, instrument_id);
282
283 let ts_event = ms_to_nanos(trade.timestamp);
284
285 Ok(Some(FillReport::new(
286 account_id,
287 instrument_id,
288 venue_order_id,
289 trade_id,
290 order_side,
291 last_qty,
292 last_px,
293 commission,
294 liquidity_side,
295 client_order_id,
296 None,
297 ts_event,
298 ts_init,
299 Some(UUID4::new()),
300 )))
301}
302
303pub fn parse_derive_position_to_report(
312 position: &DerivePosition,
313 account_id: AccountId,
314 ts_init: UnixNanos,
315) -> anyhow::Result<PositionStatusReport> {
316 parse_derive_position_to_report_with_precision(position, account_id, None, ts_init)
317}
318
319pub(crate) fn parse_derive_position_to_report_with_precision(
320 position: &DerivePosition,
321 account_id: AccountId,
322 size_precision: Option<u8>,
323 ts_init: UnixNanos,
324) -> anyhow::Result<PositionStatusReport> {
325 let instrument_id = format_instrument_id(position.instrument_name)?;
326 let signed_amount = position.amount;
327
328 let side = if signed_amount > Decimal::ZERO {
329 PositionSide::Long
330 } else if signed_amount < Decimal::ZERO {
331 PositionSide::Short
332 } else {
333 PositionSide::Flat
334 };
335
336 let abs_amount = signed_amount.abs();
337 let quantity = quantity_from_decimal(abs_amount, size_precision, "position.amount")?;
338 anyhow::ensure!(
339 quantity.as_decimal() == abs_amount,
340 "Derive position.amount {signed_amount} cannot be represented exactly for {instrument_id}",
341 );
342
343 let report = PositionStatusReport::new(
344 account_id,
345 instrument_id,
346 side,
347 quantity,
348 ts_init,
349 ts_init,
350 Some(UUID4::new()),
351 None,
352 Some(position.average_price),
353 );
354
355 if position.instrument_type == DeriveInstrumentType::Perp {
357 Ok(report.with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly))
358 } else {
359 Ok(report)
360 }
361}
362
363pub fn parse_derive_subaccount_to_balances(
388 subaccount: &DeriveSubaccount,
389) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>, Params)> {
390 anyhow::ensure!(
391 !subaccount.failed_to_fetch,
392 "Derive subaccount snapshot is incomplete"
393 );
394
395 for position in &subaccount.positions {
396 quantity_from_decimal(position.amount.abs(), None, "position.amount")?;
397 }
398
399 let mut balances = Vec::with_capacity(subaccount.collaterals.len());
400 for collateral in &subaccount.collaterals {
401 let currency = Currency::get_or_create_crypto(collateral.asset_name);
402
403 let balance =
404 AccountBalance::from_total_and_locked(collateral.amount, Decimal::ZERO, currency)
405 .map_err(|e| {
406 anyhow::anyhow!(
407 "failed to build collateral balance for {} (total={}): {e}",
408 collateral.asset_name,
409 collateral.amount,
410 )
411 })?;
412
413 balances.push(balance);
414 }
415
416 let currency = Currency::USD();
417 let initial_dec = subaccount
418 .positions_value
419 .checked_sub(subaccount.positions_initial_margin)
420 .and_then(|value| value.checked_sub(subaccount.open_orders_margin))
421 .context("Derive initial margin requirement exceeds Decimal range")?;
422 let maintenance_dec = subaccount
423 .positions_value
424 .checked_sub(subaccount.positions_maintenance_margin)
425 .context("Derive maintenance margin requirement exceeds Decimal range")?;
426
427 let initial = Money::from_decimal(initial_dec, currency).with_context(|| {
428 format!(
429 "initial margin requirement {initial_dec} cannot be represented at {currency} precision",
430 )
431 })?;
432
433 let maintenance =
434 Money::from_decimal(maintenance_dec, currency).with_context(|| {
435 format!(
436 "maintenance margin requirement {maintenance_dec} cannot be represented at {currency} precision",
437 )
438 })?;
439
440 let margins = vec![MarginBalance::new(initial, maintenance, None)];
441
442 let info = Params::new(
443 [
444 (
445 "collaterals".to_string(),
446 serde_json::to_value(&subaccount.collaterals)?,
447 ),
448 (
449 "currency".to_string(),
450 serde_json::to_value(&subaccount.currency)?,
451 ),
452 (
453 "margin_type".to_string(),
454 Value::String(subaccount.margin_type.clone()),
455 ),
456 ("manager_id".to_string(), Value::from(subaccount.manager_id)),
457 (
458 "risk_universe_id".to_string(),
459 Value::from(subaccount.risk_universe_id),
460 ),
461 (
462 "mm_credits".to_string(),
463 Value::String(subaccount.mm_credits.to_string()),
464 ),
465 (
466 "projected_margin_change".to_string(),
467 Value::String(subaccount.projected_margin_change.to_string()),
468 ),
469 (
470 "vault_deposit_holds".to_string(),
471 serde_json::to_value(&subaccount.vault_deposit_holds)?,
472 ),
473 (
474 "net_initial_margin".to_string(),
475 Value::String(subaccount.initial_margin.to_string()),
476 ),
477 (
478 "net_maintenance_margin".to_string(),
479 Value::String(subaccount.maintenance_margin.to_string()),
480 ),
481 (
482 "positions_value".to_string(),
483 Value::String(subaccount.positions_value.to_string()),
484 ),
485 (
486 "positions_initial_margin".to_string(),
487 Value::String(subaccount.positions_initial_margin.to_string()),
488 ),
489 (
490 "positions_maintenance_margin".to_string(),
491 Value::String(subaccount.positions_maintenance_margin.to_string()),
492 ),
493 (
494 "open_orders_margin".to_string(),
495 Value::String(subaccount.open_orders_margin.to_string()),
496 ),
497 (
498 "is_under_liquidation".to_string(),
499 Value::Bool(subaccount.is_under_liquidation),
500 ),
501 ]
502 .into(),
503 );
504
505 Ok((balances, margins, info))
506}
507
508fn price_from_decimal(value: Decimal, precision: Option<u8>, field: &str) -> anyhow::Result<Price> {
509 match precision {
510 Some(precision) => Price::from_decimal_dp(value, precision),
511 None => Price::from_decimal(value.normalize()),
512 }
513 .with_context(|| format!("invalid Derive {field}"))
514}
515
516fn quantity_from_decimal(
517 value: Decimal,
518 precision: Option<u8>,
519 field: &str,
520) -> anyhow::Result<Quantity> {
521 match precision {
522 Some(precision) => Quantity::from_decimal_dp(value, precision),
523 None => Quantity::from_decimal(value.normalize()),
524 }
525 .with_context(|| format!("invalid Derive {field}"))
526}
527
528fn parse_client_order_id(
529 label: &str,
530 venue_order_id: VenueOrderId,
531 instrument_id: InstrumentId,
532) -> Option<ClientOrderId> {
533 if label.is_empty() {
534 return None;
535 }
536
537 match ClientOrderId::new_checked(label) {
538 Ok(client_order_id) => Some(client_order_id),
539 Err(_) => {
540 log::warn!(
541 "Ignoring unrepresentable Derive client label for order {:?} on {:?}",
542 venue_order_id.as_str(),
543 instrument_id.to_string(),
544 );
545 None
546 }
547 }
548}
549
550fn commission_from_decimal(value: Decimal, currency: Currency) -> anyhow::Result<Money> {
551 Money::from_decimal(value, currency).with_context(|| CommissionError { value, currency })
552}
553
554#[derive(Debug, Error)]
555#[error("trade_fee {value} cannot be represented at {currency} precision")]
556pub(crate) struct CommissionError {
557 value: Decimal,
558 currency: Currency,
559}
560
561pub(crate) fn ms_to_nanos(value: i64) -> UnixNanos {
562 let clamped = u64::try_from(value.max(0)).unwrap_or(0);
563 UnixNanos::from(clamped.saturating_mul(NANOSECONDS_IN_MILLISECOND))
564}
565
566#[cfg(test)]
567mod tests {
568 use indexmap::IndexMap;
569 use nautilus_model::enums::{OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType};
570 use rstest::rstest;
571 use rust_decimal_macros::dec;
572
573 use super::*;
574 use crate::{
575 common::{
576 enums::{
577 DeriveAssetType, DeriveLiquidityRole, DeriveOrderCancelReason, DeriveOrderSide,
578 DeriveOrderStatus, DeriveOrderType, DeriveTimeInForce, DeriveTriggerPriceType,
579 DeriveTriggerType,
580 },
581 parse::{
582 derive_status_to_nautilus, order_side_to_derive, order_type_to_derive,
583 time_in_force_to_derive,
584 },
585 },
586 http::models::DeriveCollateral,
587 };
588
589 fn sample_order() -> DeriveOrder {
590 DeriveOrder {
591 amount: dec!(10),
592 average_price: dec!(3500),
593 cancel_reason: DeriveOrderCancelReason::Empty,
594 creation_timestamp: 1_700_000_000_000,
595 direction: DeriveOrderSide::Buy,
596 filled_amount: dec!(4),
597 instrument_name: "ETH-PERP".into(),
598 is_transfer: false,
599 label: "STRATEGY-1-O-1".into(),
600 last_update_timestamp: 1_700_000_001_000,
601 limit_price: dec!(3500),
602 max_fee: dec!(1),
603 mmp: false,
604 nonce: 1,
605 order_fee: dec!(0),
606 order_id: "ord-1".to_string(),
607 order_status: DeriveOrderStatus::Open,
608 order_type: DeriveOrderType::Limit,
609 quote_id: None,
610 replaced_order_id: None,
611 signature: SecretString::from("0x00"),
612 signature_expiry_sec: 1_700_000_999,
613 signer: "0xsigner".into(),
614 subaccount_id: 30769,
615 time_in_force: DeriveTimeInForce::Gtc,
616 trigger_price: None,
617 trigger_price_type: None,
618 trigger_reject_message: None,
619 trigger_type: None,
620 }
621 }
622
623 fn sample_trade() -> DeriveTrade {
624 DeriveTrade {
625 direction: DeriveOrderSide::Sell,
626 index_price: dec!(3500),
627 instrument_name: "ETH-PERP".into(),
628 is_transfer: false,
629 label: "STRATEGY-1-O-2".into(),
630 liquidity_role: DeriveLiquidityRole::Taker,
631 mark_price: dec!(3500),
632 order_id: "ord-2".to_string(),
633 quote_id: None,
634 realized_pnl: dec!(0),
635 subaccount_id: 30769,
636 timestamp: 1_700_000_002_000,
637 trade_amount: dec!(2),
638 trade_fee: dec!(0.5),
639 trade_id: "tr-1".to_string(),
640 trade_price: dec!(3505),
641 tx_hash: Some("0xabc".to_string()),
642 batch_status: Some("Settled".to_string()),
643 op_uuid: None,
644 wallet: Some("0xwallet".into()),
645 }
646 }
647
648 #[rstest]
649 fn test_order_side_round_trip() {
650 assert_eq!(order_side_to_derive(OrderSide::Buy), DeriveOrderSide::Buy,);
651 assert_eq!(order_side_to_derive(OrderSide::Sell), DeriveOrderSide::Sell,);
652 }
653
654 #[rstest]
655 fn test_order_type_rejects_unsupported() {
656 assert_eq!(
657 order_type_to_derive(OrderType::Limit).unwrap(),
658 DeriveOrderType::Limit,
659 );
660 assert_eq!(
661 order_type_to_derive(OrderType::Market).unwrap(),
662 DeriveOrderType::Market,
663 );
664 assert!(order_type_to_derive(OrderType::StopMarket).is_err());
665 }
666
667 #[rstest]
668 #[case(TimeInForce::Gtc, false, DeriveTimeInForce::Gtc)]
669 #[case(TimeInForce::Gtc, true, DeriveTimeInForce::PostOnly)]
670 #[case(TimeInForce::Ioc, false, DeriveTimeInForce::Ioc)]
671 #[case(TimeInForce::Fok, false, DeriveTimeInForce::Fok)]
672 fn test_time_in_force_maps_supported_values(
673 #[case] tif: TimeInForce,
674 #[case] post_only: bool,
675 #[case] expected: DeriveTimeInForce,
676 ) {
677 assert_eq!(time_in_force_to_derive(tif, post_only).unwrap(), expected);
678 }
679
680 #[rstest]
681 #[case(TimeInForce::Ioc)]
682 #[case(TimeInForce::Fok)]
683 fn test_time_in_force_rejects_post_only_immediate_values(#[case] tif: TimeInForce) {
684 let err = time_in_force_to_derive(tif, true)
685 .expect_err("post-only immediate TIF must be rejected");
686
687 assert!(
688 err.to_string()
689 .contains("post-only Derive orders only support GTC"),
690 "unexpected error: {err}",
691 );
692 }
693
694 #[rstest]
695 #[case(TimeInForce::Gtd, false)]
696 #[case(TimeInForce::Gtd, true)]
697 #[case(TimeInForce::Day, false)]
698 #[case(TimeInForce::Day, true)]
699 #[case(TimeInForce::AtTheOpen, false)]
700 #[case(TimeInForce::AtTheOpen, true)]
701 #[case(TimeInForce::AtTheClose, false)]
702 #[case(TimeInForce::AtTheClose, true)]
703 fn test_time_in_force_rejects_unsupported(#[case] tif: TimeInForce, #[case] post_only: bool) {
704 let err = time_in_force_to_derive(tif, post_only).expect_err("must reject unsupported TIF");
705
706 assert!(
707 err.to_string().contains("unsupported time in force"),
708 "unexpected error: {err}",
709 );
710 }
711
712 #[rstest]
713 #[case("order_type")]
714 #[case("time_in_force")]
715 #[case("trigger_type")]
716 #[case("trigger_price_type")]
717 fn test_order_report_rejects_unknown_semantics(#[case] field: &str) {
718 let mut value = serde_json::to_value(sample_order()).unwrap();
719 value[field] = serde_json::json!("future-variant");
720 let order: DeriveOrder = serde_json::from_value(value).unwrap();
721 assert!(
722 parse_derive_order_to_report(
723 &order,
724 AccountId::from("DERIVE-001"),
725 UnixNanos::from(17)
726 )
727 .is_err()
728 );
729 }
730
731 #[rstest]
732 #[case(false)]
733 #[case(true)]
734 fn test_order_report_rejects_unrepresentable_price(#[case] trigger: bool) {
735 let mut order = sample_order();
736 if trigger {
737 order.trigger_type = Some(DeriveTriggerType::Stoploss);
738 order.trigger_price = Some(Decimal::MAX);
739 } else {
740 order.limit_price = Decimal::MAX;
741 }
742
743 assert!(
744 parse_derive_order_to_report(
745 &order,
746 AccountId::from("DERIVE-001"),
747 UnixNanos::from(17)
748 )
749 .is_err()
750 );
751 }
752
753 #[rstest]
754 fn test_derive_status_partial_fill_classification() {
755 assert_eq!(
756 derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(0), dec!(10)).unwrap(),
757 OrderStatus::Accepted,
758 );
759 assert_eq!(
760 derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(4), dec!(10)).unwrap(),
761 OrderStatus::PartiallyFilled,
762 );
763 assert_eq!(
764 derive_status_to_nautilus(DeriveOrderStatus::Filled, dec!(10), dec!(10)).unwrap(),
765 OrderStatus::Filled,
766 );
767 assert_eq!(
768 derive_status_to_nautilus(DeriveOrderStatus::Cancelled, dec!(0), dec!(10)).unwrap(),
769 OrderStatus::Canceled,
770 );
771 }
772
773 #[rstest]
774 fn test_parse_order_report_assigns_partial_fill_status() {
775 let account_id = AccountId::new("DERIVE-001");
776 let report =
777 parse_derive_order_to_report(&sample_order(), account_id, UnixNanos::from(1)).unwrap();
778 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
779 assert_eq!(report.quantity, Quantity::from("10"));
780 assert_eq!(report.filled_qty, Quantity::from("4"));
781 assert_eq!(report.client_order_id.unwrap().as_str(), "STRATEGY-1-O-1");
782 assert_eq!(report.venue_order_id.as_str(), "ord-1");
783 }
784
785 #[rstest]
786 fn test_parse_order_report_normalizes_without_instrument_precision() {
787 let mut order = sample_order();
788 order.amount = Decimal::from_str_exact("0.100000000000000000").unwrap();
789 order.filled_amount = Decimal::from_str_exact("0.000000000000000000").unwrap();
790 order.limit_price = Decimal::from_str_exact("0.100000000000000000").unwrap();
791 order.average_price = Decimal::ZERO;
792 order.order_status = DeriveOrderStatus::Cancelled;
793 let account_id = AccountId::new("DERIVE-001");
794
795 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
796
797 assert_eq!(report.quantity, Quantity::from("0.1"));
798 assert_eq!(report.filled_qty, Quantity::from("0"));
799 assert_eq!(report.price, Some(Price::from("0.1")));
800 }
801
802 #[rstest]
803 fn test_parse_order_report_uses_instrument_precision() {
804 let mut order = sample_order();
805 order.amount = Decimal::from_str_exact("25.000").unwrap();
806 order.filled_amount = Decimal::from_str_exact("5.000").unwrap();
807 order.limit_price = Decimal::from_str_exact("25.000").unwrap();
808
809 let report = parse_derive_order_to_report_with_precision(
810 &order,
811 AccountId::new("DERIVE-001"),
812 Some(2),
813 Some(2),
814 UnixNanos::from(1),
815 )
816 .unwrap();
817
818 assert_eq!(report.quantity, Quantity::from("25.00"));
819 assert_eq!(report.quantity.precision, 2);
820 assert_eq!(report.filled_qty, Quantity::from("5.00"));
821 assert_eq!(report.filled_qty.precision, 2);
822 assert_eq!(report.price, Some(Price::from("25.00")));
823 assert_eq!(report.price.unwrap().precision, 2);
824 }
825
826 #[rstest]
827 fn test_parse_order_report_maps_untriggered_stop_market() {
828 let mut order = sample_order();
829 order.average_price = Decimal::ZERO;
830 order.filled_amount = Decimal::ZERO;
831 order.limit_price = dec!(3400);
832 order.order_status = DeriveOrderStatus::Untriggered;
833 order.order_type = DeriveOrderType::Market;
834 order.trigger_price = Some(dec!(3450));
835 order.trigger_price_type = Some(DeriveTriggerPriceType::Mark);
836 order.trigger_type = Some(DeriveTriggerType::Stoploss);
837 let account_id = AccountId::new("DERIVE-001");
838
839 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
840
841 assert_eq!(report.order_type, OrderType::StopMarket);
842 assert_eq!(report.order_status, OrderStatus::Accepted);
843 assert_eq!(report.price, None);
844 assert_eq!(report.trigger_price, Some(Price::from("3450")));
845 assert_eq!(report.trigger_type, Some(TriggerType::MarkPrice));
846 }
847
848 #[rstest]
849 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3600))]
850 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3700))]
851 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3800))]
852 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3700))]
853 fn test_parse_order_report_maps_limit_if_touched_trigger(
854 #[case] direction: DeriveOrderSide,
855 #[case] limit_price: Decimal,
856 #[case] trigger_price: Decimal,
857 ) {
858 let mut order = sample_order();
859 order.average_price = Decimal::ZERO;
860 order.direction = direction;
861 order.filled_amount = Decimal::ZERO;
862 order.limit_price = limit_price;
863 order.order_status = DeriveOrderStatus::Untriggered;
864 order.order_type = DeriveOrderType::Limit;
865 order.trigger_price = Some(trigger_price);
866 order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
867 order.trigger_type = Some(DeriveTriggerType::Takeprofit);
868 let account_id = AccountId::new("DERIVE-001");
869
870 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
871
872 assert_eq!(report.order_type, OrderType::LimitIfTouched);
873 assert_eq!(
874 report.price,
875 Some(Price::from_decimal(limit_price.normalize()).unwrap())
876 );
877 assert_eq!(
878 report.trigger_price,
879 Some(Price::from_decimal(trigger_price.normalize()).unwrap())
880 );
881 assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
882 }
883
884 #[rstest]
885 #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3800))]
886 #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3600))]
887 fn test_parse_order_report_maps_take_profit_limit_with_stop_shape(
888 #[case] direction: DeriveOrderSide,
889 #[case] limit_price: Decimal,
890 #[case] trigger_price: Decimal,
891 ) {
892 let mut order = sample_order();
893 order.average_price = Decimal::ZERO;
894 order.direction = direction;
895 order.filled_amount = Decimal::ZERO;
896 order.limit_price = limit_price;
897 order.order_status = DeriveOrderStatus::Untriggered;
898 order.order_type = DeriveOrderType::Limit;
899 order.trigger_price = Some(trigger_price);
900 order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
901 order.trigger_type = Some(DeriveTriggerType::Takeprofit);
902 let account_id = AccountId::new("DERIVE-001");
903
904 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
905
906 assert_eq!(report.order_type, OrderType::StopLimit);
907 assert_eq!(
908 report.price,
909 Some(Price::from_decimal(limit_price.normalize()).unwrap())
910 );
911 assert_eq!(
912 report.trigger_price,
913 Some(Price::from_decimal(trigger_price.normalize()).unwrap())
914 );
915 assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
916 }
917
918 #[rstest]
919 fn test_parse_rejected_post_only_report_keeps_cross_market_reason() {
920 let mut order = sample_order();
921 order.cancel_reason = DeriveOrderCancelReason::PostOnlyCrossMarket;
922 order.order_status = DeriveOrderStatus::Rejected;
923 order.time_in_force = DeriveTimeInForce::PostOnly;
924 let account_id = AccountId::new("DERIVE-001");
925
926 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
927
928 assert_eq!(report.order_status, OrderStatus::Rejected);
929 assert!(report.post_only);
930 assert_eq!(
931 report.cancel_reason.as_deref(),
932 Some("Post only order cannot cross the market")
933 );
934 }
935
936 #[rstest]
937 #[case(
938 "trigger price moved through limit",
939 "trigger price moved through limit"
940 )]
941 #[case(" <p>risk\t limit</p>\0\n exceeded ", "risk limit exceeded")]
942 #[case("<p>\0</p>", "Order command rejected")]
943 fn test_parse_rejected_trigger_report_uses_trigger_message(
944 #[case] message: &str,
945 #[case] expected: &str,
946 ) {
947 let mut order = sample_order();
948 order.cancel_reason = DeriveOrderCancelReason::TriggerFailed;
949 order.order_status = DeriveOrderStatus::Rejected;
950 order.trigger_reject_message = Some(message.to_string());
951 let account_id = AccountId::new("DERIVE-001");
952
953 let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
954
955 assert_eq!(report.order_status, OrderStatus::Rejected);
956 assert_eq!(report.cancel_reason.as_deref(), Some(expected));
957 }
958
959 #[rstest]
960 fn test_parse_trade_report_emits_taker_fill() {
961 let account_id = AccountId::new("DERIVE-001");
962 let usdc = Currency::USDC();
963 let report = parse_derive_trade_to_fill_report(
964 &sample_trade(),
965 account_id,
966 usdc,
967 UnixNanos::from(2),
968 )
969 .unwrap()
970 .unwrap();
971 assert_eq!(report.order_side, OrderSide::Sell);
972 assert_eq!(report.last_qty, Quantity::from("2"));
973 assert_eq!(report.last_px, Price::from("3505"));
974 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
975 assert_eq!(report.commission.as_decimal(), dec!(0.5));
976 }
977
978 #[rstest]
979 #[case::literal_zero(dec!(0), Some(2), true)]
980 #[case::unconfigured_zero(dec!(0), None, true)]
981 #[case::rounded_zero(dec!(0.004), Some(2), true)]
982 #[case::half_even_zero(dec!(0.005), Some(2), true)]
983 #[case::positive(dec!(0.006), Some(2), false)]
984 fn test_parse_trade_report_zero_quantity(
985 #[case] amount: Decimal,
986 #[case] precision: Option<u8>,
987 #[case] rejected: bool,
988 ) {
989 let mut trade = sample_trade();
990 trade.trade_amount = amount;
991
992 let result = parse_derive_trade_to_fill_report_with_precision(
993 &trade,
994 AccountId::new("DERIVE-001"),
995 Currency::USDC(),
996 None,
997 precision,
998 UnixNanos::from(2),
999 );
1000
1001 if rejected {
1002 let message = result.unwrap_err().to_string();
1003 assert!(message.contains(&format!("trade_id={}", trade.trade_id)));
1004 assert!(message.contains(&format!("instrument_id={}.DERIVE", trade.instrument_name)));
1005 assert!(message.contains(&format!("trade_amount={amount}")));
1006 assert!(message.contains(&format!("size_precision={}", precision.unwrap_or(0))));
1007 } else {
1008 assert_eq!(result.unwrap().unwrap().last_qty, Quantity::from("0.01"));
1009 }
1010 }
1011
1012 #[rstest]
1013 #[case("")]
1014 #[case(" ")]
1015 #[case("external-\u{03bb}")]
1016 fn test_parse_reports_preserve_external_order_without_client_label(#[case] label: &str) {
1017 let mut order = sample_order();
1018 order.label = label.into();
1019 let mut trade = sample_trade();
1020 trade.label = label.into();
1021 let account_id = AccountId::from("DERIVE-001");
1022 let ts_init = UnixNanos::from(123);
1023 let order_report = parse_derive_order_to_report(&order, account_id, ts_init).unwrap();
1024 let fill_report =
1025 parse_derive_trade_to_fill_report(&trade, account_id, Currency::USDC(), ts_init)
1026 .unwrap()
1027 .unwrap();
1028
1029 assert_eq!(order_report.client_order_id, None);
1030 assert_eq!(order_report.venue_order_id, VenueOrderId::from("ord-1"));
1031 assert_eq!(order_report.quantity.as_decimal(), dec!(10));
1032 assert_eq!(order_report.filled_qty.as_decimal(), dec!(4));
1033 assert_eq!(fill_report.client_order_id, None);
1034 assert_eq!(fill_report.venue_order_id, VenueOrderId::from("ord-2"));
1035 assert_eq!(fill_report.trade_id, TradeId::from("tr-1"));
1036 assert_eq!(fill_report.last_qty.as_decimal(), dec!(2));
1037 assert_eq!(fill_report.last_px.as_decimal(), dec!(3505));
1038 assert_eq!(fill_report.commission.as_decimal(), dec!(0.5));
1039 assert_eq!(fill_report.commission.currency, Currency::USDC());
1040 }
1041
1042 #[rstest]
1043 #[case(" ")]
1044 #[case("external-\u{03bb}")]
1045 #[case("1234567890123456789012345678901234567")]
1046 fn test_parse_trade_report_rejects_invalid_trade_id(#[case] trade_id: &str) {
1047 let mut trade = sample_trade();
1048 trade.trade_id = trade_id.to_string();
1049 let result = parse_derive_trade_to_fill_report(
1050 &trade,
1051 AccountId::from("DERIVE-001"),
1052 Currency::USDC(),
1053 UnixNanos::default(),
1054 );
1055
1056 assert_eq!(result.unwrap_err().to_string(), "invalid Derive trade_id");
1057 }
1058
1059 #[rstest]
1060 #[case(" ")]
1061 #[case("external-\u{03bb}")]
1062 fn test_parse_reports_reject_invalid_venue_order_id(#[case] order_id: &str) {
1063 let mut order = sample_order();
1064 order.order_id = order_id.to_string();
1065 let mut trade = sample_trade();
1066 trade.order_id = order_id.to_string();
1067 let account_id = AccountId::from("DERIVE-001");
1068 let order_result = parse_derive_order_to_report(&order, account_id, UnixNanos::default());
1069 let fill_result = parse_derive_trade_to_fill_report(
1070 &trade,
1071 account_id,
1072 Currency::USDC(),
1073 UnixNanos::default(),
1074 );
1075
1076 assert_eq!(
1077 order_result.unwrap_err().to_string(),
1078 "invalid Derive order_id"
1079 );
1080 assert_eq!(
1081 fill_result.unwrap_err().to_string(),
1082 "invalid Derive order_id"
1083 );
1084 }
1085
1086 #[rstest]
1087 fn test_parse_reports_preserve_valid_identity_bytes() {
1088 let label = " external-label ";
1089 let order_id = " external-order ";
1090 let trade_id = "123456789012345678901234567890123456";
1091 let mut order = sample_order();
1092 order.label = label.into();
1093 order.order_id = order_id.to_string();
1094 let mut trade = sample_trade();
1095 trade.label = label.into();
1096 trade.order_id = order_id.to_string();
1097 trade.trade_id = trade_id.to_string();
1098 let account_id = AccountId::from("DERIVE-001");
1099 let order_report =
1100 parse_derive_order_to_report(&order, account_id, UnixNanos::default()).unwrap();
1101 let fill_report = parse_derive_trade_to_fill_report(
1102 &trade,
1103 account_id,
1104 Currency::USDC(),
1105 UnixNanos::default(),
1106 )
1107 .unwrap()
1108 .unwrap();
1109
1110 assert_eq!(order_report.client_order_id.unwrap().as_str(), label);
1111 assert_eq!(order_report.venue_order_id.as_str(), order_id);
1112 assert_eq!(fill_report.client_order_id.unwrap().as_str(), label);
1113 assert_eq!(fill_report.venue_order_id.as_str(), order_id);
1114 assert_eq!(fill_report.trade_id.as_str(), trade_id);
1115 assert_eq!(fill_report.last_qty.as_decimal(), dec!(2));
1116 assert_eq!(fill_report.last_px.as_decimal(), dec!(3505));
1117 assert_eq!(fill_report.commission.as_decimal(), dec!(0.5));
1118 }
1119
1120 #[rstest]
1121 fn test_parse_trade_report_uses_instrument_precision() {
1122 let mut trade = sample_trade();
1123 trade.trade_amount = Decimal::from_str_exact("25.000").unwrap();
1124 trade.trade_price = Decimal::from_str_exact("25.000").unwrap();
1125
1126 let report = parse_derive_trade_to_fill_report_with_precision(
1127 &trade,
1128 AccountId::new("DERIVE-001"),
1129 Currency::USDC(),
1130 Some(2),
1131 Some(3),
1132 UnixNanos::from(2),
1133 )
1134 .unwrap()
1135 .unwrap();
1136
1137 assert_eq!(report.last_px, Price::from("25.00"));
1138 assert_eq!(report.last_px.precision, 2);
1139 assert_eq!(report.last_qty, Quantity::from("25.000"));
1140 assert_eq!(report.last_qty.precision, 3);
1141 }
1142
1143 #[rstest]
1144 #[case(DeriveLiquidityRole::Taker, LiquiditySide::Taker)]
1145 #[case(DeriveLiquidityRole::Maker, LiquiditySide::Maker)]
1146 fn test_parse_trade_report_preserves_exact_decimal_commission(
1147 #[case] liquidity_role: DeriveLiquidityRole,
1148 #[case] expected_liquidity_side: LiquiditySide,
1149 ) {
1150 let mut trade = sample_trade();
1151 trade.trade_fee = dec!(0.12345678);
1152 trade.liquidity_role = liquidity_role;
1153 let account_id = AccountId::new("DERIVE-001");
1154 let usdc = Currency::USDC();
1155 let report =
1156 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
1157 .unwrap()
1158 .expect("exact USDC-precision fee must emit the fill");
1159 assert_eq!(report.commission.as_decimal(), dec!(0.12345678));
1160 assert_eq!(report.commission.currency, usdc);
1161 assert_eq!(report.liquidity_side, expected_liquidity_side);
1162 }
1163
1164 #[rstest]
1165 #[case(dec!(0.000000025), dec!(0.00000002))]
1166 #[case(dec!(0.000000015), dec!(0.00000002))]
1167 fn test_parse_trade_report_rounds_half_unit_commission_from_decimal(
1168 #[case] trade_fee: Decimal,
1169 #[case] expected: Decimal,
1170 ) {
1171 let mut trade = sample_trade();
1173 trade.trade_fee = trade_fee;
1174 let account_id = AccountId::new("DERIVE-001");
1175 let report = parse_derive_trade_to_fill_report(
1176 &trade,
1177 account_id,
1178 Currency::USDC(),
1179 UnixNanos::from(2),
1180 )
1181 .unwrap()
1182 .expect("sub-precision fee must still emit the fill");
1183 assert_eq!(report.commission.as_decimal(), expected);
1184 }
1185
1186 #[rstest]
1187 fn test_parse_trade_report_errors_on_out_of_range_commission() {
1188 let mut trade = sample_trade();
1189 trade.trade_fee = Decimal::MAX;
1190 let account_id = AccountId::new("DERIVE-001");
1191 let err = parse_derive_trade_to_fill_report(
1192 &trade,
1193 account_id,
1194 Currency::USDC(),
1195 UnixNanos::from(2),
1196 )
1197 .expect_err("out-of-range fee must error instead of panicking");
1198 assert!(
1199 err.to_string().contains("trade_fee"),
1200 "unexpected error: {err}",
1201 );
1202 }
1203
1204 #[rstest]
1205 fn test_v3_trade_reports_preserve_execution_across_batch_stages() {
1206 let stages: Vec<Option<String>> = serde_json::from_str(include_str!(
1207 "../../test_data/common/trade_batch_stages.json"
1208 ))
1209 .unwrap();
1210 let trades: Vec<DeriveTrade> = serde_json::from_str(include_str!(
1211 "../../test_data/perps/http_trades_v3_native.json"
1212 ))
1213 .unwrap();
1214 let account_id = AccountId::new("DERIVE-001");
1215
1216 for stage in stages {
1217 for (i, original) in trades.iter().enumerate() {
1218 let mut trade = original.clone();
1219 trade.batch_status = stage.clone();
1220 trade.tx_hash = None;
1221 trade.op_uuid = None;
1222 let report = parse_derive_trade_to_fill_report_with_precision(
1223 &trade,
1224 account_id,
1225 Currency::USDC(),
1226 Some(2),
1227 Some(3),
1228 UnixNanos::from(91),
1229 )
1230 .unwrap()
1231 .unwrap();
1232 assert_eq!(report.account_id, account_id);
1233 assert_eq!(report.instrument_id, InstrumentId::from("ETH-PERP.DERIVE"));
1234 assert_eq!(report.venue_order_id, VenueOrderId::new(&trade.order_id));
1235 assert_eq!(report.trade_id, TradeId::new(&trade.trade_id));
1236 assert_eq!(
1237 report.client_order_id,
1238 Some(ClientOrderId::new(trade.label))
1239 );
1240 assert_eq!(report.venue_position_id, None);
1241 assert_eq!(report.order_side, [OrderSide::Sell, OrderSide::Buy][i]);
1242 assert_eq!(report.last_qty, Quantity::from("0.100"));
1243 assert_eq!(
1244 report.last_px,
1245 [Price::from("2568.50"), Price::from("2568.89")][i]
1246 );
1247 assert_eq!(report.commission.currency, Currency::USDC());
1248 assert_eq!(
1249 report.commission.as_decimal(),
1250 [dec!(0.08705248), dec!(0.08705274)][i]
1251 );
1252 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1253 assert_eq!(
1254 report.ts_event,
1255 UnixNanos::from([1_751_600_001_000_000_000, 1_751_600_000_000_000_000][i])
1256 );
1257 assert_eq!(report.ts_init, UnixNanos::from(91));
1258 assert_eq!(report.avg_px, None);
1259 }
1260 }
1261 }
1262
1263 #[rstest]
1264 #[case("", "order-1")]
1265 #[case("trade-1", "")]
1266 fn test_trade_reports_require_execution_identities(
1267 #[case] trade_id: &str,
1268 #[case] order_id: &str,
1269 ) {
1270 let mut trade = sample_trade();
1271 trade.trade_id = trade_id.to_string();
1272 trade.order_id = order_id.to_string();
1273 let report = parse_derive_trade_to_fill_report(
1274 &trade,
1275 AccountId::new("DERIVE-001"),
1276 Currency::USDC(),
1277 UnixNanos::from(91),
1278 )
1279 .unwrap();
1280 assert_eq!(report, None);
1281 }
1282
1283 #[rstest]
1284 fn test_parse_trade_report_preserves_batch_error_fill() {
1285 let mut trade = sample_trade();
1286 trade.batch_status = Some("SettlingError".to_string());
1287 let account_id = AccountId::new("DERIVE-001");
1288 let usdc = Currency::USDC();
1289 let report =
1290 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
1291 .unwrap();
1292 assert_eq!(report.unwrap().trade_id, TradeId::new("tr-1"));
1293 }
1294
1295 #[rstest]
1296 fn test_parse_trade_report_degrades_unknown_liquidity_role() {
1297 let mut trade = sample_trade();
1298 trade.liquidity_role = DeriveLiquidityRole::Unknown;
1299 let account_id = AccountId::new("DERIVE-001");
1300 let usdc = Currency::USDC();
1301
1302 let report =
1303 parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
1304 .unwrap()
1305 .expect("unknown liquidity role must still emit the fill");
1306
1307 assert_eq!(report.liquidity_side, LiquiditySide::NoLiquiditySide);
1308 }
1309
1310 #[rstest]
1311 #[case("order")]
1312 #[case("trade")]
1313 #[case("position")]
1314 fn test_execution_parser_rejects_blank_symbol_without_panic(
1315 #[case] record: &str,
1316 #[values("", " ", "\t\n")] symbol: &str,
1317 ) {
1318 let account_id = AccountId::from("DERIVE-001");
1319
1320 let result = std::panic::catch_unwind(|| match record {
1321 "order" => {
1322 let mut order = sample_order();
1323 order.instrument_name = symbol.into();
1324 parse_derive_order_to_report(&order, account_id, UnixNanos::from(17)).map(|_| ())
1325 }
1326 "trade" => {
1327 let mut trade = sample_trade();
1328 trade.instrument_name = symbol.into();
1329 parse_derive_trade_to_fill_report(
1330 &trade,
1331 account_id,
1332 Currency::USDC(),
1333 UnixNanos::from(17),
1334 )
1335 .map(|_| ())
1336 }
1337 "position" => {
1338 let mut position = sample_position();
1339 position.instrument_name = symbol.into();
1340 parse_derive_position_to_report(&position, account_id, UnixNanos::from(17))
1341 .map(|_| ())
1342 }
1343 _ => unreachable!(),
1344 });
1345
1346 assert!(result.is_ok(), "untrusted symbols must not panic");
1347 assert!(result.unwrap().is_err());
1348 }
1349
1350 #[rstest]
1351 fn test_parse_position_long_short_flat() {
1352 let account_id = AccountId::new("DERIVE-001");
1353
1354 let mut long_pos = sample_position();
1355 long_pos.amount = dec!(3);
1356 let report =
1357 parse_derive_position_to_report(&long_pos, account_id, UnixNanos::from(3)).unwrap();
1358 assert_eq!(report.position_side, PositionSide::Long);
1359 assert_eq!(report.quantity, Quantity::from("3"));
1360
1361 let mut short_pos = sample_position();
1362 short_pos.amount = dec!(-2);
1363 let report =
1364 parse_derive_position_to_report(&short_pos, account_id, UnixNanos::from(3)).unwrap();
1365 assert_eq!(report.position_side, PositionSide::Short);
1366 assert_eq!(report.quantity, Quantity::from("2"));
1367
1368 let mut flat_pos = sample_position();
1369 flat_pos.amount = dec!(0);
1370 let report =
1371 parse_derive_position_to_report(&flat_pos, account_id, UnixNanos::from(3)).unwrap();
1372 assert_eq!(report.position_side, PositionSide::Flat);
1373 }
1374
1375 #[rstest]
1376 #[case::perp(DeriveInstrumentType::Perp, AvgPxReconciliation::OpeningOnly)]
1377 #[case::option(DeriveInstrumentType::Option, AvgPxReconciliation::Match)]
1378 #[case::spot(DeriveInstrumentType::Erc20, AvgPxReconciliation::Match)]
1379 fn test_parse_position_report_uses_instrument_precision(
1380 #[case] instrument_type: DeriveInstrumentType,
1381 #[case] reconciliation: AvgPxReconciliation,
1382 ) {
1383 let mut position = sample_position();
1384 position.amount = Decimal::from_str_exact("25.000").unwrap();
1385 position.average_price = dec!(2553.87589666777);
1386 position.instrument_type = instrument_type;
1387
1388 let report = parse_derive_position_to_report_with_precision(
1389 &position,
1390 AccountId::new("DERIVE-001"),
1391 Some(3),
1392 UnixNanos::from(3),
1393 )
1394 .unwrap();
1395
1396 assert_eq!(
1397 report,
1398 PositionStatusReport::new(
1399 AccountId::new("DERIVE-001"),
1400 InstrumentId::from("ETH-PERP.DERIVE"),
1401 PositionSide::Long,
1402 Quantity::from("25.000"),
1403 UnixNanos::from(3),
1404 UnixNanos::from(3),
1405 Some(report.report_id),
1406 None,
1407 Some(position.average_price),
1408 )
1409 .with_avg_px_open_reconciliation(reconciliation)
1410 );
1411 assert_eq!(report.quantity.precision, 3);
1412 }
1413
1414 fn sample_position() -> DerivePosition {
1415 DerivePosition {
1416 amount: dec!(0),
1417 average_price: dec!(3500),
1418 creation_timestamp: 0,
1419 cumulative_funding: dec!(0),
1420 delta: dec!(0),
1421 gamma: dec!(0),
1422 index_price: dec!(3500),
1423 initial_margin: dec!(0),
1424 instrument_name: "ETH-PERP".into(),
1425 instrument_type: DeriveInstrumentType::Perp,
1426 leverage: None,
1427 liquidation_price: None,
1428 maintenance_margin: dec!(0),
1429 mark_price: dec!(3500),
1430 mark_value: dec!(0),
1431 net_settlements: dec!(0),
1432 open_orders_margin: dec!(0),
1433 pending_funding: dec!(0),
1434 realized_pnl: dec!(0),
1435 theta: dec!(0),
1436 unrealized_pnl: dec!(0),
1437 vega: dec!(0),
1438 }
1439 }
1440
1441 #[rstest]
1442 #[case("order_type")]
1443 #[case("time_in_force")]
1444 #[case("order_status")]
1445 #[case("trigger_price_type")]
1446 fn test_parse_subaccount_balances_ignore_order_classification(#[case] field: &str) {
1447 let mut subaccount = sample_subaccount();
1448 let expected = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1449 let mut order = serde_json::to_value(sample_order()).unwrap();
1450 order[field] = serde_json::json!("future-native-value");
1451 subaccount.open_orders = vec![serde_json::from_value(order).unwrap()];
1452
1453 let actual = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1454
1455 assert_eq!(actual, expected);
1456 }
1457
1458 #[rstest]
1459 fn test_parse_subaccount_emits_balances_margins_and_info() {
1460 let subaccount = sample_subaccount();
1461 let (balances, margins, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1462 assert_eq!(balances.len(), 1);
1463 assert_eq!(balances[0].total.as_decimal(), dec!(1000));
1464 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1465 assert_eq!(balances[0].free.as_decimal(), dec!(1000));
1466 assert_eq!(margins.len(), 1);
1467 assert_eq!(margins[0].initial.as_decimal(), dec!(0));
1468 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0));
1469 assert_eq!(
1470 info.get("net_initial_margin"),
1471 Some(&serde_json::json!("100")),
1472 );
1473 assert_eq!(
1474 info.get("net_maintenance_margin"),
1475 Some(&serde_json::json!("50")),
1476 );
1477 assert_eq!(
1478 info.get("is_under_liquidation"),
1479 Some(&serde_json::json!(false)),
1480 );
1481 }
1482
1483 #[rstest]
1484 fn test_parse_subaccount_preserves_multi_collateral_units() {
1485 let mut subaccount = sample_subaccount();
1489 subaccount.collaterals = vec![
1490 DeriveCollateral {
1491 amount: dec!(2.5),
1492 asset_name: "ETH".into(),
1493 asset_type: DeriveAssetType::Erc20,
1494 cumulative_interest: dec!(0),
1495 currency: "ETH".into(),
1496 initial_margin: dec!(1000),
1497 maintenance_margin: dec!(500),
1498 mark_price: dec!(3500),
1499 mark_value: dec!(8750),
1500 pending_interest: dec!(0),
1501 },
1502 DeriveCollateral {
1503 amount: dec!(1000),
1504 asset_name: "USDC".into(),
1505 asset_type: DeriveAssetType::Erc20,
1506 cumulative_interest: dec!(0),
1507 currency: "USDC".into(),
1508 initial_margin: dec!(1000),
1509 maintenance_margin: dec!(1000),
1510 mark_price: dec!(1),
1511 mark_value: dec!(1000),
1512 pending_interest: dec!(0),
1513 },
1514 ];
1515
1516 let (balances, _, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1517 assert_eq!(balances.len(), 2);
1518 assert_eq!(balances[0].total.currency, Currency::ETH());
1519 assert_eq!(balances[1].total.currency, Currency::USDC());
1520 assert_eq!(balances[0].total.as_decimal(), dec!(2.5));
1521 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1522 assert_eq!(balances[0].free.as_decimal(), dec!(2.5));
1523 assert_eq!(balances[1].total.as_decimal(), dec!(1000));
1524 assert_eq!(balances[1].locked.as_decimal(), dec!(0));
1525 assert_eq!(balances[1].free.as_decimal(), dec!(1000));
1526 }
1527
1528 #[rstest]
1529 fn test_parse_subaccount_aggregates_requirements_and_keeps_health_in_info() {
1530 let mut subaccount = sample_subaccount();
1533 subaccount.positions_initial_margin = dec!(-350);
1534 subaccount.positions_maintenance_margin = dec!(-175);
1535 subaccount.open_orders_margin = dec!(-40);
1536 subaccount.initial_margin = dec!(650);
1537 subaccount.maintenance_margin = dec!(825);
1538
1539 let (balances, margins, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1540 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1541 assert_eq!(margins.len(), 1);
1542 assert_eq!(margins[0].initial.as_decimal(), dec!(390));
1543 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1544 assert_eq!(
1545 info.get("positions_initial_margin"),
1546 Some(&serde_json::json!("-350")),
1547 );
1548 assert_eq!(
1549 info.get("positions_maintenance_margin"),
1550 Some(&serde_json::json!("-175")),
1551 );
1552 assert_eq!(
1553 info.get("open_orders_margin"),
1554 Some(&serde_json::json!("-40")),
1555 );
1556 assert_eq!(
1557 info.get("net_initial_margin"),
1558 Some(&serde_json::json!("650")),
1559 );
1560 assert_eq!(
1561 info.get("net_maintenance_margin"),
1562 Some(&serde_json::json!("825")),
1563 );
1564 }
1565
1566 #[rstest]
1567 fn test_parse_subaccount_funded_positionless_fixture_reports_no_locked() {
1568 let (balances, margins, info) =
1569 parse_subaccount_fixture("common/http_subaccount_usdc.json");
1570 assert_eq!(balances.len(), 1);
1571 assert_eq!(balances[0].total.as_decimal(), dec!(1000));
1572 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1573 assert_eq!(balances[0].free.as_decimal(), dec!(1000));
1574 assert_eq!(margins.len(), 1);
1575 assert_eq!(margins[0].initial.as_decimal(), dec!(0));
1576 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0));
1577 assert_eq!(
1580 info.get("net_initial_margin"),
1581 Some(&serde_json::json!("1000")),
1582 );
1583 assert_eq!(
1584 info.get("net_maintenance_margin"),
1585 Some(&serde_json::json!("1000")),
1586 );
1587 }
1588
1589 #[rstest]
1590 fn test_parse_subaccount_positions_margin_fixture_maps_requirements() {
1591 let (balances, margins, info) =
1592 parse_subaccount_fixture("common/http_subaccount_positions_margin.json");
1593 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1594 assert_eq!(margins[0].initial.as_decimal(), dec!(350));
1595 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1596 assert_eq!(
1597 info.get("net_initial_margin"),
1598 Some(&serde_json::json!("655")),
1599 );
1600 assert_eq!(
1601 info.get("net_maintenance_margin"),
1602 Some(&serde_json::json!("830")),
1603 );
1604 }
1605
1606 #[rstest]
1607 fn test_parse_subaccount_open_orders_margin_fixture_maps_reservation() {
1608 let (_, margins, info) =
1609 parse_subaccount_fixture("common/http_subaccount_open_orders_margin.json");
1610 assert_eq!(margins[0].initial.as_decimal(), dec!(40));
1611 assert_eq!(margins[0].maintenance.as_decimal(), dec!(0));
1612 assert_eq!(
1613 info.get("open_orders_margin"),
1614 Some(&serde_json::json!("-40")),
1615 );
1616 }
1617
1618 #[rstest]
1619 fn test_parse_subaccount_negative_health_fixture_preserves_signs() {
1620 let (balances, margins, info) =
1621 parse_subaccount_fixture("common/http_subaccount_negative_health.json");
1622 assert_eq!(balances[0].locked.as_decimal(), dec!(0));
1623 assert_eq!(margins[0].initial.as_decimal(), dec!(1095));
1624 assert_eq!(margins[0].maintenance.as_decimal(), dec!(1025));
1625 assert_eq!(
1626 info.get("net_initial_margin"),
1627 Some(&serde_json::json!("-50")),
1628 );
1629 assert_eq!(
1630 info.get("net_maintenance_margin"),
1631 Some(&serde_json::json!("-20")),
1632 );
1633 assert_eq!(
1634 info.get("is_under_liquidation"),
1635 Some(&serde_json::json!(true)),
1636 );
1637 }
1638
1639 #[rstest]
1640 fn test_parse_subaccount_with_no_collateral_emits_margins_only() {
1641 let mut subaccount = sample_subaccount();
1642 subaccount.collaterals = vec![];
1643 subaccount.positions_initial_margin = dec!(-350);
1644 subaccount.positions_maintenance_margin = dec!(-175);
1645
1646 let (balances, margins, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1647 assert!(balances.is_empty());
1648 assert_eq!(margins.len(), 1);
1649 assert_eq!(margins[0].initial.as_decimal(), dec!(350));
1650 assert_eq!(margins[0].maintenance.as_decimal(), dec!(175));
1651 }
1652
1653 #[rstest]
1654 fn test_parse_subaccount_errors_on_unrepresentable_amount() {
1655 let mut subaccount = sample_subaccount();
1656 subaccount.collaterals[0].amount = Decimal::MAX;
1657
1658 let err = parse_derive_subaccount_to_balances(&subaccount)
1659 .expect_err("out-of-range collateral amount must error instead of panicking");
1660 assert!(
1661 err.to_string().contains("collateral balance"),
1662 "unexpected error: {err}",
1663 );
1664 }
1665
1666 #[rstest]
1667 fn test_parse_subaccount_v3_preserves_exact_collaterals_and_portfolio_risk() {
1668 let mut subaccount = sample_subaccount();
1669 subaccount.margin_type = "PM2-next".into();
1670 subaccount.manager_id = 57;
1671 subaccount.risk_universe_id = 19;
1672 subaccount.collaterals[0].amount = dec!(1.234567895);
1673 subaccount.initial_margin = dec!(-7.123456789123);
1674 subaccount.maintenance_margin = dec!(-8.234567891234);
1675 subaccount.positions_initial_margin = dec!(3.125);
1676 subaccount.open_orders_margin = dec!(-0.01);
1677 subaccount.positions_maintenance_margin = dec!(3.905);
1678 subaccount.positions_value = dec!(6.25);
1679 subaccount.mm_credits = dec!(0.123456789123);
1680 subaccount.projected_margin_change = dec!(-0.987654321987);
1681 let mut position = sample_position();
1682 position.amount = dec!(-2.125);
1683 position.initial_margin = dec!(400.123);
1684 position.maintenance_margin = dec!(300.234);
1685 subaccount.positions = vec![position];
1686 subaccount.vault_deposit_holds = vec![crate::http::models::DeriveVaultDepositHold {
1687 amount: dec!(0.000000000123456789),
1688 asset_name: "ETH".into(),
1689 currency: "ETH".into(),
1690 vault_id: 123,
1691 }];
1692
1693 let (balances, margins, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1694
1695 assert_eq!(
1696 balances,
1697 vec![
1698 AccountBalance::from_total_and_locked(
1699 dec!(1.23456790),
1700 Decimal::ZERO,
1701 Currency::USDC(),
1702 )
1703 .unwrap()
1704 ]
1705 );
1706 assert_eq!(
1707 margins,
1708 vec![MarginBalance::new(
1709 Money::from_decimal(dec!(3.14), Currency::USD()).unwrap(),
1710 Money::from_decimal(dec!(2.34), Currency::USD()).unwrap(),
1711 None,
1712 )]
1713 );
1714 assert_eq!(info, serde_json::from_value(serde_json::json!({
1715 "currency": ["ETH", "BTC"],
1716 "margin_type": "PM2-next",
1717 "manager_id": 57,
1718 "risk_universe_id": 19,
1719 "mm_credits": "0.123456789123",
1720 "projected_margin_change": "-0.987654321987",
1721 "vault_deposit_holds": [{"amount": "0.000000000123456789", "asset_name": "ETH", "currency": "ETH", "vault_id": 123}],
1722 "net_initial_margin": "-7.123456789123",
1723 "net_maintenance_margin": "-8.234567891234",
1724 "positions_initial_margin": "3.125",
1725 "positions_maintenance_margin": "3.905",
1726 "positions_value": "6.25",
1727 "open_orders_margin": "-0.01",
1728 "is_under_liquidation": false,
1729 "collaterals": [{"amount": "1.234567895", "asset_name": "USDC", "asset_type": "erc20", "cumulative_interest": "0", "currency": "USDC", "initial_margin": "100", "maintenance_margin": "50", "mark_price": "1", "mark_value": "1000", "pending_interest": "0"}],
1730 })).unwrap());
1731 }
1732
1733 #[rstest]
1734 #[case(dec!(0.000000001), dec!(0))]
1735 #[case(dec!(0.123456785), dec!(0.12345678))]
1736 fn test_parse_subaccount_retains_collateral_below_native_precision(
1737 #[case] amount: Decimal,
1738 #[case] rounded: Decimal,
1739 ) {
1740 let mut subaccount = sample_subaccount();
1741 subaccount.collaterals[0].amount = amount;
1742
1743 let (balances, _, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1744
1745 assert_eq!(balances[0].total.as_decimal(), rounded);
1746 assert_eq!(balances[0].free.as_decimal(), rounded);
1747 assert_eq!(balances[0].locked.as_decimal(), Decimal::ZERO);
1748 assert_eq!(info["collaterals"][0]["amount"], amount.to_string());
1749 }
1750
1751 #[rstest]
1752 fn test_parse_subaccount_rejects_failed_snapshot() {
1753 let mut subaccount = sample_subaccount();
1754 subaccount.failed_to_fetch = true;
1755
1756 let err = parse_derive_subaccount_to_balances(&subaccount).unwrap_err();
1757
1758 assert_eq!(err.to_string(), "Derive subaccount snapshot is incomplete");
1759 }
1760
1761 #[rstest]
1762 #[case(Decimal::MAX, -Decimal::ONE, Decimal::ZERO, Decimal::ZERO, "initial")]
1763 #[case(Decimal::ZERO, Decimal::MIN, -Decimal::ONE, Decimal::ZERO, "initial")]
1764 #[case(Decimal::MAX, Decimal::ZERO, Decimal::ZERO, -Decimal::ONE, "maintenance")]
1765 fn test_parse_subaccount_rejects_requirement_overflow(
1766 #[case] value: Decimal,
1767 #[case] initial: Decimal,
1768 #[case] orders: Decimal,
1769 #[case] maintenance: Decimal,
1770 #[case] field: &str,
1771 ) {
1772 let mut subaccount = sample_subaccount();
1773 subaccount.positions_value = value;
1774 subaccount.positions_initial_margin = initial;
1775 subaccount.open_orders_margin = orders;
1776 subaccount.positions_maintenance_margin = maintenance;
1777
1778 let err = parse_derive_subaccount_to_balances(&subaccount).unwrap_err();
1779
1780 assert_eq!(
1781 err.to_string(),
1782 format!("Derive {field} margin requirement exceeds Decimal range")
1783 );
1784 }
1785
1786 #[rstest]
1787 #[case(dec!(0.0000000000000000001))]
1788 #[case(Decimal::MAX)]
1789 fn test_parse_subaccount_rejects_unrepresentable_embedded_position(#[case] amount: Decimal) {
1790 let mut subaccount = sample_subaccount();
1791 let mut position = sample_position();
1792 position.amount = amount;
1793 subaccount.positions = vec![position];
1794
1795 let expected = Quantity::from_decimal(amount).expect_err("unrepresentable test amount");
1796 let err = parse_derive_subaccount_to_balances(&subaccount).unwrap_err();
1797
1798 assert_eq!(
1799 format!("{err:#}"),
1800 format!("invalid Derive position.amount: {expected}")
1801 );
1802 }
1803
1804 #[rstest]
1805 #[case(dec!(0.0001))]
1806 #[case(dec!(-2.1234))]
1807 fn test_parse_position_rejects_rounding_at_instrument_precision(#[case] amount: Decimal) {
1808 let mut position = sample_position();
1809 position.amount = amount;
1810
1811 let err = parse_derive_position_to_report_with_precision(
1812 &position,
1813 AccountId::new("DERIVE-001"),
1814 Some(3),
1815 UnixNanos::from(3),
1816 )
1817 .unwrap_err();
1818
1819 assert!(err.to_string().contains("cannot be represented exactly"));
1820 }
1821
1822 #[rstest]
1823 #[case(include_str!("../../test_data/common/http_subaccount_pm2_open_orders.json"), dec!(44.84), dec!(0), "0", "0", "0", "-44.842739996872")]
1824 #[case(include_str!("../../test_data/common/http_subaccount_pm2_positions.json"), dec!(44.95), dec!(34.81), "0.000318767368", "-44.951666892242", "-34.805466288178", "0")]
1825 fn test_parse_subaccount_live_pm2_requirements(
1826 #[case] fixture: &str,
1827 #[case] initial: Decimal,
1828 #[case] maintenance: Decimal,
1829 #[case] value: &str,
1830 #[case] contribution_initial: &str,
1831 #[case] contribution_maintenance: &str,
1832 #[case] orders: &str,
1833 ) {
1834 let body: Value = serde_json::from_str(fixture).unwrap();
1835 let subaccount: DeriveSubaccount = serde_json::from_str(fixture).unwrap();
1836 let (balances, margins, info) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
1837
1838 let total = if orders == "0" {
1839 dec!(45.89433238)
1840 } else {
1841 dec!(46.00000318)
1842 };
1843
1844 let mut expected = IndexMap::new();
1845 for name in [
1846 "collaterals",
1847 "currency",
1848 "margin_type",
1849 "manager_id",
1850 "risk_universe_id",
1851 "mm_credits",
1852 "projected_margin_change",
1853 "vault_deposit_holds",
1854 "positions_value",
1855 "positions_initial_margin",
1856 "positions_maintenance_margin",
1857 "open_orders_margin",
1858 "is_under_liquidation",
1859 ] {
1860 expected.insert(name.to_string(), body[name].clone());
1861 }
1862
1863 expected.insert(
1864 "net_initial_margin".to_string(),
1865 body["initial_margin"].clone(),
1866 );
1867 expected.insert(
1868 "net_maintenance_margin".to_string(),
1869 body["maintenance_margin"].clone(),
1870 );
1871
1872 assert_eq!(
1873 balances,
1874 vec![
1875 AccountBalance::from_total_and_locked(total, Decimal::ZERO, Currency::USDC())
1876 .unwrap()
1877 ]
1878 );
1879 assert_eq!(
1880 margins,
1881 vec![MarginBalance::new(
1882 Money::from_decimal(initial, Currency::USD()).unwrap(),
1883 Money::from_decimal(maintenance, Currency::USD()).unwrap(),
1884 None
1885 )]
1886 );
1887 assert_eq!(info, Params::new(expected));
1888 assert_eq!(info["positions_value"], value);
1889 assert_eq!(info["positions_initial_margin"], contribution_initial);
1890 assert_eq!(
1891 info["positions_maintenance_margin"],
1892 contribution_maintenance
1893 );
1894 assert_eq!(info["open_orders_margin"], orders);
1895 assert_eq!(info["margin_type"], "PM2");
1896 assert_eq!(info["manager_id"], 5);
1897 assert_eq!(info["risk_universe_id"], 1);
1898 }
1899
1900 #[rstest]
1901 fn test_parse_subaccount_current_pm2_fixture() {
1902 let (balances, margins, info) = parse_subaccount_fixture("common/http_subaccount_pm2.json");
1903
1904 assert_eq!(
1905 balances,
1906 vec![
1907 AccountBalance::from_total_and_locked(dec!(6), Decimal::ZERO, Currency::USDC())
1908 .unwrap()
1909 ]
1910 );
1911 assert_eq!(
1912 margins,
1913 vec![MarginBalance::new(
1914 Money::from_decimal(Decimal::ZERO, Currency::USD()).unwrap(),
1915 Money::from_decimal(Decimal::ZERO, Currency::USD()).unwrap(),
1916 None
1917 )]
1918 );
1919 assert_eq!(info["currency"], serde_json::json!(["BTC", "ETH"]));
1920 assert_eq!(info["margin_type"], "PM2");
1921 assert_eq!(info["manager_id"], 5);
1922 assert_eq!(info["risk_universe_id"], 1);
1923 assert_eq!(info["net_initial_margin"], "6.000000127922");
1924 assert_eq!(info["net_maintenance_margin"], "6.000000127922");
1925 assert_eq!(info["collaterals"][0]["pending_interest"], "0.000000127922");
1926 }
1927
1928 fn parse_subaccount_fixture(
1929 filename: &str,
1930 ) -> (Vec<AccountBalance>, Vec<MarginBalance>, Params) {
1931 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1932 .join("test_data")
1933 .join(filename);
1934 let content =
1935 std::fs::read_to_string(&path).unwrap_or_else(|_| panic!("failed to read {filename}"));
1936 let subaccount: DeriveSubaccount = serde_json::from_str(&content)
1937 .unwrap_or_else(|e| panic!("failed to parse {filename}: {e}"));
1938 parse_derive_subaccount_to_balances(&subaccount).expect("subaccount maps")
1939 }
1940
1941 fn sample_subaccount() -> DeriveSubaccount {
1942 DeriveSubaccount {
1943 collaterals: vec![DeriveCollateral {
1944 amount: dec!(1000),
1945 asset_name: "USDC".into(),
1946 asset_type: DeriveAssetType::Erc20,
1947 cumulative_interest: dec!(0),
1948 currency: "USDC".into(),
1949 initial_margin: dec!(100),
1950 maintenance_margin: dec!(50),
1951 mark_price: dec!(1),
1952 mark_value: dec!(1000),
1953 pending_interest: dec!(0),
1954 }],
1955 collaterals_initial_margin: dec!(100),
1956 collaterals_maintenance_margin: dec!(50),
1957 collaterals_value: dec!(1000),
1958 currency: vec!["ETH".into(), "BTC".into()],
1959 failed_to_fetch: false,
1960 initial_margin: dec!(100),
1961 is_under_liquidation: false,
1962 label: None,
1963 maintenance_margin: dec!(50),
1964 manager_id: 3,
1965 margin_type: "SM".into(),
1966 mm_credits: dec!(0),
1967 open_orders: vec![],
1968 open_orders_margin: dec!(0),
1969 positions: vec![],
1970 positions_initial_margin: dec!(0),
1971 positions_maintenance_margin: dec!(0),
1972 positions_value: dec!(0),
1973 projected_margin_change: dec!(0),
1974 risk_universe_id: 1,
1975 subaccount_id: 30769,
1976 subaccount_value: dec!(1000),
1977 vault_deposit_holds: vec![],
1978 }
1979 }
1980}