use crate::order::{
Order as RustradeOrder, OrderKind, TimeInForce, TrailingOffsetType,
id::{ClientOrderId, StrategyId},
state::UnindexedOrderState,
};
use fnv::FnvHashMap;
use ibapi::orders::{Action, OcaType, Order, TimeInForce as IbTimeInForce, order_builder};
use parking_lot::{Mutex, RwLock};
use rust_decimal::Decimal;
use rustrade_instrument::{Side, exchange::ExchangeId, instrument::name::InstrumentNameExchange};
use std::{sync::Arc, time::Instant};
#[derive(Debug, Clone)]
pub struct BracketOrderRequest {
pub instrument: InstrumentNameExchange,
pub strategy: StrategyId,
pub parent_cid: ClientOrderId,
pub side: Side,
pub quantity: Decimal,
pub entry_price: Decimal,
pub take_profit_price: Decimal,
pub stop_loss_price: Decimal,
pub time_in_force: TimeInForce,
}
#[derive(Debug, Clone)]
pub struct BracketOrderResult {
pub parent: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
pub take_profit: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
pub stop_loss: RustradeOrder<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
}
#[derive(Debug, Clone)]
pub struct OrderContext {
pub instrument: InstrumentNameExchange,
pub side: Side,
pub price: Option<Decimal>,
pub quantity: Decimal,
pub kind: OrderKind,
pub time_in_force: TimeInForce,
}
#[derive(Debug, Clone)]
pub struct OrderIdMap {
inner: Arc<RwLock<OrderIdMapInner>>,
}
#[derive(Debug, Default)]
struct OrderIdMapInner {
cid_to_ib: FnvHashMap<ClientOrderId, i32>,
ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
}
impl OrderIdMap {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(OrderIdMapInner::default())),
}
}
pub fn register(&self, client_id: ClientOrderId, ib_id: i32, context: OrderContext) {
let mut inner = self.inner.write();
inner.cid_to_ib.insert(client_id.clone(), ib_id);
inner
.ib_to_entry
.insert(ib_id, (client_id, context, Instant::now()));
}
pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
self.inner.read().cid_to_ib.get(client_id).copied()
}
pub fn get_client_id(&self, ib_id: i32) -> Option<ClientOrderId> {
self.inner
.read()
.ib_to_entry
.get(&ib_id)
.map(|(cid, _, _)| cid.clone())
}
pub fn get_client_id_and_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
self.inner
.read()
.ib_to_entry
.get(&ib_id)
.map(|(cid, ctx, _)| (cid.clone(), ctx.clone()))
}
pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
let mut inner = self.inner.write();
if let Some((client_id, ctx, _)) = inner.ib_to_entry.remove(&ib_id) {
inner.cid_to_ib.remove(&client_id);
Some((client_id, ctx))
} else {
None
}
}
pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
let mut inner = self.inner.write();
if let Some((client_id, _, _)) = inner.ib_to_entry.remove(&ib_id) {
inner.cid_to_ib.remove(&client_id);
Some(client_id)
} else {
None
}
}
pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
let mut inner = self.inner.write();
let before = inner.ib_to_entry.len();
let stale_ids: Vec<i32> = inner
.ib_to_entry
.iter()
.filter(|(_, (_, _, registered_at))| registered_at.elapsed() >= max_age)
.map(|(ib_id, _)| *ib_id)
.collect();
for ib_id in stale_ids {
if let Some((client_id, _, _)) = inner.ib_to_entry.remove(&ib_id) {
inner.cid_to_ib.remove(&client_id);
}
}
before - inner.ib_to_entry.len()
}
pub fn len(&self) -> usize {
self.inner.read().cid_to_ib.len()
}
pub fn is_empty(&self) -> bool {
self.inner.read().cid_to_ib.is_empty()
}
}
impl Default for OrderIdMap {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct PendingCancels {
inner: Arc<Mutex<FnvHashMap<i32, Instant>>>,
}
impl PendingCancels {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(FnvHashMap::with_capacity_and_hasher(
8,
Default::default(),
))),
}
}
pub fn insert(&self, ib_id: i32) {
self.inner.lock().insert(ib_id, Instant::now());
}
#[must_use]
pub fn remove(&self, ib_id: i32) -> bool {
self.inner.lock().remove(&ib_id).is_some()
}
#[must_use]
pub fn clear_stale(&self, max_age: std::time::Duration) -> usize {
let mut map = self.inner.lock();
let before = map.len();
map.retain(|_, registered_at| registered_at.elapsed() < max_age);
before - map.len()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.lock().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.inner.lock().is_empty()
}
}
impl Default for PendingCancels {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn side_to_action(side: rustrade_instrument::Side) -> Action {
match side {
rustrade_instrument::Side::Buy => Action::Buy,
rustrade_instrument::Side::Sell => Action::Sell,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrderMappingError {
PostOnlyNotSupported,
InvalidPrice(String),
MissingLimitPrice(OrderKind),
UnsupportedOffsetType(TrailingOffsetType),
UnsupportedOrderKindForAtClose(OrderKind),
AtCloseRequiresOrderTypeChange,
}
impl std::fmt::Display for OrderMappingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PostOnlyNotSupported => write!(f, "post_only not supported by IB"),
Self::InvalidPrice(p) => write!(f, "invalid price for f64 conversion: {p}"),
Self::MissingLimitPrice(k) => {
write!(f, "limit price required for {k} but was None")
}
Self::UnsupportedOffsetType(t) => {
write!(f, "trailing offset type {t:?} not supported by IBKR")
}
Self::UnsupportedOrderKindForAtClose(k) => {
write!(
f,
"AtClose TIF only valid with Market or Limit orders, got {k}"
)
}
Self::AtCloseRequiresOrderTypeChange => write!(
f,
"AtClose TIF must be routed through build_ib_order, which promotes \
the order to MOC/LOC; time_in_force_to_ib cannot map it directly"
),
}
}
}
impl std::error::Error for OrderMappingError {}
pub fn time_in_force_to_ib(tif: &TimeInForce) -> Result<IbTimeInForce, OrderMappingError> {
match tif {
TimeInForce::GoodUntilCancelled { post_only } => {
if *post_only {
Err(OrderMappingError::PostOnlyNotSupported)
} else {
Ok(IbTimeInForce::GoodTilCanceled)
}
}
TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTilDate),
TimeInForce::AtOpen => Ok(IbTimeInForce::OnOpen),
TimeInForce::AtClose => Err(OrderMappingError::AtCloseRequiresOrderTypeChange),
}
}
fn decimal_to_f64(value: rust_decimal::Decimal) -> Result<f64, OrderMappingError> {
value.try_into().or_else(|_| {
value
.to_string()
.parse()
.map_err(|_| OrderMappingError::InvalidPrice(value.to_string()))
})
}
fn require_limit_price(
price: Option<rust_decimal::Decimal>,
kind: OrderKind,
) -> Result<f64, OrderMappingError> {
match price {
Some(p) => decimal_to_f64(p),
None => Err(OrderMappingError::MissingLimitPrice(kind)),
}
}
pub fn build_ib_order(
side: rustrade_instrument::Side,
quantity: f64,
kind: &OrderKind,
price: Option<rust_decimal::Decimal>,
tif: &TimeInForce,
) -> Result<Order, OrderMappingError> {
let action = side_to_action(side);
if matches!(tif, TimeInForce::AtClose) {
return build_at_close_order(action, quantity, kind, price);
}
let tif_ib = time_in_force_to_ib(tif)?;
let mut order = match kind {
OrderKind::Market => order_builder::market_order(action, quantity),
OrderKind::Limit => {
let price_f64 = require_limit_price(price, *kind)?;
order_builder::limit_order(action, quantity, price_f64)
}
OrderKind::Stop { trigger_price } => {
let trigger_f64 = decimal_to_f64(*trigger_price)?;
order_builder::stop(action, quantity, trigger_f64)
}
OrderKind::StopLimit { trigger_price } => {
let limit_f64 = require_limit_price(price, *kind)?;
let trigger_f64 = decimal_to_f64(*trigger_price)?;
order_builder::stop_limit(action, quantity, limit_f64, trigger_f64)
}
OrderKind::TrailingStop {
offset,
offset_type,
} => {
let offset_f64 = decimal_to_f64(*offset)?;
match offset_type {
TrailingOffsetType::Percentage => {
Order {
action,
order_type: "TRAIL".to_owned(),
total_quantity: quantity,
trailing_percent: Some(offset_f64),
trail_stop_price: None,
..Order::default()
}
}
TrailingOffsetType::Absolute => {
Order {
action,
order_type: "TRAIL".to_owned(),
total_quantity: quantity,
aux_price: Some(offset_f64),
trail_stop_price: None,
..Order::default()
}
}
TrailingOffsetType::BasisPoints => {
return Err(OrderMappingError::UnsupportedOffsetType(
TrailingOffsetType::BasisPoints,
));
}
}
}
OrderKind::TrailingStopLimit {
offset,
offset_type,
limit_offset,
} => {
let offset_f64 = decimal_to_f64(*offset)?;
let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
match offset_type {
TrailingOffsetType::Absolute => {
order_builder::trailing_stop_limit(
action,
quantity,
limit_offset_f64,
offset_f64,
0.0, )
}
TrailingOffsetType::Percentage => {
Order {
action,
order_type: "TRAIL LIMIT".to_owned(),
total_quantity: quantity,
trailing_percent: Some(offset_f64),
limit_price_offset: Some(limit_offset_f64),
trail_stop_price: None,
..Order::default()
}
}
TrailingOffsetType::BasisPoints => {
return Err(OrderMappingError::UnsupportedOffsetType(
TrailingOffsetType::BasisPoints,
));
}
}
}
};
order.tif = tif_ib;
if let TimeInForce::GoodTillDate { expiry } = tif {
order.good_till_date = format_gtd_datetime(expiry);
}
Ok(order)
}
fn format_gtd_datetime(dt: &chrono::DateTime<chrono::Utc>) -> String {
dt.format("%Y%m%d-%H:%M:%S").to_string()
}
fn build_at_close_order(
action: Action,
quantity: f64,
kind: &OrderKind,
price: Option<rust_decimal::Decimal>,
) -> Result<Order, OrderMappingError> {
match kind {
OrderKind::Market => Ok(order_builder::market_on_close(action, quantity)),
OrderKind::Limit => {
let price_f64 = require_limit_price(price, *kind)?;
Ok(order_builder::limit_on_close(action, quantity, price_f64))
}
_ => Err(OrderMappingError::UnsupportedOrderKindForAtClose(*kind)),
}
}
pub fn build_ib_bracket_with_oca(
parent_order_id: i32,
action: Action,
quantity: f64,
limit_price: f64,
take_profit_price: f64,
stop_loss_price: f64,
tif: IbTimeInForce,
) -> Vec<Order> {
let mut orders = order_builder::bracket_order(
parent_order_id,
action,
quantity,
limit_price,
take_profit_price,
stop_loss_price,
);
assert_eq!(
orders.len(),
3,
"ibapi bracket_order must return exactly 3 orders (parent, TP, SL)"
);
orders[0].tif = tif.clone();
orders[1].tif = tif.clone();
orders[2].tif = tif;
let oca_group = format!("bracket_{}", parent_order_id);
orders[1].oca_group = oca_group.clone();
orders[1].oca_type = OcaType::CancelWithBlock;
orders[2].oca_group = oca_group;
orders[2].oca_type = OcaType::CancelWithBlock;
orders
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rust_decimal::Decimal;
fn test_context() -> OrderContext {
OrderContext {
instrument: rustrade_instrument::instrument::name::InstrumentNameExchange::from("AAPL"),
side: Side::Buy,
price: Some(Decimal::from(150)),
quantity: Decimal::from(100),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
}
}
#[test]
fn test_order_id_map_basic() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("order-123");
let ctx = test_context();
map.register(cid.clone(), 42, ctx.clone());
assert_eq!(map.get_ib_id(&cid), Some(42));
assert_eq!(map.get_client_id(42), Some(cid.clone()));
assert_eq!(map.len(), 1);
let (retrieved_cid, retrieved_ctx) = map.get_client_id_and_context(42).unwrap();
assert_eq!(retrieved_cid, cid);
assert_eq!(retrieved_ctx.side, Side::Buy);
assert_eq!(retrieved_ctx.price, Some(Decimal::from(150)));
}
#[test]
fn test_order_id_map_remove() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("order-456");
map.register(cid.clone(), 100, test_context());
assert_eq!(map.len(), 1);
let removed = map.remove_by_ib_id(100);
assert_eq!(removed, Some(cid.clone()));
assert!(map.is_empty());
assert!(map.get_ib_id(&cid).is_none());
assert!(map.get_client_id(100).is_none());
assert!(map.get_client_id_and_context(100).is_none());
}
#[test]
fn test_side_conversion() {
assert!(matches!(
side_to_action(rustrade_instrument::Side::Buy),
Action::Buy
));
assert!(matches!(
side_to_action(rustrade_instrument::Side::Sell),
Action::Sell
));
}
#[test]
fn test_time_in_force_conversion() {
assert_eq!(
time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: false }),
Ok(IbTimeInForce::GoodTilCanceled)
);
assert!(matches!(
time_in_force_to_ib(&TimeInForce::GoodUntilCancelled { post_only: true }),
Err(OrderMappingError::PostOnlyNotSupported)
));
assert_eq!(
time_in_force_to_ib(&TimeInForce::GoodUntilEndOfDay),
Ok(IbTimeInForce::Day)
);
assert_eq!(
time_in_force_to_ib(&TimeInForce::FillOrKill),
Ok(IbTimeInForce::FillOrKill)
);
assert_eq!(
time_in_force_to_ib(&TimeInForce::ImmediateOrCancel),
Ok(IbTimeInForce::ImmediateOrCancel)
);
}
#[test]
fn test_time_in_force_conversion_good_till_date() {
use chrono::{TimeZone, Utc};
let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
assert_eq!(
time_in_force_to_ib(&TimeInForce::GoodTillDate { expiry }),
Ok(IbTimeInForce::GoodTilDate)
);
}
#[test]
fn test_time_in_force_conversion_at_open() {
assert_eq!(
time_in_force_to_ib(&TimeInForce::AtOpen),
Ok(IbTimeInForce::OnOpen)
);
}
#[test]
fn test_time_in_force_conversion_at_close_returns_err() {
assert_eq!(
time_in_force_to_ib(&TimeInForce::AtClose),
Err(OrderMappingError::AtCloseRequiresOrderTypeChange)
);
}
#[test]
fn test_build_market_order() {
let order = build_ib_order(
rustrade_instrument::Side::Buy,
100.0,
&OrderKind::Market,
None, &TimeInForce::GoodUntilEndOfDay,
)
.unwrap();
assert_eq!(order.action, Action::Buy);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "MKT");
}
#[test]
fn test_build_limit_order() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
50.0,
&OrderKind::Limit,
Some(Decimal::try_from(150.5).unwrap()),
&TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 50.0);
assert_eq!(order.order_type, "LMT");
}
#[test]
fn test_order_id_map_remove_and_get_context() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("order-789");
let ctx = test_context();
map.register(cid.clone(), 50, ctx);
assert_eq!(map.len(), 1);
let result = map.remove_and_get_context(50);
assert!(result.is_some());
let (retrieved_cid, retrieved_ctx) = result.unwrap();
assert_eq!(retrieved_cid, cid);
assert_eq!(retrieved_ctx.side, Side::Buy);
assert!(map.is_empty());
assert!(map.get_client_id(50).is_none());
assert!(map.get_ib_id(&cid).is_none());
assert!(map.remove_and_get_context(50).is_none());
}
#[test]
fn test_order_id_map_clear_stale() {
use std::time::Duration;
let map = OrderIdMap::new();
map.register(ClientOrderId::new("old-1"), 1, test_context());
map.register(ClientOrderId::new("old-2"), 2, test_context());
let cleared = map.clear_stale(Duration::ZERO);
assert_eq!(cleared, 2);
assert!(map.is_empty());
map.register(ClientOrderId::new("new-1"), 10, test_context());
map.register(ClientOrderId::new("new-2"), 20, test_context());
let cleared = map.clear_stale(Duration::from_secs(3600));
assert_eq!(cleared, 0);
assert_eq!(map.len(), 2);
}
#[test]
fn test_pending_cancels_insert_remove() {
let cancels = PendingCancels::new();
assert!(cancels.is_empty());
cancels.insert(42);
assert_eq!(cancels.len(), 1);
assert!(!cancels.is_empty());
assert!(cancels.remove(42));
assert!(cancels.is_empty());
assert!(!cancels.remove(42));
assert!(!cancels.remove(999));
}
#[test]
fn test_pending_cancels_multiple() {
let cancels = PendingCancels::new();
cancels.insert(1);
cancels.insert(2);
cancels.insert(3);
assert_eq!(cancels.len(), 3);
assert!(cancels.remove(2));
assert_eq!(cancels.len(), 2);
assert!(cancels.remove(1));
assert!(cancels.remove(3));
assert!(cancels.is_empty());
}
#[test]
fn test_pending_cancels_clear_stale() {
use std::time::Duration;
let cancels = PendingCancels::new();
cancels.insert(1);
cancels.insert(2);
let cleared = cancels.clear_stale(Duration::ZERO);
assert_eq!(cleared, 2);
assert!(cancels.is_empty());
cancels.insert(10);
cancels.insert(20);
let cleared = cancels.clear_stale(Duration::from_secs(3600));
assert_eq!(cleared, 0);
assert_eq!(cancels.len(), 2);
}
#[test]
fn test_pending_cancels_duplicate_insert() {
let cancels = PendingCancels::new();
cancels.insert(42);
cancels.insert(42);
assert_eq!(cancels.len(), 1);
assert!(cancels.remove(42));
assert!(cancels.is_empty());
}
#[test]
fn test_build_stop_order() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::Stop {
trigger_price: Decimal::from(45),
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "STP");
assert_eq!(order.aux_price, Some(45.0));
assert_eq!(order.limit_price, None);
}
#[test]
fn test_build_stop_limit_order() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::StopLimit {
trigger_price: Decimal::from(44),
},
Some(Decimal::from(45)), &TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "STP LMT");
assert_eq!(order.aux_price, Some(44.0)); assert_eq!(order.limit_price, Some(45.0)); }
#[test]
fn test_build_trailing_stop_percentage() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStop {
offset: Decimal::from(5),
offset_type: TrailingOffsetType::Percentage,
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "TRAIL");
assert_eq!(order.trailing_percent, Some(5.0));
assert_eq!(order.aux_price, None); assert_eq!(order.trail_stop_price, None); }
#[test]
fn test_build_trailing_stop_absolute() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStop {
offset: Decimal::from(2),
offset_type: TrailingOffsetType::Absolute,
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "TRAIL");
assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.trailing_percent, None);
assert_eq!(order.trail_stop_price, None);
}
#[test]
fn test_build_trailing_stop_limit_absolute() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStopLimit {
offset: Decimal::from(2),
offset_type: TrailingOffsetType::Absolute,
limit_offset: Decimal::try_from(0.5).unwrap(),
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "TRAIL LIMIT");
assert_eq!(order.aux_price, Some(2.0)); assert_eq!(order.limit_price_offset, Some(0.5)); assert_eq!(order.trailing_percent, None);
}
#[test]
fn test_build_trailing_stop_limit_percentage() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStopLimit {
offset: Decimal::from(5),
offset_type: TrailingOffsetType::Percentage,
limit_offset: Decimal::try_from(0.5).unwrap(),
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "TRAIL LIMIT");
assert_eq!(order.trailing_percent, Some(5.0));
assert_eq!(order.limit_price_offset, Some(0.5));
assert_eq!(order.aux_price, None); }
#[test]
fn test_build_trailing_stop_basis_points_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStop {
offset: Decimal::from(50), offset_type: TrailingOffsetType::BasisPoints,
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOffsetType(
TrailingOffsetType::BasisPoints
))
));
}
#[test]
fn test_build_trailing_stop_limit_basis_points_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStopLimit {
offset: Decimal::from(50),
offset_type: TrailingOffsetType::BasisPoints,
limit_offset: Decimal::try_from(0.5).unwrap(),
},
None, &TimeInForce::GoodUntilCancelled { post_only: false },
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOffsetType(
TrailingOffsetType::BasisPoints
))
));
}
#[test]
fn test_build_market_order_at_open() {
let order = build_ib_order(
rustrade_instrument::Side::Buy,
100.0,
&OrderKind::Market,
None, &TimeInForce::AtOpen,
)
.unwrap();
assert_eq!(order.action, Action::Buy);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "MKT");
assert_eq!(order.tif, IbTimeInForce::OnOpen);
}
#[test]
fn test_build_limit_order_at_open() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
50.0,
&OrderKind::Limit,
Some(Decimal::from(150)),
&TimeInForce::AtOpen,
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 50.0);
assert_eq!(order.order_type, "LMT");
assert_eq!(order.limit_price, Some(150.0));
assert_eq!(order.tif, IbTimeInForce::OnOpen);
}
#[test]
fn test_build_stop_order_at_open() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::Stop {
trigger_price: Decimal::from(45),
},
None, &TimeInForce::AtOpen,
)
.unwrap();
assert_eq!(order.order_type, "STP");
assert_eq!(order.aux_price, Some(45.0));
assert_eq!(order.tif, IbTimeInForce::OnOpen);
}
#[test]
fn test_build_market_on_close() {
let order = build_ib_order(
rustrade_instrument::Side::Buy,
100.0,
&OrderKind::Market,
None, &TimeInForce::AtClose,
)
.unwrap();
assert_eq!(order.action, Action::Buy);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "MOC");
}
#[test]
fn test_build_limit_on_close() {
let order = build_ib_order(
rustrade_instrument::Side::Sell,
50.0,
&OrderKind::Limit,
Some(Decimal::from(150)),
&TimeInForce::AtClose,
)
.unwrap();
assert_eq!(order.action, Action::Sell);
assert_eq!(order.total_quantity, 50.0);
assert_eq!(order.order_type, "LOC");
assert_eq!(order.limit_price, Some(150.0));
}
#[test]
fn test_build_stop_at_close_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::Stop {
trigger_price: Decimal::from(45),
},
None, &TimeInForce::AtClose,
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOrderKindForAtClose(
OrderKind::Stop { .. }
))
));
}
#[test]
fn test_build_stop_limit_at_close_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::StopLimit {
trigger_price: Decimal::from(44),
},
Some(Decimal::from(45)),
&TimeInForce::AtClose,
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOrderKindForAtClose(
OrderKind::StopLimit { .. }
))
));
}
#[test]
fn test_build_trailing_stop_at_close_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStop {
offset: Decimal::from(5),
offset_type: TrailingOffsetType::Percentage,
},
None, &TimeInForce::AtClose,
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOrderKindForAtClose(
OrderKind::TrailingStop { .. }
))
));
}
#[test]
fn test_build_trailing_stop_limit_at_close_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TrailingStopLimit {
offset: Decimal::from(5),
offset_type: TrailingOffsetType::Absolute,
limit_offset: Decimal::from(1),
},
None, &TimeInForce::AtClose,
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOrderKindForAtClose(
OrderKind::TrailingStopLimit { .. }
))
));
}
#[test]
fn test_build_good_till_date_order() {
use chrono::{TimeZone, Utc};
let expiry = Utc.with_ymd_and_hms(2025, 6, 30, 23, 59, 59).unwrap();
let order = build_ib_order(
rustrade_instrument::Side::Buy,
100.0,
&OrderKind::Limit,
Some(Decimal::from(150)),
&TimeInForce::GoodTillDate { expiry },
)
.unwrap();
assert_eq!(order.action, Action::Buy);
assert_eq!(order.total_quantity, 100.0);
assert_eq!(order.order_type, "LMT");
assert_eq!(order.tif, IbTimeInForce::GoodTilDate);
assert_eq!(order.good_till_date, "20250630-23:59:59");
}
#[test]
fn test_format_gtd_datetime() {
use chrono::{TimeZone, Utc};
let dt = Utc.with_ymd_and_hms(2024, 12, 25, 14, 30, 0).unwrap();
assert_eq!(format_gtd_datetime(&dt), "20241225-14:30:00");
let dt2 = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
assert_eq!(format_gtd_datetime(&dt2), "20250101-00:00:00");
}
#[test]
fn test_build_ib_bracket_with_oca_sets_oca_fields() {
let orders = build_ib_bracket_with_oca(
1000,
Action::Buy,
10.0,
150.0,
160.0,
140.0,
IbTimeInForce::Day,
);
assert_eq!(orders.len(), 3);
assert!(!orders[1].oca_group.is_empty());
assert_eq!(orders[1].oca_group, orders[2].oca_group);
assert_eq!(orders[1].oca_type, OcaType::CancelWithBlock);
assert_eq!(orders[2].oca_type, OcaType::CancelWithBlock);
assert!(orders[0].oca_group.is_empty());
assert_eq!(orders[0].oca_type, OcaType::None);
}
#[test]
fn test_build_ib_bracket_with_oca_parent_id_linkage() {
let orders = build_ib_bracket_with_oca(
1000,
Action::Buy,
10.0,
150.0,
160.0,
140.0,
IbTimeInForce::Day,
);
assert_eq!(orders[1].parent_id, 1000); assert_eq!(orders[2].parent_id, 1000);
assert_eq!(orders[0].parent_id, 0);
}
#[test]
fn test_build_ib_bracket_with_oca_transmit_flags() {
let orders = build_ib_bracket_with_oca(
1000,
Action::Buy,
10.0,
150.0,
160.0,
140.0,
IbTimeInForce::Day,
);
assert!(!orders[0].transmit); assert!(!orders[1].transmit); assert!(orders[2].transmit); }
#[test]
fn test_build_ib_bracket_with_oca_order_ids_are_consecutive() {
let orders = build_ib_bracket_with_oca(
500,
Action::Sell,
5.0,
100.0,
90.0,
110.0,
IbTimeInForce::Day,
);
assert_eq!(orders[0].order_id, 500); assert_eq!(orders[1].order_id, 501); assert_eq!(orders[2].order_id, 502); }
#[test]
fn test_build_ib_bracket_with_oca_group_name_contains_parent_id() {
let orders =
build_ib_bracket_with_oca(42, Action::Buy, 1.0, 10.0, 12.0, 8.0, IbTimeInForce::Day);
assert!(orders[1].oca_group.contains("42"));
assert!(orders[2].oca_group.contains("42"));
}
#[test]
fn test_build_ib_bracket_with_oca_order_types() {
let orders = build_ib_bracket_with_oca(
1000,
Action::Buy,
100.0,
150.0,
160.0,
140.0,
IbTimeInForce::Day,
);
assert_eq!(orders[0].order_type, "LMT");
assert_eq!(orders[0].limit_price, Some(150.0));
assert_eq!(orders[1].order_type, "LMT");
assert_eq!(orders[1].limit_price, Some(160.0));
assert_eq!(orders[2].order_type, "STP");
assert_eq!(orders[2].aux_price, Some(140.0));
}
#[test]
fn test_build_ib_bracket_with_oca_actions_reversed_for_children() {
let buy_orders =
build_ib_bracket_with_oca(100, Action::Buy, 10.0, 50.0, 55.0, 45.0, IbTimeInForce::Day);
assert_eq!(buy_orders[0].action, Action::Buy);
assert_eq!(buy_orders[1].action, Action::Sell);
assert_eq!(buy_orders[2].action, Action::Sell);
let sell_orders = build_ib_bracket_with_oca(
200,
Action::Sell,
10.0,
50.0,
45.0,
55.0,
IbTimeInForce::Day,
);
assert_eq!(sell_orders[0].action, Action::Sell);
assert_eq!(sell_orders[1].action, Action::Buy);
assert_eq!(sell_orders[2].action, Action::Buy);
}
#[test]
fn test_build_ib_bracket_with_oca_applies_tif_to_all_legs() {
let orders = build_ib_bracket_with_oca(
1000,
Action::Buy,
10.0,
150.0,
160.0,
140.0,
IbTimeInForce::GoodTilCanceled,
);
assert_eq!(orders[0].tif, IbTimeInForce::GoodTilCanceled);
assert_eq!(orders[1].tif, IbTimeInForce::GoodTilCanceled);
assert_eq!(orders[2].tif, IbTimeInForce::GoodTilCanceled);
}
}