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<Decimal>,
215 taker_fee: Option<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;
809
810use nautilus_core::UUID4;
811use nautilus_model::{
812 enums::{LiquiditySide, OrderStatus, PositionSide, TriggerType},
813 identifiers::{AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId},
814 instruments::Instrument,
815 reports::{FillReport, OrderStatusReport, PositionStatusReport},
816 types::{Money, Price, Quantity},
817};
818
819use super::models::{Fill, Order, PerpetualPosition};
820use crate::common::enums::{DydxLiquidity, DydxOrderStatus};
821
822fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
824 match status {
825 DydxOrderStatus::Open => OrderStatus::Accepted,
826 DydxOrderStatus::Filled => OrderStatus::Filled,
827 DydxOrderStatus::Canceled => OrderStatus::Canceled,
828 DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
829 DydxOrderStatus::Untriggered => OrderStatus::Accepted, DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
831 DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
832 }
833}
834
835pub fn parse_order_status_report(
841 order: &Order,
842 instrument: &InstrumentAny,
843 account_id: AccountId,
844 ts_init: UnixNanos,
845) -> anyhow::Result<OrderStatusReport> {
846 let instrument_id = instrument.id();
847 let venue_order_id = VenueOrderId::new(&order.id);
848 let client_order_id = if order.client_id.is_empty() {
849 None
850 } else {
851 Some(ClientOrderId::new(&order.client_id))
852 };
853
854 let order_type = order.order_type.into();
855
856 let execution = order.execution.or({
857 if order.post_only {
859 Some(DydxOrderExecution::PostOnly)
860 } else {
861 Some(DydxOrderExecution::Default)
862 }
863 });
864 let time_in_force = calculate_time_in_force(
865 order.order_type,
866 order.time_in_force,
867 order.reduce_only,
868 execution,
869 )?;
870
871 let order_side = order.side;
872 let order_status = parse_order_status(&order.status);
873
874 let size_precision = instrument.size_precision();
875 let quantity = Quantity::from_decimal_dp(order.size, size_precision)
876 .context("failed to parse order size")?;
877 let filled_qty = Quantity::from_decimal_dp(order.total_filled, size_precision)
878 .context("failed to parse total_filled")?;
879
880 let price_precision = instrument.price_precision();
881 let price = Price::from_decimal_dp(order.price, price_precision)
882 .context("failed to parse order price")?;
883
884 let ts_accepted = order.good_til_block_time.map_or(ts_init, |dt| {
885 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
886 });
887 let ts_last = order.updated_at.map_or(ts_init, |dt| {
888 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
889 });
890
891 let mut report = OrderStatusReport::new(
892 account_id,
893 instrument_id,
894 client_order_id,
895 venue_order_id,
896 order_side,
897 order_type,
898 time_in_force,
899 order_status,
900 quantity,
901 filled_qty,
902 ts_accepted,
903 ts_last,
904 ts_init,
905 Some(UUID4::new()),
906 );
907
908 report = report.with_price(price);
909
910 if let Some(trigger_price_dec) = order.trigger_price {
911 let trigger_price = Price::from_decimal_dp(trigger_price_dec, instrument.price_precision())
912 .context("failed to parse trigger_price")?;
913 report = report.with_trigger_price(trigger_price);
914
915 if let Some(condition_type) = order.condition_type {
916 let trigger_type = match condition_type {
917 crate::common::enums::DydxConditionType::StopLoss => TriggerType::LastPrice,
918 crate::common::enums::DydxConditionType::TakeProfit => TriggerType::LastPrice,
919 crate::common::enums::DydxConditionType::Unspecified => TriggerType::Default,
920 };
921 report = report.with_trigger_type(trigger_type);
922 }
923 }
924
925 Ok(report)
926}
927
928pub fn parse_fill_report(
934 fill: &Fill,
935 instrument: &InstrumentAny,
936 account_id: AccountId,
937 ts_init: UnixNanos,
938) -> anyhow::Result<FillReport> {
939 let instrument_id = instrument.id();
940 let venue_order_id = VenueOrderId::new(&fill.order_id);
941 let trade_id = TradeId::new(&fill.id);
942 let order_side = fill.side;
943
944 let size_precision = instrument.size_precision();
945 let price_precision = instrument.price_precision();
946
947 let last_qty = Quantity::from_decimal_dp(fill.size, size_precision)
948 .context("failed to parse fill size")?;
949 let last_px = Price::from_decimal_dp(fill.price, price_precision)
950 .context("failed to parse fill price")?;
951
952 let commission = Money::from_decimal(-fill.fee, instrument.quote_currency())
959 .context("failed to parse fee")?;
960
961 let liquidity_side = match fill.liquidity {
962 DydxLiquidity::Maker => LiquiditySide::Maker,
963 DydxLiquidity::Taker => LiquiditySide::Taker,
964 };
965
966 let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
967
968 let report = FillReport::new(
969 account_id,
970 instrument_id,
971 venue_order_id,
972 trade_id,
973 order_side,
974 last_qty,
975 last_px,
976 commission,
977 liquidity_side,
978 None, None, ts_event,
981 ts_init,
982 Some(UUID4::new()),
983 );
984
985 Ok(report)
986}
987
988pub fn parse_position_status_report(
994 position: &PerpetualPosition,
995 instrument: &InstrumentAny,
996 account_id: AccountId,
997 ts_init: UnixNanos,
998) -> anyhow::Result<PositionStatusReport> {
999 let instrument_id = instrument.id();
1000
1001 let position_side = if position.size.is_zero() {
1003 PositionSide::Flat
1004 } else if position.size.is_sign_positive() {
1005 PositionSide::Long
1006 } else {
1007 PositionSide::Short
1008 };
1009
1010 let quantity = Quantity::from_decimal_dp(position.size.abs(), instrument.size_precision())
1012 .context("failed to parse position size")?;
1013
1014 let avg_px_open = position.entry_price;
1015 let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1016
1017 let venue_position_id = Some(PositionId::new(format!(
1018 "{}_{}",
1019 account_id, position.market
1020 )));
1021
1022 Ok(PositionStatusReport::new(
1023 account_id,
1024 instrument_id,
1025 position_side.as_specified(),
1026 quantity,
1027 ts_last,
1028 ts_init,
1029 Some(UUID4::new()),
1030 venue_position_id,
1031 Some(avg_px_open),
1032 ))
1033}
1034
1035pub fn parse_account_state(
1050 subaccount: &DydxSubaccountInfo,
1051 account_id: AccountId,
1052 instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1053 oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1054 ts_event: UnixNanos,
1055 ts_init: UnixNanos,
1056) -> anyhow::Result<AccountState> {
1057 use std::collections::HashMap;
1058
1059 use nautilus_model::{
1060 enums::AccountType,
1061 events::AccountState,
1062 types::{AccountBalance, MarginBalance},
1063 };
1064
1065 let mut balances = Vec::new();
1066
1067 let equity: Decimal = subaccount
1069 .equity
1070 .parse()
1071 .context(format!("Failed to parse equity '{}'", subaccount.equity))?;
1072
1073 let free_collateral: Decimal = subaccount.free_collateral.parse().context(format!(
1074 "Failed to parse freeCollateral '{}'",
1075 subaccount.free_collateral
1076 ))?;
1077
1078 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1080
1081 let total = Money::from_decimal(equity, currency).context("failed to parse equity")?;
1082 let free = Money::from_decimal(free_collateral, currency)
1083 .context("failed to parse free collateral")?;
1084 let locked = total - free;
1085
1086 let balance = AccountBalance::new_checked(total, locked, free)
1087 .context("Failed to create AccountBalance from subaccount data")?;
1088 balances.push(balance);
1089
1090 let mut margins = Vec::new();
1092 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1093 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1094
1095 if let Some(ref positions) = subaccount.open_perpetual_positions {
1096 for position in positions.values() {
1097 let market_str = position.market.as_str();
1099 let instrument_id = parse_instrument_id(market_str);
1100
1101 let instrument = match instruments.get(&instrument_id) {
1103 Some(inst) => inst,
1104 None => {
1105 log::warn!(
1106 "Cannot calculate margin for position {market_str}: instrument not found"
1107 );
1108 continue;
1109 }
1110 };
1111
1112 let (margin_init, margin_maint) = match instrument {
1114 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1115 _ => {
1116 log::warn!(
1117 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1118 );
1119 continue;
1120 }
1121 };
1122
1123 let position_size = match Decimal::from_str(&position.size) {
1125 Ok(size) => size.abs(),
1126 Err(e) => {
1127 log::warn!(
1128 "Failed to parse position size '{}' for {}: {}",
1129 position.size,
1130 market_str,
1131 e
1132 );
1133 continue;
1134 }
1135 };
1136
1137 if position_size.is_zero() {
1139 continue;
1140 }
1141
1142 let oracle_price = oracle_prices
1144 .get(&instrument_id)
1145 .copied()
1146 .or_else(|| Decimal::from_str(&position.entry_price).ok())
1147 .unwrap_or(Decimal::ZERO);
1148
1149 if oracle_price.is_zero() {
1150 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1151 continue;
1152 }
1153
1154 let initial_margin = margin_init * position_size * oracle_price;
1156
1157 let maintenance_margin = margin_maint * position_size * oracle_price;
1158
1159 let quote_currency = instrument.quote_currency();
1161 *initial_margins
1162 .entry(quote_currency)
1163 .or_insert(Decimal::ZERO) += initial_margin;
1164 *maintenance_margins
1165 .entry(quote_currency)
1166 .or_insert(Decimal::ZERO) += maintenance_margin;
1167 }
1168 }
1169
1170 for (currency, initial_margin) in initial_margins {
1172 let maintenance_margin = maintenance_margins
1173 .get(¤cy)
1174 .copied()
1175 .unwrap_or(Decimal::ZERO);
1176
1177 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1178 "Failed to create initial margin Money for {currency}"
1179 ))?;
1180 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1181 format!("Failed to create maintenance margin Money for {currency}"),
1182 )?;
1183
1184 let margin_instrument_id = InstrumentId::new(Symbol::new("ACCOUNT"), Venue::new("DYDX"));
1187
1188 let margin_balance =
1189 MarginBalance::new(initial_money, maintenance_money, margin_instrument_id);
1190 margins.push(margin_balance);
1191 }
1192
1193 Ok(AccountState::new(
1194 account_id,
1195 AccountType::Margin, balances,
1197 margins,
1198 true, UUID4::new(),
1200 ts_event,
1201 ts_init,
1202 None, ))
1204}
1205
1206#[cfg(test)]
1207mod reconciliation_tests {
1208 use chrono::Utc;
1209 use nautilus_model::{
1210 enums::{OrderSide, OrderStatus, TimeInForce},
1211 identifiers::{AccountId, InstrumentId, Symbol, Venue},
1212 instruments::{CryptoPerpetual, Instrument},
1213 types::Currency,
1214 };
1215 use rstest::rstest;
1216 use rust_decimal::prelude::ToPrimitive;
1217 use rust_decimal_macros::dec;
1218
1219 use super::*;
1220
1221 fn create_test_instrument() -> InstrumentAny {
1222 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), Venue::new("DYDX"));
1223
1224 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1225 instrument_id,
1226 instrument_id.symbol,
1227 Currency::BTC(),
1228 Currency::USD(),
1229 Currency::USD(),
1230 false,
1231 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(), ))
1250 }
1251
1252 #[rstest]
1253 fn test_parse_order_status() {
1254 assert_eq!(
1255 parse_order_status(&DydxOrderStatus::Open),
1256 OrderStatus::Accepted
1257 );
1258 assert_eq!(
1259 parse_order_status(&DydxOrderStatus::Filled),
1260 OrderStatus::Filled
1261 );
1262 assert_eq!(
1263 parse_order_status(&DydxOrderStatus::Canceled),
1264 OrderStatus::Canceled
1265 );
1266 assert_eq!(
1267 parse_order_status(&DydxOrderStatus::PartiallyFilled),
1268 OrderStatus::PartiallyFilled
1269 );
1270 assert_eq!(
1271 parse_order_status(&DydxOrderStatus::Untriggered),
1272 OrderStatus::Accepted
1273 );
1274 }
1275
1276 #[rstest]
1277 fn test_parse_order_status_report_basic() {
1278 let instrument = create_test_instrument();
1279 let account_id = AccountId::new("DYDX-001");
1280 let ts_init = UnixNanos::default();
1281
1282 let order = Order {
1283 id: "order123".to_string(),
1284 subaccount_id: "subacct1".to_string(),
1285 client_id: "client1".to_string(),
1286 clob_pair_id: 1,
1287 side: OrderSide::Buy,
1288 size: dec!(1.5),
1289 total_filled: dec!(1.0),
1290 price: dec!(50000.0),
1291 status: DydxOrderStatus::PartiallyFilled,
1292 order_type: DydxOrderType::Limit,
1293 time_in_force: DydxTimeInForce::Gtt,
1294 reduce_only: false,
1295 post_only: false,
1296 order_flags: 0,
1297 good_til_block: None,
1298 good_til_block_time: Some(Utc::now()),
1299 created_at_height: Some(1000),
1300 client_metadata: 0,
1301 trigger_price: None,
1302 condition_type: None,
1303 conditional_order_trigger_subticks: None,
1304 execution: None,
1305 updated_at: Some(Utc::now()),
1306 updated_at_height: Some(1001),
1307 ticker: None,
1308 subaccount_number: 0,
1309 order_router_address: None,
1310 };
1311
1312 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1313 if let Err(ref e) = result {
1314 eprintln!("Parse error: {e:?}");
1315 }
1316 assert!(result.is_ok());
1317
1318 let report = result.unwrap();
1319 assert_eq!(report.account_id, account_id);
1320 assert_eq!(report.instrument_id, instrument.id());
1321 assert_eq!(report.order_side, OrderSide::Buy);
1322 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1323 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1324 }
1325
1326 #[rstest]
1327 fn test_parse_order_status_report_conditional() {
1328 let instrument = create_test_instrument();
1329 let account_id = AccountId::new("DYDX-001");
1330 let ts_init = UnixNanos::default();
1331
1332 let order = Order {
1333 id: "order456".to_string(),
1334 subaccount_id: "subacct1".to_string(),
1335 client_id: String::new(), clob_pair_id: 1,
1337 side: OrderSide::Sell,
1338 size: dec!(2.0),
1339 total_filled: dec!(0.0),
1340 price: dec!(51000.0),
1341 status: DydxOrderStatus::Untriggered,
1342 order_type: DydxOrderType::StopLimit,
1343 time_in_force: DydxTimeInForce::Gtt,
1344 reduce_only: true,
1345 post_only: false,
1346 order_flags: 0,
1347 good_til_block: None,
1348 good_til_block_time: Some(Utc::now()),
1349 created_at_height: Some(1000),
1350 client_metadata: 0,
1351 trigger_price: Some(dec!(49000.0)),
1352 condition_type: Some(crate::common::enums::DydxConditionType::StopLoss),
1353 conditional_order_trigger_subticks: Some(490000),
1354 execution: None,
1355 updated_at: Some(Utc::now()),
1356 updated_at_height: Some(1001),
1357 ticker: None,
1358 subaccount_number: 0,
1359 order_router_address: None,
1360 };
1361
1362 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1363 assert!(result.is_ok());
1364
1365 let report = result.unwrap();
1366 assert_eq!(report.client_order_id, None);
1367 assert!(report.trigger_price.is_some());
1368 assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1369 }
1370
1371 #[rstest]
1372 fn test_parse_fill_report() {
1373 let instrument = create_test_instrument();
1374 let account_id = AccountId::new("DYDX-001");
1375 let ts_init = UnixNanos::default();
1376
1377 let fill = Fill {
1378 id: "fill789".to_string(),
1379 side: OrderSide::Buy,
1380 liquidity: DydxLiquidity::Taker,
1381 fill_type: crate::common::enums::DydxFillType::Limit,
1382 market: "BTC-USD".to_string(),
1383 market_type: crate::common::enums::DydxTickerType::Perpetual,
1384 price: dec!(50100.0),
1385 size: dec!(1.0),
1386 fee: dec!(-5.01),
1387 created_at: Utc::now(),
1388 created_at_height: 1000,
1389 order_id: "order123".to_string(),
1390 client_metadata: 0,
1391 };
1392
1393 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1394 assert!(result.is_ok());
1395
1396 let report = result.unwrap();
1397 assert_eq!(report.account_id, account_id);
1398 assert_eq!(report.order_side, OrderSide::Buy);
1399 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1400 assert_eq!(report.last_px.as_f64(), 50100.0);
1401 assert_eq!(report.commission.as_f64(), 5.01);
1402 }
1403
1404 #[rstest]
1405 fn test_parse_position_status_report_long() {
1406 let instrument = create_test_instrument();
1407 let account_id = AccountId::new("DYDX-001");
1408 let ts_init = UnixNanos::default();
1409
1410 let position = PerpetualPosition {
1411 market: "BTC-USD".to_string(),
1412 status: crate::common::enums::DydxPositionStatus::Open,
1413 side: OrderSide::Buy,
1414 size: dec!(2.5),
1415 max_size: dec!(3.0),
1416 entry_price: dec!(49500.0),
1417 exit_price: None,
1418 realized_pnl: dec!(100.0),
1419 created_at_height: 1000,
1420 created_at: Utc::now(),
1421 sum_open: dec!(2.5),
1422 sum_close: dec!(0.0),
1423 net_funding: dec!(-2.5),
1424 unrealized_pnl: dec!(250.0),
1425 closed_at: None,
1426 };
1427
1428 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1429 assert!(result.is_ok());
1430
1431 let report = result.unwrap();
1432 assert_eq!(report.account_id, account_id);
1433 assert_eq!(report.position_side, PositionSide::Long.as_specified());
1434 assert_eq!(report.quantity.as_f64(), 2.5);
1435 assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
1436 }
1437
1438 #[rstest]
1439 fn test_parse_position_status_report_short() {
1440 let instrument = create_test_instrument();
1441 let account_id = AccountId::new("DYDX-001");
1442 let ts_init = UnixNanos::default();
1443
1444 let position = PerpetualPosition {
1445 market: "BTC-USD".to_string(),
1446 status: crate::common::enums::DydxPositionStatus::Open,
1447 side: OrderSide::Sell,
1448 size: dec!(-1.5),
1449 max_size: dec!(1.5),
1450 entry_price: dec!(51000.0),
1451 exit_price: None,
1452 realized_pnl: dec!(0.0),
1453 created_at_height: 1000,
1454 created_at: Utc::now(),
1455 sum_open: dec!(1.5),
1456 sum_close: dec!(0.0),
1457 net_funding: dec!(1.2),
1458 unrealized_pnl: dec!(-150.0),
1459 closed_at: None,
1460 };
1461
1462 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1463 assert!(result.is_ok());
1464
1465 let report = result.unwrap();
1466 assert_eq!(report.position_side, PositionSide::Short.as_specified());
1467 assert_eq!(report.quantity.as_f64(), 1.5);
1468 }
1469
1470 #[rstest]
1471 fn test_parse_position_status_report_flat() {
1472 let instrument = create_test_instrument();
1473 let account_id = AccountId::new("DYDX-001");
1474 let ts_init = UnixNanos::default();
1475
1476 let position = PerpetualPosition {
1477 market: "BTC-USD".to_string(),
1478 status: crate::common::enums::DydxPositionStatus::Closed,
1479 side: OrderSide::Buy,
1480 size: dec!(0.0),
1481 max_size: dec!(2.0),
1482 entry_price: dec!(50000.0),
1483 exit_price: Some(dec!(51000.0)),
1484 realized_pnl: dec!(500.0),
1485 created_at_height: 1000,
1486 created_at: Utc::now(),
1487 sum_open: dec!(2.0),
1488 sum_close: dec!(2.0),
1489 net_funding: dec!(-5.0),
1490 unrealized_pnl: dec!(0.0),
1491 closed_at: Some(Utc::now()),
1492 };
1493
1494 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1495 assert!(result.is_ok());
1496
1497 let report = result.unwrap();
1498 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
1499 assert_eq!(report.quantity.as_f64(), 0.0);
1500 }
1501
1502 #[rstest]
1504 fn test_parse_order_external_detection() {
1505 let instrument = create_test_instrument();
1506 let account_id = AccountId::new("DYDX-001");
1507 let ts_init = UnixNanos::default();
1508
1509 let order = Order {
1511 id: "external-order-123".to_string(),
1512 subaccount_id: "dydx1test/0".to_string(),
1513 client_id: "99999".to_string(),
1514 clob_pair_id: 1,
1515 side: OrderSide::Buy,
1516 size: dec!(0.5),
1517 total_filled: dec!(0.0),
1518 price: dec!(50000.0),
1519 status: DydxOrderStatus::Open,
1520 order_type: DydxOrderType::Limit,
1521 time_in_force: DydxTimeInForce::Gtt,
1522 reduce_only: false,
1523 post_only: false,
1524 order_flags: 0,
1525 good_til_block: Some(1000),
1526 good_til_block_time: None,
1527 created_at_height: Some(900),
1528 client_metadata: 0,
1529 trigger_price: None,
1530 condition_type: None,
1531 conditional_order_trigger_subticks: None,
1532 execution: None,
1533 updated_at: Some(Utc::now()),
1534 updated_at_height: Some(900),
1535 ticker: None,
1536 subaccount_number: 0,
1537 order_router_address: None,
1538 };
1539
1540 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1541 assert!(result.is_ok());
1542
1543 let report = result.unwrap();
1544 assert_eq!(report.account_id, account_id);
1545 assert_eq!(report.order_status, OrderStatus::Accepted);
1546 assert_eq!(report.filled_qty.as_f64(), 0.0);
1548 }
1549
1550 #[rstest]
1552 fn test_parse_order_partial_fill_reconciliation() {
1553 let instrument = create_test_instrument();
1554 let account_id = AccountId::new("DYDX-001");
1555 let ts_init = UnixNanos::default();
1556
1557 let order = Order {
1558 id: "partial-order-123".to_string(),
1559 subaccount_id: "dydx1test/0".to_string(),
1560 client_id: "12345".to_string(),
1561 clob_pair_id: 1,
1562 side: OrderSide::Buy,
1563 size: dec!(2.0),
1564 total_filled: dec!(0.75),
1565 price: dec!(50000.0),
1566 status: DydxOrderStatus::PartiallyFilled,
1567 order_type: DydxOrderType::Limit,
1568 time_in_force: DydxTimeInForce::Gtt,
1569 reduce_only: false,
1570 post_only: false,
1571 order_flags: 0,
1572 good_til_block: Some(2000),
1573 good_til_block_time: None,
1574 created_at_height: Some(1500),
1575 client_metadata: 0,
1576 trigger_price: None,
1577 condition_type: None,
1578 conditional_order_trigger_subticks: None,
1579 execution: None,
1580 updated_at: Some(Utc::now()),
1581 updated_at_height: Some(1600),
1582 ticker: None,
1583 subaccount_number: 0,
1584 order_router_address: None,
1585 };
1586
1587 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1588 assert!(result.is_ok());
1589
1590 let report = result.unwrap();
1591 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1592 assert_eq!(report.filled_qty.as_f64(), 0.75);
1593 assert_eq!(report.quantity.as_f64(), 2.0);
1594 }
1595
1596 #[rstest]
1598 fn test_parse_multiple_positions() {
1599 let instrument = create_test_instrument();
1600 let account_id = AccountId::new("DYDX-001");
1601 let ts_init = UnixNanos::default();
1602
1603 let long_position = PerpetualPosition {
1605 market: "BTC-USD".to_string(),
1606 status: crate::common::enums::DydxPositionStatus::Open,
1607 side: OrderSide::Buy,
1608 size: dec!(1.5),
1609 max_size: dec!(1.5),
1610 entry_price: dec!(49000.0),
1611 exit_price: None,
1612 realized_pnl: dec!(0.0),
1613 created_at_height: 1000,
1614 created_at: Utc::now(),
1615 sum_open: dec!(1.5),
1616 sum_close: dec!(0.0),
1617 net_funding: dec!(-1.0),
1618 unrealized_pnl: dec!(150.0),
1619 closed_at: None,
1620 };
1621
1622 let result1 =
1623 parse_position_status_report(&long_position, &instrument, account_id, ts_init);
1624 assert!(result1.is_ok());
1625 let report1 = result1.unwrap();
1626 assert_eq!(report1.position_side, PositionSide::Long.as_specified());
1627
1628 let short_position = PerpetualPosition {
1630 market: "BTC-USD".to_string(),
1631 status: crate::common::enums::DydxPositionStatus::Open,
1632 side: OrderSide::Sell,
1633 size: dec!(-2.0),
1634 max_size: dec!(2.0),
1635 entry_price: dec!(51000.0),
1636 exit_price: None,
1637 realized_pnl: dec!(0.0),
1638 created_at_height: 1100,
1639 created_at: Utc::now(),
1640 sum_open: dec!(2.0),
1641 sum_close: dec!(0.0),
1642 net_funding: dec!(0.5),
1643 unrealized_pnl: dec!(-200.0),
1644 closed_at: None,
1645 };
1646
1647 let result2 =
1648 parse_position_status_report(&short_position, &instrument, account_id, ts_init);
1649 assert!(result2.is_ok());
1650 let report2 = result2.unwrap();
1651 assert_eq!(report2.position_side, PositionSide::Short.as_specified());
1652 }
1653
1654 #[rstest]
1656 fn test_parse_fill_zero_fee() {
1657 let instrument = create_test_instrument();
1658 let account_id = AccountId::new("DYDX-001");
1659 let ts_init = UnixNanos::default();
1660
1661 let fill = Fill {
1662 id: "fill-zero-fee".to_string(),
1663 side: OrderSide::Sell,
1664 liquidity: DydxLiquidity::Maker,
1665 fill_type: crate::common::enums::DydxFillType::Limit,
1666 market: "BTC-USD".to_string(),
1667 market_type: crate::common::enums::DydxTickerType::Perpetual,
1668 price: dec!(50000.0),
1669 size: dec!(0.1),
1670 fee: dec!(0.0), created_at: Utc::now(),
1672 created_at_height: 1000,
1673 order_id: "order-zero-fee".to_string(),
1674 client_metadata: 0,
1675 };
1676
1677 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1678 assert!(result.is_ok());
1679
1680 let report = result.unwrap();
1681 assert_eq!(report.commission.as_f64(), 0.0);
1682 }
1683
1684 #[rstest]
1686 fn test_parse_fill_maker_rebate() {
1687 let instrument = create_test_instrument();
1688 let account_id = AccountId::new("DYDX-001");
1689 let ts_init = UnixNanos::default();
1690
1691 let fill = Fill {
1692 id: "fill-maker-rebate".to_string(),
1693 side: OrderSide::Buy,
1694 liquidity: DydxLiquidity::Maker,
1695 fill_type: crate::common::enums::DydxFillType::Limit,
1696 market: "BTC-USD".to_string(),
1697 market_type: crate::common::enums::DydxTickerType::Perpetual,
1698 price: dec!(50000.0),
1699 size: dec!(1.0),
1700 fee: dec!(-2.5), created_at: Utc::now(),
1702 created_at_height: 1000,
1703 order_id: "order-maker-rebate".to_string(),
1704 client_metadata: 0,
1705 };
1706
1707 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1708 assert!(result.is_ok());
1709
1710 let report = result.unwrap();
1711 assert_eq!(report.commission.as_f64(), 2.5);
1713 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1714 }
1715}