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 { .. }
944 | OrderKind::TakeProfitLimit { .. }
945 | OrderKind::TrailingStop {
946 activation_price: Some(_),
947 ..
948 }
949 | OrderKind::TrailingStopFixedLimit { .. }
950 | OrderKind::ConditionalUnresolved { .. } => {
951 return Err(OrderMappingError::UnsupportedOrderKind(*kind));
952 }
953 };
954
955 order.tif = tif_ib;
956
957 if let TimeInForce::GoodTillDate { expiry } = tif {
959 order.good_till_date = format_gtd_datetime(expiry);
960 }
961
962 Ok(order)
963}
964
965fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
970 dt.format(GTD_FORMAT).to_string()
971}
972
973const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
975
976fn build_at_close_order(
981 action: Action,
982 quantity: f64,
983 kind: &OrderKind,
984 price: Option<rust_decimal::Decimal>,
985) -> Result<Order, OrderMappingError> {
986 match kind {
987 OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
988 OrderKind::Limit => {
989 let price_f64 = require_limit_price(price, *kind)?;
990 Ok(order_builder::limit_on_close(action, quantity, price_f64))
991 }
992 _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
994 }
995}
996
997pub fn build_ib_bracket_with_oca(
1030 parent_order_id: i32,
1031 action: Action,
1032 quantity: f64,
1033 limit_price: f64,
1034 take_profit_price: f64,
1035 stop_loss_price: f64,
1036 tif: IbTimeInForce,
1037) -> Result<Vec<Order>, OrderMappingError> {
1038 let prices = BracketPrices {
1039 entry: limit_price,
1040 take_profit: take_profit_price,
1041 stop_loss: stop_loss_price,
1042 };
1043 let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1044 .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1045 assert_eq!(
1046 orders.len(),
1047 3,
1048 "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1049 );
1050
1051 orders[0].tif = tif.clone();
1054 orders[1].tif = tif.clone();
1055 orders[2].tif = tif;
1056
1057 let oca_group = format!("bracket_{}", parent_order_id);
1060 orders[1].oca_group = oca_group.clone();
1061 orders[1].oca_type = OcaType::CancelWithBlock;
1062 orders[2].oca_group = oca_group;
1063 orders[2].oca_type = OcaType::CancelWithBlock;
1064
1065 Ok(orders)
1066}
1067
1068#[cfg(test)]
1069#[allow(clippy::unwrap_used)] mod tests {
1071 use super::*;
1072 use rust_decimal::Decimal;
1073
1074 fn test_context() -> OrderContext {
1075 OrderContext {
1076 instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1077 side: Side::Buy,
1078 price: Some(Decimal::from(150)),
1079 quantity: Decimal::from(100),
1080 kind: OrderKind::Limit,
1081 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1082 }
1083 }
1084
1085 #[test]
1086 fn test_order_id_map_basic() {
1087 let map = OrderIdMap::new();
1088 let cid = ClientOrderId::new("order-123");
1089 let ctx = test_context();
1090
1091 map.register(cid.clone(), 42, ctx.clone()).unwrap();
1092
1093 assert_eq!(map.get_ib_id(&cid), Some(42));
1094 assert_eq!(map.get_client_id(42), Some(cid.clone()));
1095 assert_eq!(map.len(), 1);
1096
1097 let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1098 assert_eq!(retrieved_cid, cid);
1099 assert_eq!(retrieved_ctx.side, Side::Buy);
1100 assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1101 }
1102
1103 #[test]
1104 fn test_order_id_map_remove() {
1105 let map = OrderIdMap::new();
1106 let cid = ClientOrderId::new("order-456");
1107
1108 map.register(cid.clone(), 100, test_context()).unwrap();
1109 assert_eq!(map.len(), 1);
1110
1111 let removed = map.remove_by_ib_id(100);
1112 assert_eq!(removed, Some(cid.clone()));
1113 assert!(map.is_empty());
1114 assert!(map.get_ib_id(&cid).is_none());
1115 assert!(map.get_client_id(100).is_none());
1116 assert!(map.get_client_id_and_context(100).is_none());
1117 }
1118
1119 #[test]
1120 fn test_side_conversion() {
1121 assert!(matches!(
1122 side_to_action(rustrade_instrument::Side::Buy),
1123 Action::Buy
1124 ));
1125 assert!(matches!(
1126 side_to_action(rustrade_instrument::Side::Sell),
1127 Action::Sell
1128 ));
1129 }
1130
1131 #[test]
1132 fn test_time_in_force_conversion() {
1133 assert_eq!(
1134 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1135 Ok(IbTimeInForce::GoodTillCanceled)
1136 );
1137
1138 assert!(matches!(
1139 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1140 Err(OrderMappingError::PostOnlyNotSupported)
1141 ));
1142
1143 assert_eq!(
1144 time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1145 Ok(IbTimeInForce::Day)
1146 );
1147 assert_eq!(
1148 time_in_force_to_ib(&TimeInForce::FillOrKill),
1149 Ok(IbTimeInForce::FillOrKill)
1150 );
1151 assert_eq!(
1152 time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1153 Ok(IbTimeInForce::ImmediateOrCancel)
1154 );
1155 }
1156
1157 #[test]
1158 fn test_time_in_force_conversion_good_till_date() {
1159 use chrono::{TimeZone, Utc};
1160
1161 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1162 assert_eq!(
1163 time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1164 Ok(IbTimeInForce::GoodTillDate)
1165 );
1166 }
1167
1168 #[test]
1169 fn test_time_in_force_conversion_at_open() {
1170 assert_eq!(
1171 time_in_force_to_ib(&TimeInForce::AtOpen),
1172 Ok(IbTimeInForce::OnOpen)
1173 );
1174 }
1175
1176 #[test]
1177 fn test_time_in_force_conversion_at_close_returns_err() {
1178 assert_eq!(
1182 time_in_force_to_ib(&TimeInForce::AtClose),
1183 Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1184 );
1185 }
1186
1187 #[test]
1188 fn test_build_market_order() {
1189 let order = build_ib_order(
1190 rustrade_instrument::Side::Buy,
1191 100.0,
1192 &OrderKind::Market,
1193 None, &TimeInForce::GoodUntilEndOfDay,
1195 )
1196 .unwrap();
1197
1198 assert_eq!(order.action, Action::Buy);
1199 assert_eq!(order.total_quantity, 100.0);
1200 assert_eq!(order.order_type, "MKT");
1201 }
1202
1203 #[test]
1204 fn test_build_limit_order() {
1205 let order = build_ib_order(
1206 rustrade_instrument::Side::Sell,
1207 50.0,
1208 &OrderKind::Limit,
1209 Some(Decimal::try_from(150.5).unwrap()),
1210 &TimeInForce::GoodUntilCancelled { post_only: false },
1211 )
1212 .unwrap();
1213
1214 assert_eq!(order.action, Action::Sell);
1215 assert_eq!(order.total_quantity, 50.0);
1216 assert_eq!(order.order_type, "LMT");
1217 }
1218
1219 #[test]
1220 fn test_order_id_map_remove_and_get_context() {
1221 let map = OrderIdMap::new();
1222 let cid = ClientOrderId::new("order-789");
1223 let ctx = test_context();
1224
1225 map.register(cid.clone(), 50, ctx).unwrap();
1226 assert_eq!(map.len(), 1);
1227
1228 let result = map.remove_and_get_context(50);
1230 assert!(result.is_some());
1231 let (retrieved_cid, retrieved_ctx) = result.unwrap();
1232 assert_eq!(retrieved_cid, cid);
1233 assert_eq!(retrieved_ctx.side, Side::Buy);
1234
1235 assert!(map.is_empty());
1237 assert!(map.get_client_id(50).is_none());
1238 assert!(map.get_ib_id(&cid).is_none());
1239
1240 assert!(map.remove_and_get_context(50).is_none());
1242 }
1243
1244 #[test]
1245 fn test_order_id_map_clear_stale() {
1246 use std::time::Duration;
1247
1248 let map = OrderIdMap::new();
1249
1250 map.register(ClientOrderId::new("old-1"), 1, test_context())
1252 .unwrap();
1253 map.register(ClientOrderId::new("old-2"), 2, test_context())
1254 .unwrap();
1255
1256 let cleared = map.clear_stale(Duration::ZERO);
1258 assert_eq!(cleared, 2);
1259 assert!(map.is_empty());
1260
1261 map.register(ClientOrderId::new("new-1"), 10, test_context())
1263 .unwrap();
1264 map.register(ClientOrderId::new("new-2"), 20, test_context())
1265 .unwrap();
1266
1267 let cleared = map.clear_stale(Duration::from_secs(3600));
1269 assert_eq!(cleared, 0);
1270 assert_eq!(map.len(), 2);
1271 }
1272
1273 #[test]
1275 fn a_live_orders_id_is_refused_to_a_second_order() {
1276 let map = OrderIdMap::new();
1277 let cid = ClientOrderId::new("taken");
1278 map.register(cid.clone(), 1, test_context()).unwrap();
1279
1280 assert_eq!(
1281 map.register(cid.clone(), 2, test_context()),
1282 Err(ClientOrderIdInUse(cid.clone()))
1283 );
1284 assert_eq!(
1285 map.get_ib_id(&cid),
1286 Some(1),
1287 "a cancel by id still reaches the first"
1288 );
1289 assert!(
1290 map.get_client_id(2).is_none(),
1291 "nothing of the second was registered"
1292 );
1293 }
1294
1295 #[test]
1297 fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1298 let map = OrderIdMap::new();
1299 let cid = ClientOrderId::new("reused");
1300 map.register(cid.clone(), 1, test_context()).unwrap();
1301
1302 assert_eq!(
1303 map.release_client_id(1).map(|(id, _)| id),
1304 Some(cid.clone())
1305 );
1306 assert!(map.is_empty(), "no order is live");
1307 assert_eq!(
1308 map.get_client_id(1),
1309 Some(cid.clone()),
1310 "a late execution resolves"
1311 );
1312
1313 map.register(cid.clone(), 2, test_context()).unwrap();
1315 assert_eq!(map.get_ib_id(&cid), Some(2));
1316 assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1317 }
1318
1319 #[test]
1322 fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1323 let map = OrderIdMap::new();
1324 let cid = ClientOrderId::new("reused");
1325 map.register(cid.clone(), 1, test_context()).unwrap();
1326 let _ = map.release_client_id(1);
1327 map.register(cid.clone(), 2, test_context()).unwrap();
1328
1329 assert!(
1330 map.remove_and_get_context(1).is_some(),
1331 "the first order's entry goes"
1332 );
1333 assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1334 assert!(map.remove_by_ib_id(1).is_none());
1335 assert_eq!(map.get_ib_id(&cid), Some(2));
1336 }
1337
1338 #[test]
1340 fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1341 use std::time::Duration;
1342
1343 let map = OrderIdMap::new();
1344 let cid = ClientOrderId::new("reused");
1345 map.register(cid.clone(), 1, test_context()).unwrap();
1346 let _ = map.release_client_id(1);
1347 std::thread::sleep(Duration::from_millis(5));
1348 map.register(cid.clone(), 2, test_context()).unwrap();
1349
1350 assert_eq!(
1351 map.clear_stale(Duration::from_millis(5)),
1352 1,
1353 "only the filled one"
1354 );
1355 assert_eq!(map.get_ib_id(&cid), Some(2));
1356 }
1357
1358 #[test]
1361 fn register_all_registers_every_order_or_none() {
1362 let map = OrderIdMap::new();
1363 map.register(ClientOrderId::new("live"), 1, test_context())
1364 .unwrap();
1365
1366 let clash = map.register_all([
1367 (ClientOrderId::new("a"), 10, test_context()),
1368 (ClientOrderId::new("live"), 11, test_context()),
1369 ]);
1370 assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1371
1372 let repeated = map.register_all([
1373 (ClientOrderId::new("b"), 20, test_context()),
1374 (ClientOrderId::new("b"), 21, test_context()),
1375 ]);
1376 assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1377
1378 assert_eq!(map.len(), 1, "neither registered anything");
1379 assert!(
1380 [10, 11, 20, 21]
1381 .iter()
1382 .all(|ib_id| map.get_client_id(*ib_id).is_none())
1383 );
1384
1385 map.register_all([
1386 (ClientOrderId::new("a"), 10, test_context()),
1387 (ClientOrderId::new("b"), 20, test_context()),
1388 ])
1389 .unwrap();
1390 assert_eq!(map.len(), 3);
1391 }
1392
1393 #[test]
1395 fn an_adopted_order_is_tracked_and_marked_adopted() {
1396 let map = OrderIdMap::new();
1397 let cid = ClientOrderId::new("listed");
1398
1399 map.adopt(cid.clone(), 7, test_context()).unwrap();
1400
1401 assert_eq!(map.get_ib_id(&cid), Some(7));
1402 assert_eq!(map.get_client_id(7), Some(cid.clone()));
1403 assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1404 }
1405
1406 #[test]
1409 fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1410 let map = OrderIdMap::new();
1411 let placed = ClientOrderId::new("placed");
1412 let adopted = ClientOrderId::new("adopted");
1413 let filled = ClientOrderId::new("filled");
1414 map.register(placed.clone(), 1, test_context()).unwrap();
1415 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1416 map.register(filled.clone(), 3, test_context()).unwrap();
1417 let _ = map.release_client_id(3);
1418
1419 for ib_id in [1, 2, 3] {
1420 let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1421 assert_eq!(
1422 map.adopt(other.clone(), ib_id, test_context()),
1423 Err(AdoptionRefused::IbOrderIdTracked)
1424 );
1425 assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1426 }
1427
1428 assert_eq!(map.get_client_id(1), Some(placed.clone()));
1429 assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1430 assert_eq!(map.get_client_id(3), Some(filled.clone()));
1431 assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1432 assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1433 assert!(
1434 map.registration(&filled).is_none(),
1435 "the filled order's id stays free"
1436 );
1437 assert_eq!(map.len(), 2);
1438 }
1439
1440 #[test]
1443 fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1444 let map = OrderIdMap::new();
1445 let placed = ClientOrderId::new("placed");
1446 let adopted = ClientOrderId::new("adopted");
1447 map.register(placed.clone(), 1, test_context()).unwrap();
1448 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1449 let before = [
1450 map.registration(&placed).map(|r| r.adopted),
1451 map.registration(&adopted).map(|r| r.adopted),
1452 ];
1453
1454 for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1455 assert_eq!(
1456 map.adopt(cid.clone(), ib_id, test_context()),
1457 Err(AdoptionRefused::ClientOrderIdInUse)
1458 );
1459 assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1460 }
1461
1462 assert_eq!(map.get_ib_id(&placed), Some(1));
1463 assert_eq!(map.get_ib_id(&adopted), Some(2));
1464 assert_eq!(
1465 [
1466 map.registration(&placed).map(|r| r.adopted),
1467 map.registration(&adopted).map(|r| r.adopted),
1468 ],
1469 before
1470 );
1471 assert_eq!(map.len(), 2);
1472 }
1473
1474 #[test]
1477 fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1478 let map = OrderIdMap::new();
1479 let cancelled = ClientOrderId::new("cancelled");
1480 let filled = ClientOrderId::new("filled");
1481 map.register(cancelled.clone(), 1, test_context()).unwrap();
1482 map.register(filled.clone(), 2, test_context()).unwrap();
1483 let _ = map.remove_and_get_context(1);
1484 let _ = map.release_client_id(2);
1485
1486 map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1487 map.adopt(filled.clone(), 12, test_context()).unwrap();
1488
1489 assert_eq!(map.get_ib_id(&cancelled), Some(11));
1490 assert_eq!(map.get_ib_id(&filled), Some(12));
1491 assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1492 assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1493 assert_eq!(
1494 map.get_client_id(2),
1495 Some(filled),
1496 "the filled order's executions resolve"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_pending_cancels_insert_remove() {
1502 let cancels = PendingCancels::new();
1503 assert!(cancels.is_empty());
1504
1505 cancels.insert(42);
1507 assert_eq!(cancels.len(), 1);
1508 assert!(!cancels.is_empty());
1509
1510 assert!(cancels.remove(42));
1512 assert!(cancels.is_empty());
1513
1514 assert!(!cancels.remove(42));
1516 assert!(!cancels.remove(999));
1517 }
1518
1519 #[test]
1520 fn test_pending_cancels_multiple() {
1521 let cancels = PendingCancels::new();
1522
1523 cancels.insert(1);
1524 cancels.insert(2);
1525 cancels.insert(3);
1526 assert_eq!(cancels.len(), 3);
1527
1528 assert!(cancels.remove(2));
1530 assert_eq!(cancels.len(), 2);
1531
1532 assert!(cancels.remove(1));
1534 assert!(cancels.remove(3));
1535 assert!(cancels.is_empty());
1536 }
1537
1538 #[test]
1539 fn test_pending_cancels_clear_stale() {
1540 use std::time::Duration;
1541
1542 let cancels = PendingCancels::new();
1543
1544 cancels.insert(1);
1545 cancels.insert(2);
1546
1547 let cleared = cancels.clear_stale(Duration::ZERO);
1549 assert_eq!(cleared, 2);
1550 assert!(cancels.is_empty());
1551
1552 cancels.insert(10);
1554 cancels.insert(20);
1555
1556 let cleared = cancels.clear_stale(Duration::from_secs(3600));
1558 assert_eq!(cleared, 0);
1559 assert_eq!(cancels.len(), 2);
1560 }
1561
1562 #[test]
1563 fn test_pending_cancels_duplicate_insert() {
1564 let cancels = PendingCancels::new();
1565
1566 cancels.insert(42);
1567 cancels.insert(42);
1568
1569 assert_eq!(cancels.len(), 1);
1571 assert!(cancels.remove(42));
1572 assert!(cancels.is_empty());
1573 }
1574
1575 #[test]
1576 fn test_build_stop_order() {
1577 let order = build_ib_order(
1578 rustrade_instrument::Side::Sell,
1579 100.0,
1580 &OrderKind::Stop {
1581 trigger_price: Decimal::from(45),
1582 },
1583 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1585 )
1586 .unwrap();
1587
1588 assert_eq!(order.action, Action::Sell);
1589 assert_eq!(order.total_quantity, 100.0);
1590 assert_eq!(order.order_type, "STP");
1591 assert_eq!(order.aux_price, Some(45.0));
1592 assert_eq!(order.limit_price, None);
1593 }
1594
1595 #[test]
1596 fn test_build_stop_limit_order() {
1597 let order = build_ib_order(
1598 rustrade_instrument::Side::Sell,
1599 100.0,
1600 &OrderKind::StopLimit {
1601 trigger_price: Decimal::from(44),
1602 },
1603 Some(Decimal::from(45)), &TimeInForce::GoodUntilCancelled { post_only: false },
1605 )
1606 .unwrap();
1607
1608 assert_eq!(order.action, Action::Sell);
1609 assert_eq!(order.total_quantity, 100.0);
1610 assert_eq!(order.order_type, "STP LMT");
1611 assert_eq!(order.aux_price, Some(44.0)); assert_eq!(order.limit_price, Some(45.0)); }
1614
1615 #[test]
1616 fn test_build_trailing_stop_percentage() {
1617 let order = build_ib_order(
1618 rustrade_instrument::Side::Sell,
1619 100.0,
1620 &OrderKind::TrailingStop {
1621 offset: Decimal::from(5),
1622 offset_type: TrailingOffsetType::Percentage,
1623 activation_price: None,
1624 },
1625 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1627 )
1628 .unwrap();
1629
1630 assert_eq!(order.action, Action::Sell);
1631 assert_eq!(order.total_quantity, 100.0);
1632 assert_eq!(order.order_type, "TRAIL");
1633 assert_eq!(order.trailing_percent, Some(5.0));
1634 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, None); }
1637
1638 #[test]
1639 fn test_build_trailing_stop_absolute() {
1640 let order = build_ib_order(
1641 rustrade_instrument::Side::Sell,
1642 100.0,
1643 &OrderKind::TrailingStop {
1644 offset: Decimal::from(2),
1645 offset_type: TrailingOffsetType::Absolute,
1646 activation_price: None,
1647 },
1648 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1650 )
1651 .unwrap();
1652
1653 assert_eq!(order.action, Action::Sell);
1654 assert_eq!(order.total_quantity, 100.0);
1655 assert_eq!(order.order_type, "TRAIL");
1656 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.trailing_percent, None);
1658 assert_eq!(order.trail_stop_price, None);
1659 }
1660
1661 #[test]
1662 fn test_build_trailing_stop_limit_absolute() {
1663 let order = build_ib_order(
1664 rustrade_instrument::Side::Sell,
1665 100.0,
1666 &OrderKind::TrailingStopLimit {
1667 offset: Decimal::from(2),
1668 offset_type: TrailingOffsetType::Absolute,
1669 stop_price: Decimal::from(95),
1670 limit_offset: Decimal::try_from(0.5).unwrap(),
1671 },
1672 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1674 )
1675 .unwrap();
1676
1677 assert_eq!(order.action, Action::Sell);
1678 assert_eq!(order.total_quantity, 100.0);
1679 assert_eq!(order.order_type, "TRAIL LIMIT");
1680 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.limit_price_offset, Some(0.5)); assert_eq!(order.trailing_percent, None);
1683 assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1686
1687 #[test]
1688 fn test_build_trailing_stop_limit_percentage() {
1689 let order = build_ib_order(
1690 rustrade_instrument::Side::Sell,
1691 100.0,
1692 &OrderKind::TrailingStopLimit {
1693 offset: Decimal::from(5),
1694 offset_type: TrailingOffsetType::Percentage,
1695 stop_price: Decimal::from(95),
1696 limit_offset: Decimal::try_from(0.5).unwrap(),
1697 },
1698 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1700 )
1701 .unwrap();
1702
1703 assert_eq!(order.action, Action::Sell);
1704 assert_eq!(order.total_quantity, 100.0);
1705 assert_eq!(order.order_type, "TRAIL LIMIT");
1706 assert_eq!(order.trailing_percent, Some(5.0));
1707 assert_eq!(order.limit_price_offset, Some(0.5));
1708 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1712
1713 #[test]
1715 fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1716 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1717 for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1718 let kind = OrderKind::TrailingStopLimit {
1719 offset: Decimal::from(2),
1720 offset_type,
1721 stop_price,
1722 limit_offset: Decimal::from(1),
1723 };
1724 assert_eq!(
1725 build_ib_order(
1726 rustrade_instrument::Side::Sell,
1727 1.0,
1728 &kind,
1729 None,
1730 &TimeInForce::GoodUntilCancelled { post_only: false },
1731 ),
1732 Err(OrderMappingError::NonPositiveStopPrice(kind)),
1733 "{kind}"
1734 );
1735 }
1736 }
1737 }
1738
1739 #[test]
1742 fn test_build_trailing_stop_limit_rejects_limit_price() {
1743 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1744 let kind = OrderKind::TrailingStopLimit {
1745 offset: Decimal::from(2),
1746 offset_type,
1747 stop_price: Decimal::from(95),
1748 limit_offset: Decimal::from(1),
1749 };
1750 assert_eq!(
1751 build_ib_order(
1752 rustrade_instrument::Side::Sell,
1753 1.0,
1754 &kind,
1755 Some(Decimal::from(94)),
1756 &TimeInForce::GoodUntilCancelled { post_only: false },
1757 ),
1758 Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1759 "{kind}"
1760 );
1761 }
1762 }
1763
1764 #[test]
1765 fn test_build_trailing_stop_basis_points_unsupported() {
1766 let result = build_ib_order(
1767 rustrade_instrument::Side::Sell,
1768 100.0,
1769 &OrderKind::TrailingStop {
1770 offset: Decimal::from(50), offset_type: TrailingOffsetType::BasisPoints,
1772 activation_price: None,
1773 },
1774 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1776 );
1777
1778 assert!(matches!(
1779 result,
1780 Err(OrderMappingError::UnsupportedOffsetType(
1781 TrailingOffsetType::BasisPoints
1782 ))
1783 ));
1784 }
1785
1786 #[test]
1788 fn test_build_unresolved_kind_unsupported() {
1789 let kind = OrderKind::ConditionalUnresolved {
1790 family: crate::order::ConditionalFamily::StopLimit,
1791 trigger_price: Some(Decimal::from(95)),
1792 };
1793 let result = build_ib_order(
1794 rustrade_instrument::Side::Sell,
1795 100.0,
1796 &kind,
1797 Some(Decimal::from(94)),
1798 &TimeInForce::GoodUntilCancelled { post_only: false },
1799 );
1800 assert!(matches!(result, Err(OrderMappingError::UnsupportedOrderKind(k)) if k == kind));
1801 }
1802
1803 #[test]
1804 fn test_build_trailing_stop_limit_basis_points_unsupported() {
1805 let result = build_ib_order(
1806 rustrade_instrument::Side::Sell,
1807 100.0,
1808 &OrderKind::TrailingStopLimit {
1809 offset: Decimal::from(50),
1810 offset_type: TrailingOffsetType::BasisPoints,
1811 stop_price: Decimal::from(95),
1812 limit_offset: Decimal::try_from(0.5).unwrap(),
1813 },
1814 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1816 );
1817
1818 assert!(matches!(
1819 result,
1820 Err(OrderMappingError::UnsupportedOffsetType(
1821 TrailingOffsetType::BasisPoints
1822 ))
1823 ));
1824 }
1825
1826 #[test]
1828 fn test_build_activated_and_fixed_limit_trailing_stops_unsupported() {
1829 let tif = TimeInForce::GoodUntilCancelled { post_only: false };
1830 for kind in [
1831 OrderKind::TrailingStop {
1832 offset: Decimal::from(2),
1833 offset_type: TrailingOffsetType::Percentage,
1834 activation_price: Some(Decimal::from(150)),
1835 },
1836 OrderKind::TrailingStopFixedLimit {
1837 offset: Decimal::from(2),
1838 offset_type: TrailingOffsetType::Percentage,
1839 activation_price: None,
1840 },
1841 ] {
1842 let result = build_ib_order(
1843 rustrade_instrument::Side::Sell,
1844 100.0,
1845 &kind,
1846 Some(Decimal::from(140)),
1847 &tif,
1848 );
1849 assert!(
1850 matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
1851 "{kind}: {result:?}"
1852 );
1853 }
1854 }
1855
1856 #[test]
1857 fn test_build_take_profit_unsupported() {
1858 let result = build_ib_order(
1859 rustrade_instrument::Side::Sell,
1860 100.0,
1861 &OrderKind::TakeProfit {
1862 trigger_price: Decimal::from(150),
1863 },
1864 None,
1865 &TimeInForce::GoodUntilCancelled { post_only: false },
1866 );
1867
1868 assert!(matches!(
1869 result,
1870 Err(OrderMappingError::UnsupportedOrderKind(
1871 OrderKind::TakeProfit { .. }
1872 ))
1873 ));
1874 }
1875
1876 #[test]
1877 fn test_build_take_profit_limit_unsupported() {
1878 let result = build_ib_order(
1879 rustrade_instrument::Side::Sell,
1880 100.0,
1881 &OrderKind::TakeProfitLimit {
1882 trigger_price: Decimal::from(150),
1883 },
1884 None,
1885 &TimeInForce::GoodUntilCancelled { post_only: false },
1886 );
1887
1888 assert!(matches!(
1889 result,
1890 Err(OrderMappingError::UnsupportedOrderKind(
1891 OrderKind::TakeProfitLimit { .. }
1892 ))
1893 ));
1894 }
1895
1896 #[test]
1901 fn test_build_market_order_at_open() {
1902 let order = build_ib_order(
1903 rustrade_instrument::Side::Buy,
1904 100.0,
1905 &OrderKind::Market,
1906 None, &TimeInForce::AtOpen,
1908 )
1909 .unwrap();
1910
1911 assert_eq!(order.action, Action::Buy);
1912 assert_eq!(order.total_quantity, 100.0);
1913 assert_eq!(order.order_type, "MKT");
1914 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1915 }
1916
1917 #[test]
1918 fn test_build_limit_order_at_open() {
1919 let order = build_ib_order(
1920 rustrade_instrument::Side::Sell,
1921 50.0,
1922 &OrderKind::Limit,
1923 Some(Decimal::from(150)),
1924 &TimeInForce::AtOpen,
1925 )
1926 .unwrap();
1927
1928 assert_eq!(order.action, Action::Sell);
1929 assert_eq!(order.total_quantity, 50.0);
1930 assert_eq!(order.order_type, "LMT");
1931 assert_eq!(order.limit_price, Some(150.0));
1932 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1933 }
1934
1935 #[test]
1936 fn test_build_stop_order_at_open() {
1937 let order = build_ib_order(
1939 rustrade_instrument::Side::Sell,
1940 100.0,
1941 &OrderKind::Stop {
1942 trigger_price: Decimal::from(45),
1943 },
1944 None, &TimeInForce::AtOpen,
1946 )
1947 .unwrap();
1948
1949 assert_eq!(order.order_type, "STP");
1950 assert_eq!(order.aux_price, Some(45.0));
1951 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1952 }
1953
1954 #[test]
1955 fn test_build_market_on_close() {
1956 let order = build_ib_order(
1957 rustrade_instrument::Side::Buy,
1958 100.0,
1959 &OrderKind::Market,
1960 None, &TimeInForce::AtClose,
1962 )
1963 .unwrap();
1964
1965 assert_eq!(order.action, Action::Buy);
1966 assert_eq!(order.total_quantity, 100.0);
1967 assert_eq!(order.order_type, "MOC");
1968 }
1969
1970 #[test]
1971 fn test_build_limit_on_close() {
1972 let order = build_ib_order(
1973 rustrade_instrument::Side::Sell,
1974 50.0,
1975 &OrderKind::Limit,
1976 Some(Decimal::from(150)),
1977 &TimeInForce::AtClose,
1978 )
1979 .unwrap();
1980
1981 assert_eq!(order.action, Action::Sell);
1982 assert_eq!(order.total_quantity, 50.0);
1983 assert_eq!(order.order_type, "LOC");
1984 assert_eq!(order.limit_price, Some(150.0));
1985 }
1986
1987 #[test]
1988 fn test_build_stop_at_close_unsupported() {
1989 let result = build_ib_order(
1990 rustrade_instrument::Side::Sell,
1991 100.0,
1992 &OrderKind::Stop {
1993 trigger_price: Decimal::from(45),
1994 },
1995 None, &TimeInForce::AtClose,
1997 );
1998
1999 assert!(matches!(
2000 result,
2001 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2002 OrderKind::Stop { .. }
2003 ))
2004 ));
2005 }
2006
2007 #[test]
2008 fn test_build_stop_limit_at_close_unsupported() {
2009 let result = build_ib_order(
2010 rustrade_instrument::Side::Sell,
2011 100.0,
2012 &OrderKind::StopLimit {
2013 trigger_price: Decimal::from(44),
2014 },
2015 Some(Decimal::from(45)),
2016 &TimeInForce::AtClose,
2017 );
2018
2019 assert!(matches!(
2020 result,
2021 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2022 OrderKind::StopLimit { .. }
2023 ))
2024 ));
2025 }
2026
2027 #[test]
2028 fn test_build_trailing_stop_at_close_unsupported() {
2029 let result = build_ib_order(
2030 rustrade_instrument::Side::Sell,
2031 100.0,
2032 &OrderKind::TrailingStop {
2033 offset: Decimal::from(5),
2034 offset_type: TrailingOffsetType::Percentage,
2035 activation_price: None,
2036 },
2037 None, &TimeInForce::AtClose,
2039 );
2040
2041 assert!(matches!(
2042 result,
2043 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2044 OrderKind::TrailingStop { .. }
2045 ))
2046 ));
2047 }
2048
2049 #[test]
2050 fn test_build_trailing_stop_limit_at_close_unsupported() {
2051 let result = build_ib_order(
2052 rustrade_instrument::Side::Sell,
2053 100.0,
2054 &OrderKind::TrailingStopLimit {
2055 offset: Decimal::from(5),
2056 offset_type: TrailingOffsetType::Absolute,
2057 stop_price: Decimal::from(95),
2058 limit_offset: Decimal::from(1),
2059 },
2060 None, &TimeInForce::AtClose,
2062 );
2063
2064 assert!(matches!(
2065 result,
2066 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2067 OrderKind::TrailingStopLimit { .. }
2068 ))
2069 ));
2070 }
2071
2072 #[test]
2073 fn test_build_good_till_date_order() {
2074 use chrono::{TimeZone, Utc};
2075
2076 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2077 let order = build_ib_order(
2078 rustrade_instrument::Side::Buy,
2079 100.0,
2080 &OrderKind::Limit,
2081 Some(Decimal::from(150)),
2082 &TimeInForce::GoodTillDate { expiry },
2083 )
2084 .unwrap();
2085
2086 assert_eq!(order.action, Action::Buy);
2087 assert_eq!(order.total_quantity, 100.0);
2088 assert_eq!(order.order_type, "LMT");
2089 assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2090 assert_eq!(order.good_till_date, "20250630-23:59:59");
2091 }
2092
2093 #[test]
2094 fn test_format_gtd_datetime() {
2095 use chrono::{TimeZone, Utc};
2096
2097 let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2098 assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2099
2100 let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2101 assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2102 }
2103
2104 #[test]
2109 fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2110 let orders = build_ib_bracket_with_oca(
2111 1000,
2112 Action::Buy,
2113 10.0,
2114 150.0,
2115 160.0,
2116 140.0,
2117 IbTimeInForce::Day,
2118 )
2119 .unwrap();
2120
2121 assert_eq!(orders.len(), 3);
2122
2123 assert!(!orders[1].oca_group.is_empty());
2125 assert_eq!(orders[1].oca_group, orders[2].oca_group);
2126
2127 assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2129 assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2130
2131 assert!(orders[0].oca_group.is_empty());
2133 assert_eq!(orders[0].oca_type, OcaType::None);
2134 }
2135
2136 #[test]
2137 fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2138 let orders = build_ib_bracket_with_oca(
2139 1000,
2140 Action::Buy,
2141 10.0,
2142 150.0,
2143 160.0,
2144 140.0,
2145 IbTimeInForce::Day,
2146 )
2147 .unwrap();
2148
2149 assert_eq!(orders[1].parent_id, 1000); assert_eq!(orders[2].parent_id, 1000); assert_eq!(orders[0].parent_id, 0);
2155 }
2156
2157 #[test]
2158 fn test_build_ib_bracket_with_oca_transmit_flags() {
2159 let orders = build_ib_bracket_with_oca(
2160 1000,
2161 Action::Buy,
2162 10.0,
2163 150.0,
2164 160.0,
2165 140.0,
2166 IbTimeInForce::Day,
2167 )
2168 .unwrap();
2169
2170 assert!(!orders[0].transmit); assert!(!orders[1].transmit); assert!(orders[2].transmit); }
2175
2176 #[test]
2177 fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2178 let orders = build_ib_bracket_with_oca(
2179 500,
2180 Action::Sell,
2181 5.0,
2182 100.0,
2183 90.0,
2184 110.0,
2185 IbTimeInForce::Day,
2186 )
2187 .unwrap();
2188
2189 assert_eq!(orders[0].order_id, 500); assert_eq!(orders[1].order_id, 501); assert_eq!(orders[2].order_id, 502); }
2193
2194 #[test]
2195 fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2196 let result =
2198 build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2199 assert!(matches!(
2200 result,
2201 Err(OrderMappingError::InvalidBracketPrices(_))
2202 ));
2203
2204 let result =
2206 build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2207 assert!(matches!(
2208 result,
2209 Err(OrderMappingError::InvalidBracketPrices(_))
2210 ));
2211 }
2212
2213 #[test]
2214 fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2215 let orders =
2216 build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2217 .unwrap();
2218
2219 assert!(orders[1].oca_group.contains("42"));
2220 assert!(orders[2].oca_group.contains("42"));
2221 }
2222
2223 #[test]
2224 fn test_build_ib_bracket_with_oca_order_types() {
2225 let orders = build_ib_bracket_with_oca(
2226 1000,
2227 Action::Buy,
2228 100.0,
2229 150.0,
2230 160.0,
2231 140.0,
2232 IbTimeInForce::Day,
2233 )
2234 .unwrap();
2235
2236 assert_eq!(orders[0].order_type, "LMT");
2238 assert_eq!(orders[0].limit_price, Some(150.0));
2239
2240 assert_eq!(orders[1].order_type, "LMT");
2242 assert_eq!(orders[1].limit_price, Some(160.0));
2243
2244 assert_eq!(orders[2].order_type, "STP");
2246 assert_eq!(orders[2].aux_price, Some(140.0));
2247 }
2248
2249 #[test]
2250 fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2251 let buy_orders =
2253 build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2254 .unwrap();
2255 assert_eq!(buy_orders[0].action, Action::Buy);
2256 assert_eq!(buy_orders[1].action, Action::Sell);
2257 assert_eq!(buy_orders[2].action, Action::Sell);
2258
2259 let sell_orders = build_ib_bracket_with_oca(
2261 200,
2262 Action::Sell,
2263 10.0,
2264 50.0,
2265 45.0,
2266 55.0,
2267 IbTimeInForce::Day,
2268 )
2269 .unwrap();
2270 assert_eq!(sell_orders[0].action, Action::Sell);
2271 assert_eq!(sell_orders[1].action, Action::Buy);
2272 assert_eq!(sell_orders[2].action, Action::Buy);
2273 }
2274
2275 #[test]
2276 fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2277 let orders = build_ib_bracket_with_oca(
2278 1000,
2279 Action::Buy,
2280 10.0,
2281 150.0,
2282 160.0,
2283 140.0,
2284 IbTimeInForce::GoodTillCanceled,
2285 )
2286 .unwrap();
2287
2288 assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2289 assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2290 assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2291 }
2292
2293 #[test]
2296 fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2297 let longest = "a".repeat(MAX_ORDER_REF_LEN);
2298 assert_eq!(
2299 order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2300 longest
2301 );
2302 assert_eq!(
2303 order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2304 "cid-1_tp"
2305 );
2306
2307 let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2308 for refused in ["", too_long.as_str(), "ordre-é"] {
2309 let cid = ClientOrderId::new(refused);
2310 assert_eq!(
2311 order_ref(&cid),
2312 Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2313 );
2314 }
2315 }
2316
2317 #[test]
2320 fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2321 use chrono::TimeZone;
2322 use rust_decimal_macros::dec;
2323
2324 let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2325 let kinds = [
2326 (OrderKind::Market, None),
2327 (OrderKind::Limit, Some(dec!(150.25))),
2328 (
2329 OrderKind::Stop {
2330 trigger_price: dec!(140.5),
2331 },
2332 None,
2333 ),
2334 (
2335 OrderKind::StopLimit {
2336 trigger_price: dec!(140.5),
2337 },
2338 Some(dec!(140)),
2339 ),
2340 (
2341 OrderKind::TrailingStop {
2342 offset: dec!(2.5),
2343 offset_type: TrailingOffsetType::Percentage,
2344 activation_price: None,
2345 },
2346 None,
2347 ),
2348 (
2349 OrderKind::TrailingStop {
2350 offset: dec!(1.75),
2351 offset_type: TrailingOffsetType::Absolute,
2352 activation_price: None,
2353 },
2354 None,
2355 ),
2356 (
2357 OrderKind::TrailingStopLimit {
2358 offset: dec!(2),
2359 offset_type: TrailingOffsetType::Percentage,
2360 stop_price: dec!(130.5),
2361 limit_offset: dec!(0.5),
2362 },
2363 None,
2364 ),
2365 (
2366 OrderKind::TrailingStopLimit {
2367 offset: dec!(1.5),
2368 offset_type: TrailingOffsetType::Absolute,
2369 stop_price: dec!(139.75),
2370 limit_offset: dec!(0.25),
2371 },
2372 None,
2373 ),
2374 ];
2375 let tifs = [
2376 TimeInForce::GoodUntilCancelled { post_only: false },
2377 TimeInForce::GoodUntilEndOfDay,
2378 TimeInForce::ImmediateOrCancel,
2379 TimeInForce::FillOrKill,
2380 TimeInForce::AtOpen,
2381 TimeInForce::GoodTillDate { expiry },
2382 ];
2383
2384 for (kind, price) in kinds {
2385 for time_in_force in tifs {
2386 let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2387 assert_eq!(
2388 order_shape_from_ib(&order),
2389 Ok(OrderShape {
2390 kind,
2391 price,
2392 time_in_force
2393 }),
2394 "{kind} {time_in_force}"
2395 );
2396 }
2397 }
2398
2399 for (kind, price) in [
2401 (OrderKind::Market, None),
2402 (OrderKind::Limit, Some(dec!(99))),
2403 ] {
2404 let order =
2405 build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2406 assert_eq!(
2407 order_shape_from_ib(&order),
2408 Ok(OrderShape {
2409 kind,
2410 price,
2411 time_in_force: TimeInForce::AtClose
2412 }),
2413 "{kind}"
2414 );
2415 }
2416 }
2417
2418 #[test]
2419 fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2420 use chrono::TimeZone;
2421
2422 let order = Order {
2423 order_type: "MKT".to_owned(),
2424 tif: IbTimeInForce::GoodTillDate,
2425 good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2426 ..Order::default()
2427 };
2428 assert_eq!(
2429 order_shape_from_ib(&order).unwrap().time_in_force,
2430 TimeInForce::GoodTillDate {
2431 expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2432 }
2433 );
2434 }
2435
2436 #[test]
2437 fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2438 let order = |order_type: &str, tif: IbTimeInForce| Order {
2439 order_type: order_type.to_owned(),
2440 tif,
2441 limit_price: Some(1.0),
2442 ..Order::default()
2443 };
2444 for (order, reason) in [
2445 (order("REL", IbTimeInForce::Day), "order type"),
2446 (
2447 order("LMT", IbTimeInForce::DayTillCanceled),
2448 "time in force",
2449 ),
2450 (
2451 Order {
2452 limit_price: None,
2453 ..order("LMT", IbTimeInForce::Day)
2454 },
2455 "without limit_price",
2456 ),
2457 (order("STP", IbTimeInForce::Day), "without aux_price"),
2458 (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2459 (
2460 Order {
2461 aux_price: Some(2.0),
2462 limit_price_offset: Some(0.5),
2463 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2464 },
2465 "without trail_stop_price",
2466 ),
2467 (
2468 Order {
2469 aux_price: Some(2.0),
2470 trail_stop_price: Some(0.0),
2471 limit_price_offset: Some(0.5),
2472 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2473 },
2474 "not positive",
2475 ),
2476 (
2477 Order {
2478 trailing_percent: Some(5.0),
2479 trail_stop_price: Some(-1.0),
2480 limit_price_offset: Some(0.5),
2481 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2482 },
2483 "not positive",
2484 ),
2485 ] {
2486 let error = order_shape_from_ib(&order).unwrap_err();
2487 assert!(error.contains(reason), "{error:?} should name {reason:?}");
2488 }
2489 }
2490
2491 #[test]
2492 fn action_to_side_reads_a_short_sale_as_a_sell() {
2493 assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2494 for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2495 assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2496 }
2497 }
2498}