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