1use std::{collections::HashMap, sync::Arc};
30
31use chrono::{DateTime, Duration, Utc};
32use cosmrs::Any;
33use nautilus_common::cache::InstrumentLookupError;
34use nautilus_model::{
35 enums::{OrderSide, TimeInForce},
36 identifiers::InstrumentId,
37 types::{Price, Quantity},
38};
39
40use super::{
41 block_time::BlockTimeMonitor,
42 types::{
43 ConditionalOrderType, GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS, LimitOrderParams,
44 ORDER_FLAG_SHORT_TERM, OrderLifetime, calculate_conditional_order_expiration,
45 },
46};
47use crate::{
48 common::parse::{
49 nanos_to_secs_i64, order_side_to_proto, time_in_force_to_proto_with_post_only,
50 },
51 error::DydxError,
52 grpc::{OrderBuilder, OrderGoodUntil, OrderMarketParams, SHORT_TERM_ORDER_MAXIMUM_LIFETIME},
53 http::client::DydxHttpClient,
54 proto::{
55 ToAny,
56 dydxprotocol::{
57 clob::{
58 MsgBatchCancel, MsgCancelOrder, MsgPlaceOrder, OrderBatch, OrderId,
59 msg_cancel_order::GoodTilOneof,
60 },
61 subaccounts::SubaccountId,
62 },
63 },
64};
65
66#[derive(Debug)]
81pub struct OrderMessageBuilder {
82 http_client: DydxHttpClient,
83 wallet_address: String,
84 subaccount_number: u32,
85 block_time_monitor: Arc<BlockTimeMonitor>,
87}
88
89impl OrderMessageBuilder {
90 #[must_use]
92 pub fn new(
93 http_client: DydxHttpClient,
94 wallet_address: String,
95 subaccount_number: u32,
96 block_time_monitor: Arc<BlockTimeMonitor>,
97 ) -> Self {
98 Self {
99 http_client,
100 wallet_address,
101 subaccount_number,
102 block_time_monitor,
103 }
104 }
105
106 #[must_use]
113 pub fn max_short_term_secs(&self) -> f64 {
114 SHORT_TERM_ORDER_MAXIMUM_LIFETIME as f64
115 * self.block_time_monitor.seconds_per_block_or_default()
116 }
117
118 #[must_use]
120 fn expire_time_to_secs(
121 &self,
122 order_expire_time_ns: Option<nautilus_core::UnixNanos>,
123 ) -> Option<i64> {
124 order_expire_time_ns.map(nanos_to_secs_i64)
125 }
126
127 #[must_use]
138 pub fn get_order_lifetime(&self, params: &LimitOrderParams) -> OrderLifetime {
139 let expire_time = self.expire_time_to_secs(params.expire_time_ns);
140 OrderLifetime::from_time_in_force(
141 params.time_in_force,
142 expire_time,
143 false,
144 self.max_short_term_secs(),
145 )
146 }
147
148 #[must_use]
155 pub fn is_short_term_order(&self, params: &LimitOrderParams) -> bool {
156 self.get_order_lifetime(params).is_short_term()
157 }
158
159 #[must_use]
166 pub fn is_short_term_cancel(
167 &self,
168 time_in_force: TimeInForce,
169 expire_time_ns: Option<nautilus_core::UnixNanos>,
170 ) -> bool {
171 let expire_time = self.expire_time_to_secs(expire_time_ns);
172 OrderLifetime::from_time_in_force(
173 time_in_force,
174 expire_time,
175 false,
176 self.max_short_term_secs(),
177 )
178 .is_short_term()
179 }
180
181 pub fn build_market_order(
189 &self,
190 instrument_id: InstrumentId,
191 client_order_id: u32,
192 client_metadata: u32,
193 side: OrderSide,
194 quantity: Quantity,
195 block_height: u32,
196 ) -> Result<Any, DydxError> {
197 let market_params = self.get_market_params(instrument_id)?;
198
199 let builder = OrderBuilder::new(
200 market_params,
201 self.wallet_address.clone(),
202 self.subaccount_number,
203 client_order_id,
204 client_metadata,
205 )
206 .market(order_side_to_proto(side), quantity.as_decimal())
207 .short_term()
208 .until(OrderGoodUntil::Block(
209 block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
210 ));
211
212 let order = builder
213 .build()
214 .map_err(|e| DydxError::Order(format!("Failed to build market order: {e}")))?;
215
216 Ok(MsgPlaceOrder { order: Some(order) }.to_any())
217 }
218
219 #[expect(clippy::too_many_arguments)]
227 pub fn build_limit_order(
228 &self,
229 instrument_id: InstrumentId,
230 client_order_id: u32,
231 client_metadata: u32,
232 side: OrderSide,
233 price: Price,
234 quantity: Quantity,
235 time_in_force: TimeInForce,
236 post_only: bool,
237 reduce_only: bool,
238 block_height: u32,
239 expire_time: Option<i64>,
240 ) -> Result<Any, DydxError> {
241 let market_params = self.get_market_params(instrument_id)?;
242 let lifetime = OrderLifetime::from_time_in_force(
243 time_in_force,
244 expire_time,
245 false,
246 self.max_short_term_secs(),
247 );
248
249 let mut builder = OrderBuilder::new(
250 market_params,
251 self.wallet_address.clone(),
252 self.subaccount_number,
253 client_order_id,
254 client_metadata,
255 )
256 .limit(
257 order_side_to_proto(side),
258 price.as_decimal(),
259 quantity.as_decimal(),
260 )
261 .time_in_force(time_in_force_to_proto_with_post_only(
262 time_in_force,
263 post_only,
264 ));
265
266 if reduce_only {
267 builder = builder.reduce_only(true);
268 }
269
270 builder = self.apply_order_lifetime(builder, lifetime, block_height, expire_time)?;
272
273 let order = builder
274 .build()
275 .map_err(|e| DydxError::Order(format!("Failed to build limit order: {e}")))?;
276
277 Ok(MsgPlaceOrder { order: Some(order) }.to_any())
278 }
279
280 pub fn build_limit_order_from_params(
286 &self,
287 params: &LimitOrderParams,
288 block_height: u32,
289 ) -> Result<Any, DydxError> {
290 let expire_time = self.expire_time_to_secs(params.expire_time_ns);
291
292 self.build_limit_order(
293 params.instrument_id,
294 params.client_order_id,
295 params.client_metadata,
296 params.side,
297 params.price,
298 params.quantity,
299 params.time_in_force,
300 params.post_only,
301 params.reduce_only,
302 block_height,
303 expire_time,
304 )
305 }
306
307 pub fn build_limit_orders_batch(
313 &self,
314 orders: &[LimitOrderParams],
315 block_height: u32,
316 ) -> Result<Vec<Any>, DydxError> {
317 orders
318 .iter()
319 .map(|params| self.build_limit_order_from_params(params, block_height))
320 .collect()
321 }
322
323 pub fn build_cancel_order(
332 &self,
333 instrument_id: InstrumentId,
334 client_order_id: u32,
335 time_in_force: TimeInForce,
336 expire_time_ns: Option<nautilus_core::UnixNanos>,
337 block_height: u32,
338 ) -> Result<Any, DydxError> {
339 let expire_time = self.expire_time_to_secs(expire_time_ns);
340 let market_params = self.get_market_params(instrument_id)?;
341 let lifetime = OrderLifetime::from_time_in_force(
342 time_in_force,
343 expire_time,
344 false,
345 self.max_short_term_secs(),
346 );
347
348 let (order_flags, good_til_oneof) = match lifetime {
349 OrderLifetime::ShortTerm => (
350 0,
351 GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME),
352 ),
353 OrderLifetime::LongTerm | OrderLifetime::Conditional => {
354 let cancel_good_til = (Utc::now()
355 + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
356 .timestamp() as u32;
357 (
358 lifetime.order_flags(),
359 GoodTilOneof::GoodTilBlockTime(cancel_good_til),
360 )
361 }
362 };
363
364 let msg = MsgCancelOrder {
365 order_id: Some(OrderId {
366 subaccount_id: Some(SubaccountId {
367 owner: self.wallet_address.clone(),
368 number: self.subaccount_number,
369 }),
370 client_id: client_order_id,
371 order_flags,
372 clob_pair_id: market_params.clob_pair_id,
373 }),
374 good_til_oneof: Some(good_til_oneof),
375 };
376
377 Ok(msg.to_any())
378 }
379
380 pub fn build_cancel_order_with_flags(
389 &self,
390 instrument_id: InstrumentId,
391 client_order_id: u32,
392 order_flags: u32,
393 block_height: u32,
394 ) -> Result<Any, DydxError> {
395 let market_params = self.get_market_params(instrument_id)?;
396
397 let good_til_oneof = if order_flags == ORDER_FLAG_SHORT_TERM {
398 GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
399 } else {
400 let cancel_good_til = (Utc::now()
401 + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
402 .timestamp() as u32;
403 GoodTilOneof::GoodTilBlockTime(cancel_good_til)
404 };
405
406 let msg = MsgCancelOrder {
407 order_id: Some(OrderId {
408 subaccount_id: Some(SubaccountId {
409 owner: self.wallet_address.clone(),
410 number: self.subaccount_number,
411 }),
412 client_id: client_order_id,
413 order_flags,
414 clob_pair_id: market_params.clob_pair_id,
415 }),
416 good_til_oneof: Some(good_til_oneof),
417 };
418
419 Ok(msg.to_any())
420 }
421
422 pub fn build_cancel_orders_batch(
430 &self,
431 orders: &[(
432 InstrumentId,
433 u32,
434 TimeInForce,
435 Option<nautilus_core::UnixNanos>,
436 )],
437 block_height: u32,
438 ) -> Result<Vec<Any>, DydxError> {
439 orders
440 .iter()
441 .map(|(instrument_id, client_order_id, tif, expire_time_ns)| {
442 self.build_cancel_order(
443 *instrument_id,
444 *client_order_id,
445 *tif,
446 *expire_time_ns,
447 block_height,
448 )
449 })
450 .collect()
451 }
452
453 pub fn build_cancel_orders_batch_with_flags(
462 &self,
463 orders: &[(InstrumentId, u32, u32)],
464 block_height: u32,
465 ) -> Result<Vec<Any>, DydxError> {
466 orders
467 .iter()
468 .map(|(instrument_id, client_order_id, order_flags)| {
469 self.build_cancel_order_with_flags(
470 *instrument_id,
471 *client_order_id,
472 *order_flags,
473 block_height,
474 )
475 })
476 .collect()
477 }
478
479 pub fn build_batch_cancel_short_term(
488 &self,
489 orders: &[(InstrumentId, u32)],
490 block_height: u32,
491 ) -> Result<Any, DydxError> {
492 let mut clob_groups: HashMap<u32, Vec<u32>> = HashMap::new();
494
495 for (instrument_id, client_order_id) in orders {
496 let market_params = self.get_market_params(*instrument_id)?;
497 clob_groups
498 .entry(market_params.clob_pair_id)
499 .or_default()
500 .push(*client_order_id);
501 }
502
503 let short_term_cancels: Vec<OrderBatch> = clob_groups
504 .into_iter()
505 .map(|(clob_pair_id, client_ids)| OrderBatch {
506 clob_pair_id,
507 client_ids,
508 })
509 .collect();
510
511 let msg = MsgBatchCancel {
512 subaccount_id: Some(SubaccountId {
513 owner: self.wallet_address.clone(),
514 number: self.subaccount_number,
515 }),
516 short_term_cancels,
517 good_til_block: block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
518 };
519
520 Ok(msg.to_any())
521 }
522
523 #[expect(clippy::too_many_arguments)]
546 pub fn build_cancel_and_replace(
547 &self,
548 instrument_id: InstrumentId,
549 old_client_order_id: u32,
550 _new_client_order_id: u32,
551 old_time_in_force: TimeInForce,
552 old_expire_time_ns: Option<nautilus_core::UnixNanos>,
553 new_params: &LimitOrderParams,
554 block_height: u32,
555 ) -> Result<Vec<Any>, DydxError> {
556 let cancel_msg = self.build_cancel_order(
558 instrument_id,
559 old_client_order_id,
560 old_time_in_force,
561 old_expire_time_ns,
562 block_height,
563 )?;
564
565 let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
567
568 Ok(vec![cancel_msg, place_msg])
570 }
571
572 pub fn build_cancel_and_replace_with_flags(
580 &self,
581 instrument_id: InstrumentId,
582 old_client_order_id: u32,
583 old_order_flags: u32,
584 new_params: &LimitOrderParams,
585 block_height: u32,
586 ) -> Result<Vec<Any>, DydxError> {
587 let cancel_msg = self.build_cancel_order_with_flags(
589 instrument_id,
590 old_client_order_id,
591 old_order_flags,
592 block_height,
593 )?;
594
595 let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
597
598 Ok(vec![cancel_msg, place_msg])
600 }
601
602 #[expect(clippy::too_many_arguments)]
610 pub fn build_conditional_order(
611 &self,
612 instrument_id: InstrumentId,
613 client_order_id: u32,
614 client_metadata: u32,
615 order_type: ConditionalOrderType,
616 side: OrderSide,
617 trigger_price: Price,
618 limit_price: Option<Price>,
619 quantity: Quantity,
620 time_in_force: Option<TimeInForce>,
621 post_only: bool,
622 reduce_only: bool,
623 expire_time: Option<i64>,
624 ) -> Result<Any, DydxError> {
625 let market_params = self.get_market_params(instrument_id)?;
626
627 let mut builder = OrderBuilder::new(
628 market_params,
629 self.wallet_address.clone(),
630 self.subaccount_number,
631 client_order_id,
632 client_metadata,
633 );
634
635 let proto_side = order_side_to_proto(side);
636 let trigger_decimal = trigger_price.as_decimal();
637 let size_decimal = quantity.as_decimal();
638
639 builder = match order_type {
641 ConditionalOrderType::StopMarket => {
642 builder.stop_market(proto_side, trigger_decimal, size_decimal)
643 }
644 ConditionalOrderType::StopLimit => {
645 let limit = limit_price.ok_or_else(|| {
646 DydxError::Order("StopLimit requires limit_price".to_string())
647 })?;
648 builder.stop_limit(
649 proto_side,
650 limit.as_decimal(),
651 trigger_decimal,
652 size_decimal,
653 )
654 }
655 ConditionalOrderType::TakeProfitMarket => {
656 builder.take_profit_market(proto_side, trigger_decimal, size_decimal)
657 }
658 ConditionalOrderType::TakeProfitLimit => {
659 let limit = limit_price.ok_or_else(|| {
660 DydxError::Order("TakeProfitLimit requires limit_price".to_string())
661 })?;
662 builder.take_profit_limit(
663 proto_side,
664 limit.as_decimal(),
665 trigger_decimal,
666 size_decimal,
667 )
668 }
669 };
670
671 let effective_tif = time_in_force.unwrap_or(TimeInForce::Gtc);
673
674 if matches!(
675 order_type,
676 ConditionalOrderType::StopLimit | ConditionalOrderType::TakeProfitLimit
677 ) {
678 let proto_tif = time_in_force_to_proto_with_post_only(effective_tif, post_only);
679 builder = builder.time_in_force(proto_tif);
680 }
681
682 if reduce_only {
683 builder = builder.reduce_only(true);
684 }
685
686 let expire = calculate_conditional_order_expiration(effective_tif, expire_time)?;
688 builder = builder.until(OrderGoodUntil::Time(expire));
689
690 let order = builder
691 .build()
692 .map_err(|e| DydxError::Order(format!("Failed to build {order_type:?} order: {e}")))?;
693
694 Ok(MsgPlaceOrder { order: Some(order) }.to_any())
695 }
696
697 #[expect(clippy::too_many_arguments)]
703 pub fn build_stop_market_order(
704 &self,
705 instrument_id: InstrumentId,
706 client_order_id: u32,
707 client_metadata: u32,
708 side: OrderSide,
709 trigger_price: Price,
710 quantity: Quantity,
711 reduce_only: bool,
712 expire_time: Option<i64>,
713 ) -> Result<Any, DydxError> {
714 self.build_conditional_order(
715 instrument_id,
716 client_order_id,
717 client_metadata,
718 ConditionalOrderType::StopMarket,
719 side,
720 trigger_price,
721 None,
722 quantity,
723 None,
724 false,
725 reduce_only,
726 expire_time,
727 )
728 }
729
730 #[expect(clippy::too_many_arguments)]
736 pub fn build_stop_limit_order(
737 &self,
738 instrument_id: InstrumentId,
739 client_order_id: u32,
740 client_metadata: u32,
741 side: OrderSide,
742 trigger_price: Price,
743 limit_price: Price,
744 quantity: Quantity,
745 time_in_force: TimeInForce,
746 post_only: bool,
747 reduce_only: bool,
748 expire_time: Option<i64>,
749 ) -> Result<Any, DydxError> {
750 self.build_conditional_order(
751 instrument_id,
752 client_order_id,
753 client_metadata,
754 ConditionalOrderType::StopLimit,
755 side,
756 trigger_price,
757 Some(limit_price),
758 quantity,
759 Some(time_in_force),
760 post_only,
761 reduce_only,
762 expire_time,
763 )
764 }
765
766 #[expect(clippy::too_many_arguments)]
772 pub fn build_take_profit_market_order(
773 &self,
774 instrument_id: InstrumentId,
775 client_order_id: u32,
776 client_metadata: u32,
777 side: OrderSide,
778 trigger_price: Price,
779 quantity: Quantity,
780 reduce_only: bool,
781 expire_time: Option<i64>,
782 ) -> Result<Any, DydxError> {
783 self.build_conditional_order(
784 instrument_id,
785 client_order_id,
786 client_metadata,
787 ConditionalOrderType::TakeProfitMarket,
788 side,
789 trigger_price,
790 None,
791 quantity,
792 None,
793 false,
794 reduce_only,
795 expire_time,
796 )
797 }
798
799 #[expect(clippy::too_many_arguments)]
805 pub fn build_take_profit_limit_order(
806 &self,
807 instrument_id: InstrumentId,
808 client_order_id: u32,
809 client_metadata: u32,
810 side: OrderSide,
811 trigger_price: Price,
812 limit_price: Price,
813 quantity: Quantity,
814 time_in_force: TimeInForce,
815 post_only: bool,
816 reduce_only: bool,
817 expire_time: Option<i64>,
818 ) -> Result<Any, DydxError> {
819 self.build_conditional_order(
820 instrument_id,
821 client_order_id,
822 client_metadata,
823 ConditionalOrderType::TakeProfitLimit,
824 side,
825 trigger_price,
826 Some(limit_price),
827 quantity,
828 Some(time_in_force),
829 post_only,
830 reduce_only,
831 expire_time,
832 )
833 }
834
835 fn get_market_params(
837 &self,
838 instrument_id: InstrumentId,
839 ) -> Result<OrderMarketParams, DydxError> {
840 let market = self
841 .http_client
842 .get_market_params(&instrument_id)
843 .ok_or_else(|| {
844 DydxError::Order(InstrumentLookupError::not_found(instrument_id).to_string())
845 })?;
846
847 Ok(OrderMarketParams {
848 atomic_resolution: market.atomic_resolution,
849 clob_pair_id: market.clob_pair_id,
850 oracle_price: market.oracle_price,
851 quantum_conversion_exponent: market.quantum_conversion_exponent,
852 step_base_quantums: market.step_base_quantums,
853 subticks_per_tick: market.subticks_per_tick,
854 })
855 }
856
857 fn apply_order_lifetime(
859 &self,
860 builder: OrderBuilder,
861 lifetime: OrderLifetime,
862 block_height: u32,
863 expire_time: Option<i64>,
864 ) -> Result<OrderBuilder, DydxError> {
865 match lifetime {
866 OrderLifetime::ShortTerm => {
867 let blocks_offset = self.calculate_block_offset(expire_time);
868 Ok(builder
869 .short_term()
870 .until(OrderGoodUntil::Block(block_height + blocks_offset)))
871 }
872 OrderLifetime::LongTerm => {
873 let expire_dt = self.calculate_expire_datetime(expire_time)?;
874 Ok(builder.long_term().until(OrderGoodUntil::Time(expire_dt)))
875 }
876 OrderLifetime::Conditional => {
877 Err(DydxError::Order(
879 "Use build_conditional_order for conditional orders".to_string(),
880 ))
881 }
882 }
883 }
884
885 fn calculate_block_offset(&self, expire_time: Option<i64>) -> u32 {
890 if let Some(expire_ts) = expire_time {
891 let now = Utc::now().timestamp();
892 let seconds = expire_ts - now;
893 self.seconds_to_blocks(seconds)
894 } else {
895 SHORT_TERM_ORDER_MAXIMUM_LIFETIME
896 }
897 }
898
899 fn seconds_to_blocks(&self, seconds: i64) -> u32 {
904 if seconds <= 0 {
905 return 1; }
907
908 let secs_per_block = self.block_time_monitor.seconds_per_block_or_default();
909 let blocks = (seconds as f64 / secs_per_block).ceil() as u32;
910
911 blocks.clamp(1, SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
912 }
913
914 fn calculate_expire_datetime(
916 &self,
917 expire_time: Option<i64>,
918 ) -> Result<DateTime<Utc>, DydxError> {
919 if let Some(expire_ts) = expire_time {
920 DateTime::from_timestamp(expire_ts, 0)
921 .ok_or_else(|| DydxError::Parse(format!("Invalid expire timestamp: {expire_ts}")))
922 } else {
923 Ok(Utc::now() + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
924 }
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use rstest::rstest;
931
932 use super::*;
933
934 const TEST_MAX_SHORT_TERM_SECS: f64 = 10.0;
936
937 #[rstest]
938 fn test_get_market_params_missing_cache_returns_canonical_error() {
939 let builder = OrderMessageBuilder::new(
940 DydxHttpClient::default(),
941 "dydx1testwalletaddress".to_string(),
942 0,
943 Arc::new(BlockTimeMonitor::new()),
944 );
945 let instrument_id = InstrumentId::from("BTC-USD.DYDX");
946
947 let result = builder.get_market_params(instrument_id);
948
949 match result {
950 Err(DydxError::Order(reason)) => {
951 assert_eq!(
952 reason,
953 InstrumentLookupError::not_found(instrument_id).to_string()
954 );
955 }
956 other => panic!("Expected DydxError::Order, was {other:?}"),
957 }
958 }
959
960 #[rstest]
961 fn test_order_lifetime_routing() {
962 let lifetime = OrderLifetime::from_time_in_force(
964 TimeInForce::Ioc,
965 None,
966 false,
967 TEST_MAX_SHORT_TERM_SECS,
968 );
969 assert!(lifetime.is_short_term());
970
971 let lifetime = OrderLifetime::from_time_in_force(
973 TimeInForce::Gtc,
974 None,
975 false,
976 TEST_MAX_SHORT_TERM_SECS,
977 );
978 assert!(!lifetime.is_short_term());
979
980 let lifetime = OrderLifetime::from_time_in_force(
982 TimeInForce::Gtc,
983 None,
984 true,
985 TEST_MAX_SHORT_TERM_SECS,
986 );
987 assert!(lifetime.is_conditional());
988 }
989
990 #[rstest]
991 fn test_order_lifetime_with_short_expiry() {
992 let expire_time = Some(Utc::now().timestamp() + 5);
994 let lifetime = OrderLifetime::from_time_in_force(
995 TimeInForce::Gtd,
996 expire_time,
997 false,
998 TEST_MAX_SHORT_TERM_SECS,
999 );
1000 assert!(lifetime.is_short_term());
1001 }
1002
1003 #[rstest]
1004 fn test_order_lifetime_with_long_expiry() {
1005 let expire_time = Some(Utc::now().timestamp() + 60);
1007 let lifetime = OrderLifetime::from_time_in_force(
1008 TimeInForce::Gtd,
1009 expire_time,
1010 false,
1011 TEST_MAX_SHORT_TERM_SECS,
1012 );
1013 assert!(!lifetime.is_short_term());
1014 }
1015}