1use anyhow::Context;
38use nautilus_core::UnixNanos;
39use nautilus_model::{
40 enums::{OrderSide, TimeInForce},
41 identifiers::{InstrumentId, Symbol},
42 instruments::{CryptoPerpetual, InstrumentAny},
43 types::Currency,
44};
45use rust_decimal::Decimal;
46
47use super::models::PerpetualMarket;
48use crate::common::{
49 enums::{DydxMarketStatus, DydxOrderExecution, DydxOrderType, DydxTimeInForce},
50 parse::{get_currency, parse_decimal, parse_instrument_id, parse_price, parse_quantity},
51};
52
53pub fn validate_ticker_format(ticker: &str) -> anyhow::Result<()> {
60 let parts: Vec<&str> = ticker.split('-').collect();
61 if parts.len() != 2 {
62 anyhow::bail!(
63 "Invalid ticker format '{}', expected 'BASE-QUOTE' (e.g., 'BTC-USD')",
64 ticker
65 );
66 }
67 if parts[0].is_empty() || parts[1].is_empty() {
68 anyhow::bail!(
69 "Invalid ticker format '{}', base and quote cannot be empty",
70 ticker
71 );
72 }
73 Ok(())
74}
75
76pub fn parse_ticker_currencies(ticker: &str) -> anyhow::Result<(&str, &str)> {
83 validate_ticker_format(ticker)?;
84 let parts: Vec<&str> = ticker.split('-').collect();
85 Ok((parts[0], parts[1]))
86}
87
88pub fn validate_market_active(ticker: &str, status: &DydxMarketStatus) -> anyhow::Result<()> {
94 if *status != DydxMarketStatus::Active {
95 anyhow::bail!(
96 "Market '{}' is not active (status: {:?}). Only active markets can be parsed.",
97 ticker,
98 status
99 );
100 }
101 Ok(())
102}
103
104pub fn calculate_time_in_force(
110 order_type: DydxOrderType,
111 base_tif: DydxTimeInForce,
112 post_only: bool,
113 execution: Option<DydxOrderExecution>,
114) -> anyhow::Result<TimeInForce> {
115 match order_type {
116 DydxOrderType::Market => Ok(TimeInForce::Ioc),
117 DydxOrderType::Limit if post_only => Ok(TimeInForce::Gtc), DydxOrderType::Limit => match base_tif {
119 DydxTimeInForce::Gtt => Ok(TimeInForce::Gtc),
120 DydxTimeInForce::Fok => Ok(TimeInForce::Fok),
121 DydxTimeInForce::Ioc => Ok(TimeInForce::Ioc),
122 },
123
124 DydxOrderType::StopLimit | DydxOrderType::TakeProfitLimit => match execution {
125 Some(DydxOrderExecution::PostOnly) => Ok(TimeInForce::Gtc), Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
127 Some(DydxOrderExecution::Ioc) => Ok(TimeInForce::Ioc),
128 Some(DydxOrderExecution::Default) | None => Ok(TimeInForce::Gtc), },
130
131 DydxOrderType::StopMarket | DydxOrderType::TakeProfitMarket => match execution {
132 Some(DydxOrderExecution::Fok) => Ok(TimeInForce::Fok),
133 Some(DydxOrderExecution::Ioc | DydxOrderExecution::Default) | None => {
134 Ok(TimeInForce::Ioc)
135 }
136 Some(DydxOrderExecution::PostOnly) => {
137 anyhow::bail!("Execution PostOnly not supported for {order_type:?}")
138 }
139 },
140
141 DydxOrderType::TrailingStop => Ok(TimeInForce::Gtc),
142 }
143}
144
145pub fn validate_conditional_order(
156 order_type: DydxOrderType,
157 trigger_price: Option<Decimal>,
158 price: Decimal,
159 side: OrderSide,
160) -> anyhow::Result<()> {
161 if !order_type.is_conditional() {
162 return Ok(());
163 }
164
165 let trigger_price = trigger_price
166 .ok_or_else(|| anyhow::anyhow!("trigger_price required for {order_type:?}"))?;
167
168 match order_type {
170 DydxOrderType::StopLimit | DydxOrderType::StopMarket => {
171 match side {
173 OrderSide::Buy if trigger_price < price => {
174 anyhow::bail!(
175 "Stop buy trigger_price ({trigger_price}) must be >= limit price ({price})"
176 );
177 }
178 OrderSide::Sell if trigger_price > price => {
179 anyhow::bail!(
180 "Stop sell trigger_price ({trigger_price}) must be <= limit price ({price})"
181 );
182 }
183 _ => {}
184 }
185 }
186 DydxOrderType::TakeProfitLimit | DydxOrderType::TakeProfitMarket => {
187 match side {
189 OrderSide::Buy if trigger_price > price => {
190 anyhow::bail!(
191 "Take profit buy trigger_price ({trigger_price}) must be <= limit price ({price})"
192 );
193 }
194 OrderSide::Sell if trigger_price < price => {
195 anyhow::bail!(
196 "Take profit sell trigger_price ({trigger_price}) must be >= limit price ({price})"
197 );
198 }
199 _ => {}
200 }
201 }
202 _ => {}
203 }
204
205 Ok(())
206}
207
208pub fn parse_instrument_any(
224 definition: &PerpetualMarket,
225 maker_fee: Option<rust_decimal::Decimal>,
226 taker_fee: Option<rust_decimal::Decimal>,
227 ts_init: UnixNanos,
228) -> anyhow::Result<InstrumentAny> {
229 validate_market_active(&definition.ticker, &definition.status)?;
231
232 let instrument_id = parse_instrument_id(&definition.ticker);
234 let raw_symbol = Symbol::from(definition.ticker.as_str());
235
236 let (base_str, quote_str) = parse_ticker_currencies(&definition.ticker)
238 .context(format!("Failed to parse ticker '{}'", definition.ticker))?;
239
240 let base_currency = get_currency(base_str);
241 let quote_currency = get_currency(quote_str);
242 let settlement_currency = quote_currency; let price_increment =
246 parse_price(&definition.tick_size.to_string(), "tick_size").context(format!(
247 "Failed to parse tick_size '{}' for market '{}'",
248 definition.tick_size, definition.ticker
249 ))?;
250
251 let size_increment =
252 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
253 "Failed to parse step_size '{}' for market '{}'",
254 definition.step_size, definition.ticker
255 ))?;
256
257 let min_quantity = Some(if let Some(min_size) = &definition.min_order_size {
259 parse_quantity(&min_size.to_string(), "min_order_size").context(format!(
260 "Failed to parse min_order_size '{}' for market '{}'",
261 min_size, definition.ticker
262 ))?
263 } else {
264 parse_quantity(&definition.step_size.to_string(), "step_size").context(format!(
266 "Failed to parse step_size as min_quantity for market '{}'",
267 definition.ticker
268 ))?
269 });
270
271 let margin_init = Some(
273 parse_decimal(
274 &definition.initial_margin_fraction.to_string(),
275 "initial_margin_fraction",
276 )
277 .context(format!(
278 "Failed to parse initial_margin_fraction '{}' for market '{}'",
279 definition.initial_margin_fraction, definition.ticker
280 ))?,
281 );
282
283 let margin_maint = Some(
284 parse_decimal(
285 &definition.maintenance_margin_fraction.to_string(),
286 "maintenance_margin_fraction",
287 )
288 .context(format!(
289 "Failed to parse maintenance_margin_fraction '{}' for market '{}'",
290 definition.maintenance_margin_fraction, definition.ticker
291 ))?,
292 );
293
294 let instrument = CryptoPerpetual::new(
296 instrument_id,
297 raw_symbol,
298 base_currency,
299 quote_currency,
300 settlement_currency,
301 false, price_increment.precision,
303 size_increment.precision,
304 price_increment,
305 size_increment,
306 None, Some(size_increment), None, min_quantity,
310 None, None, None, None, margin_init,
315 margin_maint,
316 maker_fee,
317 taker_fee,
318 ts_init,
319 ts_init,
320 );
321
322 Ok(InstrumentAny::CryptoPerpetual(instrument))
323}
324
325#[cfg(test)]
330mod tests {
331 use std::str::FromStr;
332
333 use chrono::Utc;
334 use nautilus_model::enums::OrderSide;
335 use rstest::rstest;
336 use rust_decimal::Decimal;
337 use rust_decimal_macros::dec;
338
339 use super::*;
340 use crate::common::enums::{
341 DydxOrderExecution, DydxOrderType, DydxTickerType, DydxTimeInForce,
342 };
343
344 fn create_test_market() -> PerpetualMarket {
345 PerpetualMarket {
346 clob_pair_id: 1,
347 ticker: "BTC-USD".to_string(),
348 status: DydxMarketStatus::Active,
349 base_asset: Some("BTC".to_string()),
350 quote_asset: Some("USD".to_string()),
351 step_size: Decimal::from_str("0.001").unwrap(),
352 tick_size: Decimal::from_str("1").unwrap(),
353 index_price: Some(Decimal::from_str("50000").unwrap()),
354 oracle_price: Decimal::from_str("50000").unwrap(),
355 price_change_24h: Decimal::ZERO,
356 next_funding_rate: Decimal::ZERO,
357 next_funding_at: Some(Utc::now()),
358 min_order_size: Some(Decimal::from_str("0.001").unwrap()),
359 market_type: Some(DydxTickerType::Perpetual),
360 initial_margin_fraction: Decimal::from_str("0.05").unwrap(),
361 maintenance_margin_fraction: Decimal::from_str("0.03").unwrap(),
362 base_position_notional: Some(Decimal::from_str("10000").unwrap()),
363 incremental_position_size: Some(Decimal::from_str("10000").unwrap()),
364 incremental_initial_margin_fraction: Some(Decimal::from_str("0.01").unwrap()),
365 max_position_size: Some(Decimal::from_str("100").unwrap()),
366 open_interest: Decimal::from_str("1000000").unwrap(),
367 atomic_resolution: -10,
368 quantum_conversion_exponent: -10,
369 subticks_per_tick: 100,
370 step_base_quantums: 1000,
371 is_reduce_only: false,
372 }
373 }
374
375 #[rstest]
376 fn test_parse_instrument_any_valid() {
377 let market = create_test_market();
378 let maker_fee = Some(Decimal::from_str("0.0002").unwrap());
379 let taker_fee = Some(Decimal::from_str("0.0005").unwrap());
380 let ts_init = UnixNanos::default();
381
382 let result = parse_instrument_any(&market, maker_fee, taker_fee, ts_init);
383 assert!(result.is_ok());
384
385 let instrument = result.unwrap();
386 if let InstrumentAny::CryptoPerpetual(perp) = instrument {
387 assert_eq!(perp.id.symbol.as_str(), "BTC-USD-PERP");
388 assert_eq!(perp.base_currency.code.as_str(), "BTC");
389 assert_eq!(perp.quote_currency.code.as_str(), "USD");
390 assert!(!perp.is_inverse);
391 assert_eq!(perp.price_increment.to_string(), "1");
392 assert_eq!(perp.size_increment.to_string(), "0.001");
393 } else {
394 panic!("Expected CryptoPerpetual instrument");
395 }
396 }
397
398 #[rstest]
399 fn test_parse_instrument_any_inactive_market() {
400 let mut market = create_test_market();
401 market.status = DydxMarketStatus::Paused;
402
403 let result = parse_instrument_any(&market, None, None, UnixNanos::default());
404 assert!(result.is_err());
405 assert!(result.unwrap_err().to_string().contains("not active"));
406 }
407
408 #[rstest]
409 fn test_parse_instrument_any_invalid_ticker() {
410 let mut market = create_test_market();
411 market.ticker = "INVALID".to_string();
412
413 let result = parse_instrument_any(&market, None, None, UnixNanos::default());
414 assert!(result.is_err());
415 let error_msg = result.unwrap_err().to_string();
416 assert!(
418 error_msg.contains("Invalid ticker format")
419 || error_msg.contains("Failed to parse ticker"),
420 "Expected ticker format error, was: {}",
421 error_msg
422 );
423 }
424
425 #[rstest]
426 fn test_validate_ticker_format_valid() {
427 assert!(validate_ticker_format("BTC-USD").is_ok());
428 assert!(validate_ticker_format("ETH-USD").is_ok());
429 assert!(validate_ticker_format("ATOM-USD").is_ok());
430 }
431
432 #[rstest]
433 fn test_validate_ticker_format_invalid() {
434 assert!(validate_ticker_format("BTCUSD").is_err());
436
437 assert!(validate_ticker_format("BTC-USD-PERP").is_err());
439
440 assert!(validate_ticker_format("-USD").is_err());
442
443 assert!(validate_ticker_format("BTC-").is_err());
445
446 assert!(validate_ticker_format("-").is_err());
448 }
449
450 #[rstest]
451 fn test_parse_ticker_currencies_valid() {
452 let (base, quote) = parse_ticker_currencies("BTC-USD").unwrap();
453 assert_eq!(base, "BTC");
454 assert_eq!(quote, "USD");
455
456 let (base, quote) = parse_ticker_currencies("ETH-USDC").unwrap();
457 assert_eq!(base, "ETH");
458 assert_eq!(quote, "USDC");
459 }
460
461 #[rstest]
462 fn test_parse_ticker_currencies_invalid() {
463 assert!(parse_ticker_currencies("INVALID").is_err());
464 assert!(parse_ticker_currencies("BTC-USD-PERP").is_err());
465 }
466
467 #[rstest]
468 fn test_validate_market_active() {
469 assert!(validate_market_active("BTC-USD", &DydxMarketStatus::Active).is_ok());
470
471 assert!(validate_market_active("BTC-USD", &DydxMarketStatus::Paused).is_err());
472 assert!(validate_market_active("BTC-USD", &DydxMarketStatus::CancelOnly).is_err());
473 assert!(validate_market_active("BTC-USD", &DydxMarketStatus::PostOnly).is_err());
474 }
475
476 #[rstest]
477 fn test_validate_stop_limit_buy_valid() {
478 let result = validate_conditional_order(
479 DydxOrderType::StopLimit,
480 Some(dec!(51000)), dec!(50000), OrderSide::Buy,
483 );
484 assert!(result.is_ok());
485 }
486
487 #[rstest]
488 fn test_validate_stop_limit_buy_invalid() {
489 let result = validate_conditional_order(
491 DydxOrderType::StopLimit,
492 Some(dec!(49000)),
493 dec!(50000),
494 OrderSide::Buy,
495 );
496 assert!(result.is_err());
497 assert!(
498 result
499 .unwrap_err()
500 .to_string()
501 .contains("must be >= limit price")
502 );
503 }
504
505 #[rstest]
506 fn test_validate_stop_limit_sell_valid() {
507 let result = validate_conditional_order(
508 DydxOrderType::StopLimit,
509 Some(dec!(49000)), dec!(50000), OrderSide::Sell,
512 );
513 assert!(result.is_ok());
514 }
515
516 #[rstest]
517 fn test_validate_stop_limit_sell_invalid() {
518 let result = validate_conditional_order(
520 DydxOrderType::StopLimit,
521 Some(dec!(51000)),
522 dec!(50000),
523 OrderSide::Sell,
524 );
525 assert!(result.is_err());
526 assert!(
527 result
528 .unwrap_err()
529 .to_string()
530 .contains("must be <= limit price")
531 );
532 }
533
534 #[rstest]
535 fn test_validate_take_profit_sell_valid() {
536 let result = validate_conditional_order(
537 DydxOrderType::TakeProfitLimit,
538 Some(dec!(51000)), dec!(50000), OrderSide::Sell,
541 );
542 assert!(result.is_ok());
543 }
544
545 #[rstest]
546 fn test_validate_take_profit_buy_valid() {
547 let result = validate_conditional_order(
548 DydxOrderType::TakeProfitLimit,
549 Some(dec!(49000)), dec!(50000), OrderSide::Buy,
552 );
553 assert!(result.is_ok());
554 }
555
556 #[rstest]
557 fn test_validate_missing_trigger_price() {
558 let result =
559 validate_conditional_order(DydxOrderType::StopLimit, None, dec!(50000), OrderSide::Buy);
560 assert!(result.is_err());
561 assert!(
562 result
563 .unwrap_err()
564 .to_string()
565 .contains("trigger_price required")
566 );
567 }
568
569 #[rstest]
570 fn test_validate_non_conditional_order() {
571 let result =
573 validate_conditional_order(DydxOrderType::Limit, None, dec!(50000), OrderSide::Buy);
574 assert!(result.is_ok());
575 }
576
577 #[rstest]
578 fn test_calculate_tif_market() {
579 let tif = calculate_time_in_force(DydxOrderType::Market, DydxTimeInForce::Gtt, false, None)
580 .unwrap();
581 assert_eq!(tif, TimeInForce::Ioc);
582 }
583
584 #[rstest]
585 fn test_calculate_tif_limit_post_only() {
586 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, true, None)
587 .unwrap();
588 assert_eq!(tif, TimeInForce::Gtc); }
590
591 #[rstest]
592 fn test_calculate_tif_limit_gtc() {
593 let tif = calculate_time_in_force(DydxOrderType::Limit, DydxTimeInForce::Gtt, false, None)
594 .unwrap();
595 assert_eq!(tif, TimeInForce::Gtc);
596 }
597
598 #[rstest]
599 fn test_calculate_tif_stop_market_ioc() {
600 let tif = calculate_time_in_force(
601 DydxOrderType::StopMarket,
602 DydxTimeInForce::Gtt,
603 false,
604 Some(DydxOrderExecution::Ioc),
605 )
606 .unwrap();
607 assert_eq!(tif, TimeInForce::Ioc);
608 }
609
610 #[rstest]
611 fn test_calculate_tif_stop_limit_post_only() {
612 let tif = calculate_time_in_force(
613 DydxOrderType::StopLimit,
614 DydxTimeInForce::Gtt,
615 false,
616 Some(DydxOrderExecution::PostOnly),
617 )
618 .unwrap();
619 assert_eq!(tif, TimeInForce::Gtc); }
621
622 #[rstest]
623 fn test_calculate_tif_stop_limit_gtc() {
624 let tif =
625 calculate_time_in_force(DydxOrderType::StopLimit, DydxTimeInForce::Gtt, false, None)
626 .unwrap();
627 assert_eq!(tif, TimeInForce::Gtc);
628 }
629
630 #[rstest]
631 fn test_calculate_tif_stop_market_invalid_post_only() {
632 let result = calculate_time_in_force(
633 DydxOrderType::StopMarket,
634 DydxTimeInForce::Gtt,
635 false,
636 Some(DydxOrderExecution::PostOnly),
637 );
638 assert!(result.is_err());
639 assert!(
640 result
641 .unwrap_err()
642 .to_string()
643 .contains("PostOnly not supported")
644 );
645 }
646
647 #[rstest]
648 fn test_calculate_tif_trailing_stop() {
649 let tif = calculate_time_in_force(
650 DydxOrderType::TrailingStop,
651 DydxTimeInForce::Gtt,
652 false,
653 None,
654 )
655 .unwrap();
656 assert_eq!(tif, TimeInForce::Gtc);
657 }
658}
659
660use std::str::FromStr;
665
666use nautilus_core::UUID4;
667use nautilus_model::{
668 enums::{LiquiditySide, OrderStatus, PositionSide, TriggerType},
669 identifiers::{AccountId, ClientOrderId, PositionId, TradeId, VenueOrderId},
670 instruments::Instrument,
671 reports::{FillReport, OrderStatusReport, PositionStatusReport},
672 types::{Money, Price, Quantity},
673};
674use rust_decimal::prelude::ToPrimitive;
675
676use super::models::{Fill, Order, PerpetualPosition};
677use crate::common::enums::{DydxLiquidity, DydxOrderStatus};
678
679fn parse_order_status(status: &DydxOrderStatus) -> OrderStatus {
681 match status {
682 DydxOrderStatus::Open => OrderStatus::Accepted,
683 DydxOrderStatus::Filled => OrderStatus::Filled,
684 DydxOrderStatus::Canceled => OrderStatus::Canceled,
685 DydxOrderStatus::BestEffortCanceled => OrderStatus::Canceled,
686 DydxOrderStatus::Untriggered => OrderStatus::Accepted, DydxOrderStatus::BestEffortOpened => OrderStatus::Accepted,
688 DydxOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
689 }
690}
691
692pub fn parse_order_status_report(
698 order: &Order,
699 instrument: &InstrumentAny,
700 account_id: AccountId,
701 ts_init: UnixNanos,
702) -> anyhow::Result<OrderStatusReport> {
703 let instrument_id = instrument.id();
704 let venue_order_id = VenueOrderId::new(&order.id);
705 let client_order_id = if order.client_id.is_empty() {
706 None
707 } else {
708 Some(ClientOrderId::new(&order.client_id))
709 };
710
711 let dydx_order_type = DydxOrderType::from_str(&order.order_type)?;
713 let order_type = dydx_order_type.into();
714
715 let execution = order.execution.or({
716 if order.post_only {
718 Some(DydxOrderExecution::PostOnly)
719 } else {
720 Some(DydxOrderExecution::Default)
721 }
722 });
723 let time_in_force = calculate_time_in_force(
724 dydx_order_type,
725 order.time_in_force,
726 order.reduce_only,
727 execution,
728 )?;
729
730 let order_side = order.side;
731 let order_status = parse_order_status(&order.status);
732
733 let size_precision = instrument.size_precision();
735 let quantity = Quantity::new(
736 order
737 .size
738 .to_f64()
739 .context("failed to convert order size to f64")?,
740 size_precision,
741 );
742 let filled_qty = Quantity::from_raw(
743 quantity.raw.saturating_sub(
744 Quantity::new(
745 order
746 .remaining_size
747 .to_f64()
748 .context("failed to convert remaining_size to f64")?,
749 size_precision,
750 )
751 .raw,
752 ),
753 size_precision,
754 );
755
756 let price_precision = instrument.price_precision();
758 let price = Price::new(
759 order
760 .price
761 .to_f64()
762 .context("failed to convert order price to f64")?,
763 price_precision,
764 );
765
766 let ts_accepted = order.good_til_block_time.map_or(ts_init, |dt| {
768 UnixNanos::from(dt.timestamp_millis() as u64 * 1_000_000)
769 });
770 let ts_last = UnixNanos::from(order.updated_at.timestamp_millis() as u64 * 1_000_000);
771
772 let mut report = OrderStatusReport::new(
774 account_id,
775 instrument_id,
776 client_order_id,
777 venue_order_id,
778 order_side,
779 order_type,
780 time_in_force,
781 order_status,
782 quantity,
783 filled_qty,
784 ts_accepted,
785 ts_last,
786 ts_init,
787 Some(UUID4::new()),
788 );
789
790 report = report.with_price(price);
792
793 if let Some(trigger_price_dec) = order.trigger_price {
795 let trigger_price = Price::new(
796 trigger_price_dec
797 .to_f64()
798 .context("failed to convert trigger_price to f64")?,
799 instrument.price_precision(),
800 );
801 report = report.with_trigger_price(trigger_price);
802
803 if let Some(condition_type) = order.condition_type {
805 let trigger_type = match condition_type {
806 crate::common::enums::DydxConditionType::StopLoss => TriggerType::LastPrice,
807 crate::common::enums::DydxConditionType::TakeProfit => TriggerType::LastPrice,
808 crate::common::enums::DydxConditionType::Unspecified => TriggerType::Default,
809 };
810 report = report.with_trigger_type(trigger_type);
811 }
812 }
813
814 Ok(report)
815}
816
817pub fn parse_fill_report(
823 fill: &Fill,
824 instrument: &InstrumentAny,
825 account_id: AccountId,
826 ts_init: UnixNanos,
827) -> anyhow::Result<FillReport> {
828 let instrument_id = instrument.id();
829 let venue_order_id = VenueOrderId::new(&fill.order_id);
830
831 let trade_id = TradeId::new(&fill.id);
833
834 let order_side = fill.side;
835
836 let size_precision = instrument.size_precision();
838 let price_precision = instrument.price_precision();
839
840 let last_qty = Quantity::new(
841 fill.size
842 .to_f64()
843 .context("failed to convert fill size to f64")?,
844 size_precision,
845 );
846 let last_px = Price::new(
847 fill.price
848 .to_f64()
849 .context("failed to convert fill price to f64")?,
850 price_precision,
851 );
852
853 let commission = Money::new(
860 -fill.fee.to_f64().context("failed to convert fee to f64")?,
861 instrument.quote_currency(),
862 );
863
864 let liquidity_side = match fill.liquidity {
866 DydxLiquidity::Maker => LiquiditySide::Maker,
867 DydxLiquidity::Taker => LiquiditySide::Taker,
868 };
869
870 let ts_event = UnixNanos::from(fill.created_at.timestamp_millis() as u64 * 1_000_000);
872
873 let report = FillReport::new(
874 account_id,
875 instrument_id,
876 venue_order_id,
877 trade_id,
878 order_side,
879 last_qty,
880 last_px,
881 commission,
882 liquidity_side,
883 None, None, ts_event,
886 ts_init,
887 Some(UUID4::new()),
888 );
889
890 Ok(report)
891}
892
893pub fn parse_position_status_report(
899 position: &PerpetualPosition,
900 instrument: &InstrumentAny,
901 account_id: AccountId,
902 ts_init: UnixNanos,
903) -> anyhow::Result<PositionStatusReport> {
904 let instrument_id = instrument.id();
905
906 let position_side = if position.size.is_zero() {
908 PositionSide::Flat
909 } else if position.size.is_sign_positive() {
910 PositionSide::Long
911 } else {
912 PositionSide::Short
913 };
914
915 let quantity = Quantity::new(
917 position
918 .size
919 .abs()
920 .to_f64()
921 .context("failed to convert position size to f64")?,
922 instrument.size_precision(),
923 );
924
925 let avg_px_open = position.entry_price;
927
928 let ts_last = UnixNanos::from(position.created_at.timestamp_millis() as u64 * 1_000_000);
930
931 let venue_position_id = Some(PositionId::new(format!(
933 "{}_{}",
934 account_id, position.market
935 )));
936
937 Ok(PositionStatusReport::new(
938 account_id,
939 instrument_id,
940 position_side.as_specified(),
941 quantity,
942 ts_last,
943 ts_init,
944 Some(UUID4::new()),
945 venue_position_id,
946 Some(avg_px_open),
947 ))
948}
949
950pub fn parse_account_state(
965 subaccount: &crate::schemas::ws::DydxSubaccountInfo,
966 account_id: AccountId,
967 instruments: &std::collections::HashMap<InstrumentId, InstrumentAny>,
968 oracle_prices: &std::collections::HashMap<InstrumentId, Decimal>,
969 ts_event: UnixNanos,
970 ts_init: UnixNanos,
971) -> anyhow::Result<nautilus_model::events::AccountState> {
972 use std::collections::HashMap;
973
974 use nautilus_model::{
975 enums::AccountType,
976 events::AccountState,
977 types::{AccountBalance, MarginBalance},
978 };
979
980 let mut balances = Vec::new();
981
982 let equity_f64 = subaccount.equity.parse::<f64>().context(format!(
984 "Failed to parse equity '{}' as f64",
985 subaccount.equity
986 ))?;
987
988 let free_collateral_f64 = subaccount.free_collateral.parse::<f64>().context(format!(
989 "Failed to parse freeCollateral '{}' as f64",
990 subaccount.free_collateral
991 ))?;
992
993 let currency = get_currency("USDC");
995
996 let total = Money::new(equity_f64, currency);
997 let free = Money::new(free_collateral_f64, currency);
998 let locked = total - free;
999
1000 let balance = AccountBalance::new_checked(total, locked, free)
1001 .context("Failed to create AccountBalance from subaccount data")?;
1002 balances.push(balance);
1003
1004 let mut margins = Vec::new();
1006 let mut initial_margins: HashMap<Currency, Decimal> = HashMap::new();
1007 let mut maintenance_margins: HashMap<Currency, Decimal> = HashMap::new();
1008
1009 if let Some(ref positions) = subaccount.open_perpetual_positions {
1010 for position in positions.values() {
1011 let market_str = position.market.as_str();
1013 let instrument_id = parse_instrument_id(market_str);
1014
1015 let instrument = match instruments.get(&instrument_id) {
1017 Some(inst) => inst,
1018 None => {
1019 tracing::warn!(
1020 "Cannot calculate margin for position {}: instrument not found",
1021 market_str
1022 );
1023 continue;
1024 }
1025 };
1026
1027 let (margin_init, margin_maint) = match instrument {
1029 InstrumentAny::CryptoPerpetual(perp) => (perp.margin_init, perp.margin_maint),
1030 _ => {
1031 tracing::warn!(
1032 "Instrument {} is not a CryptoPerpetual, skipping margin calculation",
1033 instrument_id
1034 );
1035 continue;
1036 }
1037 };
1038
1039 let position_size = match Decimal::from_str(&position.size) {
1041 Ok(size) => size.abs(),
1042 Err(e) => {
1043 tracing::warn!(
1044 "Failed to parse position size '{}' for {}: {}",
1045 position.size,
1046 market_str,
1047 e
1048 );
1049 continue;
1050 }
1051 };
1052
1053 if position_size.is_zero() {
1055 continue;
1056 }
1057
1058 let oracle_price = oracle_prices
1060 .get(&instrument_id)
1061 .copied()
1062 .or_else(|| Decimal::from_str(&position.entry_price).ok())
1063 .unwrap_or(Decimal::ZERO);
1064
1065 if oracle_price.is_zero() {
1066 tracing::warn!(
1067 "No valid price for position {}, skipping margin calculation",
1068 market_str
1069 );
1070 continue;
1071 }
1072
1073 let initial_margin = margin_init * position_size * oracle_price;
1075
1076 let maintenance_margin = margin_maint * position_size * oracle_price;
1077
1078 let quote_currency = instrument.quote_currency();
1080 *initial_margins
1081 .entry(quote_currency)
1082 .or_insert(Decimal::ZERO) += initial_margin;
1083 *maintenance_margins
1084 .entry(quote_currency)
1085 .or_insert(Decimal::ZERO) += maintenance_margin;
1086 }
1087 }
1088
1089 for (currency, initial_margin) in initial_margins {
1091 let maintenance_margin = maintenance_margins
1092 .get(¤cy)
1093 .copied()
1094 .unwrap_or(Decimal::ZERO);
1095
1096 let initial_money = Money::from_decimal(initial_margin, currency).context(format!(
1097 "Failed to create initial margin Money for {currency}"
1098 ))?;
1099 let maintenance_money = Money::from_decimal(maintenance_margin, currency).context(
1100 format!("Failed to create maintenance margin Money for {currency}"),
1101 )?;
1102
1103 let margin_instrument_id = InstrumentId::new(
1106 Symbol::new("ACCOUNT"),
1107 nautilus_model::identifiers::Venue::new("DYDX"),
1108 );
1109
1110 let margin_balance =
1111 MarginBalance::new(initial_money, maintenance_money, margin_instrument_id);
1112 margins.push(margin_balance);
1113 }
1114
1115 Ok(AccountState::new(
1116 account_id,
1117 AccountType::Margin, balances,
1119 margins,
1120 true, UUID4::new(),
1122 ts_event,
1123 ts_init,
1124 None, ))
1126}
1127
1128#[cfg(test)]
1129mod reconciliation_tests {
1130 use chrono::Utc;
1131 use nautilus_model::{
1132 enums::{OrderSide, OrderStatus, TimeInForce},
1133 identifiers::{AccountId, InstrumentId, Symbol, Venue},
1134 instruments::{CryptoPerpetual, Instrument},
1135 types::Currency,
1136 };
1137 use rstest::rstest;
1138 use rust_decimal_macros::dec;
1139
1140 use super::*;
1141
1142 fn create_test_instrument() -> InstrumentAny {
1143 let instrument_id = InstrumentId::new(Symbol::new("BTC-USD"), Venue::new("DYDX"));
1144
1145 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1146 instrument_id,
1147 instrument_id.symbol,
1148 Currency::BTC(),
1149 Currency::USD(),
1150 Currency::USD(),
1151 false,
1152 2, 8, Price::new(0.01, 2), Quantity::new(0.001, 8), Some(Quantity::new(1.0, 0)), Some(Quantity::new(0.001, 8)), Some(Quantity::new(100000.0, 8)), Some(Quantity::new(0.001, 8)), None, None, Some(Price::new(1000000.0, 2)), Some(Price::new(0.01, 2)), Some(dec!(0.05)), Some(dec!(0.03)), Some(dec!(0.0002)), Some(dec!(0.0005)), UnixNanos::default(), UnixNanos::default(), ))
1171 }
1172
1173 #[rstest]
1174 fn test_parse_order_status() {
1175 assert_eq!(
1176 parse_order_status(&DydxOrderStatus::Open),
1177 OrderStatus::Accepted
1178 );
1179 assert_eq!(
1180 parse_order_status(&DydxOrderStatus::Filled),
1181 OrderStatus::Filled
1182 );
1183 assert_eq!(
1184 parse_order_status(&DydxOrderStatus::Canceled),
1185 OrderStatus::Canceled
1186 );
1187 assert_eq!(
1188 parse_order_status(&DydxOrderStatus::PartiallyFilled),
1189 OrderStatus::PartiallyFilled
1190 );
1191 assert_eq!(
1192 parse_order_status(&DydxOrderStatus::Untriggered),
1193 OrderStatus::Accepted
1194 );
1195 }
1196
1197 #[rstest]
1198 fn test_parse_order_status_report_basic() {
1199 let instrument = create_test_instrument();
1200 let account_id = AccountId::new("DYDX-001");
1201 let ts_init = UnixNanos::default();
1202
1203 let order = Order {
1204 id: "order123".to_string(),
1205 subaccount_id: "subacct1".to_string(),
1206 client_id: "client1".to_string(),
1207 clob_pair_id: 1,
1208 side: OrderSide::Buy,
1209 size: dec!(1.5),
1210 remaining_size: dec!(0.5),
1211 price: dec!(50000.0),
1212 status: DydxOrderStatus::PartiallyFilled,
1213 order_type: "Limit".to_string(), time_in_force: DydxTimeInForce::Gtt,
1215 reduce_only: false,
1216 post_only: false,
1217 order_flags: 0,
1218 good_til_block: None,
1219 good_til_block_time: Some(Utc::now()),
1220 created_at_height: 1000,
1221 client_metadata: 0,
1222 trigger_price: None,
1223 condition_type: None,
1224 conditional_order_trigger_subticks: None,
1225 execution: None,
1226 updated_at: Utc::now(),
1227 updated_at_height: 1001,
1228 };
1229
1230 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1231 if let Err(ref e) = result {
1232 eprintln!("Parse error: {e:?}");
1233 }
1234 assert!(result.is_ok());
1235
1236 let report = result.unwrap();
1237 assert_eq!(report.account_id, account_id);
1238 assert_eq!(report.instrument_id, instrument.id());
1239 assert_eq!(report.order_side, OrderSide::Buy);
1240 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1241 assert_eq!(report.time_in_force, TimeInForce::Gtc);
1242 }
1243
1244 #[rstest]
1245 fn test_parse_order_status_report_conditional() {
1246 let instrument = create_test_instrument();
1247 let account_id = AccountId::new("DYDX-001");
1248 let ts_init = UnixNanos::default();
1249
1250 let order = Order {
1251 id: "order456".to_string(),
1252 subaccount_id: "subacct1".to_string(),
1253 client_id: "".to_string(), clob_pair_id: 1,
1255 side: OrderSide::Sell,
1256 size: dec!(2.0),
1257 remaining_size: dec!(2.0),
1258 price: dec!(51000.0),
1259 status: DydxOrderStatus::Untriggered,
1260 order_type: "StopLimit".to_string(), time_in_force: DydxTimeInForce::Gtt,
1262 reduce_only: true,
1263 post_only: false,
1264 order_flags: 0,
1265 good_til_block: None,
1266 good_til_block_time: Some(Utc::now()),
1267 created_at_height: 1000,
1268 client_metadata: 0,
1269 trigger_price: Some(dec!(49000.0)),
1270 condition_type: Some(crate::common::enums::DydxConditionType::StopLoss),
1271 conditional_order_trigger_subticks: Some(490000),
1272 execution: None,
1273 updated_at: Utc::now(),
1274 updated_at_height: 1001,
1275 };
1276
1277 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1278 assert!(result.is_ok());
1279
1280 let report = result.unwrap();
1281 assert_eq!(report.client_order_id, None);
1282 assert!(report.trigger_price.is_some());
1283 assert_eq!(report.trigger_price.unwrap().as_f64(), 49000.0);
1284 }
1285
1286 #[rstest]
1287 fn test_parse_fill_report() {
1288 let instrument = create_test_instrument();
1289 let account_id = AccountId::new("DYDX-001");
1290 let ts_init = UnixNanos::default();
1291
1292 let fill = Fill {
1293 id: "fill789".to_string(),
1294 side: OrderSide::Buy,
1295 liquidity: DydxLiquidity::Taker,
1296 fill_type: crate::common::enums::DydxFillType::Limit,
1297 market: "BTC-USD".to_string(),
1298 market_type: crate::common::enums::DydxTickerType::Perpetual,
1299 price: dec!(50100.0),
1300 size: dec!(1.0),
1301 fee: dec!(-5.01),
1302 created_at: Utc::now(),
1303 created_at_height: 1000,
1304 order_id: "order123".to_string(),
1305 client_metadata: 0,
1306 };
1307
1308 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1309 assert!(result.is_ok());
1310
1311 let report = result.unwrap();
1312 assert_eq!(report.account_id, account_id);
1313 assert_eq!(report.order_side, OrderSide::Buy);
1314 assert_eq!(report.liquidity_side, LiquiditySide::Taker);
1315 assert_eq!(report.last_px.as_f64(), 50100.0);
1316 assert_eq!(report.commission.as_f64(), 5.01);
1317 }
1318
1319 #[rstest]
1320 fn test_parse_position_status_report_long() {
1321 let instrument = create_test_instrument();
1322 let account_id = AccountId::new("DYDX-001");
1323 let ts_init = UnixNanos::default();
1324
1325 let position = PerpetualPosition {
1326 market: "BTC-USD".to_string(),
1327 status: crate::common::enums::DydxPositionStatus::Open,
1328 side: OrderSide::Buy,
1329 size: dec!(2.5),
1330 max_size: dec!(3.0),
1331 entry_price: dec!(49500.0),
1332 exit_price: None,
1333 realized_pnl: dec!(100.0),
1334 created_at_height: 1000,
1335 created_at: Utc::now(),
1336 sum_open: dec!(2.5),
1337 sum_close: dec!(0.0),
1338 net_funding: dec!(-2.5),
1339 unrealized_pnl: dec!(250.0),
1340 closed_at: None,
1341 };
1342
1343 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1344 assert!(result.is_ok());
1345
1346 let report = result.unwrap();
1347 assert_eq!(report.account_id, account_id);
1348 assert_eq!(report.position_side, PositionSide::Long.as_specified());
1349 assert_eq!(report.quantity.as_f64(), 2.5);
1350 assert_eq!(report.avg_px_open.unwrap().to_f64().unwrap(), 49500.0);
1351 }
1352
1353 #[rstest]
1354 fn test_parse_position_status_report_short() {
1355 let instrument = create_test_instrument();
1356 let account_id = AccountId::new("DYDX-001");
1357 let ts_init = UnixNanos::default();
1358
1359 let position = PerpetualPosition {
1360 market: "BTC-USD".to_string(),
1361 status: crate::common::enums::DydxPositionStatus::Open,
1362 side: OrderSide::Sell,
1363 size: dec!(-1.5),
1364 max_size: dec!(1.5),
1365 entry_price: dec!(51000.0),
1366 exit_price: None,
1367 realized_pnl: dec!(0.0),
1368 created_at_height: 1000,
1369 created_at: Utc::now(),
1370 sum_open: dec!(1.5),
1371 sum_close: dec!(0.0),
1372 net_funding: dec!(1.2),
1373 unrealized_pnl: dec!(-150.0),
1374 closed_at: None,
1375 };
1376
1377 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1378 assert!(result.is_ok());
1379
1380 let report = result.unwrap();
1381 assert_eq!(report.position_side, PositionSide::Short.as_specified());
1382 assert_eq!(report.quantity.as_f64(), 1.5);
1383 }
1384
1385 #[rstest]
1386 fn test_parse_position_status_report_flat() {
1387 let instrument = create_test_instrument();
1388 let account_id = AccountId::new("DYDX-001");
1389 let ts_init = UnixNanos::default();
1390
1391 let position = PerpetualPosition {
1392 market: "BTC-USD".to_string(),
1393 status: crate::common::enums::DydxPositionStatus::Closed,
1394 side: OrderSide::Buy,
1395 size: dec!(0.0),
1396 max_size: dec!(2.0),
1397 entry_price: dec!(50000.0),
1398 exit_price: Some(dec!(51000.0)),
1399 realized_pnl: dec!(500.0),
1400 created_at_height: 1000,
1401 created_at: Utc::now(),
1402 sum_open: dec!(2.0),
1403 sum_close: dec!(2.0),
1404 net_funding: dec!(-5.0),
1405 unrealized_pnl: dec!(0.0),
1406 closed_at: Some(Utc::now()),
1407 };
1408
1409 let result = parse_position_status_report(&position, &instrument, account_id, ts_init);
1410 assert!(result.is_ok());
1411
1412 let report = result.unwrap();
1413 assert_eq!(report.position_side, PositionSide::Flat.as_specified());
1414 assert_eq!(report.quantity.as_f64(), 0.0);
1415 }
1416
1417 #[rstest]
1419 fn test_parse_order_external_detection() {
1420 let instrument = create_test_instrument();
1421 let account_id = AccountId::new("DYDX-001");
1422 let ts_init = UnixNanos::default();
1423
1424 let order = Order {
1426 id: "external-order-123".to_string(),
1427 subaccount_id: "dydx1test/0".to_string(),
1428 client_id: "99999".to_string(),
1429 clob_pair_id: 1,
1430 side: OrderSide::Buy,
1431 size: dec!(0.5),
1432 remaining_size: dec!(0.5),
1433 price: dec!(50000.0),
1434 status: DydxOrderStatus::Open,
1435 order_type: "Limit".to_string(),
1436 time_in_force: DydxTimeInForce::Gtt,
1437 reduce_only: false,
1438 post_only: false,
1439 order_flags: 0,
1440 good_til_block: Some(1000),
1441 good_til_block_time: None,
1442 created_at_height: 900,
1443 client_metadata: 0,
1444 trigger_price: None,
1445 condition_type: None,
1446 conditional_order_trigger_subticks: None,
1447 execution: None,
1448 updated_at: Utc::now(),
1449 updated_at_height: 900,
1450 };
1451
1452 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1453 assert!(result.is_ok());
1454
1455 let report = result.unwrap();
1456 assert_eq!(report.account_id, account_id);
1457 assert_eq!(report.order_status, OrderStatus::Accepted);
1458 assert_eq!(report.filled_qty.as_f64(), 0.0);
1460 }
1461
1462 #[rstest]
1464 fn test_parse_order_partial_fill_reconciliation() {
1465 let instrument = create_test_instrument();
1466 let account_id = AccountId::new("DYDX-001");
1467 let ts_init = UnixNanos::default();
1468
1469 let order = Order {
1470 id: "partial-order-123".to_string(),
1471 subaccount_id: "dydx1test/0".to_string(),
1472 client_id: "12345".to_string(),
1473 clob_pair_id: 1,
1474 side: OrderSide::Buy,
1475 size: dec!(2.0),
1476 remaining_size: dec!(1.25), price: dec!(50000.0),
1478 status: DydxOrderStatus::PartiallyFilled,
1479 order_type: "Limit".to_string(),
1480 time_in_force: DydxTimeInForce::Gtt,
1481 reduce_only: false,
1482 post_only: false,
1483 order_flags: 0,
1484 good_til_block: Some(2000),
1485 good_til_block_time: None,
1486 created_at_height: 1500,
1487 client_metadata: 0,
1488 trigger_price: None,
1489 condition_type: None,
1490 conditional_order_trigger_subticks: None,
1491 execution: None,
1492 updated_at: Utc::now(),
1493 updated_at_height: 1600,
1494 };
1495
1496 let result = parse_order_status_report(&order, &instrument, account_id, ts_init);
1497 assert!(result.is_ok());
1498
1499 let report = result.unwrap();
1500 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1501 assert_eq!(report.filled_qty.as_f64(), 0.75);
1502 assert_eq!(report.quantity.as_f64(), 2.0);
1503 }
1504
1505 #[rstest]
1507 fn test_parse_multiple_positions() {
1508 let instrument = create_test_instrument();
1509 let account_id = AccountId::new("DYDX-001");
1510 let ts_init = UnixNanos::default();
1511
1512 let long_position = PerpetualPosition {
1514 market: "BTC-USD".to_string(),
1515 status: crate::common::enums::DydxPositionStatus::Open,
1516 side: OrderSide::Buy,
1517 size: dec!(1.5),
1518 max_size: dec!(1.5),
1519 entry_price: dec!(49000.0),
1520 exit_price: None,
1521 realized_pnl: dec!(0.0),
1522 created_at_height: 1000,
1523 created_at: Utc::now(),
1524 sum_open: dec!(1.5),
1525 sum_close: dec!(0.0),
1526 net_funding: dec!(-1.0),
1527 unrealized_pnl: dec!(150.0),
1528 closed_at: None,
1529 };
1530
1531 let result1 =
1532 parse_position_status_report(&long_position, &instrument, account_id, ts_init);
1533 assert!(result1.is_ok());
1534 let report1 = result1.unwrap();
1535 assert_eq!(report1.position_side, PositionSide::Long.as_specified());
1536
1537 let short_position = PerpetualPosition {
1539 market: "BTC-USD".to_string(),
1540 status: crate::common::enums::DydxPositionStatus::Open,
1541 side: OrderSide::Sell,
1542 size: dec!(-2.0),
1543 max_size: dec!(2.0),
1544 entry_price: dec!(51000.0),
1545 exit_price: None,
1546 realized_pnl: dec!(0.0),
1547 created_at_height: 1100,
1548 created_at: Utc::now(),
1549 sum_open: dec!(2.0),
1550 sum_close: dec!(0.0),
1551 net_funding: dec!(0.5),
1552 unrealized_pnl: dec!(-200.0),
1553 closed_at: None,
1554 };
1555
1556 let result2 =
1557 parse_position_status_report(&short_position, &instrument, account_id, ts_init);
1558 assert!(result2.is_ok());
1559 let report2 = result2.unwrap();
1560 assert_eq!(report2.position_side, PositionSide::Short.as_specified());
1561 }
1562
1563 #[rstest]
1565 fn test_parse_fill_zero_fee() {
1566 let instrument = create_test_instrument();
1567 let account_id = AccountId::new("DYDX-001");
1568 let ts_init = UnixNanos::default();
1569
1570 let fill = Fill {
1571 id: "fill-zero-fee".to_string(),
1572 side: OrderSide::Sell,
1573 liquidity: DydxLiquidity::Maker,
1574 fill_type: crate::common::enums::DydxFillType::Limit,
1575 market: "BTC-USD".to_string(),
1576 market_type: crate::common::enums::DydxTickerType::Perpetual,
1577 price: dec!(50000.0),
1578 size: dec!(0.1),
1579 fee: dec!(0.0), created_at: Utc::now(),
1581 created_at_height: 1000,
1582 order_id: "order-zero-fee".to_string(),
1583 client_metadata: 0,
1584 };
1585
1586 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1587 assert!(result.is_ok());
1588
1589 let report = result.unwrap();
1590 assert_eq!(report.commission.as_f64(), 0.0);
1591 }
1592
1593 #[rstest]
1595 fn test_parse_fill_maker_rebate() {
1596 let instrument = create_test_instrument();
1597 let account_id = AccountId::new("DYDX-001");
1598 let ts_init = UnixNanos::default();
1599
1600 let fill = Fill {
1601 id: "fill-maker-rebate".to_string(),
1602 side: OrderSide::Buy,
1603 liquidity: DydxLiquidity::Maker,
1604 fill_type: crate::common::enums::DydxFillType::Limit,
1605 market: "BTC-USD".to_string(),
1606 market_type: crate::common::enums::DydxTickerType::Perpetual,
1607 price: dec!(50000.0),
1608 size: dec!(1.0),
1609 fee: dec!(-2.5), created_at: Utc::now(),
1611 created_at_height: 1000,
1612 order_id: "order-maker-rebate".to_string(),
1613 client_metadata: 0,
1614 };
1615
1616 let result = parse_fill_report(&fill, &instrument, account_id, ts_init);
1617 assert!(result.is_ok());
1618
1619 let report = result.unwrap();
1620 assert_eq!(report.commission.as_f64(), 2.5);
1622 assert_eq!(report.liquidity_side, LiquiditySide::Maker);
1623 }
1624}