pub mod account;
pub mod contract;
pub mod execution;
pub mod order;
mod recovery;
use crate::{
AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, Snapshot, UnindexedAccountEvent,
UnindexedAccountSnapshot,
balance::AssetBalance,
client::{BracketOrderClient, ExecutionClient, dedup::new_dedup_cache},
error::{
ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
},
order::{
Order, OrderKey, OrderKind, TimeInForce,
bracket::{
BracketOrderRequest as UnifiedBracketOrderRequest,
BracketOrderResult as UnifiedBracketOrderResult,
},
id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
request::{
OrderRequestCancel, OrderRequestOpen, OrderResponseCancel, UnindexedOrderResponseCancel,
},
state::{Cancelled, Expired, Filled, Open, OrderState, UnindexedOrderState},
},
trade::{AssetFees, Trade, TradeId},
};
use account::BalanceAggregator;
use chrono::{DateTime, Utc};
use execution::{ExecutionBuffer, parse_decimal_or_warn};
use futures::stream::BoxStream;
use ibapi::{
accounts::{AccountSummaryResult, types::AccountGroup},
client::blocking::Client,
};
pub use order::{BracketOrderRequest, BracketOrderResult};
use order::{
OrderContext, OrderIdMap, PendingCancels, build_ib_bracket_with_oca, build_ib_order,
side_to_action, time_in_force_to_ib,
};
use parking_lot::Mutex;
use rust_decimal::Decimal;
use rustrade_instrument::{
Side,
asset::name::AssetNameExchange,
exchange::ExchangeId,
ibkr::ContractRegistry,
instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
};
use serde::{Deserialize, Serialize};
use smol_str::format_smolstr;
use std::{
collections::HashSet,
panic::{AssertUnwindSafe, catch_unwind},
sync::Arc,
};
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IbkrConfig {
pub host: String,
pub port: u16,
pub client_id: i32,
pub account: String,
#[serde(default)]
pub contracts: Vec<ContractConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractConfig {
pub name: String,
pub symbol: String,
pub security_type: String,
pub exchange: String,
pub currency: String,
#[serde(default)]
pub last_trade_date: Option<String>,
#[serde(default)]
pub strike: Option<f64>,
#[serde(default)]
pub right: Option<String>,
}
impl ContractConfig {
fn to_contract(&self) -> Result<ibapi::contracts::Contract, contract::ContractConfigError> {
use contract::ContractConfigError as E;
Ok(match self.security_type.as_str() {
"STK" => contract::stock_contract(&self.symbol, &self.exchange, &self.currency),
"FUT" => contract::futures_contract(
&self.symbol,
self.last_trade_date
.as_deref()
.ok_or(E::MissingLastTradeDate)?,
&self.exchange,
&self.currency,
),
"OPT" => contract::option_contract(
&self.symbol,
self.last_trade_date
.as_deref()
.ok_or(E::MissingLastTradeDate)?,
self.strike.ok_or(E::MissingStrike)?,
self.right.as_deref().ok_or(E::MissingOptionRight)?,
&self.exchange,
&self.currency,
)?,
"CASH" => contract::forex_contract(&self.symbol, &self.currency),
other => {
return Err(E::UnrecognizedSecurityType {
security_type: other.to_string(),
});
}
})
}
}
static ACCOUNT_GROUP_ALL: std::sync::LazyLock<AccountGroup> =
std::sync::LazyLock::new(|| AccountGroup("All".to_string()));
const POSITION_STREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const PLACEMENT_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const INFORMATIONAL_ORDER_CODES: &[i32] = &[399];
#[must_use]
#[derive(Debug)]
enum PlacementOutcome {
Accepted { filled: f64 },
Rejected(String),
HeldPending(String),
NoStatus,
}
fn is_transport_loss(e: &ibapi::Error) -> bool {
matches!(
e,
ibapi::Error::ConnectionReset
| ibapi::Error::ConnectionFailed
| ibapi::Error::Shutdown
| ibapi::Error::Io(_)
)
}
fn resolve_execution(
execution: &ibapi::orders::ExecutionData,
contracts: &ContractRegistry,
order_ids: &OrderIdMap,
) -> Option<(InstrumentNameExchange, ClientOrderId)> {
let order_id = execution.execution.order_id;
let con_id = execution.contract.contract_id;
let Some(client_id) = order_ids.get_client_id(order_id) else {
debug!(
ib_order_id = order_id,
con_id, "ExecutionData for unknown order ID, dropping"
);
return None;
};
let Some(instrument) = contracts.get_name_by_con_id(con_id) else {
debug!(
ib_order_id = order_id,
con_id, "ExecutionData for unknown contract ID, dropping"
);
return None;
};
Some((instrument, client_id))
}
fn forward_order_updates(
updates: impl IntoIterator<Item = Result<ibapi::orders::OrderUpdate, ibapi::Error>>,
sink: &recovery::EventSink,
contracts: &ContractRegistry,
order_ids: &OrderIdMap,
pending_cancels: &PendingCancels,
exec_buffer: &ExecutionBuffer,
) {
use ibapi::orders::{OrderStatusKind, OrderUpdate};
for update in updates {
if !sink.is_open() {
return;
}
let update = match update {
Ok(u) => u,
Err(e) => {
error!(error = %e, "Order stream subscription error");
sink.terminate(StreamTerminationReason::Error(e.to_string()));
return;
}
};
let event = match update {
OrderUpdate::OrderStatus(status) => {
let ib_id = status.order_id;
let is_terminal = matches!(
status.status,
OrderStatusKind::Cancelled | OrderStatusKind::Inactive
);
let lookup_result = if is_terminal {
order_ids.remove_and_get_context(ib_id)
} else {
order_ids.get_client_id_and_context(ib_id)
};
if let Some((client_id, ctx)) = lookup_result {
let order = make_order_from_status(&status, client_id, &ctx, pending_cancels);
Some(UnindexedAccountEvent {
exchange: ExchangeId::Ibkr,
kind: AccountEventKind::OrderSnapshot(Snapshot::new(order)),
})
} else {
debug!(ib_order_id = ib_id, "OrderStatus for unknown order ID");
None
}
}
OrderUpdate::ExecutionData(exec) => {
if recovery::is_replayed_execution(&exec) {
trace!(
exec_id = %exec.execution.execution_id,
request_id = exec.request_id,
"ExecutionData answering an executions request, skipping"
);
continue;
}
let Some((instrument, client_id)) = resolve_execution(&exec, contracts, order_ids)
else {
continue;
};
exec_buffer.add_execution(exec, instrument, client_id);
None
}
OrderUpdate::CommissionReport(report) => {
if let Some(trade) = exec_buffer.complete_with_commission(&report)
&& !sink.send_trade(trade)
{
return;
}
None
}
_ => None,
};
if let Some(e) = event
&& !sink.send(e)
{
return;
}
}
sink.terminate(StreamTerminationReason::Error(
"IBKR order-update stream ended".to_string(),
));
}
fn panic_message(panic_info: &(dyn std::any::Any + Send)) -> String {
panic_info
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic_info.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".to_string())
}
fn send_error<AssetKey, InstrumentKey>(
e: &ibapi::Error,
message: String,
) -> OrderError<AssetKey, InstrumentKey> {
match e {
ibapi::Error::ConnectionReset | ibapi::Error::Io(_) => {
OrderError::Connectivity(ConnectivityError::Socket(message))
}
_ => OrderError::Rejected(ApiError::OrderRejected(message)),
}
}
fn await_order_placement<I>(events: I) -> PlacementOutcome
where
I: IntoIterator<Item = Result<ibapi::orders::PlaceOrder, ibapi::Error>>,
{
use ibapi::orders::PlaceOrder;
for event in events {
let event = match event {
Ok(event) => event,
Err(ibapi::Error::Notice(n)) if INFORMATIONAL_ORDER_CODES.contains(&n.code) => {
return PlacementOutcome::HeldPending(format!("[{}] {}", n.code, n.message));
}
Err(e) if is_transport_loss(&e) => {
warn!(error = %e, "transport lost while awaiting order placement; status unknown");
return PlacementOutcome::NoStatus;
}
Err(e) => return PlacementOutcome::Rejected(e.to_string()),
};
if let PlaceOrder::OrderStatus(status) = event {
use ibapi::orders::OrderStatusKind;
match status.status {
OrderStatusKind::Submitted
| OrderStatusKind::PreSubmitted
| OrderStatusKind::PendingSubmit
| OrderStatusKind::Filled => {
return PlacementOutcome::Accepted {
filled: status.filled,
};
}
OrderStatusKind::Cancelled | OrderStatusKind::Inactive => {
return PlacementOutcome::Rejected(status.status.to_string());
}
OrderStatusKind::ApiCancelled | OrderStatusKind::PendingCancel => {
return PlacementOutcome::Accepted {
filled: status.filled,
};
}
OrderStatusKind::ApiPending => {}
OrderStatusKind::Unknown(ref raw) => {
warn!(
ib_order_id = status.order_id,
status = raw.as_str(),
"unmodelled IBKR order status during placement; \
treating as live, account stream is authoritative"
);
return PlacementOutcome::HeldPending(format!(
"unmodelled order status: {raw}"
));
}
}
}
}
PlacementOutcome::NoStatus
}
#[derive(Clone)]
pub struct IbkrClient {
config: Arc<IbkrConfig>,
client: Arc<Client>,
contracts: ContractRegistry,
order_ids: OrderIdMap,
pending_cancels: PendingCancels,
execution_buffer: ExecutionBuffer,
next_order_id: Arc<Mutex<i32>>,
}
impl std::fmt::Debug for IbkrClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IbkrClient")
.field("config", &self.config)
.field("contracts_count", &self.contracts.len())
.field("pending_orders", &self.order_ids.len())
.finish()
}
}
impl IbkrClient {
pub fn connect_sync(config: IbkrConfig) -> Result<Self, UnindexedClientError> {
let url = format!("{}:{}", config.host, config.port);
info!(url = %url, client_id = config.client_id, "Connecting to IB");
let client = Client::connect(&url, config.client_id).map_err(|e| {
UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
})?;
let next_id = client.next_order_id();
let contracts = ContractRegistry::new();
for contract_config in &config.contracts {
let contract = match contract_config.to_contract() {
Ok(contract) => contract,
Err(e) => {
warn!(name = %contract_config.name, error = %e, "Invalid contract config, skipping");
continue;
}
};
let name = InstrumentNameExchange::from(contract_config.name.as_str());
match client.contract_details(&contract) {
Ok(details) => {
if let Some(detail) = details.into_iter().next() {
contracts.register(name.clone(), detail.contract.clone());
debug!(name = %name, con_id = detail.contract.contract_id, "Registered contract");
}
}
Err(e) => {
warn!(name = %name, error = %e, "Failed to resolve contract");
}
}
}
info!(
contracts = contracts.len(),
next_order_id = next_id,
"Connected to IB"
);
Ok(Self {
config: Arc::new(config),
client: Arc::new(client),
contracts,
order_ids: OrderIdMap::new(),
pending_cancels: PendingCancels::new(),
execution_buffer: ExecutionBuffer::new(),
next_order_id: Arc::new(Mutex::new(next_id)),
})
}
fn allocate_order_id(&self) -> i32 {
self.allocate_order_id_range(1)
}
#[allow(clippy::expect_used)] fn allocate_order_id_range(&self, count: u32) -> i32 {
let count_i32: i32 = count.try_into().expect("count exceeds i32::MAX");
let mut id = self.next_order_id.lock();
let base = *id;
*id = id
.checked_add(count_i32)
.expect("order ID overflow: i32::MAX exceeded");
base
}
pub fn register_contract(
&self,
name: InstrumentNameExchange,
contract: ibapi::contracts::Contract,
) {
self.contracts.register(name, contract);
}
pub fn contract_registry(&self) -> &ContractRegistry {
&self.contracts
}
pub fn pending_execution_count(&self) -> usize {
self.execution_buffer.pending_count()
}
pub fn clear_stale_executions(&self, max_age: std::time::Duration) -> usize {
self.execution_buffer.clear_stale(max_age)
}
pub fn clear_stale_order_ids(&self, max_age: std::time::Duration) -> usize {
self.order_ids.clear_stale(max_age)
}
pub fn clear_stale_pending_cancels(&self, max_age: std::time::Duration) -> usize {
self.pending_cancels.clear_stale(max_age)
}
pub fn disconnect(&self) {
debug!("Disconnecting IbkrClient");
self.client.disconnect();
}
pub async fn open_bracket_order(&self, request: BracketOrderRequest) -> BracketOrderResult {
let instrument = request.instrument.clone();
let contract = match self.contracts.get_contract(&instrument) {
Some(c) => c,
None => {
return make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::InstrumentInvalid(
instrument,
"contract not registered".to_string(),
)),
);
}
};
let quantity: f64 = match request.quantity.try_into() {
Ok(q) => q,
Err(_) => {
return make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::OrderRejected(format!(
"quantity {} exceeds f64 range",
request.quantity
))),
);
}
};
let entry_price: f64 = match request.entry_price.try_into() {
Ok(p) => p,
Err(_) => {
return make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::OrderRejected(format!(
"entry_price {} exceeds f64 range",
request.entry_price
))),
);
}
};
let tp_price: f64 = match request.take_profit_price.try_into() {
Ok(p) => p,
Err(_) => {
return make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::OrderRejected(format!(
"take_profit_price {} exceeds f64 range",
request.take_profit_price
))),
);
}
};
let sl_price: f64 = match request.stop_loss_price.try_into() {
Ok(p) => p,
Err(_) => {
return make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::OrderRejected(format!(
"stop_loss_price {} exceeds f64 range",
request.stop_loss_price
))),
);
}
};
let ib_tif = match time_in_force_to_ib(&request.time_in_force) {
Ok(tif) => tif,
Err(e) => {
return make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::OrderRejected(format!(
"TIF not supported by IB: {e}"
))),
);
}
};
let parent_ib_id = self.allocate_order_id_range(3);
let tp_ib_id = parent_ib_id + 1;
let sl_ib_id = parent_ib_id + 2;
let action = side_to_action(request.side);
let ib_orders = build_ib_bracket_with_oca(
parent_ib_id,
action,
quantity,
entry_price,
tp_price,
sl_price,
ib_tif,
);
let parent_cid = request.parent_cid.clone();
let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
let exit_side = match request.side {
Side::Buy => Side::Sell,
Side::Sell => Side::Buy,
};
let parent_ctx = OrderContext {
instrument: instrument.clone(),
side: request.side,
price: Some(request.entry_price),
quantity: request.quantity,
kind: OrderKind::Limit,
time_in_force: request.time_in_force,
};
let tp_ctx = OrderContext {
instrument: instrument.clone(),
side: exit_side,
price: Some(request.take_profit_price),
quantity: request.quantity,
kind: OrderKind::Limit,
time_in_force: request.time_in_force,
};
let sl_ctx = OrderContext {
instrument: instrument.clone(),
side: exit_side,
price: None, quantity: request.quantity,
kind: OrderKind::Stop {
trigger_price: request.stop_loss_price,
},
time_in_force: request.time_in_force,
};
self.order_ids
.register(parent_cid.clone(), parent_ib_id, parent_ctx);
self.order_ids.register(tp_cid.clone(), tp_ib_id, tp_ctx);
self.order_ids.register(sl_cid.clone(), sl_ib_id, sl_ctx);
let client = self.client.clone();
let result = tokio::task::spawn_blocking(move || {
let rollback = |ids: &[i32]| -> (Vec<i32>, String) {
let failed: Vec<(i32, ibapi::Error)> = ids
.iter()
.filter_map(|&id| {
let e = client.cancel_order(id, "").err()?;
error!(order_id = id, error = %e, "bracket rollback cancel failed; leg may be live");
Some((id, e))
})
.collect();
if failed.is_empty() {
return (Vec::new(), String::new());
}
let named: Vec<String> = failed.iter().map(|(id, e)| format!("{id} ({e})")).collect();
let suffix = format!(
"; rollback cancel failed for order ids {}, which may be live",
named.join(", ")
);
(failed.into_iter().map(|(id, _)| id).collect(), suffix)
};
let leg_send_failure = |e: &ibapi::Error, message: String, sent: &[i32]| {
let (cancel_failed, rollback) = rollback(sent);
BracketFailure {
error: leg_send_error(e, message, &rollback),
unresolved: cancel_failed,
}
};
let parent_sub = match client.place_order(parent_ib_id, &contract, &ib_orders[0]) {
Ok(s) => s,
Err(e) => {
return Err(BracketFailure {
error: send_error(&e, format!("parent order failed: {e}")),
unresolved: Vec::new(),
});
}
};
let tp_sub = match client.place_order(tp_ib_id, &contract, &ib_orders[1]) {
Ok(s) => s,
Err(e) => {
return Err(leg_send_failure(
&e,
format!("take_profit order failed: {e}"),
&[parent_ib_id],
));
}
};
let sl_sub = match client.place_order(sl_ib_id, &contract, &ib_orders[2]) {
Ok(s) => s,
Err(e) => {
return Err(leg_send_failure(
&e,
format!("stop_loss order failed: {e}"),
&[parent_ib_id, tp_ib_id],
));
}
};
let leg_status = |outcome: PlacementOutcome, leg: &str| match outcome {
PlacementOutcome::Accepted { filled } => Some(Ok(filled)),
PlacementOutcome::HeldPending(notice) => {
warn!(leg, %notice, "bracket leg held/pending; tracking via stream");
Some(Ok(0.0))
}
PlacementOutcome::Rejected(reason) => Some(Err(reason)),
PlacementOutcome::NoStatus => None,
};
let parent_status = leg_status(
await_order_placement(parent_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
"parent",
);
let tp_status = leg_status(
await_order_placement(tp_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
"take_profit",
);
let sl_status = leg_status(
await_order_placement(sl_sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)),
"stop_loss",
);
match (parent_status, tp_status, sl_status) {
(Some(Ok(parent)), Some(Ok(tp)), Some(Ok(sl))) => Ok((parent, tp, sl)),
(parent, tp, sl) => {
let (cancel_failed, rollback) = rollback(&[parent_ib_id, tp_ib_id, sl_ib_id]);
let unresolved = unresolved_legs(
[(parent_ib_id, &parent), (tp_ib_id, &tp), (sl_ib_id, &sl)],
&cancel_failed,
);
let show = |status: Option<Result<f64, String>>| match status {
Some(status) => format!("{status:?}"),
None => "no status (timed out or transport lost)".to_string(),
};
Err(BracketFailure {
error: OrderError::Rejected(ApiError::OrderRejected(format!(
"bracket order failed: parent={}, tp={}, sl={}{rollback}",
show(parent),
show(tp),
show(sl)
))),
unresolved,
})
}
}
})
.await;
match result {
Ok(Ok((parent_filled, tp_filled, sl_filled))) => {
let now = Utc::now();
let parent_filled_dec = parse_decimal_or_warn(parent_filled, "parent.filled");
let tp_filled_dec = parse_decimal_or_warn(tp_filled, "tp.filled");
let sl_filled_dec = parse_decimal_or_warn(sl_filled, "sl.filled");
BracketOrderResult {
parent: Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: instrument.clone(),
strategy: request.strategy.clone(),
cid: parent_cid,
},
side: request.side,
price: Some(request.entry_price),
quantity: request.quantity,
kind: OrderKind::Limit,
time_in_force: request.time_in_force,
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!(
"{}",
parent_ib_id
))),
now,
parent_filled_dec,
)),
},
take_profit: Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: instrument.clone(),
strategy: request.strategy.clone(),
cid: tp_cid,
},
side: exit_side,
price: Some(request.take_profit_price),
quantity: request.quantity,
kind: OrderKind::Limit,
time_in_force: request.time_in_force,
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", tp_ib_id))),
now,
tp_filled_dec,
)),
},
stop_loss: Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: instrument.clone(),
strategy: request.strategy.clone(),
cid: sl_cid,
},
side: exit_side,
price: None, quantity: request.quantity,
kind: OrderKind::Stop {
trigger_price: request.stop_loss_price,
},
time_in_force: request.time_in_force,
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", sl_ib_id))),
now,
sl_filled_dec,
)),
},
}
}
Ok(Err(failure)) => failed_bracket(
&request,
[parent_ib_id, tp_ib_id, sl_ib_id],
failure,
&self.order_ids,
),
Err(join_err) => {
self.order_ids.remove_by_ib_id(parent_ib_id);
self.order_ids.remove_by_ib_id(tp_ib_id);
self.order_ids.remove_by_ib_id(sl_ib_id);
make_all_inactive_bracket(
&request,
OrderError::Rejected(ApiError::OrderRejected(format!(
"task join error: {join_err}"
))),
)
}
}
}
}
fn derive_child_cids(parent_cid: &ClientOrderId) -> (ClientOrderId, ClientOrderId) {
(
ClientOrderId::new(format_smolstr!("{}_tp", parent_cid.0)),
ClientOrderId::new(format_smolstr!("{}_sl", parent_cid.0)),
)
}
struct BracketFailure {
error: OrderError<AssetNameExchange, InstrumentNameExchange>,
unresolved: Vec<i32>,
}
fn unresolved_legs<T, E>(
legs: [(i32, &Option<Result<T, E>>); 3],
cancel_failed: &[i32],
) -> Vec<i32> {
legs.into_iter()
.filter(|(id, status)| match status {
None => true,
Some(Ok(_)) => cancel_failed.contains(id),
Some(Err(_)) => false,
})
.map(|(id, _)| id)
.collect()
}
fn leg_send_error(
e: &ibapi::Error,
message: String,
rollback: &str,
) -> OrderError<AssetNameExchange, InstrumentNameExchange> {
if rollback.is_empty() {
send_error(e, message)
} else {
OrderError::Rejected(ApiError::OrderRejected(format!("{message}{rollback}")))
}
}
fn failed_bracket(
request: &BracketOrderRequest,
[parent_ib_id, tp_ib_id, sl_ib_id]: [i32; 3],
failure: BracketFailure,
order_ids: &OrderIdMap,
) -> BracketOrderResult {
let BracketFailure { error, unresolved } = failure;
if !unresolved.is_empty() {
warn!(
%error,
?unresolved,
"bracket placement failed with legs of unknown fate; returning them Open, tracking via stream"
);
}
let mut result = make_all_inactive_bracket(request, error);
let now = Utc::now();
for (leg, ib_id) in [
(&mut result.parent, parent_ib_id),
(&mut result.take_profit, tp_ib_id),
(&mut result.stop_loss, sl_ib_id),
] {
if unresolved.contains(&ib_id) {
leg.state = OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!("{ib_id}"))),
now,
Decimal::ZERO,
));
} else {
order_ids.remove_by_ib_id(ib_id);
}
}
result
}
fn make_all_inactive_bracket(
request: &BracketOrderRequest,
error: OrderError<AssetNameExchange, InstrumentNameExchange>,
) -> BracketOrderResult {
let exit_side = match request.side {
Side::Buy => Side::Sell,
Side::Sell => Side::Buy,
};
let parent_cid = request.parent_cid.clone();
let (tp_cid, sl_cid) = derive_child_cids(&parent_cid);
BracketOrderResult {
parent: Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: request.instrument.clone(),
strategy: request.strategy.clone(),
cid: parent_cid,
},
side: request.side,
price: Some(request.entry_price),
quantity: request.quantity,
kind: OrderKind::Limit,
time_in_force: request.time_in_force,
state: OrderState::inactive(error.clone()),
},
take_profit: Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: request.instrument.clone(),
strategy: request.strategy.clone(),
cid: tp_cid,
},
side: exit_side,
price: Some(request.take_profit_price),
quantity: request.quantity,
kind: OrderKind::Limit,
time_in_force: request.time_in_force,
state: OrderState::inactive(error.clone()),
},
stop_loss: Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: request.instrument.clone(),
strategy: request.strategy.clone(),
cid: sl_cid,
},
side: exit_side,
price: None, quantity: request.quantity,
kind: OrderKind::Stop {
trigger_price: request.stop_loss_price,
},
time_in_force: request.time_in_force,
state: OrderState::inactive(error),
},
}
}
impl ExecutionClient for IbkrClient {
const EXCHANGE: ExchangeId = ExchangeId::Ibkr;
const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] = &[
InstrumentKindDiscriminant::Spot,
InstrumentKindDiscriminant::Future,
InstrumentKindDiscriminant::Option,
];
type Config = IbkrConfig;
type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
#[track_caller]
fn new(config: Self::Config) -> Self {
#[allow(clippy::expect_used)] Self::connect_sync(config).expect("failed to connect to IB")
}
async fn account_snapshot(
&self,
assets: &[AssetNameExchange],
instruments: &[InstrumentNameExchange],
) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
let client = self.client.clone();
let contracts = self.contracts.clone();
let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
None
} else {
Some(instruments.iter().cloned().collect())
};
let balances_future = self.fetch_balances(assets);
let positions_future = tokio::task::spawn_blocking(move || {
use ibapi::accounts::PositionUpdate;
let positions_sub = client
.positions()
.map_err(|e| UnindexedClientError::Internal(format!("positions: {e}")))?;
let mut snapshots = Vec::new();
let mut seen = HashSet::new();
for pos_update in positions_sub.timeout_iter_data(POSITION_STREAM_TIMEOUT) {
let pos_update = match pos_update {
Ok(p) => p,
Err(e) => {
return Err(UnindexedClientError::Internal(format!(
"positions subscription: {e}"
)));
}
};
let PositionUpdate::Position(pos) = pos_update else {
trace!(?pos_update, "Ignoring non-Position variant");
continue;
};
let Some(instrument) = contracts.get_name_by_con_id(pos.contract.contract_id)
else {
continue;
};
if instruments_filter
.as_ref()
.is_some_and(|f| !f.contains(&instrument))
{
continue;
}
if seen.contains(&instrument) {
debug!(
instrument = %instrument,
"Duplicate position for instrument (multi-account?), skipping"
);
continue;
}
seen.insert(instrument.clone());
snapshots.push(InstrumentAccountSnapshot {
instrument,
orders: Vec::new(),
orders_complete: false,
position: None,
isolated: None,
});
}
Ok::<_, UnindexedClientError>(snapshots)
});
let (balances_result, positions_result) = tokio::join!(balances_future, positions_future);
let balances = balances_result?;
let instrument_snapshots = positions_result
.map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))??;
Ok(AccountSnapshot {
exchange: ExchangeId::Ibkr,
balances,
instruments: instrument_snapshots,
})
}
async fn account_stream(
&self,
_assets: &[AssetNameExchange],
_instruments: &[InstrumentNameExchange],
) -> Result<Self::AccountStream, UnindexedClientError> {
let client = self.client.clone();
let (notices, order_sub) = tokio::task::spawn_blocking(move || {
let notices = client.notice_stream()?;
client.order_update_stream().map(|sub| (notices, sub))
})
.await
.map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
.map_err(|e| UnindexedClientError::Internal(format!("order updates: {e}")))?;
let contracts_clone = self.contracts.clone();
let order_ids_clone = self.order_ids.clone();
let pending_cancels_clone = self.pending_cancels.clone();
let exec_buffer_clone = self.execution_buffer.clone();
let (tx, rx) = mpsc::unbounded_channel();
let sink = recovery::EventSink::new(tx, new_dedup_cache());
let watcher = recovery::RecoveryWatcher {
client: self.client.clone(),
notices,
contracts: self.contracts.clone(),
order_ids: self.order_ids.clone(),
pending: self.execution_buffer.clone(),
sink: sink.clone(),
};
let watcher_sink = sink.clone();
std::thread::Builder::new()
.name("ibkr-fill-recovery".to_string())
.spawn(move || {
if let Err(panic_info) = catch_unwind(AssertUnwindSafe(|| watcher.run())) {
let msg = panic_message(panic_info.as_ref());
error!("Fill recovery worker panicked: {msg}");
watcher_sink.terminate(StreamTerminationReason::Error(format!(
"IBKR fill-recovery worker panicked: {msg}"
)));
}
})
.map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
std::thread::Builder::new()
.name("ibkr-order-stream".to_string())
.spawn(move || {
let result = catch_unwind(AssertUnwindSafe(|| {
forward_order_updates(
order_sub.iter_data(),
&sink,
&contracts_clone,
&order_ids_clone,
&pending_cancels_clone,
&exec_buffer_clone,
);
}));
if let Err(panic_info) = result {
let msg = panic_message(panic_info.as_ref());
error!("Order stream worker panicked: {msg}");
sink.terminate(StreamTerminationReason::Error(format!(
"IBKR order-update worker panicked: {msg}"
)));
}
})
.map_err(|e| UnindexedClientError::TaskFailed(format!("thread spawn: {e}")))?;
Ok(Box::pin(
tokio_stream::wrappers::UnboundedReceiverStream::new(rx),
))
}
async fn cancel_order(
&self,
request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
) -> Option<UnindexedOrderResponseCancel> {
let key = OrderKey {
exchange: request.key.exchange,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
};
let ib_order_id = match self.order_ids.get_ib_id(&request.key.cid) {
Some(id) => id,
None => {
return Some(OrderResponseCancel {
key,
state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
"order ID not found in map".to_string(),
))),
});
}
};
let client = self.client.clone();
let result =
tokio::task::spawn_blocking(move || client.cancel_order(ib_order_id, "")).await;
match result {
Ok(Ok(_sub)) => {
self.pending_cancels.insert(ib_order_id);
Some(OrderResponseCancel {
key,
state: Ok(Cancelled::new(
OrderId::new(format_smolstr!("{}", ib_order_id)),
Utc::now(),
Decimal::ZERO,
)),
})
}
Ok(Err(e)) => {
error!(order_id = ib_order_id, error = %e, "Failed to cancel order");
Some(OrderResponseCancel {
key,
state: Err(send_error(&e, e.to_string())),
})
}
Err(e) => {
error!(order_id = ib_order_id, error = %e, "Task join error");
Some(OrderResponseCancel {
key,
state: Err(crate::error::OrderError::Rejected(ApiError::OrderRejected(
e.to_string(),
))),
})
}
}
}
async fn open_order(
&self,
request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
let key = OrderKey {
exchange: ExchangeId::Ibkr,
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
cid: request.key.cid.clone(),
};
let contract = match self.contracts.get_contract(request.key.instrument) {
Some(c) => c,
None => {
return Some(Order {
key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(OrderError::Rejected(ApiError::InstrumentInvalid(
request.key.instrument.clone(),
"contract not registered".to_string(),
))),
});
}
};
let quantity: f64 = match request.state.quantity.try_into() {
Ok(q) => q,
Err(_) => {
return Some(Order {
key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
format!("quantity {} exceeds f64 range", request.state.quantity),
))),
});
}
};
let ib_order = match build_ib_order(
request.state.side,
quantity,
&request.state.kind,
request.state.price,
&request.state.time_in_force,
) {
Ok(o) => o,
Err(e) => {
return Some(Order {
key,
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
e.to_string(),
))),
});
}
};
let ib_order_id = self.allocate_order_id();
let context = OrderContext {
instrument: request.key.instrument.clone(),
side: request.state.side,
price: request.state.price,
quantity: request.state.quantity,
kind: request.state.kind,
time_in_force: request.state.time_in_force,
};
self.order_ids
.register(request.key.cid.clone(), ib_order_id, context);
let client = self.client.clone();
let side = request.state.side;
let price = request.state.price;
let req_quantity = request.state.quantity;
let kind = request.state.kind;
let tif = request.state.time_in_force;
let result = tokio::task::spawn_blocking(move || {
let sub = match client.place_order(ib_order_id, &contract, &ib_order) {
Ok(s) => s,
Err(e) => return Err(send_error(&e, e.to_string())),
};
match await_order_placement(sub.timeout_iter_data(PLACEMENT_STATUS_TIMEOUT)) {
PlacementOutcome::Accepted { filled } => {
let filled = parse_decimal_or_warn(filled, "status.filled");
Ok(Some((ib_order_id, filled)))
}
PlacementOutcome::Rejected(reason) => {
Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
}
PlacementOutcome::HeldPending(notice) => {
warn!(ib_order_id, %notice, "order held/pending; tracking via stream");
Ok(None)
}
PlacementOutcome::NoStatus => Ok(None),
}
})
.await;
match result {
Ok(Ok(Some((order_id, filled)))) => {
Some(Order {
key,
side,
price,
quantity: req_quantity,
kind,
time_in_force: tif,
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", order_id))),
Utc::now(),
filled,
)),
})
}
Ok(Ok(None)) => {
warn!(
ib_order_id,
"Order subscription ended without terminal status, returning Open"
);
Some(Order {
key,
side,
price,
quantity: req_quantity,
kind,
time_in_force: tif,
state: OrderState::active(Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!("{}", ib_order_id))),
Utc::now(),
Decimal::ZERO,
)),
})
}
Ok(Err(error)) => {
self.order_ids.remove_by_ib_id(ib_order_id);
Some(Order {
key,
side,
price,
quantity: req_quantity,
kind,
time_in_force: tif,
state: OrderState::inactive(error),
})
}
Err(e) => {
self.order_ids.remove_by_ib_id(ib_order_id);
Some(Order {
key,
side,
price,
quantity: req_quantity,
kind,
time_in_force: tif,
state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
e.to_string(),
))),
})
}
}
}
async fn fetch_balances(
&self,
assets: &[AssetNameExchange],
) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
let client = self.client.clone();
let assets_filter: Option<HashSet<AssetNameExchange>> = if assets.is_empty() {
None
} else {
Some(assets.iter().cloned().collect())
};
tokio::task::spawn_blocking(move || {
let sub = client
.account_summary(&ACCOUNT_GROUP_ALL, &["TotalCashValue", "AvailableFunds"])
.map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
let mut aggregator = BalanceAggregator::new();
for summary in sub.iter_data() {
let summary = summary
.map_err(|e| UnindexedClientError::Internal(format!("account_summary: {e}")))?;
match summary {
AccountSummaryResult::Summary(s) => aggregator.process(&s),
AccountSummaryResult::End => break,
}
}
let mut balances = aggregator.to_balances();
if let Some(ref filter) = assets_filter {
balances.retain(|b| filter.contains(&b.asset));
}
Ok(balances)
})
.await
.map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
}
async fn fetch_open_orders(
&self,
instruments: &[InstrumentNameExchange],
) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
let client = self.client.clone();
let contracts = self.contracts.clone();
let order_ids = self.order_ids.clone();
let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
None
} else {
Some(instruments.iter().cloned().collect())
};
tokio::task::spawn_blocking(move || {
use ibapi::orders::Orders;
let sub = client
.all_open_orders()
.map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
let mut orders = Vec::new();
for order_item in sub.iter_data() {
let order_item = order_item
.map_err(|e| UnindexedClientError::Internal(format!("open_orders: {e}")))?;
let order_data = match order_item {
Orders::OrderData(data) => data,
_ => continue,
};
let instrument = match contracts.get_name_by_con_id(order_data.contract.contract_id)
{
Some(i) => i,
None => continue,
};
if instruments_filter
.as_ref()
.is_some_and(|f| !f.contains(&instrument))
{
continue;
}
let client_id = order_ids
.get_client_id(order_data.order_id)
.unwrap_or_else(|| {
ClientOrderId::new(format_smolstr!("{}", order_data.order_id))
});
let side = match order_data.order.action {
ibapi::orders::Action::Buy => Side::Buy,
ibapi::orders::Action::Sell
| ibapi::orders::Action::SellShort
| ibapi::orders::Action::SellLong => Side::Sell,
};
let price = order_data
.order
.limit_price
.map(|p| parse_decimal_or_warn(p, "limit_price"));
let kind = if order_data.order.order_type == "LMT" {
OrderKind::Limit
} else {
OrderKind::Market
};
orders.push(Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument,
strategy: StrategyId::unknown(),
cid: client_id,
},
side,
price,
quantity: parse_decimal_or_warn(
order_data.order.total_quantity,
"total_quantity",
),
kind,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
state: Open::new(
VenueOrderId::Assigned(OrderId::new(format_smolstr!(
"{}",
order_data.order_id
))),
Utc::now(),
Decimal::ZERO, ),
});
}
Ok(orders)
})
.await
.map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
}
async fn fetch_trades(
&self,
time_since: DateTime<Utc>,
instruments: &[InstrumentNameExchange],
) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
let client = self.client.clone();
let contracts = self.contracts.clone();
let order_ids = self.order_ids.clone();
let instruments_filter: Option<HashSet<_>> = if instruments.is_empty() {
None
} else {
Some(instruments.iter().cloned().collect())
};
tokio::task::spawn_blocking(move || {
use ibapi::orders::{ExecutionSide, Executions};
let exec_filter = ibapi::orders::ExecutionFilter::default();
let sub = client
.executions(exec_filter)
.map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
let mut trades = Vec::new();
for exec_item in sub.iter_data() {
let exec_item = exec_item
.map_err(|e| UnindexedClientError::Internal(format!("executions: {e}")))?;
let exec_data = match exec_item {
Executions::ExecutionData(data) => data,
_ => continue,
};
let instrument = match contracts.get_name_by_con_id(exec_data.contract.contract_id)
{
Some(i) => i,
None => continue,
};
if instruments_filter
.as_ref()
.is_some_and(|f| !f.contains(&instrument))
{
continue;
}
let exec = &exec_data.execution;
let exec_time = match execution::parse_ib_timestamp(&exec.time) {
Some(t) => t,
None => {
warn!(
exec_id = %exec.execution_id,
time = %exec.time,
"Unparseable timestamp in execution, skipping"
);
continue;
}
};
if exec_time < time_since {
continue;
}
let side = match exec.side {
ExecutionSide::Bought => Side::Buy,
ExecutionSide::Sold => Side::Sell,
};
let client_id = order_ids
.get_client_id(exec.order_id)
.unwrap_or_else(|| ClientOrderId::new(format_smolstr!("{}", exec.order_id)));
trades.push(Trade {
id: TradeId::new(&exec.execution_id),
order_id: OrderId::new(&client_id.0),
instrument,
strategy: StrategyId::unknown(),
time_exchange: exec_time,
side,
price: parse_decimal_or_warn(exec.price, "exec.price"),
quantity: parse_decimal_or_warn(exec.shares, "exec.shares"),
order_filled_quantity: Some(parse_decimal_or_warn(
exec.cumulative_quantity,
"exec.cumulative_quantity",
)),
fees: AssetFees::new(AssetNameExchange::from("UNKNOWN"), Decimal::ZERO, None),
});
}
Ok(trades)
})
.await
.map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
}
}
fn make_order_from_status(
status: &ibapi::orders::OrderStatus,
client_id: ClientOrderId,
ctx: &OrderContext,
pending_cancels: &PendingCancels,
) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
{
use ibapi::orders::OrderStatusKind;
let ib_id = status.order_id;
let order_id = OrderId::new(format_smolstr!("{}", ib_id));
let filled_qty = parse_decimal_or_warn(status.filled, "status.filled");
let state = match status.status {
OrderStatusKind::Inactive => {
OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
"IB status: Inactive (order blocked by validation/margin/exchange)".into(),
)))
}
OrderStatusKind::Cancelled => {
let was_user_cancel = pending_cancels.remove(ib_id);
if was_user_cancel {
OrderState::inactive(Cancelled::new(order_id, Utc::now(), filled_qty))
} else if matches!(ctx.time_in_force, TimeInForce::GoodUntilEndOfDay) {
OrderState::inactive(Expired::new(order_id, Utc::now(), filled_qty))
} else {
OrderState::inactive(Cancelled::new(order_id, Utc::now(), filled_qty))
}
}
OrderStatusKind::Filled => {
OrderState::fully_filled(Filled::new(order_id, Utc::now(), filled_qty, None))
}
OrderStatusKind::Submitted
| OrderStatusKind::PreSubmitted
| OrderStatusKind::PendingSubmit
| OrderStatusKind::ApiPending
| OrderStatusKind::PendingCancel
| OrderStatusKind::ApiCancelled => OrderState::active(Open::new(
VenueOrderId::Assigned(order_id),
Utc::now(),
filled_qty,
)),
OrderStatusKind::Unknown(ref raw) => {
warn!(
ib_id,
status = raw.as_str(),
"unmodelled IBKR order status; treating as live, account \
stream is authoritative"
);
OrderState::active(Open::new(
VenueOrderId::Assigned(order_id),
Utc::now(),
filled_qty,
))
}
};
Order {
key: OrderKey {
exchange: ExchangeId::Ibkr,
instrument: ctx.instrument.clone(),
strategy: StrategyId::unknown(),
cid: client_id,
},
side: ctx.side,
price: ctx.price,
quantity: ctx.quantity,
kind: ctx.kind,
time_in_force: ctx.time_in_force,
state,
}
}
pub use execution::parse_ib_timestamp;
impl BracketOrderClient for IbkrClient {
async fn open_bracket_order(
&self,
request: UnifiedBracketOrderRequest<ExchangeId, &InstrumentNameExchange>,
) -> UnifiedBracketOrderResult {
let ibkr_request = BracketOrderRequest {
instrument: request.key.instrument.clone(),
strategy: request.key.strategy.clone(),
parent_cid: request.key.cid.clone(),
side: request.state.side,
quantity: request.state.quantity,
entry_price: request.state.entry_price,
take_profit_price: request.state.take_profit_price,
stop_loss_price: request.state.stop_loss_price,
time_in_force: request.state.time_in_force,
};
let result = self.open_bracket_order(ibkr_request).await;
UnifiedBracketOrderResult::with_all_legs(
result.parent,
result.take_profit,
result.stop_loss,
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod contract_config_tests {
use super::*;
use contract::ContractConfigError as E;
fn full_config(security_type: &str) -> ContractConfig {
ContractConfig {
name: "TEST".to_string(),
symbol: "AAPL".to_string(),
security_type: security_type.to_string(),
exchange: "SMART".to_string(),
currency: "USD".to_string(),
last_trade_date: Some("20260116".to_string()),
strike: Some(150.0),
right: Some("C".to_string()),
}
}
#[test]
fn stk_and_cash_ignore_option_fields() {
let mut stk = full_config("STK");
stk.last_trade_date = None;
stk.strike = None;
stk.right = None;
assert!(stk.to_contract().is_ok());
let mut cash = full_config("CASH");
cash.last_trade_date = None;
cash.strike = None;
cash.right = None;
assert!(cash.to_contract().is_ok());
}
#[test]
fn valid_fut_and_opt_build() {
assert!(full_config("FUT").to_contract().is_ok());
assert!(full_config("OPT").to_contract().is_ok());
}
#[test]
fn opt_missing_right_is_error_not_silent_call() {
let mut cfg = full_config("OPT");
cfg.right = None;
assert_eq!(cfg.to_contract().unwrap_err(), E::MissingOptionRight);
}
#[test]
fn opt_unrecognized_right_is_error() {
let mut cfg = full_config("OPT");
cfg.right = Some("X".to_string());
assert_eq!(
cfg.to_contract().unwrap_err(),
E::UnrecognizedOptionRight {
right: "X".to_string()
}
);
}
#[test]
fn opt_missing_strike_is_error() {
let mut cfg = full_config("OPT");
cfg.strike = None;
assert_eq!(cfg.to_contract().unwrap_err(), E::MissingStrike);
}
#[test]
fn fut_and_opt_missing_last_trade_date_is_error() {
let mut fut = full_config("FUT");
fut.last_trade_date = None;
assert_eq!(fut.to_contract().unwrap_err(), E::MissingLastTradeDate);
let mut opt = full_config("OPT");
opt.last_trade_date = None;
assert_eq!(opt.to_contract().unwrap_err(), E::MissingLastTradeDate);
}
#[test]
fn unknown_security_type_is_error_not_silent_stk() {
let cfg = full_config("BOND");
assert_eq!(
cfg.to_contract().unwrap_err(),
E::UnrecognizedSecurityType {
security_type: "BOND".to_string()
}
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)] mod order_status_tests {
use super::*;
use crate::order::state::ActiveOrderState;
use ibapi::orders::{OrderStatus, OrderStatusKind, PlaceOrder};
fn status(kind: OrderStatusKind, filled: f64) -> Result<PlaceOrder, ibapi::Error> {
Ok(PlaceOrder::OrderStatus(OrderStatus {
order_id: 42,
status: kind,
filled,
..OrderStatus::default()
}))
}
fn ctx() -> OrderContext {
OrderContext {
instrument: InstrumentNameExchange::new("AAPL"),
side: Side::Buy,
price: Some(Decimal::from(150)),
quantity: Decimal::from(10),
kind: OrderKind::Limit,
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
}
}
#[test]
fn unknown_status_is_held_pending_during_placement() {
let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
match await_order_placement(events) {
PlacementOutcome::HeldPending(reason) => {
assert!(
reason.contains("Reclassified"),
"the raw status must reach the caller, got {reason:?}"
);
}
other => panic!("expected HeldPending, got {other:?}"),
}
}
#[test]
fn unknown_status_does_not_fall_through_to_no_status() {
let events = vec![status(OrderStatusKind::Unknown("Reclassified".into()), 0.0)];
assert!(!matches!(
await_order_placement(events),
PlacementOutcome::NoStatus
));
let events = vec![status(OrderStatusKind::ApiPending, 0.0)];
assert!(matches!(
await_order_placement(events),
PlacementOutcome::NoStatus
));
}
#[test]
fn transport_loss_while_awaiting_placement_is_no_status() {
for e in [
ibapi::Error::ConnectionReset,
ibapi::Error::ConnectionFailed,
ibapi::Error::Shutdown,
ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
] {
let label = e.to_string();
assert!(
matches!(
await_order_placement(vec![Err(e)]),
PlacementOutcome::NoStatus
),
"{label} must leave the placement unresolved, not rejected"
);
}
let events = vec![
status(OrderStatusKind::Submitted, 0.0),
Err(ibapi::Error::ConnectionReset),
];
assert!(matches!(
await_order_placement(events),
PlacementOutcome::Accepted { .. }
));
}
#[test]
fn non_transport_error_while_awaiting_placement_is_rejected() {
let events = vec![Err(ibapi::Error::Simple("boom".into()))];
assert!(matches!(
await_order_placement(events),
PlacementOutcome::Rejected(reason) if reason.contains("boom")
));
}
#[test]
fn send_error_separates_transport_loss_from_refusal() {
let refused: OrderError<AssetNameExchange, InstrumentNameExchange> =
send_error(&ibapi::Error::ConnectionReset, "down".into());
assert!(
matches!(&refused, OrderError::Connectivity(ConnectivityError::Socket(m)) if m == "down")
);
assert!(refused.is_transient());
let io: OrderError<AssetNameExchange, InstrumentNameExchange> = send_error(
&ibapi::Error::Io(std::io::Error::from(std::io::ErrorKind::BrokenPipe)),
"io".into(),
);
assert!(io.is_transient());
for e in [
ibapi::Error::Shutdown,
ibapi::Error::ConnectionFailed,
ibapi::Error::Simple("no".into()),
] {
let label = e.to_string();
let rejected: OrderError<AssetNameExchange, InstrumentNameExchange> =
send_error(&e, "no".into());
assert!(
matches!(&rejected, OrderError::Rejected(ApiError::OrderRejected(m)) if m == "no"),
"{label} must be a non-transient refusal"
);
assert!(!rejected.is_transient(), "{label}");
}
}
#[test]
fn known_statuses_keep_their_placement_outcomes() {
assert!(matches!(
await_order_placement(vec![status(OrderStatusKind::Submitted, 0.0)]),
PlacementOutcome::Accepted { .. }
));
assert!(matches!(
await_order_placement(vec![status(OrderStatusKind::Filled, 10.0)]),
PlacementOutcome::Accepted { filled } if filled == 10.0
));
assert!(matches!(
await_order_placement(vec![status(OrderStatusKind::Inactive, 0.0)]),
PlacementOutcome::Rejected(_)
));
assert!(matches!(
await_order_placement(vec![status(OrderStatusKind::Cancelled, 0.0)]),
PlacementOutcome::Rejected(_)
));
}
#[test]
fn unknown_status_maps_to_active_open() {
let raw = OrderStatus {
order_id: 42,
status: OrderStatusKind::Unknown("Reclassified".into()),
filled: 3.0,
..OrderStatus::default()
};
let order = make_order_from_status(
&raw,
ClientOrderId::new("cid-1"),
&ctx(),
&PendingCancels::new(),
);
assert!(
matches!(order.state, OrderState::Active(_)),
"unmodelled status must stay active, got {:?}",
order.state
);
}
#[test]
fn unknown_status_leaves_pending_cancel_intact() {
let pending = PendingCancels::new();
pending.insert(42);
let raw = OrderStatus {
order_id: 42,
status: OrderStatusKind::Unknown("Reclassified".into()),
..OrderStatus::default()
};
let _ = make_order_from_status(&raw, ClientOrderId::new("cid-1"), &ctx(), &pending);
assert!(
pending.remove(42),
"the pending cancel must still be there for the confirmed Cancelled"
);
}
#[test]
fn rejected_leg_with_failed_cancel_is_settled() {
let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
let rejected: Option<Result<f64, String>> = Some(Err("Cancelled".into()));
assert_eq!(
unresolved_legs([(1, &rejected), (2, &accepted), (3, &accepted)], &[1, 2, 3]),
vec![2, 3]
);
}
#[test]
fn leg_send_error_is_transient_only_after_a_clean_rollback() {
let clean = leg_send_error(&ibapi::Error::ConnectionReset, "sl failed".into(), "");
assert!(matches!(clean, OrderError::Connectivity(_)));
let dirty = leg_send_error(
&ibapi::Error::ConnectionReset,
"sl failed".into(),
"; rollback cancel failed for order ids 10 (connection reset), which may be live",
);
match dirty {
OrderError::Rejected(ApiError::OrderRejected(message)) => {
assert!(message.starts_with("sl failed; rollback cancel failed"));
}
other => panic!("expected a non-transient rejection, got {other:?}"),
}
}
#[test]
fn unresolved_legs_are_no_status_or_failed_cancel() {
let accepted: Option<Result<f64, String>> = Some(Ok(0.0));
let rejected: Option<Result<f64, String>> = Some(Err("201".into()));
let no_status: Option<Result<f64, String>> = None;
assert_eq!(
unresolved_legs([(1, &accepted), (2, &rejected), (3, &no_status)], &[]),
vec![3]
);
assert_eq!(
unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[1]),
vec![1]
);
assert_eq!(
unresolved_legs([(1, &no_status), (2, &no_status), (3, &accepted)], &[2]),
vec![1, 2]
);
assert!(unresolved_legs([(1, &accepted), (2, &rejected), (3, &accepted)], &[]).is_empty());
}
fn bracket_request() -> BracketOrderRequest {
BracketOrderRequest {
instrument: InstrumentNameExchange::new("AAPL"),
strategy: StrategyId::new("test"),
parent_cid: ClientOrderId::new("br"),
side: Side::Buy,
quantity: Decimal::from(10),
entry_price: Decimal::from(150),
take_profit_price: Decimal::from(160),
stop_loss_price: Decimal::from(145),
time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
}
}
#[test]
fn failed_bracket_keeps_unresolved_legs_open_and_tracked() {
let request = bracket_request();
let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
let order_ids = OrderIdMap::new();
order_ids.register(request.parent_cid.clone(), 10, ctx());
order_ids.register(tp_cid, 11, ctx());
order_ids.register(sl_cid, 12, ctx());
let result = failed_bracket(
&request,
[10, 11, 12],
BracketFailure {
error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
unresolved: vec![11],
},
&order_ids,
);
assert!(matches!(result.parent.state, OrderState::Inactive(_)));
assert!(matches!(result.stop_loss.state, OrderState::Inactive(_)));
match &result.take_profit.state {
OrderState::Active(ActiveOrderState::Open(open)) => {
assert_eq!(open.id.assigned(), Some(&OrderId::new("11")));
assert_eq!(open.filled_quantity, Decimal::ZERO);
}
other => panic!("unresolved leg must be Open, got {other:?}"),
}
assert!(order_ids.get_client_id(10).is_none());
assert!(order_ids.get_client_id(11).is_some());
assert!(order_ids.get_client_id(12).is_none());
}
#[test]
fn failed_bracket_without_unresolved_legs_is_all_inactive() {
let request = bracket_request();
let (tp_cid, sl_cid) = derive_child_cids(&request.parent_cid);
let order_ids = OrderIdMap::new();
order_ids.register(request.parent_cid.clone(), 10, ctx());
order_ids.register(tp_cid, 11, ctx());
order_ids.register(sl_cid, 12, ctx());
let result = failed_bracket(
&request,
[10, 11, 12],
BracketFailure {
error: OrderError::Rejected(ApiError::OrderRejected("boom".into())),
unresolved: Vec::new(),
},
&order_ids,
);
for leg in [&result.parent, &result.take_profit, &result.stop_loss] {
assert!(matches!(leg.state, OrderState::Inactive(_)));
}
for ib_id in [10, 11, 12] {
assert!(order_ids.get_client_id(ib_id).is_none());
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod order_reader_tests {
use super::*;
use crate::client::dedup::new_dedup_cache;
use ibapi::orders::{OrderStatus, OrderUpdate};
use std::cell::Cell;
fn unforwarded(n: usize) -> Vec<Result<OrderUpdate, ibapi::Error>> {
(0..n)
.map(|_| {
Ok(OrderUpdate::OrderStatus(OrderStatus {
order_id: 999,
..OrderStatus::default()
}))
})
.collect()
}
fn run(sink: &recovery::EventSink, updates: Vec<Result<OrderUpdate, ibapi::Error>>) -> usize {
let pulled = Cell::new(0);
forward_order_updates(
updates
.into_iter()
.inspect(|_| pulled.set(pulled.get() + 1)),
sink,
&ContractRegistry::new(),
&OrderIdMap::new(),
&PendingCancels::new(),
&ExecutionBuffer::new(),
);
pulled.get()
}
#[test]
fn stops_on_the_first_update_after_the_stream_ends() {
let (tx, _rx) = mpsc::unbounded_channel();
let sink = recovery::EventSink::new(tx, new_dedup_cache());
sink.terminate(StreamTerminationReason::Error("recovery failed".into()));
assert_eq!(run(&sink, unforwarded(3)), 1);
}
#[test]
fn stops_on_the_first_update_after_the_consumer_goes() {
let (tx, rx) = mpsc::unbounded_channel();
let sink = recovery::EventSink::new(tx, new_dedup_cache());
drop(rx);
assert_eq!(run(&sink, unforwarded(3)), 1);
}
#[test]
fn open_stream_reads_past_unforwarded_updates() {
let (tx, mut rx) = mpsc::unbounded_channel();
let sink = recovery::EventSink::new(tx, new_dedup_cache());
assert_eq!(run(&sink, unforwarded(3)), 3);
assert!(matches!(
rx.try_recv().unwrap().kind,
AccountEventKind::StreamTerminated(StreamTerminationReason::Error(ref reason))
if reason == "IBKR order-update stream ended"
));
}
}