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