1use anyhow::Context;
36use nautilus_core::UnixNanos;
37use nautilus_model::{
38 enums::{OrderSide, TimeInForce},
39 events::AccountState,
40 identifiers::{InstrumentId, Symbol, Venue},
41 instruments::{CryptoPerpetual, InstrumentAny},
42 types::Currency,
43};
44use rust_decimal::Decimal;
45
46use super::models::PerpetualMarket;
47#[cfg(test)]
48use crate::common::enums::DydxTransferType;
49use crate::{
50 common::{
51 enums::{DydxMarketStatus, DydxOrderExecution, DydxOrderType, DydxTimeInForce},
52 parse::{parse_decimal, parse_instrument_id, parse_price, parse_quantity},
53 },
54 websocket::messages::DydxSubaccountInfo,
55};
56
57pub fn validate_ticker_format(ticker: &str) -> anyhow::Result<()> {
64 let parts: Vec<&str> = ticker.split('-').collect();
65 if parts.len() != 2 {
66 anyhow::bail!("Invalid ticker format '{ticker}', expected 'BASE-QUOTE' (e.g., 'BTC-USD')");
67 }
68 if parts[0].is_empty() || parts[1].is_empty() {
69 anyhow::bail!("Invalid ticker format '{ticker}', base and quote cannot be empty");
70 }
71 Ok(())
72}
73
74pub fn parse_ticker_currencies(ticker: &str) -> anyhow::Result<(&str, &str)> {
81 validate_ticker_format(ticker)?;
82 let parts: Vec<&str> = ticker.split('-').collect();
83 Ok((parts[0], parts[1]))
84}
85
86#[must_use]
88pub const fn is_market_active(status: &DydxMarketStatus) -> bool {
89 matches!(status, DydxMarketStatus::Active)
90}
91
92pub fn calculate_time_in_force(
98 order_type: DydxOrderType,
99 base_tif: DydxTimeInForce,
100 post_only: bool,
101 execution: Option<DydxOrderExecution>,
102) -> anyhow::Result<TimeInForce> {
103 match order_type {
104 DydxOrderType::Market => Ok(TimeInForce::Ioc),
105 DydxOrderType::Limit if post_only => Ok(TimeInForce::Gtc), DydxOrderType::Limit => match base_tif {
107 DydxTimeInForce::Gtt => Ok(TimeInForce::Gtc),
108 DydxTimeInForce::Fok => Ok(TimeInForce::Fok),
109 DydxTimeInForce::Ioc => Ok(TimeInForce::Ioc),
110 },
111
112 DydxOrderType::StopLimit | DydxOrderType::TakeProfitLimit => match execution {
113 Some(DydxOrderExecution::PostOnly) => Ok(TimeInForce::Gtc), Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
115 Some(DydxOrderExecution::Ioc) => Ok(TimeInForce::Ioc),
116 Some(DydxOrderExecution::Default) | None => Ok(TimeInForce::Gtc), },
118
119 DydxOrderType::StopMarket | DydxOrderType::TakeProfitMarket => match execution {
120 Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
121 Some(DydxOrderExecution::Ioc | DydxOrderExecution::Default) | None => {
122 Ok(TimeInForce::Ioc)
123 }
124 Some(DydxOrderExecution::PostOnly) => {
125 anyhow::bail!("Execution PostOnly not supported for {order_type:?}")
126 }
127 },
128
129 DydxOrderType::TrailingStop => Ok(TimeInForce::Gtc),
130 }
131}
132
133pub fn validate_conditional_order(
144 order_type: DydxOrderType,
145 trigger_price: Option<Decimal>,
146 price: Decimal,
147 side: OrderSide,
148) -> anyhow::Result<()> {
149 if !order_type.is_conditional() {
150 return Ok(());
151 }
152
153 let trigger_price = trigger_price
154 .ok_or_else(|| anyhow::anyhow!("trigger_price required for {order_type:?}"))?;
155
156 match order_type {
158 DydxOrderType::StopLimit | DydxOrderType::StopMarket => {
159 match side {
161 OrderSide::Buy if trigger_price < price => {
162 anyhow::bail!(
163 "Stop buy trigger_price ({trigger_price}) must be >= limit price ({price})"
164 );
165 }
166 OrderSide::Sell if trigger_price > price => {
167 anyhow::bail!(
168 "Stop sell trigger_price ({trigger_price}) must be <= limit price ({price})"
169 );
170 }
171 _ => {}
172 }
173 }
174 DydxOrderType::TakeProfitLimit | DydxOrderType::TakeProfitMarket => {
175 match side {
177 OrderSide::Buy if trigger_price > price => {
178 anyhow::bail!(
179 "Take profit buy trigger_price ({trigger_price}) must be <= limit price ({price})"
180 );
181 }
182 OrderSide::Sell if trigger_price < price => {
183 anyhow::bail!(
184 "Take profit sell trigger_price ({trigger_price}) must be >= limit price ({price})"
185 );
186 }
187 _ => {}
188 }
189 }
190 _ => {}
191 }
192
193 Ok(())
194}
195
196pub fn parse_instrument_any(
213 definition: &PerpetualMarket,
214 maker_fee: Option<rust_decimal::Decimal>,
215 taker_fee: Option<rust_decimal::Decimal>,
216 ts_init: UnixNanos,
217) -> anyhow::Result<InstrumentAny> {
218 let instrument_id = parse_instrument_id(&definition.ticker);
220 let raw_symbol = Symbol::from(definition.ticker.as_str());
221
222 let (base_str, quote_str) = parse_ticker_currencies(&definition.ticker)
224 .context(format!("Failed to parse ticker '{}'", definition.ticker))?;
225
226 let base_currency = Currency::get_or_create_crypto_with_context(base_str, None);
227 let quote_currency = Currency::get_or_create_crypto_with_context(quote_str, None);
228 let settlement_currency = quote_currency; let price_increment =
232 parse_price(&definition.tick_size.to_string(), "tick_size").context(format!(
233 "Failed to parse tick_size '{}' for market '{}'",
234 definition.tick_size, definition.ticker
235 ))?;
236
237 let size_increment =
238 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
239 "Failed to parse step_size '{}' for market '{}'",
240 definition.step_size, definition.ticker
241 ))?;
242
243 let min_quantity = Some(if let Some(min_size) = &definition.min_order_size {
245 parse_quantity(&min_size.to_string(), "min_order_size").context(format!(
246 "Failed to parse min_order_size '{}' for market '{}'",
247 min_size, definition.ticker
248 ))?
249 } else {
250 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
252 "Failed to parse step_size as min_quantity for market '{}'",
253 definition.ticker
254 ))?
255 });
256
257 let margin_init = Some(
259 parse_decimal(
260 &definition.initial_margin_fraction.to_string(),
261 "initial_margin_fraction",
262 )
263 .context(format!(
264 "Failed to parse initial_margin_fraction '{}' for market '{}'",
265 definition.initial_margin_fraction, definition.ticker
266 ))?,
267 );
268
269 let margin_maint = Some(
270 parse_decimal(
271 &definition.maintenance_margin_fraction.to_string(),
272 "maintenance_margin_fraction",
273 )
274 .context(format!(
275 "Failed to parse maintenance_margin_fraction '{}' for market '{}'",
276 definition.maintenance_margin_fraction, definition.ticker
277 ))?,
278 );
279
280 let instrument = CryptoPerpetual::new(
282 instrument_id,
283 raw_symbol,
284 base_currency,
285 quote_currency,
286 settlement_currency,
287 false, price_increment.precision,
289 size_increment.precision,
290 price_increment,
291 size_increment,
292 None, Some(size_increment), None, min_quantity,
296 None, None, None, None, margin_init,
301 margin_maint,
302 maker_fee,
303 taker_fee,
304 ts_init,
305 ts_init,
306 );
307
308 Ok(InstrumentAny::CryptoPerpetual(instrument))
309}
310
311#[cfg(test)]
312mod tests {
313 use std::str::FromStr;
314
315 use chrono::Utc;
316 use nautilus_model::{enums::OrderSide, instruments::Instrument};
317 use rstest::rstest;
318 use rust_decimal::Decimal;
319 use rust_decimal_macros::dec;
320
321 use super::*;
322 use crate::{
323 common::{
324 enums::{DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce},
325 testing::load_json_result_fixture,
326 },
327 http::models::{
328 CandlesResponse, FillsResponse, MarketsResponse, Order, OrderbookResponse,
329 SubaccountResponse, TradesResponse, TransfersResponse,
330 },
331 };
332
333 fn create_test_market() -> PerpetualMarket {
334 PerpetualMarket {
335 clob_pair_id: 1,
336 ticker: "BTC-USD".to_string(),
337 status: DydxMarketStatus::Active,
338 base_asset: Some("BTC".to_string()),
339 quote_asset: Some("USD".to_string()),
340 step_size: Decimal::from_str("0.001").unwrap(),
341 tick_size: Decimal::from_str("1").unwrap(),
342 index_price: Some(Decimal::from_str("50000").unwrap()),
343 oracle_price: Decimal::from_str("50000").unwrap(),
344 price_change_24h: Decimal::ZERO,
345 next_funding_rate: Decimal::ZERO,
346 next_funding_at: Some(Utc::now()),
347 min_order_size: Some(Decimal::from_str("0.001").unwrap()),
348 market_type: Some(DydxTickerType::Perpetual),
349 initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
350 maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
351 base_position_notional: Some(Decimal::from_str("10000").unwrap()),
352 incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
353 incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
354 max_position_size: Some(Decimal::from_str("100").unwrap()),
355 open_interest: Decimal::from_str("1000000").unwrap(),
356 atomic_resolution: -10,
357 quantum_conversion_exponent: -10,
358 subticks_per_tick: 100,
359 step_base_quantums: 1000,
360 is_reduce_only: false,
361 }
362 }
363
364 #[rstest]
365 fn test_parse_instrument_any_valid() {
366 let market = create_test_market();
367 let maker_fee = Some(Decimal::from_str("0.0002").unwrap());
368 let taker_fee = Some(Decimal::from_str("0.0005").unwrap());
369 let ts_init = UnixNanos::default();
370
371 let result = parse_instrument_any(&market, maker_fee, taker_fee, ts_init);
372 assert!(result.is_ok());
373
374 let instrument = result.unwrap();
375 if let InstrumentAny::CryptoPerpetual(perp) = instrument {
376 assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
377 assert_eq!(perp.base_currency.code.as_str(), "BTC");
378 assert_eq!(perp.quote_currency.code.as_str(), "USD");
379 assert!(!perp.is_inverse);
380 assert_eq!(perp.price_increment.to_string(), "1");
381 assert_eq!(perp.size_increment.to_string(), "0.001");
382 } else {
383 panic!("Expected CryptoPerpetual instrument");
384 }
385 }
386
387 #[rstest]
388 fn test_is_market_active() {
389 assert!(is_market_active(&DydxMarketStatus::Active));
390 assert!(!is_market_active(&DydxMarketStatus::Paused));
391 assert!(!is_market_active(&DydxMarketStatus::CancelOnly));
392 assert!(!is_market_active(&DydxMarketStatus::PostOnly));
393 assert!(!is_market_active(&DydxMarketStatus::Initializing));
394 assert!(!is_market_active(&DydxMarketStatus::FinalSettlement));
395 }
396
397 #[rstest]
398 fn test_parse_instrument_any_invalid_ticker() {
399 let mut market = create_test_market();
400 market.ticker = "INVALID".to_string();
401
402 let result = parse_instrument_any(&market, None, None, UnixNanos::default());
403 assert!(result.is_err());
404 let error_msg = result.unwrap_err().to_string();
405 assert!(
407 error_msg.contains("Invalid ticker format")
408 || error_msg.contains("Failed to parse ticker"),
409 "Expected ticker format error, was: {error_msg}"
410 );
411 }
412
413 #[rstest]
414 fn test_validate_ticker_format_valid() {
415 assert!(validate_ticker_format("BTC-USD").is_ok());
416 assert!(validate_ticker_format("ETH-USD").is_ok());
417 assert!(validate_ticker_format("ATOM-USD").is_ok());
418 }
419
420 #[rstest]
421 fn test_validate_ticker_format_invalid() {
422 assert!(validate_ticker_format("BTCUSD").is_err());
424
425 assert!(validate_ticker_format("BTC-USD-PERP").is_err());
427
428 assert!(validate_ticker_format("-USD").is_err());
430
431 assert!(validate_ticker_format("BTC-").is_err());
433
434 assert!(validate_ticker_format("-").is_err());
436 }
437
438 #[rstest]
439 fn test_parse_ticker_currencies_valid() {
440 let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
441 assert_eq!(base, "BTC");
442 assert_eq!(quote, "USD");
443
444 let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
445 assert_eq!(base, "ETH");
446 assert_eq!(quote, "USDC");
447 }
448
449 #[rstest]
450 fn test_parse_ticker_currencies_invalid() {
451 assert!(parse_ticker_currencies("INVALID").is_err());
452 assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
453 }
454
455 #[rstest]
456 fn test_validate_stop_limit_buy_valid() {
457 let result = validate_conditional_order(
458 DydxOrderType::StopLimit,
459 Some(dec!(51000)), dec!(50000), OrderSide::Buy,
462 );
463 assert!(result.is_ok());
464 }
465
466 #[rstest]
467 fn test_validate_stop_limit_buy_invalid() {
468 let result = validate_conditional_order(
470 DydxOrderType::StopLimit,
471 Some(dec!(49000)),
472 dec!(50000),
473 OrderSide::Buy,
474 );
475 assert!(result.is_err());
476 assert!(
477 result
478 .unwrap_err()
479 .to_string()
480 .contains("must be >= limit price")
481 );
482 }
483
484 #[rstest]
485 fn test_validate_stop_limit_sell_valid() {
486 let result = validate_conditional_order(
487 DydxOrderType::StopLimit,
488 Some(dec!(49000)), dec!(50000), OrderSide::Sell,
491 );
492 assert!(result.is_ok());
493 }
494
495 #[rstest]
496 fn test_validate_stop_limit_sell_invalid() {
497 let result = validate_conditional_order(
499 DydxOrderType::StopLimit,
500 Some(dec!(51000)),
501 dec!(50000),
502 OrderSide::Sell,
503 );
504 assert!(result.is_err());
505 assert!(
506 result
507 .unwrap_err()
508 .to_string()
509 .contains("must be <= limit price")
510 );
511 }
512
513 #[rstest]
514 fn test_validate_take_profit_sell_valid() {
515 let result = validate_conditional_order(
516 DydxOrderType::TakeProfitLimit,
517 Some(dec!(51000)), dec!(50000), OrderSide::Sell,
520 );
521 assert!(result.is_ok());
522 }
523
524 #[rstest]
525 fn test_validate_take_profit_buy_valid() {
526 let result = validate_conditional_order(
527 DydxOrderType::TakeProfitLimit,
528 Some(dec!(49000)), dec!(50000), OrderSide::Buy,
531 );
532 assert!(result.is_ok());
533 }
534
535 #[rstest]
536 fn test_validate_missing_trigger_price() {
537 let result =
538 validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
539 assert!(result.is_err());
540 assert!(
541 result
542 .unwrap_err()
543 .to_string()
544 .contains("trigger_price required")
545 );
546 }
547
548 #[rstest]
549 fn test_validate_non_conditional_order() {
550 let result =
552 validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
553 assert!(result.is_ok());
554 }
555
556 #[rstest]
557 fn test_calculate_tif_market() {
558 let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
559 .unwrap();
560 assert_eq!(tif, TimeInForce::Ioc);
561 }
562
563 #[rstest]
564 fn test_calculate_tif_limit_post_only() {
565 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
566 .unwrap();
567 assert_eq!(tif, TimeInForce::Gtc); }
569
570 #[rstest]
571 fn test_calculate_tif_limit_gtc() {
572 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
573 .unwrap();
574 assert_eq!(tif, TimeInForce::Gtc);
575 }
576
577 #[rstest]
578 fn test_calculate_tif_stop_market_ioc() {
579 let tif = calculate_time_in_force(
580 DydxOrderType::StopMarket,
581 DydxTimeInForce::Gtt,
582 false,
583 Some(DydxOrderExecution::Ioc),
584 )
585 .unwrap();
586 assert_eq!(tif, TimeInForce::Ioc);
587 }
588
589 #[rstest]
590 fn test_calculate_tif_stop_limit_post_only() {
591 let tif = calculate_time_in_force(
592 DydxOrderType::StopLimit,
593 DydxTimeInForce::Gtt,
594 false,
595 Some(DydxOrderExecution::PostOnly),
596 )
597 .unwrap();
598 assert_eq!(tif, TimeInForce::Gtc); }
600
601 #[rstest]
602 fn test_calculate_tif_stop_limit_gtc() {
603 let tif =
604 calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
605 .unwrap();
606 assert_eq!(tif, TimeInForce::Gtc);
607 }
608
609 #[rstest]
610 fn test_calculate_tif_stop_market_invalid_post_only() {
611 let result = calculate_time_in_force(
612 DydxOrderType::StopMarket,
613 DydxTimeInForce::Gtt,
614 false,
615 Some(DydxOrderExecution::PostOnly),
616 );
617 assert!(result.is_err());
618 assert!(
619 result
620 .unwrap_err()
621 .to_string()
622 .contains("PostOnly not supported")
623 );
624 }
625
626 #[rstest]
627 fn test_calculate_tif_trailing_stop() {
628 let tif = calculate_time_in_force(
629 DydxOrderType::TrailingStop,
630 DydxTimeInForce::Gtt,
631 false,
632 None,
633 )
634 .unwrap();
635 assert_eq!(tif, TimeInForce::Gtc);
636 }
637
638 #[rstest]
639 fn test_parse_perpetual_markets() {
640 let json = load_json_result_fixture("http_get_perpetual_markets.json");
641 let response: MarketsResponse =
642 serde_json::from_value(json).expect("Failed to parse markets");
643
644 assert_eq!(response.markets.len(), 3);
645 assert!(response.markets.contains_key("BTC-USD"));
646 assert!(response.markets.contains_key("ETH-USD"));
647 assert!(response.markets.contains_key("SOL-USD"));
648
649 let btc = response.markets.get("BTC-USD").unwrap();
650 assert_eq!(btc.ticker, "BTC-USD");
651 assert_eq!(btc.clob_pair_id, 0);
652 assert_eq!(btc.atomic_resolution, -10);
653 }
654
655 #[rstest]
656 fn test_parse_instrument_from_market() {
657 let json = load_json_result_fixture("http_get_perpetual_markets.json");
658 let response: MarketsResponse =
659 serde_json::from_value(json).expect("Failed to parse markets");
660 let btc = response.markets.get("BTC-USD").unwrap();
661
662 let ts_init = UnixNanos::default();
663 let instrument =
664 parse_instrument_any(btc, None, None, ts_init).expect("Failed to parse instrument");
665
666 assert_eq!(instrument.id().symbol.as_str(), "BTC-USD-PERP");
667 assert_eq!(instrument.id().venue.as_str(), "DYDX");
668 }
669
670 #[rstest]
671 fn test_parse_orderbook_response() {
672 let json = load_json_result_fixture("http_get_orderbook.json");
673 let response: OrderbookResponse =
674 serde_json::from_value(json).expect("Failed to parse orderbook");
675
676 assert_eq!(response.bids.len(), 5);
677 assert_eq!(response.asks.len(), 5);
678
679 let best_bid = &response.bids[0];
680 assert_eq!(best_bid.price.to_string(), "89947");
681 assert_eq!(best_bid.size.to_string(), "0.0002");
682
683 let best_ask = &response.asks[0];
684 assert_eq!(best_ask.price.to_string(), "89958");
685 assert_eq!(best_ask.size.to_string(), "0.1177");
686 }
687
688 #[rstest]
689 fn test_parse_trades_response() {
690 let json = load_json_result_fixture("http_get_trades.json");
691 let response: TradesResponse =
692 serde_json::from_value(json).expect("Failed to parse trades");
693
694 assert_eq!(response.trades.len(), 3);
695
696 let first_trade = &response.trades[0];
697 assert_eq!(first_trade.id, "03f89a550000000200000002");
698 assert_eq!(first_trade.side, OrderSide::Buy);
699 assert_eq!(first_trade.price.to_string(), "89942");
700 assert_eq!(first_trade.size.to_string(), "0.0001");
701 }
702
703 #[rstest]
704 fn test_parse_candles_response() {
705 let json = load_json_result_fixture("http_get_candles.json");
706 let response: CandlesResponse =
707 serde_json::from_value(json).expect("Failed to parse candles");
708
709 assert_eq!(response.candles.len(), 3);
710
711 let first_candle = &response.candles[0];
712 assert_eq!(first_candle.ticker, "BTC-USD");
713 assert_eq!(first_candle.open.to_string(), "89934");
714 assert_eq!(first_candle.high.to_string(), "89970");
715 assert_eq!(first_candle.low.to_string(), "89911");
716 assert_eq!(first_candle.close.to_string(), "89941");
717 }
718
719 #[rstest]
720 fn test_parse_subaccount_response() {
721 let json = load_json_result_fixture("http_get_subaccount.json");
722 let response: SubaccountResponse =
723 serde_json::from_value(json).expect("Failed to parse subaccount");
724
725 let subaccount = &response.subaccount;
726 assert_eq!(subaccount.subaccount_number, 0);
727 assert_eq!(subaccount.equity.to_string(), "45.201296");
728 assert_eq!(subaccount.free_collateral.to_string(), "45.201296");
729 assert!(subaccount.margin_enabled);
730 assert_eq!(subaccount.open_perpetual_positions.len(), 0);
731 }
732
733 #[rstest]
734 fn test_parse_orders_response() {
735 let json = load_json_result_fixture("http_get_orders.json");
736 let response: Vec<Order> = serde_json::from_value(json).expect("Failed to parse orders");
737
738 assert_eq!(response.len(), 3);
739
740 let first_order = &response[0];
741 assert_eq!(first_order.id, "0f0981cb-152e-57d3-bea9-4d8e0dd5ed35");
742 assert_eq!(first_order.side, OrderSide::Buy);
743 assert_eq!(first_order.order_type, DydxOrderType::Limit);
744 assert!(first_order.reduce_only);
745
746 let second_order = &response[1];
747 assert_eq!(second_order.side, OrderSide::Sell);
748 assert!(!second_order.reduce_only);
749 }
750
751 #[rstest]
752 fn test_parse_fills_response() {
753 let json = load_json_result_fixture("http_get_fills.json");
754 let response: FillsResponse = serde_json::from_value(json).expect("Failed to parse fills");
755
756 assert_eq!(response.fills.len(), 3);
757
758 let first_fill = &response.fills[0];
759 assert_eq!(first_fill.id, "6450e369-1dc3-5229-8dc2-fb3b5d1cf2ab");
760 assert_eq!(first_fill.side, OrderSide::Buy);
761 assert_eq!(first_fill.market, "BTC-USD");
762 assert_eq!(first_fill.price.to_string(), "105117");
763 }
764
765 #[rstest]
766 fn test_parse_transfers_response() {
767 let json = load_json_result_fixture("http_get_transfers.json");
768 let response: TransfersResponse =
769 serde_json::from_value(json).expect("Failed to parse transfers");
770
771 assert_eq!(response.transfers.len(), 1);
772
773 let deposit = &response.transfers[0];
774 assert_eq!(deposit.transfer_type, DydxTransferType::Deposit);
775 assert_eq!(deposit.asset, "USDC");
776 assert_eq!(deposit.amount.to_string(), "45.334703");
777 }
778
779 #[rstest]
780 fn test_transfer_type_enum_serde() {
781 let test_cases = vec![
783 (DydxTransferType::Deposit, "\"DEPOSIT\""),
784 (DydxTransferType::Withdrawal, "\"WITHDRAWAL\""),
785 (DydxTransferType::TransferIn, "\"TRANSFER_IN\""),
786 (DydxTransferType::TransferOut, "\"TRANSFER_OUT\""),
787 ];
788
789 for (variant, expected_json) in test_cases {
790 let serialized = serde_json::to_string(&variant).expect("Failed to serialize");
792 assert_eq!(
793 serialized, expected_json,
794 "Serialization failed for {variant:?}"
795 );
796
797 let deserialized: DydxTransferType =
799 serde_json::from_str(&serialized).expect("Failed to deserialize");
800 assert_eq!(
801 deserialized, variant,
802 "Deserialization failed for {variant:?}"
803 );
804 }
805 }
806}
807
808use std::str::FromStr;
813
814use nautilus_core::UUID4;
815use nautilus_model::{
816 enums::{LiquiditySide, OrderStatus, PositionSide, TriggerType},
817 identifiers::{AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId},
818 instruments::Instrument,
819 reports::{FillReport, OrderStatusReport, PositionStatusReport},
820 types::{Money, Price, Quantity},
821};
822use rust_decimal::prelude::ToPrimitive;
823
824use super::models::{Fill, Order, PerpetualPosition};
825use crate::common::enums::{DydxLiquidity, DydxOrderStatus};
826
827fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
829 match status {
830 DydxOrderStatus::Open => OrderStatus::Accepted,
831 DydxOrderStatus::Filled => OrderStatus::Filled,
832 DydxOrderStatus::Canceled => OrderStatus::Canceled,
833 DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
834 DydxOrderStatus::Untriggered => OrderStatus::Accepted, DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
836 DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
837 }
838}
839
840pub fn parse_order_status_report(
846 order: &Order,
847 instrument: &InstrumentAny,
848 account_id: AccountId,
849 ts_init: UnixNanos,
850) -> anyhow::Result<OrderStatusReport> {
851 let instrument_id = instrument.id();
852 let venue_order_id = VenueOrderId::new(&order.id);
853 let client_order_id = if order.client_id.is_empty() {
854 None
855 } else {
856 Some(ClientOrderId::new(&order.client_id))
857 };
858
859 let order_type = order.order_type.into();
861
862 let execution = order.execution.or({
863 if order.post_only {
865 Some(DydxOrderExecution::PostOnly)
866 } else {
867 Some(DydxOrderExecution::Default)
868 }
869 });
870 let time_in_force = calculate_time_in_force(
871 order.order_type,
872 order.time_in_force,
873 order.reduce_only,
874 execution,
875 )?;
876
877 let order_side = order.side;
878 let order_status = parse_order_status(&order.status);
879
880 let size_precision = instrument.size_precision();
882 let quantity = Quantity::new(
883 order
884 .size
885 .to_f64()
886 .context("failed to convert order size to f64")?,
887 size_precision,
888 );
889 let filled_qty = Quantity::new(
890 order
891 .total_filled
892 .to_f64()
893 .context("failed to convert total_filled to f64")?,
894 size_precision,
895 );
896
897 let price_precision = instrument.price_precision();
899 let price = Price::new(
900 order
901 .price
902 .to_f64()
903 .context("failed to convert order price to f64")?,
904 price_precision,
905 );
906
907 let ts_accepted = order.good_til_block_time.map_or(ts_init, |dt| {
909 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
910 });
911 let ts_last = order.updated_at.map_or(ts_init, |dt| {
912 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
913 });
914
915 let mut report = OrderStatusReport::new(
917 account_id,
918 instrument_id,
919 client_order_id,
920 venue_order_id,
921 order_side,
922 order_type,
923 time_in_force,
924 order_status,
925 quantity,
926 filled_qty,
927 ts_accepted,
928 ts_last,
929 ts_init,
930 Some(UUID4::new()),
931 );
932
933 report = report.with_price(price);
935
936 if let Some(trigger_price_dec) = order.trigger_price {
938 let trigger_price = Price::new(
939 trigger_price_dec
940 .to_f64()
941 .context("failed to convert trigger_price to f64")?,
942 instrument.price_precision(),
943 );
944 report = report.with_trigger_price(trigger_price);
945
946 if let Some(condition_type) = order.condition_type {
948 let trigger_type = match condition_type {
949 crate::common::enums::DydxConditionType::StopLoss => TriggerType::LastPrice,
950 crate::common::enums::DydxConditionType::TakeProfit => TriggerType::LastPrice,
951 crate::common::enums::DydxConditionType::Unspecified => TriggerType::Default,
952 };
953 report = report.with_trigger_type(trigger_type);
954 }
955 }
956
957 Ok(report)
958}
959
960pub fn parse_fill_report(
966 fill: &Fill,
967 instrument: &InstrumentAny,
968 account_id: AccountId,
969 ts_init: UnixNanos,
970) -> anyhow::Result<FillReport> {
971 let instrument_id = instrument.id();
972 let venue_order_id = VenueOrderId::new(&fill.order_id);
973
974 let trade_id = TradeId::new(&fill.id);
976
977 let order_side = fill.side;
978
979 let size_precision = instrument.size_precision();
981 let price_precision = instrument.price_precision();
982
983 let last_qty = Quantity::new(
984 fill.size
985 .to_f64()
986 .context("failed to convert fill size to f64")?,
987 size_precision,
988 );
989 let last_px = Price::new(
990 fill.price
991 .to_f64()
992 .context("failed to convert fill price to f64")?,
993 price_precision,
994 );
995
996 let commission = Money::new(
1003 -fill.fee.to_f64().context("failed to convert fee to f64")?,
1004 instrument.quote_currency(),
1005 );
1006
1007 let liquidity_side = match fill.liquidity {
1009 DydxLiquidity::Maker => LiquiditySide::Maker,
1010 DydxLiquidity::Taker => LiquiditySide::Taker,
1011 };
1012
1013 let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
1015
1016 let report = FillReport::new(
1017 account_id,
1018 instrument_id,
1019 venue_order_id,
1020 trade_id,
1021 order_side,
1022 last_qty,
1023 last_px,
1024 commission,
1025 liquidity_side,
1026 None, None, ts_event,
1029 ts_init,
1030 Some(UUID4::new()),
1031 );
1032
1033 Ok(report)
1034}
1035
1036pub fn parse_position_status_report(
1042 position: &PerpetualPosition,
1043 instrument: &InstrumentAny,
1044 account_id: AccountId,
1045 ts_init: UnixNanos,
1046) -> anyhow::Result<PositionStatusReport> {
1047 let instrument_id = instrument.id();
1048
1049 let position_side = if position.size.is_zero() {
1051 PositionSide::Flat
1052 } else if position.size.is_sign_positive() {
1053 PositionSide::Long
1054 } else {
1055 PositionSide::Short
1056 };
1057
1058 let quantity = Quantity::new(
1060 position
1061 .size
1062 .abs()
1063 .to_f64()
1064 .context("failed to convert position size to f64")?,
1065 instrument.size_precision(),
1066 );
1067
1068 let avg_px_open = position.entry_price;
1070
1071 let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1073
1074 let venue_position_id = Some(PositionId::new(format!(
1076 "{}_{}",
1077 account_id, position.market
1078 )));
1079
1080 Ok(PositionStatusReport::new(
1081 account_id,
1082 instrument_id,
1083 position_side.as_specified(),
1084 quantity,
1085 ts_last,
1086 ts_init,
1087 Some(UUID4::new()),
1088 venue_position_id,
1089 Some(avg_px_open),
1090 ))
1091}
1092
1093pub fn parse_account_state(
1108 subaccount: &DydxSubaccountInfo,
1109 account_id: AccountId,
1110 instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1111 oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1112 ts_event: UnixNanos,
1113 ts_init: UnixNanos,
1114) -> anyhow::Result<AccountState> {
1115 use std::collections::HashMap;
1116
1117 use nautilus_model::{
1118 enums::AccountType,
1119 events::AccountState,
1120 types::{AccountBalance, MarginBalance},
1121 };
1122
1123 let mut balances = Vec::new();
1124
1125 let equity_f64 = subaccount.equity.parse::<f64>().context(format!(
1127 "Failed to parse equity '{}' as f64",
1128 subaccount.equity
1129 ))?;
1130
1131 let free_collateral_f64 = subaccount.free_collateral.parse::<f64>().context(format!(
1132 "Failed to parse freeCollateral '{}' as f64",
1133 subaccount.free_collateral
1134 ))?;
1135
1136 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1138
1139 let total = Money::new(equity_f64, currency);
1140 let free = Money::new(free_collateral_f64, currency);
1141 let locked = total - free;
1142
1143 let balance = AccountBalance::new_checked(total, locked, free)
1144 .context("Failed to create AccountBalance from subaccount data")?;
1145 balances.push(balance);
1146
1147 let mut margins = Vec::new();
1149 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1150 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1151
1152 if let Some(ref positions) = subaccount.open_perpetual_positions {
1153 for position in positions.values() {
1154 let market_str = position.market.as_str();
1156 let instrument_id = parse_instrument_id(market_str);
1157
1158 let instrument = match instruments.get(&instrument_id) {
1160 Some(inst) => inst,
1161 None => {
1162 tracing::warn!(
1163 "Cannot calculate margin for position {}: instrument not found",
1164 market_str
1165 );
1166 continue;
1167 }
1168 };
1169
1170 let (margin_init, margin_maint) = match instrument {
1172 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1173 _ => {
1174 tracing::warn!(
1175 "Instrument {} is not a CryptoPerpetual, skipping margin calculation",
1176 instrument_id
1177 );
1178 continue;
1179 }
1180 };
1181
1182 let position_size = match Decimal::from_str(&position.size) {
1184 Ok(size) => size.abs(),
1185 Err(e) => {
1186 tracing::warn!(
1187 "Failed to parse position size '{}' for {}: {}",
1188 position.size,
1189 market_str,
1190 e
1191 );
1192 continue;
1193 }
1194 };
1195
1196 if position_size.is_zero() {
1198 continue;
1199 }
1200
1201 let oracle_price = oracle_prices
1203 .get(&instrument_id)
1204 .copied()
1205 .or_else(|| Decimal::from_str(&position.entry_price).ok())
1206 .unwrap_or(Decimal::ZERO);
1207
1208 if oracle_price.is_zero() {
1209 tracing::warn!(
1210 "No valid price for position {}, skipping margin calculation",
1211 market_str
1212 );
1213 continue;
1214 }
1215
1216 let initial_margin = margin_init * position_size * oracle_price;
1218
1219 let maintenance_margin = margin_maint * position_size * oracle_price;
1220
1221 let quote_currency = instrument.quote_currency();
1223 *initial_margins
1224 .entry(quote_currency)
1225 .or_insert(Decimal::ZERO) += initial_margin;
1226 *maintenance_margins
1227 .entry(quote_currency)
1228 .or_insert(Decimal::ZERO) += maintenance_margin;
1229 }
1230 }
1231
1232 for (currency, initial_margin) in initial_margins {
1234 let maintenance_margin = maintenance_margins
1235 .get(¤cy)
1236 .copied()
1237 .unwrap_or(Decimal::ZERO);
1238
1239 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1240 "Failed to create initial margin Money for {currency}"
1241 ))?;
1242 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1243 format!("Failed to create maintenance margin Money for {currency}"),
1244 )?;
1245
1246 let margin_instrument_id = InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("DYDX"));
1249
1250 let margin_balance =
1251 MarginBalance::new(initial_money, maintenance_money, margin_instrument_id);
1252 margins.push(margin_balance);
1253 }
1254
1255 Ok(AccountState::new(
1256 account_id,
1257 AccountType::Margin, balances,
1259 margins,
1260 true, UUID4::new(),
1262 ts_event,
1263 ts_init,
1264 None, ))
1266}
1267
1268#[cfg(test)]
1269mod reconciliation_tests {
1270 use chrono::Utc;
1271 use nautilus_model::{
1272 enums::{OrderSide, OrderStatus, TimeInForce},
1273 identifiers::{AccountId, InstrumentId, Symbol, Venue},
1274 instruments::{CryptoPerpetual, Instrument},
1275 types::Currency,
1276 };
1277 use rstest::rstest;
1278 use rust_decimal_macros::dec;
1279
1280 use super::*;
1281
1282 fn create_test_instrument() -> InstrumentAny {
1283 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), Venue::new("DYDX"));
1284
1285 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1286 instrument_id,
1287 instrument_id.symbol,
1288 Currency::BTC(),
1289 Currency::USD(),
1290 Currency::USD(),
1291 false,
1292 2, 8, Price::new(0.01, 2), Quantity::new(0.001, 8), Some(Quantity::new(1.0, 0)), Some(Quantity::new(0.001, 8)), Some(Quantity::new(100000.0, 8)), Some(Quantity::new(0.001, 8)), None, None, Some(Price::new(1000000.0, 2)), Some(Price::new(0.01, 2)), Some(dec!(0.05)), Some(dec!(0.03)), Some(dec!(0.0002)), Some(dec!(0.0005)), UnixNanos::default(), UnixNanos::default(), ))
1311 }
1312
1313 #[rstest]
1314 fn test_parse_order_status() {
1315 assert_eq!(
1316 parse_order_status(&DydxOrderStatus::Open),
1317 OrderStatus::Accepted
1318 );
1319 assert_eq!(
1320 parse_order_status(&DydxOrderStatus::Filled),
1321 OrderStatus::Filled
1322 );
1323 assert_eq!(
1324 parse_order_status(&DydxOrderStatus::Canceled),
1325 OrderStatus::Canceled
1326 );
1327 assert_eq!(
1328 parse_order_status(&DydxOrderStatus::PartiallyFilled),
1329 OrderStatus::PartiallyFilled
1330 );
1331 assert_eq!(
1332 parse_order_status(&DydxOrderStatus::Untriggered),
1333 OrderStatus::Accepted
1334 );
1335 }
1336
1337 #[rstest]
1338 fn test_parse_order_status_report_basic() {
1339 let instrument = create_test_instrument();
1340 let account_id = AccountId::new("DYDX-001");
1341 let ts_init = UnixNanos::default();
1342
1343 let order = Order {
1344 id: "order123".to_string(),
1345 subaccount_id: "subacct1".to_string(),
1346 client_id: "client1".to_string(),
1347 clob_pair_id: 1,
1348 side: OrderSide::Buy,
1349 size: dec!(1.5),
1350 total_filled: dec!(1.0),
1351 price: dec!(50000.0),
1352 status: DydxOrderStatus::PartiallyFilled,
1353 order_type: DydxOrderType::Limit,
1354 time_in_force: DydxTimeInForce::Gtt,
1355 reduce_only: false,
1356 post_only: false,
1357 order_flags: 0,
1358 good_til_block: None,
1359 good_til_block_time: Some(Utc::now()),
1360 created_at_height: Some(1000),
1361 client_metadata: 0,
1362 trigger_price: None,
1363 condition_type: None,
1364 conditional_order_trigger_subticks: None,
1365 execution: None,
1366 updated_at: Some(Utc::now()),
1367 updated_at_height: Some(1001),
1368 ticker: None,
1369 subaccount_number: 0,
1370 order_router_address: None,
1371 };
1372
1373 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1374 if let Err(ref e) = result {
1375 eprintln!("Parse error: {e:?}");
1376 }
1377 assert!(result.is_ok());
1378
1379 let report = result.unwrap();
1380 assert_eq!(report.account_id, account_id);
1381 assert_eq!(report.instrument_id, instrument.id());
1382 assert_eq!(report.order_side, OrderSide::Buy);
1383 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1384 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1385 }
1386
1387 #[rstest]
1388 fn test_parse_order_status_report_conditional() {
1389 let instrument = create_test_instrument();
1390 let account_id = AccountId::new("DYDX-001");
1391 let ts_init = UnixNanos::default();
1392
1393 let order = Order {
1394 id: "order456".to_string(),
1395 subaccount_id: "subacct1".to_string(),
1396 client_id: String::new(), clob_pair_id: 1,
1398 side: OrderSide::Sell,
1399 size: dec!(2.0),
1400 total_filled: dec!(0.0),
1401 price: dec!(51000.0),
1402 status: DydxOrderStatus::Untriggered,
1403 order_type: DydxOrderType::StopLimit,
1404 time_in_force: DydxTimeInForce::Gtt,
1405 reduce_only: true,
1406 post_only: false,
1407 order_flags: 0,
1408 good_til_block: None,
1409 good_til_block_time: Some(Utc::now()),
1410 created_at_height: Some(1000),
1411 client_metadata: 0,
1412 trigger_price: Some(dec!(49000.0)),
1413 condition_type: Some(crate::common::enums::DydxConditionType::StopLoss),
1414 conditional_order_trigger_subticks: Some(490000),
1415 execution: None,
1416 updated_at: Some(Utc::now()),
1417 updated_at_height: Some(1001),
1418 ticker: None,
1419 subaccount_number: 0,
1420 order_router_address: None,
1421 };
1422
1423 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1424 assert!(result.is_ok());
1425
1426 let report = result.unwrap();
1427 assert_eq!(report.client_order_id, None);
1428 assert!(report.trigger_price.is_some());
1429 assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1430 }
1431
1432 #[rstest]
1433 fn test_parse_fill_report() {
1434 let instrument = create_test_instrument();
1435 let account_id = AccountId::new("DYDX-001");
1436 let ts_init = UnixNanos::default();
1437
1438 let fill = Fill {
1439 id: "fill789".to_string(),
1440 side: OrderSide::Buy,
1441 liquidity: DydxLiquidity::Taker,
1442 fill_type: crate::common::enums::DydxFillType::Limit,
1443 market: "BTC-USD".to_string(),
1444 market_type: crate::common::enums::DydxTickerType::Perpetual,
1445 price: dec!(50100.0),
1446 size: dec!(1.0),
1447 fee: dec!(-5.01),
1448 created_at: Utc::now(),
1449 created_at_height: 1000,
1450 order_id: "order123".to_string(),
1451 client_metadata: 0,
1452 };
1453
1454 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1455 assert!(result.is_ok());
1456
1457 let report = result.unwrap();
1458 assert_eq!(report.account_id, account_id);
1459 assert_eq!(report.order_side, OrderSide::Buy);
1460 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1461 assert_eq!(report.last_px.as_f64(), 50100.0);
1462 assert_eq!(report.commission.as_f64(), 5.01);
1463 }
1464
1465 #[rstest]
1466 fn test_parse_position_status_report_long() {
1467 let instrument = create_test_instrument();
1468 let account_id = AccountId::new("DYDX-001");
1469 let ts_init = UnixNanos::default();
1470
1471 let position = PerpetualPosition {
1472 market: "BTC-USD".to_string(),
1473 status: crate::common::enums::DydxPositionStatus::Open,
1474 side: OrderSide::Buy,
1475 size: dec!(2.5),
1476 max_size: dec!(3.0),
1477 entry_price: dec!(49500.0),
1478 exit_price: None,
1479 realized_pnl: dec!(100.0),
1480 created_at_height: 1000,
1481 created_at: Utc::now(),
1482 sum_open: dec!(2.5),
1483 sum_close: dec!(0.0),
1484 net_funding: dec!(-2.5),
1485 unrealized_pnl: dec!(250.0),
1486 closed_at: None,
1487 };
1488
1489 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1490 assert!(result.is_ok());
1491
1492 let report = result.unwrap();
1493 assert_eq!(report.account_id, account_id);
1494 assert_eq!(report.position_side, PositionSide::Long.as_specified());
1495 assert_eq!(report.quantity.as_f64(), 2.5);
1496 assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
1497 }
1498
1499 #[rstest]
1500 fn test_parse_position_status_report_short() {
1501 let instrument = create_test_instrument();
1502 let account_id = AccountId::new("DYDX-001");
1503 let ts_init = UnixNanos::default();
1504
1505 let position = PerpetualPosition {
1506 market: "BTC-USD".to_string(),
1507 status: crate::common::enums::DydxPositionStatus::Open,
1508 side: OrderSide::Sell,
1509 size: dec!(-1.5),
1510 max_size: dec!(1.5),
1511 entry_price: dec!(51000.0),
1512 exit_price: None,
1513 realized_pnl: dec!(0.0),
1514 created_at_height: 1000,
1515 created_at: Utc::now(),
1516 sum_open: dec!(1.5),
1517 sum_close: dec!(0.0),
1518 net_funding: dec!(1.2),
1519 unrealized_pnl: dec!(-150.0),
1520 closed_at: None,
1521 };
1522
1523 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1524 assert!(result.is_ok());
1525
1526 let report = result.unwrap();
1527 assert_eq!(report.position_side, PositionSide::Short.as_specified());
1528 assert_eq!(report.quantity.as_f64(), 1.5);
1529 }
1530
1531 #[rstest]
1532 fn test_parse_position_status_report_flat() {
1533 let instrument = create_test_instrument();
1534 let account_id = AccountId::new("DYDX-001");
1535 let ts_init = UnixNanos::default();
1536
1537 let position = PerpetualPosition {
1538 market: "BTC-USD".to_string(),
1539 status: crate::common::enums::DydxPositionStatus::Closed,
1540 side: OrderSide::Buy,
1541 size: dec!(0.0),
1542 max_size: dec!(2.0),
1543 entry_price: dec!(50000.0),
1544 exit_price: Some(dec!(51000.0)),
1545 realized_pnl: dec!(500.0),
1546 created_at_height: 1000,
1547 created_at: Utc::now(),
1548 sum_open: dec!(2.0),
1549 sum_close: dec!(2.0),
1550 net_funding: dec!(-5.0),
1551 unrealized_pnl: dec!(0.0),
1552 closed_at: Some(Utc::now()),
1553 };
1554
1555 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1556 assert!(result.is_ok());
1557
1558 let report = result.unwrap();
1559 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
1560 assert_eq!(report.quantity.as_f64(), 0.0);
1561 }
1562
1563 #[rstest]
1565 fn test_parse_order_external_detection() {
1566 let instrument = create_test_instrument();
1567 let account_id = AccountId::new("DYDX-001");
1568 let ts_init = UnixNanos::default();
1569
1570 let order = Order {
1572 id: "external-order-123".to_string(),
1573 subaccount_id: "dydx1test/0".to_string(),
1574 client_id: "99999".to_string(),
1575 clob_pair_id: 1,
1576 side: OrderSide::Buy,
1577 size: dec!(0.5),
1578 total_filled: dec!(0.0),
1579 price: dec!(50000.0),
1580 status: DydxOrderStatus::Open,
1581 order_type: DydxOrderType::Limit,
1582 time_in_force: DydxTimeInForce::Gtt,
1583 reduce_only: false,
1584 post_only: false,
1585 order_flags: 0,
1586 good_til_block: Some(1000),
1587 good_til_block_time: None,
1588 created_at_height: Some(900),
1589 client_metadata: 0,
1590 trigger_price: None,
1591 condition_type: None,
1592 conditional_order_trigger_subticks: None,
1593 execution: None,
1594 updated_at: Some(Utc::now()),
1595 updated_at_height: Some(900),
1596 ticker: None,
1597 subaccount_number: 0,
1598 order_router_address: None,
1599 };
1600
1601 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1602 assert!(result.is_ok());
1603
1604 let report = result.unwrap();
1605 assert_eq!(report.account_id, account_id);
1606 assert_eq!(report.order_status, OrderStatus::Accepted);
1607 assert_eq!(report.filled_qty.as_f64(), 0.0);
1609 }
1610
1611 #[rstest]
1613 fn test_parse_order_partial_fill_reconciliation() {
1614 let instrument = create_test_instrument();
1615 let account_id = AccountId::new("DYDX-001");
1616 let ts_init = UnixNanos::default();
1617
1618 let order = Order {
1619 id: "partial-order-123".to_string(),
1620 subaccount_id: "dydx1test/0".to_string(),
1621 client_id: "12345".to_string(),
1622 clob_pair_id: 1,
1623 side: OrderSide::Buy,
1624 size: dec!(2.0),
1625 total_filled: dec!(0.75),
1626 price: dec!(50000.0),
1627 status: DydxOrderStatus::PartiallyFilled,
1628 order_type: DydxOrderType::Limit,
1629 time_in_force: DydxTimeInForce::Gtt,
1630 reduce_only: false,
1631 post_only: false,
1632 order_flags: 0,
1633 good_til_block: Some(2000),
1634 good_til_block_time: None,
1635 created_at_height: Some(1500),
1636 client_metadata: 0,
1637 trigger_price: None,
1638 condition_type: None,
1639 conditional_order_trigger_subticks: None,
1640 execution: None,
1641 updated_at: Some(Utc::now()),
1642 updated_at_height: Some(1600),
1643 ticker: None,
1644 subaccount_number: 0,
1645 order_router_address: None,
1646 };
1647
1648 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1649 assert!(result.is_ok());
1650
1651 let report = result.unwrap();
1652 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1653 assert_eq!(report.filled_qty.as_f64(), 0.75);
1654 assert_eq!(report.quantity.as_f64(), 2.0);
1655 }
1656
1657 #[rstest]
1659 fn test_parse_multiple_positions() {
1660 let instrument = create_test_instrument();
1661 let account_id = AccountId::new("DYDX-001");
1662 let ts_init = UnixNanos::default();
1663
1664 let long_position = PerpetualPosition {
1666 market: "BTC-USD".to_string(),
1667 status: crate::common::enums::DydxPositionStatus::Open,
1668 side: OrderSide::Buy,
1669 size: dec!(1.5),
1670 max_size: dec!(1.5),
1671 entry_price: dec!(49000.0),
1672 exit_price: None,
1673 realized_pnl: dec!(0.0),
1674 created_at_height: 1000,
1675 created_at: Utc::now(),
1676 sum_open: dec!(1.5),
1677 sum_close: dec!(0.0),
1678 net_funding: dec!(-1.0),
1679 unrealized_pnl: dec!(150.0),
1680 closed_at: None,
1681 };
1682
1683 let result1 =
1684 parse_position_status_report(&long_position, &instrument, account_id, ts_init);
1685 assert!(result1.is_ok());
1686 let report1 = result1.unwrap();
1687 assert_eq!(report1.position_side, PositionSide::Long.as_specified());
1688
1689 let short_position = PerpetualPosition {
1691 market: "BTC-USD".to_string(),
1692 status: crate::common::enums::DydxPositionStatus::Open,
1693 side: OrderSide::Sell,
1694 size: dec!(-2.0),
1695 max_size: dec!(2.0),
1696 entry_price: dec!(51000.0),
1697 exit_price: None,
1698 realized_pnl: dec!(0.0),
1699 created_at_height: 1100,
1700 created_at: Utc::now(),
1701 sum_open: dec!(2.0),
1702 sum_close: dec!(0.0),
1703 net_funding: dec!(0.5),
1704 unrealized_pnl: dec!(-200.0),
1705 closed_at: None,
1706 };
1707
1708 let result2 =
1709 parse_position_status_report(&short_position, &instrument, account_id, ts_init);
1710 assert!(result2.is_ok());
1711 let report2 = result2.unwrap();
1712 assert_eq!(report2.position_side, PositionSide::Short.as_specified());
1713 }
1714
1715 #[rstest]
1717 fn test_parse_fill_zero_fee() {
1718 let instrument = create_test_instrument();
1719 let account_id = AccountId::new("DYDX-001");
1720 let ts_init = UnixNanos::default();
1721
1722 let fill = Fill {
1723 id: "fill-zero-fee".to_string(),
1724 side: OrderSide::Sell,
1725 liquidity: DydxLiquidity::Maker,
1726 fill_type: crate::common::enums::DydxFillType::Limit,
1727 market: "BTC-USD".to_string(),
1728 market_type: crate::common::enums::DydxTickerType::Perpetual,
1729 price: dec!(50000.0),
1730 size: dec!(0.1),
1731 fee: dec!(0.0), created_at: Utc::now(),
1733 created_at_height: 1000,
1734 order_id: "order-zero-fee".to_string(),
1735 client_metadata: 0,
1736 };
1737
1738 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1739 assert!(result.is_ok());
1740
1741 let report = result.unwrap();
1742 assert_eq!(report.commission.as_f64(), 0.0);
1743 }
1744
1745 #[rstest]
1747 fn test_parse_fill_maker_rebate() {
1748 let instrument = create_test_instrument();
1749 let account_id = AccountId::new("DYDX-001");
1750 let ts_init = UnixNanos::default();
1751
1752 let fill = Fill {
1753 id: "fill-maker-rebate".to_string(),
1754 side: OrderSide::Buy,
1755 liquidity: DydxLiquidity::Maker,
1756 fill_type: crate::common::enums::DydxFillType::Limit,
1757 market: "BTC-USD".to_string(),
1758 market_type: crate::common::enums::DydxTickerType::Perpetual,
1759 price: dec!(50000.0),
1760 size: dec!(1.0),
1761 fee: dec!(-2.5), created_at: Utc::now(),
1763 created_at_height: 1000,
1764 order_id: "order-maker-rebate".to_string(),
1765 client_metadata: 0,
1766 };
1767
1768 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1769 assert!(result.is_ok());
1770
1771 let report = result.unwrap();
1772 assert_eq!(report.commission.as_f64(), 2.5);
1774 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1775 }
1776}