use crate::order::{
Order as RustradeOrder, OrderKind, TimeInForce, TrailingOffsetType,
id::{ClientOrderId, StrategyId},
state::UnindexedOrderState,
};
use fnv::{FnvHashMap, FnvHashSet};
use ibapi::orders::{
Action, OcaType, Order, TimeInForce as IbTimeInForce, builder::BracketPrices, 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};
use thiserror::Error;
#[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, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Registration {
pub(crate) age: std::time::Duration,
pub(crate) adopted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub(crate) enum AdoptionRefused {
#[error("its client order id names another live order")]
ClientOrderIdInUse,
#[error("its IB order id names another tracked order")]
IbOrderIdTracked,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[error("client order id {0} already names a live IBKR order")]
pub struct ClientOrderIdInUse(pub ClientOrderId);
#[derive(Debug, Default)]
struct OrderIdMapInner {
cid_to_ib: FnvHashMap<ClientOrderId, i32>,
ib_to_entry: FnvHashMap<i32, (ClientOrderId, OrderContext, Instant)>,
adopted: FnvHashSet<i32>,
}
impl OrderIdMapInner {
fn remove(&mut self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
let (client_id, ctx, _) = self.ib_to_entry.remove(&ib_id)?;
self.adopted.remove(&ib_id);
self.release(&client_id, ib_id);
Some((client_id, ctx))
}
fn in_use<'a>(
&self,
mut orders: impl Iterator<Item = &'a ClientOrderId>,
client_id: &ClientOrderId,
) -> bool {
orders.any(|earlier| earlier == client_id) || self.cid_to_ib.contains_key(client_id)
}
fn insert(
&mut self,
client_id: ClientOrderId,
ib_id: i32,
context: OrderContext,
now: Instant,
) {
self.adopted.remove(&ib_id);
self.cid_to_ib.insert(client_id.clone(), ib_id);
self.ib_to_entry.insert(ib_id, (client_id, context, now));
}
fn release(&mut self, client_id: &ClientOrderId, ib_id: i32) {
if self.cid_to_ib.get(client_id) == Some(&ib_id) {
self.cid_to_ib.remove(client_id);
}
}
}
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,
) -> Result<(), ClientOrderIdInUse> {
self.register_all([(client_id, ib_id, context)])
}
pub fn register_all<const N: usize>(
&self,
orders: [(ClientOrderId, i32, OrderContext); N],
) -> Result<(), ClientOrderIdInUse> {
let mut inner = self.inner.write();
for (index, (client_id, _, _)) in orders.iter().enumerate() {
if inner.in_use(
orders[..index].iter().map(|(earlier, _, _)| earlier),
client_id,
) {
return Err(ClientOrderIdInUse(client_id.clone()));
}
}
let now = Instant::now();
for (client_id, ib_id, context) in orders {
inner.insert(client_id, ib_id, context, now);
}
Ok(())
}
pub(crate) fn adopt(
&self,
client_id: ClientOrderId,
ib_id: i32,
context: OrderContext,
) -> Result<(), AdoptionRefused> {
let mut inner = self.inner.write();
if inner.ib_to_entry.contains_key(&ib_id) {
return Err(AdoptionRefused::IbOrderIdTracked);
}
if inner.in_use(std::iter::empty(), &client_id) {
return Err(AdoptionRefused::ClientOrderIdInUse);
}
inner.insert(client_id, ib_id, context, Instant::now());
inner.adopted.insert(ib_id);
Ok(())
}
pub fn get_ib_id(&self, client_id: &ClientOrderId) -> Option<i32> {
self.inner.read().cid_to_ib.get(client_id).copied()
}
pub(crate) fn registration(&self, client_id: &ClientOrderId) -> Option<Registration> {
let inner = self.inner.read();
let ib_id = inner.cid_to_ib.get(client_id)?;
let (_, _, registered_at) = inner.ib_to_entry.get(ib_id)?;
Some(Registration {
age: registered_at.elapsed(),
adopted: inner.adopted.contains(ib_id),
})
}
pub fn contains(&self, ib_id: i32) -> bool {
self.inner.read().ib_to_entry.contains_key(&ib_id)
}
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 names_other_order(&self, client_id: &ClientOrderId, ib_id: i32) -> bool {
self.inner
.read()
.cid_to_ib
.get(client_id)
.is_some_and(|live| *live != ib_id)
}
pub fn release_client_id(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
let mut inner = self.inner.write();
let (client_id, ctx, _) = inner.ib_to_entry.get(&ib_id)?;
let (client_id, ctx) = (client_id.clone(), ctx.clone());
inner.release(&client_id, ib_id);
Some((client_id, ctx))
}
pub fn remove_and_get_context(&self, ib_id: i32) -> Option<(ClientOrderId, OrderContext)> {
self.inner.write().remove(ib_id)
}
pub fn remove_by_ib_id(&self, ib_id: i32) -> Option<ClientOrderId> {
self.inner
.write()
.remove(ib_id)
.map(|(client_id, _)| client_id)
}
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 {
inner.remove(ib_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()
}
pub(crate) fn contains(&self, ib_id: i32) -> bool {
self.inner.lock().contains_key(&ib_id)
}
#[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,
}
}
pub(crate) fn action_to_side(action: &Action) -> rustrade_instrument::Side {
match action {
Action::Buy => rustrade_instrument::Side::Buy,
Action::Sell | Action::SellShort | Action::SellLong => rustrade_instrument::Side::Sell,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OrderShape {
pub(crate) kind: OrderKind,
pub(crate) price: Option<Decimal>,
pub(crate) time_in_force: TimeInForce,
}
pub(crate) fn order_shape_from_ib(order: &Order) -> Result<OrderShape, String> {
let order_type = order.order_type.as_str();
let decimal = |value: Option<f64>, field: &str| -> Result<Decimal, String> {
let value = value.ok_or_else(|| format!("{order_type} order without {field}"))?;
Decimal::try_from(value).map_err(|e| format!("{order_type} order's {field} {value}: {e}"))
};
let time_in_force = match &order.tif {
IbTimeInForce::Day => TimeInForce::GoodUntilEndOfDay,
IbTimeInForce::GoodTillCanceled => TimeInForce::GoodUntilCancelled { post_only: false },
IbTimeInForce::ImmediateOrCancel => TimeInForce::ImmediateOrCancel,
IbTimeInForce::FillOrKill => TimeInForce::FillOrKill,
IbTimeInForce::OnOpen => TimeInForce::AtOpen,
IbTimeInForce::GoodTillDate => TimeInForce::GoodTillDate {
expiry: parse_gtd_datetime(&order.good_till_date).ok_or_else(|| {
format!(
"good-till-date time {:?} does not parse",
order.good_till_date
)
})?,
},
other => return Err(format!("time in force {other:?}")),
};
let trailing = || -> Result<(Decimal, TrailingOffsetType), String> {
match order.trailing_percent {
Some(_) => Ok((
decimal(order.trailing_percent, "trailing_percent")?,
TrailingOffsetType::Percentage,
)),
None => Ok((
decimal(order.aux_price, "aux_price")?,
TrailingOffsetType::Absolute,
)),
}
};
let (kind, price, time_in_force) = match order_type {
"MKT" => (OrderKind::Market, None, time_in_force),
"LMT" => (
OrderKind::Limit,
Some(decimal(order.limit_price, "limit_price")?),
time_in_force,
),
"MOC" => (OrderKind::Market, None, TimeInForce::AtClose),
"LOC" => (
OrderKind::Limit,
Some(decimal(order.limit_price, "limit_price")?),
TimeInForce::AtClose,
),
"STP" => (
OrderKind::Stop {
trigger_price: decimal(order.aux_price, "aux_price")?,
},
None,
time_in_force,
),
"STP LMT" => (
OrderKind::StopLimit {
trigger_price: decimal(order.aux_price, "aux_price")?,
},
Some(decimal(order.limit_price, "limit_price")?),
time_in_force,
),
"TRAIL" => {
let (offset, offset_type) = trailing()?;
(
OrderKind::TrailingStop {
offset,
offset_type,
activation_price: None,
},
None,
time_in_force,
)
}
"TRAIL LIMIT" => {
let (offset, offset_type) = trailing()?;
let stop_price = decimal(order.trail_stop_price, "trail_stop_price")?;
if stop_price <= Decimal::ZERO {
return Err(format!(
"{order_type} order's trail_stop_price {stop_price} is not positive"
));
}
(
OrderKind::TrailingStopLimit {
offset,
offset_type,
stop_price,
limit_offset: decimal(order.limit_price_offset, "limit_price_offset")?,
},
None,
time_in_force,
)
}
other => return Err(format!("order type {other:?}")),
};
Ok(OrderShape {
kind,
price,
time_in_force,
})
}
fn parse_gtd_datetime(value: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::NaiveDateTime::parse_from_str(value, GTD_FORMAT)
.map(|naive| naive.and_utc())
.ok()
.or_else(|| super::execution::parse_ib_timestamp(value))
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrderMappingError {
PostOnlyNotSupported,
InvalidPrice(String),
MissingLimitPrice(OrderKind),
UnexpectedLimitPrice(OrderKind),
NonPositiveStopPrice(OrderKind),
UnsupportedOffsetType(TrailingOffsetType),
UnsupportedOrderKind(OrderKind),
UnsupportedOrderKindForAtClose(OrderKind),
AtCloseRequiresOrderTypeChange,
InvalidBracketPrices(String),
InvalidClientOrderId(ClientOrderId),
}
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::UnexpectedLimitPrice(k) => write!(
f,
"{k} takes no limit price: it follows from the stop price and limit offset"
),
Self::NonPositiveStopPrice(k) => {
write!(f, "stop price must be positive for {k}")
}
Self::UnsupportedOffsetType(t) => {
write!(f, "trailing offset type {t:?} not supported by IBKR")
}
Self::UnsupportedOrderKind(k) => {
write!(f, "order kind {k} 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"
),
Self::InvalidBracketPrices(e) => write!(f, "invalid bracket prices: {e}"),
Self::InvalidClientOrderId(cid) => write!(
f,
"client order id {cid} is not an IB order reference: at most \
{MAX_ORDER_REF_LEN} ASCII characters"
),
}
}
}
pub const MAX_ORDER_REF_LEN: usize = 128;
pub fn order_ref(cid: &ClientOrderId) -> Result<String, OrderMappingError> {
let id = cid.0.as_str();
if id.is_empty() || id.len() > MAX_ORDER_REF_LEN || !id.is_ascii() {
return Err(OrderMappingError::InvalidClientOrderId(cid.clone()));
}
Ok(id.to_owned())
}
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::GoodTillCanceled)
}
}
TimeInForce::GoodUntilEndOfDay => Ok(IbTimeInForce::Day),
TimeInForce::FillOrKill => Ok(IbTimeInForce::FillOrKill),
TimeInForce::ImmediateOrCancel => Ok(IbTimeInForce::ImmediateOrCancel),
TimeInForce::GoodTillDate { .. } => Ok(IbTimeInForce::GoodTillDate),
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 {
action,
order_type: "STP LMT".to_owned(),
total_quantity: quantity,
limit_price: Some(limit_f64),
aux_price: Some(trigger_f64),
..Order::default()
}
}
OrderKind::TrailingStop {
offset,
offset_type,
activation_price: None,
} => {
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,
stop_price,
limit_offset,
} => {
if price.is_some() {
return Err(OrderMappingError::UnexpectedLimitPrice(*kind));
}
if *stop_price <= Decimal::ZERO {
return Err(OrderMappingError::NonPositiveStopPrice(*kind));
}
let offset_f64 = decimal_to_f64(*offset)?;
let stop_price_f64 = decimal_to_f64(*stop_price)?;
let limit_offset_f64 = decimal_to_f64(*limit_offset)?;
let (aux_price, trailing_percent) = match offset_type {
TrailingOffsetType::Absolute => (Some(offset_f64), None),
TrailingOffsetType::Percentage => (None, Some(offset_f64)),
TrailingOffsetType::BasisPoints => {
return Err(OrderMappingError::UnsupportedOffsetType(
TrailingOffsetType::BasisPoints,
));
}
};
Order {
action,
order_type: "TRAIL LIMIT".to_owned(),
total_quantity: quantity,
aux_price,
trailing_percent,
trail_stop_price: Some(stop_price_f64),
limit_price_offset: Some(limit_offset_f64),
..Order::default()
}
}
OrderKind::TakeProfit { .. }
| OrderKind::TakeProfitLimit { .. }
| OrderKind::TrailingStop {
activation_price: Some(_),
..
}
| OrderKind::TrailingStopFixedLimit { .. } => {
return Err(OrderMappingError::UnsupportedOrderKind(*kind));
}
};
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(GTD_FORMAT).to_string()
}
const GTD_FORMAT: &str = "%Y%m%d-%H:%M:%S";
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,
) -> Result<Vec<Order>, OrderMappingError> {
let prices = BracketPrices {
entry: limit_price,
take_profit: take_profit_price,
stop_loss: stop_loss_price,
};
let mut orders = order_builder::bracket_order(parent_order_id, action, quantity, prices)
.map_err(|e| OrderMappingError::InvalidBracketPrices(e.to_string()))?;
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;
Ok(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()).unwrap();
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()).unwrap();
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::GoodTillCanceled)
);
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::GoodTillDate)
);
}
#[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).unwrap();
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())
.unwrap();
map.register(ClientOrderId::new("old-2"), 2, test_context())
.unwrap();
let cleared = map.clear_stale(Duration::ZERO);
assert_eq!(cleared, 2);
assert!(map.is_empty());
map.register(ClientOrderId::new("new-1"), 10, test_context())
.unwrap();
map.register(ClientOrderId::new("new-2"), 20, test_context())
.unwrap();
let cleared = map.clear_stale(Duration::from_secs(3600));
assert_eq!(cleared, 0);
assert_eq!(map.len(), 2);
}
#[test]
fn a_live_orders_id_is_refused_to_a_second_order() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("taken");
map.register(cid.clone(), 1, test_context()).unwrap();
assert_eq!(
map.register(cid.clone(), 2, test_context()),
Err(ClientOrderIdInUse(cid.clone()))
);
assert_eq!(
map.get_ib_id(&cid),
Some(1),
"a cancel by id still reaches the first"
);
assert!(
map.get_client_id(2).is_none(),
"nothing of the second was registered"
);
}
#[test]
fn a_filled_order_frees_its_id_and_keeps_resolving_by_ib_id() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("reused");
map.register(cid.clone(), 1, test_context()).unwrap();
assert_eq!(
map.release_client_id(1).map(|(id, _)| id),
Some(cid.clone())
);
assert!(map.is_empty(), "no order is live");
assert_eq!(
map.get_client_id(1),
Some(cid.clone()),
"a late execution resolves"
);
map.register(cid.clone(), 2, test_context()).unwrap();
assert_eq!(map.get_ib_id(&cid), Some(2));
assert_eq!(map.get_client_id(1), Some(cid.clone()), "and still does");
}
#[test]
fn an_earlier_order_ending_leaves_a_reused_ids_mapping() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("reused");
map.register(cid.clone(), 1, test_context()).unwrap();
let _ = map.release_client_id(1);
map.register(cid.clone(), 2, test_context()).unwrap();
assert!(
map.remove_and_get_context(1).is_some(),
"the first order's entry goes"
);
assert_eq!(map.get_ib_id(&cid), Some(2), "the second keeps the id");
assert!(map.remove_by_ib_id(1).is_none());
assert_eq!(map.get_ib_id(&cid), Some(2));
}
#[test]
fn reaping_a_filled_order_leaves_a_reused_ids_mapping() {
use std::time::Duration;
let map = OrderIdMap::new();
let cid = ClientOrderId::new("reused");
map.register(cid.clone(), 1, test_context()).unwrap();
let _ = map.release_client_id(1);
std::thread::sleep(Duration::from_millis(5));
map.register(cid.clone(), 2, test_context()).unwrap();
assert_eq!(
map.clear_stale(Duration::from_millis(5)),
1,
"only the filled one"
);
assert_eq!(map.get_ib_id(&cid), Some(2));
}
#[test]
fn register_all_registers_every_order_or_none() {
let map = OrderIdMap::new();
map.register(ClientOrderId::new("live"), 1, test_context())
.unwrap();
let clash = map.register_all([
(ClientOrderId::new("a"), 10, test_context()),
(ClientOrderId::new("live"), 11, test_context()),
]);
assert_eq!(clash, Err(ClientOrderIdInUse(ClientOrderId::new("live"))));
let repeated = map.register_all([
(ClientOrderId::new("b"), 20, test_context()),
(ClientOrderId::new("b"), 21, test_context()),
]);
assert_eq!(repeated, Err(ClientOrderIdInUse(ClientOrderId::new("b"))));
assert_eq!(map.len(), 1, "neither registered anything");
assert!(
[10, 11, 20, 21]
.iter()
.all(|ib_id| map.get_client_id(*ib_id).is_none())
);
map.register_all([
(ClientOrderId::new("a"), 10, test_context()),
(ClientOrderId::new("b"), 20, test_context()),
])
.unwrap();
assert_eq!(map.len(), 3);
}
#[test]
fn an_adopted_order_is_tracked_and_marked_adopted() {
let map = OrderIdMap::new();
let cid = ClientOrderId::new("listed");
map.adopt(cid.clone(), 7, test_context()).unwrap();
assert_eq!(map.get_ib_id(&cid), Some(7));
assert_eq!(map.get_client_id(7), Some(cid.clone()));
assert!(map.registration(&cid).is_some_and(|r| r.adopted));
}
#[test]
fn adopting_an_ib_order_id_already_tracked_is_refused_and_changes_nothing() {
let map = OrderIdMap::new();
let placed = ClientOrderId::new("placed");
let adopted = ClientOrderId::new("adopted");
let filled = ClientOrderId::new("filled");
map.register(placed.clone(), 1, test_context()).unwrap();
map.adopt(adopted.clone(), 2, test_context()).unwrap();
map.register(filled.clone(), 3, test_context()).unwrap();
let _ = map.release_client_id(3);
for ib_id in [1, 2, 3] {
let other = ClientOrderId::new(smol_str::format_smolstr!("other-{ib_id}"));
assert_eq!(
map.adopt(other.clone(), ib_id, test_context()),
Err(AdoptionRefused::IbOrderIdTracked)
);
assert!(map.get_ib_id(&other).is_none(), "{other} was not tracked");
}
assert_eq!(map.get_client_id(1), Some(placed.clone()));
assert_eq!(map.get_client_id(2), Some(adopted.clone()));
assert_eq!(map.get_client_id(3), Some(filled.clone()));
assert!(map.registration(&placed).is_some_and(|r| !r.adopted));
assert!(map.registration(&adopted).is_some_and(|r| r.adopted));
assert!(
map.registration(&filled).is_none(),
"the filled order's id stays free"
);
assert_eq!(map.len(), 2);
}
#[test]
fn adopting_a_client_order_id_a_live_order_holds_is_refused_and_changes_nothing() {
let map = OrderIdMap::new();
let placed = ClientOrderId::new("placed");
let adopted = ClientOrderId::new("adopted");
map.register(placed.clone(), 1, test_context()).unwrap();
map.adopt(adopted.clone(), 2, test_context()).unwrap();
let before = [
map.registration(&placed).map(|r| r.adopted),
map.registration(&adopted).map(|r| r.adopted),
];
for (cid, ib_id) in [(&placed, 10), (&adopted, 20)] {
assert_eq!(
map.adopt(cid.clone(), ib_id, test_context()),
Err(AdoptionRefused::ClientOrderIdInUse)
);
assert!(!map.contains(ib_id), "IB order {ib_id} was not tracked");
}
assert_eq!(map.get_ib_id(&placed), Some(1));
assert_eq!(map.get_ib_id(&adopted), Some(2));
assert_eq!(
[
map.registration(&placed).map(|r| r.adopted),
map.registration(&adopted).map(|r| r.adopted),
],
before
);
assert_eq!(map.len(), 2);
}
#[test]
fn an_order_is_adopted_under_an_id_whose_earlier_order_ended() {
let map = OrderIdMap::new();
let cancelled = ClientOrderId::new("cancelled");
let filled = ClientOrderId::new("filled");
map.register(cancelled.clone(), 1, test_context()).unwrap();
map.register(filled.clone(), 2, test_context()).unwrap();
let _ = map.remove_and_get_context(1);
let _ = map.release_client_id(2);
map.adopt(cancelled.clone(), 11, test_context()).unwrap();
map.adopt(filled.clone(), 12, test_context()).unwrap();
assert_eq!(map.get_ib_id(&cancelled), Some(11));
assert_eq!(map.get_ib_id(&filled), Some(12));
assert!(map.registration(&cancelled).is_some_and(|r| r.adopted));
assert!(map.registration(&filled).is_some_and(|r| r.adopted));
assert_eq!(
map.get_client_id(2),
Some(filled),
"the filled order's executions resolve"
);
}
#[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,
activation_price: None,
},
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,
activation_price: None,
},
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,
stop_price: Decimal::from(95),
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);
assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, 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,
stop_price: Decimal::from(95),
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); assert_eq!(order.trail_stop_price, Some(95.0)); assert_eq!(order.limit_price, None); }
#[test]
fn test_build_trailing_stop_limit_rejects_non_positive_stop_price() {
for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
for stop_price in [Decimal::ZERO, Decimal::from(-1)] {
let kind = OrderKind::TrailingStopLimit {
offset: Decimal::from(2),
offset_type,
stop_price,
limit_offset: Decimal::from(1),
};
assert_eq!(
build_ib_order(
rustrade_instrument::Side::Sell,
1.0,
&kind,
None,
&TimeInForce::GoodUntilCancelled { post_only: false },
),
Err(OrderMappingError::NonPositiveStopPrice(kind)),
"{kind}"
);
}
}
}
#[test]
fn test_build_trailing_stop_limit_rejects_limit_price() {
for offset_type in [TrailingOffsetType::Absolute, TrailingOffsetType::Percentage] {
let kind = OrderKind::TrailingStopLimit {
offset: Decimal::from(2),
offset_type,
stop_price: Decimal::from(95),
limit_offset: Decimal::from(1),
};
assert_eq!(
build_ib_order(
rustrade_instrument::Side::Sell,
1.0,
&kind,
Some(Decimal::from(94)),
&TimeInForce::GoodUntilCancelled { post_only: false },
),
Err(OrderMappingError::UnexpectedLimitPrice(kind)),
"{kind}"
);
}
}
#[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,
activation_price: None,
},
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,
stop_price: Decimal::from(95),
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_activated_and_fixed_limit_trailing_stops_unsupported() {
let tif = TimeInForce::GoodUntilCancelled { post_only: false };
for kind in [
OrderKind::TrailingStop {
offset: Decimal::from(2),
offset_type: TrailingOffsetType::Percentage,
activation_price: Some(Decimal::from(150)),
},
OrderKind::TrailingStopFixedLimit {
offset: Decimal::from(2),
offset_type: TrailingOffsetType::Percentage,
activation_price: None,
},
] {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&kind,
Some(Decimal::from(140)),
&tif,
);
assert!(
matches!(result, Err(OrderMappingError::UnsupportedOrderKind(_))),
"{kind}: {result:?}"
);
}
}
#[test]
fn test_build_take_profit_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TakeProfit {
trigger_price: Decimal::from(150),
},
None,
&TimeInForce::GoodUntilCancelled { post_only: false },
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOrderKind(
OrderKind::TakeProfit { .. }
))
));
}
#[test]
fn test_build_take_profit_limit_unsupported() {
let result = build_ib_order(
rustrade_instrument::Side::Sell,
100.0,
&OrderKind::TakeProfitLimit {
trigger_price: Decimal::from(150),
},
None,
&TimeInForce::GoodUntilCancelled { post_only: false },
);
assert!(matches!(
result,
Err(OrderMappingError::UnsupportedOrderKind(
OrderKind::TakeProfitLimit { .. }
))
));
}
#[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,
activation_price: None,
},
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,
stop_price: Decimal::from(95),
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::GoodTillDate);
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,
)
.unwrap();
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,
)
.unwrap();
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,
)
.unwrap();
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,
)
.unwrap();
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_rejects_prices_on_the_wrong_side_of_the_entry() {
let result =
build_ib_bracket_with_oca(7, Action::Buy, 1.0, 10.0, 9.0, 8.0, IbTimeInForce::Day);
assert!(matches!(
result,
Err(OrderMappingError::InvalidBracketPrices(_))
));
let result =
build_ib_bracket_with_oca(7, Action::Sell, 1.0, 10.0, 8.0, 9.0, IbTimeInForce::Day);
assert!(matches!(
result,
Err(OrderMappingError::InvalidBracketPrices(_))
));
}
#[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)
.unwrap();
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,
)
.unwrap();
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)
.unwrap();
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,
)
.unwrap();
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::GoodTillCanceled,
)
.unwrap();
assert_eq!(orders[0].tif, IbTimeInForce::GoodTillCanceled);
assert_eq!(orders[1].tif, IbTimeInForce::GoodTillCanceled);
assert_eq!(orders[2].tif, IbTimeInForce::GoodTillCanceled);
}
#[test]
fn order_ref_is_the_client_order_id_when_ib_echoes_it_whole() {
let longest = "a".repeat(MAX_ORDER_REF_LEN);
assert_eq!(
order_ref(&ClientOrderId::new(longest.as_str())).unwrap(),
longest
);
assert_eq!(
order_ref(&ClientOrderId::new("cid-1_tp")).unwrap(),
"cid-1_tp"
);
let too_long = "a".repeat(MAX_ORDER_REF_LEN + 1);
for refused in ["", too_long.as_str(), "ordre-é"] {
let cid = ClientOrderId::new(refused);
assert_eq!(
order_ref(&cid),
Err(OrderMappingError::InvalidClientOrderId(cid.clone()))
);
}
}
#[test]
fn order_shape_from_ib_reads_back_what_build_ib_order_sends() {
use chrono::TimeZone;
use rust_decimal_macros::dec;
let expiry = chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap();
let kinds = [
(OrderKind::Market, None),
(OrderKind::Limit, Some(dec!(150.25))),
(
OrderKind::Stop {
trigger_price: dec!(140.5),
},
None,
),
(
OrderKind::StopLimit {
trigger_price: dec!(140.5),
},
Some(dec!(140)),
),
(
OrderKind::TrailingStop {
offset: dec!(2.5),
offset_type: TrailingOffsetType::Percentage,
activation_price: None,
},
None,
),
(
OrderKind::TrailingStop {
offset: dec!(1.75),
offset_type: TrailingOffsetType::Absolute,
activation_price: None,
},
None,
),
(
OrderKind::TrailingStopLimit {
offset: dec!(2),
offset_type: TrailingOffsetType::Percentage,
stop_price: dec!(130.5),
limit_offset: dec!(0.5),
},
None,
),
(
OrderKind::TrailingStopLimit {
offset: dec!(1.5),
offset_type: TrailingOffsetType::Absolute,
stop_price: dec!(139.75),
limit_offset: dec!(0.25),
},
None,
),
];
let tifs = [
TimeInForce::GoodUntilCancelled { post_only: false },
TimeInForce::GoodUntilEndOfDay,
TimeInForce::ImmediateOrCancel,
TimeInForce::FillOrKill,
TimeInForce::AtOpen,
TimeInForce::GoodTillDate { expiry },
];
for (kind, price) in kinds {
for time_in_force in tifs {
let order = build_ib_order(Side::Buy, 1.0, &kind, price, &time_in_force).unwrap();
assert_eq!(
order_shape_from_ib(&order),
Ok(OrderShape {
kind,
price,
time_in_force
}),
"{kind} {time_in_force}"
);
}
}
for (kind, price) in [
(OrderKind::Market, None),
(OrderKind::Limit, Some(dec!(99))),
] {
let order =
build_ib_order(Side::Sell, 1.0, &kind, price, &TimeInForce::AtClose).unwrap();
assert_eq!(
order_shape_from_ib(&order),
Ok(OrderShape {
kind,
price,
time_in_force: TimeInForce::AtClose
}),
"{kind}"
);
}
}
#[test]
fn order_shape_from_ib_reads_ibs_own_good_till_date_form() {
use chrono::TimeZone;
let order = Order {
order_type: "MKT".to_owned(),
tif: IbTimeInForce::GoodTillDate,
good_till_date: "20261009 16:00:00 US/Eastern".to_owned(),
..Order::default()
};
assert_eq!(
order_shape_from_ib(&order).unwrap().time_in_force,
TimeInForce::GoodTillDate {
expiry: chrono::Utc.with_ymd_and_hms(2026, 10, 9, 20, 0, 0).unwrap()
}
);
}
#[test]
fn order_shape_from_ib_refuses_what_this_client_never_sends() {
let order = |order_type: &str, tif: IbTimeInForce| Order {
order_type: order_type.to_owned(),
tif,
limit_price: Some(1.0),
..Order::default()
};
for (order, reason) in [
(order("REL", IbTimeInForce::Day), "order type"),
(
order("LMT", IbTimeInForce::DayTillCanceled),
"time in force",
),
(
Order {
limit_price: None,
..order("LMT", IbTimeInForce::Day)
},
"without limit_price",
),
(order("STP", IbTimeInForce::Day), "without aux_price"),
(order("MKT", IbTimeInForce::GoodTillDate), "good-till-date"),
(
Order {
aux_price: Some(2.0),
limit_price_offset: Some(0.5),
..order("TRAIL LIMIT", IbTimeInForce::Day)
},
"without trail_stop_price",
),
(
Order {
aux_price: Some(2.0),
trail_stop_price: Some(0.0),
limit_price_offset: Some(0.5),
..order("TRAIL LIMIT", IbTimeInForce::Day)
},
"not positive",
),
(
Order {
trailing_percent: Some(5.0),
trail_stop_price: Some(-1.0),
limit_price_offset: Some(0.5),
..order("TRAIL LIMIT", IbTimeInForce::Day)
},
"not positive",
),
] {
let error = order_shape_from_ib(&order).unwrap_err();
assert!(error.contains(reason), "{error:?} should name {reason:?}");
}
}
#[test]
fn action_to_side_reads_a_short_sale_as_a_sell() {
assert_eq!(action_to_side(&Action::Buy), Side::Buy);
for action in [Action::Sell, Action::SellShort, Action::SellLong] {
assert_eq!(action_to_side(&action), Side::Sell, "{action:?}");
}
}
}