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::Buyer,
74 OrderSide::Sell => AggressorSide::Seller,
75 OrderSide::NoOrderSide => AggressorSide::NoAggressor,
76 };
77
78 let price = Price::from_decimal_dp(trade.price, price_precision)
79 .context(format!("failed to parse price for trade {}", trade.id))?;
80
81 let size = Quantity::from_decimal_dp(trade.size, size_precision)
82 .context(format!("failed to parse size for trade {}", trade.id))?;
83
84 let ts_event_nanos = trade
85 .created_at
86 .timestamp_nanos_opt()
87 .ok_or_else(|| anyhow::anyhow!("Timestamp out of range for trade {}", trade.id))?;
88 let ts_event = UnixNanos::from(ts_event_nanos as u64);
89
90 Ok(TradeTick::new(
91 instrument_id,
92 price,
93 size,
94 aggressor_side,
95 TradeId::new(&trade.id),
96 ts_event,
97 ts_init,
98 ))
99}
100
101pub fn parse_bar(
110 candle: &Candle,
111 bar_type: BarType,
112 price_precision: u8,
113 size_precision: u8,
114 timestamp_on_close: bool,
115 ts_init: UnixNanos,
116) -> anyhow::Result<Bar> {
117 let started_at_nanos = candle.started_at.timestamp_nanos_opt().ok_or_else(|| {
118 anyhow::anyhow!("Timestamp out of range for candle at {}", candle.started_at)
119 })?;
120 let mut ts_event = UnixNanos::from(started_at_nanos as u64);
121
122 if timestamp_on_close {
123 let interval_ns = bar_type
124 .spec()
125 .timedelta()
126 .num_nanoseconds()
127 .context("bar specification produced non-integer interval")?;
128 let interval_ns =
129 u64::try_from(interval_ns).context("bar interval overflowed u64 nanoseconds")?;
130 let updated = ts_event
131 .as_u64()
132 .checked_add(interval_ns)
133 .context("bar timestamp overflowed when adjusting to close time")?;
134 ts_event = UnixNanos::from(updated);
135 }
136
137 let open = Price::from_decimal_dp(candle.open, price_precision)
138 .context("failed to parse candle open price")?;
139 let high = Price::from_decimal_dp(candle.high, price_precision)
140 .context("failed to parse candle high price")?;
141 let low = Price::from_decimal_dp(candle.low, price_precision)
142 .context("failed to parse candle low price")?;
143 let close = Price::from_decimal_dp(candle.close, price_precision)
144 .context("failed to parse candle close price")?;
145 let volume = Quantity::from_decimal_dp(candle.base_token_volume, size_precision)
146 .context("failed to parse candle base_token_volume")?;
147
148 Ok(Bar::new(
149 bar_type, open, high, low, close, volume, ts_event, ts_init,
150 ))
151}
152
153pub fn validate_ticker_format(ticker: &str) -> anyhow::Result<()> {
160 let parts: Vec<&str> = ticker.split('-').collect();
161 if parts.len() != 2 {
162 anyhow::bail!("Invalid ticker format '{ticker}', expected 'BASE-QUOTE' (e.g., 'BTC-USD')");
163 }
164
165 if parts[0].is_empty() || parts[1].is_empty() {
166 anyhow::bail!("Invalid ticker format '{ticker}', base and quote cannot be empty");
167 }
168 Ok(())
169}
170
171pub fn parse_ticker_currencies(ticker: &str) -> anyhow::Result<(&str, &str)> {
178 validate_ticker_format(ticker)?;
179 let parts: Vec<&str> = ticker.split('-').collect();
180 Ok((parts[0], parts[1]))
181}
182
183#[must_use]
185pub const fn is_market_active(status: &DydxMarketStatus) -> bool {
186 matches!(status, DydxMarketStatus::Active)
187}
188
189pub fn calculate_time_in_force(
195 order_type: DydxOrderType,
196 base_tif: DydxTimeInForce,
197 post_only: bool,
198 execution: Option<DydxOrderExecution>,
199) -> anyhow::Result<TimeInForce> {
200 match order_type {
201 DydxOrderType::Market => Ok(TimeInForce::Ioc),
202 DydxOrderType::Limit if post_only => Ok(TimeInForce::Gtc), DydxOrderType::Limit => match base_tif {
204 DydxTimeInForce::Gtt => Ok(TimeInForce::Gtc),
205 DydxTimeInForce::Fok => Ok(TimeInForce::Fok),
206 DydxTimeInForce::Ioc => Ok(TimeInForce::Ioc),
207 },
208
209 DydxOrderType::StopLimit | DydxOrderType::TakeProfitLimit => match execution {
210 Some(DydxOrderExecution::PostOnly) => Ok(TimeInForce::Gtc), Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
212 Some(DydxOrderExecution::Ioc) => Ok(TimeInForce::Ioc),
213 Some(DydxOrderExecution::Default) | None => Ok(TimeInForce::Gtc), },
215
216 DydxOrderType::StopMarket | DydxOrderType::TakeProfitMarket => match execution {
217 Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
218 Some(DydxOrderExecution::Ioc | DydxOrderExecution::Default) | None => {
219 Ok(TimeInForce::Ioc)
220 }
221 Some(DydxOrderExecution::PostOnly) => {
222 anyhow::bail!("Execution PostOnly not supported for {order_type:?}")
223 }
224 },
225
226 DydxOrderType::TrailingStop => Ok(TimeInForce::Gtc),
227 }
228}
229
230pub fn validate_conditional_order(
241 order_type: DydxOrderType,
242 trigger_price: Option<Decimal>,
243 price: Decimal,
244 side: OrderSide,
245) -> anyhow::Result<()> {
246 if !order_type.is_conditional() {
247 return Ok(());
248 }
249
250 let trigger_price = trigger_price
251 .ok_or_else(|| anyhow::anyhow!("trigger_price required for {order_type:?}"))?;
252
253 match order_type {
255 DydxOrderType::StopLimit | DydxOrderType::StopMarket => {
256 match side {
258 OrderSide::Buy if trigger_price < price => {
259 anyhow::bail!(
260 "Stop buy trigger_price ({trigger_price}) must be >= limit price ({price})"
261 );
262 }
263 OrderSide::Sell if trigger_price > price => {
264 anyhow::bail!(
265 "Stop sell trigger_price ({trigger_price}) must be <= limit price ({price})"
266 );
267 }
268 _ => {}
269 }
270 }
271 DydxOrderType::TakeProfitLimit | DydxOrderType::TakeProfitMarket => {
272 match side {
274 OrderSide::Buy if trigger_price > price => {
275 anyhow::bail!(
276 "Take profit buy trigger_price ({trigger_price}) must be <= limit price ({price})"
277 );
278 }
279 OrderSide::Sell if trigger_price < price => {
280 anyhow::bail!(
281 "Take profit sell trigger_price ({trigger_price}) must be >= limit price ({price})"
282 );
283 }
284 _ => {}
285 }
286 }
287 _ => {}
288 }
289
290 Ok(())
291}
292
293pub fn parse_instrument_any(
310 definition: &PerpetualMarket,
311 maker_fee: Option<Decimal>,
312 taker_fee: Option<Decimal>,
313 ts_init: UnixNanos,
314) -> anyhow::Result<InstrumentAny> {
315 let instrument_id = parse_instrument_id(definition.ticker);
317 let raw_symbol = Symbol::from(definition.ticker.as_str());
318
319 let (base_str, quote_str) = parse_ticker_currencies(&definition.ticker)
321 .context(format!("Failed to parse ticker '{}'", definition.ticker))?;
322
323 let base_currency = Currency::get_or_create_crypto_with_context(base_str, None);
324 let quote_currency = Currency::get_or_create_crypto_with_context(quote_str, None);
325 let settlement_currency = quote_currency; let price_increment =
329 parse_price(&definition.tick_size.to_string(), "tick_size").context(format!(
330 "Failed to parse tick_size '{}' for market '{}'",
331 definition.tick_size, definition.ticker
332 ))?;
333
334 let size_increment =
335 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
336 "Failed to parse step_size '{}' for market '{}'",
337 definition.step_size, definition.ticker
338 ))?;
339
340 let min_quantity = Some(if let Some(min_size) = &definition.min_order_size {
342 parse_quantity(&min_size.to_string(), "min_order_size").context(format!(
343 "Failed to parse min_order_size '{}' for market '{}'",
344 min_size, definition.ticker
345 ))?
346 } else {
347 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
349 "Failed to parse step_size as min_quantity for market '{}'",
350 definition.ticker
351 ))?
352 });
353
354 let margin_init = Some(
356 parse_decimal(
357 &definition.initial_margin_fraction.to_string(),
358 "initial_margin_fraction",
359 )
360 .context(format!(
361 "Failed to parse initial_margin_fraction '{}' for market '{}'",
362 definition.initial_margin_fraction, definition.ticker
363 ))?,
364 );
365
366 let margin_maint = Some(
367 parse_decimal(
368 &definition.maintenance_margin_fraction.to_string(),
369 "maintenance_margin_fraction",
370 )
371 .context(format!(
372 "Failed to parse maintenance_margin_fraction '{}' for market '{}'",
373 definition.maintenance_margin_fraction, definition.ticker
374 ))?,
375 );
376
377 let instrument = CryptoPerpetual::new(
379 instrument_id,
380 raw_symbol,
381 base_currency,
382 quote_currency,
383 settlement_currency,
384 false, price_increment.precision,
386 size_increment.precision,
387 price_increment,
388 size_increment,
389 None, Some(size_increment), None, min_quantity,
393 None, None, None, None, margin_init,
398 margin_maint,
399 maker_fee,
400 taker_fee,
401 None,
402 None, ts_init,
404 ts_init,
405 );
406
407 Ok(InstrumentAny::CryptoPerpetual(instrument))
408}
409
410pub(super) mod display_fromstr {
412 use std::{fmt::Display, str::FromStr};
413
414 use serde::{Deserialize, Deserializer, Serializer, de};
415
416 pub(crate) fn serialize<T, S>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
417 where
418 T: Display,
419 S: Serializer,
420 {
421 serializer.collect_str(value)
422 }
423
424 pub(crate) fn deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
425 where
426 T: FromStr,
427 T::Err: Display,
428 D: Deserializer<'de>,
429 {
430 let s = String::deserialize(deserializer)?;
431 s.parse().map_err(de::Error::custom)
432 }
433}
434
435pub(super) mod display_fromstr_opt {
438 use std::{fmt::Display, str::FromStr};
439
440 use serde::{Deserialize, Deserializer, Serializer, de};
441
442 pub(crate) fn serialize<T, S>(value: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
443 where
444 T: Display,
445 S: Serializer,
446 {
447 match value {
448 Some(v) => serializer.collect_str(v),
449 None => serializer.serialize_none(),
450 }
451 }
452
453 pub(crate) fn deserialize<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
454 where
455 T: FromStr,
456 T::Err: Display,
457 D: Deserializer<'de>,
458 {
459 match Option::<String>::deserialize(deserializer)? {
460 Some(s) => s.parse().map(Some).map_err(de::Error::custom),
461 None => Ok(None),
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use std::str::FromStr;
469
470 use chrono::Utc;
471 use nautilus_model::{
472 data::BarType,
473 enums::{AggressorSide, OrderSide},
474 identifiers::InstrumentId,
475 instruments::Instrument,
476 };
477 use rstest::rstest;
478 use rust_decimal::Decimal;
479 use rust_decimal_macros::dec;
480 use ustr::Ustr;
481
482 use super::*;
483 use crate::{
484 common::{
485 enums::{DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce},
486 testing::load_json_result_fixture,
487 },
488 http::models::{
489 CandlesResponse, FillsResponse, MarketsResponse, Order, OrderbookResponse,
490 SubaccountResponse, TradesResponse, TransfersResponse,
491 },
492 };
493
494 fn create_test_market() -> PerpetualMarket {
495 PerpetualMarket {
496 clob_pair_id: 1,
497 ticker: Ustr::from("BTC-USD"),
498 status: DydxMarketStatus::Active,
499 base_asset: Some(Ustr::from("BTC")),
500 quote_asset: Some(Ustr::from("USD")),
501 step_size: Decimal::from_str("0.001").unwrap(),
502 tick_size: Decimal::from_str("1").unwrap(),
503 index_price: Some(Decimal::from_str("50000").unwrap()),
504 oracle_price: Some(Decimal::from_str("50000").unwrap()),
505 price_change_24h: Decimal::ZERO,
506 next_funding_rate: Decimal::ZERO,
507 next_funding_at: Some(Utc::now()),
508 min_order_size: Some(Decimal::from_str("0.001").unwrap()),
509 market_type: Some(DydxTickerType::Perpetual),
510 initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
511 maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
512 base_position_notional: Some(Decimal::from_str("10000").unwrap()),
513 incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
514 incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
515 max_position_size: Some(Decimal::from_str("100").unwrap()),
516 open_interest: Decimal::from_str("1000000").unwrap(),
517 atomic_resolution: -10,
518 quantum_conversion_exponent: -10,
519 subticks_per_tick: 100,
520 step_base_quantums: 1000,
521 is_reduce_only: false,
522 }
523 }
524
525 #[rstest]
526 fn test_parse_instrument_any_valid() {
527 let market = create_test_market();
528 let maker_fee = Some(Decimal::from_str("0.0002").unwrap());
529 let taker_fee = Some(Decimal::from_str("0.0005").unwrap());
530 let ts_init = UnixNanos::default();
531
532 let result = parse_instrument_any(&market, maker_fee, taker_fee, ts_init);
533 assert!(result.is_ok());
534
535 let instrument = result.unwrap();
536 if let InstrumentAny::CryptoPerpetual(perp) = instrument {
537 assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
538 assert_eq!(perp.base_currency.code.as_str(), "BTC");
539 assert_eq!(perp.quote_currency.code.as_str(), "USD");
540 assert!(!perp.is_inverse);
541 assert_eq!(perp.price_increment.to_string(), "1");
542 assert_eq!(perp.size_increment.to_string(), "0.001");
543 } else {
544 panic!("Expected CryptoPerpetual instrument");
545 }
546 }
547
548 #[rstest]
549 fn test_is_market_active() {
550 assert!(is_market_active(&DydxMarketStatus::Active));
551 assert!(!is_market_active(&DydxMarketStatus::Paused));
552 assert!(!is_market_active(&DydxMarketStatus::CancelOnly));
553 assert!(!is_market_active(&DydxMarketStatus::PostOnly));
554 assert!(!is_market_active(&DydxMarketStatus::Initializing));
555 assert!(!is_market_active(&DydxMarketStatus::FinalSettlement));
556 }
557
558 #[rstest]
559 fn test_parse_instrument_any_invalid_ticker() {
560 let mut market = create_test_market();
561 market.ticker = Ustr::from("INVALID");
562
563 let result = parse_instrument_any(&market, None, None, UnixNanos::default());
564 assert!(result.is_err());
565 let error_msg = result.unwrap_err().to_string();
566 assert!(
568 error_msg.contains("Invalid ticker format")
569 || error_msg.contains("Failed to parse ticker"),
570 "Expected ticker format error, was: {error_msg}"
571 );
572 }
573
574 #[rstest]
575 fn test_validate_ticker_format_valid() {
576 assert!(validate_ticker_format("BTC-USD").is_ok());
577 assert!(validate_ticker_format("ETH-USD").is_ok());
578 assert!(validate_ticker_format("ATOM-USD").is_ok());
579 }
580
581 #[rstest]
582 fn test_validate_ticker_format_invalid() {
583 assert!(validate_ticker_format("BTCUSD").is_err());
585
586 assert!(validate_ticker_format("BTC-USD-PERP").is_err());
588
589 assert!(validate_ticker_format("-USD").is_err());
591
592 assert!(validate_ticker_format("BTC-").is_err());
594
595 assert!(validate_ticker_format("-").is_err());
597 }
598
599 #[rstest]
600 fn test_parse_ticker_currencies_valid() {
601 let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
602 assert_eq!(base, "BTC");
603 assert_eq!(quote, "USD");
604
605 let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
606 assert_eq!(base, "ETH");
607 assert_eq!(quote, "USDC");
608 }
609
610 #[rstest]
611 fn test_parse_ticker_currencies_invalid() {
612 assert!(parse_ticker_currencies("INVALID").is_err());
613 assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
614 }
615
616 #[rstest]
617 fn test_validate_stop_limit_buy_valid() {
618 let result = validate_conditional_order(
619 DydxOrderType::StopLimit,
620 Some(dec!(51000)), dec!(50000), OrderSide::Buy,
623 );
624 assert!(result.is_ok());
625 }
626
627 #[rstest]
628 fn test_validate_stop_limit_buy_invalid() {
629 let result = validate_conditional_order(
631 DydxOrderType::StopLimit,
632 Some(dec!(49000)),
633 dec!(50000),
634 OrderSide::Buy,
635 );
636 assert!(result.is_err());
637 assert!(
638 result
639 .unwrap_err()
640 .to_string()
641 .contains("must be >= limit price")
642 );
643 }
644
645 #[rstest]
646 fn test_validate_stop_limit_sell_valid() {
647 let result = validate_conditional_order(
648 DydxOrderType::StopLimit,
649 Some(dec!(49000)), dec!(50000), OrderSide::Sell,
652 );
653 assert!(result.is_ok());
654 }
655
656 #[rstest]
657 fn test_validate_stop_limit_sell_invalid() {
658 let result = validate_conditional_order(
660 DydxOrderType::StopLimit,
661 Some(dec!(51000)),
662 dec!(50000),
663 OrderSide::Sell,
664 );
665 assert!(result.is_err());
666 assert!(
667 result
668 .unwrap_err()
669 .to_string()
670 .contains("must be <= limit price")
671 );
672 }
673
674 #[rstest]
675 fn test_validate_take_profit_sell_valid() {
676 let result = validate_conditional_order(
677 DydxOrderType::TakeProfitLimit,
678 Some(dec!(51000)), dec!(50000), OrderSide::Sell,
681 );
682 assert!(result.is_ok());
683 }
684
685 #[rstest]
686 fn test_validate_take_profit_buy_valid() {
687 let result = validate_conditional_order(
688 DydxOrderType::TakeProfitLimit,
689 Some(dec!(49000)), dec!(50000), OrderSide::Buy,
692 );
693 assert!(result.is_ok());
694 }
695
696 #[rstest]
697 fn test_validate_missing_trigger_price() {
698 let result =
699 validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
700 assert!(result.is_err());
701 assert!(
702 result
703 .unwrap_err()
704 .to_string()
705 .contains("trigger_price required")
706 );
707 }
708
709 #[rstest]
710 fn test_validate_non_conditional_order() {
711 let result =
713 validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
714 assert!(result.is_ok());
715 }
716
717 #[rstest]
718 fn test_calculate_tif_market() {
719 let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
720 .unwrap();
721 assert_eq!(tif, TimeInForce::Ioc);
722 }
723
724 #[rstest]
725 fn test_calculate_tif_limit_post_only() {
726 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
727 .unwrap();
728 assert_eq!(tif, TimeInForce::Gtc); }
730
731 #[rstest]
732 fn test_calculate_tif_limit_gtc() {
733 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
734 .unwrap();
735 assert_eq!(tif, TimeInForce::Gtc);
736 }
737
738 #[rstest]
739 fn test_calculate_tif_stop_market_ioc() {
740 let tif = calculate_time_in_force(
741 DydxOrderType::StopMarket,
742 DydxTimeInForce::Gtt,
743 false,
744 Some(DydxOrderExecution::Ioc),
745 )
746 .unwrap();
747 assert_eq!(tif, TimeInForce::Ioc);
748 }
749
750 #[rstest]
751 fn test_calculate_tif_stop_limit_post_only() {
752 let tif = calculate_time_in_force(
753 DydxOrderType::StopLimit,
754 DydxTimeInForce::Gtt,
755 false,
756 Some(DydxOrderExecution::PostOnly),
757 )
758 .unwrap();
759 assert_eq!(tif, TimeInForce::Gtc); }
761
762 #[rstest]
763 fn test_calculate_tif_stop_limit_gtc() {
764 let tif =
765 calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
766 .unwrap();
767 assert_eq!(tif, TimeInForce::Gtc);
768 }
769
770 #[rstest]
771 fn test_calculate_tif_stop_market_invalid_post_only() {
772 let result = calculate_time_in_force(
773 DydxOrderType::StopMarket,
774 DydxTimeInForce::Gtt,
775 false,
776 Some(DydxOrderExecution::PostOnly),
777 );
778 assert!(result.is_err());
779 assert!(
780 result
781 .unwrap_err()
782 .to_string()
783 .contains("PostOnly not supported")
784 );
785 }
786
787 #[rstest]
788 fn test_calculate_tif_trailing_stop() {
789 let tif = calculate_time_in_force(
790 DydxOrderType::TrailingStop,
791 DydxTimeInForce::Gtt,
792 false,
793 None,
794 )
795 .unwrap();
796 assert_eq!(tif, TimeInForce::Gtc);
797 }
798
799 #[rstest]
800 fn test_parse_perpetual_markets() {
801 let json = load_json_result_fixture("http_get_perpetual_markets.json");
802 let response: MarketsResponse =
803 serde_json::from_value(json).expect("Failed to parse markets");
804
805 assert_eq!(response.markets.len(), 3);
806 assert!(response.markets.contains_key("BTC-USD"));
807 assert!(response.markets.contains_key("ETH-USD"));
808 assert!(response.markets.contains_key("SOL-USD"));
809
810 let btc = response.markets.get("BTC-USD").unwrap();
811 assert_eq!(btc.ticker, "BTC-USD");
812 assert_eq!(btc.clob_pair_id, 0);
813 assert_eq!(btc.atomic_resolution, -10);
814 }
815
816 #[rstest]
817 fn test_parse_perpetual_market_with_null_oracle_price() {
818 let json = serde_json::json!({
819 "markets": {
820 "WTI-USD": {
821 "clobPairId": "99",
822 "ticker": "WTI-USD",
823 "status": "ACTIVE",
824 "oraclePrice": null,
825 "priceChange24H": "0",
826 "nextFundingRate": "0",
827 "initialMarginFraction": "0.1",
828 "maintenanceMarginFraction": "0.05",
829 "openInterest": "0",
830 "atomicResolution": -7,
831 "quantumConversionExponent": -9,
832 "tickSize": "0.01",
833 "stepSize": "0.1",
834 "stepBaseQuantums": 1000000,
835 "subticksPerTick": 1000000
836 }
837 }
838 });
839 let response: MarketsResponse =
840 serde_json::from_value(json).expect("Failed to parse market with null oraclePrice");
841
842 let wti = response.markets.get("WTI-USD").unwrap();
843 assert_eq!(wti.ticker.as_str(), "WTI-USD");
844 assert_eq!(wti.oracle_price, None);
845 }
846
847 #[rstest]
848 fn test_parse_perpetual_market_with_missing_oracle_price() {
849 let json = serde_json::json!({
850 "markets": {
851 "WTI-USD": {
852 "clobPairId": "99",
853 "ticker": "WTI-USD",
854 "status": "ACTIVE",
855 "priceChange24H": "0",
856 "nextFundingRate": "0",
857 "initialMarginFraction": "0.1",
858 "maintenanceMarginFraction": "0.05",
859 "openInterest": "0",
860 "atomicResolution": -7,
861 "quantumConversionExponent": -9,
862 "tickSize": "0.01",
863 "stepSize": "0.1",
864 "stepBaseQuantums": 1000000,
865 "subticksPerTick": 1000000
866 }
867 }
868 });
869 let response: MarketsResponse =
870 serde_json::from_value(json).expect("Failed to parse market with missing oraclePrice");
871
872 let wti = response.markets.get("WTI-USD").unwrap();
873 assert_eq!(wti.oracle_price, None);
874 }
875
876 #[rstest]
877 fn test_parse_instrument_from_market() {
878 let json = load_json_result_fixture("http_get_perpetual_markets.json");
879 let response: MarketsResponse =
880 serde_json::from_value(json).expect("Failed to parse markets");
881 let btc = response.markets.get("BTC-USD").unwrap();
882
883 let ts_init = UnixNanos::default();
884 let instrument =
885 parse_instrument_any(btc, None, None, ts_init).expect("Failed to parse instrument");
886
887 assert_eq!(instrument.id().symbol.as_str(), "BTC-USD-PERP");
888 assert_eq!(instrument.id().venue.as_str(), "DYDX");
889 }
890
891 #[rstest]
892 fn test_parse_orderbook_response() {
893 let json = load_json_result_fixture("http_get_orderbook.json");
894 let response: OrderbookResponse =
895 serde_json::from_value(json).expect("Failed to parse orderbook");
896
897 assert_eq!(response.bids.len(), 5);
898 assert_eq!(response.asks.len(), 5);
899
900 let best_bid = &response.bids[0];
901 assert_eq!(best_bid.price.to_string(), "89947");
902 assert_eq!(best_bid.size.to_string(), "0.0002");
903
904 let best_ask = &response.asks[0];
905 assert_eq!(best_ask.price.to_string(), "89958");
906 assert_eq!(best_ask.size.to_string(), "0.1177");
907 }
908
909 #[rstest]
910 fn test_parse_trades_response() {
911 let json = load_json_result_fixture("http_get_trades.json");
912 let response: TradesResponse =
913 serde_json::from_value(json).expect("Failed to parse trades");
914
915 assert_eq!(response.trades.len(), 3);
916
917 let first_trade = &response.trades[0];
918 assert_eq!(first_trade.id, "03f89a550000000200000002");
919 assert_eq!(first_trade.side, OrderSide::Buy);
920 assert_eq!(first_trade.price.to_string(), "89942");
921 assert_eq!(first_trade.size.to_string(), "0.0001");
922 }
923
924 #[rstest]
925 fn test_parse_candles_response() {
926 let json = load_json_result_fixture("http_get_candles.json");
927 let response: CandlesResponse =
928 serde_json::from_value(json).expect("Failed to parse candles");
929
930 assert_eq!(response.candles.len(), 3);
931
932 let first_candle = &response.candles[0];
933 assert_eq!(first_candle.ticker, "BTC-USD");
934 assert_eq!(first_candle.open.to_string(), "89934");
935 assert_eq!(first_candle.high.to_string(), "89970");
936 assert_eq!(first_candle.low.to_string(), "89911");
937 assert_eq!(first_candle.close.to_string(), "89941");
938 }
939
940 #[rstest]
941 fn test_parse_subaccount_response() {
942 let json = load_json_result_fixture("http_get_subaccount.json");
943 let response: SubaccountResponse =
944 serde_json::from_value(json).expect("Failed to parse subaccount");
945
946 let subaccount = &response.subaccount;
947 assert_eq!(subaccount.subaccount_number, 0);
948 assert_eq!(subaccount.equity.to_string(), "45.201296");
949 assert_eq!(subaccount.free_collateral.to_string(), "45.201296");
950 assert!(subaccount.margin_enabled);
951 assert_eq!(subaccount.open_perpetual_positions.len(), 0);
952 }
953
954 #[rstest]
955 fn test_parse_orders_response() {
956 let json = load_json_result_fixture("http_get_orders.json");
957 let response: Vec<Order> = serde_json::from_value(json).expect("Failed to parse orders");
958
959 assert_eq!(response.len(), 3);
960
961 let first_order = &response[0];
962 assert_eq!(first_order.id, "0f0981cb-152e-57d3-bea9-4d8e0dd5ed35");
963 assert_eq!(first_order.side, OrderSide::Buy);
964 assert_eq!(first_order.order_type, DydxOrderType::Limit);
965 assert!(first_order.reduce_only);
966
967 let second_order = &response[1];
968 assert_eq!(second_order.side, OrderSide::Sell);
969 assert!(!second_order.reduce_only);
970 }
971
972 #[rstest]
973 fn test_parse_fills_response() {
974 let json = load_json_result_fixture("http_get_fills.json");
975 let response: FillsResponse = serde_json::from_value(json).expect("Failed to parse fills");
976
977 assert_eq!(response.fills.len(), 3);
978
979 let first_fill = &response.fills[0];
980 assert_eq!(first_fill.id, "6450e369-1dc3-5229-8dc2-fb3b5d1cf2ab");
981 assert_eq!(first_fill.side, OrderSide::Buy);
982 assert_eq!(first_fill.market, "BTC-USD");
983 assert_eq!(first_fill.price.to_string(), "105117");
984 }
985
986 #[rstest]
987 fn test_parse_transfers_response() {
988 let json = load_json_result_fixture("http_get_transfers.json");
989 let response: TransfersResponse =
990 serde_json::from_value(json).expect("Failed to parse transfers");
991
992 assert_eq!(response.transfers.len(), 1);
993
994 let deposit = &response.transfers[0];
995 assert_eq!(deposit.transfer_type, DydxTransferType::Deposit);
996 assert_eq!(deposit.asset, "USDC");
997 assert_eq!(deposit.amount.to_string(), "45.334703");
998 }
999
1000 #[rstest]
1001 fn test_transfer_type_enum_serde() {
1002 let test_cases = vec![
1004 (DydxTransferType::Deposit, "\"DEPOSIT\""),
1005 (DydxTransferType::Withdrawal, "\"WITHDRAWAL\""),
1006 (DydxTransferType::TransferIn, "\"TRANSFER_IN\""),
1007 (DydxTransferType::TransferOut, "\"TRANSFER_OUT\""),
1008 ];
1009
1010 for (variant, expected_json) in test_cases {
1011 let serialized = serde_json::to_string(&variant).expect("Failed to serialize");
1013 assert_eq!(
1014 serialized, expected_json,
1015 "Serialization failed for {variant:?}"
1016 );
1017
1018 let deserialized: DydxTransferType =
1020 serde_json::from_str(&serialized).expect("Failed to deserialize");
1021 assert_eq!(
1022 deserialized, variant,
1023 "Deserialization failed for {variant:?}"
1024 );
1025 }
1026 }
1027
1028 #[rstest]
1029 fn test_parse_trade_tick() {
1030 let json = load_json_result_fixture("http_get_trades.json");
1031 let response: TradesResponse =
1032 serde_json::from_value(json).expect("Failed to parse trades");
1033
1034 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
1035 let ts_init = UnixNanos::from(1_000_000_000u64);
1036
1037 let tick = parse_trade_tick(&response.trades[0], instrument_id, 0, 4, ts_init)
1038 .expect("Failed to parse trade tick");
1039
1040 assert_eq!(tick.instrument_id, instrument_id);
1041 assert_eq!(tick.price.to_string(), "89942");
1042 assert_eq!(tick.size.to_string(), "0.0001");
1043 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
1044 assert_eq!(tick.trade_id.to_string(), "03f89a550000000200000002");
1045 assert_eq!(tick.ts_init, ts_init);
1046 }
1047
1048 #[rstest]
1049 #[case(true)]
1050 #[case(false)]
1051 fn test_parse_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
1052 let json = load_json_result_fixture("http_get_candles.json");
1053 let response: CandlesResponse =
1054 serde_json::from_value(json).expect("Failed to parse candles");
1055
1056 let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
1057 .expect("Failed to parse bar type");
1058 let ts_init = UnixNanos::from(1_000_000_000u64);
1059
1060 let bar = parse_bar(
1061 &response.candles[0],
1062 bar_type,
1063 0,
1064 4,
1065 timestamp_on_close,
1066 ts_init,
1067 )
1068 .expect("Failed to parse bar");
1069
1070 assert_eq!(bar.bar_type, bar_type);
1071 assert_eq!(bar.open.to_string(), "89934");
1072 assert_eq!(bar.high.to_string(), "89970");
1073 assert_eq!(bar.low.to_string(), "89911");
1074 assert_eq!(bar.close.to_string(), "89941");
1075 assert_eq!(bar.volume.to_string(), "3.2767");
1076
1077 let started_at_ns = 1_765_210_260_000_000_000u64;
1079 let one_min_ns = 60_000_000_000u64;
1080
1081 if timestamp_on_close {
1082 assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
1083 } else {
1084 assert_eq!(bar.ts_event.as_u64(), started_at_ns);
1085 }
1086 }
1087}
1088
1089use std::str::FromStr;
1090
1091use nautilus_core::UUID4;
1092use nautilus_model::{
1093 enums::{LiquiditySide, OrderStatus, OrderType, PositionSide, TriggerType},
1094 identifiers::{AccountId, ClientOrderId, VenueOrderId},
1095 instruments::Instrument,
1096 reports::{FillReport, OrderStatusReport, PositionStatusReport},
1097 types::Money,
1098};
1099
1100use super::models::{Fill, Order, PerpetualPosition};
1101use crate::common::enums::{DydxConditionType, DydxLiquidity, DydxOrderStatus};
1102#[cfg(test)]
1103use crate::common::enums::{DydxFillType, DydxPositionSide, DydxPositionStatus, DydxTickerType};
1104
1105fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
1107 match status {
1108 DydxOrderStatus::Open => OrderStatus::Accepted,
1109 DydxOrderStatus::Filled => OrderStatus::Filled,
1110 DydxOrderStatus::Canceled => OrderStatus::Canceled,
1111 DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
1112 DydxOrderStatus::Untriggered => OrderStatus::Accepted, DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
1114 DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1115 }
1116}
1117
1118pub fn parse_order_status_report(
1124 order: &Order,
1125 instrument: &InstrumentAny,
1126 account_id: AccountId,
1127 ts_init: UnixNanos,
1128) -> anyhow::Result<OrderStatusReport> {
1129 let instrument_id = instrument.id();
1130 let venue_order_id = VenueOrderId::new(&order.id);
1131 let client_order_id = if order.client_id.is_empty() {
1132 None
1133 } else {
1134 Some(ClientOrderId::new(&order.client_id))
1135 };
1136
1137 let mut order_type: OrderType = order.order_type.into();
1138 let mut dydx_order_type = order.order_type;
1141
1142 if order_type == OrderType::LimitIfTouched
1152 && let Some(trigger_dec) = order.trigger_price
1153 && !trigger_dec.is_zero()
1154 {
1155 let drift = (order.price - trigger_dec).abs() / trigger_dec;
1156 if drift >= rust_decimal::Decimal::new(2, 2) {
1157 order_type = OrderType::MarketIfTouched;
1158 dydx_order_type = DydxOrderType::TakeProfitMarket;
1159 }
1160 }
1161
1162 let execution = order.execution.or({
1163 if order.post_only {
1165 Some(DydxOrderExecution::PostOnly)
1166 } else {
1167 Some(DydxOrderExecution::Default)
1168 }
1169 });
1170 let time_in_force = calculate_time_in_force(
1171 dydx_order_type,
1172 order.time_in_force,
1173 order.reduce_only,
1174 execution,
1175 )?;
1176
1177 let order_side = order.side;
1178 let order_status = parse_order_status(&order.status);
1179
1180 let size_precision = instrument.size_precision();
1181 let quantity = Quantity::from_decimal_dp(order.size, size_precision)
1182 .context("failed to parse order size")?;
1183 let filled_qty = Quantity::from_decimal_dp(order.total_filled, size_precision)
1184 .context("failed to parse total_filled")?;
1185
1186 let price_precision = instrument.price_precision();
1187 let price = Price::from_decimal_dp(order.price, price_precision)
1188 .context("failed to parse order price")?;
1189
1190 let ts_accepted = order.updated_at.map_or(ts_init, |dt| {
1192 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
1193 });
1194 let ts_last = ts_accepted;
1195
1196 let mut report = OrderStatusReport::new(
1197 account_id,
1198 instrument_id,
1199 client_order_id,
1200 venue_order_id,
1201 order_side,
1202 order_type,
1203 time_in_force,
1204 order_status,
1205 quantity,
1206 filled_qty,
1207 ts_accepted,
1208 ts_last,
1209 ts_init,
1210 Some(UUID4::new()),
1211 );
1212
1213 report = report.with_price(price);
1214
1215 if let Some(trigger_price_dec) = order.trigger_price {
1216 let trigger_price = Price::from_decimal_dp(trigger_price_dec, instrument.price_precision())
1217 .context("failed to parse trigger_price")?;
1218 report = report.with_trigger_price(trigger_price);
1219
1220 let trigger_type = match order.condition_type {
1221 Some(DydxConditionType::StopLoss) => TriggerType::LastPrice,
1222 Some(DydxConditionType::TakeProfit) => TriggerType::LastPrice,
1223 Some(DydxConditionType::Unspecified) | None => TriggerType::Default,
1224 };
1225 report = report.with_trigger_type(trigger_type);
1226 }
1227
1228 if let Some(good_til_block_time) = order.good_til_block_time {
1229 let expire_ns = good_til_block_time.timestamp_millis() as u64 * 1_000_000;
1230 report = report.with_expire_time(UnixNanos::from(expire_ns));
1231
1232 if report.order_status == OrderStatus::Canceled
1236 && report.ts_last >= UnixNanos::from(expire_ns)
1237 {
1238 report.order_status = OrderStatus::Expired;
1239 }
1240 }
1241
1242 Ok(report)
1243}
1244
1245pub fn parse_fill_report(
1251 fill: &Fill,
1252 instrument: &InstrumentAny,
1253 account_id: AccountId,
1254 ts_init: UnixNanos,
1255) -> anyhow::Result<FillReport> {
1256 let instrument_id = instrument.id();
1257 let venue_order_id = VenueOrderId::new(&fill.order_id);
1258 let trade_id = TradeId::new(&fill.id);
1259 let order_side = fill.side;
1260
1261 match fill.fill_type {
1266 crate::common::enums::DydxFillType::Liquidated
1267 | crate::common::enums::DydxFillType::Liquidation => {
1268 log::warn!(
1269 "Liquidation fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1270 instrument_id,
1271 fill.id,
1272 fill.order_id,
1273 fill.fill_type,
1274 order_side,
1275 fill.size,
1276 fill.price,
1277 );
1278 }
1279 crate::common::enums::DydxFillType::Deleveraged
1280 | crate::common::enums::DydxFillType::Offsetting => {
1281 log::warn!(
1282 "Deleveraging (ADL) fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1283 instrument_id,
1284 fill.id,
1285 fill.order_id,
1286 fill.fill_type,
1287 order_side,
1288 fill.size,
1289 fill.price,
1290 );
1291 }
1292 crate::common::enums::DydxFillType::Limit => {}
1293 crate::common::enums::DydxFillType::Unknown => {
1294 log::warn!(
1295 "Unmodeled dYdX fill type: {} id={} order_id={} side={:?} size={} price={}",
1296 instrument_id,
1297 fill.id,
1298 fill.order_id,
1299 order_side,
1300 fill.size,
1301 fill.price,
1302 );
1303 }
1304 }
1305
1306 let size_precision = instrument.size_precision();
1307 let price_precision = instrument.price_precision();
1308
1309 let last_qty = Quantity::from_decimal_dp(fill.size, size_precision)
1310 .context("failed to parse fill size")?;
1311 let last_px = Price::from_decimal_dp(fill.price, price_precision)
1312 .context("failed to parse fill price")?;
1313
1314 let commission = Money::from_decimal(fill.fee, instrument.quote_currency())
1316 .context("failed to parse fee")?;
1317
1318 let liquidity_side = match fill.liquidity {
1319 DydxLiquidity::Maker => LiquiditySide::Maker,
1320 DydxLiquidity::Taker => LiquiditySide::Taker,
1321 };
1322
1323 let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
1324
1325 let report = FillReport::new(
1326 account_id,
1327 instrument_id,
1328 venue_order_id,
1329 trade_id,
1330 order_side,
1331 last_qty,
1332 last_px,
1333 commission,
1334 liquidity_side,
1335 None, None, ts_event,
1338 ts_init,
1339 Some(UUID4::new()),
1340 );
1341
1342 Ok(report)
1343}
1344
1345pub fn parse_position_status_report(
1351 position: &PerpetualPosition,
1352 instrument: &InstrumentAny,
1353 account_id: AccountId,
1354 ts_init: UnixNanos,
1355) -> anyhow::Result<PositionStatusReport> {
1356 let instrument_id = instrument.id();
1357
1358 let position_side = if position.status.is_closed() || position.size.is_zero() {
1363 PositionSide::Flat
1364 } else {
1365 PositionSide::from(position.side)
1366 };
1367
1368 let quantity = Quantity::from_decimal_dp(position.size.abs(), instrument.size_precision())
1370 .context("failed to parse position size")?;
1371
1372 let avg_px_open = position.entry_price;
1373 let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1374
1375 Ok(PositionStatusReport::new(
1376 account_id,
1377 instrument_id,
1378 position_side.as_specified(),
1379 quantity,
1380 ts_last,
1381 ts_init,
1382 Some(UUID4::new()),
1383 None, Some(avg_px_open),
1385 ))
1386}
1387
1388pub fn parse_account_state(
1403 subaccount: &DydxSubaccountInfo,
1404 account_id: AccountId,
1405 instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1406 oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1407 ts_event: UnixNanos,
1408 ts_init: UnixNanos,
1409) -> anyhow::Result<AccountState> {
1410 use std::collections::HashMap;
1411
1412 use nautilus_model::{
1413 enums::AccountType,
1414 events::AccountState,
1415 types::{AccountBalance, MarginBalance},
1416 };
1417
1418 let mut balances = Vec::new();
1419
1420 let equity: Decimal = if subaccount.equity.is_empty() {
1422 Decimal::ZERO
1423 } else {
1424 subaccount
1425 .equity
1426 .parse()
1427 .context(format!("Failed to parse equity '{}'", subaccount.equity))?
1428 };
1429
1430 let free_collateral: Decimal = if subaccount.free_collateral.is_empty() {
1431 Decimal::ZERO
1432 } else {
1433 subaccount.free_collateral.parse().context(format!(
1434 "Failed to parse freeCollateral '{}'",
1435 subaccount.free_collateral
1436 ))?
1437 };
1438
1439 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1441
1442 let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1443 .context("failed to derive account balance from subaccount data")?;
1444 balances.push(balance);
1445
1446 let mut margins = Vec::new();
1448 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1449 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1450
1451 if let Some(ref positions) = subaccount.open_perpetual_positions {
1452 for position in positions.values() {
1453 let market_str = position.market.as_str();
1455 let instrument_id = parse_instrument_id(market_str);
1456
1457 let instrument = match instruments.get(&instrument_id) {
1459 Some(inst) => inst,
1460 None => {
1461 log::warn!(
1462 "Cannot calculate margin for position {market_str}: instrument not found"
1463 );
1464 continue;
1465 }
1466 };
1467
1468 let (margin_init, margin_maint) = match instrument {
1470 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1471 _ => {
1472 log::warn!(
1473 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1474 );
1475 continue;
1476 }
1477 };
1478
1479 let position_size = match Decimal::from_str(&position.size) {
1481 Ok(size) => size.abs(),
1482 Err(e) => {
1483 log::warn!(
1484 "Failed to parse position size '{}' for {}: {}",
1485 position.size,
1486 market_str,
1487 e
1488 );
1489 continue;
1490 }
1491 };
1492
1493 if position_size.is_zero() {
1495 continue;
1496 }
1497
1498 let oracle_price = oracle_prices
1500 .get(&instrument_id)
1501 .copied()
1502 .or_else(|| Decimal::from_str(&position.entry_price).ok())
1503 .unwrap_or(Decimal::ZERO);
1504
1505 if oracle_price.is_zero() {
1506 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1507 continue;
1508 }
1509
1510 let initial_margin = margin_init * position_size * oracle_price;
1512
1513 let maintenance_margin = margin_maint * position_size * oracle_price;
1514
1515 let quote_currency = instrument.quote_currency();
1517 *initial_margins
1518 .entry(quote_currency)
1519 .or_insert(Decimal::ZERO) += initial_margin;
1520 *maintenance_margins
1521 .entry(quote_currency)
1522 .or_insert(Decimal::ZERO) += maintenance_margin;
1523 }
1524 }
1525
1526 for (currency, initial_margin) in initial_margins {
1528 let maintenance_margin = maintenance_margins
1529 .get(¤cy)
1530 .copied()
1531 .unwrap_or(Decimal::ZERO);
1532
1533 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1534 "Failed to create initial margin Money for {currency}"
1535 ))?;
1536 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1537 format!("Failed to create maintenance margin Money for {currency}"),
1538 )?;
1539
1540 let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1543 margins.push(margin_balance);
1544 }
1545
1546 Ok(AccountState::new(
1547 account_id,
1548 AccountType::Margin, balances,
1550 margins,
1551 true, UUID4::new(),
1553 ts_event,
1554 ts_init,
1555 None, ))
1557}
1558
1559pub fn parse_account_state_from_http(
1570 subaccount: &Subaccount,
1571 account_id: AccountId,
1572 instruments: &HashMap<InstrumentId, InstrumentAny>,
1573 oracle_prices: &HashMap<InstrumentId, Decimal>,
1574 ts_event: UnixNanos,
1575 ts_init: UnixNanos,
1576) -> anyhow::Result<AccountState> {
1577 let mut balances = Vec::new();
1578
1579 let equity = subaccount.equity;
1580 let free_collateral = subaccount.free_collateral;
1581
1582 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1584
1585 let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1586 .context("failed to derive account balance from subaccount data")?;
1587 balances.push(balance);
1588
1589 let mut margins = Vec::new();
1591 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1592 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1593
1594 for position in subaccount.open_perpetual_positions.values() {
1595 let market_str = position.market.as_str();
1596 let instrument_id = parse_instrument_id(market_str);
1597
1598 let instrument = match instruments.get(&instrument_id) {
1599 Some(inst) => inst,
1600 None => {
1601 log::warn!(
1602 "Cannot calculate margin for position {market_str}: instrument not found"
1603 );
1604 continue;
1605 }
1606 };
1607
1608 let (margin_init, margin_maint) = match instrument {
1609 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1610 _ => {
1611 log::warn!(
1612 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1613 );
1614 continue;
1615 }
1616 };
1617
1618 let position_size = position.size.abs();
1619
1620 if position_size.is_zero() {
1621 continue;
1622 }
1623
1624 let oracle_price = oracle_prices
1626 .get(&instrument_id)
1627 .copied()
1628 .unwrap_or(position.entry_price);
1629
1630 if oracle_price.is_zero() {
1631 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1632 continue;
1633 }
1634
1635 let initial_margin = margin_init * position_size * oracle_price;
1636 let maintenance_margin = margin_maint * position_size * oracle_price;
1637
1638 let quote_currency = instrument.quote_currency();
1639 *initial_margins
1640 .entry(quote_currency)
1641 .or_insert(Decimal::ZERO) += initial_margin;
1642 *maintenance_margins
1643 .entry(quote_currency)
1644 .or_insert(Decimal::ZERO) += maintenance_margin;
1645 }
1646
1647 for (currency, initial_margin) in initial_margins {
1648 let maintenance_margin = maintenance_margins
1649 .get(¤cy)
1650 .copied()
1651 .unwrap_or(Decimal::ZERO);
1652
1653 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1654 "Failed to create initial margin Money for {currency}"
1655 ))?;
1656 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1657 format!("Failed to create maintenance margin Money for {currency}"),
1658 )?;
1659
1660 let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1661 margins.push(margin_balance);
1662 }
1663
1664 Ok(AccountState::new(
1665 account_id,
1666 AccountType::Margin,
1667 balances,
1668 margins,
1669 true, UUID4::new(),
1671 ts_event,
1672 ts_init,
1673 None, ))
1675}
1676
1677#[cfg(test)]
1678mod reconciliation_tests {
1679 use chrono::Utc;
1680 use nautilus_model::{
1681 enums::{OrderSide, OrderStatus, TimeInForce},
1682 identifiers::{AccountId, InstrumentId, Symbol},
1683 instruments::{CryptoPerpetual, Instrument},
1684 types::Currency,
1685 };
1686 use rstest::rstest;
1687 use rust_decimal::prelude::ToPrimitive;
1688 use rust_decimal_macros::dec;
1689 use ustr::Ustr;
1690
1691 use super::*;
1692 use crate::common::consts::DYDX_VENUE;
1693
1694 fn create_test_instrument() -> InstrumentAny {
1695 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), *DYDX_VENUE);
1696
1697 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1698 instrument_id,
1699 instrument_id.symbol,
1700 Currency::BTC(),
1701 Currency::USD(),
1702 Currency::USD(),
1703 false,
1704 2, 8, Price::new(0.01, 2), Quantity::new(0.001, 8), Some(Quantity::new(1.0, 0)), Some(Quantity::new(0.001, 8)), Some(Quantity::new(100000.0, 8)), Some(Quantity::new(0.001, 8)), None, None, Some(Price::new(1000000.0, 2)), Some(Price::new(0.01, 2)), Some(dec!(0.05)), Some(dec!(0.03)), Some(dec!(0.0002)), Some(dec!(0.0005)), None, None, UnixNanos::default(), UnixNanos::default(), ))
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(Utc::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(Utc::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, 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(Utc::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(Utc::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 chrono::Duration;
1852
1853 let instrument = create_test_instrument();
1854 let account_id = AccountId::new("DYDX-001");
1855 let now = Utc::now();
1856 let ts_init = UnixNanos::from(now.timestamp_millis() as u64 * 1_000_000);
1857
1858 let expired_at = now - Duration::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 chrono::Duration;
1901
1902 let instrument = create_test_instrument();
1903 let account_id = AccountId::new("DYDX-001");
1904 let now = Utc::now();
1905 let ts_init = UnixNanos::from(now.timestamp_millis() as u64 * 1_000_000);
1906 let future_expiry = now + Duration::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(Utc::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(Utc::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(Utc::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(Utc::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 = Utc::now();
2054 let ts_init = UnixNanos::from(expire_at.timestamp_millis() 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: Utc::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: Utc::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.as_specified());
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: Utc::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.as_specified());
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: Utc::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(Utc::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.as_specified());
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(Utc::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(Utc::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: Utc::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.as_specified());
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: Utc::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.as_specified());
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: Utc::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: Utc::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.as_str(), "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.as_str(), "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}