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