1use crate::order::{
2 Order as RustradeOrder, OrderKind, TimeInForce, TrailingOffsetType,
3 id::{ClientOrderId, StrategyId},
4 state::UnindexedOrderState,
5};
6use fnv::FnvHashMap;
7use ibapi::orders::{Action, OcaType, Order, TimeInForce as IbTimeInForce, order_builder};
8use parking_lot::{Mutex, RwLock};
9use rust_decimal::Decimal;
10use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
11use std::{sync::Arc, time::Instant};
12
13#[derive(Debug, Clone)]
30pub struct BracketOrderRequest {
31 pub instrument: InstrumentNameExchange,
33 pub strategy: StrategyId,
35 pub parent_cid: ClientOrderId,
38 pub side: Side,
40 pub quantity: Decimal,
42 pub entry_price: Decimal,
44 pub take_profit_price: Decimal,
46 pub stop_loss_price: Decimal,
48 pub time_in_force: TimeInForce,
50}
51
52#[derive(Debug, Clone)]
63pub struct BracketOrderResult {
64 pub parent: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
66 pub take_profit: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
68 pub stop_loss: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
70}
71
72#[derive(Debug, Clone)]
87pub struct OrderContext {
88 pub instrument: InstrumentNameExchange,
89 pub side: Side,
90 pub price: Option<Decimal>,
92 pub quantity: Decimal,
93 pub kind: OrderKind,
94 pub time_in_force: TimeInForce,
95}
96
97#[derive(Debug, Clone)]
103pub struct OrderIdMap {
104 inner: Arc<RwLock<OrderIdMapInner>>,
105}
106
107#[derive(Debug, Default)]
108struct OrderIdMapInner {
109 cid_to_ib: FnvHashMap<ClientOrderId, i32>,
110 ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
113}
114
115impl OrderIdMap {
116 pub fn new() -> Self {
117 Self {
118 inner: Arc::new(RwLock::new(OrderIdMapInner::default())),
119 }
120 }
121
122 pub fn register(&self, client_id: ClientOrderId, ib_id: i32, context: OrderContext) {
124 let mut inner = self.inner.write();
125 inner.cid_to_ib.insert(client_id.clone(), ib_id);
126 inner
127 .ib_to_entry
128 .insert(ib_id, (client_id, context, Instant::now()));
129 }
130
131 pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
133 self.inner.read().cid_to_ib.get(client_id).copied()
134 }
135
136 pub fn get_client_id(&self, ib_id: i32) -> Option<ClientOrderId> {
138 self.inner
139 .read()
140 .ib_to_entry
141 .get(&ib_id)
142 .map(|(cid, _, _)| cid.clone())
143 }
144
145 pub fn get_client_id_and_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
147 self.inner
148 .read()
149 .ib_to_entry
150 .get(&ib_id)
151 .map(|(cid, ctx, _)| (cid.clone(), ctx.clone()))
152 }
153
154 pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
159 let mut inner = self.inner.write();
160 if let Some((client_id, ctx, _)) = inner.ib_to_entry.remove(&ib_id) {
161 inner.cid_to_ib.remove(&client_id);
162 Some((client_id, ctx))
163 } else {
164 None
165 }
166 }
167
168 pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
170 let mut inner = self.inner.write();
171 if let Some((client_id, _, _)) = inner.ib_to_entry.remove(&ib_id) {
172 inner.cid_to_ib.remove(&client_id);
173 Some(client_id)
174 } else {
175 None
176 }
177 }
178
179 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
194 let mut inner = self.inner.write();
195 let before = inner.ib_to_entry.len();
196
197 let stale_ids: Vec<i32> = inner
199 .ib_to_entry
200 .iter()
201 .filter(|(_, (_, _, registered_at))| registered_at.elapsed() >= max_age)
202 .map(|(ib_id, _)| *ib_id)
203 .collect();
204
205 for ib_id in stale_ids {
206 if let Some((client_id, _, _)) = inner.ib_to_entry.remove(&ib_id) {
207 inner.cid_to_ib.remove(&client_id);
208 }
209 }
210
211 before - inner.ib_to_entry.len()
212 }
213
214 pub fn len(&self) -> usize {
216 self.inner.read().cid_to_ib.len()
217 }
218
219 pub fn is_empty(&self) -> bool {
221 self.inner.read().cid_to_ib.is_empty()
222 }
223}
224
225impl Default for OrderIdMap {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231#[derive(Debug, Clone)]
237pub struct PendingCancels {
238 inner: Arc<Mutex<FnvHashMap<i32, Instant>>>,
239}
240
241impl PendingCancels {
242 pub fn new() -> Self {
243 Self {
244 inner: Arc::new(Mutex::new(FnvHashMap::with_capacity_and_hasher(
245 8,
246 Default::default(),
247 ))),
248 }
249 }
250
251 pub fn insert(&self, ib_id: i32) {
253 self.inner.lock().insert(ib_id, Instant::now());
254 }
255
256 #[must_use]
260 pub fn remove(&self, ib_id: i32) -> bool {
261 self.inner.lock().remove(&ib_id).is_some()
262 }
263
264 #[must_use]
269 pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
270 let mut map = self.inner.lock();
271 let before = map.len();
272 map.retain(|_, registered_at| registered_at.elapsed() < max_age);
273 before - map.len()
274 }
275
276 #[must_use]
278 pub fn len(&self) -> usize {
279 self.inner.lock().len()
280 }
281
282 #[must_use]
284 pub fn is_empty(&self) -> bool {
285 self.inner.lock().is_empty()
286 }
287}
288
289impl Default for PendingCancels {
290 fn default() -> Self {
291 Self::new()
292 }
293}
294
295pub(crate) fn side_to_action(side: rustrade_instrument::Side) -> Action {
297 match side {
298 rustrade_instrument::Side::Buy => Action::Buy,
299 rustrade_instrument::Side::Sell => Action::Sell,
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
305pub enum OrderMappingError {
306 PostOnlyNotSupported,
307 InvalidPrice(String),
309 MissingLimitPrice(OrderKind),
311 UnsupportedOffsetType(TrailingOffsetType),
313 UnsupportedOrderKind(OrderKind),
315 UnsupportedOrderKindForAtClose(OrderKind),
318 AtCloseRequiresOrderTypeChange,
322}
323
324impl std::fmt::Display for OrderMappingError {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 match self {
327 Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
328 Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
329 Self::MissingLimitPrice(k) => {
330 write!(f, "limit price required for {k} but was None")
331 }
332 Self::UnsupportedOffsetType(t) => {
333 write!(f, "trailing offset type {t:?} not supported by IBKR")
334 }
335 Self::UnsupportedOrderKind(k) => {
336 write!(f, "order kind {k} not supported by IBKR")
337 }
338 Self::UnsupportedOrderKindForAtClose(k) => {
339 write!(
340 f,
341 "AtClose TIF only valid with Market or Limit orders, got {k}"
342 )
343 }
344 Self::AtCloseRequiresOrderTypeChange => write!(
345 f,
346 "AtClose TIF must be routed through build_ib_order, which promotes \
347 the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
348 ),
349 }
350 }
351}
352
353impl std::error::Error for OrderMappingError {}
354
355pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
363 match tif {
364 TimeInForce::GoodUntilCancelled { post_only } => {
365 if *post_only {
366 Err(OrderMappingError::PostOnlyNotSupported)
367 } else {
368 Ok(IbTimeInForce::GoodTilCanceled)
369 }
370 }
371 TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
372 TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
373 TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
374 TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTilDate),
375 TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
376 TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
380 }
381}
382
383fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
385 value.try_into().or_else(|_| {
386 value
387 .to_string()
388 .parse()
389 .map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
390 })
391}
392
393fn require_limit_price(
395 price: Option<rust_decimal::Decimal>,
396 kind: OrderKind,
397) -> Result<f64, OrderMappingError> {
398 match price {
399 Some(p) => decimal_to_f64(p),
400 None => Err(OrderMappingError::MissingLimitPrice(kind)),
401 }
402}
403
404pub fn build_ib_order(
432 side: rustrade_instrument::Side,
433 quantity: f64,
434 kind: &OrderKind,
435 price: Option<rust_decimal::Decimal>,
436 tif: &TimeInForce,
437) -> Result<Order, OrderMappingError> {
438 let action = side_to_action(side);
439
440 if matches!(tif, TimeInForce::AtClose) {
442 return build_at_close_order(action, quantity, kind, price);
443 }
444
445 let tif_ib = time_in_force_to_ib(tif)?;
446
447 let mut order = match kind {
448 OrderKind::Market => order_builder::market_order(action, quantity),
449
450 OrderKind::Limit => {
451 let price_f64 = require_limit_price(price, *kind)?;
452 order_builder::limit_order(action, quantity, price_f64)
453 }
454
455 OrderKind::Stop { trigger_price } => {
456 let trigger_f64 = decimal_to_f64(*trigger_price)?;
457 order_builder::stop(action, quantity, trigger_f64)
458 }
459
460 OrderKind::StopLimit { trigger_price } => {
461 let limit_f64 = require_limit_price(price, *kind)?;
462 let trigger_f64 = decimal_to_f64(*trigger_price)?;
463 order_builder::stop_limit(action, quantity, limit_f64, trigger_f64)
464 }
465
466 OrderKind::TrailingStop {
467 offset,
468 offset_type,
469 } => {
470 let offset_f64 = decimal_to_f64(*offset)?;
471 match offset_type {
472 TrailingOffsetType::Percentage => {
473 Order {
476 action,
477 order_type: "TRAIL".to_owned(),
478 total_quantity: quantity,
479 trailing_percent: Some(offset_f64),
480 trail_stop_price: None,
481 ..Order::default()
482 }
483 }
484 TrailingOffsetType::Absolute => {
485 Order {
488 action,
489 order_type: "TRAIL".to_owned(),
490 total_quantity: quantity,
491 aux_price: Some(offset_f64),
492 trail_stop_price: None,
493 ..Order::default()
494 }
495 }
496 TrailingOffsetType::BasisPoints => {
497 return Err(OrderMappingError::UnsupportedOffsetType(
498 TrailingOffsetType::BasisPoints,
499 ));
500 }
501 }
502 }
503
504 OrderKind::TrailingStopLimit {
505 offset,
506 offset_type,
507 limit_offset,
508 } => {
509 let offset_f64 = decimal_to_f64(*offset)?;
510 let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
511 match offset_type {
512 TrailingOffsetType::Absolute => {
513 order_builder::trailing_stop_limit(
516 action,
517 quantity,
518 limit_offset_f64,
519 offset_f64,
520 0.0, )
522 }
523 TrailingOffsetType::Percentage => {
524 Order {
526 action,
527 order_type: "TRAIL LIMIT".to_owned(),
528 total_quantity: quantity,
529 trailing_percent: Some(offset_f64),
530 limit_price_offset: Some(limit_offset_f64),
531 trail_stop_price: None,
532 ..Order::default()
533 }
534 }
535 TrailingOffsetType::BasisPoints => {
536 return Err(OrderMappingError::UnsupportedOffsetType(
537 TrailingOffsetType::BasisPoints,
538 ));
539 }
540 }
541 }
542
543 OrderKind::TakeProfit { .. } | OrderKind::TakeProfitLimit { .. } => {
546 return Err(OrderMappingError::UnsupportedOrderKind(*kind));
547 }
548 };
549
550 order.tif = tif_ib;
551
552 if let TimeInForce::GoodTillDate { expiry } = tif {
554 order.good_till_date = format_gtd_datetime(expiry);
555 }
556
557 Ok(order)
558}
559
560fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
565 dt.format("%Y%m%d-%H:%M:%S").to_string()
566}
567
568fn build_at_close_order(
573 action: Action,
574 quantity: f64,
575 kind: &OrderKind,
576 price: Option<rust_decimal::Decimal>,
577) -> Result<Order, OrderMappingError> {
578 match kind {
579 OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
580 OrderKind::Limit => {
581 let price_f64 = require_limit_price(price, *kind)?;
582 Ok(order_builder::limit_on_close(action, quantity, price_f64))
583 }
584 _ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
586 }
587}
588
589pub fn build_ib_bracket_with_oca(
620 parent_order_id: i32,
621 action: Action,
622 quantity: f64,
623 limit_price: f64,
624 take_profit_price: f64,
625 stop_loss_price: f64,
626 tif: IbTimeInForce,
627) -> Vec<Order> {
628 let mut orders = order_builder::bracket_order(
629 parent_order_id,
630 action,
631 quantity,
632 limit_price,
633 take_profit_price,
634 stop_loss_price,
635 );
636 assert_eq!(
637 orders.len(),
638 3,
639 "ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
640 );
641
642 orders[0].tif = tif.clone();
645 orders[1].tif = tif.clone();
646 orders[2].tif = tif;
647
648 let oca_group = format!("bracket_{}", parent_order_id);
651 orders[1].oca_group = oca_group.clone();
652 orders[1].oca_type = OcaType::CancelWithBlock;
653 orders[2].oca_group = oca_group;
654 orders[2].oca_type = OcaType::CancelWithBlock;
655
656 orders
657}
658
659#[cfg(test)]
660#[allow(clippy::unwrap_used)] mod tests {
662 use super::*;
663 use rust_decimal::Decimal;
664
665 fn test_context() -> OrderContext {
666 OrderContext {
667 instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
668 side: Side::Buy,
669 price: Some(Decimal::from(150)),
670 quantity: Decimal::from(100),
671 kind: OrderKind::Limit,
672 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
673 }
674 }
675
676 #[test]
677 fn test_order_id_map_basic() {
678 let map = OrderIdMap::new();
679 let cid = ClientOrderId::new("order-123");
680 let ctx = test_context();
681
682 map.register(cid.clone(), 42, ctx.clone());
683
684 assert_eq!(map.get_ib_id(&cid), Some(42));
685 assert_eq!(map.get_client_id(42), Some(cid.clone()));
686 assert_eq!(map.len(), 1);
687
688 let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
689 assert_eq!(retrieved_cid, cid);
690 assert_eq!(retrieved_ctx.side, Side::Buy);
691 assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
692 }
693
694 #[test]
695 fn test_order_id_map_remove() {
696 let map = OrderIdMap::new();
697 let cid = ClientOrderId::new("order-456");
698
699 map.register(cid.clone(), 100, test_context());
700 assert_eq!(map.len(), 1);
701
702 let removed = map.remove_by_ib_id(100);
703 assert_eq!(removed, Some(cid.clone()));
704 assert!(map.is_empty());
705 assert!(map.get_ib_id(&cid).is_none());
706 assert!(map.get_client_id(100).is_none());
707 assert!(map.get_client_id_and_context(100).is_none());
708 }
709
710 #[test]
711 fn test_side_conversion() {
712 assert!(matches!(
713 side_to_action(rustrade_instrument::Side::Buy),
714 Action::Buy
715 ));
716 assert!(matches!(
717 side_to_action(rustrade_instrument::Side::Sell),
718 Action::Sell
719 ));
720 }
721
722 #[test]
723 fn test_time_in_force_conversion() {
724 assert_eq!(
725 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
726 Ok(IbTimeInForce::GoodTilCanceled)
727 );
728
729 assert!(matches!(
730 time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
731 Err(OrderMappingError::PostOnlyNotSupported)
732 ));
733
734 assert_eq!(
735 time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
736 Ok(IbTimeInForce::Day)
737 );
738 assert_eq!(
739 time_in_force_to_ib(&TimeInForce::FillOrKill),
740 Ok(IbTimeInForce::FillOrKill)
741 );
742 assert_eq!(
743 time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
744 Ok(IbTimeInForce::ImmediateOrCancel)
745 );
746 }
747
748 #[test]
749 fn test_time_in_force_conversion_good_till_date() {
750 use chrono::{TimeZone, Utc};
751
752 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
753 assert_eq!(
754 time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
755 Ok(IbTimeInForce::GoodTilDate)
756 );
757 }
758
759 #[test]
760 fn test_time_in_force_conversion_at_open() {
761 assert_eq!(
762 time_in_force_to_ib(&TimeInForce::AtOpen),
763 Ok(IbTimeInForce::OnOpen)
764 );
765 }
766
767 #[test]
768 fn test_time_in_force_conversion_at_close_returns_err() {
769 assert_eq!(
773 time_in_force_to_ib(&TimeInForce::AtClose),
774 Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
775 );
776 }
777
778 #[test]
779 fn test_build_market_order() {
780 let order = build_ib_order(
781 rustrade_instrument::Side::Buy,
782 100.0,
783 &OrderKind::Market,
784 None, &TimeInForce::GoodUntilEndOfDay,
786 )
787 .unwrap();
788
789 assert_eq!(order.action, Action::Buy);
790 assert_eq!(order.total_quantity, 100.0);
791 assert_eq!(order.order_type, "MKT");
792 }
793
794 #[test]
795 fn test_build_limit_order() {
796 let order = build_ib_order(
797 rustrade_instrument::Side::Sell,
798 50.0,
799 &OrderKind::Limit,
800 Some(Decimal::try_from(150.5).unwrap()),
801 &TimeInForce::GoodUntilCancelled { post_only: false },
802 )
803 .unwrap();
804
805 assert_eq!(order.action, Action::Sell);
806 assert_eq!(order.total_quantity, 50.0);
807 assert_eq!(order.order_type, "LMT");
808 }
809
810 #[test]
811 fn test_order_id_map_remove_and_get_context() {
812 let map = OrderIdMap::new();
813 let cid = ClientOrderId::new("order-789");
814 let ctx = test_context();
815
816 map.register(cid.clone(), 50, ctx);
817 assert_eq!(map.len(), 1);
818
819 let result = map.remove_and_get_context(50);
821 assert!(result.is_some());
822 let (retrieved_cid, retrieved_ctx) = result.unwrap();
823 assert_eq!(retrieved_cid, cid);
824 assert_eq!(retrieved_ctx.side, Side::Buy);
825
826 assert!(map.is_empty());
828 assert!(map.get_client_id(50).is_none());
829 assert!(map.get_ib_id(&cid).is_none());
830
831 assert!(map.remove_and_get_context(50).is_none());
833 }
834
835 #[test]
836 fn test_order_id_map_clear_stale() {
837 use std::time::Duration;
838
839 let map = OrderIdMap::new();
840
841 map.register(ClientOrderId::new("old-1"), 1, test_context());
843 map.register(ClientOrderId::new("old-2"), 2, test_context());
844
845 let cleared = map.clear_stale(Duration::ZERO);
847 assert_eq!(cleared, 2);
848 assert!(map.is_empty());
849
850 map.register(ClientOrderId::new("new-1"), 10, test_context());
852 map.register(ClientOrderId::new("new-2"), 20, test_context());
853
854 let cleared = map.clear_stale(Duration::from_secs(3600));
856 assert_eq!(cleared, 0);
857 assert_eq!(map.len(), 2);
858 }
859
860 #[test]
861 fn test_pending_cancels_insert_remove() {
862 let cancels = PendingCancels::new();
863 assert!(cancels.is_empty());
864
865 cancels.insert(42);
867 assert_eq!(cancels.len(), 1);
868 assert!(!cancels.is_empty());
869
870 assert!(cancels.remove(42));
872 assert!(cancels.is_empty());
873
874 assert!(!cancels.remove(42));
876 assert!(!cancels.remove(999));
877 }
878
879 #[test]
880 fn test_pending_cancels_multiple() {
881 let cancels = PendingCancels::new();
882
883 cancels.insert(1);
884 cancels.insert(2);
885 cancels.insert(3);
886 assert_eq!(cancels.len(), 3);
887
888 assert!(cancels.remove(2));
890 assert_eq!(cancels.len(), 2);
891
892 assert!(cancels.remove(1));
894 assert!(cancels.remove(3));
895 assert!(cancels.is_empty());
896 }
897
898 #[test]
899 fn test_pending_cancels_clear_stale() {
900 use std::time::Duration;
901
902 let cancels = PendingCancels::new();
903
904 cancels.insert(1);
905 cancels.insert(2);
906
907 let cleared = cancels.clear_stale(Duration::ZERO);
909 assert_eq!(cleared, 2);
910 assert!(cancels.is_empty());
911
912 cancels.insert(10);
914 cancels.insert(20);
915
916 let cleared = cancels.clear_stale(Duration::from_secs(3600));
918 assert_eq!(cleared, 0);
919 assert_eq!(cancels.len(), 2);
920 }
921
922 #[test]
923 fn test_pending_cancels_duplicate_insert() {
924 let cancels = PendingCancels::new();
925
926 cancels.insert(42);
927 cancels.insert(42);
928
929 assert_eq!(cancels.len(), 1);
931 assert!(cancels.remove(42));
932 assert!(cancels.is_empty());
933 }
934
935 #[test]
936 fn test_build_stop_order() {
937 let order = build_ib_order(
938 rustrade_instrument::Side::Sell,
939 100.0,
940 &OrderKind::Stop {
941 trigger_price: Decimal::from(45),
942 },
943 None, &TimeInForce::GoodUntilCancelled { post_only: false },
945 )
946 .unwrap();
947
948 assert_eq!(order.action, Action::Sell);
949 assert_eq!(order.total_quantity, 100.0);
950 assert_eq!(order.order_type, "STP");
951 assert_eq!(order.aux_price, Some(45.0));
952 assert_eq!(order.limit_price, None);
953 }
954
955 #[test]
956 fn test_build_stop_limit_order() {
957 let order = build_ib_order(
958 rustrade_instrument::Side::Sell,
959 100.0,
960 &OrderKind::StopLimit {
961 trigger_price: Decimal::from(44),
962 },
963 Some(Decimal::from(45)), &TimeInForce::GoodUntilCancelled { post_only: false },
965 )
966 .unwrap();
967
968 assert_eq!(order.action, Action::Sell);
969 assert_eq!(order.total_quantity, 100.0);
970 assert_eq!(order.order_type, "STP LMT");
971 assert_eq!(order.aux_price, Some(44.0)); assert_eq!(order.limit_price, Some(45.0)); }
974
975 #[test]
976 fn test_build_trailing_stop_percentage() {
977 let order = build_ib_order(
978 rustrade_instrument::Side::Sell,
979 100.0,
980 &OrderKind::TrailingStop {
981 offset: Decimal::from(5),
982 offset_type: TrailingOffsetType::Percentage,
983 },
984 None, &TimeInForce::GoodUntilCancelled { post_only: false },
986 )
987 .unwrap();
988
989 assert_eq!(order.action, Action::Sell);
990 assert_eq!(order.total_quantity, 100.0);
991 assert_eq!(order.order_type, "TRAIL");
992 assert_eq!(order.trailing_percent, Some(5.0));
993 assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, None); }
996
997 #[test]
998 fn test_build_trailing_stop_absolute() {
999 let order = build_ib_order(
1000 rustrade_instrument::Side::Sell,
1001 100.0,
1002 &OrderKind::TrailingStop {
1003 offset: Decimal::from(2),
1004 offset_type: TrailingOffsetType::Absolute,
1005 },
1006 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1008 )
1009 .unwrap();
1010
1011 assert_eq!(order.action, Action::Sell);
1012 assert_eq!(order.total_quantity, 100.0);
1013 assert_eq!(order.order_type, "TRAIL");
1014 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.trailing_percent, None);
1016 assert_eq!(order.trail_stop_price, None);
1017 }
1018
1019 #[test]
1020 fn test_build_trailing_stop_limit_absolute() {
1021 let order = build_ib_order(
1022 rustrade_instrument::Side::Sell,
1023 100.0,
1024 &OrderKind::TrailingStopLimit {
1025 offset: Decimal::from(2),
1026 offset_type: TrailingOffsetType::Absolute,
1027 limit_offset: Decimal::try_from(0.5).unwrap(),
1028 },
1029 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1031 )
1032 .unwrap();
1033
1034 assert_eq!(order.action, Action::Sell);
1035 assert_eq!(order.total_quantity, 100.0);
1036 assert_eq!(order.order_type, "TRAIL LIMIT");
1037 assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.limit_price_offset, Some(0.5)); assert_eq!(order.trailing_percent, None);
1040 }
1041
1042 #[test]
1043 fn test_build_trailing_stop_limit_percentage() {
1044 let order = build_ib_order(
1045 rustrade_instrument::Side::Sell,
1046 100.0,
1047 &OrderKind::TrailingStopLimit {
1048 offset: Decimal::from(5),
1049 offset_type: TrailingOffsetType::Percentage,
1050 limit_offset: Decimal::try_from(0.5).unwrap(),
1051 },
1052 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1054 )
1055 .unwrap();
1056
1057 assert_eq!(order.action, Action::Sell);
1058 assert_eq!(order.total_quantity, 100.0);
1059 assert_eq!(order.order_type, "TRAIL LIMIT");
1060 assert_eq!(order.trailing_percent, Some(5.0));
1061 assert_eq!(order.limit_price_offset, Some(0.5));
1062 assert_eq!(order.aux_price, None); }
1064
1065 #[test]
1066 fn test_build_trailing_stop_basis_points_unsupported() {
1067 let result = build_ib_order(
1068 rustrade_instrument::Side::Sell,
1069 100.0,
1070 &OrderKind::TrailingStop {
1071 offset: Decimal::from(50), offset_type: TrailingOffsetType::BasisPoints,
1073 },
1074 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1076 );
1077
1078 assert!(matches!(
1079 result,
1080 Err(OrderMappingError::UnsupportedOffsetType(
1081 TrailingOffsetType::BasisPoints
1082 ))
1083 ));
1084 }
1085
1086 #[test]
1087 fn test_build_trailing_stop_limit_basis_points_unsupported() {
1088 let result = build_ib_order(
1089 rustrade_instrument::Side::Sell,
1090 100.0,
1091 &OrderKind::TrailingStopLimit {
1092 offset: Decimal::from(50),
1093 offset_type: TrailingOffsetType::BasisPoints,
1094 limit_offset: Decimal::try_from(0.5).unwrap(),
1095 },
1096 None, &TimeInForce::GoodUntilCancelled { post_only: false },
1098 );
1099
1100 assert!(matches!(
1101 result,
1102 Err(OrderMappingError::UnsupportedOffsetType(
1103 TrailingOffsetType::BasisPoints
1104 ))
1105 ));
1106 }
1107
1108 #[test]
1109 fn test_build_take_profit_unsupported() {
1110 let result = build_ib_order(
1111 rustrade_instrument::Side::Sell,
1112 100.0,
1113 &OrderKind::TakeProfit {
1114 trigger_price: Decimal::from(150),
1115 },
1116 None,
1117 &TimeInForce::GoodUntilCancelled { post_only: false },
1118 );
1119
1120 assert!(matches!(
1121 result,
1122 Err(OrderMappingError::UnsupportedOrderKind(
1123 OrderKind::TakeProfit { .. }
1124 ))
1125 ));
1126 }
1127
1128 #[test]
1129 fn test_build_take_profit_limit_unsupported() {
1130 let result = build_ib_order(
1131 rustrade_instrument::Side::Sell,
1132 100.0,
1133 &OrderKind::TakeProfitLimit {
1134 trigger_price: Decimal::from(150),
1135 },
1136 None,
1137 &TimeInForce::GoodUntilCancelled { post_only: false },
1138 );
1139
1140 assert!(matches!(
1141 result,
1142 Err(OrderMappingError::UnsupportedOrderKind(
1143 OrderKind::TakeProfitLimit { .. }
1144 ))
1145 ));
1146 }
1147
1148 #[test]
1153 fn test_build_market_order_at_open() {
1154 let order = build_ib_order(
1155 rustrade_instrument::Side::Buy,
1156 100.0,
1157 &OrderKind::Market,
1158 None, &TimeInForce::AtOpen,
1160 )
1161 .unwrap();
1162
1163 assert_eq!(order.action, Action::Buy);
1164 assert_eq!(order.total_quantity, 100.0);
1165 assert_eq!(order.order_type, "MKT");
1166 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1167 }
1168
1169 #[test]
1170 fn test_build_limit_order_at_open() {
1171 let order = build_ib_order(
1172 rustrade_instrument::Side::Sell,
1173 50.0,
1174 &OrderKind::Limit,
1175 Some(Decimal::from(150)),
1176 &TimeInForce::AtOpen,
1177 )
1178 .unwrap();
1179
1180 assert_eq!(order.action, Action::Sell);
1181 assert_eq!(order.total_quantity, 50.0);
1182 assert_eq!(order.order_type, "LMT");
1183 assert_eq!(order.limit_price, Some(150.0));
1184 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1185 }
1186
1187 #[test]
1188 fn test_build_stop_order_at_open() {
1189 let order = build_ib_order(
1191 rustrade_instrument::Side::Sell,
1192 100.0,
1193 &OrderKind::Stop {
1194 trigger_price: Decimal::from(45),
1195 },
1196 None, &TimeInForce::AtOpen,
1198 )
1199 .unwrap();
1200
1201 assert_eq!(order.order_type, "STP");
1202 assert_eq!(order.aux_price, Some(45.0));
1203 assert_eq!(order.tif, IbTimeInForce::OnOpen);
1204 }
1205
1206 #[test]
1207 fn test_build_market_on_close() {
1208 let order = build_ib_order(
1209 rustrade_instrument::Side::Buy,
1210 100.0,
1211 &OrderKind::Market,
1212 None, &TimeInForce::AtClose,
1214 )
1215 .unwrap();
1216
1217 assert_eq!(order.action, Action::Buy);
1218 assert_eq!(order.total_quantity, 100.0);
1219 assert_eq!(order.order_type, "MOC");
1220 }
1221
1222 #[test]
1223 fn test_build_limit_on_close() {
1224 let order = build_ib_order(
1225 rustrade_instrument::Side::Sell,
1226 50.0,
1227 &OrderKind::Limit,
1228 Some(Decimal::from(150)),
1229 &TimeInForce::AtClose,
1230 )
1231 .unwrap();
1232
1233 assert_eq!(order.action, Action::Sell);
1234 assert_eq!(order.total_quantity, 50.0);
1235 assert_eq!(order.order_type, "LOC");
1236 assert_eq!(order.limit_price, Some(150.0));
1237 }
1238
1239 #[test]
1240 fn test_build_stop_at_close_unsupported() {
1241 let result = build_ib_order(
1242 rustrade_instrument::Side::Sell,
1243 100.0,
1244 &OrderKind::Stop {
1245 trigger_price: Decimal::from(45),
1246 },
1247 None, &TimeInForce::AtClose,
1249 );
1250
1251 assert!(matches!(
1252 result,
1253 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1254 OrderKind::Stop { .. }
1255 ))
1256 ));
1257 }
1258
1259 #[test]
1260 fn test_build_stop_limit_at_close_unsupported() {
1261 let result = build_ib_order(
1262 rustrade_instrument::Side::Sell,
1263 100.0,
1264 &OrderKind::StopLimit {
1265 trigger_price: Decimal::from(44),
1266 },
1267 Some(Decimal::from(45)),
1268 &TimeInForce::AtClose,
1269 );
1270
1271 assert!(matches!(
1272 result,
1273 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1274 OrderKind::StopLimit { .. }
1275 ))
1276 ));
1277 }
1278
1279 #[test]
1280 fn test_build_trailing_stop_at_close_unsupported() {
1281 let result = build_ib_order(
1282 rustrade_instrument::Side::Sell,
1283 100.0,
1284 &OrderKind::TrailingStop {
1285 offset: Decimal::from(5),
1286 offset_type: TrailingOffsetType::Percentage,
1287 },
1288 None, &TimeInForce::AtClose,
1290 );
1291
1292 assert!(matches!(
1293 result,
1294 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1295 OrderKind::TrailingStop { .. }
1296 ))
1297 ));
1298 }
1299
1300 #[test]
1301 fn test_build_trailing_stop_limit_at_close_unsupported() {
1302 let result = build_ib_order(
1303 rustrade_instrument::Side::Sell,
1304 100.0,
1305 &OrderKind::TrailingStopLimit {
1306 offset: Decimal::from(5),
1307 offset_type: TrailingOffsetType::Absolute,
1308 limit_offset: Decimal::from(1),
1309 },
1310 None, &TimeInForce::AtClose,
1312 );
1313
1314 assert!(matches!(
1315 result,
1316 Err(OrderMappingError::UnsupportedOrderKindForAtClose(
1317 OrderKind::TrailingStopLimit { .. }
1318 ))
1319 ));
1320 }
1321
1322 #[test]
1323 fn test_build_good_till_date_order() {
1324 use chrono::{TimeZone, Utc};
1325
1326 let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
1327 let order = build_ib_order(
1328 rustrade_instrument::Side::Buy,
1329 100.0,
1330 &OrderKind::Limit,
1331 Some(Decimal::from(150)),
1332 &TimeInForce::GoodTillDate { expiry },
1333 )
1334 .unwrap();
1335
1336 assert_eq!(order.action, Action::Buy);
1337 assert_eq!(order.total_quantity, 100.0);
1338 assert_eq!(order.order_type, "LMT");
1339 assert_eq!(order.tif, IbTimeInForce::GoodTilDate);
1340 assert_eq!(order.good_till_date, "20250630-23:59:59");
1341 }
1342
1343 #[test]
1344 fn test_format_gtd_datetime() {
1345 use chrono::{TimeZone, Utc};
1346
1347 let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
1348 assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
1349
1350 let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1351 assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
1352 }
1353
1354 #[test]
1359 fn test_build_ib_bracket_with_oca_sets_oca_fields() {
1360 let orders = build_ib_bracket_with_oca(
1361 1000,
1362 Action::Buy,
1363 10.0,
1364 150.0,
1365 160.0,
1366 140.0,
1367 IbTimeInForce::Day,
1368 );
1369
1370 assert_eq!(orders.len(), 3);
1371
1372 assert!(!orders[1].oca_group.is_empty());
1374 assert_eq!(orders[1].oca_group, orders[2].oca_group);
1375
1376 assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
1378 assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
1379
1380 assert!(orders[0].oca_group.is_empty());
1382 assert_eq!(orders[0].oca_type, OcaType::None);
1383 }
1384
1385 #[test]
1386 fn test_build_ib_bracket_with_oca_parent_id_linkage() {
1387 let orders = build_ib_bracket_with_oca(
1388 1000,
1389 Action::Buy,
1390 10.0,
1391 150.0,
1392 160.0,
1393 140.0,
1394 IbTimeInForce::Day,
1395 );
1396
1397 assert_eq!(orders[1].parent_id, 1000); assert_eq!(orders[2].parent_id, 1000); assert_eq!(orders[0].parent_id, 0);
1403 }
1404
1405 #[test]
1406 fn test_build_ib_bracket_with_oca_transmit_flags() {
1407 let orders = build_ib_bracket_with_oca(
1408 1000,
1409 Action::Buy,
1410 10.0,
1411 150.0,
1412 160.0,
1413 140.0,
1414 IbTimeInForce::Day,
1415 );
1416
1417 assert!(!orders[0].transmit); assert!(!orders[1].transmit); assert!(orders[2].transmit); }
1422
1423 #[test]
1424 fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
1425 let orders = build_ib_bracket_with_oca(
1426 500,
1427 Action::Sell,
1428 5.0,
1429 100.0,
1430 90.0,
1431 110.0,
1432 IbTimeInForce::Day,
1433 );
1434
1435 assert_eq!(orders[0].order_id, 500); assert_eq!(orders[1].order_id, 501); assert_eq!(orders[2].order_id, 502); }
1439
1440 #[test]
1441 fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
1442 let orders =
1443 build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day);
1444
1445 assert!(orders[1].oca_group.contains("42"));
1446 assert!(orders[2].oca_group.contains("42"));
1447 }
1448
1449 #[test]
1450 fn test_build_ib_bracket_with_oca_order_types() {
1451 let orders = build_ib_bracket_with_oca(
1452 1000,
1453 Action::Buy,
1454 100.0,
1455 150.0,
1456 160.0,
1457 140.0,
1458 IbTimeInForce::Day,
1459 );
1460
1461 assert_eq!(orders[0].order_type, "LMT");
1463 assert_eq!(orders[0].limit_price, Some(150.0));
1464
1465 assert_eq!(orders[1].order_type, "LMT");
1467 assert_eq!(orders[1].limit_price, Some(160.0));
1468
1469 assert_eq!(orders[2].order_type, "STP");
1471 assert_eq!(orders[2].aux_price, Some(140.0));
1472 }
1473
1474 #[test]
1475 fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
1476 let buy_orders =
1478 build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day);
1479 assert_eq!(buy_orders[0].action, Action::Buy);
1480 assert_eq!(buy_orders[1].action, Action::Sell);
1481 assert_eq!(buy_orders[2].action, Action::Sell);
1482
1483 let sell_orders = build_ib_bracket_with_oca(
1485 200,
1486 Action::Sell,
1487 10.0,
1488 50.0,
1489 45.0,
1490 55.0,
1491 IbTimeInForce::Day,
1492 );
1493 assert_eq!(sell_orders[0].action, Action::Sell);
1494 assert_eq!(sell_orders[1].action, Action::Buy);
1495 assert_eq!(sell_orders[2].action, Action::Buy);
1496 }
1497
1498 #[test]
1499 fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
1500 let orders = build_ib_bracket_with_oca(
1501 1000,
1502 Action::Buy,
1503 10.0,
1504 150.0,
1505 160.0,
1506 140.0,
1507 IbTimeInForce::GoodTilCanceled,
1508 );
1509
1510 assert_eq!(orders[0].tif, IbTimeInForce::GoodTilCanceled);
1511 assert_eq!(orders[1].tif, IbTimeInForce::GoodTilCanceled);
1512 assert_eq!(orders[2].tif, IbTimeInForce::GoodTilCanceled);
1513 }
1514}