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