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