1use std::collections::HashMap;
36
37use anyhow::Context;
38use nautilus_core::UnixNanos;
39use nautilus_model::{
40 data::{Bar, BarType, TradeTick},
41 enums::{AccountType, AggressorSide, OrderSide, TimeInForce},
42 events::AccountState,
43 identifiers::{InstrumentId, Symbol, TradeId},
44 instruments::{CryptoPerpetual, InstrumentAny},
45 types::{AccountBalance, Currency, MarginBalance, Price, Quantity},
46};
47use rust_decimal::Decimal;
48
49use super::models::{Candle, PerpetualMarket, Subaccount, Trade};
50#[cfg(test)]
51use crate::common::enums::DydxTransferType;
52use crate::{
53 common::{
54 enums::{DydxMarketStatus, DydxOrderExecution, DydxOrderType, DydxTimeInForce},
55 parse::{parse_decimal, parse_instrument_id, parse_price, parse_quantity},
56 },
57 websocket::messages::DydxSubaccountInfo,
58};
59
60pub fn parse_trade_tick(
66 trade: &Trade,
67 instrument_id: InstrumentId,
68 price_precision: u8,
69 size_precision: u8,
70 ts_init: UnixNanos,
71) -> anyhow::Result<TradeTick> {
72 let aggressor_side = match trade.side {
73 OrderSide::Buy => AggressorSide::Buy,
74 OrderSide::Sell => AggressorSide::Sell,
75 };
76
77 let price = Price::from_decimal_dp(trade.price, price_precision)
78 .context(format!("failed to parse price for trade {}", trade.id))?;
79
80 let size = Quantity::from_decimal_dp(trade.size, size_precision)
81 .context(format!("failed to parse size for trade {}", trade.id))?;
82
83 let ts_event_nanos = u64::try_from(trade.created_at.as_nanosecond())
84 .map_err(|_| anyhow::anyhow!("Timestamp out of range for trade {}", trade.id))?;
85 let ts_event = UnixNanos::from(ts_event_nanos);
86
87 Ok(TradeTick::new(
88 instrument_id,
89 price,
90 size,
91 aggressor_side,
92 TradeId::new(&trade.id),
93 ts_event,
94 ts_init,
95 ))
96}
97
98pub fn parse_bar(
107 candle: &Candle,
108 bar_type: BarType,
109 price_precision: u8,
110 size_precision: u8,
111 timestamp_on_close: bool,
112 ts_init: UnixNanos,
113) -> anyhow::Result<Bar> {
114 let started_at_nanos = u64::try_from(candle.started_at.as_nanosecond()).map_err(|_| {
115 anyhow::anyhow!("Timestamp out of range for candle at {}", candle.started_at)
116 })?;
117 let mut ts_event = UnixNanos::from(started_at_nanos);
118
119 if timestamp_on_close {
120 let interval_ns = bar_type.spec().timedelta().as_nanos();
121 let interval_ns =
122 u64::try_from(interval_ns).context("bar interval overflowed u64 nanoseconds")?;
123 let updated = ts_event
124 .as_u64()
125 .checked_add(interval_ns)
126 .context("bar timestamp overflowed when adjusting to close time")?;
127 ts_event = UnixNanos::from(updated);
128 }
129
130 let open = Price::from_decimal_dp(candle.open, price_precision)
131 .context("failed to parse candle open price")?;
132 let high = Price::from_decimal_dp(candle.high, price_precision)
133 .context("failed to parse candle high price")?;
134 let low = Price::from_decimal_dp(candle.low, price_precision)
135 .context("failed to parse candle low price")?;
136 let close = Price::from_decimal_dp(candle.close, price_precision)
137 .context("failed to parse candle close price")?;
138 let volume = Quantity::from_decimal_dp(candle.base_token_volume, size_precision)
139 .context("failed to parse candle base_token_volume")?;
140
141 Ok(Bar::new(
142 bar_type, open, high, low, close, volume, ts_event, ts_init,
143 ))
144}
145
146pub fn validate_ticker_format(ticker: &str) -> anyhow::Result<()> {
152 let parts: Vec<&str> = ticker.split('-').collect();
153 if parts.len() != 2 {
154 anyhow::bail!("Invalid ticker format '{ticker}', expected 'BASE-QUOTE' (e.g., 'BTC-USD')");
155 }
156
157 if parts[0].is_empty() || parts[1].is_empty() {
158 anyhow::bail!("Invalid ticker format '{ticker}', base and quote cannot be empty");
159 }
160 Ok(())
161}
162
163pub fn parse_ticker_currencies(ticker: &str) -> anyhow::Result<(&str, &str)> {
169 validate_ticker_format(ticker)?;
170 let parts: Vec<&str> = ticker.split('-').collect();
171 Ok((parts[0], parts[1]))
172}
173
174#[must_use]
176pub const fn is_market_active(status: &DydxMarketStatus) -> bool {
177 matches!(status, DydxMarketStatus::Active)
178}
179
180pub fn calculate_time_in_force(
186 order_type: DydxOrderType,
187 base_tif: DydxTimeInForce,
188 post_only: bool,
189 execution: Option<DydxOrderExecution>,
190) -> anyhow::Result<TimeInForce> {
191 match order_type {
192 DydxOrderType::Market => Ok(TimeInForce::Ioc),
193 DydxOrderType::Limit if post_only => Ok(TimeInForce::Gtc), DydxOrderType::Limit => match base_tif {
195 DydxTimeInForce::Gtt => Ok(TimeInForce::Gtc),
196 DydxTimeInForce::Fok => Ok(TimeInForce::Fok),
197 DydxTimeInForce::Ioc => Ok(TimeInForce::Ioc),
198 },
199
200 DydxOrderType::StopLimit | DydxOrderType::TakeProfitLimit => match execution {
201 Some(DydxOrderExecution::PostOnly) => Ok(TimeInForce::Gtc), Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
203 Some(DydxOrderExecution::Ioc) => Ok(TimeInForce::Ioc),
204 Some(DydxOrderExecution::Default) | None => Ok(TimeInForce::Gtc), },
206
207 DydxOrderType::StopMarket | DydxOrderType::TakeProfitMarket => match execution {
208 Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
209 Some(DydxOrderExecution::Ioc | DydxOrderExecution::Default) | None => {
210 Ok(TimeInForce::Ioc)
211 }
212 Some(DydxOrderExecution::PostOnly) => {
213 anyhow::bail!("Execution PostOnly not supported for {order_type:?}")
214 }
215 },
216
217 DydxOrderType::TrailingStop => Ok(TimeInForce::Gtc),
218 }
219}
220
221pub fn validate_conditional_order(
232 order_type: DydxOrderType,
233 trigger_price: Option<Decimal>,
234 price: Decimal,
235 side: OrderSide,
236) -> anyhow::Result<()> {
237 if !order_type.is_conditional() {
238 return Ok(());
239 }
240
241 let trigger_price = trigger_price
242 .ok_or_else(|| anyhow::anyhow!("trigger_price required for {order_type:?}"))?;
243
244 match order_type {
246 DydxOrderType::StopLimit | DydxOrderType::StopMarket => {
247 match side {
249 OrderSide::Buy if trigger_price < price => {
250 anyhow::bail!(
251 "Stop buy trigger_price ({trigger_price}) must be >= limit price ({price})"
252 );
253 }
254 OrderSide::Sell if trigger_price > price => {
255 anyhow::bail!(
256 "Stop sell trigger_price ({trigger_price}) must be <= limit price ({price})"
257 );
258 }
259 _ => {}
260 }
261 }
262 DydxOrderType::TakeProfitLimit | DydxOrderType::TakeProfitMarket => {
263 match side {
265 OrderSide::Buy if trigger_price > price => {
266 anyhow::bail!(
267 "Take profit buy trigger_price ({trigger_price}) must be <= limit price ({price})"
268 );
269 }
270 OrderSide::Sell if trigger_price < price => {
271 anyhow::bail!(
272 "Take profit sell trigger_price ({trigger_price}) must be >= limit price ({price})"
273 );
274 }
275 _ => {}
276 }
277 }
278 _ => {}
279 }
280
281 Ok(())
282}
283
284pub fn parse_instrument_any(
301 definition: &PerpetualMarket,
302 ts_init: UnixNanos,
303) -> anyhow::Result<InstrumentAny> {
304 let instrument_id = parse_instrument_id(definition.ticker);
306 let raw_symbol = Symbol::from(definition.ticker.as_str());
307
308 let (base_str, quote_str) = parse_ticker_currencies(&definition.ticker)
310 .context(format!("Failed to parse ticker '{}'", definition.ticker))?;
311
312 let base_currency = Currency::get_or_create_crypto_with_context(base_str, None);
313 let quote_currency = Currency::get_or_create_crypto_with_context(quote_str, None);
314 let settlement_currency = quote_currency; let price_increment =
318 parse_price(&definition.tick_size.to_string(), "tick_size").context(format!(
319 "Failed to parse tick_size '{}' for market '{}'",
320 definition.tick_size, definition.ticker
321 ))?;
322
323 let size_increment =
324 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
325 "Failed to parse step_size '{}' for market '{}'",
326 definition.step_size, definition.ticker
327 ))?;
328
329 let min_quantity = Some(if let Some(min_size) = &definition.min_order_size {
331 parse_quantity(&min_size.to_string(), "min_order_size").context(format!(
332 "Failed to parse min_order_size '{}' for market '{}'",
333 min_size, definition.ticker
334 ))?
335 } else {
336 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
338 "Failed to parse step_size as min_quantity for market '{}'",
339 definition.ticker
340 ))?
341 });
342
343 let margin_init = Some(
345 parse_decimal(
346 &definition.initial_margin_fraction.to_string(),
347 "initial_margin_fraction",
348 )
349 .context(format!(
350 "Failed to parse initial_margin_fraction '{}' for market '{}'",
351 definition.initial_margin_fraction, definition.ticker
352 ))?,
353 );
354
355 let margin_maint = Some(
356 parse_decimal(
357 &definition.maintenance_margin_fraction.to_string(),
358 "maintenance_margin_fraction",
359 )
360 .context(format!(
361 "Failed to parse maintenance_margin_fraction '{}' for market '{}'",
362 definition.maintenance_margin_fraction, definition.ticker
363 ))?,
364 );
365
366 let instrument = CryptoPerpetual::builder()
368 .instrument_id(instrument_id)
369 .raw_symbol(raw_symbol)
370 .base_currency(base_currency)
371 .quote_currency(quote_currency)
372 .settlement_currency(settlement_currency)
373 .is_inverse(false)
375 .price_precision(price_increment.precision)
376 .size_precision(size_increment.precision)
377 .price_increment(price_increment)
378 .size_increment(size_increment)
379 .lot_size(size_increment)
382 .maybe_min_quantity(min_quantity)
384 .maybe_margin_init(margin_init)
389 .maybe_margin_maint(margin_maint)
390 .ts_event(ts_init)
391 .ts_init(ts_init)
392 .build()?;
393
394 Ok(InstrumentAny::CryptoPerpetual(instrument))
395}
396
397pub(super) mod display_fromstr {
399 use std::{fmt::Display, str::FromStr};
400
401 use serde::{Deserialize, Deserializer, Serializer, de};
402
403 pub(crate) fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
404 where
405 T: Display,
406 S: Serializer,
407 {
408 serializer.collect_str(value)
409 }
410
411 pub(crate) fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
412 where
413 T: FromStr,
414 T::Err: Display,
415 D: Deserializer<'de>,
416 {
417 let s = String::deserialize(deserializer)?;
418 s.parse().map_err(de::Error::custom)
419 }
420}
421
422pub(super) mod display_fromstr_opt {
425 use std::{fmt::Display, str::FromStr};
426
427 use serde::{Deserialize, Deserializer, Serializer, de};
428
429 pub(crate) fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
430 where
431 T: Display,
432 S: Serializer,
433 {
434 match value {
435 Some(v) => serializer.collect_str(v),
436 None => serializer.serialize_none(),
437 }
438 }
439
440 pub(crate) fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
441 where
442 T: FromStr,
443 T::Err: Display,
444 D: Deserializer<'de>,
445 {
446 match Option::<String>::deserialize(deserializer)? {
447 Some(s) => s.parse().map(Some).map_err(de::Error::custom),
448 None => Ok(None),
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use std::str::FromStr;
456
457 use jiff::Timestamp;
458 use nautilus_core::correctness::CorrectnessError;
459 use nautilus_model::{
460 data::BarType,
461 enums::{AggressorSide, OrderSide},
462 identifiers::InstrumentId,
463 instruments::Instrument,
464 };
465 use rstest::rstest;
466 use rust_decimal::Decimal;
467 use rust_decimal_macros::dec;
468 use ustr::Ustr;
469
470 use super::*;
471 use crate::{
472 common::{
473 enums::{DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce},
474 testing::load_json_result_fixture,
475 },
476 http::models::{
477 CandlesResponse, FillsResponse, MarketsResponse, Order, OrderbookResponse,
478 SubaccountResponse, TradesResponse, TransfersResponse,
479 },
480 };
481
482 fn create_test_market() -> PerpetualMarket {
483 PerpetualMarket {
484 clob_pair_id: 1,
485 ticker: Ustr::from("BTC-USD"),
486 status: DydxMarketStatus::Active,
487 base_asset: Some(Ustr::from("BTC")),
488 quote_asset: Some(Ustr::from("USD")),
489 step_size: Decimal::from_str("0.001").unwrap(),
490 tick_size: Decimal::from_str("1").unwrap(),
491 index_price: Some(Decimal::from_str("50000").unwrap()),
492 oracle_price: Some(Decimal::from_str("50000").unwrap()),
493 price_change_24h: Decimal::ZERO,
494 next_funding_rate: Decimal::ZERO,
495 next_funding_at: Some(Timestamp::now()),
496 min_order_size: Some(Decimal::from_str("0.001").unwrap()),
497 market_type: Some(DydxTickerType::Perpetual),
498 initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
499 maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
500 base_position_notional: Some(Decimal::from_str("10000").unwrap()),
501 incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
502 incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
503 max_position_size: Some(Decimal::from_str("100").unwrap()),
504 open_interest: Decimal::from_str("1000000").unwrap(),
505 atomic_resolution: -10,
506 quantum_conversion_exponent: -10,
507 subticks_per_tick: 100,
508 step_base_quantums: 1000,
509 is_reduce_only: false,
510 }
511 }
512
513 #[rstest]
514 fn test_parse_instrument_any_valid() {
515 let market = create_test_market();
516 let ts_init = UnixNanos::default();
517
518 let result = parse_instrument_any(&market, ts_init);
519 assert!(result.is_ok());
520
521 let instrument = result.unwrap();
522 if let InstrumentAny::CryptoPerpetual(perp) = instrument {
523 assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
524 assert_eq!(perp.base_currency.code, "BTC");
525 assert_eq!(perp.quote_currency.code, "USD");
526 assert!(!perp.is_inverse);
527 assert_eq!(perp.price_increment.to_string(), "1");
528 assert_eq!(perp.size_increment.to_string(), "0.001");
529 } else {
530 panic!("Expected CryptoPerpetual instrument");
531 }
532 }
533
534 #[rstest]
535 fn test_is_market_active() {
536 assert!(is_market_active(&DydxMarketStatus::Active));
537 assert!(!is_market_active(&DydxMarketStatus::Paused));
538 assert!(!is_market_active(&DydxMarketStatus::CancelOnly));
539 assert!(!is_market_active(&DydxMarketStatus::PostOnly));
540 assert!(!is_market_active(&DydxMarketStatus::Initializing));
541 assert!(!is_market_active(&DydxMarketStatus::FinalSettlement));
542 }
543
544 #[rstest]
545 fn test_parse_instrument_any_invalid_ticker() {
546 let mut market = create_test_market();
547 market.ticker = Ustr::from("INVALID");
548
549 let result = parse_instrument_any(&market, UnixNanos::default());
550 assert!(result.is_err());
551 let error_msg = result.unwrap_err().to_string();
552 assert!(
554 error_msg.contains("Invalid ticker format")
555 || error_msg.contains("Failed to parse ticker"),
556 "Expected ticker format error, was: {error_msg}"
557 );
558 }
559
560 #[rstest]
561 fn test_parse_instrument_any_checked() {
562 let mut market = create_test_market();
563 market.tick_size = Decimal::ZERO;
564
565 let result = parse_instrument_any(&market, UnixNanos::default());
566
567 assert!(result.is_err());
568
569 let correctness_error = result.err().unwrap();
570
571 let not_positive = correctness_error
572 .downcast_ref::<CorrectnessError>()
573 .unwrap();
574
575 assert!(matches!(not_positive, CorrectnessError::NotPositive { .. }));
576 }
577
578 #[rstest]
579 fn test_validate_ticker_format_valid() {
580 assert!(validate_ticker_format("BTC-USD").is_ok());
581 assert!(validate_ticker_format("ETH-USD").is_ok());
582 assert!(validate_ticker_format("ATOM-USD").is_ok());
583 }
584
585 #[rstest]
586 fn test_validate_ticker_format_invalid() {
587 assert!(validate_ticker_format("BTCUSD").is_err());
589
590 assert!(validate_ticker_format("BTC-USD-PERP").is_err());
592
593 assert!(validate_ticker_format("-USD").is_err());
595
596 assert!(validate_ticker_format("BTC-").is_err());
598
599 assert!(validate_ticker_format("-").is_err());
601 }
602
603 #[rstest]
604 fn test_parse_ticker_currencies_valid() {
605 let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
606 assert_eq!(base, "BTC");
607 assert_eq!(quote, "USD");
608
609 let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
610 assert_eq!(base, "ETH");
611 assert_eq!(quote, "USDC");
612 }
613
614 #[rstest]
615 fn test_parse_ticker_currencies_invalid() {
616 assert!(parse_ticker_currencies("INVALID").is_err());
617 assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
618 }
619
620 #[rstest]
621 fn test_validate_stop_limit_buy_valid() {
622 let result = validate_conditional_order(
623 DydxOrderType::StopLimit,
624 Some(dec!(51000)), dec!(50000), OrderSide::Buy,
627 );
628 assert!(result.is_ok());
629 }
630
631 #[rstest]
632 fn test_validate_stop_limit_buy_invalid() {
633 let result = validate_conditional_order(
635 DydxOrderType::StopLimit,
636 Some(dec!(49000)),
637 dec!(50000),
638 OrderSide::Buy,
639 );
640 assert!(result.is_err());
641 assert!(
642 result
643 .unwrap_err()
644 .to_string()
645 .contains("must be >= limit price")
646 );
647 }
648
649 #[rstest]
650 fn test_validate_stop_limit_sell_valid() {
651 let result = validate_conditional_order(
652 DydxOrderType::StopLimit,
653 Some(dec!(49000)), dec!(50000), OrderSide::Sell,
656 );
657 assert!(result.is_ok());
658 }
659
660 #[rstest]
661 fn test_validate_stop_limit_sell_invalid() {
662 let result = validate_conditional_order(
664 DydxOrderType::StopLimit,
665 Some(dec!(51000)),
666 dec!(50000),
667 OrderSide::Sell,
668 );
669 assert!(result.is_err());
670 assert!(
671 result
672 .unwrap_err()
673 .to_string()
674 .contains("must be <= limit price")
675 );
676 }
677
678 #[rstest]
679 fn test_validate_take_profit_sell_valid() {
680 let result = validate_conditional_order(
681 DydxOrderType::TakeProfitLimit,
682 Some(dec!(51000)), dec!(50000), OrderSide::Sell,
685 );
686 assert!(result.is_ok());
687 }
688
689 #[rstest]
690 fn test_validate_take_profit_buy_valid() {
691 let result = validate_conditional_order(
692 DydxOrderType::TakeProfitLimit,
693 Some(dec!(49000)), dec!(50000), OrderSide::Buy,
696 );
697 assert!(result.is_ok());
698 }
699
700 #[rstest]
701 fn test_validate_missing_trigger_price() {
702 let result =
703 validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
704 assert!(result.is_err());
705 assert!(
706 result
707 .unwrap_err()
708 .to_string()
709 .contains("trigger_price required")
710 );
711 }
712
713 #[rstest]
714 fn test_validate_non_conditional_order() {
715 let result =
717 validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
718 assert!(result.is_ok());
719 }
720
721 #[rstest]
722 fn test_calculate_tif_market() {
723 let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
724 .unwrap();
725 assert_eq!(tif, TimeInForce::Ioc);
726 }
727
728 #[rstest]
729 fn test_calculate_tif_limit_post_only() {
730 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
731 .unwrap();
732 assert_eq!(tif, TimeInForce::Gtc); }
734
735 #[rstest]
736 fn test_calculate_tif_limit_gtc() {
737 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
738 .unwrap();
739 assert_eq!(tif, TimeInForce::Gtc);
740 }
741
742 #[rstest]
743 fn test_calculate_tif_stop_market_ioc() {
744 let tif = calculate_time_in_force(
745 DydxOrderType::StopMarket,
746 DydxTimeInForce::Gtt,
747 false,
748 Some(DydxOrderExecution::Ioc),
749 )
750 .unwrap();
751 assert_eq!(tif, TimeInForce::Ioc);
752 }
753
754 #[rstest]
755 fn test_calculate_tif_stop_limit_post_only() {
756 let tif = calculate_time_in_force(
757 DydxOrderType::StopLimit,
758 DydxTimeInForce::Gtt,
759 false,
760 Some(DydxOrderExecution::PostOnly),
761 )
762 .unwrap();
763 assert_eq!(tif, TimeInForce::Gtc); }
765
766 #[rstest]
767 fn test_calculate_tif_stop_limit_gtc() {
768 let tif =
769 calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
770 .unwrap();
771 assert_eq!(tif, TimeInForce::Gtc);
772 }
773
774 #[rstest]
775 fn test_calculate_tif_stop_market_invalid_post_only() {
776 let result = calculate_time_in_force(
777 DydxOrderType::StopMarket,
778 DydxTimeInForce::Gtt,
779 false,
780 Some(DydxOrderExecution::PostOnly),
781 );
782 assert!(result.is_err());
783 assert!(
784 result
785 .unwrap_err()
786 .to_string()
787 .contains("PostOnly not supported")
788 );
789 }
790
791 #[rstest]
792 fn test_calculate_tif_trailing_stop() {
793 let tif = calculate_time_in_force(
794 DydxOrderType::TrailingStop,
795 DydxTimeInForce::Gtt,
796 false,
797 None,
798 )
799 .unwrap();
800 assert_eq!(tif, TimeInForce::Gtc);
801 }
802
803 #[rstest]
804 fn test_parse_perpetual_markets() {
805 let json = load_json_result_fixture("http_get_perpetual_markets.json");
806 let response: MarketsResponse =
807 serde_json::from_value(json).expect("Failed to parse markets");
808
809 assert_eq!(response.markets.len(), 3);
810 assert!(response.markets.contains_key("BTC-USD"));
811 assert!(response.markets.contains_key("ETH-USD"));
812 assert!(response.markets.contains_key("SOL-USD"));
813
814 let btc = response.markets.get("BTC-USD").unwrap();
815 assert_eq!(btc.ticker, "BTC-USD");
816 assert_eq!(btc.clob_pair_id, 0);
817 assert_eq!(btc.atomic_resolution, -10);
818 }
819
820 #[rstest]
821 fn test_parse_perpetual_market_with_null_oracle_price() {
822 let json = serde_json::json!({
823 "markets": {
824 "WTI-USD": {
825 "clobPairId": "99",
826 "ticker": "WTI-USD",
827 "status": "ACTIVE",
828 "oraclePrice": null,
829 "priceChange24H": "0",
830 "nextFundingRate": "0",
831 "initialMarginFraction": "0.1",
832 "maintenanceMarginFraction": "0.05",
833 "openInterest": "0",
834 "atomicResolution": -7,
835 "quantumConversionExponent": -9,
836 "tickSize": "0.01",
837 "stepSize": "0.1",
838 "stepBaseQuantums": 1000000,
839 "subticksPerTick": 1000000
840 }
841 }
842 });
843 let response: MarketsResponse =
844 serde_json::from_value(json).expect("Failed to parse market with null oraclePrice");
845
846 let wti = response.markets.get("WTI-USD").unwrap();
847 assert_eq!(wti.ticker.as_str(), "WTI-USD");
848 assert_eq!(wti.oracle_price, None);
849 }
850
851 #[rstest]
852 fn test_parse_perpetual_market_with_missing_oracle_price() {
853 let json = serde_json::json!({
854 "markets": {
855 "WTI-USD": {
856 "clobPairId": "99",
857 "ticker": "WTI-USD",
858 "status": "ACTIVE",
859 "priceChange24H": "0",
860 "nextFundingRate": "0",
861 "initialMarginFraction": "0.1",
862 "maintenanceMarginFraction": "0.05",
863 "openInterest": "0",
864 "atomicResolution": -7,
865 "quantumConversionExponent": -9,
866 "tickSize": "0.01",
867 "stepSize": "0.1",
868 "stepBaseQuantums": 1000000,
869 "subticksPerTick": 1000000
870 }
871 }
872 });
873 let response: MarketsResponse =
874 serde_json::from_value(json).expect("Failed to parse market with missing oraclePrice");
875
876 let wti = response.markets.get("WTI-USD").unwrap();
877 assert_eq!(wti.oracle_price, None);
878 }
879
880 #[rstest]
881 fn test_parse_instrument_from_market() {
882 let json = load_json_result_fixture("http_get_perpetual_markets.json");
883 let response: MarketsResponse =
884 serde_json::from_value(json).expect("Failed to parse markets");
885 let btc = response.markets.get("BTC-USD").unwrap();
886
887 let ts_init = UnixNanos::default();
888 let instrument = parse_instrument_any(btc, ts_init).expect("Failed to parse instrument");
889
890 assert_eq!(instrument.id().symbol.as_str(), "BTC-USD-PERP");
891 assert_eq!(instrument.id().venue.as_str(), "DYDX");
892 }
893
894 #[rstest]
895 fn test_parse_orderbook_response() {
896 let json = load_json_result_fixture("http_get_orderbook.json");
897 let response: OrderbookResponse =
898 serde_json::from_value(json).expect("Failed to parse orderbook");
899
900 assert_eq!(response.bids.len(), 5);
901 assert_eq!(response.asks.len(), 5);
902
903 let best_bid = &response.bids[0];
904 assert_eq!(best_bid.price.to_string(), "89947");
905 assert_eq!(best_bid.size.to_string(), "0.0002");
906
907 let best_ask = &response.asks[0];
908 assert_eq!(best_ask.price.to_string(), "89958");
909 assert_eq!(best_ask.size.to_string(), "0.1177");
910 }
911
912 #[rstest]
913 fn test_parse_trades_response() {
914 let json = load_json_result_fixture("http_get_trades.json");
915 let response: TradesResponse =
916 serde_json::from_value(json).expect("Failed to parse trades");
917
918 assert_eq!(response.trades.len(), 3);
919
920 let first_trade = &response.trades[0];
921 assert_eq!(first_trade.id, "03f89a550000000200000002");
922 assert_eq!(first_trade.side, OrderSide::Buy);
923 assert_eq!(first_trade.price.to_string(), "89942");
924 assert_eq!(first_trade.size.to_string(), "0.0001");
925 }
926
927 #[rstest]
928 fn test_parse_candles_response() {
929 let json = load_json_result_fixture("http_get_candles.json");
930 let response: CandlesResponse =
931 serde_json::from_value(json).expect("Failed to parse candles");
932
933 assert_eq!(response.candles.len(), 3);
934
935 let first_candle = &response.candles[0];
936 assert_eq!(first_candle.ticker, "BTC-USD");
937 assert_eq!(first_candle.open.to_string(), "89934");
938 assert_eq!(first_candle.high.to_string(), "89970");
939 assert_eq!(first_candle.low.to_string(), "89911");
940 assert_eq!(first_candle.close.to_string(), "89941");
941 }
942
943 #[rstest]
944 fn test_parse_subaccount_response() {
945 let json = load_json_result_fixture("http_get_subaccount.json");
946 let response: SubaccountResponse =
947 serde_json::from_value(json).expect("Failed to parse subaccount");
948
949 let subaccount = &response.subaccount;
950 assert_eq!(subaccount.subaccount_number, 0);
951 assert_eq!(subaccount.equity.to_string(), "45.201296");
952 assert_eq!(subaccount.free_collateral.to_string(), "45.201296");
953 assert!(subaccount.margin_enabled);
954 assert_eq!(subaccount.open_perpetual_positions.len(), 0);
955 }
956
957 #[rstest]
958 fn test_parse_orders_response() {
959 let json = load_json_result_fixture("http_get_orders.json");
960 let response: Vec<Order> = serde_json::from_value(json).expect("Failed to parse orders");
961
962 assert_eq!(response.len(), 3);
963
964 let first_order = &response[0];
965 assert_eq!(first_order.id, "0f0981cb-152e-57d3-bea9-4d8e0dd5ed35");
966 assert_eq!(first_order.side, OrderSide::Buy);
967 assert_eq!(first_order.order_type, DydxOrderType::Limit);
968 assert!(first_order.reduce_only);
969
970 let second_order = &response[1];
971 assert_eq!(second_order.side, OrderSide::Sell);
972 assert!(!second_order.reduce_only);
973 }
974
975 #[rstest]
976 fn test_parse_fills_response() {
977 let json = load_json_result_fixture("http_get_fills.json");
978 let response: FillsResponse = serde_json::from_value(json).expect("Failed to parse fills");
979
980 assert_eq!(response.fills.len(), 3);
981
982 let first_fill = &response.fills[0];
983 assert_eq!(first_fill.id, "6450e369-1dc3-5229-8dc2-fb3b5d1cf2ab");
984 assert_eq!(first_fill.side, OrderSide::Buy);
985 assert_eq!(first_fill.market, "BTC-USD");
986 assert_eq!(first_fill.price.to_string(), "105117");
987 }
988
989 #[rstest]
990 fn test_parse_transfers_response() {
991 let json = load_json_result_fixture("http_get_transfers.json");
992 let response: TransfersResponse =
993 serde_json::from_value(json).expect("Failed to parse transfers");
994
995 assert_eq!(response.transfers.len(), 1);
996
997 let deposit = &response.transfers[0];
998 assert_eq!(deposit.transfer_type, DydxTransferType::Deposit);
999 assert_eq!(deposit.asset, "USDC");
1000 assert_eq!(deposit.amount.to_string(), "45.334703");
1001 }
1002
1003 #[rstest]
1004 fn test_transfer_type_enum_serde() {
1005 let test_cases = vec![
1007 (DydxTransferType::Deposit, "\"DEPOSIT\""),
1008 (DydxTransferType::Withdrawal, "\"WITHDRAWAL\""),
1009 (DydxTransferType::TransferIn, "\"TRANSFER_IN\""),
1010 (DydxTransferType::TransferOut, "\"TRANSFER_OUT\""),
1011 ];
1012
1013 for (variant, expected_json) in test_cases {
1014 let serialized = serde_json::to_string(&variant).expect("Failed to serialize");
1016 assert_eq!(
1017 serialized, expected_json,
1018 "Serialization failed for {variant:?}"
1019 );
1020
1021 let deserialized: DydxTransferType =
1023 serde_json::from_str(&serialized).expect("Failed to deserialize");
1024 assert_eq!(
1025 deserialized, variant,
1026 "Deserialization failed for {variant:?}"
1027 );
1028 }
1029 }
1030
1031 #[rstest]
1032 fn test_parse_trade_tick() {
1033 let json = load_json_result_fixture("http_get_trades.json");
1034 let response: TradesResponse =
1035 serde_json::from_value(json).expect("Failed to parse trades");
1036
1037 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1038 let ts_init = UnixNanos::from(1_000_000_000u64);
1039
1040 let tick = parse_trade_tick(&response.trades[0], instrument_id, 0, 4, ts_init)
1041 .expect("Failed to parse trade tick");
1042
1043 assert_eq!(tick.instrument_id, instrument_id);
1044 assert_eq!(tick.price.to_string(), "89942");
1045 assert_eq!(tick.size.to_string(), "0.0001");
1046 assert_eq!(tick.aggressor_side, AggressorSide::Buy);
1047 assert_eq!(tick.trade_id.to_string(), "03f89a550000000200000002");
1048 assert_eq!(tick.ts_init, ts_init);
1049 }
1050
1051 #[rstest]
1052 #[case(true)]
1053 #[case(false)]
1054 fn test_parse_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
1055 let json = load_json_result_fixture("http_get_candles.json");
1056 let response: CandlesResponse =
1057 serde_json::from_value(json).expect("Failed to parse candles");
1058
1059 let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
1060 .expect("Failed to parse bar type");
1061 let ts_init = UnixNanos::from(1_000_000_000u64);
1062
1063 let bar = parse_bar(
1064 &response.candles[0],
1065 bar_type,
1066 0,
1067 4,
1068 timestamp_on_close,
1069 ts_init,
1070 )
1071 .expect("Failed to parse bar");
1072
1073 assert_eq!(bar.bar_type, bar_type);
1074 assert_eq!(bar.open.to_string(), "89934");
1075 assert_eq!(bar.high.to_string(), "89970");
1076 assert_eq!(bar.low.to_string(), "89911");
1077 assert_eq!(bar.close.to_string(), "89941");
1078 assert_eq!(bar.volume.to_string(), "3.2767");
1079
1080 let started_at_ns = 1_765_210_260_000_000_000u64;
1082 let one_min_ns = 60_000_000_000u64;
1083
1084 if timestamp_on_close {
1085 assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
1086 } else {
1087 assert_eq!(bar.ts_event.as_u64(), started_at_ns);
1088 }
1089 }
1090}
1091
1092use std::str::FromStr;
1093
1094use nautilus_core::UUID4;
1095use nautilus_model::{
1096 enums::{LiquiditySide, OrderStatus, OrderType, PositionSide, TriggerType},
1097 identifiers::{AccountId, ClientOrderId, VenueOrderId},
1098 instruments::Instrument,
1099 reports::{FillReport, OrderStatusReport, PositionStatusReport},
1100 types::Money,
1101};
1102
1103use super::models::{Fill, Order, PerpetualPosition};
1104use crate::common::enums::{DydxConditionType, DydxLiquidity, DydxOrderStatus};
1105#[cfg(test)]
1106use crate::common::enums::{DydxFillType, DydxPositionSide, DydxPositionStatus, DydxTickerType};
1107
1108fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
1110 match status {
1111 DydxOrderStatus::Open => OrderStatus::Accepted,
1112 DydxOrderStatus::Filled => OrderStatus::Filled,
1113 DydxOrderStatus::Canceled => OrderStatus::Canceled,
1114 DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
1115 DydxOrderStatus::Untriggered => OrderStatus::Accepted, DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
1117 DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1118 }
1119}
1120
1121pub fn parse_order_status_report(
1127 order: &Order,
1128 instrument: &InstrumentAny,
1129 account_id: AccountId,
1130 ts_init: UnixNanos,
1131) -> anyhow::Result<OrderStatusReport> {
1132 let instrument_id = instrument.id();
1133 let venue_order_id = VenueOrderId::new(&order.id);
1134 let client_order_id = if order.client_id.is_empty() {
1135 None
1136 } else {
1137 Some(ClientOrderId::new(&order.client_id))
1138 };
1139
1140 let mut order_type: OrderType = order.order_type.into();
1141 let mut dydx_order_type = order.order_type;
1144
1145 if order_type == OrderType::LimitIfTouched
1155 && let Some(trigger_dec) = order.trigger_price
1156 && !trigger_dec.is_zero()
1157 {
1158 let drift = (order.price - trigger_dec).abs() / trigger_dec;
1159 if drift >= rust_decimal::Decimal::new(2, 2) {
1160 order_type = OrderType::MarketIfTouched;
1161 dydx_order_type = DydxOrderType::TakeProfitMarket;
1162 }
1163 }
1164
1165 let execution = order.execution.or({
1166 if order.post_only {
1168 Some(DydxOrderExecution::PostOnly)
1169 } else {
1170 Some(DydxOrderExecution::Default)
1171 }
1172 });
1173 let time_in_force = calculate_time_in_force(
1174 dydx_order_type,
1175 order.time_in_force,
1176 order.reduce_only,
1177 execution,
1178 )?;
1179
1180 let order_side = order.side;
1181 let order_status = parse_order_status(&order.status);
1182
1183 let size_precision = instrument.size_precision();
1184 let quantity = Quantity::from_decimal_dp(order.size, size_precision)
1185 .context("failed to parse order size")?;
1186 let filled_qty = Quantity::from_decimal_dp(order.total_filled, size_precision)
1187 .context("failed to parse total_filled")?;
1188
1189 let price_precision = instrument.price_precision();
1190 let price = Price::from_decimal_dp(order.price, price_precision)
1191 .context("failed to parse order price")?;
1192
1193 let ts_accepted = order.updated_at.map_or(ts_init, |dt| {
1195 UnixNanos::from(dt.as_millisecond() as u64 * 1_000_000)
1196 });
1197 let ts_last = ts_accepted;
1198
1199 let mut report = OrderStatusReport::new(
1200 account_id,
1201 instrument_id,
1202 client_order_id,
1203 venue_order_id,
1204 order_side.into(),
1205 order_type,
1206 time_in_force,
1207 order_status,
1208 quantity,
1209 filled_qty,
1210 ts_accepted,
1211 ts_last,
1212 ts_init,
1213 Some(UUID4::new()),
1214 );
1215
1216 report = report.with_price(price);
1217
1218 if let Some(trigger_price_dec) = order.trigger_price {
1219 let trigger_price = Price::from_decimal_dp(trigger_price_dec, instrument.price_precision())
1220 .context("failed to parse trigger_price")?;
1221 report = report.with_trigger_price(trigger_price);
1222
1223 let trigger_type = match order.condition_type {
1224 Some(DydxConditionType::StopLoss) => TriggerType::LastPrice,
1225 Some(DydxConditionType::TakeProfit) => TriggerType::LastPrice,
1226 Some(DydxConditionType::Unspecified) | None => TriggerType::Default,
1227 };
1228 report = report.with_trigger_type(trigger_type);
1229 }
1230
1231 if let Some(good_til_block_time) = order.good_til_block_time {
1232 let expire_ns = good_til_block_time.as_millisecond() as u64 * 1_000_000;
1233 report = report.with_expire_time(UnixNanos::from(expire_ns));
1234
1235 if report.order_status == OrderStatus::Canceled
1239 && report.ts_last >= UnixNanos::from(expire_ns)
1240 {
1241 report.order_status = OrderStatus::Expired;
1242 }
1243 }
1244
1245 Ok(report)
1246}
1247
1248pub fn parse_fill_report(
1254 fill: &Fill,
1255 instrument: &InstrumentAny,
1256 account_id: AccountId,
1257 ts_init: UnixNanos,
1258) -> anyhow::Result<FillReport> {
1259 let instrument_id = instrument.id();
1260 let venue_order_id = VenueOrderId::new(&fill.order_id);
1261 let trade_id = TradeId::new(&fill.id);
1262 let order_side = fill.side;
1263
1264 match fill.fill_type {
1269 crate::common::enums::DydxFillType::Liquidated
1270 | crate::common::enums::DydxFillType::Liquidation => {
1271 log::warn!(
1272 "Liquidation fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1273 instrument_id,
1274 fill.id,
1275 fill.order_id,
1276 fill.fill_type,
1277 order_side,
1278 fill.size,
1279 fill.price,
1280 );
1281 }
1282 crate::common::enums::DydxFillType::Deleveraged
1283 | crate::common::enums::DydxFillType::Offsetting => {
1284 log::warn!(
1285 "Deleveraging (ADL) fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1286 instrument_id,
1287 fill.id,
1288 fill.order_id,
1289 fill.fill_type,
1290 order_side,
1291 fill.size,
1292 fill.price,
1293 );
1294 }
1295 crate::common::enums::DydxFillType::Limit => {}
1296 crate::common::enums::DydxFillType::Unknown => {
1297 log::warn!(
1298 "Unmodeled dYdX fill type: {} id={} order_id={} side={:?} size={} price={}",
1299 instrument_id,
1300 fill.id,
1301 fill.order_id,
1302 order_side,
1303 fill.size,
1304 fill.price,
1305 );
1306 }
1307 }
1308
1309 let size_precision = instrument.size_precision();
1310 let price_precision = instrument.price_precision();
1311
1312 let last_qty = Quantity::from_decimal_dp(fill.size, size_precision)
1313 .context("failed to parse fill size")?;
1314 let last_px = Price::from_decimal_dp(fill.price, price_precision)
1315 .context("failed to parse fill price")?;
1316
1317 let commission = Money::from_decimal(fill.fee, instrument.quote_currency())
1319 .context("failed to parse fee")?;
1320
1321 let liquidity_side = match fill.liquidity {
1322 DydxLiquidity::Maker => LiquiditySide::Maker,
1323 DydxLiquidity::Taker => LiquiditySide::Taker,
1324 };
1325
1326 let ts_event = UnixNanos::from(fill.created_at.as_millisecond() as u64 * 1_000_000);
1327
1328 let report = FillReport::new(
1329 account_id,
1330 instrument_id,
1331 venue_order_id,
1332 trade_id,
1333 order_side,
1334 last_qty,
1335 last_px,
1336 commission,
1337 liquidity_side,
1338 None, None, ts_event,
1341 ts_init,
1342 Some(UUID4::new()),
1343 );
1344
1345 Ok(report)
1346}
1347
1348pub fn parse_position_status_report(
1354 position: &PerpetualPosition,
1355 instrument: &InstrumentAny,
1356 account_id: AccountId,
1357 ts_init: UnixNanos,
1358) -> anyhow::Result<PositionStatusReport> {
1359 let instrument_id = instrument.id();
1360
1361 let position_side = if position.status.is_closed() || position.size.is_zero() {
1366 PositionSide::Flat
1367 } else {
1368 PositionSide::from(position.side)
1369 };
1370
1371 let quantity = Quantity::from_decimal_dp(position.size.abs(), instrument.size_precision())
1373 .context("failed to parse position size")?;
1374
1375 let avg_px_open = position.entry_price;
1376 let ts_last = UnixNanos::from(position.created_at.as_millisecond() as u64 * 1_000_000);
1377
1378 Ok(PositionStatusReport::new(
1379 account_id,
1380 instrument_id,
1381 position_side,
1382 quantity,
1383 ts_last,
1384 ts_init,
1385 Some(UUID4::new()),
1386 None, Some(avg_px_open),
1388 ))
1389}
1390
1391pub fn parse_account_state(
1406 subaccount: &DydxSubaccountInfo,
1407 account_id: AccountId,
1408 instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1409 oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1410 ts_event: UnixNanos,
1411 ts_init: UnixNanos,
1412) -> anyhow::Result<AccountState> {
1413 use std::collections::HashMap;
1414
1415 use nautilus_model::{
1416 enums::AccountType,
1417 events::AccountState,
1418 types::{AccountBalance, MarginBalance},
1419 };
1420
1421 let mut balances = Vec::new();
1422
1423 let equity: Decimal = if subaccount.equity.is_empty() {
1425 Decimal::ZERO
1426 } else {
1427 subaccount
1428 .equity
1429 .parse()
1430 .context(format!("Failed to parse equity '{}'", subaccount.equity))?
1431 };
1432
1433 let free_collateral: Decimal = if subaccount.free_collateral.is_empty() {
1434 Decimal::ZERO
1435 } else {
1436 subaccount.free_collateral.parse().context(format!(
1437 "Failed to parse freeCollateral '{}'",
1438 subaccount.free_collateral
1439 ))?
1440 };
1441
1442 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1444
1445 let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1446 .context("failed to derive account balance from subaccount data")?;
1447 balances.push(balance);
1448
1449 let mut margins = Vec::new();
1451 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1452 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1453
1454 if let Some(ref positions) = subaccount.open_perpetual_positions {
1455 for position in positions.values() {
1456 let market_str = position.market.as_str();
1458 let instrument_id = parse_instrument_id(market_str);
1459
1460 let instrument = match instruments.get(&instrument_id) {
1462 Some(inst) => inst,
1463 None => {
1464 log::warn!(
1465 "Cannot calculate margin for position {market_str}: instrument not found"
1466 );
1467 continue;
1468 }
1469 };
1470
1471 let (margin_init, margin_maint) = match instrument {
1473 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1474 _ => {
1475 log::warn!(
1476 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1477 );
1478 continue;
1479 }
1480 };
1481
1482 let position_size = match Decimal::from_str(&position.size) {
1484 Ok(size) => size.abs(),
1485 Err(e) => {
1486 log::warn!(
1487 "Failed to parse position size '{}' for {}: {}",
1488 position.size,
1489 market_str,
1490 e
1491 );
1492 continue;
1493 }
1494 };
1495
1496 if position_size.is_zero() {
1498 continue;
1499 }
1500
1501 let oracle_price = oracle_prices
1503 .get(&instrument_id)
1504 .copied()
1505 .or_else(|| Decimal::from_str(&position.entry_price).ok())
1506 .unwrap_or(Decimal::ZERO);
1507
1508 if oracle_price.is_zero() {
1509 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1510 continue;
1511 }
1512
1513 let initial_margin = margin_init * position_size * oracle_price;
1515
1516 let maintenance_margin = margin_maint * position_size * oracle_price;
1517
1518 let quote_currency = instrument.quote_currency();
1520 *initial_margins
1521 .entry(quote_currency)
1522 .or_insert(Decimal::ZERO) += initial_margin;
1523 *maintenance_margins
1524 .entry(quote_currency)
1525 .or_insert(Decimal::ZERO) += maintenance_margin;
1526 }
1527 }
1528
1529 for (currency, initial_margin) in initial_margins {
1531 let maintenance_margin = maintenance_margins
1532 .get(¤cy)
1533 .copied()
1534 .unwrap_or(Decimal::ZERO);
1535
1536 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1537 "Failed to create initial margin Money for {currency}"
1538 ))?;
1539 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1540 format!("Failed to create maintenance margin Money for {currency}"),
1541 )?;
1542
1543 let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1546 margins.push(margin_balance);
1547 }
1548
1549 Ok(AccountState::new(
1550 account_id,
1551 AccountType::Margin, balances,
1553 margins,
1554 true, UUID4::new(),
1556 ts_event,
1557 ts_init,
1558 None, ))
1560}
1561
1562pub fn parse_account_state_from_http(
1573 subaccount: &Subaccount,
1574 account_id: AccountId,
1575 instruments: &HashMap<InstrumentId, InstrumentAny>,
1576 oracle_prices: &HashMap<InstrumentId, Decimal>,
1577 ts_event: UnixNanos,
1578 ts_init: UnixNanos,
1579) -> anyhow::Result<AccountState> {
1580 let mut balances = Vec::new();
1581
1582 let equity = subaccount.equity;
1583 let free_collateral = subaccount.free_collateral;
1584
1585 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1587
1588 let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1589 .context("failed to derive account balance from subaccount data")?;
1590 balances.push(balance);
1591
1592 let mut margins = Vec::new();
1594 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1595 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1596
1597 for position in subaccount.open_perpetual_positions.values() {
1598 let market_str = position.market.as_str();
1599 let instrument_id = parse_instrument_id(market_str);
1600
1601 let instrument = match instruments.get(&instrument_id) {
1602 Some(inst) => inst,
1603 None => {
1604 log::warn!(
1605 "Cannot calculate margin for position {market_str}: instrument not found"
1606 );
1607 continue;
1608 }
1609 };
1610
1611 let (margin_init, margin_maint) = match instrument {
1612 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1613 _ => {
1614 log::warn!(
1615 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1616 );
1617 continue;
1618 }
1619 };
1620
1621 let position_size = position.size.abs();
1622
1623 if position_size.is_zero() {
1624 continue;
1625 }
1626
1627 let oracle_price = oracle_prices
1629 .get(&instrument_id)
1630 .copied()
1631 .unwrap_or(position.entry_price);
1632
1633 if oracle_price.is_zero() {
1634 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1635 continue;
1636 }
1637
1638 let initial_margin = margin_init * position_size * oracle_price;
1639 let maintenance_margin = margin_maint * position_size * oracle_price;
1640
1641 let quote_currency = instrument.quote_currency();
1642 *initial_margins
1643 .entry(quote_currency)
1644 .or_insert(Decimal::ZERO) += initial_margin;
1645 *maintenance_margins
1646 .entry(quote_currency)
1647 .or_insert(Decimal::ZERO) += maintenance_margin;
1648 }
1649
1650 for (currency, initial_margin) in initial_margins {
1651 let maintenance_margin = maintenance_margins
1652 .get(¤cy)
1653 .copied()
1654 .unwrap_or(Decimal::ZERO);
1655
1656 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1657 "Failed to create initial margin Money for {currency}"
1658 ))?;
1659 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1660 format!("Failed to create maintenance margin Money for {currency}"),
1661 )?;
1662
1663 let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1664 margins.push(margin_balance);
1665 }
1666
1667 Ok(AccountState::new(
1668 account_id,
1669 AccountType::Margin,
1670 balances,
1671 margins,
1672 true, UUID4::new(),
1674 ts_event,
1675 ts_init,
1676 None, ))
1678}
1679
1680#[cfg(test)]
1681mod reconciliation_tests {
1682 use jiff::Timestamp;
1683 use nautilus_model::{
1684 enums::{OrderSide, OrderStatus, PositionSide, TimeInForce},
1685 identifiers::{AccountId, InstrumentId, Symbol},
1686 instruments::{CryptoPerpetual, Instrument},
1687 types::Currency,
1688 };
1689 use rstest::rstest;
1690 use rust_decimal::prelude::ToPrimitive;
1691 use rust_decimal_macros::dec;
1692 use ustr::Ustr;
1693
1694 use super::*;
1695 use crate::common::consts::DYDX_VENUE;
1696
1697 fn create_test_instrument() -> InstrumentAny {
1698 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *DYDX_VENUE);
1699
1700 InstrumentAny::CryptoPerpetual(
1701 CryptoPerpetual::builder()
1702 .instrument_id(instrument_id)
1703 .raw_symbol(instrument_id.symbol)
1704 .base_currency(Currency::BTC())
1705 .quote_currency(Currency::USD())
1706 .settlement_currency(Currency::USD())
1707 .is_inverse(false)
1708 .price_precision(2)
1709 .size_precision(8)
1710 .price_increment(Price::new(0.01, 2))
1711 .size_increment(Quantity::new(0.001, 8))
1712 .multiplier(Quantity::new(1.0, 0))
1713 .lot_size(Quantity::new(0.001, 8))
1714 .max_quantity(Quantity::new(100000.0, 8))
1715 .min_quantity(Quantity::new(0.001, 8))
1716 .max_price(Price::new(1000000.0, 2))
1717 .min_price(Price::new(0.01, 2))
1718 .margin_init(dec!(0.05))
1719 .margin_maint(dec!(0.03))
1720 .ts_event(UnixNanos::default())
1721 .ts_init(UnixNanos::default())
1722 .build()
1723 .unwrap(),
1724 )
1725 }
1726
1727 #[rstest]
1728 fn test_parse_order_status() {
1729 assert_eq!(
1730 parse_order_status(&DydxOrderStatus::Open),
1731 OrderStatus::Accepted
1732 );
1733 assert_eq!(
1734 parse_order_status(&DydxOrderStatus::Filled),
1735 OrderStatus::Filled
1736 );
1737 assert_eq!(
1738 parse_order_status(&DydxOrderStatus::Canceled),
1739 OrderStatus::Canceled
1740 );
1741 assert_eq!(
1742 parse_order_status(&DydxOrderStatus::PartiallyFilled),
1743 OrderStatus::PartiallyFilled
1744 );
1745 assert_eq!(
1746 parse_order_status(&DydxOrderStatus::Untriggered),
1747 OrderStatus::Accepted
1748 );
1749 }
1750
1751 #[rstest]
1752 fn test_parse_order_status_report_basic() {
1753 let instrument = create_test_instrument();
1754 let account_id = AccountId::new("DYDX-001");
1755 let ts_init = UnixNanos::default();
1756
1757 let order = Order {
1758 id: "order123".to_string(),
1759 subaccount_id: "subacct1".to_string(),
1760 client_id: "client1".to_string(),
1761 clob_pair_id: 1,
1762 side: OrderSide::Buy,
1763 size: dec!(1.5),
1764 total_filled: dec!(1.0),
1765 price: dec!(50000.0),
1766 status: DydxOrderStatus::PartiallyFilled,
1767 order_type: DydxOrderType::Limit,
1768 time_in_force: DydxTimeInForce::Gtt,
1769 reduce_only: false,
1770 post_only: false,
1771 order_flags: 0,
1772 good_til_block: None,
1773 good_til_block_time: Some(Timestamp::now()),
1774 created_at_height: Some(1000),
1775 client_metadata: 0,
1776 trigger_price: None,
1777 condition_type: None,
1778 conditional_order_trigger_subticks: None,
1779 execution: None,
1780 updated_at: Some(Timestamp::now()),
1781 updated_at_height: Some(1001),
1782 ticker: None,
1783 subaccount_number: 0,
1784 order_router_address: None,
1785 };
1786
1787 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1788 if let Err(ref e) = result {
1789 eprintln!("Parse error: {e:?}");
1790 }
1791 assert!(result.is_ok());
1792
1793 let report = result.unwrap();
1794 assert_eq!(report.account_id, account_id);
1795 assert_eq!(report.instrument_id, instrument.id());
1796 assert_eq!(report.order_side, Some(OrderSide::Buy));
1797 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1798 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1799 }
1800
1801 #[rstest]
1802 fn test_parse_order_status_report_conditional() {
1803 let instrument = create_test_instrument();
1804 let account_id = AccountId::new("DYDX-001");
1805 let ts_init = UnixNanos::default();
1806
1807 let order = Order {
1808 id: "order456".to_string(),
1809 subaccount_id: "subacct1".to_string(),
1810 client_id: String::new(), clob_pair_id: 1,
1812 side: OrderSide::Sell,
1813 size: dec!(2.0),
1814 total_filled: dec!(0.0),
1815 price: dec!(51000.0),
1816 status: DydxOrderStatus::Untriggered,
1817 order_type: DydxOrderType::StopLimit,
1818 time_in_force: DydxTimeInForce::Gtt,
1819 reduce_only: true,
1820 post_only: false,
1821 order_flags: 0,
1822 good_til_block: None,
1823 good_til_block_time: Some(Timestamp::now()),
1824 created_at_height: Some(1000),
1825 client_metadata: 0,
1826 trigger_price: Some(dec!(49000.0)),
1827 condition_type: Some(DydxConditionType::StopLoss),
1828 conditional_order_trigger_subticks: Some(490000),
1829 execution: None,
1830 updated_at: Some(Timestamp::now()),
1831 updated_at_height: Some(1001),
1832 ticker: None,
1833 subaccount_number: 0,
1834 order_router_address: None,
1835 };
1836
1837 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1838 assert!(result.is_ok());
1839
1840 let report = result.unwrap();
1841 assert_eq!(report.client_order_id, None);
1842 assert!(report.trigger_price.is_some());
1843 assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1844 }
1845
1846 #[rstest]
1850 fn test_parse_order_status_report_canceled_after_expiry_becomes_expired() {
1851 use jiff::SignedDuration;
1852
1853 let instrument = create_test_instrument();
1854 let account_id = AccountId::new("DYDX-001");
1855 let now = Timestamp::now();
1856 let ts_init = UnixNanos::from(now.as_millisecond() as u64 * 1_000_000);
1857
1858 let expired_at = now - SignedDuration::from_hours(1);
1860
1861 let order = Order {
1862 id: "order-expired".to_string(),
1863 subaccount_id: "subacct1".to_string(),
1864 client_id: "client1".to_string(),
1865 clob_pair_id: 1,
1866 side: OrderSide::Buy,
1867 size: dec!(1.0),
1868 total_filled: dec!(0),
1869 price: dec!(50000.0),
1870 status: DydxOrderStatus::Canceled,
1871 order_type: DydxOrderType::Limit,
1872 time_in_force: DydxTimeInForce::Gtt,
1873 reduce_only: false,
1874 post_only: false,
1875 order_flags: 0,
1876 good_til_block: None,
1877 good_til_block_time: Some(expired_at),
1878 created_at_height: Some(1000),
1879 client_metadata: 0,
1880 trigger_price: None,
1881 condition_type: None,
1882 conditional_order_trigger_subticks: None,
1883 execution: None,
1884 updated_at: Some(now),
1885 updated_at_height: Some(1001),
1886 ticker: None,
1887 subaccount_number: 0,
1888 order_router_address: None,
1889 };
1890
1891 let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
1892 assert_eq!(report.order_status, OrderStatus::Expired);
1893 assert!(report.expire_time.is_some());
1894 }
1895
1896 #[rstest]
1899 fn test_parse_order_status_report_canceled_before_expiry_stays_canceled() {
1900 use jiff::SignedDuration;
1901
1902 let instrument = create_test_instrument();
1903 let account_id = AccountId::new("DYDX-001");
1904 let now = Timestamp::now();
1905 let ts_init = UnixNanos::from(now.as_millisecond() as u64 * 1_000_000);
1906 let future_expiry = now + SignedDuration::from_hours(1);
1907
1908 let order = Order {
1909 id: "order-cancel".to_string(),
1910 subaccount_id: "subacct1".to_string(),
1911 client_id: "client1".to_string(),
1912 clob_pair_id: 1,
1913 side: OrderSide::Buy,
1914 size: dec!(1.0),
1915 total_filled: dec!(0),
1916 price: dec!(50000.0),
1917 status: DydxOrderStatus::Canceled,
1918 order_type: DydxOrderType::Limit,
1919 time_in_force: DydxTimeInForce::Gtt,
1920 reduce_only: false,
1921 post_only: false,
1922 order_flags: 0,
1923 good_til_block: None,
1924 good_til_block_time: Some(future_expiry),
1925 created_at_height: Some(1000),
1926 client_metadata: 0,
1927 trigger_price: None,
1928 condition_type: None,
1929 conditional_order_trigger_subticks: None,
1930 execution: None,
1931 updated_at: Some(now),
1932 updated_at_height: Some(1001),
1933 ticker: None,
1934 subaccount_number: 0,
1935 order_router_address: None,
1936 };
1937
1938 let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
1939 assert_eq!(report.order_status, OrderStatus::Canceled);
1940 }
1941
1942 #[rstest]
1949 #[case(OrderSide::Buy, dec!(50000.0), dec!(50100.0), OrderType::LimitIfTouched, TimeInForce::Gtc)]
1950 #[case(OrderSide::Buy, dec!(50000.0), dec!(52500.0), OrderType::MarketIfTouched, TimeInForce::Ioc)]
1951 #[case(OrderSide::Sell, dec!(50000.0), dec!(49900.0), OrderType::LimitIfTouched, TimeInForce::Gtc)]
1952 #[case(OrderSide::Sell, dec!(50000.0), dec!(47500.0), OrderType::MarketIfTouched, TimeInForce::Ioc)]
1953 #[case(OrderSide::Buy, dec!(50000.0), dec!(51000.0), OrderType::MarketIfTouched, TimeInForce::Ioc)]
1954 fn test_parse_order_status_report_take_profit_disambiguation(
1955 #[case] side: OrderSide,
1956 #[case] trigger: rust_decimal::Decimal,
1957 #[case] price: rust_decimal::Decimal,
1958 #[case] expected_type: OrderType,
1959 #[case] expected_tif: TimeInForce,
1960 ) {
1961 let instrument = create_test_instrument();
1962 let account_id = AccountId::new("DYDX-001");
1963 let ts_init = UnixNanos::default();
1964
1965 let order = Order {
1966 id: "order-tp".to_string(),
1967 subaccount_id: "subacct1".to_string(),
1968 client_id: "client1".to_string(),
1969 clob_pair_id: 1,
1970 side,
1971 size: dec!(1.0),
1972 total_filled: dec!(0),
1973 price,
1974 status: DydxOrderStatus::Untriggered,
1975 order_type: DydxOrderType::TakeProfitLimit,
1976 time_in_force: DydxTimeInForce::Gtt,
1977 reduce_only: false,
1978 post_only: false,
1979 order_flags: 0,
1980 good_til_block: None,
1981 good_til_block_time: Some(Timestamp::now()),
1982 created_at_height: Some(1000),
1983 client_metadata: 0,
1984 trigger_price: Some(trigger),
1985 condition_type: None,
1986 conditional_order_trigger_subticks: Some(490_000),
1987 execution: None,
1988 updated_at: Some(Timestamp::now()),
1989 updated_at_height: Some(1001),
1990 ticker: None,
1991 subaccount_number: 0,
1992 order_router_address: None,
1993 };
1994
1995 let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
1996 assert_eq!(report.order_type, expected_type);
1997 assert_eq!(report.time_in_force, expected_tif);
1998 }
1999
2000 #[rstest]
2007 fn test_parse_order_status_report_default_trigger_type_when_condition_none() {
2008 let instrument = create_test_instrument();
2009 let account_id = AccountId::new("DYDX-001");
2010 let ts_init = UnixNanos::default();
2011
2012 let order = Order {
2013 id: "order-default-trigger".to_string(),
2014 subaccount_id: "subacct1".to_string(),
2015 client_id: "client1".to_string(),
2016 clob_pair_id: 1,
2017 side: OrderSide::Buy,
2018 size: dec!(1.0),
2019 total_filled: dec!(0),
2020 price: dec!(50000.0),
2021 status: DydxOrderStatus::Untriggered,
2022 order_type: DydxOrderType::StopLimit,
2023 time_in_force: DydxTimeInForce::Gtt,
2024 reduce_only: false,
2025 post_only: false,
2026 order_flags: 0,
2027 good_til_block: None,
2028 good_til_block_time: Some(Timestamp::now()),
2029 created_at_height: Some(1000),
2030 client_metadata: 0,
2031 trigger_price: Some(dec!(49000.0)),
2032 condition_type: None,
2033 conditional_order_trigger_subticks: Some(490_000),
2034 execution: None,
2035 updated_at: Some(Timestamp::now()),
2036 updated_at_height: Some(1001),
2037 ticker: None,
2038 subaccount_number: 0,
2039 order_router_address: None,
2040 };
2041
2042 let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
2043 assert_eq!(report.trigger_type, Some(TriggerType::Default));
2044 }
2045
2046 #[rstest]
2050 fn test_parse_order_status_report_canceled_at_expiry_boundary_becomes_expired() {
2051 let instrument = create_test_instrument();
2052 let account_id = AccountId::new("DYDX-001");
2053 let expire_at = Timestamp::now();
2054 let ts_init = UnixNanos::from(expire_at.as_millisecond() as u64 * 1_000_000);
2055
2056 let order = Order {
2057 id: "order-expired-boundary".to_string(),
2058 subaccount_id: "subacct1".to_string(),
2059 client_id: "client1".to_string(),
2060 clob_pair_id: 1,
2061 side: OrderSide::Buy,
2062 size: dec!(1.0),
2063 total_filled: dec!(0),
2064 price: dec!(50000.0),
2065 status: DydxOrderStatus::Canceled,
2066 order_type: DydxOrderType::Limit,
2067 time_in_force: DydxTimeInForce::Gtt,
2068 reduce_only: false,
2069 post_only: false,
2070 order_flags: 0,
2071 good_til_block: None,
2072 good_til_block_time: Some(expire_at),
2073 created_at_height: Some(1000),
2074 client_metadata: 0,
2075 trigger_price: None,
2076 condition_type: None,
2077 conditional_order_trigger_subticks: None,
2078 execution: None,
2079 updated_at: Some(expire_at),
2080 updated_at_height: Some(1001),
2081 ticker: None,
2082 subaccount_number: 0,
2083 order_router_address: None,
2084 };
2085
2086 let report = parse_order_status_report(&order, &instrument, account_id, ts_init).unwrap();
2087 assert_eq!(report.order_status, OrderStatus::Expired);
2088 }
2089
2090 #[rstest]
2091 fn test_parse_fill_report() {
2092 let instrument = create_test_instrument();
2093 let account_id = AccountId::new("DYDX-001");
2094 let ts_init = UnixNanos::default();
2095
2096 let fill = Fill {
2097 id: "fill789".to_string(),
2098 side: OrderSide::Buy,
2099 liquidity: DydxLiquidity::Taker,
2100 fill_type: DydxFillType::Limit,
2101 market: Ustr::from("BTC-USD"),
2102 market_type: DydxTickerType::Perpetual,
2103 price: dec!(50100.0),
2104 size: dec!(1.0),
2105 fee: dec!(-5.01),
2106 created_at: Timestamp::now(),
2107 created_at_height: 1000,
2108 order_id: "order123".to_string(),
2109 client_metadata: 0,
2110 };
2111
2112 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2113 assert!(result.is_ok());
2114
2115 let report = result.unwrap();
2116 assert_eq!(report.account_id, account_id);
2117 assert_eq!(report.order_side, OrderSide::Buy);
2118 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
2119 assert_eq!(report.last_px.as_f64(), 50100.0);
2120 assert_eq!(report.commission.as_decimal(), dec!(-5.01));
2121 }
2122
2123 #[rstest]
2124 fn test_parse_position_status_report_long() {
2125 let instrument = create_test_instrument();
2126 let account_id = AccountId::new("DYDX-001");
2127 let ts_init = UnixNanos::default();
2128
2129 let position = PerpetualPosition {
2130 market: Ustr::from("BTC-USD"),
2131 status: DydxPositionStatus::Open,
2132 side: DydxPositionSide::Long,
2133 size: dec!(2.5),
2134 max_size: dec!(3.0),
2135 entry_price: dec!(49500.0),
2136 exit_price: None,
2137 realized_pnl: dec!(100.0),
2138 created_at_height: 1000,
2139 created_at: Timestamp::now(),
2140 sum_open: dec!(2.5),
2141 sum_close: dec!(0.0),
2142 net_funding: dec!(-2.5),
2143 unrealized_pnl: dec!(250.0),
2144 closed_at: None,
2145 };
2146
2147 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
2148 assert!(result.is_ok());
2149
2150 let report = result.unwrap();
2151 assert_eq!(report.account_id, account_id);
2152 assert_eq!(report.position_side, PositionSide::Long);
2153 assert_eq!(report.quantity.as_f64(), 2.5);
2154 assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
2155 }
2156
2157 #[rstest]
2158 fn test_parse_position_status_report_short() {
2159 let instrument = create_test_instrument();
2160 let account_id = AccountId::new("DYDX-001");
2161 let ts_init = UnixNanos::default();
2162
2163 let position = PerpetualPosition {
2164 market: Ustr::from("BTC-USD"),
2165 status: DydxPositionStatus::Open,
2166 side: DydxPositionSide::Short,
2167 size: dec!(-1.5),
2168 max_size: dec!(1.5),
2169 entry_price: dec!(51000.0),
2170 exit_price: None,
2171 realized_pnl: dec!(0.0),
2172 created_at_height: 1000,
2173 created_at: Timestamp::now(),
2174 sum_open: dec!(1.5),
2175 sum_close: dec!(0.0),
2176 net_funding: dec!(1.2),
2177 unrealized_pnl: dec!(-150.0),
2178 closed_at: None,
2179 };
2180
2181 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
2182 assert!(result.is_ok());
2183
2184 let report = result.unwrap();
2185 assert_eq!(report.position_side, PositionSide::Short);
2186 assert_eq!(report.quantity.as_f64(), 1.5);
2187 }
2188
2189 #[rstest]
2190 fn test_parse_position_status_report_flat() {
2191 let instrument = create_test_instrument();
2192 let account_id = AccountId::new("DYDX-001");
2193 let ts_init = UnixNanos::default();
2194
2195 let position = PerpetualPosition {
2196 market: Ustr::from("BTC-USD"),
2197 status: DydxPositionStatus::Closed,
2198 side: DydxPositionSide::Long,
2199 size: dec!(0.0),
2200 max_size: dec!(2.0),
2201 entry_price: dec!(50000.0),
2202 exit_price: Some(dec!(51000.0)),
2203 realized_pnl: dec!(500.0),
2204 created_at_height: 1000,
2205 created_at: Timestamp::now(),
2206 sum_open: dec!(2.0),
2207 sum_close: dec!(2.0),
2208 net_funding: dec!(-5.0),
2209 unrealized_pnl: dec!(0.0),
2210 closed_at: Some(Timestamp::now()),
2211 };
2212
2213 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
2214 assert!(result.is_ok());
2215
2216 let report = result.unwrap();
2217 assert_eq!(report.position_side, PositionSide::Flat);
2218 assert_eq!(report.quantity.as_f64(), 0.0);
2219 }
2220
2221 #[rstest]
2223 fn test_parse_order_external_detection() {
2224 let instrument = create_test_instrument();
2225 let account_id = AccountId::new("DYDX-001");
2226 let ts_init = UnixNanos::default();
2227
2228 let order = Order {
2230 id: "external-order-123".to_string(),
2231 subaccount_id: "dydx1test/0".to_string(),
2232 client_id: "99999".to_string(),
2233 clob_pair_id: 1,
2234 side: OrderSide::Buy,
2235 size: dec!(0.5),
2236 total_filled: dec!(0.0),
2237 price: dec!(50000.0),
2238 status: DydxOrderStatus::Open,
2239 order_type: DydxOrderType::Limit,
2240 time_in_force: DydxTimeInForce::Gtt,
2241 reduce_only: false,
2242 post_only: false,
2243 order_flags: 0,
2244 good_til_block: Some(1000),
2245 good_til_block_time: None,
2246 created_at_height: Some(900),
2247 client_metadata: 0,
2248 trigger_price: None,
2249 condition_type: None,
2250 conditional_order_trigger_subticks: None,
2251 execution: None,
2252 updated_at: Some(Timestamp::now()),
2253 updated_at_height: Some(900),
2254 ticker: None,
2255 subaccount_number: 0,
2256 order_router_address: None,
2257 };
2258
2259 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
2260 assert!(result.is_ok());
2261
2262 let report = result.unwrap();
2263 assert_eq!(report.account_id, account_id);
2264 assert_eq!(report.order_status, OrderStatus::Accepted);
2265 assert_eq!(report.filled_qty.as_f64(), 0.0);
2267 }
2268
2269 #[rstest]
2271 fn test_parse_order_partial_fill_reconciliation() {
2272 let instrument = create_test_instrument();
2273 let account_id = AccountId::new("DYDX-001");
2274 let ts_init = UnixNanos::default();
2275
2276 let order = Order {
2277 id: "partial-order-123".to_string(),
2278 subaccount_id: "dydx1test/0".to_string(),
2279 client_id: "12345".to_string(),
2280 clob_pair_id: 1,
2281 side: OrderSide::Buy,
2282 size: dec!(2.0),
2283 total_filled: dec!(0.75),
2284 price: dec!(50000.0),
2285 status: DydxOrderStatus::PartiallyFilled,
2286 order_type: DydxOrderType::Limit,
2287 time_in_force: DydxTimeInForce::Gtt,
2288 reduce_only: false,
2289 post_only: false,
2290 order_flags: 0,
2291 good_til_block: Some(2000),
2292 good_til_block_time: None,
2293 created_at_height: Some(1500),
2294 client_metadata: 0,
2295 trigger_price: None,
2296 condition_type: None,
2297 conditional_order_trigger_subticks: None,
2298 execution: None,
2299 updated_at: Some(Timestamp::now()),
2300 updated_at_height: Some(1600),
2301 ticker: None,
2302 subaccount_number: 0,
2303 order_router_address: None,
2304 };
2305
2306 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
2307 assert!(result.is_ok());
2308
2309 let report = result.unwrap();
2310 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
2311 assert_eq!(report.filled_qty.as_f64(), 0.75);
2312 assert_eq!(report.quantity.as_f64(), 2.0);
2313 }
2314
2315 #[rstest]
2317 fn test_parse_multiple_positions() {
2318 let instrument = create_test_instrument();
2319 let account_id = AccountId::new("DYDX-001");
2320 let ts_init = UnixNanos::default();
2321
2322 let long_position = PerpetualPosition {
2324 market: Ustr::from("BTC-USD"),
2325 status: DydxPositionStatus::Open,
2326 side: DydxPositionSide::Long,
2327 size: dec!(1.5),
2328 max_size: dec!(1.5),
2329 entry_price: dec!(49000.0),
2330 exit_price: None,
2331 realized_pnl: dec!(0.0),
2332 created_at_height: 1000,
2333 created_at: Timestamp::now(),
2334 sum_open: dec!(1.5),
2335 sum_close: dec!(0.0),
2336 net_funding: dec!(-1.0),
2337 unrealized_pnl: dec!(150.0),
2338 closed_at: None,
2339 };
2340
2341 let result1 =
2342 parse_position_status_report(&long_position, &instrument, account_id, ts_init);
2343 assert!(result1.is_ok());
2344 let report1 = result1.unwrap();
2345 assert_eq!(report1.position_side, PositionSide::Long);
2346
2347 let short_position = PerpetualPosition {
2349 market: Ustr::from("BTC-USD"),
2350 status: DydxPositionStatus::Open,
2351 side: DydxPositionSide::Short,
2352 size: dec!(-2.0),
2353 max_size: dec!(2.0),
2354 entry_price: dec!(51000.0),
2355 exit_price: None,
2356 realized_pnl: dec!(0.0),
2357 created_at_height: 1100,
2358 created_at: Timestamp::now(),
2359 sum_open: dec!(2.0),
2360 sum_close: dec!(0.0),
2361 net_funding: dec!(0.5),
2362 unrealized_pnl: dec!(-200.0),
2363 closed_at: None,
2364 };
2365
2366 let result2 =
2367 parse_position_status_report(&short_position, &instrument, account_id, ts_init);
2368 assert!(result2.is_ok());
2369 let report2 = result2.unwrap();
2370 assert_eq!(report2.position_side, PositionSide::Short);
2371 }
2372
2373 #[rstest]
2375 fn test_parse_fill_zero_fee() {
2376 let instrument = create_test_instrument();
2377 let account_id = AccountId::new("DYDX-001");
2378 let ts_init = UnixNanos::default();
2379
2380 let fill = Fill {
2381 id: "fill-zero-fee".to_string(),
2382 side: OrderSide::Sell,
2383 liquidity: DydxLiquidity::Maker,
2384 fill_type: DydxFillType::Limit,
2385 market: Ustr::from("BTC-USD"),
2386 market_type: DydxTickerType::Perpetual,
2387 price: dec!(50000.0),
2388 size: dec!(0.1),
2389 fee: dec!(0.0), created_at: Timestamp::now(),
2391 created_at_height: 1000,
2392 order_id: "order-zero-fee".to_string(),
2393 client_metadata: 0,
2394 };
2395
2396 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2397 assert!(result.is_ok());
2398
2399 let report = result.unwrap();
2400 assert_eq!(report.commission.as_f64(), 0.0);
2401 }
2402
2403 #[rstest]
2405 fn test_parse_fill_maker_rebate() {
2406 let instrument = create_test_instrument();
2407 let account_id = AccountId::new("DYDX-001");
2408 let ts_init = UnixNanos::default();
2409
2410 let fill = Fill {
2411 id: "fill-maker-rebate".to_string(),
2412 side: OrderSide::Buy,
2413 liquidity: DydxLiquidity::Maker,
2414 fill_type: DydxFillType::Limit,
2415 market: Ustr::from("BTC-USD"),
2416 market_type: DydxTickerType::Perpetual,
2417 price: dec!(50000.0),
2418 size: dec!(1.0),
2419 fee: dec!(-2.5), created_at: Timestamp::now(),
2421 created_at_height: 1000,
2422 order_id: "order-maker-rebate".to_string(),
2423 client_metadata: 0,
2424 };
2425
2426 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2427 assert!(result.is_ok());
2428
2429 let report = result.unwrap();
2430 assert_eq!(report.commission.as_decimal(), dec!(-2.5));
2431 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2432 }
2433
2434 #[rstest]
2435 fn test_parse_account_state_empty_balance() {
2436 use crate::websocket::messages::DydxSubaccountInfo;
2437
2438 let subaccount = DydxSubaccountInfo {
2439 address: "dydx1abc".to_string(),
2440 subaccount_number: 0,
2441 equity: String::new(),
2442 free_collateral: String::new(),
2443 open_perpetual_positions: None,
2444 asset_positions: None,
2445 margin_enabled: true,
2446 updated_at_height: "0".to_string(),
2447 latest_processed_block_height: "0".to_string(),
2448 };
2449
2450 let account_id = AccountId::new("DYDX-001");
2451 let instruments = std::collections::HashMap::new();
2452 let oracle_prices = std::collections::HashMap::new();
2453 let ts = UnixNanos::default();
2454
2455 let state = parse_account_state(
2456 &subaccount,
2457 account_id,
2458 &instruments,
2459 &oracle_prices,
2460 ts,
2461 ts,
2462 )
2463 .unwrap();
2464
2465 assert_eq!(state.account_id, account_id);
2466 assert_eq!(state.balances.len(), 1);
2467 let balance = &state.balances[0];
2468 assert_eq!(balance.total.as_f64(), 0.0);
2469 assert_eq!(balance.free.as_f64(), 0.0);
2470 assert_eq!(balance.locked.as_f64(), 0.0);
2471 }
2472
2473 #[rstest]
2474 fn test_parse_account_state_nonzero_balance() {
2475 use crate::websocket::messages::DydxSubaccountInfo;
2476
2477 let subaccount = DydxSubaccountInfo {
2481 address: "dydx1abc".to_string(),
2482 subaccount_number: 0,
2483 equity: "15000".to_string(),
2484 free_collateral: "12500".to_string(),
2485 open_perpetual_positions: None,
2486 asset_positions: None,
2487 margin_enabled: true,
2488 updated_at_height: "0".to_string(),
2489 latest_processed_block_height: "0".to_string(),
2490 };
2491
2492 let account_id = AccountId::new("DYDX-001");
2493 let instruments = std::collections::HashMap::new();
2494 let oracle_prices = std::collections::HashMap::new();
2495 let ts = UnixNanos::default();
2496
2497 let state = parse_account_state(
2498 &subaccount,
2499 account_id,
2500 &instruments,
2501 &oracle_prices,
2502 ts,
2503 ts,
2504 )
2505 .unwrap();
2506
2507 assert_eq!(state.balances.len(), 1);
2508 let balance = &state.balances[0];
2509 assert_eq!(balance.currency.code, "USDC");
2510 assert_eq!(balance.total.as_decimal(), dec!(15000));
2511 assert_eq!(balance.free.as_decimal(), dec!(12500));
2512 assert_eq!(balance.locked.as_decimal(), dec!(2500));
2513 }
2514
2515 #[rstest]
2516 fn test_parse_account_state_from_http_nonzero_balance() {
2517 use crate::http::models::Subaccount;
2518
2519 let subaccount = Subaccount {
2523 address: "dydx1abc".to_string(),
2524 subaccount_number: 0,
2525 equity: dec!(15000),
2526 free_collateral: dec!(12500),
2527 open_perpetual_positions: std::collections::HashMap::new(),
2528 asset_positions: std::collections::HashMap::new(),
2529 margin_enabled: true,
2530 updated_at_height: 0,
2531 latest_processed_block_height: None,
2532 };
2533
2534 let account_id = AccountId::new("DYDX-001");
2535 let instruments = std::collections::HashMap::new();
2536 let oracle_prices = std::collections::HashMap::new();
2537 let ts = UnixNanos::default();
2538
2539 let state = parse_account_state_from_http(
2540 &subaccount,
2541 account_id,
2542 &instruments,
2543 &oracle_prices,
2544 ts,
2545 ts,
2546 )
2547 .unwrap();
2548
2549 assert_eq!(state.balances.len(), 1);
2550 let balance = &state.balances[0];
2551 assert_eq!(balance.currency.code, "USDC");
2552 assert_eq!(balance.total.as_decimal(), dec!(15000));
2553 assert_eq!(balance.free.as_decimal(), dec!(12500));
2554 assert_eq!(balance.locked.as_decimal(), dec!(2500));
2555 }
2556}