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)]
100pub struct OrderContext {
101 pub instrument: InstrumentNameExchange,
102 pub side: Side,
103 pub price: Option<Decimal>,
105 pub quantity: Decimal,
106 pub kind: OrderKind,
107 pub time_in_force: TimeInForce,
108}
109
110#[derive(Debug, Clone)]
135pub struct OrderIdMap {
136 inner: Arc<RwLock<OrderIdMapInner>>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub(crate) struct Registration {
142 pub(crate) age: std::time::Duration,
144 pub(crate) adopted: bool,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
150pub(crate) enum AdoptionRefused {
151 #[error("its client order id names another live order")]
152 ClientOrderIdInUse,
153 #[error("its IB order id names another tracked order")]
154 IbOrderIdTracked,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Error)]
159#[error("client order id {0} already names a live IBKR order")]
160pub struct ClientOrderIdInUse(pub ClientOrderId);
161
162#[derive(Debug, Default)]
163struct OrderIdMapInner {
164 cid_to_ib: FnvHashMap<ClientOrderId, i32>,
166 ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
171 adopted: FnvHashSet<i32>,
174}
175
176impl OrderIdMapInner {
177 fn remove(&mut self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
179 let (client_id, ctx, _) = self.ib_to_entry.remove(&ib_id)?;
180 self.adopted.remove(&ib_id);
181 self.release(&client_id, ib_id);
182 Some((client_id, ctx))
183 }
184
185 fn in_use<'a>(
188 &self,
189 mut orders: impl Iterator<Item = &'a ClientOrderId>,
190 client_id: &ClientOrderId,
191 ) -> bool {
192 orders.any(|earlier| earlier == client_id) || self.cid_to_ib.contains_key(client_id)
193 }
194
195 fn insert(
196 &mut self,
197 client_id: ClientOrderId,
198 ib_id: i32,
199 context: OrderContext,
200 now: Instant,
201 ) {
202 self.adopted.remove(&ib_id);
203 self.cid_to_ib.insert(client_id.clone(), ib_id);
204 self.ib_to_entry.insert(ib_id, (client_id, context, now));
205 }
206
207 fn release(&mut self, client_id: &ClientOrderId, ib_id: i32) {
209 if self.cid_to_ib.get(client_id) == Some(&ib_id) {
210 self.cid_to_ib.remove(client_id);
211 }
212 }
213}
214
215impl OrderIdMap {
216 pub fn new() -> Self {
217 Self {
218 inner: Arc::new(RwLock::new(OrderIdMapInner::default())),
219 }
220 }
221
222 pub fn register(
227 &self,
228 client_id: ClientOrderId,
229 ib_id: i32,
230 context: OrderContext,
231 ) -> Result<(), ClientOrderIdInUse> {
232 self.register_all([(client_id, ib_id, context)])
233 }
234
235 pub fn register_all<const N: usize>(
241 &self,
242 orders: [(ClientOrderId, i32, OrderContext); N],
243 ) -> Result<(), ClientOrderIdInUse> {
244 let mut inner = self.inner.write();
245 for (index, (client_id, _, _)) in orders.iter().enumerate() {
246 if inner.in_use(
247 orders[..index].iter().map(|(earlier, _, _)| earlier),
248 client_id,
249 ) {
250 return Err(ClientOrderIdInUse(client_id.clone()));
251 }
252 }
253 let now = Instant::now();
254 for (client_id, ib_id, context) in orders {
255 inner.insert(client_id, ib_id, context, now);
256 }
257 Ok(())
258 }
259
260 pub(crate) fn adopt(
268 &self,
269 client_id: ClientOrderId,
270 ib_id: i32,
271 context: OrderContext,
272 ) -> Result<(), AdoptionRefused> {
273 let mut inner = self.inner.write();
274 if inner.ib_to_entry.contains_key(&ib_id) {
275 return Err(AdoptionRefused::IbOrderIdTracked);
276 }
277 if inner.in_use(std::iter::empty(), &client_id) {
278 return Err(AdoptionRefused::ClientOrderIdInUse);
279 }
280 inner.insert(client_id, ib_id, context, Instant::now());
281 inner.adopted.insert(ib_id);
282 Ok(())
283 }
284
285 pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
287 self.inner.read().cid_to_ib.get(client_id).copied()
288 }
289
290 pub(crate) fn registration(&self, client_id: &ClientOrderId) -> Option<Registration> {
297 let inner = self.inner.read();
298 let ib_id = inner.cid_to_ib.get(client_id)?;
299 let (_, _, registered_at) = inner.ib_to_entry.get(ib_id)?;
300 Some(Registration {
301 age: registered_at.elapsed(),
302 adopted: inner.adopted.contains(ib_id),
303 })
304 }
305
306 pub fn contains(&self, ib_id: i32) -> bool {
308 self.inner.read().ib_to_entry.contains_key(&ib_id)
309 }
310
311 pub fn get_client_id(&self, ib_id: i32) -> Option<ClientOrderId> {
313 self.inner
314 .read()
315 .ib_to_entry
316 .get(&ib_id)
317 .map(|(cid, _, _)| cid.clone())
318 }
319
320 pub fn get_client_id_and_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
322 self.inner
323 .read()
324 .ib_to_entry
325 .get(&ib_id)
326 .map(|(cid, ctx, _)| (cid.clone(), ctx.clone()))
327 }
328
329 pub fn names_other_order(&self, client_id: &ClientOrderId, ib_id: i32) -> bool {
332 self.inner
333 .read()
334 .cid_to_ib
335 .get(client_id)
336 .is_some_and(|live| *live != ib_id)
337 }
338
339 pub fn release_client_id(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
345 let mut inner = self.inner.write();
346 let (client_id, ctx, _) = inner.ib_to_entry.get(&ib_id)?;
347 let (client_id, ctx) = (client_id.clone(), ctx.clone());
348 inner.release(&client_id, ib_id);
349 Some((client_id, ctx))
350 }
351
352 pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
358 self.inner.write().remove(ib_id)
359 }
360
361 pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
365 self.inner
366 .write()
367 .remove(ib_id)
368 .map(|(client_id, _)| client_id)
369 }
370
371 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
388 let mut inner = self.inner.write();
389 let before = inner.ib_to_entry.len();
390
391 let stale_ids: Vec<i32> = inner
393 .ib_to_entry
394 .iter()
395 .filter(|(_, (_, _, registered_at))| registered_at.elapsed() >= max_age)
396 .map(|(ib_id, _)| *ib_id)
397 .collect();
398
399 for ib_id in stale_ids {
400 inner.remove(ib_id);
401 }
402
403 before - inner.ib_to_entry.len()
404 }
405
406 pub fn len(&self) -> usize {
408 self.inner.read().cid_to_ib.len()
409 }
410
411 pub fn is_empty(&self) -> bool {
413 self.inner.read().cid_to_ib.is_empty()
414 }
415}
416
417impl Default for OrderIdMap {
418 fn default() -> Self {
419 Self::new()
420 }
421}
422
423#[derive(Debug, Clone)]
429pub struct PendingCancels {
430 inner: Arc<Mutex<FnvHashMap<i32, Instant>>>,
431}
432
433impl PendingCancels {
434 pub fn new() -> Self {
435 Self {
436 inner: Arc::new(Mutex::new(FnvHashMap::with_capacity_and_hasher(
437 8,
438 Default::default(),
439 ))),
440 }
441 }
442
443 pub fn insert(&self, ib_id: i32) {
445 self.inner.lock().insert(ib_id, Instant::now());
446 }
447
448 #[must_use]
452 pub fn remove(&self, ib_id: i32) -> bool {
453 self.inner.lock().remove(&ib_id).is_some()
454 }
455
456 pub(crate) fn contains(&self, ib_id: i32) -> bool {
459 self.inner.lock().contains_key(&ib_id)
460 }
461
462 #[must_use]
467 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
468 let mut map = self.inner.lock();
469 let before = map.len();
470 map.retain(|_, registered_at| registered_at.elapsed() < max_age);
471 before - map.len()
472 }
473
474 #[must_use]
476 pub fn len(&self) -> usize {
477 self.inner.lock().len()
478 }
479
480 #[must_use]
482 pub fn is_empty(&self) -> bool {
483 self.inner.lock().is_empty()
484 }
485}
486
487impl Default for PendingCancels {
488 fn default() -> Self {
489 Self::new()
490 }
491}
492
493pub(crate) fn side_to_action(side: rustrade_instrument::Side) -> Action {
495 match side {
496 rustrade_instrument::Side::Buy => Action::Buy,
497 rustrade_instrument::Side::Sell => Action::Sell,
498 }
499}
500
501pub(crate) fn action_to_side(action: &Action) -> rustrade_instrument::Side {
503 match action {
504 Action::Buy => rustrade_instrument::Side::Buy,
505 Action::Sell | Action::SellShort | Action::SellLong => rustrade_instrument::Side::Sell,
506 }
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
511pub(crate) struct OrderShape {
512 pub(crate) kind: OrderKind,
513 pub(crate) price: Option<Decimal>,
515 pub(crate) time_in_force: TimeInForce,
516}
517
518pub(crate) fn order_shape_from_ib(
529 order: &Order,
530 session_zone: Option<chrono_tz::Tz>,
531) -> Result<OrderShape, String> {
532 let order_type = order.order_type.as_str();
533 let decimal = |value: Option<f64>, field: &str| -> Result<Decimal, String> {
534 let value = value.ok_or_else(|| format!("{order_type} order without {field}"))?;
535 Decimal::try_from(value).map_err(|e| format!("{order_type} order's {field} {value}: {e}"))
536 };
537
538 let time_in_force = match &order.tif {
539 IbTimeInForce::Day => TimeInForce::GoodUntilEndOfDay,
540 IbTimeInForce::GoodTillCanceled => TimeInForce::GoodUntilCancelled { post_only: false },
541 IbTimeInForce::ImmediateOrCancel => TimeInForce::ImmediateOrCancel,
542 IbTimeInForce::FillOrKill => TimeInForce::FillOrKill,
543 IbTimeInForce::OnOpen => TimeInForce::AtOpen,
544 IbTimeInForce::GoodTillDate => TimeInForce::GoodTillDate {
545 expiry: parse_gtd_datetime(&order.good_till_date, session_zone).ok_or_else(|| {
546 format!(
547 "good-till-date time {:?} does not parse",
548 order.good_till_date
549 )
550 })?,
551 },
552 other => return Err(format!("time in force {other:?}")),
553 };
554
555 let trailing = || -> Result<(Decimal, TrailingOffsetType), String> {
558 match order.trailing_percent {
559 Some(_) => Ok((
560 decimal(order.trailing_percent, "trailing_percent")?,
561 TrailingOffsetType::Percentage,
562 )),
563 None => Ok((
564 decimal(order.aux_price, "aux_price")?,
565 TrailingOffsetType::Absolute,
566 )),
567 }
568 };
569
570 let (kind, price, time_in_force) = match order_type {
571 "MKT" => (OrderKind::Market, None, time_in_force),
572 "LMT" => (
573 OrderKind::Limit,
574 Some(decimal(order.limit_price, "limit_price")?),
575 time_in_force,
576 ),
577 "MOC" => (OrderKind::Market, None, TimeInForce::AtClose),
579 "LOC" => (
580 OrderKind::Limit,
581 Some(decimal(order.limit_price, "limit_price")?),
582 TimeInForce::AtClose,
583 ),
584 "STP" => (
585 OrderKind::Stop {
586 trigger_price: decimal(order.aux_price, "aux_price")?,
587 },
588 None,
589 time_in_force,
590 ),
591 "STP LMT" => (
592 OrderKind::StopLimit {
593 trigger_price: decimal(order.aux_price, "aux_price")?,
594 },
595 Some(decimal(order.limit_price, "limit_price")?),
596 time_in_force,
597 ),
598 "TRAIL" => {
599 let (offset, offset_type) = trailing()?;
600 (
601 OrderKind::TrailingStop {
602 offset,
603 offset_type,
604 activation_price: None,
605 },
606 None,
607 time_in_force,
608 )
609 }
610 "TRAIL LIMIT" => {
611 let (offset, offset_type) = trailing()?;
612 let stop_price = decimal(order.trail_stop_price, "trail_stop_price")?;
613 if stop_price <= Decimal::ZERO {
614 return Err(format!(
615 "{order_type} order's trail_stop_price {stop_price} is not positive"
616 ));
617 }
618 (
619 OrderKind::TrailingStopLimit {
620 offset,
621 offset_type,
622 stop_price,
623 limit_offset: decimal(order.limit_price_offset, "limit_price_offset")?,
624 },
625 None,
626 time_in_force,
627 )
628 }
629 other => return Err(format!("order type {other:?}")),
630 };
631
632 Ok(OrderShape {
633 kind,
634 price,
635 time_in_force,
636 })
637}
638
639fn parse_gtd_datetime(
650 value: &str,
651 session_zone: Option<chrono_tz::Tz>,
652) -> Option<chrono::DateTime<chrono::Utc>> {
653 chrono::NaiveDateTime::parse_from_str(value, GTD_FORMAT)
654 .map(|naive| naive.and_utc())
655 .ok()
656 .or_else(|| super::execution::parse_ib_timestamp(value, session_zone))
657 .or_else(|| {
658 let mut parts = value.split_whitespace();
659 let wall_clock = format!("{} {}", parts.next()?, parts.next()?);
660 let time = super::execution::parse_ib_timestamp(&wall_clock, Some(chrono_tz::UTC))?;
661 tracing::warn!(
662 good_till_date = value,
663 session_zone = session_zone.map(|zone| zone.name()),
664 "IBKR good-till-date could not be read in the zone it names or, naming none, in \
665 the session's; read as UTC"
666 );
667 Some(time)
668 })
669}
670
671#[non_exhaustive]
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub enum OrderMappingError {
675 PostOnlyNotSupported,
676 InvalidPrice(String),
678 MissingLimitPrice(OrderKind),
680 UnexpectedLimitPrice(OrderKind),
683 NonPositiveStopPrice(OrderKind),
685 UnsupportedOffsetType(TrailingOffsetType),
687 UnsupportedOrderKind(OrderKind),
689 UnsupportedOrderKindForAtClose(OrderKind),
692 AtCloseRequiresOrderTypeChange,
696 InvalidBracketPrices(String),
700 InvalidClientOrderId(ClientOrderId),
703}
704
705impl std::fmt::Display for OrderMappingError {
706 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
707 match self {
708 Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
709 Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
710 Self::MissingLimitPrice(k) => {
711 write!(f, "limit price required for {k} but was None")
712 }
713 Self::UnexpectedLimitPrice(k) => write!(
714 f,
715 "{k} takes no limit price: it follows from the stop price and limit offset"
716 ),
717 Self::NonPositiveStopPrice(k) => {
718 write!(f, "stop price must be positive for {k}")
719 }
720 Self::UnsupportedOffsetType(t) => {
721 write!(f, "trailing offset type {t:?} not supported by IBKR")
722 }
723 Self::UnsupportedOrderKind(k) => {
724 write!(f, "order kind {k} not supported by IBKR")
725 }
726 Self::UnsupportedOrderKindForAtClose(k) => {
727 write!(
728 f,
729 "AtClose TIF only valid with Market or Limit orders, got {k}"
730 )
731 }
732 Self::AtCloseRequiresOrderTypeChange => write!(
733 f,
734 "AtClose TIF must be routed through build_ib_order, which promotes \
735 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
736 ),
737 Self::InvalidBracketPrices(e) => write!(f, "invalid bracket prices: {e}"),
738 Self::InvalidClientOrderId(cid) => write!(
739 f,
740 "client order id {cid} is not an IB order reference: at most \
741 {MAX_ORDER_REF_LEN} ASCII characters"
742 ),
743 }
744 }
745}
746
747pub const MAX_ORDER_REF_LEN: usize = 128;
750
751pub fn order_ref(cid: &ClientOrderId) -> Result<String, OrderMappingError> {
760 let id = cid.0.as_str();
761 if id.is_empty() || id.len() > MAX_ORDER_REF_LEN || !id.is_ascii() {
762 return Err(OrderMappingError::InvalidClientOrderId(cid.clone()));
763 }
764 Ok(id.to_owned())
765}
766
767impl std::error::Error for OrderMappingError {}
768
769pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
777 match tif {
778 TimeInForce::GoodUntilCancelled { post_only } => {
779 if *post_only {
780 Err(OrderMappingError::PostOnlyNotSupported)
781 } else {
782 Ok(IbTimeInForce::GoodTillCanceled)
783 }
784 }
785 TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
786 TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
787 TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
788 TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTillDate),
789 TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
790 TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
794 }
795}
796
797fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
799 value.try_into().or_else(|_| {
800 value
801 .to_string()
802 .parse()
803 .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
804 })
805}
806
807fn require_limit_price(
809 price: Option<rust_decimal::Decimal>,
810 kind: OrderKind,
811) -> Result<f64, OrderMappingError> {
812 match price {
813 Some(p) => decimal_to_f64(p),
814 None => Err(OrderMappingError::MissingLimitPrice(kind)),
815 }
816}
817
818pub fn build_ib_order(
853 side: rustrade_instrument::Side,
854 quantity: f64,
855 kind: &OrderKind,
856 price: Option<rust_decimal::Decimal>,
857 tif: &TimeInForce,
858) -> Result<Order, OrderMappingError> {
859 let action = side_to_action(side);
860
861 if matches!(tif, TimeInForce::AtClose) {
863 return build_at_close_order(action, quantity, kind, price);
864 }
865
866 let tif_ib = time_in_force_to_ib(tif)?;
867
868 let mut order = match kind {
869 OrderKind::Market => order_builder::market_order(action, quantity),
870
871 OrderKind::Limit => {
872 let price_f64 = require_limit_price(price, *kind)?;
873 order_builder::limit_order(action, quantity, price_f64)
874 }
875
876 OrderKind::Stop { trigger_price } => {
877 let trigger_f64 = decimal_to_f64(*trigger_price)?;
878 order_builder::stop(action, quantity, trigger_f64)
879 }
880
881 OrderKind::StopLimit { trigger_price } => {
882 let limit_f64 = require_limit_price(price, *kind)?;
883 let trigger_f64 = decimal_to_f64(*trigger_price)?;
884 Order {
887 action,
888 order_type: "STP LMT".to_owned(),
889 total_quantity: quantity,
890 limit_price: Some(limit_f64),
891 aux_price: Some(trigger_f64),
892 ..Order::default()
893 }
894 }
895
896 OrderKind::TrailingStop {
897 offset,
898 offset_type,
899 activation_price: None,
900 } => {
901 let offset_f64 = decimal_to_f64(*offset)?;
902 match offset_type {
903 TrailingOffsetType::Percentage => {
904 Order {
907 action,
908 order_type: "TRAIL".to_owned(),
909 total_quantity: quantity,
910 trailing_percent: Some(offset_f64),
911 trail_stop_price: None,
912 ..Order::default()
913 }
914 }
915 TrailingOffsetType::Absolute => {
916 Order {
919 action,
920 order_type: "TRAIL".to_owned(),
921 total_quantity: quantity,
922 aux_price: Some(offset_f64),
923 trail_stop_price: None,
924 ..Order::default()
925 }
926 }
927 TrailingOffsetType::BasisPoints => {
928 return Err(OrderMappingError::UnsupportedOffsetType(
929 TrailingOffsetType::BasisPoints,
930 ));
931 }
932 }
933 }
934
935 OrderKind::TrailingStopLimit {
936 offset,
937 offset_type,
938 stop_price,
939 limit_offset,
940 } => {
941 if price.is_some() {
944 return Err(OrderMappingError::UnexpectedLimitPrice(*kind));
945 }
946 if *stop_price <= Decimal::ZERO {
949 return Err(OrderMappingError::NonPositiveStopPrice(*kind));
950 }
951 let offset_f64 = decimal_to_f64(*offset)?;
952 let stop_price_f64 = decimal_to_f64(*stop_price)?;
953 let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
954 let (aux_price, trailing_percent) = match offset_type {
956 TrailingOffsetType::Absolute => (Some(offset_f64), None),
957 TrailingOffsetType::Percentage => (None, Some(offset_f64)),
958 TrailingOffsetType::BasisPoints => {
959 return Err(OrderMappingError::UnsupportedOffsetType(
960 TrailingOffsetType::BasisPoints,
961 ));
962 }
963 };
964 Order {
965 action,
966 order_type: "TRAIL LIMIT".to_owned(),
967 total_quantity: quantity,
968 aux_price,
969 trailing_percent,
970 trail_stop_price: Some(stop_price_f64),
971 limit_price_offset: Some(limit_offset_f64),
972 ..Order::default()
973 }
974 }
975
976 OrderKind::TakeProfit { .. }
981 | OrderKind::TakeProfitLimit { .. }
982 | OrderKind::TrailingStop {
983 activation_price: Some(_),
984 ..
985 }
986 | OrderKind::TrailingStopFixedLimit { .. }
987 | OrderKind::ConditionalUnresolved { .. } => {
988 return Err(OrderMappingError::UnsupportedOrderKind(*kind));
989 }
990 };
991
992 order.tif = tif_ib;
993
994 if let TimeInForce::GoodTillDate { expiry } = tif {
996 order.good_till_date = format_gtd_datetime(expiry);
997 }
998
999 Ok(order)
1000}
1001
1002fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
1007 dt.format(GTD_FORMAT).to_string()
1008}
1009
1010const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
1012
1013fn build_at_close_order(
1018 action: Action,
1019 quantity: f64,
1020 kind: &OrderKind,
1021 price: Option<rust_decimal::Decimal>,
1022) -> Result<Order, OrderMappingError> {
1023 match kind {
1024 OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
1025 OrderKind::Limit => {
1026 let price_f64 = require_limit_price(price, *kind)?;
1027 Ok(order_builder::limit_on_close(action, quantity, price_f64))
1028 }
1029 _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
1031 }
1032}
1033
1034pub fn build_ib_bracket_with_oca(
1067 parent_order_id: i32,
1068 action: Action,
1069 quantity: f64,
1070 limit_price: f64,
1071 take_profit_price: f64,
1072 stop_loss_price: f64,
1073 tif: IbTimeInForce,
1074) -> Result<Vec<Order>, OrderMappingError> {
1075 let prices = BracketPrices {
1076 entry: limit_price,
1077 take_profit: take_profit_price,
1078 stop_loss: stop_loss_price,
1079 };
1080 let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
1081 .map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
1082 assert_eq!(
1083 orders.len(),
1084 3,
1085 "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
1086 );
1087
1088 orders[0].tif = tif.clone();
1091 orders[1].tif = tif.clone();
1092 orders[2].tif = tif;
1093
1094 let oca_group = format!("bracket_{}", parent_order_id);
1097 orders[1].oca_group = oca_group.clone();
1098 orders[1].oca_type = OcaType::CancelWithBlock;
1099 orders[2].oca_group = oca_group;
1100 orders[2].oca_type = OcaType::CancelWithBlock;
1101
1102 Ok(orders)
1103}
1104
1105#[cfg(test)]
1106#[allow(clippy::unwrap_used)] mod tests {
1108 use super::*;
1109 use rust_decimal::Decimal;
1110
1111 fn test_context() -> OrderContext {
1112 OrderContext {
1113 instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
1114 side: Side::Buy,
1115 price: Some(Decimal::from(150)),
1116 quantity: Decimal::from(100),
1117 kind: OrderKind::Limit,
1118 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
1119 }
1120 }
1121
1122 #[test]
1123 fn test_order_id_map_basic() {
1124 let map = OrderIdMap::new();
1125 let cid = ClientOrderId::new("order-123");
1126 let ctx = test_context();
1127
1128 map.register(cid.clone(), 42, ctx.clone()).unwrap();
1129
1130 assert_eq!(map.get_ib_id(&cid), Some(42));
1131 assert_eq!(map.get_client_id(42), Some(cid.clone()));
1132 assert_eq!(map.len(), 1);
1133
1134 let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
1135 assert_eq!(retrieved_cid, cid);
1136 assert_eq!(retrieved_ctx.side, Side::Buy);
1137 assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
1138 }
1139
1140 #[test]
1141 fn test_order_id_map_remove() {
1142 let map = OrderIdMap::new();
1143 let cid = ClientOrderId::new("order-456");
1144
1145 map.register(cid.clone(), 100, test_context()).unwrap();
1146 assert_eq!(map.len(), 1);
1147
1148 let removed = map.remove_by_ib_id(100);
1149 assert_eq!(removed, Some(cid.clone()));
1150 assert!(map.is_empty());
1151 assert!(map.get_ib_id(&cid).is_none());
1152 assert!(map.get_client_id(100).is_none());
1153 assert!(map.get_client_id_and_context(100).is_none());
1154 }
1155
1156 #[test]
1157 fn test_side_conversion() {
1158 assert!(matches!(
1159 side_to_action(rustrade_instrument::Side::Buy),
1160 Action::Buy
1161 ));
1162 assert!(matches!(
1163 side_to_action(rustrade_instrument::Side::Sell),
1164 Action::Sell
1165 ));
1166 }
1167
1168 #[test]
1169 fn test_time_in_force_conversion() {
1170 assert_eq!(
1171 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
1172 Ok(IbTimeInForce::GoodTillCanceled)
1173 );
1174
1175 assert!(matches!(
1176 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
1177 Err(OrderMappingError::PostOnlyNotSupported)
1178 ));
1179
1180 assert_eq!(
1181 time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
1182 Ok(IbTimeInForce::Day)
1183 );
1184 assert_eq!(
1185 time_in_force_to_ib(&TimeInForce::FillOrKill),
1186 Ok(IbTimeInForce::FillOrKill)
1187 );
1188 assert_eq!(
1189 time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
1190 Ok(IbTimeInForce::ImmediateOrCancel)
1191 );
1192 }
1193
1194 #[test]
1195 fn test_time_in_force_conversion_good_till_date() {
1196 use chrono::{TimeZone, Utc};
1197
1198 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1199 assert_eq!(
1200 time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
1201 Ok(IbTimeInForce::GoodTillDate)
1202 );
1203 }
1204
1205 #[test]
1206 fn test_time_in_force_conversion_at_open() {
1207 assert_eq!(
1208 time_in_force_to_ib(&TimeInForce::AtOpen),
1209 Ok(IbTimeInForce::OnOpen)
1210 );
1211 }
1212
1213 #[test]
1214 fn test_time_in_force_conversion_at_close_returns_err() {
1215 assert_eq!(
1219 time_in_force_to_ib(&TimeInForce::AtClose),
1220 Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
1221 );
1222 }
1223
1224 #[test]
1225 fn test_build_market_order() {
1226 let order = build_ib_order(
1227 rustrade_instrument::Side::Buy,
1228 100.0,
1229 &OrderKind::Market,
1230 None, &TimeInForce::GoodUntilEndOfDay,
1232 )
1233 .unwrap();
1234
1235 assert_eq!(order.action, Action::Buy);
1236 assert_eq!(order.total_quantity, 100.0);
1237 assert_eq!(order.order_type, "MKT");
1238 }
1239
1240 #[test]
1241 fn test_build_limit_order() {
1242 let order = build_ib_order(
1243 rustrade_instrument::Side::Sell,
1244 50.0,
1245 &OrderKind::Limit,
1246 Some(Decimal::try_from(150.5).unwrap()),
1247 &TimeInForce::GoodUntilCancelled { post_only: false },
1248 )
1249 .unwrap();
1250
1251 assert_eq!(order.action, Action::Sell);
1252 assert_eq!(order.total_quantity, 50.0);
1253 assert_eq!(order.order_type, "LMT");
1254 }
1255
1256 #[test]
1257 fn test_order_id_map_remove_and_get_context() {
1258 let map = OrderIdMap::new();
1259 let cid = ClientOrderId::new("order-789");
1260 let ctx = test_context();
1261
1262 map.register(cid.clone(), 50, ctx).unwrap();
1263 assert_eq!(map.len(), 1);
1264
1265 let result = map.remove_and_get_context(50);
1267 assert!(result.is_some());
1268 let (retrieved_cid, retrieved_ctx) = result.unwrap();
1269 assert_eq!(retrieved_cid, cid);
1270 assert_eq!(retrieved_ctx.side, Side::Buy);
1271
1272 assert!(map.is_empty());
1274 assert!(map.get_client_id(50).is_none());
1275 assert!(map.get_ib_id(&cid).is_none());
1276
1277 assert!(map.remove_and_get_context(50).is_none());
1279 }
1280
1281 #[test]
1282 fn test_order_id_map_clear_stale() {
1283 use std::time::Duration;
1284
1285 let map = OrderIdMap::new();
1286
1287 map.register(ClientOrderId::new("old-1"), 1, test_context())
1289 .unwrap();
1290 map.register(ClientOrderId::new("old-2"), 2, test_context())
1291 .unwrap();
1292
1293 let cleared = map.clear_stale(Duration::ZERO);
1295 assert_eq!(cleared, 2);
1296 assert!(map.is_empty());
1297
1298 map.register(ClientOrderId::new("new-1"), 10, test_context())
1300 .unwrap();
1301 map.register(ClientOrderId::new("new-2"), 20, test_context())
1302 .unwrap();
1303
1304 let cleared = map.clear_stale(Duration::from_secs(3600));
1306 assert_eq!(cleared, 0);
1307 assert_eq!(map.len(), 2);
1308 }
1309
1310 #[test]
1312 fn a_live_orders_id_is_refused_to_a_second_order() {
1313 let map = OrderIdMap::new();
1314 let cid = ClientOrderId::new("taken");
1315 map.register(cid.clone(), 1, test_context()).unwrap();
1316
1317 assert_eq!(
1318 map.register(cid.clone(), 2, test_context()),
1319 Err(ClientOrderIdInUse(cid.clone()))
1320 );
1321 assert_eq!(
1322 map.get_ib_id(&cid),
1323 Some(1),
1324 "a cancel by id still reaches the first"
1325 );
1326 assert!(
1327 map.get_client_id(2).is_none(),
1328 "nothing of the second was registered"
1329 );
1330 }
1331
1332 #[test]
1334 fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
1335 let map = OrderIdMap::new();
1336 let cid = ClientOrderId::new("reused");
1337 map.register(cid.clone(), 1, test_context()).unwrap();
1338
1339 assert_eq!(
1340 map.release_client_id(1).map(|(id, _)| id),
1341 Some(cid.clone())
1342 );
1343 assert!(map.is_empty(), "no order is live");
1344 assert_eq!(
1345 map.get_client_id(1),
1346 Some(cid.clone()),
1347 "a late execution resolves"
1348 );
1349
1350 map.register(cid.clone(), 2, test_context()).unwrap();
1352 assert_eq!(map.get_ib_id(&cid), Some(2));
1353 assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
1354 }
1355
1356 #[test]
1359 fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
1360 let map = OrderIdMap::new();
1361 let cid = ClientOrderId::new("reused");
1362 map.register(cid.clone(), 1, test_context()).unwrap();
1363 let _ = map.release_client_id(1);
1364 map.register(cid.clone(), 2, test_context()).unwrap();
1365
1366 assert!(
1367 map.remove_and_get_context(1).is_some(),
1368 "the first order's entry goes"
1369 );
1370 assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
1371 assert!(map.remove_by_ib_id(1).is_none());
1372 assert_eq!(map.get_ib_id(&cid), Some(2));
1373 }
1374
1375 #[test]
1377 fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
1378 use std::time::Duration;
1379
1380 let map = OrderIdMap::new();
1381 let cid = ClientOrderId::new("reused");
1382 map.register(cid.clone(), 1, test_context()).unwrap();
1383 let _ = map.release_client_id(1);
1384 std::thread::sleep(Duration::from_millis(5));
1385 map.register(cid.clone(), 2, test_context()).unwrap();
1386
1387 assert_eq!(
1388 map.clear_stale(Duration::from_millis(5)),
1389 1,
1390 "only the filled one"
1391 );
1392 assert_eq!(map.get_ib_id(&cid), Some(2));
1393 }
1394
1395 #[test]
1398 fn register_all_registers_every_order_or_none() {
1399 let map = OrderIdMap::new();
1400 map.register(ClientOrderId::new("live"), 1, test_context())
1401 .unwrap();
1402
1403 let clash = map.register_all([
1404 (ClientOrderId::new("a"), 10, test_context()),
1405 (ClientOrderId::new("live"), 11, test_context()),
1406 ]);
1407 assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
1408
1409 let repeated = map.register_all([
1410 (ClientOrderId::new("b"), 20, test_context()),
1411 (ClientOrderId::new("b"), 21, test_context()),
1412 ]);
1413 assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
1414
1415 assert_eq!(map.len(), 1, "neither registered anything");
1416 assert!(
1417 [10, 11, 20, 21]
1418 .iter()
1419 .all(|ib_id| map.get_client_id(*ib_id).is_none())
1420 );
1421
1422 map.register_all([
1423 (ClientOrderId::new("a"), 10, test_context()),
1424 (ClientOrderId::new("b"), 20, test_context()),
1425 ])
1426 .unwrap();
1427 assert_eq!(map.len(), 3);
1428 }
1429
1430 #[test]
1432 fn an_adopted_order_is_tracked_and_marked_adopted() {
1433 let map = OrderIdMap::new();
1434 let cid = ClientOrderId::new("listed");
1435
1436 map.adopt(cid.clone(), 7, test_context()).unwrap();
1437
1438 assert_eq!(map.get_ib_id(&cid), Some(7));
1439 assert_eq!(map.get_client_id(7), Some(cid.clone()));
1440 assert!(map.registration(&cid).is_some_and(|r| r.adopted));
1441 }
1442
1443 #[test]
1446 fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
1447 let map = OrderIdMap::new();
1448 let placed = ClientOrderId::new("placed");
1449 let adopted = ClientOrderId::new("adopted");
1450 let filled = ClientOrderId::new("filled");
1451 map.register(placed.clone(), 1, test_context()).unwrap();
1452 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1453 map.register(filled.clone(), 3, test_context()).unwrap();
1454 let _ = map.release_client_id(3);
1455
1456 for ib_id in [1, 2, 3] {
1457 let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
1458 assert_eq!(
1459 map.adopt(other.clone(), ib_id, test_context()),
1460 Err(AdoptionRefused::IbOrderIdTracked)
1461 );
1462 assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
1463 }
1464
1465 assert_eq!(map.get_client_id(1), Some(placed.clone()));
1466 assert_eq!(map.get_client_id(2), Some(adopted.clone()));
1467 assert_eq!(map.get_client_id(3), Some(filled.clone()));
1468 assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
1469 assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
1470 assert!(
1471 map.registration(&filled).is_none(),
1472 "the filled order's id stays free"
1473 );
1474 assert_eq!(map.len(), 2);
1475 }
1476
1477 #[test]
1480 fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
1481 let map = OrderIdMap::new();
1482 let placed = ClientOrderId::new("placed");
1483 let adopted = ClientOrderId::new("adopted");
1484 map.register(placed.clone(), 1, test_context()).unwrap();
1485 map.adopt(adopted.clone(), 2, test_context()).unwrap();
1486 let before = [
1487 map.registration(&placed).map(|r| r.adopted),
1488 map.registration(&adopted).map(|r| r.adopted),
1489 ];
1490
1491 for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
1492 assert_eq!(
1493 map.adopt(cid.clone(), ib_id, test_context()),
1494 Err(AdoptionRefused::ClientOrderIdInUse)
1495 );
1496 assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
1497 }
1498
1499 assert_eq!(map.get_ib_id(&placed), Some(1));
1500 assert_eq!(map.get_ib_id(&adopted), Some(2));
1501 assert_eq!(
1502 [
1503 map.registration(&placed).map(|r| r.adopted),
1504 map.registration(&adopted).map(|r| r.adopted),
1505 ],
1506 before
1507 );
1508 assert_eq!(map.len(), 2);
1509 }
1510
1511 #[test]
1514 fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
1515 let map = OrderIdMap::new();
1516 let cancelled = ClientOrderId::new("cancelled");
1517 let filled = ClientOrderId::new("filled");
1518 map.register(cancelled.clone(), 1, test_context()).unwrap();
1519 map.register(filled.clone(), 2, test_context()).unwrap();
1520 let _ = map.remove_and_get_context(1);
1521 let _ = map.release_client_id(2);
1522
1523 map.adopt(cancelled.clone(), 11, test_context()).unwrap();
1524 map.adopt(filled.clone(), 12, test_context()).unwrap();
1525
1526 assert_eq!(map.get_ib_id(&cancelled), Some(11));
1527 assert_eq!(map.get_ib_id(&filled), Some(12));
1528 assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
1529 assert!(map.registration(&filled).is_some_and(|r| r.adopted));
1530 assert_eq!(
1531 map.get_client_id(2),
1532 Some(filled),
1533 "the filled order's executions resolve"
1534 );
1535 }
1536
1537 #[test]
1538 fn test_pending_cancels_insert_remove() {
1539 let cancels = PendingCancels::new();
1540 assert!(cancels.is_empty());
1541
1542 cancels.insert(42);
1544 assert_eq!(cancels.len(), 1);
1545 assert!(!cancels.is_empty());
1546
1547 assert!(cancels.remove(42));
1549 assert!(cancels.is_empty());
1550
1551 assert!(!cancels.remove(42));
1553 assert!(!cancels.remove(999));
1554 }
1555
1556 #[test]
1557 fn test_pending_cancels_multiple() {
1558 let cancels = PendingCancels::new();
1559
1560 cancels.insert(1);
1561 cancels.insert(2);
1562 cancels.insert(3);
1563 assert_eq!(cancels.len(), 3);
1564
1565 assert!(cancels.remove(2));
1567 assert_eq!(cancels.len(), 2);
1568
1569 assert!(cancels.remove(1));
1571 assert!(cancels.remove(3));
1572 assert!(cancels.is_empty());
1573 }
1574
1575 #[test]
1576 fn test_pending_cancels_clear_stale() {
1577 use std::time::Duration;
1578
1579 let cancels = PendingCancels::new();
1580
1581 cancels.insert(1);
1582 cancels.insert(2);
1583
1584 let cleared = cancels.clear_stale(Duration::ZERO);
1586 assert_eq!(cleared, 2);
1587 assert!(cancels.is_empty());
1588
1589 cancels.insert(10);
1591 cancels.insert(20);
1592
1593 let cleared = cancels.clear_stale(Duration::from_secs(3600));
1595 assert_eq!(cleared, 0);
1596 assert_eq!(cancels.len(), 2);
1597 }
1598
1599 #[test]
1600 fn test_pending_cancels_duplicate_insert() {
1601 let cancels = PendingCancels::new();
1602
1603 cancels.insert(42);
1604 cancels.insert(42);
1605
1606 assert_eq!(cancels.len(), 1);
1608 assert!(cancels.remove(42));
1609 assert!(cancels.is_empty());
1610 }
1611
1612 #[test]
1613 fn test_build_stop_order() {
1614 let order = build_ib_order(
1615 rustrade_instrument::Side::Sell,
1616 100.0,
1617 &OrderKind::Stop {
1618 trigger_price: Decimal::from(45),
1619 },
1620 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1622 )
1623 .unwrap();
1624
1625 assert_eq!(order.action, Action::Sell);
1626 assert_eq!(order.total_quantity, 100.0);
1627 assert_eq!(order.order_type, "STP");
1628 assert_eq!(order.aux_price, Some(45.0));
1629 assert_eq!(order.limit_price, None);
1630 }
1631
1632 #[test]
1633 fn test_build_stop_limit_order() {
1634 let order = build_ib_order(
1635 rustrade_instrument::Side::Sell,
1636 100.0,
1637 &OrderKind::StopLimit {
1638 trigger_price: Decimal::from(44),
1639 },
1640 Some(Decimal::from(45)), &TimeInForce::GoodUntilCancelled { post_only: false },
1642 )
1643 .unwrap();
1644
1645 assert_eq!(order.action, Action::Sell);
1646 assert_eq!(order.total_quantity, 100.0);
1647 assert_eq!(order.order_type, "STP LMT");
1648 assert_eq!(order.aux_price, Some(44.0)); assert_eq!(order.limit_price, Some(45.0)); }
1651
1652 #[test]
1653 fn test_build_trailing_stop_percentage() {
1654 let order = build_ib_order(
1655 rustrade_instrument::Side::Sell,
1656 100.0,
1657 &OrderKind::TrailingStop {
1658 offset: Decimal::from(5),
1659 offset_type: TrailingOffsetType::Percentage,
1660 activation_price: None,
1661 },
1662 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1664 )
1665 .unwrap();
1666
1667 assert_eq!(order.action, Action::Sell);
1668 assert_eq!(order.total_quantity, 100.0);
1669 assert_eq!(order.order_type, "TRAIL");
1670 assert_eq!(order.trailing_percent, Some(5.0));
1671 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, None); }
1674
1675 #[test]
1676 fn test_build_trailing_stop_absolute() {
1677 let order = build_ib_order(
1678 rustrade_instrument::Side::Sell,
1679 100.0,
1680 &OrderKind::TrailingStop {
1681 offset: Decimal::from(2),
1682 offset_type: TrailingOffsetType::Absolute,
1683 activation_price: None,
1684 },
1685 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1687 )
1688 .unwrap();
1689
1690 assert_eq!(order.action, Action::Sell);
1691 assert_eq!(order.total_quantity, 100.0);
1692 assert_eq!(order.order_type, "TRAIL");
1693 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.trailing_percent, None);
1695 assert_eq!(order.trail_stop_price, None);
1696 }
1697
1698 #[test]
1699 fn test_build_trailing_stop_limit_absolute() {
1700 let order = build_ib_order(
1701 rustrade_instrument::Side::Sell,
1702 100.0,
1703 &OrderKind::TrailingStopLimit {
1704 offset: Decimal::from(2),
1705 offset_type: TrailingOffsetType::Absolute,
1706 stop_price: Decimal::from(95),
1707 limit_offset: Decimal::try_from(0.5).unwrap(),
1708 },
1709 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1711 )
1712 .unwrap();
1713
1714 assert_eq!(order.action, Action::Sell);
1715 assert_eq!(order.total_quantity, 100.0);
1716 assert_eq!(order.order_type, "TRAIL LIMIT");
1717 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.limit_price_offset, Some(0.5)); assert_eq!(order.trailing_percent, None);
1720 assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1723
1724 #[test]
1725 fn test_build_trailing_stop_limit_percentage() {
1726 let order = build_ib_order(
1727 rustrade_instrument::Side::Sell,
1728 100.0,
1729 &OrderKind::TrailingStopLimit {
1730 offset: Decimal::from(5),
1731 offset_type: TrailingOffsetType::Percentage,
1732 stop_price: Decimal::from(95),
1733 limit_offset: Decimal::try_from(0.5).unwrap(),
1734 },
1735 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1737 )
1738 .unwrap();
1739
1740 assert_eq!(order.action, Action::Sell);
1741 assert_eq!(order.total_quantity, 100.0);
1742 assert_eq!(order.order_type, "TRAIL LIMIT");
1743 assert_eq!(order.trailing_percent, Some(5.0));
1744 assert_eq!(order.limit_price_offset, Some(0.5));
1745 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
1749
1750 #[test]
1752 fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
1753 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1754 for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
1755 let kind = OrderKind::TrailingStopLimit {
1756 offset: Decimal::from(2),
1757 offset_type,
1758 stop_price,
1759 limit_offset: Decimal::from(1),
1760 };
1761 assert_eq!(
1762 build_ib_order(
1763 rustrade_instrument::Side::Sell,
1764 1.0,
1765 &kind,
1766 None,
1767 &TimeInForce::GoodUntilCancelled { post_only: false },
1768 ),
1769 Err(OrderMappingError::NonPositiveStopPrice(kind)),
1770 "{kind}"
1771 );
1772 }
1773 }
1774 }
1775
1776 #[test]
1779 fn test_build_trailing_stop_limit_rejects_limit_price() {
1780 for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
1781 let kind = OrderKind::TrailingStopLimit {
1782 offset: Decimal::from(2),
1783 offset_type,
1784 stop_price: Decimal::from(95),
1785 limit_offset: Decimal::from(1),
1786 };
1787 assert_eq!(
1788 build_ib_order(
1789 rustrade_instrument::Side::Sell,
1790 1.0,
1791 &kind,
1792 Some(Decimal::from(94)),
1793 &TimeInForce::GoodUntilCancelled { post_only: false },
1794 ),
1795 Err(OrderMappingError::UnexpectedLimitPrice(kind)),
1796 "{kind}"
1797 );
1798 }
1799 }
1800
1801 #[test]
1802 fn test_build_trailing_stop_basis_points_unsupported() {
1803 let result = build_ib_order(
1804 rustrade_instrument::Side::Sell,
1805 100.0,
1806 &OrderKind::TrailingStop {
1807 offset: Decimal::from(50), offset_type: TrailingOffsetType::BasisPoints,
1809 activation_price: None,
1810 },
1811 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1813 );
1814
1815 assert!(matches!(
1816 result,
1817 Err(OrderMappingError::UnsupportedOffsetType(
1818 TrailingOffsetType::BasisPoints
1819 ))
1820 ));
1821 }
1822
1823 #[test]
1825 fn test_build_unresolved_kind_unsupported() {
1826 let kind = OrderKind::ConditionalUnresolved {
1827 family: crate::order::ConditionalFamily::StopLimit,
1828 trigger_price: Some(Decimal::from(95)),
1829 };
1830 let result = build_ib_order(
1831 rustrade_instrument::Side::Sell,
1832 100.0,
1833 &kind,
1834 Some(Decimal::from(94)),
1835 &TimeInForce::GoodUntilCancelled { post_only: false },
1836 );
1837 assert!(matches!(result, Err(OrderMappingError::UnsupportedOrderKind(k)) if k == kind));
1838 }
1839
1840 #[test]
1841 fn test_build_trailing_stop_limit_basis_points_unsupported() {
1842 let result = build_ib_order(
1843 rustrade_instrument::Side::Sell,
1844 100.0,
1845 &OrderKind::TrailingStopLimit {
1846 offset: Decimal::from(50),
1847 offset_type: TrailingOffsetType::BasisPoints,
1848 stop_price: Decimal::from(95),
1849 limit_offset: Decimal::try_from(0.5).unwrap(),
1850 },
1851 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1853 );
1854
1855 assert!(matches!(
1856 result,
1857 Err(OrderMappingError::UnsupportedOffsetType(
1858 TrailingOffsetType::BasisPoints
1859 ))
1860 ));
1861 }
1862
1863 #[test]
1865 fn test_build_activated_and_fixed_limit_trailing_stops_unsupported() {
1866 let tif = TimeInForce::GoodUntilCancelled { post_only: false };
1867 for kind in [
1868 OrderKind::TrailingStop {
1869 offset: Decimal::from(2),
1870 offset_type: TrailingOffsetType::Percentage,
1871 activation_price: Some(Decimal::from(150)),
1872 },
1873 OrderKind::TrailingStopFixedLimit {
1874 offset: Decimal::from(2),
1875 offset_type: TrailingOffsetType::Percentage,
1876 activation_price: None,
1877 },
1878 ] {
1879 let result = build_ib_order(
1880 rustrade_instrument::Side::Sell,
1881 100.0,
1882 &kind,
1883 Some(Decimal::from(140)),
1884 &tif,
1885 );
1886 assert!(
1887 matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
1888 "{kind}: {result:?}"
1889 );
1890 }
1891 }
1892
1893 #[test]
1894 fn test_build_take_profit_unsupported() {
1895 let result = build_ib_order(
1896 rustrade_instrument::Side::Sell,
1897 100.0,
1898 &OrderKind::TakeProfit {
1899 trigger_price: Decimal::from(150),
1900 },
1901 None,
1902 &TimeInForce::GoodUntilCancelled { post_only: false },
1903 );
1904
1905 assert!(matches!(
1906 result,
1907 Err(OrderMappingError::UnsupportedOrderKind(
1908 OrderKind::TakeProfit { .. }
1909 ))
1910 ));
1911 }
1912
1913 #[test]
1914 fn test_build_take_profit_limit_unsupported() {
1915 let result = build_ib_order(
1916 rustrade_instrument::Side::Sell,
1917 100.0,
1918 &OrderKind::TakeProfitLimit {
1919 trigger_price: Decimal::from(150),
1920 },
1921 None,
1922 &TimeInForce::GoodUntilCancelled { post_only: false },
1923 );
1924
1925 assert!(matches!(
1926 result,
1927 Err(OrderMappingError::UnsupportedOrderKind(
1928 OrderKind::TakeProfitLimit { .. }
1929 ))
1930 ));
1931 }
1932
1933 #[test]
1938 fn test_build_market_order_at_open() {
1939 let order = build_ib_order(
1940 rustrade_instrument::Side::Buy,
1941 100.0,
1942 &OrderKind::Market,
1943 None, &TimeInForce::AtOpen,
1945 )
1946 .unwrap();
1947
1948 assert_eq!(order.action, Action::Buy);
1949 assert_eq!(order.total_quantity, 100.0);
1950 assert_eq!(order.order_type, "MKT");
1951 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1952 }
1953
1954 #[test]
1955 fn test_build_limit_order_at_open() {
1956 let order = build_ib_order(
1957 rustrade_instrument::Side::Sell,
1958 50.0,
1959 &OrderKind::Limit,
1960 Some(Decimal::from(150)),
1961 &TimeInForce::AtOpen,
1962 )
1963 .unwrap();
1964
1965 assert_eq!(order.action, Action::Sell);
1966 assert_eq!(order.total_quantity, 50.0);
1967 assert_eq!(order.order_type, "LMT");
1968 assert_eq!(order.limit_price, Some(150.0));
1969 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1970 }
1971
1972 #[test]
1973 fn test_build_stop_order_at_open() {
1974 let order = build_ib_order(
1976 rustrade_instrument::Side::Sell,
1977 100.0,
1978 &OrderKind::Stop {
1979 trigger_price: Decimal::from(45),
1980 },
1981 None, &TimeInForce::AtOpen,
1983 )
1984 .unwrap();
1985
1986 assert_eq!(order.order_type, "STP");
1987 assert_eq!(order.aux_price, Some(45.0));
1988 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1989 }
1990
1991 #[test]
1992 fn test_build_market_on_close() {
1993 let order = build_ib_order(
1994 rustrade_instrument::Side::Buy,
1995 100.0,
1996 &OrderKind::Market,
1997 None, &TimeInForce::AtClose,
1999 )
2000 .unwrap();
2001
2002 assert_eq!(order.action, Action::Buy);
2003 assert_eq!(order.total_quantity, 100.0);
2004 assert_eq!(order.order_type, "MOC");
2005 }
2006
2007 #[test]
2008 fn test_build_limit_on_close() {
2009 let order = build_ib_order(
2010 rustrade_instrument::Side::Sell,
2011 50.0,
2012 &OrderKind::Limit,
2013 Some(Decimal::from(150)),
2014 &TimeInForce::AtClose,
2015 )
2016 .unwrap();
2017
2018 assert_eq!(order.action, Action::Sell);
2019 assert_eq!(order.total_quantity, 50.0);
2020 assert_eq!(order.order_type, "LOC");
2021 assert_eq!(order.limit_price, Some(150.0));
2022 }
2023
2024 #[test]
2025 fn test_build_stop_at_close_unsupported() {
2026 let result = build_ib_order(
2027 rustrade_instrument::Side::Sell,
2028 100.0,
2029 &OrderKind::Stop {
2030 trigger_price: Decimal::from(45),
2031 },
2032 None, &TimeInForce::AtClose,
2034 );
2035
2036 assert!(matches!(
2037 result,
2038 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2039 OrderKind::Stop { .. }
2040 ))
2041 ));
2042 }
2043
2044 #[test]
2045 fn test_build_stop_limit_at_close_unsupported() {
2046 let result = build_ib_order(
2047 rustrade_instrument::Side::Sell,
2048 100.0,
2049 &OrderKind::StopLimit {
2050 trigger_price: Decimal::from(44),
2051 },
2052 Some(Decimal::from(45)),
2053 &TimeInForce::AtClose,
2054 );
2055
2056 assert!(matches!(
2057 result,
2058 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2059 OrderKind::StopLimit { .. }
2060 ))
2061 ));
2062 }
2063
2064 #[test]
2065 fn test_build_trailing_stop_at_close_unsupported() {
2066 let result = build_ib_order(
2067 rustrade_instrument::Side::Sell,
2068 100.0,
2069 &OrderKind::TrailingStop {
2070 offset: Decimal::from(5),
2071 offset_type: TrailingOffsetType::Percentage,
2072 activation_price: None,
2073 },
2074 None, &TimeInForce::AtClose,
2076 );
2077
2078 assert!(matches!(
2079 result,
2080 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2081 OrderKind::TrailingStop { .. }
2082 ))
2083 ));
2084 }
2085
2086 #[test]
2087 fn test_build_trailing_stop_limit_at_close_unsupported() {
2088 let result = build_ib_order(
2089 rustrade_instrument::Side::Sell,
2090 100.0,
2091 &OrderKind::TrailingStopLimit {
2092 offset: Decimal::from(5),
2093 offset_type: TrailingOffsetType::Absolute,
2094 stop_price: Decimal::from(95),
2095 limit_offset: Decimal::from(1),
2096 },
2097 None, &TimeInForce::AtClose,
2099 );
2100
2101 assert!(matches!(
2102 result,
2103 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
2104 OrderKind::TrailingStopLimit { .. }
2105 ))
2106 ));
2107 }
2108
2109 #[test]
2110 fn test_build_good_till_date_order() {
2111 use chrono::{TimeZone, Utc};
2112
2113 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
2114 let order = build_ib_order(
2115 rustrade_instrument::Side::Buy,
2116 100.0,
2117 &OrderKind::Limit,
2118 Some(Decimal::from(150)),
2119 &TimeInForce::GoodTillDate { expiry },
2120 )
2121 .unwrap();
2122
2123 assert_eq!(order.action, Action::Buy);
2124 assert_eq!(order.total_quantity, 100.0);
2125 assert_eq!(order.order_type, "LMT");
2126 assert_eq!(order.tif, IbTimeInForce::GoodTillDate);
2127 assert_eq!(order.good_till_date, "20250630-23:59:59");
2128 }
2129
2130 #[test]
2135 fn a_good_till_date_is_read_in_its_zone_or_the_sessions_or_as_utc() {
2136 use chrono_tz::{America::New_York, Asia::Singapore};
2137 let utc = |time: &str| Some(chrono::DateTime::parse_from_rfc3339(time).unwrap().to_utc());
2138 for (value, session_zone, expected) in [
2139 ("20261027-04:48:41", None, "2026-10-27T04:48:41Z"),
2140 ("20261027-04:48:41", Some(Singapore), "2026-10-27T04:48:41Z"),
2141 ("20261027 12:48:41", Some(Singapore), "2026-10-27T04:48:41Z"),
2142 (
2143 "20261027 12:48:41 Asia/Singapore",
2144 None,
2145 "2026-10-27T04:48:41Z",
2146 ),
2147 (
2148 "20261027 12:48:41 Asia/Singapore",
2149 Some(New_York),
2150 "2026-10-27T04:48:41Z",
2151 ),
2152 ("20261027 04:48:41", None, "2026-10-27T04:48:41Z"),
2154 (
2156 "20261027 04:48:41 Some Standard Time",
2157 Some(Singapore),
2158 "2026-10-27T04:48:41Z",
2159 ),
2160 (
2162 "20260308 02:30:00 America/New_York",
2163 None,
2164 "2026-03-08T02:30:00Z",
2165 ),
2166 ("20260308 02:30:00", Some(New_York), "2026-03-08T02:30:00Z"),
2167 ] {
2168 assert_eq!(
2169 parse_gtd_datetime(value, session_zone),
2170 utc(expected),
2171 "{value} in {session_zone:?}"
2172 );
2173 }
2174 assert_eq!(parse_gtd_datetime("not a date", Some(Singapore)), None);
2175 assert_eq!(parse_gtd_datetime("20261027-04:48:41 UTC", None), None);
2176 }
2177
2178 #[test]
2179 fn test_format_gtd_datetime() {
2180 use chrono::{TimeZone, Utc};
2181
2182 let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
2183 assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
2184
2185 let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
2186 assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
2187 }
2188
2189 #[test]
2194 fn test_build_ib_bracket_with_oca_sets_oca_fields() {
2195 let orders = build_ib_bracket_with_oca(
2196 1000,
2197 Action::Buy,
2198 10.0,
2199 150.0,
2200 160.0,
2201 140.0,
2202 IbTimeInForce::Day,
2203 )
2204 .unwrap();
2205
2206 assert_eq!(orders.len(), 3);
2207
2208 assert!(!orders[1].oca_group.is_empty());
2210 assert_eq!(orders[1].oca_group, orders[2].oca_group);
2211
2212 assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
2214 assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
2215
2216 assert!(orders[0].oca_group.is_empty());
2218 assert_eq!(orders[0].oca_type, OcaType::None);
2219 }
2220
2221 #[test]
2222 fn test_build_ib_bracket_with_oca_parent_id_linkage() {
2223 let orders = build_ib_bracket_with_oca(
2224 1000,
2225 Action::Buy,
2226 10.0,
2227 150.0,
2228 160.0,
2229 140.0,
2230 IbTimeInForce::Day,
2231 )
2232 .unwrap();
2233
2234 assert_eq!(orders[1].parent_id, 1000); assert_eq!(orders[2].parent_id, 1000); assert_eq!(orders[0].parent_id, 0);
2240 }
2241
2242 #[test]
2243 fn test_build_ib_bracket_with_oca_transmit_flags() {
2244 let orders = build_ib_bracket_with_oca(
2245 1000,
2246 Action::Buy,
2247 10.0,
2248 150.0,
2249 160.0,
2250 140.0,
2251 IbTimeInForce::Day,
2252 )
2253 .unwrap();
2254
2255 assert!(!orders[0].transmit); assert!(!orders[1].transmit); assert!(orders[2].transmit); }
2260
2261 #[test]
2262 fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
2263 let orders = build_ib_bracket_with_oca(
2264 500,
2265 Action::Sell,
2266 5.0,
2267 100.0,
2268 90.0,
2269 110.0,
2270 IbTimeInForce::Day,
2271 )
2272 .unwrap();
2273
2274 assert_eq!(orders[0].order_id, 500); assert_eq!(orders[1].order_id, 501); assert_eq!(orders[2].order_id, 502); }
2278
2279 #[test]
2280 fn test_build_ib_bracket_with_oca_rejects_prices_on_the_wrong_side_of_the_entry() {
2281 let result =
2283 build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
2284 assert!(matches!(
2285 result,
2286 Err(OrderMappingError::InvalidBracketPrices(_))
2287 ));
2288
2289 let result =
2291 build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
2292 assert!(matches!(
2293 result,
2294 Err(OrderMappingError::InvalidBracketPrices(_))
2295 ));
2296 }
2297
2298 #[test]
2299 fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
2300 let orders =
2301 build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day)
2302 .unwrap();
2303
2304 assert!(orders[1].oca_group.contains("42"));
2305 assert!(orders[2].oca_group.contains("42"));
2306 }
2307
2308 #[test]
2309 fn test_build_ib_bracket_with_oca_order_types() {
2310 let orders = build_ib_bracket_with_oca(
2311 1000,
2312 Action::Buy,
2313 100.0,
2314 150.0,
2315 160.0,
2316 140.0,
2317 IbTimeInForce::Day,
2318 )
2319 .unwrap();
2320
2321 assert_eq!(orders[0].order_type, "LMT");
2323 assert_eq!(orders[0].limit_price, Some(150.0));
2324
2325 assert_eq!(orders[1].order_type, "LMT");
2327 assert_eq!(orders[1].limit_price, Some(160.0));
2328
2329 assert_eq!(orders[2].order_type, "STP");
2331 assert_eq!(orders[2].aux_price, Some(140.0));
2332 }
2333
2334 #[test]
2335 fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
2336 let buy_orders =
2338 build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day)
2339 .unwrap();
2340 assert_eq!(buy_orders[0].action, Action::Buy);
2341 assert_eq!(buy_orders[1].action, Action::Sell);
2342 assert_eq!(buy_orders[2].action, Action::Sell);
2343
2344 let sell_orders = build_ib_bracket_with_oca(
2346 200,
2347 Action::Sell,
2348 10.0,
2349 50.0,
2350 45.0,
2351 55.0,
2352 IbTimeInForce::Day,
2353 )
2354 .unwrap();
2355 assert_eq!(sell_orders[0].action, Action::Sell);
2356 assert_eq!(sell_orders[1].action, Action::Buy);
2357 assert_eq!(sell_orders[2].action, Action::Buy);
2358 }
2359
2360 #[test]
2361 fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
2362 let orders = build_ib_bracket_with_oca(
2363 1000,
2364 Action::Buy,
2365 10.0,
2366 150.0,
2367 160.0,
2368 140.0,
2369 IbTimeInForce::GoodTillCanceled,
2370 )
2371 .unwrap();
2372
2373 assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
2374 assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
2375 assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
2376 }
2377
2378 #[test]
2381 fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
2382 let longest = "a".repeat(MAX_ORDER_REF_LEN);
2383 assert_eq!(
2384 order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
2385 longest
2386 );
2387 assert_eq!(
2388 order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
2389 "cid-1_tp"
2390 );
2391
2392 let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
2393 for refused in ["", too_long.as_str(), "ordre-é"] {
2394 let cid = ClientOrderId::new(refused);
2395 assert_eq!(
2396 order_ref(&cid),
2397 Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
2398 );
2399 }
2400 }
2401
2402 #[test]
2405 fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
2406 use chrono::TimeZone;
2407 use rust_decimal_macros::dec;
2408
2409 let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
2410 let kinds = [
2411 (OrderKind::Market, None),
2412 (OrderKind::Limit, Some(dec!(150.25))),
2413 (
2414 OrderKind::Stop {
2415 trigger_price: dec!(140.5),
2416 },
2417 None,
2418 ),
2419 (
2420 OrderKind::StopLimit {
2421 trigger_price: dec!(140.5),
2422 },
2423 Some(dec!(140)),
2424 ),
2425 (
2426 OrderKind::TrailingStop {
2427 offset: dec!(2.5),
2428 offset_type: TrailingOffsetType::Percentage,
2429 activation_price: None,
2430 },
2431 None,
2432 ),
2433 (
2434 OrderKind::TrailingStop {
2435 offset: dec!(1.75),
2436 offset_type: TrailingOffsetType::Absolute,
2437 activation_price: None,
2438 },
2439 None,
2440 ),
2441 (
2442 OrderKind::TrailingStopLimit {
2443 offset: dec!(2),
2444 offset_type: TrailingOffsetType::Percentage,
2445 stop_price: dec!(130.5),
2446 limit_offset: dec!(0.5),
2447 },
2448 None,
2449 ),
2450 (
2451 OrderKind::TrailingStopLimit {
2452 offset: dec!(1.5),
2453 offset_type: TrailingOffsetType::Absolute,
2454 stop_price: dec!(139.75),
2455 limit_offset: dec!(0.25),
2456 },
2457 None,
2458 ),
2459 ];
2460 let tifs = [
2461 TimeInForce::GoodUntilCancelled { post_only: false },
2462 TimeInForce::GoodUntilEndOfDay,
2463 TimeInForce::ImmediateOrCancel,
2464 TimeInForce::FillOrKill,
2465 TimeInForce::AtOpen,
2466 TimeInForce::GoodTillDate { expiry },
2467 ];
2468
2469 for (kind, price) in kinds {
2470 for time_in_force in tifs {
2471 let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
2472 assert_eq!(
2473 order_shape_from_ib(&order, None),
2474 Ok(OrderShape {
2475 kind,
2476 price,
2477 time_in_force
2478 }),
2479 "{kind} {time_in_force}"
2480 );
2481 }
2482 }
2483
2484 for (kind, price) in [
2486 (OrderKind::Market, None),
2487 (OrderKind::Limit, Some(dec!(99))),
2488 ] {
2489 let order =
2490 build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
2491 assert_eq!(
2492 order_shape_from_ib(&order, None),
2493 Ok(OrderShape {
2494 kind,
2495 price,
2496 time_in_force: TimeInForce::AtClose
2497 }),
2498 "{kind}"
2499 );
2500 }
2501 }
2502
2503 #[test]
2504 fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
2505 use chrono::TimeZone;
2506
2507 let order = Order {
2508 order_type: "MKT".to_owned(),
2509 tif: IbTimeInForce::GoodTillDate,
2510 good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
2511 ..Order::default()
2512 };
2513 assert_eq!(
2514 order_shape_from_ib(&order, None).unwrap().time_in_force,
2515 TimeInForce::GoodTillDate {
2516 expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
2517 }
2518 );
2519
2520 let order = Order {
2522 good_till_date: "20261009 16:00:00".to_owned(),
2523 ..order
2524 };
2525 assert_eq!(
2526 order_shape_from_ib(&order, Some(chrono_tz::Asia::Singapore))
2527 .unwrap()
2528 .time_in_force,
2529 TimeInForce::GoodTillDate {
2530 expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 8, 0, 0).unwrap()
2531 }
2532 );
2533 }
2534
2535 #[test]
2536 fn order_shape_from_ib_refuses_what_this_client_never_sends() {
2537 let order = |order_type: &str, tif: IbTimeInForce| Order {
2538 order_type: order_type.to_owned(),
2539 tif,
2540 limit_price: Some(1.0),
2541 ..Order::default()
2542 };
2543 for (order, reason) in [
2544 (order("REL", IbTimeInForce::Day), "order type"),
2545 (
2546 order("LMT", IbTimeInForce::DayTillCanceled),
2547 "time in force",
2548 ),
2549 (
2550 Order {
2551 limit_price: None,
2552 ..order("LMT", IbTimeInForce::Day)
2553 },
2554 "without limit_price",
2555 ),
2556 (order("STP", IbTimeInForce::Day), "without aux_price"),
2557 (order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
2558 (
2559 Order {
2560 aux_price: Some(2.0),
2561 limit_price_offset: Some(0.5),
2562 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2563 },
2564 "without trail_stop_price",
2565 ),
2566 (
2567 Order {
2568 aux_price: Some(2.0),
2569 trail_stop_price: Some(0.0),
2570 limit_price_offset: Some(0.5),
2571 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2572 },
2573 "not positive",
2574 ),
2575 (
2576 Order {
2577 trailing_percent: Some(5.0),
2578 trail_stop_price: Some(-1.0),
2579 limit_price_offset: Some(0.5),
2580 ..order("TRAIL LIMIT", IbTimeInForce::Day)
2581 },
2582 "not positive",
2583 ),
2584 ] {
2585 let error = order_shape_from_ib(&order, None).unwrap_err();
2586 assert!(error.contains(reason), "{error:?} should name {reason:?}");
2587 }
2588 }
2589
2590 #[test]
2591 fn action_to_side_reads_a_short_sale_as_a_sell() {
2592 assert_eq!(action_to_side(&Action::Buy), Side::Buy);
2593 for action in [Action::Sell, Action::SellShort, Action::SellLong] {
2594 assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
2595 }
2596 }
2597}