1use std::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::{MsgCancelOrder, MsgPlaceOrder, OrderId, msg_cancel_order::GoodTilOneof},
57 subaccounts::SubaccountId,
58 },
59 },
60};
61
62#[derive(Debug)]
77pub struct OrderMessageBuilder {
78 http_client: DydxHttpClient,
79 wallet_address: String,
80 subaccount_number: u32,
81 block_time_monitor: Arc<BlockTimeMonitor>,
83}
84
85impl OrderMessageBuilder {
86 #[must_use]
88 pub fn new(
89 http_client: DydxHttpClient,
90 wallet_address: String,
91 subaccount_number: u32,
92 block_time_monitor: Arc<BlockTimeMonitor>,
93 ) -> Self {
94 Self {
95 http_client,
96 wallet_address,
97 subaccount_number,
98 block_time_monitor,
99 }
100 }
101
102 #[must_use]
109 pub fn max_short_term_secs(&self) -> f64 {
110 SHORT_TERM_ORDER_MAXIMUM_LIFETIME as f64
111 * self.block_time_monitor.seconds_per_block_or_default()
112 }
113
114 #[must_use]
116 fn expire_time_to_secs(
117 &self,
118 order_expire_time_ns: Option<nautilus_core::UnixNanos>,
119 ) -> Option<i64> {
120 order_expire_time_ns.map(nanos_to_secs_i64)
121 }
122
123 #[must_use]
134 pub fn get_order_lifetime(&self, params: &LimitOrderParams) -> OrderLifetime {
135 let expire_time = self.expire_time_to_secs(params.expire_time_ns);
136 OrderLifetime::from_time_in_force(
137 params.time_in_force,
138 expire_time,
139 false,
140 self.max_short_term_secs(),
141 )
142 }
143
144 #[must_use]
151 pub fn is_short_term_order(&self, params: &LimitOrderParams) -> bool {
152 self.get_order_lifetime(params).is_short_term()
153 }
154
155 #[must_use]
162 pub fn is_short_term_cancel(
163 &self,
164 time_in_force: TimeInForce,
165 expire_time_ns: Option<nautilus_core::UnixNanos>,
166 ) -> bool {
167 let expire_time = self.expire_time_to_secs(expire_time_ns);
168 OrderLifetime::from_time_in_force(
169 time_in_force,
170 expire_time,
171 false,
172 self.max_short_term_secs(),
173 )
174 .is_short_term()
175 }
176
177 pub fn build_market_order(
185 &self,
186 instrument_id: InstrumentId,
187 client_order_id: u32,
188 client_metadata: u32,
189 side: OrderSide,
190 quantity: Quantity,
191 block_height: u32,
192 ) -> Result<Any, DydxError> {
193 let market_params = self.get_market_params(instrument_id)?;
194
195 let builder = OrderBuilder::new(
196 market_params,
197 self.wallet_address.clone(),
198 self.subaccount_number,
199 client_order_id,
200 client_metadata,
201 )
202 .market(order_side_to_proto(side), quantity.as_decimal())
203 .short_term()
204 .until(OrderGoodUntil::Block(
205 block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME,
206 ));
207
208 let order = builder
209 .build()
210 .map_err(|e| DydxError::Order(format!("Failed to build market order: {e}")))?;
211
212 Ok(MsgPlaceOrder { order: Some(order) }.to_any())
213 }
214
215 #[allow(clippy::too_many_arguments)]
223 pub fn build_limit_order(
224 &self,
225 instrument_id: InstrumentId,
226 client_order_id: u32,
227 client_metadata: u32,
228 side: OrderSide,
229 price: Price,
230 quantity: Quantity,
231 time_in_force: TimeInForce,
232 post_only: bool,
233 reduce_only: bool,
234 block_height: u32,
235 expire_time: Option<i64>,
236 ) -> Result<Any, DydxError> {
237 let market_params = self.get_market_params(instrument_id)?;
238 let lifetime = OrderLifetime::from_time_in_force(
239 time_in_force,
240 expire_time,
241 false,
242 self.max_short_term_secs(),
243 );
244
245 let mut builder = OrderBuilder::new(
246 market_params,
247 self.wallet_address.clone(),
248 self.subaccount_number,
249 client_order_id,
250 client_metadata,
251 )
252 .limit(
253 order_side_to_proto(side),
254 price.as_decimal(),
255 quantity.as_decimal(),
256 )
257 .time_in_force(time_in_force_to_proto_with_post_only(
258 time_in_force,
259 post_only,
260 ));
261
262 if reduce_only {
263 builder = builder.reduce_only(true);
264 }
265
266 builder = self.apply_order_lifetime(builder, lifetime, block_height, expire_time)?;
268
269 let order = builder
270 .build()
271 .map_err(|e| DydxError::Order(format!("Failed to build limit order: {e}")))?;
272
273 Ok(MsgPlaceOrder { order: Some(order) }.to_any())
274 }
275
276 pub fn build_limit_order_from_params(
282 &self,
283 params: &LimitOrderParams,
284 block_height: u32,
285 ) -> Result<Any, DydxError> {
286 let expire_time = self.expire_time_to_secs(params.expire_time_ns);
287
288 self.build_limit_order(
289 params.instrument_id,
290 params.client_order_id,
291 params.client_metadata,
292 params.side,
293 params.price,
294 params.quantity,
295 params.time_in_force,
296 params.post_only,
297 params.reduce_only,
298 block_height,
299 expire_time,
300 )
301 }
302
303 pub fn build_limit_orders_batch(
309 &self,
310 orders: &[LimitOrderParams],
311 block_height: u32,
312 ) -> Result<Vec<Any>, DydxError> {
313 orders
314 .iter()
315 .map(|params| self.build_limit_order_from_params(params, block_height))
316 .collect()
317 }
318
319 pub fn build_cancel_order(
328 &self,
329 instrument_id: InstrumentId,
330 client_order_id: u32,
331 time_in_force: TimeInForce,
332 expire_time_ns: Option<nautilus_core::UnixNanos>,
333 block_height: u32,
334 ) -> Result<Any, DydxError> {
335 let expire_time = self.expire_time_to_secs(expire_time_ns);
336 let market_params = self.get_market_params(instrument_id)?;
337 let lifetime = OrderLifetime::from_time_in_force(
338 time_in_force,
339 expire_time,
340 false,
341 self.max_short_term_secs(),
342 );
343
344 let (order_flags, good_til_oneof) = match lifetime {
345 OrderLifetime::ShortTerm => (
346 0,
347 GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME),
348 ),
349 OrderLifetime::LongTerm | OrderLifetime::Conditional => {
350 let cancel_good_til = (Utc::now()
351 + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
352 .timestamp() as u32;
353 (
354 lifetime.order_flags(),
355 GoodTilOneof::GoodTilBlockTime(cancel_good_til),
356 )
357 }
358 };
359
360 let msg = MsgCancelOrder {
361 order_id: Some(OrderId {
362 subaccount_id: Some(SubaccountId {
363 owner: self.wallet_address.clone(),
364 number: self.subaccount_number,
365 }),
366 client_id: client_order_id,
367 order_flags,
368 clob_pair_id: market_params.clob_pair_id,
369 }),
370 good_til_oneof: Some(good_til_oneof),
371 };
372
373 Ok(msg.to_any())
374 }
375
376 pub fn build_cancel_order_with_flags(
385 &self,
386 instrument_id: InstrumentId,
387 client_order_id: u32,
388 order_flags: u32,
389 block_height: u32,
390 ) -> Result<Any, DydxError> {
391 let market_params = self.get_market_params(instrument_id)?;
392
393 let good_til_oneof = if order_flags == ORDER_FLAG_SHORT_TERM {
394 GoodTilOneof::GoodTilBlock(block_height + SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
395 } else {
396 let cancel_good_til = (Utc::now()
397 + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
398 .timestamp() as u32;
399 GoodTilOneof::GoodTilBlockTime(cancel_good_til)
400 };
401
402 let msg = MsgCancelOrder {
403 order_id: Some(OrderId {
404 subaccount_id: Some(SubaccountId {
405 owner: self.wallet_address.clone(),
406 number: self.subaccount_number,
407 }),
408 client_id: client_order_id,
409 order_flags,
410 clob_pair_id: market_params.clob_pair_id,
411 }),
412 good_til_oneof: Some(good_til_oneof),
413 };
414
415 Ok(msg.to_any())
416 }
417
418 pub fn build_cancel_orders_batch(
426 &self,
427 orders: &[(
428 InstrumentId,
429 u32,
430 TimeInForce,
431 Option<nautilus_core::UnixNanos>,
432 )],
433 block_height: u32,
434 ) -> Result<Vec<Any>, DydxError> {
435 orders
436 .iter()
437 .map(|(instrument_id, client_order_id, tif, expire_time_ns)| {
438 self.build_cancel_order(
439 *instrument_id,
440 *client_order_id,
441 *tif,
442 *expire_time_ns,
443 block_height,
444 )
445 })
446 .collect()
447 }
448
449 pub fn build_cancel_orders_batch_with_flags(
458 &self,
459 orders: &[(InstrumentId, u32, u32)],
460 block_height: u32,
461 ) -> Result<Vec<Any>, DydxError> {
462 orders
463 .iter()
464 .map(|(instrument_id, client_order_id, order_flags)| {
465 self.build_cancel_order_with_flags(
466 *instrument_id,
467 *client_order_id,
468 *order_flags,
469 block_height,
470 )
471 })
472 .collect()
473 }
474
475 #[allow(clippy::too_many_arguments)]
498 pub fn build_cancel_and_replace(
499 &self,
500 instrument_id: InstrumentId,
501 old_client_order_id: u32,
502 _new_client_order_id: u32,
503 old_time_in_force: TimeInForce,
504 old_expire_time_ns: Option<nautilus_core::UnixNanos>,
505 new_params: &LimitOrderParams,
506 block_height: u32,
507 ) -> Result<Vec<Any>, DydxError> {
508 let cancel_msg = self.build_cancel_order(
510 instrument_id,
511 old_client_order_id,
512 old_time_in_force,
513 old_expire_time_ns,
514 block_height,
515 )?;
516
517 let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
519
520 Ok(vec![cancel_msg, place_msg])
522 }
523
524 pub fn build_cancel_and_replace_with_flags(
532 &self,
533 instrument_id: InstrumentId,
534 old_client_order_id: u32,
535 old_order_flags: u32,
536 new_params: &LimitOrderParams,
537 block_height: u32,
538 ) -> Result<Vec<Any>, DydxError> {
539 let cancel_msg = self.build_cancel_order_with_flags(
541 instrument_id,
542 old_client_order_id,
543 old_order_flags,
544 block_height,
545 )?;
546
547 let place_msg = self.build_limit_order_from_params(new_params, block_height)?;
549
550 Ok(vec![cancel_msg, place_msg])
552 }
553
554 #[allow(clippy::too_many_arguments)]
562 pub fn build_conditional_order(
563 &self,
564 instrument_id: InstrumentId,
565 client_order_id: u32,
566 client_metadata: u32,
567 order_type: ConditionalOrderType,
568 side: OrderSide,
569 trigger_price: Price,
570 limit_price: Option<Price>,
571 quantity: Quantity,
572 time_in_force: Option<TimeInForce>,
573 post_only: bool,
574 reduce_only: bool,
575 expire_time: Option<i64>,
576 ) -> Result<Any, DydxError> {
577 let market_params = self.get_market_params(instrument_id)?;
578
579 let mut builder = OrderBuilder::new(
580 market_params,
581 self.wallet_address.clone(),
582 self.subaccount_number,
583 client_order_id,
584 client_metadata,
585 );
586
587 let proto_side = order_side_to_proto(side);
588 let trigger_decimal = trigger_price.as_decimal();
589 let size_decimal = quantity.as_decimal();
590
591 builder = match order_type {
593 ConditionalOrderType::StopMarket => {
594 builder.stop_market(proto_side, trigger_decimal, size_decimal)
595 }
596 ConditionalOrderType::StopLimit => {
597 let limit = limit_price.ok_or_else(|| {
598 DydxError::Order("StopLimit requires limit_price".to_string())
599 })?;
600 builder.stop_limit(
601 proto_side,
602 limit.as_decimal(),
603 trigger_decimal,
604 size_decimal,
605 )
606 }
607 ConditionalOrderType::TakeProfitMarket => {
608 builder.take_profit_market(proto_side, trigger_decimal, size_decimal)
609 }
610 ConditionalOrderType::TakeProfitLimit => {
611 let limit = limit_price.ok_or_else(|| {
612 DydxError::Order("TakeProfitLimit requires limit_price".to_string())
613 })?;
614 builder.take_profit_limit(
615 proto_side,
616 limit.as_decimal(),
617 trigger_decimal,
618 size_decimal,
619 )
620 }
621 };
622
623 let effective_tif = time_in_force.unwrap_or(TimeInForce::Gtc);
625 if matches!(
626 order_type,
627 ConditionalOrderType::StopLimit | ConditionalOrderType::TakeProfitLimit
628 ) {
629 let proto_tif = time_in_force_to_proto_with_post_only(effective_tif, post_only);
630 builder = builder.time_in_force(proto_tif);
631 }
632
633 if reduce_only {
634 builder = builder.reduce_only(true);
635 }
636
637 let expire = calculate_conditional_order_expiration(effective_tif, expire_time)?;
639 builder = builder.until(OrderGoodUntil::Time(expire));
640
641 let order = builder
642 .build()
643 .map_err(|e| DydxError::Order(format!("Failed to build {order_type:?} order: {e}")))?;
644
645 Ok(MsgPlaceOrder { order: Some(order) }.to_any())
646 }
647
648 #[allow(clippy::too_many_arguments)]
654 pub fn build_stop_market_order(
655 &self,
656 instrument_id: InstrumentId,
657 client_order_id: u32,
658 client_metadata: u32,
659 side: OrderSide,
660 trigger_price: Price,
661 quantity: Quantity,
662 reduce_only: bool,
663 expire_time: Option<i64>,
664 ) -> Result<Any, DydxError> {
665 self.build_conditional_order(
666 instrument_id,
667 client_order_id,
668 client_metadata,
669 ConditionalOrderType::StopMarket,
670 side,
671 trigger_price,
672 None,
673 quantity,
674 None,
675 false,
676 reduce_only,
677 expire_time,
678 )
679 }
680
681 #[allow(clippy::too_many_arguments)]
687 pub fn build_stop_limit_order(
688 &self,
689 instrument_id: InstrumentId,
690 client_order_id: u32,
691 client_metadata: u32,
692 side: OrderSide,
693 trigger_price: Price,
694 limit_price: Price,
695 quantity: Quantity,
696 time_in_force: TimeInForce,
697 post_only: bool,
698 reduce_only: bool,
699 expire_time: Option<i64>,
700 ) -> Result<Any, DydxError> {
701 self.build_conditional_order(
702 instrument_id,
703 client_order_id,
704 client_metadata,
705 ConditionalOrderType::StopLimit,
706 side,
707 trigger_price,
708 Some(limit_price),
709 quantity,
710 Some(time_in_force),
711 post_only,
712 reduce_only,
713 expire_time,
714 )
715 }
716
717 #[allow(clippy::too_many_arguments)]
723 pub fn build_take_profit_market_order(
724 &self,
725 instrument_id: InstrumentId,
726 client_order_id: u32,
727 client_metadata: u32,
728 side: OrderSide,
729 trigger_price: Price,
730 quantity: Quantity,
731 reduce_only: bool,
732 expire_time: Option<i64>,
733 ) -> Result<Any, DydxError> {
734 self.build_conditional_order(
735 instrument_id,
736 client_order_id,
737 client_metadata,
738 ConditionalOrderType::TakeProfitMarket,
739 side,
740 trigger_price,
741 None,
742 quantity,
743 None,
744 false,
745 reduce_only,
746 expire_time,
747 )
748 }
749
750 #[allow(clippy::too_many_arguments)]
756 pub fn build_take_profit_limit_order(
757 &self,
758 instrument_id: InstrumentId,
759 client_order_id: u32,
760 client_metadata: u32,
761 side: OrderSide,
762 trigger_price: Price,
763 limit_price: Price,
764 quantity: Quantity,
765 time_in_force: TimeInForce,
766 post_only: bool,
767 reduce_only: bool,
768 expire_time: Option<i64>,
769 ) -> Result<Any, DydxError> {
770 self.build_conditional_order(
771 instrument_id,
772 client_order_id,
773 client_metadata,
774 ConditionalOrderType::TakeProfitLimit,
775 side,
776 trigger_price,
777 Some(limit_price),
778 quantity,
779 Some(time_in_force),
780 post_only,
781 reduce_only,
782 expire_time,
783 )
784 }
785
786 fn get_market_params(
788 &self,
789 instrument_id: InstrumentId,
790 ) -> Result<OrderMarketParams, DydxError> {
791 let market = self
792 .http_client
793 .get_market_params(&instrument_id)
794 .ok_or_else(|| {
795 DydxError::Order(format!(
796 "Market params for instrument '{instrument_id}' not found in cache"
797 ))
798 })?;
799
800 Ok(OrderMarketParams {
801 atomic_resolution: market.atomic_resolution,
802 clob_pair_id: market.clob_pair_id,
803 oracle_price: Some(market.oracle_price),
804 quantum_conversion_exponent: market.quantum_conversion_exponent,
805 step_base_quantums: market.step_base_quantums,
806 subticks_per_tick: market.subticks_per_tick,
807 })
808 }
809
810 fn apply_order_lifetime(
812 &self,
813 builder: OrderBuilder,
814 lifetime: OrderLifetime,
815 block_height: u32,
816 expire_time: Option<i64>,
817 ) -> Result<OrderBuilder, DydxError> {
818 match lifetime {
819 OrderLifetime::ShortTerm => {
820 let blocks_offset = self.calculate_block_offset(expire_time);
821 Ok(builder
822 .short_term()
823 .until(OrderGoodUntil::Block(block_height + blocks_offset)))
824 }
825 OrderLifetime::LongTerm => {
826 let expire_dt = self.calculate_expire_datetime(expire_time)?;
827 Ok(builder.long_term().until(OrderGoodUntil::Time(expire_dt)))
828 }
829 OrderLifetime::Conditional => {
830 Err(DydxError::Order(
832 "Use build_conditional_order for conditional orders".to_string(),
833 ))
834 }
835 }
836 }
837
838 fn calculate_block_offset(&self, expire_time: Option<i64>) -> u32 {
843 if let Some(expire_ts) = expire_time {
844 let now = Utc::now().timestamp();
845 let seconds = expire_ts - now;
846 self.seconds_to_blocks(seconds)
847 } else {
848 SHORT_TERM_ORDER_MAXIMUM_LIFETIME
849 }
850 }
851
852 fn seconds_to_blocks(&self, seconds: i64) -> u32 {
857 if seconds <= 0 {
858 return 1; }
860
861 let secs_per_block = self.block_time_monitor.seconds_per_block_or_default();
862 let blocks = (seconds as f64 / secs_per_block).ceil() as u32;
863
864 blocks.clamp(1, SHORT_TERM_ORDER_MAXIMUM_LIFETIME)
865 }
866
867 fn calculate_expire_datetime(
869 &self,
870 expire_time: Option<i64>,
871 ) -> Result<DateTime<Utc>, DydxError> {
872 if let Some(expire_ts) = expire_time {
873 DateTime::from_timestamp(expire_ts, 0)
874 .ok_or_else(|| DydxError::Parse(format!("Invalid expire timestamp: {expire_ts}")))
875 } else {
876 Ok(Utc::now() + Duration::days(GTC_CONDITIONAL_ORDER_EXPIRATION_DAYS))
877 }
878 }
879}
880
881#[cfg(test)]
882mod tests {
883 use rstest::rstest;
884
885 use super::*;
886
887 const TEST_MAX_SHORT_TERM_SECS: f64 = 10.0;
889
890 #[rstest]
891 fn test_order_lifetime_routing() {
892 let lifetime = OrderLifetime::from_time_in_force(
894 TimeInForce::Ioc,
895 None,
896 false,
897 TEST_MAX_SHORT_TERM_SECS,
898 );
899 assert!(lifetime.is_short_term());
900
901 let lifetime = OrderLifetime::from_time_in_force(
903 TimeInForce::Gtc,
904 None,
905 false,
906 TEST_MAX_SHORT_TERM_SECS,
907 );
908 assert!(!lifetime.is_short_term());
909
910 let lifetime = OrderLifetime::from_time_in_force(
912 TimeInForce::Gtc,
913 None,
914 true,
915 TEST_MAX_SHORT_TERM_SECS,
916 );
917 assert!(lifetime.is_conditional());
918 }
919
920 #[rstest]
921 fn test_order_lifetime_with_short_expiry() {
922 let expire_time = Some(Utc::now().timestamp() + 5);
924 let lifetime = OrderLifetime::from_time_in_force(
925 TimeInForce::Gtd,
926 expire_time,
927 false,
928 TEST_MAX_SHORT_TERM_SECS,
929 );
930 assert!(lifetime.is_short_term());
931 }
932
933 #[rstest]
934 fn test_order_lifetime_with_long_expiry() {
935 let expire_time = Some(Utc::now().timestamp() + 60);
937 let lifetime = OrderLifetime::from_time_in_force(
938 TimeInForce::Gtd,
939 expire_time,
940 false,
941 TEST_MAX_SHORT_TERM_SECS,
942 );
943 assert!(!lifetime.is_short_term());
944 }
945}