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, ts_init,
403 ts_init,
404 );
405
406 Ok(InstrumentAny::CryptoPerpetual(instrument))
407}
408
409#[cfg(test)]
410mod tests {
411 use std::str::FromStr;
412
413 use chrono::Utc;
414 use nautilus_model::{
415 data::BarType,
416 enums::{AggressorSide, OrderSide},
417 identifiers::InstrumentId,
418 instruments::Instrument,
419 };
420 use rstest::rstest;
421 use rust_decimal::Decimal;
422 use rust_decimal_macros::dec;
423 use ustr::Ustr;
424
425 use super::*;
426 use crate::{
427 common::{
428 enums::{DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce},
429 testing::load_json_result_fixture,
430 },
431 http::models::{
432 CandlesResponse, FillsResponse, MarketsResponse, Order, OrderbookResponse,
433 SubaccountResponse, TradesResponse, TransfersResponse,
434 },
435 };
436
437 fn create_test_market() -> PerpetualMarket {
438 PerpetualMarket {
439 clob_pair_id: 1,
440 ticker: Ustr::from("BTC-USD"),
441 status: DydxMarketStatus::Active,
442 base_asset: Some(Ustr::from("BTC")),
443 quote_asset: Some(Ustr::from("USD")),
444 step_size: Decimal::from_str("0.001").unwrap(),
445 tick_size: Decimal::from_str("1").unwrap(),
446 index_price: Some(Decimal::from_str("50000").unwrap()),
447 oracle_price: Some(Decimal::from_str("50000").unwrap()),
448 price_change_24h: Decimal::ZERO,
449 next_funding_rate: Decimal::ZERO,
450 next_funding_at: Some(Utc::now()),
451 min_order_size: Some(Decimal::from_str("0.001").unwrap()),
452 market_type: Some(DydxTickerType::Perpetual),
453 initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
454 maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
455 base_position_notional: Some(Decimal::from_str("10000").unwrap()),
456 incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
457 incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
458 max_position_size: Some(Decimal::from_str("100").unwrap()),
459 open_interest: Decimal::from_str("1000000").unwrap(),
460 atomic_resolution: -10,
461 quantum_conversion_exponent: -10,
462 subticks_per_tick: 100,
463 step_base_quantums: 1000,
464 is_reduce_only: false,
465 }
466 }
467
468 #[rstest]
469 fn test_parse_instrument_any_valid() {
470 let market = create_test_market();
471 let maker_fee = Some(Decimal::from_str("0.0002").unwrap());
472 let taker_fee = Some(Decimal::from_str("0.0005").unwrap());
473 let ts_init = UnixNanos::default();
474
475 let result = parse_instrument_any(&market, maker_fee, taker_fee, ts_init);
476 assert!(result.is_ok());
477
478 let instrument = result.unwrap();
479 if let InstrumentAny::CryptoPerpetual(perp) = instrument {
480 assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
481 assert_eq!(perp.base_currency.code.as_str(), "BTC");
482 assert_eq!(perp.quote_currency.code.as_str(), "USD");
483 assert!(!perp.is_inverse);
484 assert_eq!(perp.price_increment.to_string(), "1");
485 assert_eq!(perp.size_increment.to_string(), "0.001");
486 } else {
487 panic!("Expected CryptoPerpetual instrument");
488 }
489 }
490
491 #[rstest]
492 fn test_is_market_active() {
493 assert!(is_market_active(&DydxMarketStatus::Active));
494 assert!(!is_market_active(&DydxMarketStatus::Paused));
495 assert!(!is_market_active(&DydxMarketStatus::CancelOnly));
496 assert!(!is_market_active(&DydxMarketStatus::PostOnly));
497 assert!(!is_market_active(&DydxMarketStatus::Initializing));
498 assert!(!is_market_active(&DydxMarketStatus::FinalSettlement));
499 }
500
501 #[rstest]
502 fn test_parse_instrument_any_invalid_ticker() {
503 let mut market = create_test_market();
504 market.ticker = Ustr::from("INVALID");
505
506 let result = parse_instrument_any(&market, None, None, UnixNanos::default());
507 assert!(result.is_err());
508 let error_msg = result.unwrap_err().to_string();
509 assert!(
511 error_msg.contains("Invalid ticker format")
512 || error_msg.contains("Failed to parse ticker"),
513 "Expected ticker format error, was: {error_msg}"
514 );
515 }
516
517 #[rstest]
518 fn test_validate_ticker_format_valid() {
519 assert!(validate_ticker_format("BTC-USD").is_ok());
520 assert!(validate_ticker_format("ETH-USD").is_ok());
521 assert!(validate_ticker_format("ATOM-USD").is_ok());
522 }
523
524 #[rstest]
525 fn test_validate_ticker_format_invalid() {
526 assert!(validate_ticker_format("BTCUSD").is_err());
528
529 assert!(validate_ticker_format("BTC-USD-PERP").is_err());
531
532 assert!(validate_ticker_format("-USD").is_err());
534
535 assert!(validate_ticker_format("BTC-").is_err());
537
538 assert!(validate_ticker_format("-").is_err());
540 }
541
542 #[rstest]
543 fn test_parse_ticker_currencies_valid() {
544 let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
545 assert_eq!(base, "BTC");
546 assert_eq!(quote, "USD");
547
548 let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
549 assert_eq!(base, "ETH");
550 assert_eq!(quote, "USDC");
551 }
552
553 #[rstest]
554 fn test_parse_ticker_currencies_invalid() {
555 assert!(parse_ticker_currencies("INVALID").is_err());
556 assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
557 }
558
559 #[rstest]
560 fn test_validate_stop_limit_buy_valid() {
561 let result = validate_conditional_order(
562 DydxOrderType::StopLimit,
563 Some(dec!(51000)), dec!(50000), OrderSide::Buy,
566 );
567 assert!(result.is_ok());
568 }
569
570 #[rstest]
571 fn test_validate_stop_limit_buy_invalid() {
572 let result = validate_conditional_order(
574 DydxOrderType::StopLimit,
575 Some(dec!(49000)),
576 dec!(50000),
577 OrderSide::Buy,
578 );
579 assert!(result.is_err());
580 assert!(
581 result
582 .unwrap_err()
583 .to_string()
584 .contains("must be >= limit price")
585 );
586 }
587
588 #[rstest]
589 fn test_validate_stop_limit_sell_valid() {
590 let result = validate_conditional_order(
591 DydxOrderType::StopLimit,
592 Some(dec!(49000)), dec!(50000), OrderSide::Sell,
595 );
596 assert!(result.is_ok());
597 }
598
599 #[rstest]
600 fn test_validate_stop_limit_sell_invalid() {
601 let result = validate_conditional_order(
603 DydxOrderType::StopLimit,
604 Some(dec!(51000)),
605 dec!(50000),
606 OrderSide::Sell,
607 );
608 assert!(result.is_err());
609 assert!(
610 result
611 .unwrap_err()
612 .to_string()
613 .contains("must be <= limit price")
614 );
615 }
616
617 #[rstest]
618 fn test_validate_take_profit_sell_valid() {
619 let result = validate_conditional_order(
620 DydxOrderType::TakeProfitLimit,
621 Some(dec!(51000)), dec!(50000), OrderSide::Sell,
624 );
625 assert!(result.is_ok());
626 }
627
628 #[rstest]
629 fn test_validate_take_profit_buy_valid() {
630 let result = validate_conditional_order(
631 DydxOrderType::TakeProfitLimit,
632 Some(dec!(49000)), dec!(50000), OrderSide::Buy,
635 );
636 assert!(result.is_ok());
637 }
638
639 #[rstest]
640 fn test_validate_missing_trigger_price() {
641 let result =
642 validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
643 assert!(result.is_err());
644 assert!(
645 result
646 .unwrap_err()
647 .to_string()
648 .contains("trigger_price required")
649 );
650 }
651
652 #[rstest]
653 fn test_validate_non_conditional_order() {
654 let result =
656 validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
657 assert!(result.is_ok());
658 }
659
660 #[rstest]
661 fn test_calculate_tif_market() {
662 let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
663 .unwrap();
664 assert_eq!(tif, TimeInForce::Ioc);
665 }
666
667 #[rstest]
668 fn test_calculate_tif_limit_post_only() {
669 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
670 .unwrap();
671 assert_eq!(tif, TimeInForce::Gtc); }
673
674 #[rstest]
675 fn test_calculate_tif_limit_gtc() {
676 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
677 .unwrap();
678 assert_eq!(tif, TimeInForce::Gtc);
679 }
680
681 #[rstest]
682 fn test_calculate_tif_stop_market_ioc() {
683 let tif = calculate_time_in_force(
684 DydxOrderType::StopMarket,
685 DydxTimeInForce::Gtt,
686 false,
687 Some(DydxOrderExecution::Ioc),
688 )
689 .unwrap();
690 assert_eq!(tif, TimeInForce::Ioc);
691 }
692
693 #[rstest]
694 fn test_calculate_tif_stop_limit_post_only() {
695 let tif = calculate_time_in_force(
696 DydxOrderType::StopLimit,
697 DydxTimeInForce::Gtt,
698 false,
699 Some(DydxOrderExecution::PostOnly),
700 )
701 .unwrap();
702 assert_eq!(tif, TimeInForce::Gtc); }
704
705 #[rstest]
706 fn test_calculate_tif_stop_limit_gtc() {
707 let tif =
708 calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
709 .unwrap();
710 assert_eq!(tif, TimeInForce::Gtc);
711 }
712
713 #[rstest]
714 fn test_calculate_tif_stop_market_invalid_post_only() {
715 let result = calculate_time_in_force(
716 DydxOrderType::StopMarket,
717 DydxTimeInForce::Gtt,
718 false,
719 Some(DydxOrderExecution::PostOnly),
720 );
721 assert!(result.is_err());
722 assert!(
723 result
724 .unwrap_err()
725 .to_string()
726 .contains("PostOnly not supported")
727 );
728 }
729
730 #[rstest]
731 fn test_calculate_tif_trailing_stop() {
732 let tif = calculate_time_in_force(
733 DydxOrderType::TrailingStop,
734 DydxTimeInForce::Gtt,
735 false,
736 None,
737 )
738 .unwrap();
739 assert_eq!(tif, TimeInForce::Gtc);
740 }
741
742 #[rstest]
743 fn test_parse_perpetual_markets() {
744 let json = load_json_result_fixture("http_get_perpetual_markets.json");
745 let response: MarketsResponse =
746 serde_json::from_value(json).expect("Failed to parse markets");
747
748 assert_eq!(response.markets.len(), 3);
749 assert!(response.markets.contains_key("BTC-USD"));
750 assert!(response.markets.contains_key("ETH-USD"));
751 assert!(response.markets.contains_key("SOL-USD"));
752
753 let btc = response.markets.get("BTC-USD").unwrap();
754 assert_eq!(btc.ticker, "BTC-USD");
755 assert_eq!(btc.clob_pair_id, 0);
756 assert_eq!(btc.atomic_resolution, -10);
757 }
758
759 #[rstest]
760 fn test_parse_perpetual_market_with_null_oracle_price() {
761 let json = serde_json::json!({
762 "markets": {
763 "WTI-USD": {
764 "clobPairId": "99",
765 "ticker": "WTI-USD",
766 "status": "ACTIVE",
767 "oraclePrice": null,
768 "priceChange24H": "0",
769 "nextFundingRate": "0",
770 "initialMarginFraction": "0.1",
771 "maintenanceMarginFraction": "0.05",
772 "openInterest": "0",
773 "atomicResolution": -7,
774 "quantumConversionExponent": -9,
775 "tickSize": "0.01",
776 "stepSize": "0.1",
777 "stepBaseQuantums": 1000000,
778 "subticksPerTick": 1000000
779 }
780 }
781 });
782 let response: MarketsResponse =
783 serde_json::from_value(json).expect("Failed to parse market with null oraclePrice");
784
785 let wti = response.markets.get("WTI-USD").unwrap();
786 assert_eq!(wti.ticker.as_str(), "WTI-USD");
787 assert_eq!(wti.oracle_price, None);
788 }
789
790 #[rstest]
791 fn test_parse_perpetual_market_with_missing_oracle_price() {
792 let json = serde_json::json!({
793 "markets": {
794 "WTI-USD": {
795 "clobPairId": "99",
796 "ticker": "WTI-USD",
797 "status": "ACTIVE",
798 "priceChange24H": "0",
799 "nextFundingRate": "0",
800 "initialMarginFraction": "0.1",
801 "maintenanceMarginFraction": "0.05",
802 "openInterest": "0",
803 "atomicResolution": -7,
804 "quantumConversionExponent": -9,
805 "tickSize": "0.01",
806 "stepSize": "0.1",
807 "stepBaseQuantums": 1000000,
808 "subticksPerTick": 1000000
809 }
810 }
811 });
812 let response: MarketsResponse =
813 serde_json::from_value(json).expect("Failed to parse market with missing oraclePrice");
814
815 let wti = response.markets.get("WTI-USD").unwrap();
816 assert_eq!(wti.oracle_price, None);
817 }
818
819 #[rstest]
820 fn test_parse_instrument_from_market() {
821 let json = load_json_result_fixture("http_get_perpetual_markets.json");
822 let response: MarketsResponse =
823 serde_json::from_value(json).expect("Failed to parse markets");
824 let btc = response.markets.get("BTC-USD").unwrap();
825
826 let ts_init = UnixNanos::default();
827 let instrument =
828 parse_instrument_any(btc, None, None, ts_init).expect("Failed to parse instrument");
829
830 assert_eq!(instrument.id().symbol.as_str(), "BTC-USD-PERP");
831 assert_eq!(instrument.id().venue.as_str(), "DYDX");
832 }
833
834 #[rstest]
835 fn test_parse_orderbook_response() {
836 let json = load_json_result_fixture("http_get_orderbook.json");
837 let response: OrderbookResponse =
838 serde_json::from_value(json).expect("Failed to parse orderbook");
839
840 assert_eq!(response.bids.len(), 5);
841 assert_eq!(response.asks.len(), 5);
842
843 let best_bid = &response.bids[0];
844 assert_eq!(best_bid.price.to_string(), "89947");
845 assert_eq!(best_bid.size.to_string(), "0.0002");
846
847 let best_ask = &response.asks[0];
848 assert_eq!(best_ask.price.to_string(), "89958");
849 assert_eq!(best_ask.size.to_string(), "0.1177");
850 }
851
852 #[rstest]
853 fn test_parse_trades_response() {
854 let json = load_json_result_fixture("http_get_trades.json");
855 let response: TradesResponse =
856 serde_json::from_value(json).expect("Failed to parse trades");
857
858 assert_eq!(response.trades.len(), 3);
859
860 let first_trade = &response.trades[0];
861 assert_eq!(first_trade.id, "03f89a550000000200000002");
862 assert_eq!(first_trade.side, OrderSide::Buy);
863 assert_eq!(first_trade.price.to_string(), "89942");
864 assert_eq!(first_trade.size.to_string(), "0.0001");
865 }
866
867 #[rstest]
868 fn test_parse_candles_response() {
869 let json = load_json_result_fixture("http_get_candles.json");
870 let response: CandlesResponse =
871 serde_json::from_value(json).expect("Failed to parse candles");
872
873 assert_eq!(response.candles.len(), 3);
874
875 let first_candle = &response.candles[0];
876 assert_eq!(first_candle.ticker, "BTC-USD");
877 assert_eq!(first_candle.open.to_string(), "89934");
878 assert_eq!(first_candle.high.to_string(), "89970");
879 assert_eq!(first_candle.low.to_string(), "89911");
880 assert_eq!(first_candle.close.to_string(), "89941");
881 }
882
883 #[rstest]
884 fn test_parse_subaccount_response() {
885 let json = load_json_result_fixture("http_get_subaccount.json");
886 let response: SubaccountResponse =
887 serde_json::from_value(json).expect("Failed to parse subaccount");
888
889 let subaccount = &response.subaccount;
890 assert_eq!(subaccount.subaccount_number, 0);
891 assert_eq!(subaccount.equity.to_string(), "45.201296");
892 assert_eq!(subaccount.free_collateral.to_string(), "45.201296");
893 assert!(subaccount.margin_enabled);
894 assert_eq!(subaccount.open_perpetual_positions.len(), 0);
895 }
896
897 #[rstest]
898 fn test_parse_orders_response() {
899 let json = load_json_result_fixture("http_get_orders.json");
900 let response: Vec<Order> = serde_json::from_value(json).expect("Failed to parse orders");
901
902 assert_eq!(response.len(), 3);
903
904 let first_order = &response[0];
905 assert_eq!(first_order.id, "0f0981cb-152e-57d3-bea9-4d8e0dd5ed35");
906 assert_eq!(first_order.side, OrderSide::Buy);
907 assert_eq!(first_order.order_type, DydxOrderType::Limit);
908 assert!(first_order.reduce_only);
909
910 let second_order = &response[1];
911 assert_eq!(second_order.side, OrderSide::Sell);
912 assert!(!second_order.reduce_only);
913 }
914
915 #[rstest]
916 fn test_parse_fills_response() {
917 let json = load_json_result_fixture("http_get_fills.json");
918 let response: FillsResponse = serde_json::from_value(json).expect("Failed to parse fills");
919
920 assert_eq!(response.fills.len(), 3);
921
922 let first_fill = &response.fills[0];
923 assert_eq!(first_fill.id, "6450e369-1dc3-5229-8dc2-fb3b5d1cf2ab");
924 assert_eq!(first_fill.side, OrderSide::Buy);
925 assert_eq!(first_fill.market, "BTC-USD");
926 assert_eq!(first_fill.price.to_string(), "105117");
927 }
928
929 #[rstest]
930 fn test_parse_transfers_response() {
931 let json = load_json_result_fixture("http_get_transfers.json");
932 let response: TransfersResponse =
933 serde_json::from_value(json).expect("Failed to parse transfers");
934
935 assert_eq!(response.transfers.len(), 1);
936
937 let deposit = &response.transfers[0];
938 assert_eq!(deposit.transfer_type, DydxTransferType::Deposit);
939 assert_eq!(deposit.asset, "USDC");
940 assert_eq!(deposit.amount.to_string(), "45.334703");
941 }
942
943 #[rstest]
944 fn test_transfer_type_enum_serde() {
945 let test_cases = vec![
947 (DydxTransferType::Deposit, "\"DEPOSIT\""),
948 (DydxTransferType::Withdrawal, "\"WITHDRAWAL\""),
949 (DydxTransferType::TransferIn, "\"TRANSFER_IN\""),
950 (DydxTransferType::TransferOut, "\"TRANSFER_OUT\""),
951 ];
952
953 for (variant, expected_json) in test_cases {
954 let serialized = serde_json::to_string(&variant).expect("Failed to serialize");
956 assert_eq!(
957 serialized, expected_json,
958 "Serialization failed for {variant:?}"
959 );
960
961 let deserialized: DydxTransferType =
963 serde_json::from_str(&serialized).expect("Failed to deserialize");
964 assert_eq!(
965 deserialized, variant,
966 "Deserialization failed for {variant:?}"
967 );
968 }
969 }
970
971 #[rstest]
972 fn test_parse_trade_tick() {
973 let json = load_json_result_fixture("http_get_trades.json");
974 let response: TradesResponse =
975 serde_json::from_value(json).expect("Failed to parse trades");
976
977 let instrument_id = InstrumentId::from("BTC-USD-PERP.DYDX");
978 let ts_init = UnixNanos::from(1_000_000_000u64);
979
980 let tick = parse_trade_tick(&response.trades[0], instrument_id, 0, 4, ts_init)
981 .expect("Failed to parse trade tick");
982
983 assert_eq!(tick.instrument_id, instrument_id);
984 assert_eq!(tick.price.to_string(), "89942");
985 assert_eq!(tick.size.to_string(), "0.0001");
986 assert_eq!(tick.aggressor_side, AggressorSide::Buyer);
987 assert_eq!(tick.trade_id.to_string(), "03f89a550000000200000002");
988 assert_eq!(tick.ts_init, ts_init);
989 }
990
991 #[rstest]
992 #[case(true)]
993 #[case(false)]
994 fn test_parse_bar_timestamp_on_close(#[case] timestamp_on_close: bool) {
995 let json = load_json_result_fixture("http_get_candles.json");
996 let response: CandlesResponse =
997 serde_json::from_value(json).expect("Failed to parse candles");
998
999 let bar_type = BarType::from_str("BTC-USD-PERP.DYDX-1-MINUTE-LAST-EXTERNAL")
1000 .expect("Failed to parse bar type");
1001 let ts_init = UnixNanos::from(1_000_000_000u64);
1002
1003 let bar = parse_bar(
1004 &response.candles[0],
1005 bar_type,
1006 0,
1007 4,
1008 timestamp_on_close,
1009 ts_init,
1010 )
1011 .expect("Failed to parse bar");
1012
1013 assert_eq!(bar.bar_type, bar_type);
1014 assert_eq!(bar.open.to_string(), "89934");
1015 assert_eq!(bar.high.to_string(), "89970");
1016 assert_eq!(bar.low.to_string(), "89911");
1017 assert_eq!(bar.close.to_string(), "89941");
1018 assert_eq!(bar.volume.to_string(), "3.2767");
1019
1020 let started_at_ns = 1_765_210_260_000_000_000u64;
1022 let one_min_ns = 60_000_000_000u64;
1023
1024 if timestamp_on_close {
1025 assert_eq!(bar.ts_event.as_u64(), started_at_ns + one_min_ns);
1026 } else {
1027 assert_eq!(bar.ts_event.as_u64(), started_at_ns);
1028 }
1029 }
1030}
1031
1032use std::str::FromStr;
1033
1034use nautilus_core::UUID4;
1035use nautilus_model::{
1036 enums::{LiquiditySide, OrderStatus, PositionSide, TriggerType},
1037 identifiers::{AccountId, ClientOrderId, VenueOrderId},
1038 instruments::Instrument,
1039 reports::{FillReport, OrderStatusReport, PositionStatusReport},
1040 types::Money,
1041};
1042
1043use super::models::{Fill, Order, PerpetualPosition};
1044use crate::common::enums::{DydxConditionType, DydxLiquidity, DydxOrderStatus};
1045#[cfg(test)]
1046use crate::common::enums::{DydxFillType, DydxPositionSide, DydxPositionStatus, DydxTickerType};
1047
1048fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
1050 match status {
1051 DydxOrderStatus::Open => OrderStatus::Accepted,
1052 DydxOrderStatus::Filled => OrderStatus::Filled,
1053 DydxOrderStatus::Canceled => OrderStatus::Canceled,
1054 DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
1055 DydxOrderStatus::Untriggered => OrderStatus::Accepted, DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
1057 DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
1058 }
1059}
1060
1061pub fn parse_order_status_report(
1067 order: &Order,
1068 instrument: &InstrumentAny,
1069 account_id: AccountId,
1070 ts_init: UnixNanos,
1071) -> anyhow::Result<OrderStatusReport> {
1072 let instrument_id = instrument.id();
1073 let venue_order_id = VenueOrderId::new(&order.id);
1074 let client_order_id = if order.client_id.is_empty() {
1075 None
1076 } else {
1077 Some(ClientOrderId::new(&order.client_id))
1078 };
1079
1080 let order_type = order.order_type.into();
1081
1082 let execution = order.execution.or({
1083 if order.post_only {
1085 Some(DydxOrderExecution::PostOnly)
1086 } else {
1087 Some(DydxOrderExecution::Default)
1088 }
1089 });
1090 let time_in_force = calculate_time_in_force(
1091 order.order_type,
1092 order.time_in_force,
1093 order.reduce_only,
1094 execution,
1095 )?;
1096
1097 let order_side = order.side;
1098 let order_status = parse_order_status(&order.status);
1099
1100 let size_precision = instrument.size_precision();
1101 let quantity = Quantity::from_decimal_dp(order.size, size_precision)
1102 .context("failed to parse order size")?;
1103 let filled_qty = Quantity::from_decimal_dp(order.total_filled, size_precision)
1104 .context("failed to parse total_filled")?;
1105
1106 let price_precision = instrument.price_precision();
1107 let price = Price::from_decimal_dp(order.price, price_precision)
1108 .context("failed to parse order price")?;
1109
1110 let ts_accepted = order.updated_at.map_or(ts_init, |dt| {
1112 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
1113 });
1114 let ts_last = ts_accepted;
1115
1116 let mut report = OrderStatusReport::new(
1117 account_id,
1118 instrument_id,
1119 client_order_id,
1120 venue_order_id,
1121 order_side,
1122 order_type,
1123 time_in_force,
1124 order_status,
1125 quantity,
1126 filled_qty,
1127 ts_accepted,
1128 ts_last,
1129 ts_init,
1130 Some(UUID4::new()),
1131 );
1132
1133 report = report.with_price(price);
1134
1135 if let Some(trigger_price_dec) = order.trigger_price {
1136 let trigger_price = Price::from_decimal_dp(trigger_price_dec, instrument.price_precision())
1137 .context("failed to parse trigger_price")?;
1138 report = report.with_trigger_price(trigger_price);
1139
1140 if let Some(condition_type) = order.condition_type {
1141 let trigger_type = match condition_type {
1142 DydxConditionType::StopLoss => TriggerType::LastPrice,
1143 DydxConditionType::TakeProfit => TriggerType::LastPrice,
1144 DydxConditionType::Unspecified => TriggerType::Default,
1145 };
1146 report = report.with_trigger_type(trigger_type);
1147 }
1148 }
1149
1150 Ok(report)
1151}
1152
1153pub fn parse_fill_report(
1159 fill: &Fill,
1160 instrument: &InstrumentAny,
1161 account_id: AccountId,
1162 ts_init: UnixNanos,
1163) -> anyhow::Result<FillReport> {
1164 let instrument_id = instrument.id();
1165 let venue_order_id = VenueOrderId::new(&fill.order_id);
1166 let trade_id = TradeId::new(&fill.id);
1167 let order_side = fill.side;
1168
1169 match fill.fill_type {
1174 crate::common::enums::DydxFillType::Liquidated
1175 | crate::common::enums::DydxFillType::Liquidation => {
1176 log::warn!(
1177 "Liquidation fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1178 instrument_id,
1179 fill.id,
1180 fill.order_id,
1181 fill.fill_type,
1182 order_side,
1183 fill.size,
1184 fill.price,
1185 );
1186 }
1187 crate::common::enums::DydxFillType::Deleveraged
1188 | crate::common::enums::DydxFillType::Offsetting => {
1189 log::warn!(
1190 "Deleveraging (ADL) fill: {} id={} order_id={} type={:?} side={:?} size={} price={}",
1191 instrument_id,
1192 fill.id,
1193 fill.order_id,
1194 fill.fill_type,
1195 order_side,
1196 fill.size,
1197 fill.price,
1198 );
1199 }
1200 crate::common::enums::DydxFillType::Limit => {}
1201 }
1202
1203 let size_precision = instrument.size_precision();
1204 let price_precision = instrument.price_precision();
1205
1206 let last_qty = Quantity::from_decimal_dp(fill.size, size_precision)
1207 .context("failed to parse fill size")?;
1208 let last_px = Price::from_decimal_dp(fill.price, price_precision)
1209 .context("failed to parse fill price")?;
1210
1211 let commission = Money::from_decimal(fill.fee, instrument.quote_currency())
1213 .context("failed to parse fee")?;
1214
1215 let liquidity_side = match fill.liquidity {
1216 DydxLiquidity::Maker => LiquiditySide::Maker,
1217 DydxLiquidity::Taker => LiquiditySide::Taker,
1218 };
1219
1220 let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
1221
1222 let report = FillReport::new(
1223 account_id,
1224 instrument_id,
1225 venue_order_id,
1226 trade_id,
1227 order_side,
1228 last_qty,
1229 last_px,
1230 commission,
1231 liquidity_side,
1232 None, None, ts_event,
1235 ts_init,
1236 Some(UUID4::new()),
1237 );
1238
1239 Ok(report)
1240}
1241
1242pub fn parse_position_status_report(
1248 position: &PerpetualPosition,
1249 instrument: &InstrumentAny,
1250 account_id: AccountId,
1251 ts_init: UnixNanos,
1252) -> anyhow::Result<PositionStatusReport> {
1253 let instrument_id = instrument.id();
1254
1255 let position_side = if position.status.is_closed() || position.size.is_zero() {
1260 PositionSide::Flat
1261 } else {
1262 PositionSide::from(position.side)
1263 };
1264
1265 let quantity = Quantity::from_decimal_dp(position.size.abs(), instrument.size_precision())
1267 .context("failed to parse position size")?;
1268
1269 let avg_px_open = position.entry_price;
1270 let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
1271
1272 Ok(PositionStatusReport::new(
1273 account_id,
1274 instrument_id,
1275 position_side.as_specified(),
1276 quantity,
1277 ts_last,
1278 ts_init,
1279 Some(UUID4::new()),
1280 None, Some(avg_px_open),
1282 ))
1283}
1284
1285pub fn parse_account_state(
1300 subaccount: &DydxSubaccountInfo,
1301 account_id: AccountId,
1302 instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
1303 oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
1304 ts_event: UnixNanos,
1305 ts_init: UnixNanos,
1306) -> anyhow::Result<AccountState> {
1307 use std::collections::HashMap;
1308
1309 use nautilus_model::{
1310 enums::AccountType,
1311 events::AccountState,
1312 types::{AccountBalance, MarginBalance},
1313 };
1314
1315 let mut balances = Vec::new();
1316
1317 let equity: Decimal = if subaccount.equity.is_empty() {
1319 Decimal::ZERO
1320 } else {
1321 subaccount
1322 .equity
1323 .parse()
1324 .context(format!("Failed to parse equity '{}'", subaccount.equity))?
1325 };
1326
1327 let free_collateral: Decimal = if subaccount.free_collateral.is_empty() {
1328 Decimal::ZERO
1329 } else {
1330 subaccount.free_collateral.parse().context(format!(
1331 "Failed to parse freeCollateral '{}'",
1332 subaccount.free_collateral
1333 ))?
1334 };
1335
1336 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1338
1339 let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1340 .context("failed to derive account balance from subaccount data")?;
1341 balances.push(balance);
1342
1343 let mut margins = Vec::new();
1345 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1346 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1347
1348 if let Some(ref positions) = subaccount.open_perpetual_positions {
1349 for position in positions.values() {
1350 let market_str = position.market.as_str();
1352 let instrument_id = parse_instrument_id(market_str);
1353
1354 let instrument = match instruments.get(&instrument_id) {
1356 Some(inst) => inst,
1357 None => {
1358 log::warn!(
1359 "Cannot calculate margin for position {market_str}: instrument not found"
1360 );
1361 continue;
1362 }
1363 };
1364
1365 let (margin_init, margin_maint) = match instrument {
1367 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1368 _ => {
1369 log::warn!(
1370 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1371 );
1372 continue;
1373 }
1374 };
1375
1376 let position_size = match Decimal::from_str(&position.size) {
1378 Ok(size) => size.abs(),
1379 Err(e) => {
1380 log::warn!(
1381 "Failed to parse position size '{}' for {}: {}",
1382 position.size,
1383 market_str,
1384 e
1385 );
1386 continue;
1387 }
1388 };
1389
1390 if position_size.is_zero() {
1392 continue;
1393 }
1394
1395 let oracle_price = oracle_prices
1397 .get(&instrument_id)
1398 .copied()
1399 .or_else(|| Decimal::from_str(&position.entry_price).ok())
1400 .unwrap_or(Decimal::ZERO);
1401
1402 if oracle_price.is_zero() {
1403 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1404 continue;
1405 }
1406
1407 let initial_margin = margin_init * position_size * oracle_price;
1409
1410 let maintenance_margin = margin_maint * position_size * oracle_price;
1411
1412 let quote_currency = instrument.quote_currency();
1414 *initial_margins
1415 .entry(quote_currency)
1416 .or_insert(Decimal::ZERO) += initial_margin;
1417 *maintenance_margins
1418 .entry(quote_currency)
1419 .or_insert(Decimal::ZERO) += maintenance_margin;
1420 }
1421 }
1422
1423 for (currency, initial_margin) in initial_margins {
1425 let maintenance_margin = maintenance_margins
1426 .get(¤cy)
1427 .copied()
1428 .unwrap_or(Decimal::ZERO);
1429
1430 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1431 "Failed to create initial margin Money for {currency}"
1432 ))?;
1433 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1434 format!("Failed to create maintenance margin Money for {currency}"),
1435 )?;
1436
1437 let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1440 margins.push(margin_balance);
1441 }
1442
1443 Ok(AccountState::new(
1444 account_id,
1445 AccountType::Margin, balances,
1447 margins,
1448 true, UUID4::new(),
1450 ts_event,
1451 ts_init,
1452 None, ))
1454}
1455
1456pub fn parse_account_state_from_http(
1467 subaccount: &Subaccount,
1468 account_id: AccountId,
1469 instruments: &HashMap<InstrumentId, InstrumentAny>,
1470 oracle_prices: &HashMap<InstrumentId, Decimal>,
1471 ts_event: UnixNanos,
1472 ts_init: UnixNanos,
1473) -> anyhow::Result<AccountState> {
1474 let mut balances = Vec::new();
1475
1476 let equity = subaccount.equity;
1477 let free_collateral = subaccount.free_collateral;
1478
1479 let currency = Currency::get_or_create_crypto_with_context("USDC", None);
1481
1482 let balance = AccountBalance::from_total_and_free(equity, free_collateral, currency)
1483 .context("failed to derive account balance from subaccount data")?;
1484 balances.push(balance);
1485
1486 let mut margins = Vec::new();
1488 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1489 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1490
1491 for position in subaccount.open_perpetual_positions.values() {
1492 let market_str = position.market.as_str();
1493 let instrument_id = parse_instrument_id(market_str);
1494
1495 let instrument = match instruments.get(&instrument_id) {
1496 Some(inst) => inst,
1497 None => {
1498 log::warn!(
1499 "Cannot calculate margin for position {market_str}: instrument not found"
1500 );
1501 continue;
1502 }
1503 };
1504
1505 let (margin_init, margin_maint) = match instrument {
1506 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1507 _ => {
1508 log::warn!(
1509 "Instrument {instrument_id} is not a CryptoPerpetual, skipping margin calculation"
1510 );
1511 continue;
1512 }
1513 };
1514
1515 let position_size = position.size.abs();
1516
1517 if position_size.is_zero() {
1518 continue;
1519 }
1520
1521 let oracle_price = oracle_prices
1523 .get(&instrument_id)
1524 .copied()
1525 .unwrap_or(position.entry_price);
1526
1527 if oracle_price.is_zero() {
1528 log::warn!("No valid price for position {market_str}, skipping margin calculation");
1529 continue;
1530 }
1531
1532 let initial_margin = margin_init * position_size * oracle_price;
1533 let maintenance_margin = margin_maint * position_size * oracle_price;
1534
1535 let quote_currency = instrument.quote_currency();
1536 *initial_margins
1537 .entry(quote_currency)
1538 .or_insert(Decimal::ZERO) += initial_margin;
1539 *maintenance_margins
1540 .entry(quote_currency)
1541 .or_insert(Decimal::ZERO) += maintenance_margin;
1542 }
1543
1544 for (currency, initial_margin) in initial_margins {
1545 let maintenance_margin = maintenance_margins
1546 .get(¤cy)
1547 .copied()
1548 .unwrap_or(Decimal::ZERO);
1549
1550 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1551 "Failed to create initial margin Money for {currency}"
1552 ))?;
1553 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1554 format!("Failed to create maintenance margin Money for {currency}"),
1555 )?;
1556
1557 let margin_balance = MarginBalance::new(initial_money, maintenance_money, None);
1558 margins.push(margin_balance);
1559 }
1560
1561 Ok(AccountState::new(
1562 account_id,
1563 AccountType::Margin,
1564 balances,
1565 margins,
1566 true, UUID4::new(),
1568 ts_event,
1569 ts_init,
1570 None, ))
1572}
1573
1574#[cfg(test)]
1575mod reconciliation_tests {
1576 use chrono::Utc;
1577 use nautilus_model::{
1578 enums::{OrderSide, OrderStatus, TimeInForce},
1579 identifiers::{AccountId, InstrumentId, Symbol, Venue},
1580 instruments::{CryptoPerpetual, Instrument},
1581 types::Currency,
1582 };
1583 use rstest::rstest;
1584 use rust_decimal::prelude::ToPrimitive;
1585 use rust_decimal_macros::dec;
1586 use ustr::Ustr;
1587
1588 use super::*;
1589
1590 fn create_test_instrument() -> InstrumentAny {
1591 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), Venue::new("DYDX"));
1592
1593 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1594 instrument_id,
1595 instrument_id.symbol,
1596 Currency::BTC(),
1597 Currency::USD(),
1598 Currency::USD(),
1599 false,
1600 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, UnixNanos::default(), UnixNanos::default(), ))
1620 }
1621
1622 #[rstest]
1623 fn test_parse_order_status() {
1624 assert_eq!(
1625 parse_order_status(&DydxOrderStatus::Open),
1626 OrderStatus::Accepted
1627 );
1628 assert_eq!(
1629 parse_order_status(&DydxOrderStatus::Filled),
1630 OrderStatus::Filled
1631 );
1632 assert_eq!(
1633 parse_order_status(&DydxOrderStatus::Canceled),
1634 OrderStatus::Canceled
1635 );
1636 assert_eq!(
1637 parse_order_status(&DydxOrderStatus::PartiallyFilled),
1638 OrderStatus::PartiallyFilled
1639 );
1640 assert_eq!(
1641 parse_order_status(&DydxOrderStatus::Untriggered),
1642 OrderStatus::Accepted
1643 );
1644 }
1645
1646 #[rstest]
1647 fn test_parse_order_status_report_basic() {
1648 let instrument = create_test_instrument();
1649 let account_id = AccountId::new("DYDX-001");
1650 let ts_init = UnixNanos::default();
1651
1652 let order = Order {
1653 id: "order123".to_string(),
1654 subaccount_id: "subacct1".to_string(),
1655 client_id: "client1".to_string(),
1656 clob_pair_id: 1,
1657 side: OrderSide::Buy,
1658 size: dec!(1.5),
1659 total_filled: dec!(1.0),
1660 price: dec!(50000.0),
1661 status: DydxOrderStatus::PartiallyFilled,
1662 order_type: DydxOrderType::Limit,
1663 time_in_force: DydxTimeInForce::Gtt,
1664 reduce_only: false,
1665 post_only: false,
1666 order_flags: 0,
1667 good_til_block: None,
1668 good_til_block_time: Some(Utc::now()),
1669 created_at_height: Some(1000),
1670 client_metadata: 0,
1671 trigger_price: None,
1672 condition_type: None,
1673 conditional_order_trigger_subticks: None,
1674 execution: None,
1675 updated_at: Some(Utc::now()),
1676 updated_at_height: Some(1001),
1677 ticker: None,
1678 subaccount_number: 0,
1679 order_router_address: None,
1680 };
1681
1682 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1683 if let Err(ref e) = result {
1684 eprintln!("Parse error: {e:?}");
1685 }
1686 assert!(result.is_ok());
1687
1688 let report = result.unwrap();
1689 assert_eq!(report.account_id, account_id);
1690 assert_eq!(report.instrument_id, instrument.id());
1691 assert_eq!(report.order_side, OrderSide::Buy);
1692 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1693 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1694 }
1695
1696 #[rstest]
1697 fn test_parse_order_status_report_conditional() {
1698 let instrument = create_test_instrument();
1699 let account_id = AccountId::new("DYDX-001");
1700 let ts_init = UnixNanos::default();
1701
1702 let order = Order {
1703 id: "order456".to_string(),
1704 subaccount_id: "subacct1".to_string(),
1705 client_id: String::new(), clob_pair_id: 1,
1707 side: OrderSide::Sell,
1708 size: dec!(2.0),
1709 total_filled: dec!(0.0),
1710 price: dec!(51000.0),
1711 status: DydxOrderStatus::Untriggered,
1712 order_type: DydxOrderType::StopLimit,
1713 time_in_force: DydxTimeInForce::Gtt,
1714 reduce_only: true,
1715 post_only: false,
1716 order_flags: 0,
1717 good_til_block: None,
1718 good_til_block_time: Some(Utc::now()),
1719 created_at_height: Some(1000),
1720 client_metadata: 0,
1721 trigger_price: Some(dec!(49000.0)),
1722 condition_type: Some(DydxConditionType::StopLoss),
1723 conditional_order_trigger_subticks: Some(490000),
1724 execution: None,
1725 updated_at: Some(Utc::now()),
1726 updated_at_height: Some(1001),
1727 ticker: None,
1728 subaccount_number: 0,
1729 order_router_address: None,
1730 };
1731
1732 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1733 assert!(result.is_ok());
1734
1735 let report = result.unwrap();
1736 assert_eq!(report.client_order_id, None);
1737 assert!(report.trigger_price.is_some());
1738 assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1739 }
1740
1741 #[rstest]
1742 fn test_parse_fill_report() {
1743 let instrument = create_test_instrument();
1744 let account_id = AccountId::new("DYDX-001");
1745 let ts_init = UnixNanos::default();
1746
1747 let fill = Fill {
1748 id: "fill789".to_string(),
1749 side: OrderSide::Buy,
1750 liquidity: DydxLiquidity::Taker,
1751 fill_type: DydxFillType::Limit,
1752 market: Ustr::from("BTC-USD"),
1753 market_type: DydxTickerType::Perpetual,
1754 price: dec!(50100.0),
1755 size: dec!(1.0),
1756 fee: dec!(-5.01),
1757 created_at: Utc::now(),
1758 created_at_height: 1000,
1759 order_id: "order123".to_string(),
1760 client_metadata: 0,
1761 };
1762
1763 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1764 assert!(result.is_ok());
1765
1766 let report = result.unwrap();
1767 assert_eq!(report.account_id, account_id);
1768 assert_eq!(report.order_side, OrderSide::Buy);
1769 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1770 assert_eq!(report.last_px.as_f64(), 50100.0);
1771 assert_eq!(report.commission.as_decimal(), dec!(-5.01));
1772 }
1773
1774 #[rstest]
1775 fn test_parse_position_status_report_long() {
1776 let instrument = create_test_instrument();
1777 let account_id = AccountId::new("DYDX-001");
1778 let ts_init = UnixNanos::default();
1779
1780 let position = PerpetualPosition {
1781 market: Ustr::from("BTC-USD"),
1782 status: DydxPositionStatus::Open,
1783 side: DydxPositionSide::Long,
1784 size: dec!(2.5),
1785 max_size: dec!(3.0),
1786 entry_price: dec!(49500.0),
1787 exit_price: None,
1788 realized_pnl: dec!(100.0),
1789 created_at_height: 1000,
1790 created_at: Utc::now(),
1791 sum_open: dec!(2.5),
1792 sum_close: dec!(0.0),
1793 net_funding: dec!(-2.5),
1794 unrealized_pnl: dec!(250.0),
1795 closed_at: None,
1796 };
1797
1798 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1799 assert!(result.is_ok());
1800
1801 let report = result.unwrap();
1802 assert_eq!(report.account_id, account_id);
1803 assert_eq!(report.position_side, PositionSide::Long.as_specified());
1804 assert_eq!(report.quantity.as_f64(), 2.5);
1805 assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
1806 }
1807
1808 #[rstest]
1809 fn test_parse_position_status_report_short() {
1810 let instrument = create_test_instrument();
1811 let account_id = AccountId::new("DYDX-001");
1812 let ts_init = UnixNanos::default();
1813
1814 let position = PerpetualPosition {
1815 market: Ustr::from("BTC-USD"),
1816 status: DydxPositionStatus::Open,
1817 side: DydxPositionSide::Short,
1818 size: dec!(-1.5),
1819 max_size: dec!(1.5),
1820 entry_price: dec!(51000.0),
1821 exit_price: None,
1822 realized_pnl: dec!(0.0),
1823 created_at_height: 1000,
1824 created_at: Utc::now(),
1825 sum_open: dec!(1.5),
1826 sum_close: dec!(0.0),
1827 net_funding: dec!(1.2),
1828 unrealized_pnl: dec!(-150.0),
1829 closed_at: None,
1830 };
1831
1832 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1833 assert!(result.is_ok());
1834
1835 let report = result.unwrap();
1836 assert_eq!(report.position_side, PositionSide::Short.as_specified());
1837 assert_eq!(report.quantity.as_f64(), 1.5);
1838 }
1839
1840 #[rstest]
1841 fn test_parse_position_status_report_flat() {
1842 let instrument = create_test_instrument();
1843 let account_id = AccountId::new("DYDX-001");
1844 let ts_init = UnixNanos::default();
1845
1846 let position = PerpetualPosition {
1847 market: Ustr::from("BTC-USD"),
1848 status: DydxPositionStatus::Closed,
1849 side: DydxPositionSide::Long,
1850 size: dec!(0.0),
1851 max_size: dec!(2.0),
1852 entry_price: dec!(50000.0),
1853 exit_price: Some(dec!(51000.0)),
1854 realized_pnl: dec!(500.0),
1855 created_at_height: 1000,
1856 created_at: Utc::now(),
1857 sum_open: dec!(2.0),
1858 sum_close: dec!(2.0),
1859 net_funding: dec!(-5.0),
1860 unrealized_pnl: dec!(0.0),
1861 closed_at: Some(Utc::now()),
1862 };
1863
1864 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1865 assert!(result.is_ok());
1866
1867 let report = result.unwrap();
1868 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
1869 assert_eq!(report.quantity.as_f64(), 0.0);
1870 }
1871
1872 #[rstest]
1874 fn test_parse_order_external_detection() {
1875 let instrument = create_test_instrument();
1876 let account_id = AccountId::new("DYDX-001");
1877 let ts_init = UnixNanos::default();
1878
1879 let order = Order {
1881 id: "external-order-123".to_string(),
1882 subaccount_id: "dydx1test/0".to_string(),
1883 client_id: "99999".to_string(),
1884 clob_pair_id: 1,
1885 side: OrderSide::Buy,
1886 size: dec!(0.5),
1887 total_filled: dec!(0.0),
1888 price: dec!(50000.0),
1889 status: DydxOrderStatus::Open,
1890 order_type: DydxOrderType::Limit,
1891 time_in_force: DydxTimeInForce::Gtt,
1892 reduce_only: false,
1893 post_only: false,
1894 order_flags: 0,
1895 good_til_block: Some(1000),
1896 good_til_block_time: None,
1897 created_at_height: Some(900),
1898 client_metadata: 0,
1899 trigger_price: None,
1900 condition_type: None,
1901 conditional_order_trigger_subticks: None,
1902 execution: None,
1903 updated_at: Some(Utc::now()),
1904 updated_at_height: Some(900),
1905 ticker: None,
1906 subaccount_number: 0,
1907 order_router_address: None,
1908 };
1909
1910 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1911 assert!(result.is_ok());
1912
1913 let report = result.unwrap();
1914 assert_eq!(report.account_id, account_id);
1915 assert_eq!(report.order_status, OrderStatus::Accepted);
1916 assert_eq!(report.filled_qty.as_f64(), 0.0);
1918 }
1919
1920 #[rstest]
1922 fn test_parse_order_partial_fill_reconciliation() {
1923 let instrument = create_test_instrument();
1924 let account_id = AccountId::new("DYDX-001");
1925 let ts_init = UnixNanos::default();
1926
1927 let order = Order {
1928 id: "partial-order-123".to_string(),
1929 subaccount_id: "dydx1test/0".to_string(),
1930 client_id: "12345".to_string(),
1931 clob_pair_id: 1,
1932 side: OrderSide::Buy,
1933 size: dec!(2.0),
1934 total_filled: dec!(0.75),
1935 price: dec!(50000.0),
1936 status: DydxOrderStatus::PartiallyFilled,
1937 order_type: DydxOrderType::Limit,
1938 time_in_force: DydxTimeInForce::Gtt,
1939 reduce_only: false,
1940 post_only: false,
1941 order_flags: 0,
1942 good_til_block: Some(2000),
1943 good_til_block_time: None,
1944 created_at_height: Some(1500),
1945 client_metadata: 0,
1946 trigger_price: None,
1947 condition_type: None,
1948 conditional_order_trigger_subticks: None,
1949 execution: None,
1950 updated_at: Some(Utc::now()),
1951 updated_at_height: Some(1600),
1952 ticker: None,
1953 subaccount_number: 0,
1954 order_router_address: None,
1955 };
1956
1957 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1958 assert!(result.is_ok());
1959
1960 let report = result.unwrap();
1961 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1962 assert_eq!(report.filled_qty.as_f64(), 0.75);
1963 assert_eq!(report.quantity.as_f64(), 2.0);
1964 }
1965
1966 #[rstest]
1968 fn test_parse_multiple_positions() {
1969 let instrument = create_test_instrument();
1970 let account_id = AccountId::new("DYDX-001");
1971 let ts_init = UnixNanos::default();
1972
1973 let long_position = PerpetualPosition {
1975 market: Ustr::from("BTC-USD"),
1976 status: DydxPositionStatus::Open,
1977 side: DydxPositionSide::Long,
1978 size: dec!(1.5),
1979 max_size: dec!(1.5),
1980 entry_price: dec!(49000.0),
1981 exit_price: None,
1982 realized_pnl: dec!(0.0),
1983 created_at_height: 1000,
1984 created_at: Utc::now(),
1985 sum_open: dec!(1.5),
1986 sum_close: dec!(0.0),
1987 net_funding: dec!(-1.0),
1988 unrealized_pnl: dec!(150.0),
1989 closed_at: None,
1990 };
1991
1992 let result1 =
1993 parse_position_status_report(&long_position, &instrument, account_id, ts_init);
1994 assert!(result1.is_ok());
1995 let report1 = result1.unwrap();
1996 assert_eq!(report1.position_side, PositionSide::Long.as_specified());
1997
1998 let short_position = PerpetualPosition {
2000 market: Ustr::from("BTC-USD"),
2001 status: DydxPositionStatus::Open,
2002 side: DydxPositionSide::Short,
2003 size: dec!(-2.0),
2004 max_size: dec!(2.0),
2005 entry_price: dec!(51000.0),
2006 exit_price: None,
2007 realized_pnl: dec!(0.0),
2008 created_at_height: 1100,
2009 created_at: Utc::now(),
2010 sum_open: dec!(2.0),
2011 sum_close: dec!(0.0),
2012 net_funding: dec!(0.5),
2013 unrealized_pnl: dec!(-200.0),
2014 closed_at: None,
2015 };
2016
2017 let result2 =
2018 parse_position_status_report(&short_position, &instrument, account_id, ts_init);
2019 assert!(result2.is_ok());
2020 let report2 = result2.unwrap();
2021 assert_eq!(report2.position_side, PositionSide::Short.as_specified());
2022 }
2023
2024 #[rstest]
2026 fn test_parse_fill_zero_fee() {
2027 let instrument = create_test_instrument();
2028 let account_id = AccountId::new("DYDX-001");
2029 let ts_init = UnixNanos::default();
2030
2031 let fill = Fill {
2032 id: "fill-zero-fee".to_string(),
2033 side: OrderSide::Sell,
2034 liquidity: DydxLiquidity::Maker,
2035 fill_type: DydxFillType::Limit,
2036 market: Ustr::from("BTC-USD"),
2037 market_type: DydxTickerType::Perpetual,
2038 price: dec!(50000.0),
2039 size: dec!(0.1),
2040 fee: dec!(0.0), created_at: Utc::now(),
2042 created_at_height: 1000,
2043 order_id: "order-zero-fee".to_string(),
2044 client_metadata: 0,
2045 };
2046
2047 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2048 assert!(result.is_ok());
2049
2050 let report = result.unwrap();
2051 assert_eq!(report.commission.as_f64(), 0.0);
2052 }
2053
2054 #[rstest]
2056 fn test_parse_fill_maker_rebate() {
2057 let instrument = create_test_instrument();
2058 let account_id = AccountId::new("DYDX-001");
2059 let ts_init = UnixNanos::default();
2060
2061 let fill = Fill {
2062 id: "fill-maker-rebate".to_string(),
2063 side: OrderSide::Buy,
2064 liquidity: DydxLiquidity::Maker,
2065 fill_type: DydxFillType::Limit,
2066 market: Ustr::from("BTC-USD"),
2067 market_type: DydxTickerType::Perpetual,
2068 price: dec!(50000.0),
2069 size: dec!(1.0),
2070 fee: dec!(-2.5), created_at: Utc::now(),
2072 created_at_height: 1000,
2073 order_id: "order-maker-rebate".to_string(),
2074 client_metadata: 0,
2075 };
2076
2077 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
2078 assert!(result.is_ok());
2079
2080 let report = result.unwrap();
2081 assert_eq!(report.commission.as_decimal(), dec!(-2.5));
2082 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
2083 }
2084
2085 #[rstest]
2086 fn test_parse_account_state_empty_balance() {
2087 use crate::websocket::messages::DydxSubaccountInfo;
2088
2089 let subaccount = DydxSubaccountInfo {
2090 address: "dydx1abc".to_string(),
2091 subaccount_number: 0,
2092 equity: String::new(),
2093 free_collateral: String::new(),
2094 open_perpetual_positions: None,
2095 asset_positions: None,
2096 margin_enabled: true,
2097 updated_at_height: "0".to_string(),
2098 latest_processed_block_height: "0".to_string(),
2099 };
2100
2101 let account_id = AccountId::new("DYDX-001");
2102 let instruments = std::collections::HashMap::new();
2103 let oracle_prices = std::collections::HashMap::new();
2104 let ts = UnixNanos::default();
2105
2106 let state = parse_account_state(
2107 &subaccount,
2108 account_id,
2109 &instruments,
2110 &oracle_prices,
2111 ts,
2112 ts,
2113 )
2114 .unwrap();
2115
2116 assert_eq!(state.account_id, account_id);
2117 assert_eq!(state.balances.len(), 1);
2118 let balance = &state.balances[0];
2119 assert_eq!(balance.total.as_f64(), 0.0);
2120 assert_eq!(balance.free.as_f64(), 0.0);
2121 assert_eq!(balance.locked.as_f64(), 0.0);
2122 }
2123
2124 #[rstest]
2125 fn test_parse_account_state_nonzero_balance() {
2126 use crate::websocket::messages::DydxSubaccountInfo;
2127
2128 let subaccount = DydxSubaccountInfo {
2132 address: "dydx1abc".to_string(),
2133 subaccount_number: 0,
2134 equity: "15000".to_string(),
2135 free_collateral: "12500".to_string(),
2136 open_perpetual_positions: None,
2137 asset_positions: None,
2138 margin_enabled: true,
2139 updated_at_height: "0".to_string(),
2140 latest_processed_block_height: "0".to_string(),
2141 };
2142
2143 let account_id = AccountId::new("DYDX-001");
2144 let instruments = std::collections::HashMap::new();
2145 let oracle_prices = std::collections::HashMap::new();
2146 let ts = UnixNanos::default();
2147
2148 let state = parse_account_state(
2149 &subaccount,
2150 account_id,
2151 &instruments,
2152 &oracle_prices,
2153 ts,
2154 ts,
2155 )
2156 .unwrap();
2157
2158 assert_eq!(state.balances.len(), 1);
2159 let balance = &state.balances[0];
2160 assert_eq!(balance.currency.code.as_str(), "USDC");
2161 assert_eq!(balance.total.as_decimal(), dec!(15000));
2162 assert_eq!(balance.free.as_decimal(), dec!(12500));
2163 assert_eq!(balance.locked.as_decimal(), dec!(2500));
2164 }
2165
2166 #[rstest]
2167 fn test_parse_account_state_from_http_nonzero_balance() {
2168 use crate::http::models::Subaccount;
2169
2170 let subaccount = Subaccount {
2174 address: "dydx1abc".to_string(),
2175 subaccount_number: 0,
2176 equity: dec!(15000),
2177 free_collateral: dec!(12500),
2178 open_perpetual_positions: std::collections::HashMap::new(),
2179 asset_positions: std::collections::HashMap::new(),
2180 margin_enabled: true,
2181 updated_at_height: 0,
2182 latest_processed_block_height: None,
2183 };
2184
2185 let account_id = AccountId::new("DYDX-001");
2186 let instruments = std::collections::HashMap::new();
2187 let oracle_prices = std::collections::HashMap::new();
2188 let ts = UnixNanos::default();
2189
2190 let state = parse_account_state_from_http(
2191 &subaccount,
2192 account_id,
2193 &instruments,
2194 &oracle_prices,
2195 ts,
2196 ts,
2197 )
2198 .unwrap();
2199
2200 assert_eq!(state.balances.len(), 1);
2201 let balance = &state.balances[0];
2202 assert_eq!(balance.currency.code.as_str(), "USDC");
2203 assert_eq!(balance.total.as_decimal(), dec!(15000));
2204 assert_eq!(balance.free.as_decimal(), dec!(12500));
2205 assert_eq!(balance.locked.as_decimal(), dec!(2500));
2206 }
2207}