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