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;
19use nautilus_core::{UUID4, UnixNanos, datetime::NANOSECONDS_IN_MILLISECOND};
20use nautilus_model::{
21    enums::{LiquiditySide, OrderType, PositionSideSpecified},
22    identifiers::{AccountId, ClientOrderId, InstrumentId, Symbol, TradeId, VenueOrderId},
23    reports::{FillReport, OrderStatusReport, PositionStatusReport},
24    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
25};
26use rust_decimal::Decimal;
27
28use crate::{
29    common::{
30        consts::DERIVE_VENUE,
31        enums::{
32            DeriveLiquidityRole, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
33            DeriveTimeInForce, DeriveTriggerType, DeriveTxStatus,
34        },
35        parse::{
36            derive_order_side_to_nautilus, derive_order_type_to_nautilus_for_order,
37            derive_rejection_due_post_only, derive_status_to_nautilus, derive_tif_to_nautilus,
38            derive_trigger_price_type_to_nautilus,
39        },
40    },
41    http::models::{DeriveOrder, DerivePosition, DeriveSubaccount, DeriveTrade},
42};
43
44/// Builds an [`OrderStatusReport`] from a Derive order record.
45///
46/// `client_order_id` is sourced from the `label` field on the order when the
47/// label is non-empty; callers that need a specific client_order_id should
48/// override via `with_client_order_id` after this call.
49///
50/// # Errors
51///
52/// Returns an error when any decimal field cannot be converted to a Nautilus
53/// `Price` or `Quantity`.
54pub fn parse_derive_order_to_report(
55    order: &DeriveOrder,
56    account_id: AccountId,
57    ts_init: UnixNanos,
58) -> anyhow::Result<OrderStatusReport> {
59    let instrument_id =
60        InstrumentId::new(Symbol::new(order.instrument_name.as_str()), *DERIVE_VENUE);
61    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
62    let order_side = derive_order_side_to_nautilus(order.direction);
63    let order_type = derive_order_type_to_nautilus_for_report(order);
64    let post_only = matches!(order.time_in_force, DeriveTimeInForce::PostOnly);
65    let time_in_force = derive_tif_to_nautilus(order.time_in_force);
66    let order_status =
67        derive_status_to_nautilus(order.order_status, order.filled_amount, order.amount);
68    let quantity = quantity_from_decimal(order.amount, "amount")?;
69    let filled_qty = quantity_from_decimal(order.filled_amount, "filled_amount")?;
70
71    let ts_accepted = ms_to_nanos(order.creation_timestamp);
72    let ts_last = ms_to_nanos(order.last_update_timestamp);
73
74    let mut report = OrderStatusReport::new(
75        account_id,
76        instrument_id,
77        None,
78        venue_order_id,
79        order_side,
80        order_type,
81        time_in_force,
82        order_status,
83        quantity,
84        filled_qty,
85        ts_accepted,
86        ts_last,
87        ts_init,
88        Some(UUID4::new()),
89    );
90
91    if !order.label.as_str().is_empty() {
92        let client_order_id = ClientOrderId::new(order.label.as_str());
93        report = report.with_client_order_id(client_order_id);
94    }
95
96    if order.limit_price > Decimal::ZERO
97        && order_type_has_limit_price(order_type)
98        && let Ok(price) = Price::from_decimal(order.limit_price.normalize())
99    {
100        report = report.with_price(price);
101    }
102
103    if let Some(trigger_price) = order.trigger_price
104        && trigger_price > Decimal::ZERO
105        && let Ok(price) = Price::from_decimal(trigger_price.normalize())
106    {
107        report = report.with_trigger_price(price);
108    }
109
110    if let Some(trigger_price_type) = order.trigger_price_type {
111        report =
112            report.with_trigger_type(derive_trigger_price_type_to_nautilus(trigger_price_type));
113    }
114
115    if order.average_price > Decimal::ZERO {
116        report.avg_px = Some(order.average_price);
117    }
118    report.post_only = post_only;
119    let trigger_reject_message = order
120        .trigger_reject_message
121        .as_deref()
122        .filter(|message| !message.is_empty())
123        .map(str::to_string);
124    let cancel_reason = trigger_reject_message
125        .clone()
126        .unwrap_or_else(|| order.cancel_reason.to_string());
127    if order.order_status == DeriveOrderStatus::Cancelled
128        || (order.order_status == DeriveOrderStatus::Rejected
129            && (trigger_reject_message.is_some()
130                || derive_rejection_due_post_only(None, &cancel_reason)))
131    {
132        report.cancel_reason = Some(cancel_reason);
133    }
134    Ok(report)
135}
136
137fn order_type_has_limit_price(order_type: OrderType) -> bool {
138    matches!(
139        order_type,
140        OrderType::Limit | OrderType::StopLimit | OrderType::LimitIfTouched
141    )
142}
143
144fn derive_order_type_to_nautilus_for_report(order: &DeriveOrder) -> OrderType {
145    let order_type = derive_order_type_to_nautilus_for_order(order.order_type, order.trigger_type);
146    if order_type != OrderType::LimitIfTouched {
147        return order_type;
148    }
149
150    match (order.order_type, order.trigger_type, order.trigger_price) {
151        (DeriveOrderType::Limit, Some(DeriveTriggerType::Takeprofit), Some(trigger_price))
152            if !limit_if_touched_prices_are_valid(
153                order.direction,
154                order.limit_price,
155                trigger_price,
156            ) =>
157        {
158            OrderType::StopLimit
159        }
160        _ => order_type,
161    }
162}
163
164fn limit_if_touched_prices_are_valid(
165    direction: DeriveOrderSide,
166    limit_price: Decimal,
167    trigger_price: Decimal,
168) -> bool {
169    match direction {
170        DeriveOrderSide::Buy => trigger_price <= limit_price,
171        DeriveOrderSide::Sell => trigger_price >= limit_price,
172    }
173}
174
175/// Builds a [`FillReport`] from a Derive trade record.
176///
177/// Quote-currency commission is reported in the same currency as the
178/// instrument's settlement (USDC for perps and options). `client_order_id`
179/// is sourced from the trade `label` when populated.
180///
181/// # Errors
182///
183/// Returns an error when any decimal field cannot be converted to a Nautilus
184/// `Price`, `Quantity`, or `Money`.
185pub fn parse_derive_trade_to_fill_report(
186    trade: &DeriveTrade,
187    account_id: AccountId,
188    fee_currency: Currency,
189    ts_init: UnixNanos,
190) -> anyhow::Result<Option<FillReport>> {
191    // The venue ships pending settlements with an empty trade_id and tx_hash;
192    // those rows would otherwise collapse identity-aware deduplication, so we
193    // skip them and let a later poll observe the settled trade.
194    if trade.trade_id.is_empty() || trade.tx_status == DeriveTxStatus::Reverted {
195        return Ok(None);
196    }
197
198    let instrument_id =
199        InstrumentId::new(Symbol::new(trade.instrument_name.as_str()), *DERIVE_VENUE);
200    let venue_order_id = VenueOrderId::new(trade.order_id.as_str());
201    let trade_id = TradeId::new(trade.trade_id.as_str());
202    let order_side = derive_order_side_to_nautilus(trade.direction);
203    let last_qty = quantity_from_decimal(trade.trade_amount, "trade_amount")?;
204    let last_px = price_from_decimal(trade.trade_price, "trade_price")?;
205    let commission = Money::new(
206        trade
207            .trade_fee
208            .try_into()
209            .with_context(|| format!("trade_fee {} out of f64 range", trade.trade_fee))?,
210        fee_currency,
211    );
212    let liquidity_side = match trade.liquidity_role {
213        DeriveLiquidityRole::Maker => LiquiditySide::Maker,
214        DeriveLiquidityRole::Taker => LiquiditySide::Taker,
215    };
216
217    let client_order_id = if trade.label.as_str().is_empty() {
218        None
219    } else {
220        Some(ClientOrderId::new(trade.label.as_str()))
221    };
222
223    let ts_event = ms_to_nanos(trade.timestamp);
224
225    Ok(Some(FillReport::new(
226        account_id,
227        instrument_id,
228        venue_order_id,
229        trade_id,
230        order_side,
231        last_qty,
232        last_px,
233        commission,
234        liquidity_side,
235        client_order_id,
236        None,
237        ts_event,
238        ts_init,
239        Some(UUID4::new()),
240    )))
241}
242
243/// Builds a [`PositionStatusReport`] from a Derive position record.
244///
245/// Returns `Ok(None)` when the position is flat (zero amount) and the caller
246/// asked for non-flat reports only.
247///
248/// # Errors
249///
250/// Returns an error when the position amount cannot be converted to a
251/// Nautilus `Quantity`.
252pub fn parse_derive_position_to_report(
253    position: &DerivePosition,
254    account_id: AccountId,
255    ts_init: UnixNanos,
256) -> anyhow::Result<PositionStatusReport> {
257    let instrument_id = InstrumentId::new(
258        Symbol::new(position.instrument_name.as_str()),
259        *DERIVE_VENUE,
260    );
261    let signed_amount = position.amount;
262    let side = if signed_amount > Decimal::ZERO {
263        PositionSideSpecified::Long
264    } else if signed_amount < Decimal::ZERO {
265        PositionSideSpecified::Short
266    } else {
267        PositionSideSpecified::Flat
268    };
269    let abs_amount = signed_amount.abs();
270    let quantity = quantity_from_decimal(abs_amount, "position.amount")?;
271
272    Ok(PositionStatusReport::new(
273        account_id,
274        instrument_id,
275        side,
276        quantity,
277        ts_init,
278        ts_init,
279        Some(UUID4::new()),
280        None,
281        Some(position.average_price),
282    ))
283}
284
285/// Derives [`AccountBalance`] and [`MarginBalance`] rows from a
286/// [`DeriveSubaccount`] snapshot.
287///
288/// Each collateral row becomes one [`AccountBalance`]; the subaccount's
289/// initial/maintenance margin requirements collapse into a single
290/// [`MarginBalance`] without an instrument scoping.
291///
292/// # Errors
293///
294/// Returns an error when a decimal field cannot be represented at the
295/// currency precision used by [`Money`].
296pub fn parse_derive_subaccount_to_balances(
297    subaccount: &DeriveSubaccount,
298) -> anyhow::Result<(Vec<AccountBalance>, Vec<MarginBalance>)> {
299    let mut balances = Vec::with_capacity(subaccount.collaterals.len());
300    for collateral in &subaccount.collaterals {
301        let currency = Currency::get_or_create_crypto(collateral.asset_name.as_str());
302        // `amount` is in collateral units (e.g. 2.5 ETH); `initial_margin` is
303        // the USD value of the venue's margin requirement on this collateral.
304        // To produce a single-unit `AccountBalance` we convert the USD margin
305        // back into collateral units via `mark_price` (USDC's mark_price is 1
306        // so this is a no-op there; ETH/BTC scale by the spot rate). When
307        // mark_price is non-positive we cannot scale and report locked = 0
308        // rather than mix units.
309        let total_dec = collateral.amount;
310        let locked_dec = if collateral.mark_price > Decimal::ZERO {
311            (collateral.initial_margin.max(Decimal::ZERO) / collateral.mark_price)
312                .max(Decimal::ZERO)
313        } else {
314            Decimal::ZERO
315        };
316        // `from_total_and_locked` clamps `locked` into `[0, total]` and derives
317        // `free` in fixed-point so the `total == locked + free` invariant holds
318        // exactly at the currency precision, even when the venue's margin
319        // formula overshoots `amount` by sub-precision dust.
320        let balance = AccountBalance::from_total_and_locked(total_dec, locked_dec, currency)
321            .map_err(|e| {
322                anyhow::anyhow!(
323                    "failed to build collateral balance for {} (total={total_dec}, locked={locked_dec}): {e}",
324                    collateral.asset_name,
325                )
326            })?;
327        balances.push(balance);
328    }
329
330    let currency = Currency::get_or_create_crypto(subaccount.currency.as_str());
331    let initial = Money::from_decimal(subaccount.initial_margin, currency).with_context(|| {
332        format!(
333            "initial_margin {} cannot be represented at {} precision",
334            subaccount.initial_margin, currency,
335        )
336    })?;
337    let maintenance =
338        Money::from_decimal(subaccount.maintenance_margin, currency).with_context(|| {
339            format!(
340                "maintenance_margin {} cannot be represented at {} precision",
341                subaccount.maintenance_margin, currency,
342            )
343        })?;
344    let margins = vec![MarginBalance::new(initial, maintenance, None)];
345
346    Ok((balances, margins))
347}
348
349fn price_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Price> {
350    Price::from_decimal(value.normalize()).with_context(|| format!("invalid Derive {field}"))
351}
352
353fn quantity_from_decimal(value: Decimal, field: &str) -> anyhow::Result<Quantity> {
354    Quantity::from_decimal(value.normalize()).with_context(|| format!("invalid Derive {field}"))
355}
356
357fn ms_to_nanos(value: i64) -> UnixNanos {
358    let clamped = u64::try_from(value.max(0)).unwrap_or(0);
359    UnixNanos::from(clamped.saturating_mul(NANOSECONDS_IN_MILLISECOND))
360}
361
362#[cfg(test)]
363mod tests {
364    use nautilus_model::enums::{OrderSide, OrderStatus, OrderType, TimeInForce, TriggerType};
365    use rstest::rstest;
366    use rust_decimal_macros::dec;
367
368    use super::*;
369    use crate::{
370        common::{
371            enums::{
372                DeriveAssetType, DeriveInstrumentType, DeriveLiquidityRole, DeriveMarginType,
373                DeriveOrderCancelReason, DeriveOrderSide, DeriveOrderStatus, DeriveOrderType,
374                DeriveTimeInForce, DeriveTriggerPriceType, DeriveTriggerType, DeriveTxStatus,
375            },
376            parse::{
377                derive_status_to_nautilus, order_side_to_derive, order_type_to_derive,
378                time_in_force_to_derive,
379            },
380        },
381        http::models::DeriveCollateral,
382    };
383
384    fn sample_order() -> DeriveOrder {
385        DeriveOrder {
386            amount: dec!(10),
387            average_price: dec!(3500),
388            cancel_reason: DeriveOrderCancelReason::Empty,
389            creation_timestamp: 1_700_000_000_000,
390            direction: DeriveOrderSide::Buy,
391            filled_amount: dec!(4),
392            instrument_name: "ETH-PERP".into(),
393            is_transfer: false,
394            label: "STRATEGY-1-O-1".into(),
395            last_update_timestamp: 1_700_000_001_000,
396            limit_price: dec!(3500),
397            max_fee: dec!(1),
398            mmp: false,
399            nonce: 1,
400            order_fee: dec!(0),
401            order_id: "ord-1".to_string(),
402            order_status: DeriveOrderStatus::Open,
403            order_type: DeriveOrderType::Limit,
404            quote_id: None,
405            replaced_order_id: None,
406            signature: "0x00".to_string(),
407            signature_expiry_sec: 1_700_000_999,
408            signer: "0xsigner".into(),
409            subaccount_id: 30769,
410            time_in_force: DeriveTimeInForce::Gtc,
411            trigger_price: None,
412            trigger_price_type: None,
413            trigger_reject_message: None,
414            trigger_type: None,
415        }
416    }
417
418    fn sample_trade() -> DeriveTrade {
419        DeriveTrade {
420            direction: DeriveOrderSide::Sell,
421            index_price: dec!(3500),
422            instrument_name: "ETH-PERP".into(),
423            is_transfer: false,
424            label: "STRATEGY-1-O-2".into(),
425            liquidity_role: DeriveLiquidityRole::Taker,
426            mark_price: dec!(3500),
427            order_id: "ord-2".to_string(),
428            quote_id: None,
429            realized_pnl: dec!(0),
430            subaccount_id: 30769,
431            timestamp: 1_700_000_002_000,
432            trade_amount: dec!(2),
433            trade_fee: dec!(0.5),
434            trade_id: "tr-1".to_string(),
435            trade_price: dec!(3505),
436            tx_hash: Some("0xabc".to_string()),
437            tx_status: DeriveTxStatus::Settled,
438            wallet: Some("0xwallet".into()),
439        }
440    }
441
442    #[rstest]
443    fn test_order_side_round_trip() {
444        assert_eq!(
445            order_side_to_derive(OrderSide::Buy).unwrap(),
446            DeriveOrderSide::Buy,
447        );
448        assert_eq!(
449            order_side_to_derive(OrderSide::Sell).unwrap(),
450            DeriveOrderSide::Sell,
451        );
452        assert!(order_side_to_derive(OrderSide::NoOrderSide).is_err());
453    }
454
455    #[rstest]
456    fn test_order_type_rejects_unsupported() {
457        assert_eq!(
458            order_type_to_derive(OrderType::Limit).unwrap(),
459            DeriveOrderType::Limit,
460        );
461        assert_eq!(
462            order_type_to_derive(OrderType::Market).unwrap(),
463            DeriveOrderType::Market,
464        );
465        assert!(order_type_to_derive(OrderType::StopMarket).is_err());
466    }
467
468    #[rstest]
469    #[case(TimeInForce::Gtc, false, DeriveTimeInForce::Gtc)]
470    #[case(TimeInForce::Gtc, true, DeriveTimeInForce::PostOnly)]
471    #[case(TimeInForce::Ioc, false, DeriveTimeInForce::Ioc)]
472    #[case(TimeInForce::Fok, false, DeriveTimeInForce::Fok)]
473    fn test_time_in_force_maps_supported_values(
474        #[case] tif: TimeInForce,
475        #[case] post_only: bool,
476        #[case] expected: DeriveTimeInForce,
477    ) {
478        assert_eq!(time_in_force_to_derive(tif, post_only).unwrap(), expected);
479    }
480
481    #[rstest]
482    #[case(TimeInForce::Ioc)]
483    #[case(TimeInForce::Fok)]
484    fn test_time_in_force_rejects_post_only_immediate_values(#[case] tif: TimeInForce) {
485        let err = time_in_force_to_derive(tif, true)
486            .expect_err("post-only immediate TIF must be rejected");
487
488        assert!(
489            err.to_string()
490                .contains("post-only Derive orders only support GTC"),
491            "unexpected error: {err}",
492        );
493    }
494
495    #[rstest]
496    #[case(TimeInForce::Gtd, false)]
497    #[case(TimeInForce::Gtd, true)]
498    #[case(TimeInForce::Day, false)]
499    #[case(TimeInForce::Day, true)]
500    #[case(TimeInForce::AtTheOpen, false)]
501    #[case(TimeInForce::AtTheOpen, true)]
502    #[case(TimeInForce::AtTheClose, false)]
503    #[case(TimeInForce::AtTheClose, true)]
504    fn test_time_in_force_rejects_unsupported(#[case] tif: TimeInForce, #[case] post_only: bool) {
505        let err = time_in_force_to_derive(tif, post_only).expect_err("must reject unsupported TIF");
506
507        assert!(
508            err.to_string().contains("unsupported time in force"),
509            "unexpected error: {err}",
510        );
511    }
512
513    #[rstest]
514    fn test_derive_status_partial_fill_classification() {
515        assert_eq!(
516            derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(0), dec!(10)),
517            OrderStatus::Accepted,
518        );
519        assert_eq!(
520            derive_status_to_nautilus(DeriveOrderStatus::Open, dec!(4), dec!(10)),
521            OrderStatus::PartiallyFilled,
522        );
523        assert_eq!(
524            derive_status_to_nautilus(DeriveOrderStatus::Filled, dec!(10), dec!(10)),
525            OrderStatus::Filled,
526        );
527        assert_eq!(
528            derive_status_to_nautilus(DeriveOrderStatus::Cancelled, dec!(0), dec!(10)),
529            OrderStatus::Canceled,
530        );
531    }
532
533    #[rstest]
534    fn test_parse_order_report_assigns_partial_fill_status() {
535        let account_id = AccountId::new("DERIVE-001");
536        let report =
537            parse_derive_order_to_report(&sample_order(), account_id, UnixNanos::from(1)).unwrap();
538        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
539        assert_eq!(report.quantity, Quantity::from("10"));
540        assert_eq!(report.filled_qty, Quantity::from("4"));
541        assert_eq!(report.client_order_id.unwrap().as_str(), "STRATEGY-1-O-1");
542        assert_eq!(report.venue_order_id.as_str(), "ord-1");
543    }
544
545    #[rstest]
546    fn test_parse_order_report_normalizes_trailing_decimal_zeros() {
547        let mut order = sample_order();
548        order.amount = Decimal::from_str_exact("0.100000000000000000").unwrap();
549        order.filled_amount = Decimal::from_str_exact("0.000000000000000000").unwrap();
550        order.limit_price = Decimal::from_str_exact("0.100000000000000000").unwrap();
551        order.average_price = Decimal::ZERO;
552        order.order_status = DeriveOrderStatus::Cancelled;
553        let account_id = AccountId::new("DERIVE-001");
554
555        let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
556
557        assert_eq!(report.quantity, Quantity::from("0.1"));
558        assert_eq!(report.filled_qty, Quantity::from("0"));
559        assert_eq!(report.price, Some(Price::from("0.1")));
560    }
561
562    #[rstest]
563    fn test_parse_order_report_maps_untriggered_stop_market() {
564        let mut order = sample_order();
565        order.average_price = Decimal::ZERO;
566        order.filled_amount = Decimal::ZERO;
567        order.limit_price = dec!(3400);
568        order.order_status = DeriveOrderStatus::Untriggered;
569        order.order_type = DeriveOrderType::Market;
570        order.trigger_price = Some(dec!(3450));
571        order.trigger_price_type = Some(DeriveTriggerPriceType::Mark);
572        order.trigger_type = Some(DeriveTriggerType::Stoploss);
573        let account_id = AccountId::new("DERIVE-001");
574
575        let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
576
577        assert_eq!(report.order_type, OrderType::StopMarket);
578        assert_eq!(report.order_status, OrderStatus::Accepted);
579        assert_eq!(report.price, None);
580        assert_eq!(report.trigger_price, Some(Price::from("3450")));
581        assert_eq!(report.trigger_type, Some(TriggerType::MarkPrice));
582    }
583
584    #[rstest]
585    #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3600))]
586    #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3700))]
587    #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3800))]
588    #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3700))]
589    fn test_parse_order_report_maps_limit_if_touched_trigger(
590        #[case] direction: DeriveOrderSide,
591        #[case] limit_price: Decimal,
592        #[case] trigger_price: Decimal,
593    ) {
594        let mut order = sample_order();
595        order.average_price = Decimal::ZERO;
596        order.direction = direction;
597        order.filled_amount = Decimal::ZERO;
598        order.limit_price = limit_price;
599        order.order_status = DeriveOrderStatus::Untriggered;
600        order.order_type = DeriveOrderType::Limit;
601        order.trigger_price = Some(trigger_price);
602        order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
603        order.trigger_type = Some(DeriveTriggerType::Takeprofit);
604        let account_id = AccountId::new("DERIVE-001");
605
606        let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
607
608        assert_eq!(report.order_type, OrderType::LimitIfTouched);
609        assert_eq!(
610            report.price,
611            Some(Price::from_decimal(limit_price.normalize()).unwrap())
612        );
613        assert_eq!(
614            report.trigger_price,
615            Some(Price::from_decimal(trigger_price.normalize()).unwrap())
616        );
617        assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
618    }
619
620    #[rstest]
621    #[case(DeriveOrderSide::Buy, dec!(3700), dec!(3800))]
622    #[case(DeriveOrderSide::Sell, dec!(3700), dec!(3600))]
623    fn test_parse_order_report_maps_take_profit_limit_with_stop_shape(
624        #[case] direction: DeriveOrderSide,
625        #[case] limit_price: Decimal,
626        #[case] trigger_price: Decimal,
627    ) {
628        let mut order = sample_order();
629        order.average_price = Decimal::ZERO;
630        order.direction = direction;
631        order.filled_amount = Decimal::ZERO;
632        order.limit_price = limit_price;
633        order.order_status = DeriveOrderStatus::Untriggered;
634        order.order_type = DeriveOrderType::Limit;
635        order.trigger_price = Some(trigger_price);
636        order.trigger_price_type = Some(DeriveTriggerPriceType::Index);
637        order.trigger_type = Some(DeriveTriggerType::Takeprofit);
638        let account_id = AccountId::new("DERIVE-001");
639
640        let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
641
642        assert_eq!(report.order_type, OrderType::StopLimit);
643        assert_eq!(
644            report.price,
645            Some(Price::from_decimal(limit_price.normalize()).unwrap())
646        );
647        assert_eq!(
648            report.trigger_price,
649            Some(Price::from_decimal(trigger_price.normalize()).unwrap())
650        );
651        assert_eq!(report.trigger_type, Some(TriggerType::IndexPrice));
652    }
653
654    #[rstest]
655    fn test_parse_rejected_post_only_report_keeps_cross_market_reason() {
656        let mut order = sample_order();
657        order.cancel_reason = DeriveOrderCancelReason::PostOnlyCrossMarket;
658        order.order_status = DeriveOrderStatus::Rejected;
659        order.time_in_force = DeriveTimeInForce::PostOnly;
660        let account_id = AccountId::new("DERIVE-001");
661
662        let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
663
664        assert_eq!(report.order_status, OrderStatus::Rejected);
665        assert!(report.post_only);
666        assert_eq!(
667            report.cancel_reason.as_deref(),
668            Some("Post only order cannot cross the market")
669        );
670    }
671
672    #[rstest]
673    fn test_parse_rejected_trigger_report_uses_trigger_message() {
674        let mut order = sample_order();
675        order.cancel_reason = DeriveOrderCancelReason::TriggerFailed;
676        order.order_status = DeriveOrderStatus::Rejected;
677        order.trigger_reject_message = Some("trigger price moved through limit".to_string());
678        let account_id = AccountId::new("DERIVE-001");
679
680        let report = parse_derive_order_to_report(&order, account_id, UnixNanos::from(1)).unwrap();
681
682        assert_eq!(report.order_status, OrderStatus::Rejected);
683        assert_eq!(
684            report.cancel_reason.as_deref(),
685            Some("trigger price moved through limit")
686        );
687    }
688
689    #[rstest]
690    fn test_parse_trade_report_emits_taker_fill() {
691        let account_id = AccountId::new("DERIVE-001");
692        let usdc = Currency::USDC();
693        let report = parse_derive_trade_to_fill_report(
694            &sample_trade(),
695            account_id,
696            usdc,
697            UnixNanos::from(2),
698        )
699        .unwrap()
700        .unwrap();
701        assert_eq!(report.order_side, OrderSide::Sell);
702        assert_eq!(report.last_qty, Quantity::from("2"));
703        assert_eq!(report.last_px, Price::from("3505"));
704        assert_eq!(report.liquidity_side, LiquiditySide::Taker);
705        assert_eq!(report.commission.as_decimal(), dec!(0.5));
706    }
707
708    #[rstest]
709    fn test_parse_trade_report_skips_reverted_settlement() {
710        let mut trade = sample_trade();
711        trade.tx_status = DeriveTxStatus::Reverted;
712        let account_id = AccountId::new("DERIVE-001");
713        let usdc = Currency::USDC();
714        let report =
715            parse_derive_trade_to_fill_report(&trade, account_id, usdc, UnixNanos::from(2))
716                .unwrap();
717        assert!(report.is_none());
718    }
719
720    #[rstest]
721    fn test_parse_position_long_short_flat() {
722        let account_id = AccountId::new("DERIVE-001");
723
724        let mut long_pos = sample_position();
725        long_pos.amount = dec!(3);
726        let report =
727            parse_derive_position_to_report(&long_pos, account_id, UnixNanos::from(3)).unwrap();
728        assert_eq!(report.position_side, PositionSideSpecified::Long);
729        assert_eq!(report.quantity, Quantity::from("3"));
730
731        let mut short_pos = sample_position();
732        short_pos.amount = dec!(-2);
733        let report =
734            parse_derive_position_to_report(&short_pos, account_id, UnixNanos::from(3)).unwrap();
735        assert_eq!(report.position_side, PositionSideSpecified::Short);
736        assert_eq!(report.quantity, Quantity::from("2"));
737
738        let mut flat_pos = sample_position();
739        flat_pos.amount = dec!(0);
740        let report =
741            parse_derive_position_to_report(&flat_pos, account_id, UnixNanos::from(3)).unwrap();
742        assert_eq!(report.position_side, PositionSideSpecified::Flat);
743    }
744
745    fn sample_position() -> DerivePosition {
746        DerivePosition {
747            amount: dec!(0),
748            average_price: dec!(3500),
749            creation_timestamp: 0,
750            cumulative_funding: dec!(0),
751            delta: dec!(0),
752            gamma: dec!(0),
753            index_price: dec!(3500),
754            initial_margin: dec!(0),
755            instrument_name: "ETH-PERP".into(),
756            instrument_type: DeriveInstrumentType::Perp,
757            leverage: None,
758            liquidation_price: None,
759            maintenance_margin: dec!(0),
760            mark_price: dec!(3500),
761            mark_value: dec!(0),
762            net_settlements: dec!(0),
763            open_orders_margin: dec!(0),
764            pending_funding: dec!(0),
765            realized_pnl: dec!(0),
766            theta: dec!(0),
767            unrealized_pnl: dec!(0),
768            vega: dec!(0),
769        }
770    }
771
772    #[rstest]
773    fn test_parse_subaccount_emits_balances_and_margins() {
774        let subaccount = sample_subaccount();
775        let (balances, margins) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
776        assert_eq!(balances.len(), 1);
777        assert_eq!(balances[0].total.as_decimal(), dec!(1000));
778        assert_eq!(balances[0].locked.as_decimal(), dec!(100));
779        assert_eq!(balances[0].free.as_decimal(), dec!(900));
780        assert_eq!(margins.len(), 1);
781        assert_eq!(margins[0].initial.as_decimal(), dec!(100));
782        assert_eq!(margins[0].maintenance.as_decimal(), dec!(50));
783    }
784
785    #[rstest]
786    fn test_parse_subaccount_converts_non_usdc_locked_to_collateral_units() {
787        // 2.5 ETH collateral at $3500 mark with a $1000 USD margin requirement
788        // should report locked = 1000/3500 ETH (~0.2857), not "1000 ETH".
789        // Pre-fix code mixed units and reported locked as the raw 1000.
790        let mut subaccount = sample_subaccount();
791        subaccount.collaterals = vec![DeriveCollateral {
792            amount: dec!(2.5),
793            asset_name: "ETH".into(),
794            asset_type: DeriveAssetType::Erc20,
795            cumulative_interest: dec!(0),
796            currency: "ETH".into(),
797            initial_margin: dec!(1000),
798            maintenance_margin: dec!(500),
799            mark_price: dec!(3500),
800            mark_value: dec!(8750),
801            pending_interest: dec!(0),
802        }];
803
804        let (balances, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
805        assert_eq!(balances[0].total.as_decimal(), dec!(2.5));
806        // Locked is computed in collateral units (USD margin / mark_price).
807        let locked = balances[0].locked.as_decimal();
808        let expected_locked = dec!(1000) / dec!(3500);
809        assert!(
810            (locked - expected_locked).abs() < dec!(0.000001),
811            "locked {locked} should be near {expected_locked} ETH"
812        );
813        let free = balances[0].free.as_decimal();
814        let expected_free = dec!(2.5) - expected_locked;
815        assert!(
816            (free - expected_free).abs() < dec!(0.000001),
817            "free {free} should be near {expected_free} ETH"
818        );
819    }
820
821    #[rstest]
822    fn test_parse_subaccount_reports_zero_locked_when_mark_price_non_positive() {
823        // A zero mark_price (venue corner case during onboarding) cannot
824        // convert USD into collateral units; report locked = 0 rather than
825        // mix units or panic on divide-by-zero.
826        let mut subaccount = sample_subaccount();
827        subaccount.collaterals[0].mark_price = dec!(0);
828        subaccount.collaterals[0].initial_margin = dec!(50);
829
830        let (balances, _) = parse_derive_subaccount_to_balances(&subaccount).unwrap();
831        assert_eq!(balances[0].locked.as_decimal(), dec!(0));
832        assert_eq!(
833            balances[0].free.as_decimal(),
834            balances[0].total.as_decimal()
835        );
836    }
837
838    fn sample_subaccount() -> DeriveSubaccount {
839        DeriveSubaccount {
840            collaterals: vec![DeriveCollateral {
841                amount: dec!(1000),
842                asset_name: "USDC".into(),
843                asset_type: DeriveAssetType::Erc20,
844                cumulative_interest: dec!(0),
845                currency: "USDC".into(),
846                initial_margin: dec!(100),
847                maintenance_margin: dec!(50),
848                mark_price: dec!(1),
849                mark_value: dec!(1000),
850                pending_interest: dec!(0),
851            }],
852            collaterals_initial_margin: dec!(100),
853            collaterals_maintenance_margin: dec!(50),
854            collaterals_value: dec!(1000),
855            currency: "USDC".into(),
856            initial_margin: dec!(100),
857            is_under_liquidation: false,
858            label: None,
859            maintenance_margin: dec!(50),
860            margin_type: DeriveMarginType::Sm,
861            open_orders: vec![],
862            open_orders_margin: dec!(0),
863            positions: vec![],
864            positions_initial_margin: dec!(0),
865            positions_maintenance_margin: dec!(0),
866            positions_value: dec!(0),
867            subaccount_id: 30769,
868            subaccount_value: dec!(1000),
869        }
870    }
871}