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