use crate::diagnostics::DiagnosticEvent;
use crate::errors::IbError;
use crate::IbClient;
use futures::StreamExt;
use futures::stream::BoxStream;
use ibapi::contracts::Contract;
use ibapi::orders::Order;
use ibapi::subscriptions::SubscriptionItemStreamExt;
pub use ibapi::orders::{Action, OrderStatusKind, TimeInForce};
#[derive(Debug, Clone, PartialEq)]
pub struct OpenOrder {
pub order_id: i32,
pub symbol: String,
pub action: String,
pub quantity: f64,
pub order_type: String,
pub limit_price: Option<f64>,
pub status: String,
pub filled_qty: f64,
pub remaining_qty: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum OrderStatusEvent {
Submitted {
order_id: i32,
},
Filled {
order_id: i32,
filled_qty: f64,
avg_price: f64,
commission: Option<f64>,
execution_id: Option<String>,
},
Cancelled {
order_id: i32,
reason: String,
},
Inactive {
order_id: i32,
},
Rejected {
order_id: i32,
reason: String,
},
Other {
order_id: i32,
status: String,
},
}
pub struct OrderStatusStream {
inner: BoxStream<'static, Result<ibapi::orders::OrderUpdate, ibapi::Error>>,
diagnostic_tx: Option<tokio::sync::broadcast::Sender<DiagnosticEvent>>,
}
impl OrderStatusStream {
pub fn new(
sub: ibapi::subscriptions::Subscription<ibapi::orders::OrderUpdate>,
) -> Self {
Self {
inner: sub.filter_data().boxed(),
diagnostic_tx: None,
}
}
pub fn with_diagnostics(mut self, tx: tokio::sync::broadcast::Sender<DiagnosticEvent>) -> Self {
self.diagnostic_tx = Some(tx);
self
}
pub async fn next(&mut self) -> Option<Result<OrderStatusEvent, IbError>> {
loop {
match self.inner.next().await {
Some(Ok(ibapi::orders::OrderUpdate::OrderStatus(status))) => {
return Some(Ok(map_order_status(&status)));
}
Some(Ok(ibapi::orders::OrderUpdate::CommissionReport(report))) => {
return Some(Ok(OrderStatusEvent::Filled {
order_id: 0, filled_qty: 0.0,
avg_price: 0.0,
commission: Some(report.commission),
execution_id: normalize_execution_id(report.execution_id),
}));
}
Some(Ok(_)) => {
continue;
}
Some(Err(e)) => return Some(Err(IbError::from(e))),
None => return None,
}
}
}
}
fn map_order_status(status: &ibapi::orders::OrderStatus) -> OrderStatusEvent {
let order_id = status.order_id;
match status.status {
OrderStatusKind::ApiPending | OrderStatusKind::ApiCancelled => {
OrderStatusEvent::Other {
order_id,
status: format!("{:?}", status.status),
}
}
OrderStatusKind::Submitted => OrderStatusEvent::Submitted { order_id },
OrderStatusKind::Filled => {
OrderStatusEvent::Filled {
order_id,
filled_qty: status.filled,
avg_price: status.average_fill_price.unwrap_or(0.0),
commission: None,
execution_id: None,
}
}
OrderStatusKind::Cancelled => OrderStatusEvent::Cancelled {
order_id,
reason: status.why_held.clone(),
},
OrderStatusKind::Inactive => OrderStatusEvent::Inactive { order_id },
_ => OrderStatusEvent::Other {
order_id,
status: format!("{:?}", status.status),
},
}
}
fn normalize_execution_id(s: String) -> Option<String> {
if s.is_empty() { None } else { Some(s) }
}
impl IbClient {
pub async fn place_order(
&self,
contract: &Contract,
order: &Order,
) -> Result<i32, IbError> {
let order_id = self
.inner()
.next_valid_order_id()
.await
.map_err(|e| IbError::OrderRejected {
code: 0,
message: format!("failed to get order ID: {e}"),
rejection_json: None,
})?;
self.inner()
.submit_order(order_id, contract, order)
.await
.map_err(|e| IbError::OrderRejected {
code: 0,
message: format!("submit_order failed: {e}"),
rejection_json: None,
})?;
Ok(order_id)
}
pub async fn cancel_order(&self, order_id: i32) -> Result<(), IbError> {
let _sub = self
.inner()
.cancel_order(order_id, "")
.await
.map_err(|e| IbError::OrderRejected {
code: 0,
message: format!("cancel_order failed: {e}"),
rejection_json: None,
})?;
Ok(())
}
pub async fn order_updates(&self) -> Result<OrderStatusStream, IbError> {
let sub = self
.inner()
.order_update_stream()
.await
.map_err(|e| IbError::Other(format!("order_update_stream failed: {e}")))?;
Ok(OrderStatusStream::new(sub))
}
pub async fn open_orders(&self) -> Result<Vec<OpenOrder>, IbError> {
let sub = self
.inner()
.open_orders()
.await
.map_err(|e| IbError::Other(format!("open_orders failed: {e}")))?;
let mut data = sub.filter_data();
let mut orders = Vec::new();
while let Some(item) = data.next().await {
match item {
Ok(ibapi::orders::Orders::OrderData(od)) => {
let order = &od.order;
let state = &od.order_state;
orders.push(OpenOrder {
order_id: od.order_id,
symbol: od.contract.symbol.0.clone(),
action: format!("{:?}", order.action),
quantity: order.total_quantity,
order_type: order.order_type.clone(),
limit_price: order.limit_price,
status: format!("{:?}", state.status),
filled_qty: order.filled_quantity,
remaining_qty: order.total_quantity - order.filled_quantity,
});
}
Ok(ibapi::orders::Orders::OrderStatus(_)) => {} Err(e) => tracing::warn!("open_orders stream error: {e}"),
}
}
Ok(orders)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::IbError;
#[test]
fn place_order_error_maps_to_order_rejected() {
let e = IbError::OrderRejected {
code: 0,
message: "failed to get order ID: connection error".into(),
rejection_json: None,
};
let s = e.to_string();
assert!(s.contains("order rejection"));
assert!(s.contains("failed to get order ID"));
let e2 = IbError::OrderRejected {
code: 201,
message: "submit_order failed: insufficient funds".into(),
rejection_json: Some("{\"reason\":\"bad\"}".into()),
};
let s2 = e2.to_string();
assert!(s2.contains("201"));
assert!(s2.contains("insufficient funds"));
}
#[test]
fn place_order_returns_i32_on_success() {
fn _check() -> Result<i32, IbError> {
Ok(42)
}
assert_eq!(_check().unwrap(), 42);
}
#[test]
fn cancel_order_error_maps_to_order_rejected() {
let e = IbError::OrderRejected {
code: 0,
message: "cancel_order failed: not found".into(),
rejection_json: None,
};
let s = e.to_string();
assert!(s.contains("order rejection"));
assert!(s.contains("cancel_order failed"));
}
#[test]
fn cancel_order_success_returns_ok() {
let result: Result<(), IbError> = Ok(());
assert!(result.is_ok());
}
#[test]
fn order_status_event_submitted_has_order_id() {
let e = OrderStatusEvent::Submitted { order_id: 42 };
assert_eq!(format!("{e:?}"), "Submitted { order_id: 42 }");
}
#[test]
fn order_status_event_filled_has_qty_and_price() {
let e = OrderStatusEvent::Filled {
order_id: 100,
filled_qty: 50.0,
avg_price: 450.25,
commission: Some(1.50),
execution_id: None,
};
match e {
OrderStatusEvent::Filled { order_id, filled_qty, avg_price, commission, .. } => {
assert_eq!(order_id, 100);
assert_eq!(filled_qty, 50.0);
assert_eq!(avg_price, 450.25);
assert_eq!(commission, Some(1.50));
}
_ => panic!("expected Filled variant"),
}
}
#[test]
fn order_status_event_filled_has_execution_id() {
let e = OrderStatusEvent::Filled {
order_id: 100,
filled_qty: 50.0,
avg_price: 450.25,
commission: None,
execution_id: Some("abc123".into()),
};
match e {
OrderStatusEvent::Filled { execution_id, .. } => {
assert_eq!(execution_id, Some("abc123".to_string()));
}
_ => panic!("expected Filled variant"),
}
}
#[test]
fn normalize_execution_id_coerces_empty_string() {
assert_eq!(super::normalize_execution_id("".into()), None);
}
#[test]
fn normalize_execution_id_preserves_non_empty() {
assert_eq!(
super::normalize_execution_id("abc123".into()),
Some("abc123".to_string())
);
}
#[test]
fn order_status_event_cancelled_has_reason() {
let e = OrderStatusEvent::Cancelled {
order_id: 200,
reason: "user requested".into(),
};
let s = format!("{e:?}");
assert!(s.contains("Cancelled"));
assert!(s.contains("user requested"));
}
#[test]
fn order_status_event_rejected_has_reason() {
let e = OrderStatusEvent::Rejected {
order_id: 300,
reason: "insufficient funds".into(),
};
let s = format!("{e:?}");
assert!(s.contains("Rejected"));
assert!(s.contains("insufficient funds"));
}
#[test]
fn order_status_event_inactive_has_order_id() {
let e = OrderStatusEvent::Inactive { order_id: 400 };
assert!(format!("{e:?}").contains("Inactive"));
}
#[test]
fn order_status_event_other_has_status() {
let e = OrderStatusEvent::Other {
order_id: 500,
status: "ApiPending".into(),
};
assert!(format!("{e:?}").contains("ApiPending"));
}
#[test]
fn map_submitted_status() {
let status = ibapi::orders::OrderStatus {
order_id: 1,
status: OrderStatusKind::Submitted,
filled: 0.0,
remaining: 100.0,
average_fill_price: None,
perm_id: 0,
parent_id: 0,
last_fill_price: None,
client_id: 0,
why_held: String::new(),
market_cap_price: None,
};
let event = map_order_status(&status);
assert_eq!(event, OrderStatusEvent::Submitted { order_id: 1 });
}
#[test]
fn map_filled_status() {
let status = ibapi::orders::OrderStatus {
order_id: 2,
status: OrderStatusKind::Filled,
filled: 100.0,
remaining: 0.0,
average_fill_price: Some(450.50),
perm_id: 0,
parent_id: 0,
last_fill_price: None,
client_id: 0,
why_held: String::new(),
market_cap_price: None,
};
let event = map_order_status(&status);
assert_eq!(
event,
OrderStatusEvent::Filled {
order_id: 2,
filled_qty: 100.0,
avg_price: 450.50,
commission: None,
execution_id: None,
}
);
}
#[test]
fn map_cancelled_status() {
let status = ibapi::orders::OrderStatus {
order_id: 3,
status: OrderStatusKind::Cancelled,
filled: 0.0,
remaining: 100.0,
average_fill_price: None,
perm_id: 0,
parent_id: 0,
last_fill_price: None,
client_id: 0,
why_held: "user cancelled".into(),
market_cap_price: None,
};
let event = map_order_status(&status);
assert_eq!(
event,
OrderStatusEvent::Cancelled {
order_id: 3,
reason: "user cancelled".into()
}
);
}
#[test]
fn map_api_pending_is_other() {
let status = ibapi::orders::OrderStatus {
order_id: 4,
status: OrderStatusKind::ApiPending,
filled: 0.0,
remaining: 0.0,
average_fill_price: None,
perm_id: 0,
parent_id: 0,
last_fill_price: None,
client_id: 0,
why_held: String::new(),
market_cap_price: None,
};
let event = map_order_status(&status);
assert!(matches!(event, OrderStatusEvent::Other { .. }));
}
#[test]
fn map_inactive_status() {
let status = ibapi::orders::OrderStatus {
order_id: 5,
status: OrderStatusKind::Inactive,
filled: 0.0,
remaining: 100.0,
average_fill_price: None,
perm_id: 0,
parent_id: 0,
last_fill_price: None,
client_id: 0,
why_held: String::new(),
market_cap_price: None,
};
let event = map_order_status(&status);
assert_eq!(event, OrderStatusEvent::Inactive { order_id: 5 });
}
#[test]
fn order_status_stream_construct() {
fn _check() {
let _ = std::mem::size_of::<OrderStatusStream>();
}
_check();
}
#[test]
fn open_order_struct_fields() {
let o = OpenOrder {
order_id: 42,
symbol: "SPY".into(),
action: "BUY".into(),
quantity: 100.0,
order_type: "LMT".into(),
limit_price: Some(450.0),
status: "Submitted".into(),
filled_qty: 0.0,
remaining_qty: 100.0,
};
assert_eq!(o.symbol, "SPY");
assert_eq!(o.order_id, 42);
assert_eq!(o.limit_price, Some(450.0));
}
#[test]
fn open_order_default_limit_price() {
let o = OpenOrder {
order_id: 1,
symbol: "QQQ".into(),
action: "SELL".into(),
quantity: 200.0,
order_type: "MKT".into(),
limit_price: None,
status: "Filled".into(),
filled_qty: 200.0,
remaining_qty: 0.0,
};
assert!(o.limit_price.is_none());
assert_eq!(o.filled_qty, o.quantity);
}
}