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::{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
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 a valid Nautilus identifier; 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 if the venue order identifier is invalid or the amount or filled amount cannot
57/// be converted to a Nautilus `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 = 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
212/// Builds a [`FillReport`] from a Derive trade record.
213///
214/// Quote-currency commission is reported in the same currency as the
215/// instrument's settlement (USDC for perps and options). `client_order_id`
216/// is sourced from the trade `label` when it is a valid Nautilus identifier.
217/// Trailing zero padding is removed without changing the value.
218/// Returns `Ok(None)` with a warning when the trade or order identifier is empty.
219///
220/// # Errors
221///
222/// Returns an error if a nonempty required identifier is invalid or a decimal field cannot be
223/// converted to a Nautilus `Price`, `Quantity`, or `Money`.
224pub 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
303/// Builds a [`PositionStatusReport`] from a Derive position record.
304///
305/// Trailing zero padding is removed without changing the value.
306///
307/// # Errors
308///
309/// Returns an error if the position amount cannot be converted to a Nautilus `Quantity` without
310/// changing its value.
311pub 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    // Perpetual averages include opening fees, which Nautilus books as fill commissions
356    if position.instrument_type == DeriveInstrumentType::Perp {
357        Ok(report.with_avg_px_open_reconciliation(AvgPxReconciliation::OpeningOnly))
358    } else {
359        Ok(report)
360    }
361}
362
363/// Derives balances, margins, and supplemental info from a [`DeriveSubaccount`] snapshot.
364///
365/// Each collateral row becomes one [`AccountBalance`] in the collateral's own
366/// units, rounded to its registered currency precision, with `total = amount`
367/// and `locked = 0`. A subset of each collateral row remains in [`Params`]
368/// with decimal strings exact within `Decimal` range and scale. The venue's
369/// `collaterals[].initial_margin` is USD credit, not locked funds. Pending
370/// vault-deposit holds remain metadata because their inclusion in
371/// collateral amounts is not specified by the venue.
372///
373/// Portfolio requirements form one account-wide [`MarginBalance`] in USD:
374/// `initial = positions_value - positions_initial_margin - open_orders_margin` and
375/// `maintenance = positions_value - positions_maintenance_margin`. The source margin
376/// fields are signed health contributions; position value removes their mark-to-market
377/// credit, and open-order margin is a signed reservation. These aggregates preserve the
378/// venue's portfolio offsets and round to native USD precision. Source margin
379/// components and signed net health remain decimal strings in [`Params`] within
380/// `Decimal` range and scale. Metadata also includes the manager, risk universe,
381/// margin model, and liquidation status.
382///
383/// # Errors
384///
385/// Returns an error if the venue marks the snapshot `failed_to_fetch`, an embedded position cannot
386/// be represented exactly, a balance exceeds the native range, or aggregate requirements overflow.
387pub 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        // These half-unit values are where the old f64 path diverged
1167        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        // 2.5 ETH collateral reporting $1000 of credit stays 2.5 ETH total
1481        // with nothing locked: credit is not a reservation on the collateral,
1482        // so no mark-price conversion applies
1483        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        // Requirements aggregate position IM with open-order margin; the
1526        // signed net health values must not appear as requirements
1527        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        // Net health equals the collateral credit with no requirements,
1573        // matching observed mainnet responses for funded positionless accounts
1574        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}