Skip to main content

nautilus_derive/http/
parse.rs

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