1use crate::order::{
2 Order as RustradeOrder, OrderKind, TimeInForce, TrailingOffsetType,
3 id::{ClientOrderId, StrategyId},
4 state::UnindexedOrderState,
5};
6use fnv::{FnvHashMap, FnvHashSet};
7use ibapi::orders::{
8 Action, OcaType, Order, TimeInForce as IbTimeInForce, builder::BracketPrices, order_builder,
9};
10use parking_lot::{Mutex, RwLock};
11use rust_decimal::Decimal;
12use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
13use std::{sync::Arc, time::Instant};
14use thiserror::Error;
15
16#[derive(Debug, Clone)]
33pub struct BracketOrderRequest {
34 pub instrument: InstrumentNameExchange,
36 pub strategy: StrategyId,
38 pub parent_cid: ClientOrderId,
41 pub side: Side,
43 pub quantity: Decimal,
45 pub entry_price: Decimal,
47 pub take_profit_price: Decimal,
49 pub stop_loss_price: Decimal,
51 pub time_in_force: TimeInForce,
53}
54
55#[derive(Debug, Clone)]
66pub struct BracketOrderResult {
67 pub parent: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
69 pub take_profit: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
71 pub stop_loss: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
73}
74
75#[derive(Debug, Clone)]
90pub struct OrderContext {
91 pub instrument: InstrumentNameExchange,
92 pub side: Side,
93 pub price: Option<Decimal>,
95 pub quantity: Decimal,
96 pub kind: OrderKind,
97 pub time_in_force: TimeInForce,
98}
99
100#[derive(Debug, Clone)]
125pub struct OrderIdMap {
126 inner: Arc<RwLock<OrderIdMapInner>>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub(crate) struct Registration {
132 pub(crate) age: std::time::Duration,
134 pub(crate) adopted: bool,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
140pub(crate) enum AdoptionRefused {
141 #[error("its client order id names another live order")]
142 ClientOrderIdInUse,
143 #[error("its IB order id names another tracked order")]
144 IbOrderIdTracked,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Error)]
149#[error("client order id {0} already names a live IBKR order")]
150pub struct ClientOrderIdInUse(pub ClientOrderId);
151
152#[derive(Debug, Default)]
153struct OrderIdMapInner {
154 cid_to_ib: FnvHashMap<ClientOrderId, i32>,
156 ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
161 adopted: FnvHashSet<i32>,
164}
165
166impl OrderIdMapInner {
167 fn remove(&mut self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
169 let (client_id, ctx, _) = self.ib_to_entry.remove(&ib_id)?;
170 self.adopted.remove(&ib_id);
171 self.release(&client_id, ib_id);
172 Some((client_id, ctx))
173 }
174
175 fn in_use<'a>(
178 &self,
179 mut orders: impl Iterator<Item = &'a ClientOrderId>,
180 client_id: &ClientOrderId,
181 ) -> bool {
182 orders.any(|earlier| earlier == client_id) || self.cid_to_ib.contains_key(client_id)
183 }
184
185 fn insert(
186 &mut self,
187 client_id: ClientOrderId,
188 ib_id: i32,
189 context: OrderContext,
190 now: Instant,
191 ) {
192 self.adopted.remove(&ib_id);
193 self.cid_to_ib.insert(client_id.clone(), ib_id);
194 self.ib_to_entry.insert(ib_id, (client_id, context, now));
195 }
196
197 fn release(&mut self, client_id: &ClientOrderId, ib_id: i32) {
199 if self.cid_to_ib.get(client_id) == Some(&ib_id) {
200 self.cid_to_ib.remove(client_id);
201 }
202 }
203}
204
205impl OrderIdMap {
206 pub fn new() -> Self {
207 Self {
208 inner: Arc::new(RwLock::new(OrderIdMapInner::default())),
209 }
210 }
211
212 pub fn register(
217 &self,
218 client_id: ClientOrderId,
219 ib_id: i32,
220 context: OrderContext,
221 ) -> Result<(), ClientOrderIdInUse> {
222 self.register_all([(client_id, ib_id, context)])
223 }
224
225 pub fn register_all<const N: usize>(
231 &self,
232 orders: [(ClientOrderId, i32, OrderContext); N],
233 ) -> Result<(), ClientOrderIdInUse> {
234 let mut inner = self.inner.write();
235 for (index, (client_id, _, _)) in orders.iter().enumerate() {
236 if inner.in_use(
237 orders[..index].iter().map(|(earlier, _, _)| earlier),
238 client_id,
239 ) {
240 return Err(ClientOrderIdInUse(client_id.clone()));
241 }
242 }
243 let now = Instant::now();
244 for (client_id, ib_id, context) in orders {
245 inner.insert(client_id, ib_id, context, now);
246 }
247 Ok(())
248 }
249
250 pub(crate) fn adopt(
258 &self,
259 client_id: ClientOrderId,
260 ib_id: i32,
261 context: OrderContext,
262 ) -> Result<(), AdoptionRefused> {
263 let mut inner = self.inner.write();
264 if inner.ib_to_entry.contains_key(&ib_id) {
265 return Err(AdoptionRefused::IbOrderIdTracked);
266 }
267 if inner.in_use(std::iter::empty(), &client_id) {
268 return Err(AdoptionRefused::ClientOrderIdInUse);
269 }
270 inner.insert(client_id, ib_id, context, Instant::now());
271 inner.adopted.insert(ib_id);
272 Ok(())
273 }
274
275 pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
277 self.inner.read().cid_to_ib.get(client_id).copied()
278 }
279
280 pub(crate) fn registration(&self, client_id: &ClientOrderId) -> Option<Registration> {
287 let inner = self.inner.read();
288 let ib_id = inner.cid_to_ib.get(client_id)?;
289 let (_, _, registered_at) = inner.ib_to_entry.get(ib_id)?;
290 Some(Registration {
291 age: registered_at.elapsed(),
292 adopted: inner.adopted.contains(ib_id),
293 })
294 }
295
296 pub fn contains(&self, ib_id: i32) -> bool {
298 self.inner.read().ib_to_entry.contains_key(&ib_id)
299 }
300
301 pub fn get_client_id(&self, ib_id: i32) -> Option<ClientOrderId> {
303 self.inner
304 .read()
305 .ib_to_entry
306 .get(&ib_id)
307 .map(|(cid, _, _)| cid.clone())
308 }
309
310 pub fn get_client_id_and_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
312 self.inner
313 .read()
314 .ib_to_entry
315 .get(&ib_id)
316 .map(|(cid, ctx, _)| (cid.clone(), ctx.clone()))
317 }
318
319 pub fn names_other_order(&self, client_id: &ClientOrderId, ib_id: i32) -> bool {
322 self.inner
323 .read()
324 .cid_to_ib
325 .get(client_id)
326 .is_some_and(|live| *live != ib_id)
327 }
328
329 pub fn release_client_id(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
335 let mut inner = self.inner.write();
336 let (client_id, ctx, _) = inner.ib_to_entry.get(&ib_id)?;
337 let (client_id, ctx) = (client_id.clone(), ctx.clone());
338 inner.release(&client_id, ib_id);
339 Some((client_id, ctx))
340 }
341
342 pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
348 self.inner.write().remove(ib_id)
349 }
350
351 pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
355 self.inner
356 .write()
357 .remove(ib_id)
358 .map(|(client_id, _)| client_id)
359 }
360
361 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
378 let mut inner = self.inner.write();
379 let before = inner.ib_to_entry.len();
380
381 let stale_ids: Vec<i32> = inner
383 .ib_to_entry
384 .iter()
385 .filter(|(_, (_, _, registered_at))| registered_at.elapsed() >= max_age)
386 .map(|(ib_id, _)| *ib_id)
387 .collect();
388
389 for ib_id in stale_ids {
390 inner.remove(ib_id);
391 }
392
393 before - inner.ib_to_entry.len()
394 }
395
396 pub fn len(&self) -> usize {
398 self.inner.read().cid_to_ib.len()
399 }
400
401 pub fn is_empty(&self) -> bool {
403 self.inner.read().cid_to_ib.is_empty()
404 }
405}
406
407impl Default for OrderIdMap {
408 fn default() -> Self {
409 Self::new()
410 }
411}
412
413#[derive(Debug, Clone)]
419pub struct PendingCancels {
420 inner: Arc<Mutex<FnvHashMap<i32, Instant>>>,
421}
422
423impl PendingCancels {
424 pub fn new() -> Self {
425 Self {
426 inner: Arc::new(Mutex::new(FnvHashMap::with_capacity_and_hasher(
427 8,
428 Default::default(),
429 ))),
430 }
431 }
432
433 pub fn insert(&self, ib_id: i32) {
435 self.inner.lock().insert(ib_id, Instant::now());
436 }
437
438 #[must_use]
442 pub fn remove(&self, ib_id: i32) -> bool {
443 self.inner.lock().remove(&ib_id).is_some()
444 }
445
446 pub(crate) fn contains(&self, ib_id: i32) -> bool {
449 self.inner.lock().contains_key(&ib_id)
450 }
451
452 #[must_use]
457 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
458 let mut map = self.inner.lock();
459 let before = map.len();
460 map.retain(|_, registered_at| registered_at.elapsed() < max_age);
461 before - map.len()
462 }
463
464 #[must_use]
466 pub fn len(&self) -> usize {
467 self.inner.lock().len()
468 }
469
470 #[must_use]
472 pub fn is_empty(&self) -> bool {
473 self.inner.lock().is_empty()
474 }
475}
476
477impl Default for PendingCancels {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483pub(crate) fn side_to_action(side: rustrade_instrument::Side) -> Action {
485 match side {
486 rustrade_instrument::Side::Buy => Action::Buy,
487 rustrade_instrument::Side::Sell => Action::Sell,
488 }
489}
490
491pub(crate) fn action_to_side(action: &Action) -> rustrade_instrument::Side {
493 match action {
494 Action::Buy => rustrade_instrument::Side::Buy,
495 Action::Sell | Action::SellShort | Action::SellLong => rustrade_instrument::Side::Sell,
496 }
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub(crate) struct OrderShape {
502 pub(crate) kind: OrderKind,
503 pub(crate) price: Option<Decimal>,
505 pub(crate) time_in_force: TimeInForce,
506}
507
508pub(crate) fn order_shape_from_ib(order: &Order) -> Result<OrderShape, String> {
518 let order_type = order.order_type.as_str();
519 let decimal = |value: Option<f64>, field: &str| -> Result<Decimal, String> {
520 let value = value.ok_or_else(|| format!("{order_type} order without {field}"))?;
521 Decimal::try_from(value).map_err(|e| format!("{order_type} order's {field} {value}: {e}"))
522 };
523
524 let time_in_force = match &order.tif {
525 IbTimeInForce::Day => TimeInForce::GoodUntilEndOfDay,
526 IbTimeInForce::GoodTillCanceled => TimeInForce::GoodUntilCancelled { post_only: false },
527 IbTimeInForce::ImmediateOrCancel => TimeInForce::ImmediateOrCancel,
528 IbTimeInForce::FillOrKill => TimeInForce::FillOrKill,
529 IbTimeInForce::OnOpen => TimeInForce::AtOpen,
530 IbTimeInForce::GoodTillDate => TimeInForce::GoodTillDate {
531 expiry: parse_gtd_datetime(&order.good_till_date).ok_or_else(|| {
532 format!(
533 "good-till-date time {:?} does not parse",
534 order.good_till_date
535 )
536 })?,
537 },
538 other => return Err(format!("time in force {other:?}")),
539 };
540
541 let trailing = || -> Result<(Decimal, TrailingOffsetType), String> {
544 match order.trailing_percent {
545 Some(_) => Ok((
546 decimal(order.trailing_percent, "trailing_percent")?,
547 TrailingOffsetType::Percentage,
548 )),
549 None => Ok((
550 decimal(order.aux_price, "aux_price")?,
551 TrailingOffsetType::Absolute,
552 )),
553 }
554 };
555
556 let (kind, price, time_in_force) = match order_type {
557 "MKT" => (OrderKind::Market, None, time_in_force),
558 "LMT" => (
559 OrderKind::Limit,
560 Some(decimal(order.limit_price, "limit_price")?),
561 time_in_force,
562 ),
563 "MOC" => (OrderKind::Market, None, TimeInForce::AtClose),
565 "LOC" => (
566 OrderKind::Limit,
567 Some(decimal(order.limit_price, "limit_price")?),
568 TimeInForce::AtClose,
569 ),
570 "STP" => (
571 OrderKind::Stop {
572 trigger_price: decimal(order.aux_price, "aux_price")?,
573 },
574 None,
575 time_in_force,
576 ),
577 "STP LMT" => (
578 OrderKind::StopLimit {
579 trigger_price: decimal(order.aux_price, "aux_price")?,
580 },
581 Some(decimal(order.limit_price, "limit_price")?),
582 time_in_force,
583 ),
584 "TRAIL" => {
585 let (offset, offset_type) = trailing()?;
586 (
587 OrderKind::TrailingStop {
588 offset,
589 offset_type,
590 activation_price: None,
591 },
592 None,
593 time_in_force,
594 )
595 }
596 "TRAIL LIMIT" => {
597 let (offset, offset_type) = trailing()?;
598 let stop_price = decimal(order.trail_stop_price, "trail_stop_price")?;
599 if stop_price <= Decimal::ZERO {
600 return Err(format!(
601 "{order_type} order's trail_stop_price {stop_price} is not positive"
602 ));
603 }
604 (
605 OrderKind::TrailingStopLimit {
606 offset,
607 offset_type,
608 stop_price,
609 limit_offset: decimal(order.limit_price_offset, "limit_price_offset")?,
610 },
611 None,
612 time_in_force,
613 )
614 }
615 other => return Err(format!("order type {other:?}")),
616 };
617
618 Ok(OrderShape {
619 kind,
620 price,
621 time_in_force,
622 })
623}
624
625fn parse_gtd_datetime(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
628 chrono::NaiveDateTime::parse_from_str(value, GTD_FORMAT)
629 .map(|naive| naive.and_utc())
630 .ok()
631 .or_else(|| super::execution::parse_ib_timestamp(value))
632}
633
634#[non_exhaustive]
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum OrderMappingError {
638 PostOnlyNotSupported,
639 InvalidPrice(String),
641 MissingLimitPrice(OrderKind),
643 UnexpectedLimitPrice(OrderKind),
646 NonPositiveStopPrice(OrderKind),
648 UnsupportedOffsetType(TrailingOffsetType),
650 UnsupportedOrderKind(OrderKind),
652 UnsupportedOrderKindForAtClose(OrderKind),
655 AtCloseRequiresOrderTypeChange,
659 InvalidBracketPrices(String),
663 InvalidClientOrderId(ClientOrderId),
666}
667
668impl std::fmt::Display for OrderMappingError {
669 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
670 match self {
671 Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
672 Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
673 Self::MissingLimitPrice(k) => {
674 write!(f, "limit price required for {k} but was None")
675 }
676 Self::UnexpectedLimitPrice(k) => write!(
677 f,
678 "{k} takes no limit price: it follows from the stop price and limit offset"
679 ),
680 Self::NonPositiveStopPrice(k) => {
681 write!(f, "stop price must be positive for {k}")
682 }
683 Self::UnsupportedOffsetType(t) => {
684 write!(f, "trailing offset type {t:?} not supported by IBKR")
685 }
686 Self::UnsupportedOrderKind(k) => {
687 write!(f, "order kind {k} not supported by IBKR")
688 }
689 Self::UnsupportedOrderKindForAtClose(k) => {
690 write!(
691 f,
692 "AtClose TIF only valid with Market or Limit orders, got {k}"
693 )
694 }
695 Self::AtCloseRequiresOrderTypeChange => write!(
696 f,
697 "AtClose TIF must be routed through build_ib_order, which promotes \
698 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
699 ),
700 Self::InvalidBracketPrices(e) => write!(f, "invalid bracket prices: {e}"),
701 Self::InvalidClientOrderId(cid) => write!(
702 f,
703 "client order id {cid} is not an IB order reference: at most \
704 {MAX_ORDER_REF_LEN} ASCII characters"
705 ),
706 }
707 }
708}
709
710pub const MAX_ORDER_REF_LEN: usize = 128;
713
714pub fn order_ref(cid: &ClientOrderId) -> Result<String, OrderMappingError> {
723 let id = cid.0.as_str();
724 if id.is_empty() || id.len() > MAX_ORDER_REF_LEN || !id.is_ascii() {
725 return Err(OrderMappingError::InvalidClientOrderId(cid.clone()));
726 }
727 Ok(id.to_owned())
728}
729
730impl std::error::Error for OrderMappingError {}
731
732pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
740 match tif {
741 TimeInForce::GoodUntilCancelled { post_only } => {
742 if *post_only {
743 Err(OrderMappingError::PostOnlyNotSupported)
744 } else {
745 Ok(IbTimeInForce::GoodTillCanceled)
746 }
747 }
748 TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
749 TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
750 TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
751 TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTillDate),
752 TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
753 TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
757 }
758}
759
760fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
762 value.try_into().or_else(|_| {
763 value
764 .to_string()
765 .parse()
766 .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
767 })
768}
769
770fn require_limit_price(
772 price: Option<rust_decimal::Decimal>,
773 kind: OrderKind,
774) -> Result<f64, OrderMappingError> {
775 match price {
776 Some(p) => decimal_to_f64(p),
777 None => Err(OrderMappingError::MissingLimitPrice(kind)),
778 }
779}
780
781pub fn build_ib_order(
816 side: rustrade_instrument::Side,
817 quantity: f64,
818 kind: &OrderKind,
819 price: Option<rust_decimal::Decimal>,
820 tif: &TimeInForce,
821) -> Result<Order, OrderMappingError> {
822 let action = side_to_action(side);
823
824 if matches!(tif, TimeInForce::AtClose) {
826 return build_at_close_order(action, quantity, kind, price);
827 }
828
829 let tif_ib = time_in_force_to_ib(tif)?;
830
831 let mut order = match kind {
832 OrderKind::Market => order_builder::market_order(action, quantity),
833
834 OrderKind::Limit => {
835 let price_f64 = require_limit_price(price, *kind)?;
836 order_builder::limit_order(action, quantity, price_f64)
837 }
838
839 OrderKind::Stop { trigger_price } => {
840 let trigger_f64 = decimal_to_f64(*trigger_price)?;
841 order_builder::stop(action, quantity, trigger_f64)
842 }
843
844 OrderKind::StopLimit { trigger_price } => {
845 let limit_f64 = require_limit_price(price, *kind)?;
846 let trigger_f64 = decimal_to_f64(*trigger_price)?;
847 Order {
850 action,
851 order_type: "STP LMT".to_owned(),
852 total_quantity: quantity,
853 limit_price: Some(limit_f64),
854 aux_price: Some(trigger_f64),
855 ..Order::default()
856 }
857 }
858
859 OrderKind::TrailingStop {
860 offset,
861 offset_type,
862 activation_price: None,
863 } => {
864 let offset_f64 = decimal_to_f64(*offset)?;
865 match offset_type {
866 TrailingOffsetType::Percentage => {
867 Order {
870 action,
871 order_type: "TRAIL".to_owned(),
872 total_quantity: quantity,
873 trailing_percent: Some(offset_f64),
874 trail_stop_price: None,
875 ..Order::default()
876 }
877 }
878 TrailingOffsetType::Absolute => {
879 Order {
882 action,
883 order_type: "TRAIL".to_owned(),
884 total_quantity: quantity,
885 aux_price: Some(offset_f64),
886 trail_stop_price: None,
887 ..Order::default()
888 }
889 }
890 TrailingOffsetType::BasisPoints => {
891 return Err(OrderMappingError::UnsupportedOffsetType(
892 TrailingOffsetType::BasisPoints,
893 ));
894 }
895 }
896 }
897
898 OrderKind::TrailingStopLimit {
899 offset,
900 offset_type,
901 stop_price,
902 limit_offset,
903 } => {
904 if price.is_some() {
907 return Err(OrderMappingError::UnexpectedLimitPrice(*kind));
908 }
909 if *stop_price <= Decimal::ZERO {
912 return Err(OrderMappingError::NonPositiveStopPrice(*kind));
913 }
914 let offset_f64 = decimal_to_f64(*offset)?;
915 let stop_price_f64 = decimal_to_f64(*stop_price)?;
916 let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
917 let (aux_price, trailing_percent) = match offset_type {
919 TrailingOffsetType::Absolute => (Some(offset_f64), None),
920 TrailingOffsetType::Percentage => (None, Some(offset_f64)),
921 TrailingOffsetType::BasisPoints => {
922 return Err(OrderMappingError::UnsupportedOffsetType(
923 TrailingOffsetType::BasisPoints,
924 ));
925 }
926 };
927 Order {
928 action,
929 order_type: "TRAIL LIMIT".to_owned(),
930 total_quantity: quantity,
931 aux_price,
932 trailing_percent,
933 trail_stop_price: Some(stop_price_f64),
934 limit_price_offset: Some(limit_offset_f64),
935 ..Order::default()
936 }
937 }
938
939 OrderKind::TakeProfit { .. }
943 | OrderKind::TakeProfitLimit { .. }
944 | OrderKind::TrailingStop {
945 activation_price: Some(_),
946 ..
947 }
948 | OrderKind::TrailingStopFixedLimit { .. } => {
949 return Err(OrderMappingError::UnsupportedOrderKind(*kind));
950 }
951 };
952
953 order.tif = tif_ib;
954
955 if let TimeInForce::GoodTillDate { expiry } = tif {
957 order.good_till_date = format_gtd_datetime(expiry);
958 }
959
960 Ok(order)
961}
962
963fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
968 dt.format(GTD_FORMAT).to_string()
969}
970
971const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
973
974fn build_at_close_order(
979 action: Action,
980 quantity: f64,
981 kind: &OrderKind,
982 price: Option<rust_decimal::Decimal>,
983) -> Result<Order, OrderMappingError> {
984 match kind {
985 OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
986 OrderKind::Limit => {
987 let price_f64 = require_limit_price(price, *kind)?;
988 Ok(order_builder::limit_on_close(action, quantity, price_f64))
989 }
990 _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
992 }
993}
994
995pub fn build_ib_bracket_with_oca(
1028 parent_order_id: i32,
1029 action: Action,
1030 quantity: f64,
1031 limit_price: f64,
1032 take_profit_price: f64,
1033 stop_loss_price: f64,
1034 tif: IbTimeInForce,
1035) -> Result<Vec<Order>, OrderMappingError> {
1036 let prices = BracketPrices {
1037 entry: limit_price,
1038 take_profit: take_profit_price,
1039 stop_loss: stop_loss_price,
1040 };
1041 let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1042 .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1043 assert_eq!(
1044 orders.len(),
1045 3,
1046 "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1047 );
1048
1049 orders[0].tif = tif.clone();
1052 orders[1].tif = tif.clone();
1053 orders[2].tif = tif;
1054
1055 let oca_group = format!("bracket_{}", parent_order_id);
1058 orders[1].oca_group = oca_group.clone();
1059 orders[1].oca_type = OcaType::CancelWithBlock;
1060 orders[2].oca_group = oca_group;
1061 orders[2].oca_type = OcaType::CancelWithBlock;
1062
1063 Ok(orders)
1064}
1065
1066#[cfg(test)]
1067#[allow(clippy::unwrap_used)] mod tests {
1069 use super::*;
1070 use rust_decimal::Decimal;
1071
1072 fn test_context() -> OrderContext {
1073 OrderContext {
1074 instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1075 side: Side::Buy,
1076 price: Some(Decimal::from(150)),
1077 quantity: Decimal::from(100),
1078 kind: OrderKind::Limit,
1079 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1080 }
1081 }
1082
1083 #[test]
1084 fn test_order_id_map_basic() {
1085 let map = OrderIdMap::new();
1086 let cid = ClientOrderId::new("order-123");
1087 let ctx = test_context();
1088
1089 map.register(cid.clone(), 42, ctx.clone()).unwrap();
1090
1091 assert_eq!(map.get_ib_id(&cid), Some(42));
1092 assert_eq!(map.get_client_id(42), Some(cid.clone()));
1093 assert_eq!(map.len(), 1);
1094
1095 let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1096 assert_eq!(retrieved_cid, cid);
1097 assert_eq!(retrieved_ctx.side, Side::Buy);
1098 assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1099 }
1100
1101 #[test]
1102 fn test_order_id_map_remove() {
1103 let map = OrderIdMap::new();
1104 let cid = ClientOrderId::new("order-456");
1105
1106 map.register(cid.clone(), 100, test_context()).unwrap();
1107 assert_eq!(map.len(), 1);
1108
1109 let removed = map.remove_by_ib_id(100);
1110 assert_eq!(removed, Some(cid.clone()));
1111 assert!(map.is_empty());
1112 assert!(map.get_ib_id(&cid).is_none());
1113 assert!(map.get_client_id(100).is_none());
1114 assert!(map.get_client_id_and_context(100).is_none());
1115 }
1116
1117 #[test]
1118 fn test_side_conversion() {
1119 assert!(matches!(
1120 side_to_action(rustrade_instrument::Side::Buy),
1121 Action::Buy
1122 ));
1123 assert!(matches!(
1124 side_to_action(rustrade_instrument::Side::Sell),
1125 Action::Sell
1126 ));
1127 }
1128
1129 #[test]
1130 fn test_time_in_force_conversion() {
1131 assert_eq!(
1132 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1133 Ok(IbTimeInForce::GoodTillCanceled)
1134 );
1135
1136 assert!(matches!(
1137 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1138 Err(OrderMappingError::PostOnlyNotSupported)
1139 ));
1140
1141 assert_eq!(
1142 time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1143 Ok(IbTimeInForce::Day)
1144 );
1145 assert_eq!(
1146 time_in_force_to_ib(&TimeInForce::FillOrKill),
1147 Ok(IbTimeInForce::FillOrKill)
1148 );
1149 assert_eq!(
1150 time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1151 Ok(IbTimeInForce::ImmediateOrCancel)
1152 );
1153 }
1154
1155 #[test]
1156 fn test_time_in_force_conversion_good_till_date() {
1157 use chrono::{TimeZone, Utc};
1158
1159 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1160 assert_eq!(
1161 time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1162 Ok(IbTimeInForce::GoodTillDate)
1163 );
1164 }
1165
1166 #[test]
1167 fn test_time_in_force_conversion_at_open() {
1168 assert_eq!(
1169 time_in_force_to_ib(&TimeInForce::AtOpen),
1170 Ok(IbTimeInForce::OnOpen)
1171 );
1172 }
1173
1174 #[test]
1175 fn test_time_in_force_conversion_at_close_returns_err() {
1176 assert_eq!(
1180 time_in_force_to_ib(&TimeInForce::AtClose),
1181 Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1182 );
1183 }
1184
1185 #[test]
1186 fn test_build_market_order() {
1187 let order = build_ib_order(
1188 rustrade_instrument::Side::Buy,
1189 100.0,
1190 &OrderKind::Market,
1191 None, &TimeInForce::GoodUntilEndOfDay,
1193 )
1194 .unwrap();
1195
1196 assert_eq!(order.action, Action::Buy);
1197 assert_eq!(order.total_quantity, 100.0);
1198 assert_eq!(order.order_type, "MKT");
1199 }
1200
1201 #[test]
1202 fn test_build_limit_order() {
1203 let order = build_ib_order(
1204 rustrade_instrument::Side::Sell,
1205 50.0,
1206 &OrderKind::Limit,
1207 Some(Decimal::try_from(150.5).unwrap()),
1208 &TimeInForce::GoodUntilCancelled { post_only: false },
1209 )
1210 .unwrap();
1211
1212 assert_eq!(order.action, Action::Sell);
1213 assert_eq!(order.total_quantity, 50.0);
1214 assert_eq!(order.order_type, "LMT");
1215 }
1216
1217 #[test]
1218 fn test_order_id_map_remove_and_get_context() {
1219 let map = OrderIdMap::new();
1220 let cid = ClientOrderId::new("order-789");
1221 let ctx = test_context();
1222
1223 map.register(cid.clone(), 50, ctx).unwrap();
1224 assert_eq!(map.len(), 1);
1225
1226 let result = map.remove_and_get_context(50);
1228 assert!(result.is_some());
1229 let (retrieved_cid, retrieved_ctx) = result.unwrap();
1230 assert_eq!(retrieved_cid, cid);
1231 assert_eq!(retrieved_ctx.side, Side::Buy);
1232
1233 assert!(map.is_empty());
1235 assert!(map.get_client_id(50).is_none());
1236 assert!(map.get_ib_id(&cid).is_none());
1237
1238 assert!(map.remove_and_get_context(50).is_none());
1240 }
1241
1242 #[test]
1243 fn test_order_id_map_clear_stale() {
1244 use std::time::Duration;
1245
1246 let map = OrderIdMap::new();
1247
1248 map.register(ClientOrderId::new("old-1"), 1, test_context())
1250 .unwrap();
1251 map.register(ClientOrderId::new("old-2"), 2, test_context())
1252 .unwrap();
1253
1254 let cleared = map.clear_stale(Duration::ZERO);
1256 assert_eq!(cleared, 2);
1257 assert!(map.is_empty());
1258
1259 map.register(ClientOrderId::new("new-1"), 10, test_context())
1261 .unwrap();
1262 map.register(ClientOrderId::new("new-2"), 20, test_context())
1263 .unwrap();
1264
1265 let cleared = map.clear_stale(Duration::from_secs(3600));
1267 assert_eq!(cleared, 0);
1268 assert_eq!(map.len(), 2);
1269 }
1270
1271 #[test]
1273 fn a_live_orders_id_is_refused_to_a_second_order() {
1274 let map = OrderIdMap::new();
1275 let cid = ClientOrderId::new("taken");
1276 map.register(cid.clone(), 1, test_context()).unwrap();
1277
1278 assert_eq!(
1279 map.register(cid.clone(), 2, test_context()),
1280 Err(ClientOrderIdInUse(cid.clone()))
1281 );
1282 assert_eq!(
1283 map.get_ib_id(&cid),
1284 Some(1),
1285 "a cancel by id still reaches the first"
1286 );
1287 assert!(
1288 map.get_client_id(2).is_none(),
1289 "nothing of the second was registered"
1290 );
1291 }
1292
1293 #[test]
1295 fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1296 let map = OrderIdMap::new();
1297 let cid = ClientOrderId::new("reused");
1298 map.register(cid.clone(), 1, test_context()).unwrap();
1299
1300 assert_eq!(
1301 map.release_client_id(1).map(|(id, _)| id),
1302 Some(cid.clone())
1303 );
1304 assert!(map.is_empty(), "no order is live");
1305 assert_eq!(
1306 map.get_client_id(1),
1307 Some(cid.clone()),
1308 "a late execution resolves"
1309 );
1310
1311 map.register(cid.clone(), 2, test_context()).unwrap();
1313 assert_eq!(map.get_ib_id(&cid), Some(2));
1314 assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1315 }
1316
1317 #[test]
1320 fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1321 let map = OrderIdMap::new();
1322 let cid = ClientOrderId::new("reused");
1323 map.register(cid.clone(), 1, test_context()).unwrap();
1324 let _ = map.release_client_id(1);
1325 map.register(cid.clone(), 2, test_context()).unwrap();
1326
1327 assert!(
1328 map.remove_and_get_context(1).is_some(),
1329 "the first order's entry goes"
1330 );
1331 assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1332 assert!(map.remove_by_ib_id(1).is_none());
1333 assert_eq!(map.get_ib_id(&cid), Some(2));
1334 }
1335
1336 #[test]
1338 fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1339 use std::time::Duration;
1340
1341 let map = OrderIdMap::new();
1342 let cid = ClientOrderId::new("reused");
1343 map.register(cid.clone(), 1, test_context()).unwrap();
1344 let _ = map.release_client_id(1);
1345 std::thread::sleep(Duration::from_millis(5));
1346 map.register(cid.clone(), 2, test_context()).unwrap();
1347
1348 assert_eq!(
1349 map.clear_stale(Duration::from_millis(5)),
1350 1,
1351 "only the filled one"
1352 );
1353 assert_eq!(map.get_ib_id(&cid), Some(2));
1354 }
1355
1356 #[test]
1359 fn register_all_registers_every_order_or_none() {
1360 let map = OrderIdMap::new();
1361 map.register(ClientOrderId::new("live"), 1, test_context())
1362 .unwrap();
1363
1364 let clash = map.register_all([
1365 (ClientOrderId::new("a"), 10, test_context()),
1366 (ClientOrderId::new("live"), 11, test_context()),
1367 ]);
1368 assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1369
1370 let repeated = map.register_all([
1371 (ClientOrderId::new("b"), 20, test_context()),
1372 (ClientOrderId::new("b"), 21, test_context()),
1373 ]);
1374 assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1375
1376 assert_eq!(map.len(), 1, "neither registered anything");
1377 assert!(
1378 [10, 11, 20, 21]
1379 .iter()
1380 .all(|ib_id| map.get_client_id(*ib_id).is_none())
1381 );
1382
1383 map.register_all([
1384 (ClientOrderId::new("a"), 10, test_context()),
1385 (ClientOrderId::new("b"), 20, test_context()),
1386 ])
1387 .unwrap();
1388 assert_eq!(map.len(), 3);
1389 }
1390
1391 #[test]
1393 fn an_adopted_order_is_tracked_and_marked_adopted() {
1394 let map = OrderIdMap::new();
1395 let cid = ClientOrderId::new("listed");
1396
1397 map.adopt(cid.clone(), 7, test_context()).unwrap();
1398
1399 assert_eq!(map.get_ib_id(&cid), Some(7));
1400 assert_eq!(map.get_client_id(7), Some(cid.clone()));
1401 assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1402 }
1403
1404 #[test]
1407 fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1408 let map = OrderIdMap::new();
1409 let placed = ClientOrderId::new("placed");
1410 let adopted = ClientOrderId::new("adopted");
1411 let filled = ClientOrderId::new("filled");
1412 map.register(placed.clone(), 1, test_context()).unwrap();
1413 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1414 map.register(filled.clone(), 3, test_context()).unwrap();
1415 let _ = map.release_client_id(3);
1416
1417 for ib_id in [1, 2, 3] {
1418 let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1419 assert_eq!(
1420 map.adopt(other.clone(), ib_id, test_context()),
1421 Err(AdoptionRefused::IbOrderIdTracked)
1422 );
1423 assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1424 }
1425
1426 assert_eq!(map.get_client_id(1), Some(placed.clone()));
1427 assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1428 assert_eq!(map.get_client_id(3), Some(filled.clone()));
1429 assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1430 assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1431 assert!(
1432 map.registration(&filled).is_none(),
1433 "the filled order's id stays free"
1434 );
1435 assert_eq!(map.len(), 2);
1436 }
1437
1438 #[test]
1441 fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1442 let map = OrderIdMap::new();
1443 let placed = ClientOrderId::new("placed");
1444 let adopted = ClientOrderId::new("adopted");
1445 map.register(placed.clone(), 1, test_context()).unwrap();
1446 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1447 let before = [
1448 map.registration(&placed).map(|r| r.adopted),
1449 map.registration(&adopted).map(|r| r.adopted),
1450 ];
1451
1452 for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1453 assert_eq!(
1454 map.adopt(cid.clone(), ib_id, test_context()),
1455 Err(AdoptionRefused::ClientOrderIdInUse)
1456 );
1457 assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1458 }
1459
1460 assert_eq!(map.get_ib_id(&placed), Some(1));
1461 assert_eq!(map.get_ib_id(&adopted), Some(2));
1462 assert_eq!(
1463 [
1464 map.registration(&placed).map(|r| r.adopted),
1465 map.registration(&adopted).map(|r| r.adopted),
1466 ],
1467 before
1468 );
1469 assert_eq!(map.len(), 2);
1470 }
1471
1472 #[test]
1475 fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1476 let map = OrderIdMap::new();
1477 let cancelled = ClientOrderId::new("cancelled");
1478 let filled = ClientOrderId::new("filled");
1479 map.register(cancelled.clone(), 1, test_context()).unwrap();
1480 map.register(filled.clone(), 2, test_context()).unwrap();
1481 let _ = map.remove_and_get_context(1);
1482 let _ = map.release_client_id(2);
1483
1484 map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1485 map.adopt(filled.clone(), 12, test_context()).unwrap();
1486
1487 assert_eq!(map.get_ib_id(&cancelled), Some(11));
1488 assert_eq!(map.get_ib_id(&filled), Some(12));
1489 assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1490 assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1491 assert_eq!(
1492 map.get_client_id(2),
1493 Some(filled),
1494 "the filled order's executions resolve"
1495 );
1496 }
1497
1498 #[test]
1499 fn test_pending_cancels_insert_remove() {
1500 let cancels = PendingCancels::new();
1501 assert!(cancels.is_empty());
1502
1503 cancels.insert(42);
1505 assert_eq!(cancels.len(), 1);
1506 assert!(!cancels.is_empty());
1507
1508 assert!(cancels.remove(42));
1510 assert!(cancels.is_empty());
1511
1512 assert!(!cancels.remove(42));
1514 assert!(!cancels.remove(999));
1515 }
1516
1517 #[test]
1518 fn test_pending_cancels_multiple() {
1519 let cancels = PendingCancels::new();
1520
1521 cancels.insert(1);
1522 cancels.insert(2);
1523 cancels.insert(3);
1524 assert_eq!(cancels.len(), 3);
1525
1526 assert!(cancels.remove(2));
1528 assert_eq!(cancels.len(), 2);
1529
1530 assert!(cancels.remove(1));
1532 assert!(cancels.remove(3));
1533 assert!(cancels.is_empty());
1534 }
1535
1536 #[test]
1537 fn test_pending_cancels_clear_stale() {
1538 use std::time::Duration;
1539
1540 let cancels = PendingCancels::new();
1541
1542 cancels.insert(1);
1543 cancels.insert(2);
1544
1545 let cleared = cancels.clear_stale(Duration::ZERO);
1547 assert_eq!(cleared, 2);
1548 assert!(cancels.is_empty());
1549
1550 cancels.insert(10);
1552 cancels.insert(20);
1553
1554 let cleared = cancels.clear_stale(Duration::from_secs(3600));
1556 assert_eq!(cleared, 0);
1557 assert_eq!(cancels.len(), 2);
1558 }
1559
1560 #[test]
1561 fn test_pending_cancels_duplicate_insert() {
1562 let cancels = PendingCancels::new();
1563
1564 cancels.insert(42);
1565 cancels.insert(42);
1566
1567 assert_eq!(cancels.len(), 1);
1569 assert!(cancels.remove(42));
1570 assert!(cancels.is_empty());
1571 }
1572
1573 #[test]
1574 fn test_build_stop_order() {
1575 let order = build_ib_order(
1576 rustrade_instrument::Side::Sell,
1577 100.0,
1578 &OrderKind::Stop {
1579 trigger_price: Decimal::from(45),
1580 },
1581 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1583 )
1584 .unwrap();
1585
1586 assert_eq!(order.action, Action::Sell);
1587 assert_eq!(order.total_quantity, 100.0);
1588 assert_eq!(order.order_type, "STP");
1589 assert_eq!(order.aux_price, Some(45.0));
1590 assert_eq!(order.limit_price, None);
1591 }
1592
1593 #[test]
1594 fn test_build_stop_limit_order() {
1595 let order = build_ib_order(
1596 rustrade_instrument::Side::Sell,
1597 100.0,
1598 &OrderKind::StopLimit {
1599 trigger_price: Decimal::from(44),
1600 },
1601 Some(Decimal::from(45)), &TimeInForce::GoodUntilCancelled { post_only: false },
1603 )
1604 .unwrap();
1605
1606 assert_eq!(order.action, Action::Sell);
1607 assert_eq!(order.total_quantity, 100.0);
1608 assert_eq!(order.order_type, "STP LMT");
1609 assert_eq!(order.aux_price, Some(44.0)); assert_eq!(order.limit_price, Some(45.0)); }
1612
1613 #[test]
1614 fn test_build_trailing_stop_percentage() {
1615 let order = build_ib_order(
1616 rustrade_instrument::Side::Sell,
1617 100.0,
1618 &OrderKind::TrailingStop {
1619 offset: Decimal::from(5),
1620 offset_type: TrailingOffsetType::Percentage,
1621 activation_price: None,
1622 },
1623 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1625 )
1626 .unwrap();
1627
1628 assert_eq!(order.action, Action::Sell);
1629 assert_eq!(order.total_quantity, 100.0);
1630 assert_eq!(order.order_type, "TRAIL");
1631 assert_eq!(order.trailing_percent, Some(5.0));
1632 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, None); }
1635
1636 #[test]
1637 fn test_build_trailing_stop_absolute() {
1638 let order = build_ib_order(
1639 rustrade_instrument::Side::Sell,
1640 100.0,
1641 &OrderKind::TrailingStop {
1642 offset: Decimal::from(2),
1643 offset_type: TrailingOffsetType::Absolute,
1644 activation_price: None,
1645 },
1646 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1648 )
1649 .unwrap();
1650
1651 assert_eq!(order.action, Action::Sell);
1652 assert_eq!(order.total_quantity, 100.0);
1653 assert_eq!(order.order_type, "TRAIL");
1654 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.trailing_percent, None);
1656 assert_eq!(order.trail_stop_price, None);
1657 }
1658
1659 #[test]
1660 fn test_build_trailing_stop_limit_absolute() {
1661 let order = build_ib_order(
1662 rustrade_instrument::Side::Sell,
1663 100.0,
1664 &OrderKind::TrailingStopLimit {
1665 offset: Decimal::from(2),
1666 offset_type: TrailingOffsetType::Absolute,
1667 stop_price: Decimal::from(95),
1668 limit_offset: Decimal::try_from(0.5).unwrap(),
1669 },
1670 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1672 )
1673 .unwrap();
1674
1675 assert_eq!(order.action, Action::Sell);
1676 assert_eq!(order.total_quantity, 100.0);
1677 assert_eq!(order.order_type, "TRAIL LIMIT");
1678 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.limit_price_offset, Some(0.5)); assert_eq!(order.trailing_percent, None);
1681 assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1684
1685 #[test]
1686 fn test_build_trailing_stop_limit_percentage() {
1687 let order = build_ib_order(
1688 rustrade_instrument::Side::Sell,
1689 100.0,
1690 &OrderKind::TrailingStopLimit {
1691 offset: Decimal::from(5),
1692 offset_type: TrailingOffsetType::Percentage,
1693 stop_price: Decimal::from(95),
1694 limit_offset: Decimal::try_from(0.5).unwrap(),
1695 },
1696 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1698 )
1699 .unwrap();
1700
1701 assert_eq!(order.action, Action::Sell);
1702 assert_eq!(order.total_quantity, 100.0);
1703 assert_eq!(order.order_type, "TRAIL LIMIT");
1704 assert_eq!(order.trailing_percent, Some(5.0));
1705 assert_eq!(order.limit_price_offset, Some(0.5));
1706 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1710
1711 #[test]
1713 fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1714 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1715 for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1716 let kind = OrderKind::TrailingStopLimit {
1717 offset: Decimal::from(2),
1718 offset_type,
1719 stop_price,
1720 limit_offset: Decimal::from(1),
1721 };
1722 assert_eq!(
1723 build_ib_order(
1724 rustrade_instrument::Side::Sell,
1725 1.0,
1726 &kind,
1727 None,
1728 &TimeInForce::GoodUntilCancelled { post_only: false },
1729 ),
1730 Err(OrderMappingError::NonPositiveStopPrice(kind)),
1731 "{kind}"
1732 );
1733 }
1734 }
1735 }
1736
1737 #[test]
1740 fn test_build_trailing_stop_limit_rejects_limit_price() {
1741 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1742 let kind = OrderKind::TrailingStopLimit {
1743 offset: Decimal::from(2),
1744 offset_type,
1745 stop_price: Decimal::from(95),
1746 limit_offset: Decimal::from(1),
1747 };
1748 assert_eq!(
1749 build_ib_order(
1750 rustrade_instrument::Side::Sell,
1751 1.0,
1752 &kind,
1753 Some(Decimal::from(94)),
1754 &TimeInForce::GoodUntilCancelled { post_only: false },
1755 ),
1756 Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1757 "{kind}"
1758 );
1759 }
1760 }
1761
1762 #[test]
1763 fn test_build_trailing_stop_basis_points_unsupported() {
1764 let result = build_ib_order(
1765 rustrade_instrument::Side::Sell,
1766 100.0,
1767 &OrderKind::TrailingStop {
1768 offset: Decimal::from(50), offset_type: TrailingOffsetType::BasisPoints,
1770 activation_price: None,
1771 },
1772 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1774 );
1775
1776 assert!(matches!(
1777 result,
1778 Err(OrderMappingError::UnsupportedOffsetType(
1779 TrailingOffsetType::BasisPoints
1780 ))
1781 ));
1782 }
1783
1784 #[test]
1785 fn test_build_trailing_stop_limit_basis_points_unsupported() {
1786 let result = build_ib_order(
1787 rustrade_instrument::Side::Sell,
1788 100.0,
1789 &OrderKind::TrailingStopLimit {
1790 offset: Decimal::from(50),
1791 offset_type: TrailingOffsetType::BasisPoints,
1792 stop_price: Decimal::from(95),
1793 limit_offset: Decimal::try_from(0.5).unwrap(),
1794 },
1795 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1797 );
1798
1799 assert!(matches!(
1800 result,
1801 Err(OrderMappingError::UnsupportedOffsetType(
1802 TrailingOffsetType::BasisPoints
1803 ))
1804 ));
1805 }
1806
1807 #[test]
1809 fn test_build_activated_and_fixed_limit_trailing_stops_unsupported() {
1810 let tif = TimeInForce::GoodUntilCancelled { post_only: false };
1811 for kind in [
1812 OrderKind::TrailingStop {
1813 offset: Decimal::from(2),
1814 offset_type: TrailingOffsetType::Percentage,
1815 activation_price: Some(Decimal::from(150)),
1816 },
1817 OrderKind::TrailingStopFixedLimit {
1818 offset: Decimal::from(2),
1819 offset_type: TrailingOffsetType::Percentage,
1820 activation_price: None,
1821 },
1822 ] {
1823 let result = build_ib_order(
1824 rustrade_instrument::Side::Sell,
1825 100.0,
1826 &kind,
1827 Some(Decimal::from(140)),
1828 &tif,
1829 );
1830 assert!(
1831 matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
1832 "{kind}: {result:?}"
1833 );
1834 }
1835 }
1836
1837 #[test]
1838 fn test_build_take_profit_unsupported() {
1839 let result = build_ib_order(
1840 rustrade_instrument::Side::Sell,
1841 100.0,
1842 &OrderKind::TakeProfit {
1843 trigger_price: Decimal::from(150),
1844 },
1845 None,
1846 &TimeInForce::GoodUntilCancelled { post_only: false },
1847 );
1848
1849 assert!(matches!(
1850 result,
1851 Err(OrderMappingError::UnsupportedOrderKind(
1852 OrderKind::TakeProfit { .. }
1853 ))
1854 ));
1855 }
1856
1857 #[test]
1858 fn test_build_take_profit_limit_unsupported() {
1859 let result = build_ib_order(
1860 rustrade_instrument::Side::Sell,
1861 100.0,
1862 &OrderKind::TakeProfitLimit {
1863 trigger_price: Decimal::from(150),
1864 },
1865 None,
1866 &TimeInForce::GoodUntilCancelled { post_only: false },
1867 );
1868
1869 assert!(matches!(
1870 result,
1871 Err(OrderMappingError::UnsupportedOrderKind(
1872 OrderKind::TakeProfitLimit { .. }
1873 ))
1874 ));
1875 }
1876
1877 #[test]
1882 fn test_build_market_order_at_open() {
1883 let order = build_ib_order(
1884 rustrade_instrument::Side::Buy,
1885 100.0,
1886 &OrderKind::Market,
1887 None, &TimeInForce::AtOpen,
1889 )
1890 .unwrap();
1891
1892 assert_eq!(order.action, Action::Buy);
1893 assert_eq!(order.total_quantity, 100.0);
1894 assert_eq!(order.order_type, "MKT");
1895 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1896 }
1897
1898 #[test]
1899 fn test_build_limit_order_at_open() {
1900 let order = build_ib_order(
1901 rustrade_instrument::Side::Sell,
1902 50.0,
1903 &OrderKind::Limit,
1904 Some(Decimal::from(150)),
1905 &TimeInForce::AtOpen,
1906 )
1907 .unwrap();
1908
1909 assert_eq!(order.action, Action::Sell);
1910 assert_eq!(order.total_quantity, 50.0);
1911 assert_eq!(order.order_type, "LMT");
1912 assert_eq!(order.limit_price, Some(150.0));
1913 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1914 }
1915
1916 #[test]
1917 fn test_build_stop_order_at_open() {
1918 let order = build_ib_order(
1920 rustrade_instrument::Side::Sell,
1921 100.0,
1922 &OrderKind::Stop {
1923 trigger_price: Decimal::from(45),
1924 },
1925 None, &TimeInForce::AtOpen,
1927 )
1928 .unwrap();
1929
1930 assert_eq!(order.order_type, "STP");
1931 assert_eq!(order.aux_price, Some(45.0));
1932 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1933 }
1934
1935 #[test]
1936 fn test_build_market_on_close() {
1937 let order = build_ib_order(
1938 rustrade_instrument::Side::Buy,
1939 100.0,
1940 &OrderKind::Market,
1941 None, &TimeInForce::AtClose,
1943 )
1944 .unwrap();
1945
1946 assert_eq!(order.action, Action::Buy);
1947 assert_eq!(order.total_quantity, 100.0);
1948 assert_eq!(order.order_type, "MOC");
1949 }
1950
1951 #[test]
1952 fn test_build_limit_on_close() {
1953 let order = build_ib_order(
1954 rustrade_instrument::Side::Sell,
1955 50.0,
1956 &OrderKind::Limit,
1957 Some(Decimal::from(150)),
1958 &TimeInForce::AtClose,
1959 )
1960 .unwrap();
1961
1962 assert_eq!(order.action, Action::Sell);
1963 assert_eq!(order.total_quantity, 50.0);
1964 assert_eq!(order.order_type, "LOC");
1965 assert_eq!(order.limit_price, Some(150.0));
1966 }
1967
1968 #[test]
1969 fn test_build_stop_at_close_unsupported() {
1970 let result = build_ib_order(
1971 rustrade_instrument::Side::Sell,
1972 100.0,
1973 &OrderKind::Stop {
1974 trigger_price: Decimal::from(45),
1975 },
1976 None, &TimeInForce::AtClose,
1978 );
1979
1980 assert!(matches!(
1981 result,
1982 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1983 OrderKind::Stop { .. }
1984 ))
1985 ));
1986 }
1987
1988 #[test]
1989 fn test_build_stop_limit_at_close_unsupported() {
1990 let result = build_ib_order(
1991 rustrade_instrument::Side::Sell,
1992 100.0,
1993 &OrderKind::StopLimit {
1994 trigger_price: Decimal::from(44),
1995 },
1996 Some(Decimal::from(45)),
1997 &TimeInForce::AtClose,
1998 );
1999
2000 assert!(matches!(
2001 result,
2002 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2003 OrderKind::StopLimit { .. }
2004 ))
2005 ));
2006 }
2007
2008 #[test]
2009 fn test_build_trailing_stop_at_close_unsupported() {
2010 let result = build_ib_order(
2011 rustrade_instrument::Side::Sell,
2012 100.0,
2013 &OrderKind::TrailingStop {
2014 offset: Decimal::from(5),
2015 offset_type: TrailingOffsetType::Percentage,
2016 activation_price: None,
2017 },
2018 None, &TimeInForce::AtClose,
2020 );
2021
2022 assert!(matches!(
2023 result,
2024 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2025 OrderKind::TrailingStop { .. }
2026 ))
2027 ));
2028 }
2029
2030 #[test]
2031 fn test_build_trailing_stop_limit_at_close_unsupported() {
2032 let result = build_ib_order(
2033 rustrade_instrument::Side::Sell,
2034 100.0,
2035 &OrderKind::TrailingStopLimit {
2036 offset: Decimal::from(5),
2037 offset_type: TrailingOffsetType::Absolute,
2038 stop_price: Decimal::from(95),
2039 limit_offset: Decimal::from(1),
2040 },
2041 None, &TimeInForce::AtClose,
2043 );
2044
2045 assert!(matches!(
2046 result,
2047 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2048 OrderKind::TrailingStopLimit { .. }
2049 ))
2050 ));
2051 }
2052
2053 #[test]
2054 fn test_build_good_till_date_order() {
2055 use chrono::{TimeZone, Utc};
2056
2057 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2058 let order = build_ib_order(
2059 rustrade_instrument::Side::Buy,
2060 100.0,
2061 &OrderKind::Limit,
2062 Some(Decimal::from(150)),
2063 &TimeInForce::GoodTillDate { expiry },
2064 )
2065 .unwrap();
2066
2067 assert_eq!(order.action, Action::Buy);
2068 assert_eq!(order.total_quantity, 100.0);
2069 assert_eq!(order.order_type, "LMT");
2070 assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2071 assert_eq!(order.good_till_date, "20250630-23:59:59");
2072 }
2073
2074 #[test]
2075 fn test_format_gtd_datetime() {
2076 use chrono::{TimeZone, Utc};
2077
2078 let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2079 assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2080
2081 let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2082 assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2083 }
2084
2085 #[test]
2090 fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2091 let orders = build_ib_bracket_with_oca(
2092 1000,
2093 Action::Buy,
2094 10.0,
2095 150.0,
2096 160.0,
2097 140.0,
2098 IbTimeInForce::Day,
2099 )
2100 .unwrap();
2101
2102 assert_eq!(orders.len(), 3);
2103
2104 assert!(!orders[1].oca_group.is_empty());
2106 assert_eq!(orders[1].oca_group, orders[2].oca_group);
2107
2108 assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2110 assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2111
2112 assert!(orders[0].oca_group.is_empty());
2114 assert_eq!(orders[0].oca_type, OcaType::None);
2115 }
2116
2117 #[test]
2118 fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2119 let orders = build_ib_bracket_with_oca(
2120 1000,
2121 Action::Buy,
2122 10.0,
2123 150.0,
2124 160.0,
2125 140.0,
2126 IbTimeInForce::Day,
2127 )
2128 .unwrap();
2129
2130 assert_eq!(orders[1].parent_id, 1000); assert_eq!(orders[2].parent_id, 1000); assert_eq!(orders[0].parent_id, 0);
2136 }
2137
2138 #[test]
2139 fn test_build_ib_bracket_with_oca_transmit_flags() {
2140 let orders = build_ib_bracket_with_oca(
2141 1000,
2142 Action::Buy,
2143 10.0,
2144 150.0,
2145 160.0,
2146 140.0,
2147 IbTimeInForce::Day,
2148 )
2149 .unwrap();
2150
2151 assert!(!orders[0].transmit); assert!(!orders[1].transmit); assert!(orders[2].transmit); }
2156
2157 #[test]
2158 fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2159 let orders = build_ib_bracket_with_oca(
2160 500,
2161 Action::Sell,
2162 5.0,
2163 100.0,
2164 90.0,
2165 110.0,
2166 IbTimeInForce::Day,
2167 )
2168 .unwrap();
2169
2170 assert_eq!(orders[0].order_id, 500); assert_eq!(orders[1].order_id, 501); assert_eq!(orders[2].order_id, 502); }
2174
2175 #[test]
2176 fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2177 let result =
2179 build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2180 assert!(matches!(
2181 result,
2182 Err(OrderMappingError::InvalidBracketPrices(_))
2183 ));
2184
2185 let result =
2187 build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2188 assert!(matches!(
2189 result,
2190 Err(OrderMappingError::InvalidBracketPrices(_))
2191 ));
2192 }
2193
2194 #[test]
2195 fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2196 let orders =
2197 build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2198 .unwrap();
2199
2200 assert!(orders[1].oca_group.contains("42"));
2201 assert!(orders[2].oca_group.contains("42"));
2202 }
2203
2204 #[test]
2205 fn test_build_ib_bracket_with_oca_order_types() {
2206 let orders = build_ib_bracket_with_oca(
2207 1000,
2208 Action::Buy,
2209 100.0,
2210 150.0,
2211 160.0,
2212 140.0,
2213 IbTimeInForce::Day,
2214 )
2215 .unwrap();
2216
2217 assert_eq!(orders[0].order_type, "LMT");
2219 assert_eq!(orders[0].limit_price, Some(150.0));
2220
2221 assert_eq!(orders[1].order_type, "LMT");
2223 assert_eq!(orders[1].limit_price, Some(160.0));
2224
2225 assert_eq!(orders[2].order_type, "STP");
2227 assert_eq!(orders[2].aux_price, Some(140.0));
2228 }
2229
2230 #[test]
2231 fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2232 let buy_orders =
2234 build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2235 .unwrap();
2236 assert_eq!(buy_orders[0].action, Action::Buy);
2237 assert_eq!(buy_orders[1].action, Action::Sell);
2238 assert_eq!(buy_orders[2].action, Action::Sell);
2239
2240 let sell_orders = build_ib_bracket_with_oca(
2242 200,
2243 Action::Sell,
2244 10.0,
2245 50.0,
2246 45.0,
2247 55.0,
2248 IbTimeInForce::Day,
2249 )
2250 .unwrap();
2251 assert_eq!(sell_orders[0].action, Action::Sell);
2252 assert_eq!(sell_orders[1].action, Action::Buy);
2253 assert_eq!(sell_orders[2].action, Action::Buy);
2254 }
2255
2256 #[test]
2257 fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2258 let orders = build_ib_bracket_with_oca(
2259 1000,
2260 Action::Buy,
2261 10.0,
2262 150.0,
2263 160.0,
2264 140.0,
2265 IbTimeInForce::GoodTillCanceled,
2266 )
2267 .unwrap();
2268
2269 assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2270 assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2271 assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2272 }
2273
2274 #[test]
2277 fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2278 let longest = "a".repeat(MAX_ORDER_REF_LEN);
2279 assert_eq!(
2280 order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2281 longest
2282 );
2283 assert_eq!(
2284 order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2285 "cid-1_tp"
2286 );
2287
2288 let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2289 for refused in ["", too_long.as_str(), "ordre-é"] {
2290 let cid = ClientOrderId::new(refused);
2291 assert_eq!(
2292 order_ref(&cid),
2293 Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2294 );
2295 }
2296 }
2297
2298 #[test]
2301 fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2302 use chrono::TimeZone;
2303 use rust_decimal_macros::dec;
2304
2305 let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2306 let kinds = [
2307 (OrderKind::Market, None),
2308 (OrderKind::Limit, Some(dec!(150.25))),
2309 (
2310 OrderKind::Stop {
2311 trigger_price: dec!(140.5),
2312 },
2313 None,
2314 ),
2315 (
2316 OrderKind::StopLimit {
2317 trigger_price: dec!(140.5),
2318 },
2319 Some(dec!(140)),
2320 ),
2321 (
2322 OrderKind::TrailingStop {
2323 offset: dec!(2.5),
2324 offset_type: TrailingOffsetType::Percentage,
2325 activation_price: None,
2326 },
2327 None,
2328 ),
2329 (
2330 OrderKind::TrailingStop {
2331 offset: dec!(1.75),
2332 offset_type: TrailingOffsetType::Absolute,
2333 activation_price: None,
2334 },
2335 None,
2336 ),
2337 (
2338 OrderKind::TrailingStopLimit {
2339 offset: dec!(2),
2340 offset_type: TrailingOffsetType::Percentage,
2341 stop_price: dec!(130.5),
2342 limit_offset: dec!(0.5),
2343 },
2344 None,
2345 ),
2346 (
2347 OrderKind::TrailingStopLimit {
2348 offset: dec!(1.5),
2349 offset_type: TrailingOffsetType::Absolute,
2350 stop_price: dec!(139.75),
2351 limit_offset: dec!(0.25),
2352 },
2353 None,
2354 ),
2355 ];
2356 let tifs = [
2357 TimeInForce::GoodUntilCancelled { post_only: false },
2358 TimeInForce::GoodUntilEndOfDay,
2359 TimeInForce::ImmediateOrCancel,
2360 TimeInForce::FillOrKill,
2361 TimeInForce::AtOpen,
2362 TimeInForce::GoodTillDate { expiry },
2363 ];
2364
2365 for (kind, price) in kinds {
2366 for time_in_force in tifs {
2367 let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2368 assert_eq!(
2369 order_shape_from_ib(&order),
2370 Ok(OrderShape {
2371 kind,
2372 price,
2373 time_in_force
2374 }),
2375 "{kind} {time_in_force}"
2376 );
2377 }
2378 }
2379
2380 for (kind, price) in [
2382 (OrderKind::Market, None),
2383 (OrderKind::Limit, Some(dec!(99))),
2384 ] {
2385 let order =
2386 build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2387 assert_eq!(
2388 order_shape_from_ib(&order),
2389 Ok(OrderShape {
2390 kind,
2391 price,
2392 time_in_force: TimeInForce::AtClose
2393 }),
2394 "{kind}"
2395 );
2396 }
2397 }
2398
2399 #[test]
2400 fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2401 use chrono::TimeZone;
2402
2403 let order = Order {
2404 order_type: "MKT".to_owned(),
2405 tif: IbTimeInForce::GoodTillDate,
2406 good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2407 ..Order::default()
2408 };
2409 assert_eq!(
2410 order_shape_from_ib(&order).unwrap().time_in_force,
2411 TimeInForce::GoodTillDate {
2412 expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2413 }
2414 );
2415 }
2416
2417 #[test]
2418 fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2419 let order = |order_type: &str, tif: IbTimeInForce| Order {
2420 order_type: order_type.to_owned(),
2421 tif,
2422 limit_price: Some(1.0),
2423 ..Order::default()
2424 };
2425 for (order, reason) in [
2426 (order("REL", IbTimeInForce::Day), "order type"),
2427 (
2428 order("LMT", IbTimeInForce::DayTillCanceled),
2429 "time in force",
2430 ),
2431 (
2432 Order {
2433 limit_price: None,
2434 ..order("LMT", IbTimeInForce::Day)
2435 },
2436 "without limit_price",
2437 ),
2438 (order("STP", IbTimeInForce::Day), "without aux_price"),
2439 (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2440 (
2441 Order {
2442 aux_price: Some(2.0),
2443 limit_price_offset: Some(0.5),
2444 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2445 },
2446 "without trail_stop_price",
2447 ),
2448 (
2449 Order {
2450 aux_price: Some(2.0),
2451 trail_stop_price: Some(0.0),
2452 limit_price_offset: Some(0.5),
2453 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2454 },
2455 "not positive",
2456 ),
2457 (
2458 Order {
2459 trailing_percent: Some(5.0),
2460 trail_stop_price: Some(-1.0),
2461 limit_price_offset: Some(0.5),
2462 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2463 },
2464 "not positive",
2465 ),
2466 ] {
2467 let error = order_shape_from_ib(&order).unwrap_err();
2468 assert!(error.contains(reason), "{error:?} should name {reason:?}");
2469 }
2470 }
2471
2472 #[test]
2473 fn action_to_side_reads_a_short_sale_as_a_sell() {
2474 assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2475 for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2476 assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2477 }
2478 }
2479}