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(
519 order: &Order,
520 session_zone: Option<chrono_tz::Tz>,
521) -> Result<OrderShape, String> {
522 let order_type = order.order_type.as_str();
523 let decimal = |value: Option<f64>, field: &str| -> Result<Decimal, String> {
524 let value = value.ok_or_else(|| format!("{order_type} order without {field}"))?;
525 Decimal::try_from(value).map_err(|e| format!("{order_type} order's {field} {value}: {e}"))
526 };
527
528 let time_in_force = match &order.tif {
529 IbTimeInForce::Day => TimeInForce::GoodUntilEndOfDay,
530 IbTimeInForce::GoodTillCanceled => TimeInForce::GoodUntilCancelled { post_only: false },
531 IbTimeInForce::ImmediateOrCancel => TimeInForce::ImmediateOrCancel,
532 IbTimeInForce::FillOrKill => TimeInForce::FillOrKill,
533 IbTimeInForce::OnOpen => TimeInForce::AtOpen,
534 IbTimeInForce::GoodTillDate => TimeInForce::GoodTillDate {
535 expiry: parse_gtd_datetime(&order.good_till_date, session_zone).ok_or_else(|| {
536 format!(
537 "good-till-date time {:?} does not parse",
538 order.good_till_date
539 )
540 })?,
541 },
542 other => return Err(format!("time in force {other:?}")),
543 };
544
545 let trailing = || -> Result<(Decimal, TrailingOffsetType), String> {
548 match order.trailing_percent {
549 Some(_) => Ok((
550 decimal(order.trailing_percent, "trailing_percent")?,
551 TrailingOffsetType::Percentage,
552 )),
553 None => Ok((
554 decimal(order.aux_price, "aux_price")?,
555 TrailingOffsetType::Absolute,
556 )),
557 }
558 };
559
560 let (kind, price, time_in_force) = match order_type {
561 "MKT" => (OrderKind::Market, None, time_in_force),
562 "LMT" => (
563 OrderKind::Limit,
564 Some(decimal(order.limit_price, "limit_price")?),
565 time_in_force,
566 ),
567 "MOC" => (OrderKind::Market, None, TimeInForce::AtClose),
569 "LOC" => (
570 OrderKind::Limit,
571 Some(decimal(order.limit_price, "limit_price")?),
572 TimeInForce::AtClose,
573 ),
574 "STP" => (
575 OrderKind::Stop {
576 trigger_price: decimal(order.aux_price, "aux_price")?,
577 },
578 None,
579 time_in_force,
580 ),
581 "STP LMT" => (
582 OrderKind::StopLimit {
583 trigger_price: decimal(order.aux_price, "aux_price")?,
584 },
585 Some(decimal(order.limit_price, "limit_price")?),
586 time_in_force,
587 ),
588 "TRAIL" => {
589 let (offset, offset_type) = trailing()?;
590 (
591 OrderKind::TrailingStop {
592 offset,
593 offset_type,
594 activation_price: None,
595 },
596 None,
597 time_in_force,
598 )
599 }
600 "TRAIL LIMIT" => {
601 let (offset, offset_type) = trailing()?;
602 let stop_price = decimal(order.trail_stop_price, "trail_stop_price")?;
603 if stop_price <= Decimal::ZERO {
604 return Err(format!(
605 "{order_type} order's trail_stop_price {stop_price} is not positive"
606 ));
607 }
608 (
609 OrderKind::TrailingStopLimit {
610 offset,
611 offset_type,
612 stop_price,
613 limit_offset: decimal(order.limit_price_offset, "limit_price_offset")?,
614 },
615 None,
616 time_in_force,
617 )
618 }
619 other => return Err(format!("order type {other:?}")),
620 };
621
622 Ok(OrderShape {
623 kind,
624 price,
625 time_in_force,
626 })
627}
628
629fn parse_gtd_datetime(
640 value: &str,
641 session_zone: Option<chrono_tz::Tz>,
642) -> Option<chrono::DateTime<chrono::Utc>> {
643 chrono::NaiveDateTime::parse_from_str(value, GTD_FORMAT)
644 .map(|naive| naive.and_utc())
645 .ok()
646 .or_else(|| super::execution::parse_ib_timestamp(value, session_zone))
647 .or_else(|| {
648 let mut parts = value.split_whitespace();
649 let wall_clock = format!("{} {}", parts.next()?, parts.next()?);
650 let time = super::execution::parse_ib_timestamp(&wall_clock, Some(chrono_tz::UTC))?;
651 tracing::warn!(
652 good_till_date = value,
653 session_zone = session_zone.map(|zone| zone.name()),
654 "IBKR good-till-date could not be read in the zone it names or, naming none, in \
655 the session's; read as UTC"
656 );
657 Some(time)
658 })
659}
660
661#[non_exhaustive]
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub enum OrderMappingError {
665 PostOnlyNotSupported,
666 InvalidPrice(String),
668 MissingLimitPrice(OrderKind),
670 UnexpectedLimitPrice(OrderKind),
673 NonPositiveStopPrice(OrderKind),
675 UnsupportedOffsetType(TrailingOffsetType),
677 UnsupportedOrderKind(OrderKind),
679 UnsupportedOrderKindForAtClose(OrderKind),
682 AtCloseRequiresOrderTypeChange,
686 InvalidBracketPrices(String),
690 InvalidClientOrderId(ClientOrderId),
693}
694
695impl std::fmt::Display for OrderMappingError {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 match self {
698 Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
699 Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
700 Self::MissingLimitPrice(k) => {
701 write!(f, "limit price required for {k} but was None")
702 }
703 Self::UnexpectedLimitPrice(k) => write!(
704 f,
705 "{k} takes no limit price: it follows from the stop price and limit offset"
706 ),
707 Self::NonPositiveStopPrice(k) => {
708 write!(f, "stop price must be positive for {k}")
709 }
710 Self::UnsupportedOffsetType(t) => {
711 write!(f, "trailing offset type {t:?} not supported by IBKR")
712 }
713 Self::UnsupportedOrderKind(k) => {
714 write!(f, "order kind {k} not supported by IBKR")
715 }
716 Self::UnsupportedOrderKindForAtClose(k) => {
717 write!(
718 f,
719 "AtClose TIF only valid with Market or Limit orders, got {k}"
720 )
721 }
722 Self::AtCloseRequiresOrderTypeChange => write!(
723 f,
724 "AtClose TIF must be routed through build_ib_order, which promotes \
725 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
726 ),
727 Self::InvalidBracketPrices(e) => write!(f, "invalid bracket prices: {e}"),
728 Self::InvalidClientOrderId(cid) => write!(
729 f,
730 "client order id {cid} is not an IB order reference: at most \
731 {MAX_ORDER_REF_LEN} ASCII characters"
732 ),
733 }
734 }
735}
736
737pub const MAX_ORDER_REF_LEN: usize = 128;
740
741pub fn order_ref(cid: &ClientOrderId) -> Result<String, OrderMappingError> {
750 let id = cid.0.as_str();
751 if id.is_empty() || id.len() > MAX_ORDER_REF_LEN || !id.is_ascii() {
752 return Err(OrderMappingError::InvalidClientOrderId(cid.clone()));
753 }
754 Ok(id.to_owned())
755}
756
757impl std::error::Error for OrderMappingError {}
758
759pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
767 match tif {
768 TimeInForce::GoodUntilCancelled { post_only } => {
769 if *post_only {
770 Err(OrderMappingError::PostOnlyNotSupported)
771 } else {
772 Ok(IbTimeInForce::GoodTillCanceled)
773 }
774 }
775 TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
776 TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
777 TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
778 TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTillDate),
779 TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
780 TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
784 }
785}
786
787fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
789 value.try_into().or_else(|_| {
790 value
791 .to_string()
792 .parse()
793 .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
794 })
795}
796
797fn require_limit_price(
799 price: Option<rust_decimal::Decimal>,
800 kind: OrderKind,
801) -> Result<f64, OrderMappingError> {
802 match price {
803 Some(p) => decimal_to_f64(p),
804 None => Err(OrderMappingError::MissingLimitPrice(kind)),
805 }
806}
807
808pub fn build_ib_order(
843 side: rustrade_instrument::Side,
844 quantity: f64,
845 kind: &OrderKind,
846 price: Option<rust_decimal::Decimal>,
847 tif: &TimeInForce,
848) -> Result<Order, OrderMappingError> {
849 let action = side_to_action(side);
850
851 if matches!(tif, TimeInForce::AtClose) {
853 return build_at_close_order(action, quantity, kind, price);
854 }
855
856 let tif_ib = time_in_force_to_ib(tif)?;
857
858 let mut order = match kind {
859 OrderKind::Market => order_builder::market_order(action, quantity),
860
861 OrderKind::Limit => {
862 let price_f64 = require_limit_price(price, *kind)?;
863 order_builder::limit_order(action, quantity, price_f64)
864 }
865
866 OrderKind::Stop { trigger_price } => {
867 let trigger_f64 = decimal_to_f64(*trigger_price)?;
868 order_builder::stop(action, quantity, trigger_f64)
869 }
870
871 OrderKind::StopLimit { trigger_price } => {
872 let limit_f64 = require_limit_price(price, *kind)?;
873 let trigger_f64 = decimal_to_f64(*trigger_price)?;
874 Order {
877 action,
878 order_type: "STP LMT".to_owned(),
879 total_quantity: quantity,
880 limit_price: Some(limit_f64),
881 aux_price: Some(trigger_f64),
882 ..Order::default()
883 }
884 }
885
886 OrderKind::TrailingStop {
887 offset,
888 offset_type,
889 activation_price: None,
890 } => {
891 let offset_f64 = decimal_to_f64(*offset)?;
892 match offset_type {
893 TrailingOffsetType::Percentage => {
894 Order {
897 action,
898 order_type: "TRAIL".to_owned(),
899 total_quantity: quantity,
900 trailing_percent: Some(offset_f64),
901 trail_stop_price: None,
902 ..Order::default()
903 }
904 }
905 TrailingOffsetType::Absolute => {
906 Order {
909 action,
910 order_type: "TRAIL".to_owned(),
911 total_quantity: quantity,
912 aux_price: Some(offset_f64),
913 trail_stop_price: None,
914 ..Order::default()
915 }
916 }
917 TrailingOffsetType::BasisPoints => {
918 return Err(OrderMappingError::UnsupportedOffsetType(
919 TrailingOffsetType::BasisPoints,
920 ));
921 }
922 }
923 }
924
925 OrderKind::TrailingStopLimit {
926 offset,
927 offset_type,
928 stop_price,
929 limit_offset,
930 } => {
931 if price.is_some() {
934 return Err(OrderMappingError::UnexpectedLimitPrice(*kind));
935 }
936 if *stop_price <= Decimal::ZERO {
939 return Err(OrderMappingError::NonPositiveStopPrice(*kind));
940 }
941 let offset_f64 = decimal_to_f64(*offset)?;
942 let stop_price_f64 = decimal_to_f64(*stop_price)?;
943 let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
944 let (aux_price, trailing_percent) = match offset_type {
946 TrailingOffsetType::Absolute => (Some(offset_f64), None),
947 TrailingOffsetType::Percentage => (None, Some(offset_f64)),
948 TrailingOffsetType::BasisPoints => {
949 return Err(OrderMappingError::UnsupportedOffsetType(
950 TrailingOffsetType::BasisPoints,
951 ));
952 }
953 };
954 Order {
955 action,
956 order_type: "TRAIL LIMIT".to_owned(),
957 total_quantity: quantity,
958 aux_price,
959 trailing_percent,
960 trail_stop_price: Some(stop_price_f64),
961 limit_price_offset: Some(limit_offset_f64),
962 ..Order::default()
963 }
964 }
965
966 OrderKind::TakeProfit { .. }
971 | OrderKind::TakeProfitLimit { .. }
972 | OrderKind::TrailingStop {
973 activation_price: Some(_),
974 ..
975 }
976 | OrderKind::TrailingStopFixedLimit { .. }
977 | OrderKind::ConditionalUnresolved { .. } => {
978 return Err(OrderMappingError::UnsupportedOrderKind(*kind));
979 }
980 };
981
982 order.tif = tif_ib;
983
984 if let TimeInForce::GoodTillDate { expiry } = tif {
986 order.good_till_date = format_gtd_datetime(expiry);
987 }
988
989 Ok(order)
990}
991
992fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
997 dt.format(GTD_FORMAT).to_string()
998}
999
1000const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
1002
1003fn build_at_close_order(
1008 action: Action,
1009 quantity: f64,
1010 kind: &OrderKind,
1011 price: Option<rust_decimal::Decimal>,
1012) -> Result<Order, OrderMappingError> {
1013 match kind {
1014 OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
1015 OrderKind::Limit => {
1016 let price_f64 = require_limit_price(price, *kind)?;
1017 Ok(order_builder::limit_on_close(action, quantity, price_f64))
1018 }
1019 _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
1021 }
1022}
1023
1024pub fn build_ib_bracket_with_oca(
1057 parent_order_id: i32,
1058 action: Action,
1059 quantity: f64,
1060 limit_price: f64,
1061 take_profit_price: f64,
1062 stop_loss_price: f64,
1063 tif: IbTimeInForce,
1064) -> Result<Vec<Order>, OrderMappingError> {
1065 let prices = BracketPrices {
1066 entry: limit_price,
1067 take_profit: take_profit_price,
1068 stop_loss: stop_loss_price,
1069 };
1070 let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1071 .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1072 assert_eq!(
1073 orders.len(),
1074 3,
1075 "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1076 );
1077
1078 orders[0].tif = tif.clone();
1081 orders[1].tif = tif.clone();
1082 orders[2].tif = tif;
1083
1084 let oca_group = format!("bracket_{}", parent_order_id);
1087 orders[1].oca_group = oca_group.clone();
1088 orders[1].oca_type = OcaType::CancelWithBlock;
1089 orders[2].oca_group = oca_group;
1090 orders[2].oca_type = OcaType::CancelWithBlock;
1091
1092 Ok(orders)
1093}
1094
1095#[cfg(test)]
1096#[allow(clippy::unwrap_used)] mod tests {
1098 use super::*;
1099 use rust_decimal::Decimal;
1100
1101 fn test_context() -> OrderContext {
1102 OrderContext {
1103 instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1104 side: Side::Buy,
1105 price: Some(Decimal::from(150)),
1106 quantity: Decimal::from(100),
1107 kind: OrderKind::Limit,
1108 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1109 }
1110 }
1111
1112 #[test]
1113 fn test_order_id_map_basic() {
1114 let map = OrderIdMap::new();
1115 let cid = ClientOrderId::new("order-123");
1116 let ctx = test_context();
1117
1118 map.register(cid.clone(), 42, ctx.clone()).unwrap();
1119
1120 assert_eq!(map.get_ib_id(&cid), Some(42));
1121 assert_eq!(map.get_client_id(42), Some(cid.clone()));
1122 assert_eq!(map.len(), 1);
1123
1124 let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1125 assert_eq!(retrieved_cid, cid);
1126 assert_eq!(retrieved_ctx.side, Side::Buy);
1127 assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1128 }
1129
1130 #[test]
1131 fn test_order_id_map_remove() {
1132 let map = OrderIdMap::new();
1133 let cid = ClientOrderId::new("order-456");
1134
1135 map.register(cid.clone(), 100, test_context()).unwrap();
1136 assert_eq!(map.len(), 1);
1137
1138 let removed = map.remove_by_ib_id(100);
1139 assert_eq!(removed, Some(cid.clone()));
1140 assert!(map.is_empty());
1141 assert!(map.get_ib_id(&cid).is_none());
1142 assert!(map.get_client_id(100).is_none());
1143 assert!(map.get_client_id_and_context(100).is_none());
1144 }
1145
1146 #[test]
1147 fn test_side_conversion() {
1148 assert!(matches!(
1149 side_to_action(rustrade_instrument::Side::Buy),
1150 Action::Buy
1151 ));
1152 assert!(matches!(
1153 side_to_action(rustrade_instrument::Side::Sell),
1154 Action::Sell
1155 ));
1156 }
1157
1158 #[test]
1159 fn test_time_in_force_conversion() {
1160 assert_eq!(
1161 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1162 Ok(IbTimeInForce::GoodTillCanceled)
1163 );
1164
1165 assert!(matches!(
1166 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1167 Err(OrderMappingError::PostOnlyNotSupported)
1168 ));
1169
1170 assert_eq!(
1171 time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1172 Ok(IbTimeInForce::Day)
1173 );
1174 assert_eq!(
1175 time_in_force_to_ib(&TimeInForce::FillOrKill),
1176 Ok(IbTimeInForce::FillOrKill)
1177 );
1178 assert_eq!(
1179 time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1180 Ok(IbTimeInForce::ImmediateOrCancel)
1181 );
1182 }
1183
1184 #[test]
1185 fn test_time_in_force_conversion_good_till_date() {
1186 use chrono::{TimeZone, Utc};
1187
1188 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1189 assert_eq!(
1190 time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1191 Ok(IbTimeInForce::GoodTillDate)
1192 );
1193 }
1194
1195 #[test]
1196 fn test_time_in_force_conversion_at_open() {
1197 assert_eq!(
1198 time_in_force_to_ib(&TimeInForce::AtOpen),
1199 Ok(IbTimeInForce::OnOpen)
1200 );
1201 }
1202
1203 #[test]
1204 fn test_time_in_force_conversion_at_close_returns_err() {
1205 assert_eq!(
1209 time_in_force_to_ib(&TimeInForce::AtClose),
1210 Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1211 );
1212 }
1213
1214 #[test]
1215 fn test_build_market_order() {
1216 let order = build_ib_order(
1217 rustrade_instrument::Side::Buy,
1218 100.0,
1219 &OrderKind::Market,
1220 None, &TimeInForce::GoodUntilEndOfDay,
1222 )
1223 .unwrap();
1224
1225 assert_eq!(order.action, Action::Buy);
1226 assert_eq!(order.total_quantity, 100.0);
1227 assert_eq!(order.order_type, "MKT");
1228 }
1229
1230 #[test]
1231 fn test_build_limit_order() {
1232 let order = build_ib_order(
1233 rustrade_instrument::Side::Sell,
1234 50.0,
1235 &OrderKind::Limit,
1236 Some(Decimal::try_from(150.5).unwrap()),
1237 &TimeInForce::GoodUntilCancelled { post_only: false },
1238 )
1239 .unwrap();
1240
1241 assert_eq!(order.action, Action::Sell);
1242 assert_eq!(order.total_quantity, 50.0);
1243 assert_eq!(order.order_type, "LMT");
1244 }
1245
1246 #[test]
1247 fn test_order_id_map_remove_and_get_context() {
1248 let map = OrderIdMap::new();
1249 let cid = ClientOrderId::new("order-789");
1250 let ctx = test_context();
1251
1252 map.register(cid.clone(), 50, ctx).unwrap();
1253 assert_eq!(map.len(), 1);
1254
1255 let result = map.remove_and_get_context(50);
1257 assert!(result.is_some());
1258 let (retrieved_cid, retrieved_ctx) = result.unwrap();
1259 assert_eq!(retrieved_cid, cid);
1260 assert_eq!(retrieved_ctx.side, Side::Buy);
1261
1262 assert!(map.is_empty());
1264 assert!(map.get_client_id(50).is_none());
1265 assert!(map.get_ib_id(&cid).is_none());
1266
1267 assert!(map.remove_and_get_context(50).is_none());
1269 }
1270
1271 #[test]
1272 fn test_order_id_map_clear_stale() {
1273 use std::time::Duration;
1274
1275 let map = OrderIdMap::new();
1276
1277 map.register(ClientOrderId::new("old-1"), 1, test_context())
1279 .unwrap();
1280 map.register(ClientOrderId::new("old-2"), 2, test_context())
1281 .unwrap();
1282
1283 let cleared = map.clear_stale(Duration::ZERO);
1285 assert_eq!(cleared, 2);
1286 assert!(map.is_empty());
1287
1288 map.register(ClientOrderId::new("new-1"), 10, test_context())
1290 .unwrap();
1291 map.register(ClientOrderId::new("new-2"), 20, test_context())
1292 .unwrap();
1293
1294 let cleared = map.clear_stale(Duration::from_secs(3600));
1296 assert_eq!(cleared, 0);
1297 assert_eq!(map.len(), 2);
1298 }
1299
1300 #[test]
1302 fn a_live_orders_id_is_refused_to_a_second_order() {
1303 let map = OrderIdMap::new();
1304 let cid = ClientOrderId::new("taken");
1305 map.register(cid.clone(), 1, test_context()).unwrap();
1306
1307 assert_eq!(
1308 map.register(cid.clone(), 2, test_context()),
1309 Err(ClientOrderIdInUse(cid.clone()))
1310 );
1311 assert_eq!(
1312 map.get_ib_id(&cid),
1313 Some(1),
1314 "a cancel by id still reaches the first"
1315 );
1316 assert!(
1317 map.get_client_id(2).is_none(),
1318 "nothing of the second was registered"
1319 );
1320 }
1321
1322 #[test]
1324 fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1325 let map = OrderIdMap::new();
1326 let cid = ClientOrderId::new("reused");
1327 map.register(cid.clone(), 1, test_context()).unwrap();
1328
1329 assert_eq!(
1330 map.release_client_id(1).map(|(id, _)| id),
1331 Some(cid.clone())
1332 );
1333 assert!(map.is_empty(), "no order is live");
1334 assert_eq!(
1335 map.get_client_id(1),
1336 Some(cid.clone()),
1337 "a late execution resolves"
1338 );
1339
1340 map.register(cid.clone(), 2, test_context()).unwrap();
1342 assert_eq!(map.get_ib_id(&cid), Some(2));
1343 assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1344 }
1345
1346 #[test]
1349 fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1350 let map = OrderIdMap::new();
1351 let cid = ClientOrderId::new("reused");
1352 map.register(cid.clone(), 1, test_context()).unwrap();
1353 let _ = map.release_client_id(1);
1354 map.register(cid.clone(), 2, test_context()).unwrap();
1355
1356 assert!(
1357 map.remove_and_get_context(1).is_some(),
1358 "the first order's entry goes"
1359 );
1360 assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1361 assert!(map.remove_by_ib_id(1).is_none());
1362 assert_eq!(map.get_ib_id(&cid), Some(2));
1363 }
1364
1365 #[test]
1367 fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1368 use std::time::Duration;
1369
1370 let map = OrderIdMap::new();
1371 let cid = ClientOrderId::new("reused");
1372 map.register(cid.clone(), 1, test_context()).unwrap();
1373 let _ = map.release_client_id(1);
1374 std::thread::sleep(Duration::from_millis(5));
1375 map.register(cid.clone(), 2, test_context()).unwrap();
1376
1377 assert_eq!(
1378 map.clear_stale(Duration::from_millis(5)),
1379 1,
1380 "only the filled one"
1381 );
1382 assert_eq!(map.get_ib_id(&cid), Some(2));
1383 }
1384
1385 #[test]
1388 fn register_all_registers_every_order_or_none() {
1389 let map = OrderIdMap::new();
1390 map.register(ClientOrderId::new("live"), 1, test_context())
1391 .unwrap();
1392
1393 let clash = map.register_all([
1394 (ClientOrderId::new("a"), 10, test_context()),
1395 (ClientOrderId::new("live"), 11, test_context()),
1396 ]);
1397 assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1398
1399 let repeated = map.register_all([
1400 (ClientOrderId::new("b"), 20, test_context()),
1401 (ClientOrderId::new("b"), 21, test_context()),
1402 ]);
1403 assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1404
1405 assert_eq!(map.len(), 1, "neither registered anything");
1406 assert!(
1407 [10, 11, 20, 21]
1408 .iter()
1409 .all(|ib_id| map.get_client_id(*ib_id).is_none())
1410 );
1411
1412 map.register_all([
1413 (ClientOrderId::new("a"), 10, test_context()),
1414 (ClientOrderId::new("b"), 20, test_context()),
1415 ])
1416 .unwrap();
1417 assert_eq!(map.len(), 3);
1418 }
1419
1420 #[test]
1422 fn an_adopted_order_is_tracked_and_marked_adopted() {
1423 let map = OrderIdMap::new();
1424 let cid = ClientOrderId::new("listed");
1425
1426 map.adopt(cid.clone(), 7, test_context()).unwrap();
1427
1428 assert_eq!(map.get_ib_id(&cid), Some(7));
1429 assert_eq!(map.get_client_id(7), Some(cid.clone()));
1430 assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1431 }
1432
1433 #[test]
1436 fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1437 let map = OrderIdMap::new();
1438 let placed = ClientOrderId::new("placed");
1439 let adopted = ClientOrderId::new("adopted");
1440 let filled = ClientOrderId::new("filled");
1441 map.register(placed.clone(), 1, test_context()).unwrap();
1442 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1443 map.register(filled.clone(), 3, test_context()).unwrap();
1444 let _ = map.release_client_id(3);
1445
1446 for ib_id in [1, 2, 3] {
1447 let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1448 assert_eq!(
1449 map.adopt(other.clone(), ib_id, test_context()),
1450 Err(AdoptionRefused::IbOrderIdTracked)
1451 );
1452 assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1453 }
1454
1455 assert_eq!(map.get_client_id(1), Some(placed.clone()));
1456 assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1457 assert_eq!(map.get_client_id(3), Some(filled.clone()));
1458 assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1459 assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1460 assert!(
1461 map.registration(&filled).is_none(),
1462 "the filled order's id stays free"
1463 );
1464 assert_eq!(map.len(), 2);
1465 }
1466
1467 #[test]
1470 fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1471 let map = OrderIdMap::new();
1472 let placed = ClientOrderId::new("placed");
1473 let adopted = ClientOrderId::new("adopted");
1474 map.register(placed.clone(), 1, test_context()).unwrap();
1475 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1476 let before = [
1477 map.registration(&placed).map(|r| r.adopted),
1478 map.registration(&adopted).map(|r| r.adopted),
1479 ];
1480
1481 for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1482 assert_eq!(
1483 map.adopt(cid.clone(), ib_id, test_context()),
1484 Err(AdoptionRefused::ClientOrderIdInUse)
1485 );
1486 assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1487 }
1488
1489 assert_eq!(map.get_ib_id(&placed), Some(1));
1490 assert_eq!(map.get_ib_id(&adopted), Some(2));
1491 assert_eq!(
1492 [
1493 map.registration(&placed).map(|r| r.adopted),
1494 map.registration(&adopted).map(|r| r.adopted),
1495 ],
1496 before
1497 );
1498 assert_eq!(map.len(), 2);
1499 }
1500
1501 #[test]
1504 fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1505 let map = OrderIdMap::new();
1506 let cancelled = ClientOrderId::new("cancelled");
1507 let filled = ClientOrderId::new("filled");
1508 map.register(cancelled.clone(), 1, test_context()).unwrap();
1509 map.register(filled.clone(), 2, test_context()).unwrap();
1510 let _ = map.remove_and_get_context(1);
1511 let _ = map.release_client_id(2);
1512
1513 map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1514 map.adopt(filled.clone(), 12, test_context()).unwrap();
1515
1516 assert_eq!(map.get_ib_id(&cancelled), Some(11));
1517 assert_eq!(map.get_ib_id(&filled), Some(12));
1518 assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1519 assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1520 assert_eq!(
1521 map.get_client_id(2),
1522 Some(filled),
1523 "the filled order's executions resolve"
1524 );
1525 }
1526
1527 #[test]
1528 fn test_pending_cancels_insert_remove() {
1529 let cancels = PendingCancels::new();
1530 assert!(cancels.is_empty());
1531
1532 cancels.insert(42);
1534 assert_eq!(cancels.len(), 1);
1535 assert!(!cancels.is_empty());
1536
1537 assert!(cancels.remove(42));
1539 assert!(cancels.is_empty());
1540
1541 assert!(!cancels.remove(42));
1543 assert!(!cancels.remove(999));
1544 }
1545
1546 #[test]
1547 fn test_pending_cancels_multiple() {
1548 let cancels = PendingCancels::new();
1549
1550 cancels.insert(1);
1551 cancels.insert(2);
1552 cancels.insert(3);
1553 assert_eq!(cancels.len(), 3);
1554
1555 assert!(cancels.remove(2));
1557 assert_eq!(cancels.len(), 2);
1558
1559 assert!(cancels.remove(1));
1561 assert!(cancels.remove(3));
1562 assert!(cancels.is_empty());
1563 }
1564
1565 #[test]
1566 fn test_pending_cancels_clear_stale() {
1567 use std::time::Duration;
1568
1569 let cancels = PendingCancels::new();
1570
1571 cancels.insert(1);
1572 cancels.insert(2);
1573
1574 let cleared = cancels.clear_stale(Duration::ZERO);
1576 assert_eq!(cleared, 2);
1577 assert!(cancels.is_empty());
1578
1579 cancels.insert(10);
1581 cancels.insert(20);
1582
1583 let cleared = cancels.clear_stale(Duration::from_secs(3600));
1585 assert_eq!(cleared, 0);
1586 assert_eq!(cancels.len(), 2);
1587 }
1588
1589 #[test]
1590 fn test_pending_cancels_duplicate_insert() {
1591 let cancels = PendingCancels::new();
1592
1593 cancels.insert(42);
1594 cancels.insert(42);
1595
1596 assert_eq!(cancels.len(), 1);
1598 assert!(cancels.remove(42));
1599 assert!(cancels.is_empty());
1600 }
1601
1602 #[test]
1603 fn test_build_stop_order() {
1604 let order = build_ib_order(
1605 rustrade_instrument::Side::Sell,
1606 100.0,
1607 &OrderKind::Stop {
1608 trigger_price: Decimal::from(45),
1609 },
1610 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1612 )
1613 .unwrap();
1614
1615 assert_eq!(order.action, Action::Sell);
1616 assert_eq!(order.total_quantity, 100.0);
1617 assert_eq!(order.order_type, "STP");
1618 assert_eq!(order.aux_price, Some(45.0));
1619 assert_eq!(order.limit_price, None);
1620 }
1621
1622 #[test]
1623 fn test_build_stop_limit_order() {
1624 let order = build_ib_order(
1625 rustrade_instrument::Side::Sell,
1626 100.0,
1627 &OrderKind::StopLimit {
1628 trigger_price: Decimal::from(44),
1629 },
1630 Some(Decimal::from(45)), &TimeInForce::GoodUntilCancelled { post_only: false },
1632 )
1633 .unwrap();
1634
1635 assert_eq!(order.action, Action::Sell);
1636 assert_eq!(order.total_quantity, 100.0);
1637 assert_eq!(order.order_type, "STP LMT");
1638 assert_eq!(order.aux_price, Some(44.0)); assert_eq!(order.limit_price, Some(45.0)); }
1641
1642 #[test]
1643 fn test_build_trailing_stop_percentage() {
1644 let order = build_ib_order(
1645 rustrade_instrument::Side::Sell,
1646 100.0,
1647 &OrderKind::TrailingStop {
1648 offset: Decimal::from(5),
1649 offset_type: TrailingOffsetType::Percentage,
1650 activation_price: None,
1651 },
1652 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1654 )
1655 .unwrap();
1656
1657 assert_eq!(order.action, Action::Sell);
1658 assert_eq!(order.total_quantity, 100.0);
1659 assert_eq!(order.order_type, "TRAIL");
1660 assert_eq!(order.trailing_percent, Some(5.0));
1661 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, None); }
1664
1665 #[test]
1666 fn test_build_trailing_stop_absolute() {
1667 let order = build_ib_order(
1668 rustrade_instrument::Side::Sell,
1669 100.0,
1670 &OrderKind::TrailingStop {
1671 offset: Decimal::from(2),
1672 offset_type: TrailingOffsetType::Absolute,
1673 activation_price: None,
1674 },
1675 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1677 )
1678 .unwrap();
1679
1680 assert_eq!(order.action, Action::Sell);
1681 assert_eq!(order.total_quantity, 100.0);
1682 assert_eq!(order.order_type, "TRAIL");
1683 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.trailing_percent, None);
1685 assert_eq!(order.trail_stop_price, None);
1686 }
1687
1688 #[test]
1689 fn test_build_trailing_stop_limit_absolute() {
1690 let order = build_ib_order(
1691 rustrade_instrument::Side::Sell,
1692 100.0,
1693 &OrderKind::TrailingStopLimit {
1694 offset: Decimal::from(2),
1695 offset_type: TrailingOffsetType::Absolute,
1696 stop_price: Decimal::from(95),
1697 limit_offset: Decimal::try_from(0.5).unwrap(),
1698 },
1699 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1701 )
1702 .unwrap();
1703
1704 assert_eq!(order.action, Action::Sell);
1705 assert_eq!(order.total_quantity, 100.0);
1706 assert_eq!(order.order_type, "TRAIL LIMIT");
1707 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.limit_price_offset, Some(0.5)); assert_eq!(order.trailing_percent, None);
1710 assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1713
1714 #[test]
1715 fn test_build_trailing_stop_limit_percentage() {
1716 let order = build_ib_order(
1717 rustrade_instrument::Side::Sell,
1718 100.0,
1719 &OrderKind::TrailingStopLimit {
1720 offset: Decimal::from(5),
1721 offset_type: TrailingOffsetType::Percentage,
1722 stop_price: Decimal::from(95),
1723 limit_offset: Decimal::try_from(0.5).unwrap(),
1724 },
1725 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1727 )
1728 .unwrap();
1729
1730 assert_eq!(order.action, Action::Sell);
1731 assert_eq!(order.total_quantity, 100.0);
1732 assert_eq!(order.order_type, "TRAIL LIMIT");
1733 assert_eq!(order.trailing_percent, Some(5.0));
1734 assert_eq!(order.limit_price_offset, Some(0.5));
1735 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1739
1740 #[test]
1742 fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1743 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1744 for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1745 let kind = OrderKind::TrailingStopLimit {
1746 offset: Decimal::from(2),
1747 offset_type,
1748 stop_price,
1749 limit_offset: Decimal::from(1),
1750 };
1751 assert_eq!(
1752 build_ib_order(
1753 rustrade_instrument::Side::Sell,
1754 1.0,
1755 &kind,
1756 None,
1757 &TimeInForce::GoodUntilCancelled { post_only: false },
1758 ),
1759 Err(OrderMappingError::NonPositiveStopPrice(kind)),
1760 "{kind}"
1761 );
1762 }
1763 }
1764 }
1765
1766 #[test]
1769 fn test_build_trailing_stop_limit_rejects_limit_price() {
1770 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1771 let kind = OrderKind::TrailingStopLimit {
1772 offset: Decimal::from(2),
1773 offset_type,
1774 stop_price: Decimal::from(95),
1775 limit_offset: Decimal::from(1),
1776 };
1777 assert_eq!(
1778 build_ib_order(
1779 rustrade_instrument::Side::Sell,
1780 1.0,
1781 &kind,
1782 Some(Decimal::from(94)),
1783 &TimeInForce::GoodUntilCancelled { post_only: false },
1784 ),
1785 Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1786 "{kind}"
1787 );
1788 }
1789 }
1790
1791 #[test]
1792 fn test_build_trailing_stop_basis_points_unsupported() {
1793 let result = build_ib_order(
1794 rustrade_instrument::Side::Sell,
1795 100.0,
1796 &OrderKind::TrailingStop {
1797 offset: Decimal::from(50), offset_type: TrailingOffsetType::BasisPoints,
1799 activation_price: None,
1800 },
1801 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1803 );
1804
1805 assert!(matches!(
1806 result,
1807 Err(OrderMappingError::UnsupportedOffsetType(
1808 TrailingOffsetType::BasisPoints
1809 ))
1810 ));
1811 }
1812
1813 #[test]
1815 fn test_build_unresolved_kind_unsupported() {
1816 let kind = OrderKind::ConditionalUnresolved {
1817 family: crate::order::ConditionalFamily::StopLimit,
1818 trigger_price: Some(Decimal::from(95)),
1819 };
1820 let result = build_ib_order(
1821 rustrade_instrument::Side::Sell,
1822 100.0,
1823 &kind,
1824 Some(Decimal::from(94)),
1825 &TimeInForce::GoodUntilCancelled { post_only: false },
1826 );
1827 assert!(matches!(result, Err(OrderMappingError::UnsupportedOrderKind(k)) if k == kind));
1828 }
1829
1830 #[test]
1831 fn test_build_trailing_stop_limit_basis_points_unsupported() {
1832 let result = build_ib_order(
1833 rustrade_instrument::Side::Sell,
1834 100.0,
1835 &OrderKind::TrailingStopLimit {
1836 offset: Decimal::from(50),
1837 offset_type: TrailingOffsetType::BasisPoints,
1838 stop_price: Decimal::from(95),
1839 limit_offset: Decimal::try_from(0.5).unwrap(),
1840 },
1841 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1843 );
1844
1845 assert!(matches!(
1846 result,
1847 Err(OrderMappingError::UnsupportedOffsetType(
1848 TrailingOffsetType::BasisPoints
1849 ))
1850 ));
1851 }
1852
1853 #[test]
1855 fn test_build_activated_and_fixed_limit_trailing_stops_unsupported() {
1856 let tif = TimeInForce::GoodUntilCancelled { post_only: false };
1857 for kind in [
1858 OrderKind::TrailingStop {
1859 offset: Decimal::from(2),
1860 offset_type: TrailingOffsetType::Percentage,
1861 activation_price: Some(Decimal::from(150)),
1862 },
1863 OrderKind::TrailingStopFixedLimit {
1864 offset: Decimal::from(2),
1865 offset_type: TrailingOffsetType::Percentage,
1866 activation_price: None,
1867 },
1868 ] {
1869 let result = build_ib_order(
1870 rustrade_instrument::Side::Sell,
1871 100.0,
1872 &kind,
1873 Some(Decimal::from(140)),
1874 &tif,
1875 );
1876 assert!(
1877 matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
1878 "{kind}: {result:?}"
1879 );
1880 }
1881 }
1882
1883 #[test]
1884 fn test_build_take_profit_unsupported() {
1885 let result = build_ib_order(
1886 rustrade_instrument::Side::Sell,
1887 100.0,
1888 &OrderKind::TakeProfit {
1889 trigger_price: Decimal::from(150),
1890 },
1891 None,
1892 &TimeInForce::GoodUntilCancelled { post_only: false },
1893 );
1894
1895 assert!(matches!(
1896 result,
1897 Err(OrderMappingError::UnsupportedOrderKind(
1898 OrderKind::TakeProfit { .. }
1899 ))
1900 ));
1901 }
1902
1903 #[test]
1904 fn test_build_take_profit_limit_unsupported() {
1905 let result = build_ib_order(
1906 rustrade_instrument::Side::Sell,
1907 100.0,
1908 &OrderKind::TakeProfitLimit {
1909 trigger_price: Decimal::from(150),
1910 },
1911 None,
1912 &TimeInForce::GoodUntilCancelled { post_only: false },
1913 );
1914
1915 assert!(matches!(
1916 result,
1917 Err(OrderMappingError::UnsupportedOrderKind(
1918 OrderKind::TakeProfitLimit { .. }
1919 ))
1920 ));
1921 }
1922
1923 #[test]
1928 fn test_build_market_order_at_open() {
1929 let order = build_ib_order(
1930 rustrade_instrument::Side::Buy,
1931 100.0,
1932 &OrderKind::Market,
1933 None, &TimeInForce::AtOpen,
1935 )
1936 .unwrap();
1937
1938 assert_eq!(order.action, Action::Buy);
1939 assert_eq!(order.total_quantity, 100.0);
1940 assert_eq!(order.order_type, "MKT");
1941 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1942 }
1943
1944 #[test]
1945 fn test_build_limit_order_at_open() {
1946 let order = build_ib_order(
1947 rustrade_instrument::Side::Sell,
1948 50.0,
1949 &OrderKind::Limit,
1950 Some(Decimal::from(150)),
1951 &TimeInForce::AtOpen,
1952 )
1953 .unwrap();
1954
1955 assert_eq!(order.action, Action::Sell);
1956 assert_eq!(order.total_quantity, 50.0);
1957 assert_eq!(order.order_type, "LMT");
1958 assert_eq!(order.limit_price, Some(150.0));
1959 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1960 }
1961
1962 #[test]
1963 fn test_build_stop_order_at_open() {
1964 let order = build_ib_order(
1966 rustrade_instrument::Side::Sell,
1967 100.0,
1968 &OrderKind::Stop {
1969 trigger_price: Decimal::from(45),
1970 },
1971 None, &TimeInForce::AtOpen,
1973 )
1974 .unwrap();
1975
1976 assert_eq!(order.order_type, "STP");
1977 assert_eq!(order.aux_price, Some(45.0));
1978 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1979 }
1980
1981 #[test]
1982 fn test_build_market_on_close() {
1983 let order = build_ib_order(
1984 rustrade_instrument::Side::Buy,
1985 100.0,
1986 &OrderKind::Market,
1987 None, &TimeInForce::AtClose,
1989 )
1990 .unwrap();
1991
1992 assert_eq!(order.action, Action::Buy);
1993 assert_eq!(order.total_quantity, 100.0);
1994 assert_eq!(order.order_type, "MOC");
1995 }
1996
1997 #[test]
1998 fn test_build_limit_on_close() {
1999 let order = build_ib_order(
2000 rustrade_instrument::Side::Sell,
2001 50.0,
2002 &OrderKind::Limit,
2003 Some(Decimal::from(150)),
2004 &TimeInForce::AtClose,
2005 )
2006 .unwrap();
2007
2008 assert_eq!(order.action, Action::Sell);
2009 assert_eq!(order.total_quantity, 50.0);
2010 assert_eq!(order.order_type, "LOC");
2011 assert_eq!(order.limit_price, Some(150.0));
2012 }
2013
2014 #[test]
2015 fn test_build_stop_at_close_unsupported() {
2016 let result = build_ib_order(
2017 rustrade_instrument::Side::Sell,
2018 100.0,
2019 &OrderKind::Stop {
2020 trigger_price: Decimal::from(45),
2021 },
2022 None, &TimeInForce::AtClose,
2024 );
2025
2026 assert!(matches!(
2027 result,
2028 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2029 OrderKind::Stop { .. }
2030 ))
2031 ));
2032 }
2033
2034 #[test]
2035 fn test_build_stop_limit_at_close_unsupported() {
2036 let result = build_ib_order(
2037 rustrade_instrument::Side::Sell,
2038 100.0,
2039 &OrderKind::StopLimit {
2040 trigger_price: Decimal::from(44),
2041 },
2042 Some(Decimal::from(45)),
2043 &TimeInForce::AtClose,
2044 );
2045
2046 assert!(matches!(
2047 result,
2048 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2049 OrderKind::StopLimit { .. }
2050 ))
2051 ));
2052 }
2053
2054 #[test]
2055 fn test_build_trailing_stop_at_close_unsupported() {
2056 let result = build_ib_order(
2057 rustrade_instrument::Side::Sell,
2058 100.0,
2059 &OrderKind::TrailingStop {
2060 offset: Decimal::from(5),
2061 offset_type: TrailingOffsetType::Percentage,
2062 activation_price: None,
2063 },
2064 None, &TimeInForce::AtClose,
2066 );
2067
2068 assert!(matches!(
2069 result,
2070 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2071 OrderKind::TrailingStop { .. }
2072 ))
2073 ));
2074 }
2075
2076 #[test]
2077 fn test_build_trailing_stop_limit_at_close_unsupported() {
2078 let result = build_ib_order(
2079 rustrade_instrument::Side::Sell,
2080 100.0,
2081 &OrderKind::TrailingStopLimit {
2082 offset: Decimal::from(5),
2083 offset_type: TrailingOffsetType::Absolute,
2084 stop_price: Decimal::from(95),
2085 limit_offset: Decimal::from(1),
2086 },
2087 None, &TimeInForce::AtClose,
2089 );
2090
2091 assert!(matches!(
2092 result,
2093 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2094 OrderKind::TrailingStopLimit { .. }
2095 ))
2096 ));
2097 }
2098
2099 #[test]
2100 fn test_build_good_till_date_order() {
2101 use chrono::{TimeZone, Utc};
2102
2103 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2104 let order = build_ib_order(
2105 rustrade_instrument::Side::Buy,
2106 100.0,
2107 &OrderKind::Limit,
2108 Some(Decimal::from(150)),
2109 &TimeInForce::GoodTillDate { expiry },
2110 )
2111 .unwrap();
2112
2113 assert_eq!(order.action, Action::Buy);
2114 assert_eq!(order.total_quantity, 100.0);
2115 assert_eq!(order.order_type, "LMT");
2116 assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2117 assert_eq!(order.good_till_date, "20250630-23:59:59");
2118 }
2119
2120 #[test]
2125 fn a_good_till_date_is_read_in_its_zone_or_the_sessions_or_as_utc() {
2126 use chrono_tz::{America::New_York, Asia::Singapore};
2127 let utc = |time: &str| Some(chrono::DateTime::parse_from_rfc3339(time).unwrap().to_utc());
2128 for (value, session_zone, expected) in [
2129 ("20261027-04:48:41", None, "2026-10-27T04:48:41Z"),
2130 ("20261027-04:48:41", Some(Singapore), "2026-10-27T04:48:41Z"),
2131 ("20261027 12:48:41", Some(Singapore), "2026-10-27T04:48:41Z"),
2132 (
2133 "20261027 12:48:41 Asia/Singapore",
2134 None,
2135 "2026-10-27T04:48:41Z",
2136 ),
2137 (
2138 "20261027 12:48:41 Asia/Singapore",
2139 Some(New_York),
2140 "2026-10-27T04:48:41Z",
2141 ),
2142 ("20261027 04:48:41", None, "2026-10-27T04:48:41Z"),
2144 (
2146 "20261027 04:48:41 Some Standard Time",
2147 Some(Singapore),
2148 "2026-10-27T04:48:41Z",
2149 ),
2150 (
2152 "20260308 02:30:00 America/New_York",
2153 None,
2154 "2026-03-08T02:30:00Z",
2155 ),
2156 ("20260308 02:30:00", Some(New_York), "2026-03-08T02:30:00Z"),
2157 ] {
2158 assert_eq!(
2159 parse_gtd_datetime(value, session_zone),
2160 utc(expected),
2161 "{value} in {session_zone:?}"
2162 );
2163 }
2164 assert_eq!(parse_gtd_datetime("not a date", Some(Singapore)), None);
2165 assert_eq!(parse_gtd_datetime("20261027-04:48:41 UTC", None), None);
2166 }
2167
2168 #[test]
2169 fn test_format_gtd_datetime() {
2170 use chrono::{TimeZone, Utc};
2171
2172 let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2173 assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2174
2175 let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2176 assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2177 }
2178
2179 #[test]
2184 fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2185 let orders = build_ib_bracket_with_oca(
2186 1000,
2187 Action::Buy,
2188 10.0,
2189 150.0,
2190 160.0,
2191 140.0,
2192 IbTimeInForce::Day,
2193 )
2194 .unwrap();
2195
2196 assert_eq!(orders.len(), 3);
2197
2198 assert!(!orders[1].oca_group.is_empty());
2200 assert_eq!(orders[1].oca_group, orders[2].oca_group);
2201
2202 assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2204 assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2205
2206 assert!(orders[0].oca_group.is_empty());
2208 assert_eq!(orders[0].oca_type, OcaType::None);
2209 }
2210
2211 #[test]
2212 fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2213 let orders = build_ib_bracket_with_oca(
2214 1000,
2215 Action::Buy,
2216 10.0,
2217 150.0,
2218 160.0,
2219 140.0,
2220 IbTimeInForce::Day,
2221 )
2222 .unwrap();
2223
2224 assert_eq!(orders[1].parent_id, 1000); assert_eq!(orders[2].parent_id, 1000); assert_eq!(orders[0].parent_id, 0);
2230 }
2231
2232 #[test]
2233 fn test_build_ib_bracket_with_oca_transmit_flags() {
2234 let orders = build_ib_bracket_with_oca(
2235 1000,
2236 Action::Buy,
2237 10.0,
2238 150.0,
2239 160.0,
2240 140.0,
2241 IbTimeInForce::Day,
2242 )
2243 .unwrap();
2244
2245 assert!(!orders[0].transmit); assert!(!orders[1].transmit); assert!(orders[2].transmit); }
2250
2251 #[test]
2252 fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2253 let orders = build_ib_bracket_with_oca(
2254 500,
2255 Action::Sell,
2256 5.0,
2257 100.0,
2258 90.0,
2259 110.0,
2260 IbTimeInForce::Day,
2261 )
2262 .unwrap();
2263
2264 assert_eq!(orders[0].order_id, 500); assert_eq!(orders[1].order_id, 501); assert_eq!(orders[2].order_id, 502); }
2268
2269 #[test]
2270 fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2271 let result =
2273 build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2274 assert!(matches!(
2275 result,
2276 Err(OrderMappingError::InvalidBracketPrices(_))
2277 ));
2278
2279 let result =
2281 build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2282 assert!(matches!(
2283 result,
2284 Err(OrderMappingError::InvalidBracketPrices(_))
2285 ));
2286 }
2287
2288 #[test]
2289 fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2290 let orders =
2291 build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2292 .unwrap();
2293
2294 assert!(orders[1].oca_group.contains("42"));
2295 assert!(orders[2].oca_group.contains("42"));
2296 }
2297
2298 #[test]
2299 fn test_build_ib_bracket_with_oca_order_types() {
2300 let orders = build_ib_bracket_with_oca(
2301 1000,
2302 Action::Buy,
2303 100.0,
2304 150.0,
2305 160.0,
2306 140.0,
2307 IbTimeInForce::Day,
2308 )
2309 .unwrap();
2310
2311 assert_eq!(orders[0].order_type, "LMT");
2313 assert_eq!(orders[0].limit_price, Some(150.0));
2314
2315 assert_eq!(orders[1].order_type, "LMT");
2317 assert_eq!(orders[1].limit_price, Some(160.0));
2318
2319 assert_eq!(orders[2].order_type, "STP");
2321 assert_eq!(orders[2].aux_price, Some(140.0));
2322 }
2323
2324 #[test]
2325 fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2326 let buy_orders =
2328 build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2329 .unwrap();
2330 assert_eq!(buy_orders[0].action, Action::Buy);
2331 assert_eq!(buy_orders[1].action, Action::Sell);
2332 assert_eq!(buy_orders[2].action, Action::Sell);
2333
2334 let sell_orders = build_ib_bracket_with_oca(
2336 200,
2337 Action::Sell,
2338 10.0,
2339 50.0,
2340 45.0,
2341 55.0,
2342 IbTimeInForce::Day,
2343 )
2344 .unwrap();
2345 assert_eq!(sell_orders[0].action, Action::Sell);
2346 assert_eq!(sell_orders[1].action, Action::Buy);
2347 assert_eq!(sell_orders[2].action, Action::Buy);
2348 }
2349
2350 #[test]
2351 fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2352 let orders = build_ib_bracket_with_oca(
2353 1000,
2354 Action::Buy,
2355 10.0,
2356 150.0,
2357 160.0,
2358 140.0,
2359 IbTimeInForce::GoodTillCanceled,
2360 )
2361 .unwrap();
2362
2363 assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2364 assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2365 assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2366 }
2367
2368 #[test]
2371 fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2372 let longest = "a".repeat(MAX_ORDER_REF_LEN);
2373 assert_eq!(
2374 order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2375 longest
2376 );
2377 assert_eq!(
2378 order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2379 "cid-1_tp"
2380 );
2381
2382 let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2383 for refused in ["", too_long.as_str(), "ordre-é"] {
2384 let cid = ClientOrderId::new(refused);
2385 assert_eq!(
2386 order_ref(&cid),
2387 Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2388 );
2389 }
2390 }
2391
2392 #[test]
2395 fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2396 use chrono::TimeZone;
2397 use rust_decimal_macros::dec;
2398
2399 let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2400 let kinds = [
2401 (OrderKind::Market, None),
2402 (OrderKind::Limit, Some(dec!(150.25))),
2403 (
2404 OrderKind::Stop {
2405 trigger_price: dec!(140.5),
2406 },
2407 None,
2408 ),
2409 (
2410 OrderKind::StopLimit {
2411 trigger_price: dec!(140.5),
2412 },
2413 Some(dec!(140)),
2414 ),
2415 (
2416 OrderKind::TrailingStop {
2417 offset: dec!(2.5),
2418 offset_type: TrailingOffsetType::Percentage,
2419 activation_price: None,
2420 },
2421 None,
2422 ),
2423 (
2424 OrderKind::TrailingStop {
2425 offset: dec!(1.75),
2426 offset_type: TrailingOffsetType::Absolute,
2427 activation_price: None,
2428 },
2429 None,
2430 ),
2431 (
2432 OrderKind::TrailingStopLimit {
2433 offset: dec!(2),
2434 offset_type: TrailingOffsetType::Percentage,
2435 stop_price: dec!(130.5),
2436 limit_offset: dec!(0.5),
2437 },
2438 None,
2439 ),
2440 (
2441 OrderKind::TrailingStopLimit {
2442 offset: dec!(1.5),
2443 offset_type: TrailingOffsetType::Absolute,
2444 stop_price: dec!(139.75),
2445 limit_offset: dec!(0.25),
2446 },
2447 None,
2448 ),
2449 ];
2450 let tifs = [
2451 TimeInForce::GoodUntilCancelled { post_only: false },
2452 TimeInForce::GoodUntilEndOfDay,
2453 TimeInForce::ImmediateOrCancel,
2454 TimeInForce::FillOrKill,
2455 TimeInForce::AtOpen,
2456 TimeInForce::GoodTillDate { expiry },
2457 ];
2458
2459 for (kind, price) in kinds {
2460 for time_in_force in tifs {
2461 let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2462 assert_eq!(
2463 order_shape_from_ib(&order, None),
2464 Ok(OrderShape {
2465 kind,
2466 price,
2467 time_in_force
2468 }),
2469 "{kind} {time_in_force}"
2470 );
2471 }
2472 }
2473
2474 for (kind, price) in [
2476 (OrderKind::Market, None),
2477 (OrderKind::Limit, Some(dec!(99))),
2478 ] {
2479 let order =
2480 build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2481 assert_eq!(
2482 order_shape_from_ib(&order, None),
2483 Ok(OrderShape {
2484 kind,
2485 price,
2486 time_in_force: TimeInForce::AtClose
2487 }),
2488 "{kind}"
2489 );
2490 }
2491 }
2492
2493 #[test]
2494 fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2495 use chrono::TimeZone;
2496
2497 let order = Order {
2498 order_type: "MKT".to_owned(),
2499 tif: IbTimeInForce::GoodTillDate,
2500 good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2501 ..Order::default()
2502 };
2503 assert_eq!(
2504 order_shape_from_ib(&order, None).unwrap().time_in_force,
2505 TimeInForce::GoodTillDate {
2506 expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2507 }
2508 );
2509
2510 let order = Order {
2512 good_till_date: "20261009 16:00:00".to_owned(),
2513 ..order
2514 };
2515 assert_eq!(
2516 order_shape_from_ib(&order, Some(chrono_tz::Asia::Singapore))
2517 .unwrap()
2518 .time_in_force,
2519 TimeInForce::GoodTillDate {
2520 expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 8, 0, 0).unwrap()
2521 }
2522 );
2523 }
2524
2525 #[test]
2526 fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2527 let order = |order_type: &str, tif: IbTimeInForce| Order {
2528 order_type: order_type.to_owned(),
2529 tif,
2530 limit_price: Some(1.0),
2531 ..Order::default()
2532 };
2533 for (order, reason) in [
2534 (order("REL", IbTimeInForce::Day), "order type"),
2535 (
2536 order("LMT", IbTimeInForce::DayTillCanceled),
2537 "time in force",
2538 ),
2539 (
2540 Order {
2541 limit_price: None,
2542 ..order("LMT", IbTimeInForce::Day)
2543 },
2544 "without limit_price",
2545 ),
2546 (order("STP", IbTimeInForce::Day), "without aux_price"),
2547 (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2548 (
2549 Order {
2550 aux_price: Some(2.0),
2551 limit_price_offset: Some(0.5),
2552 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2553 },
2554 "without trail_stop_price",
2555 ),
2556 (
2557 Order {
2558 aux_price: Some(2.0),
2559 trail_stop_price: Some(0.0),
2560 limit_price_offset: Some(0.5),
2561 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2562 },
2563 "not positive",
2564 ),
2565 (
2566 Order {
2567 trailing_percent: Some(5.0),
2568 trail_stop_price: Some(-1.0),
2569 limit_price_offset: Some(0.5),
2570 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2571 },
2572 "not positive",
2573 ),
2574 ] {
2575 let error = order_shape_from_ib(&order, None).unwrap_err();
2576 assert!(error.contains(reason), "{error:?} should name {reason:?}");
2577 }
2578 }
2579
2580 #[test]
2581 fn action_to_side_reads_a_short_sale_as_a_sell() {
2582 assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2583 for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2584 assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2585 }
2586 }
2587}