use std::str::FromStr;
use anyhow::Context;
use ibapi::orders::{Execution, OrderStatus};
use jiff::{
Timestamp,
civil::DateTime,
tz::{AmbiguousOffset, Offset},
};
use nautilus_core::{UnixNanos, datetime::get_timezone};
use nautilus_model::{
enums::{
LiquiditySide, OrderSide, OrderStatus as NautilusOrderStatus, OrderType, TimeInForce,
TrailingOffsetType,
},
identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
instruments::Instrument,
reports::{FillReport, OrderStatusReport},
types::{Currency, Money, Price, Quantity},
};
use rust_decimal::Decimal;
use crate::{
common::{
enums::{IbAction, IbOrderStatus, IbOrderType, IbTimeInForce},
parse::is_spread_instrument_id,
},
providers::instruments::InteractiveBrokersInstrumentProvider,
};
pub(crate) fn should_use_avg_fill_price(avg_fill_price: f64, instrument_id: &InstrumentId) -> bool {
avg_fill_price.is_finite()
&& avg_fill_price != f64::MAX
&& avg_fill_price != 0.0
&& (avg_fill_price > 0.0 || is_spread_instrument_id(instrument_id))
}
pub(crate) fn ib_venue_order_id(order_id: i32, perm_id: i64) -> VenueOrderId {
if perm_id != 0 {
VenueOrderId::new(format!("PERM-{perm_id}"))
} else {
VenueOrderId::new(order_id.to_string())
}
}
pub(crate) fn normalized_order_ref(order_ref: &str) -> Option<&str> {
if order_ref.is_empty() {
return None;
}
Some(
order_ref
.rsplit_once(':')
.map_or(order_ref, |(base, _)| base),
)
}
#[allow(clippy::too_many_arguments)]
pub fn parse_execution_to_fill_report(
execution: &Execution,
_contract: &ibapi::contracts::Contract,
commission: f64,
commission_currency: &str,
instrument_id: InstrumentId,
account_id: AccountId,
instrument_provider: &InteractiveBrokersInstrumentProvider,
ts_init: UnixNanos,
avg_px: Option<Price>,
) -> anyhow::Result<FillReport> {
let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
let execution_price = execution.price * price_magnifier;
let order_side = IbAction::from_str(execution.side.as_str())?.order_side();
let instrument = instrument_provider
.find(&instrument_id)
.context("Instrument not found")?;
let last_qty = Quantity::new(execution.shares, instrument.size_precision());
let last_px = Price::new(execution_price, instrument.price_precision());
let commission_clamped = if commission == -1.0 { 0.0 } else { commission };
let commission_money = Money::new(commission_clamped, Currency::from_str(commission_currency)?);
let ts_event = parse_execution_time(&execution.time)?;
let trade_id = TradeId::new(&execution.execution_id);
let venue_order_id = ib_venue_order_id(execution.order_id, execution.perm_id);
let client_order_id = normalized_order_ref(&execution.order_reference).map(ClientOrderId::new);
let mut report = FillReport::new(
account_id,
instrument_id,
venue_order_id,
trade_id,
order_side,
last_qty,
last_px,
commission_money,
LiquiditySide::NoLiquiditySide,
client_order_id,
None, ts_event,
ts_init,
Some(nautilus_core::UUID4::new()),
);
report.avg_px = avg_px.map(|price: Price| price.as_decimal());
Ok(report)
}
pub fn parse_order_status_to_report(
order_status: &OrderStatus,
order: Option<&ibapi::orders::Order>,
instrument_id: InstrumentId,
account_id: AccountId,
instrument_provider: &InteractiveBrokersInstrumentProvider,
ts_init: UnixNanos,
) -> anyhow::Result<OrderStatusReport> {
let price_magnifier = instrument_provider.get_price_magnifier(&instrument_id) as f64;
let mut nautilus_status = match IbOrderStatus::from_str(order_status.status.as_str()) {
Ok(status) => status.nautilus_status(),
_ => {
tracing::warn!(
"Unknown order status: {}, defaulting to SUBMITTED",
order_status.status.as_str()
);
NautilusOrderStatus::Submitted
}
};
let order_side = if let Some(order) = order {
IbAction::from(order.action).order_side()
} else {
OrderSide::Buy
};
let instrument = instrument_provider.find(&instrument_id);
let size_precision = instrument
.as_ref()
.map_or(0, |instr| instr.size_precision());
let price_precision = instrument
.as_ref()
.map_or(0, |instr| instr.price_precision());
let quantity = if let Some(order) = order {
Quantity::new(order.total_quantity, size_precision)
} else {
Quantity::zero(size_precision)
};
let filled_qty = Quantity::new(order_status.filled, size_precision);
let average_fill_price = order_status.average_fill_price.unwrap_or(0.0);
let include_avg_px = should_use_avg_fill_price(average_fill_price, &instrument_id);
let avg_px_value = if include_avg_px {
average_fill_price * price_magnifier
} else {
0.0
};
if order_status.filled > 0.0
&& (order_status.remaining > 0.0
|| order.is_some_and(|order| order.total_quantity > order_status.filled))
{
nautilus_status = NautilusOrderStatus::PartiallyFilled;
}
let venue_order_id = ib_venue_order_id(order_status.order_id, order_status.perm_id);
let client_order_id = order
.and_then(|order| normalized_order_ref(&order.order_ref))
.map(ClientOrderId::new);
let order_type = order
.map(|order| map_ib_order_type(&order.order_type))
.unwrap_or(OrderType::Market);
let time_in_force = if let Some(order) = order {
let ib_time_in_force = IbTimeInForce::from(order.tif.clone());
if ib_time_in_force == IbTimeInForce::GoodTilDate || !order.good_till_date.is_empty() {
TimeInForce::Gtd
} else {
ib_time_in_force.nautilus_time_in_force()
}
} else {
TimeInForce::Day };
let mut report = OrderStatusReport::new(
account_id,
instrument_id,
client_order_id,
venue_order_id,
order_side,
order_type,
time_in_force,
nautilus_status,
quantity,
filled_qty,
ts_init, ts_init, ts_init,
Some(nautilus_core::UUID4::new()), );
if let Some(order) = order {
if let Some(limit_price) = order.limit_price {
let converted = limit_price * price_magnifier;
report = report.with_price(Price::new(converted, price_precision));
}
let (trigger_price, limit_offset, trailing_offset, trailing_offset_type) =
parse_ib_order_pricing_fields(order, order_type, price_magnifier, price_precision)?;
if let Some(trigger_price) = trigger_price {
report = report.with_trigger_price(trigger_price);
}
if let Some(limit_offset) = limit_offset {
report = report.with_limit_offset(limit_offset);
}
if let Some(trailing_offset) = trailing_offset {
report = report.with_trailing_offset(trailing_offset);
}
if let Some(trailing_offset_type) = trailing_offset_type {
report = report.with_trailing_offset_type(trailing_offset_type);
}
}
if include_avg_px {
report = report.with_avg_px(decimal_from_f64(avg_px_value)?);
}
Ok(report)
}
fn map_ib_order_type(order_type: &str) -> OrderType {
IbOrderType::from_str(order_type).map_or(OrderType::Market, IbOrderType::nautilus_order_type)
}
fn parse_ib_order_pricing_fields(
order: &ibapi::orders::Order,
order_type: OrderType,
price_magnifier: f64,
price_precision: u8,
) -> anyhow::Result<(
Option<Price>,
Option<Decimal>,
Option<Decimal>,
Option<TrailingOffsetType>,
)> {
let mut trigger_price = None;
let mut limit_offset = None;
let mut trailing_offset = None;
let mut trailing_offset_type = None;
if matches!(
order_type,
OrderType::TrailingStopMarket | OrderType::TrailingStopLimit
) {
if let Some(trail_stop_price) = order.trail_stop_price {
trigger_price = Some(Price::new(
trail_stop_price * price_magnifier,
price_precision,
));
}
if let Some(aux_price) = order.aux_price {
trailing_offset = Some(decimal_from_f64(aux_price)?);
trailing_offset_type = Some(TrailingOffsetType::Price);
} else if let Some(trailing_percent) = order.trailing_percent {
trailing_offset = Some(decimal_from_f64(trailing_percent)? * Decimal::from(100));
trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
}
if order_type == OrderType::TrailingStopLimit
&& let Some(limit_price_offset) = order.limit_price_offset
{
limit_offset = Some(decimal_from_f64(limit_price_offset)?);
trailing_offset_type = Some(trailing_offset_type.unwrap_or(TrailingOffsetType::Price));
}
return Ok((
trigger_price,
limit_offset,
trailing_offset,
trailing_offset_type,
));
}
if let Some(aux_price) = order.aux_price {
trigger_price = Some(Price::new(aux_price * price_magnifier, price_precision));
}
Ok((
trigger_price,
limit_offset,
trailing_offset,
trailing_offset_type,
))
}
fn decimal_from_f64(value: f64) -> anyhow::Result<Decimal> {
Decimal::from_str(&value.to_string())
.with_context(|| format!("Failed to convert IB floating-point value {value} to Decimal"))
}
pub fn parse_execution_time(time_str: &str) -> anyhow::Result<UnixNanos> {
const NAIVE_FORMAT: &str = "%Y%m%d %H:%M:%S";
if !time_str.contains(' ') {
let normalized = time_str.replace('-', " ");
let dt = DateTime::strptime(NAIVE_FORMAT, &normalized).map_err(|e| {
anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}")
})?;
return datetime_to_unix_nanos(Offset::UTC.to_timestamp(dt)?, time_str);
}
let mut parts = time_str.splitn(3, ' ');
let (Some(date), Some(time)) = (parts.next(), parts.next()) else {
anyhow::bail!("Invalid execution time format: {time_str}");
};
let tz_str = parts.next().unwrap_or("").trim();
let naive_str = format!("{date} {time}");
let dt = DateTime::strptime(NAIVE_FORMAT, &naive_str)
.map_err(|e| anyhow::anyhow!("Failed to parse execution timestamp '{time_str}': {e}"))?;
let utc = if tz_str.is_empty() {
Offset::UTC.to_timestamp(dt)?
} else {
localize_with_zone(dt, tz_str, time_str)?
};
datetime_to_unix_nanos(utc, time_str)
}
fn localize_with_zone(dt: DateTime, tz_str: &str, time_str: &str) -> anyhow::Result<Timestamp> {
let tz_name = if tz_str.eq_ignore_ascii_case("Z") {
"UTC"
} else {
tz_str
};
let zone = get_timezone(tz_name).map_err(|_| {
anyhow::anyhow!(
"Unrecognised execution timezone '{tz_str}' in '{time_str}'. Configure TWS / IB Gateway to emit a standard timezone (e.g. UTC)"
)
})?;
let ambiguous = zone.to_ambiguous_timestamp(dt);
match ambiguous.offset() {
AmbiguousOffset::Unambiguous { .. } => Ok(ambiguous.unambiguous()?),
AmbiguousOffset::Fold { .. } => Ok(ambiguous.earlier()?),
AmbiguousOffset::Gap { .. } => {
anyhow::bail!("Execution timestamp '{time_str}' is non-existent in timezone '{tz_str}'")
}
}
}
fn datetime_to_unix_nanos(dt: Timestamp, time_str: &str) -> anyhow::Result<UnixNanos> {
let nanos: u64 = dt
.as_nanosecond()
.try_into()
.map_err(|_| anyhow::anyhow!("Execution timestamp '{time_str}' was before Unix epoch"))?;
Ok(UnixNanos::new(nanos))
}
#[cfg(test)]
mod tests {
use ibapi::{
contracts::Contract,
orders::{Action, ExecutionSide, Liquidity, Order, OrderStatusKind},
};
use nautilus_model::{
enums::TrailingOffsetType,
identifiers::{Symbol, Venue},
instruments::{InstrumentAny, stubs::equity_aapl},
};
use rust_decimal::Decimal;
use super::*;
use crate::{
config::InteractiveBrokersInstrumentProviderConfig,
providers::instruments::InteractiveBrokersInstrumentProvider,
};
fn create_test_instrument_provider() -> InteractiveBrokersInstrumentProvider {
let config = InteractiveBrokersInstrumentProviderConfig::default();
InteractiveBrokersInstrumentProvider::new(config)
}
fn create_test_instrument_id() -> InstrumentId {
InstrumentId::new(Symbol::from("AAPL"), Venue::from("NASDAQ"))
}
use rstest::rstest;
#[rstest]
fn test_parse_execution_time_hyphenated_format() {
let time_str = "20250225-15:15:00";
let result = parse_execution_time(time_str);
assert!(result.is_ok());
let timestamp = result.unwrap();
assert!(timestamp.as_i64() > 0);
}
#[rstest]
fn test_parse_execution_time_with_met_timezone() {
let met = parse_execution_time("20230223 00:43:36 MET").unwrap();
let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
assert_eq!(
met.as_i64(),
utc.as_i64() - 3_600_000_000_000,
"MET (CET) should be 1h ahead of UTC in February"
);
assert!(met.as_i64() > 0);
}
#[rstest]
fn test_parse_execution_time_applies_dst_for_regional_timezone() {
let winter = parse_execution_time("20230223 00:43:36 America/New_York").unwrap();
let summer = parse_execution_time("20230715 00:43:36 America/New_York").unwrap();
let winter_utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
let summer_utc = parse_execution_time("20230715 00:43:36 Universal").unwrap();
assert_eq!(winter.as_i64(), winter_utc.as_i64() + 5 * 3_600_000_000_000); assert_eq!(summer.as_i64(), summer_utc.as_i64() + 4 * 3_600_000_000_000); }
#[rstest]
fn test_parse_execution_time_dst_fall_back_fold_resolves_to_earliest() {
let fold = parse_execution_time("20231105 01:30:00 America/Chicago").unwrap();
assert_eq!(
fold.as_i64(),
parse_execution_time("20231105 06:30:00 Universal")
.unwrap()
.as_i64()
);
assert_ne!(
fold.as_i64(),
parse_execution_time("20231105 07:30:00 Universal")
.unwrap()
.as_i64()
);
}
#[rstest]
fn test_parse_execution_time_dst_spring_forward_gap_errors() {
let gap = parse_execution_time("20230312 02:30:00 America/Chicago");
assert!(gap.is_err());
}
#[rstest]
fn test_parse_execution_time_fixed_offset_zone_without_dst() {
let tokyo = parse_execution_time("20230223 00:43:36 Asia/Tokyo").unwrap();
let utc = parse_execution_time("20230223 00:43:36 Universal").unwrap();
assert_eq!(tokyo.as_i64(), utc.as_i64() - 9 * 3_600_000_000_000);
}
#[rstest]
fn test_parse_execution_time_with_unrecognised_timezone_errors() {
let time_str = "20230223 00:43:36 Mars/Olympus";
let result = parse_execution_time(time_str);
assert!(result.is_err());
}
#[rstest]
fn test_parse_execution_time_utc() {
let time_str = "20230223 00:43:36 Universal";
let result = parse_execution_time(time_str);
assert!(result.is_ok());
let timestamp = result.unwrap();
assert!(timestamp.as_i64() > 0);
}
#[rstest]
fn test_parse_execution_time_no_timezone_assumes_utc() {
let time_str = "20230223 00:43:36";
let result = parse_execution_time(time_str);
assert!(result.is_ok());
let timestamp = result.unwrap();
assert!(timestamp.as_i64() > 0);
}
#[rstest]
fn test_parse_execution_time_invalid_format() {
let time_str = "invalid format";
let result = parse_execution_time(time_str);
assert!(result.is_err());
}
#[rstest]
fn test_parse_execution_time_short_format() {
let time_str = "20230223 00:43";
let result = parse_execution_time(time_str);
assert!(result.is_err());
}
#[rstest]
fn test_parse_order_status_to_report_submitted() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 12345,
status: OrderStatusKind::Submitted,
filled: 0.0,
remaining: 100.0,
average_fill_price: Some(0.0),
perm_id: 0,
parent_id: 0,
last_fill_price: Some(0.0),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let result = parse_order_status_to_report(
&order_status,
None,
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
);
if let Err(e) = result {
let error_msg = e.to_string();
assert!(
error_msg.contains("not found") || error_msg.contains("instrument"),
"Unexpected error: {}",
error_msg
);
}
}
#[rstest]
fn test_parse_order_status_to_report_filled() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 12345,
status: OrderStatusKind::Filled,
filled: 100.0,
remaining: 0.0,
average_fill_price: Some(150.25),
perm_id: 0,
parent_id: 0,
last_fill_price: Some(150.25),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let result = parse_order_status_to_report(
&order_status,
None,
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
);
if let Err(e) = result {
let error_msg = e.to_string();
assert!(
error_msg.contains("not found") || error_msg.contains("instrument"),
"Unexpected error: {}",
error_msg
);
}
}
#[rstest]
fn test_parse_order_status_to_report_spread_allows_negative_avg_fill_price() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = InstrumentId::new(
Symbol::from("(1)SPY C400_((1))SPY C410"),
Venue::from("SMART"),
);
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 12345,
status: OrderStatusKind::Filled,
filled: 1.0,
remaining: 0.0,
average_fill_price: Some(-2.25),
perm_id: 0,
parent_id: 0,
last_fill_price: Some(-2.25),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let report = parse_order_status_to_report(
&order_status,
None,
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
)
.unwrap();
assert_eq!(report.avg_px, Some(Decimal::from_str("-2.25").unwrap()));
}
#[rstest]
fn test_parse_order_status_to_report_inactive_maps_to_rejected() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 12345,
status: OrderStatusKind::Inactive,
filled: 0.0,
remaining: 100.0,
average_fill_price: Some(0.0),
perm_id: 0,
parent_id: 0,
last_fill_price: Some(0.0),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let report = parse_order_status_to_report(
&order_status,
None,
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
)
.unwrap();
assert_eq!(report.order_status, NautilusOrderStatus::Rejected);
}
#[rstest]
fn test_parse_order_status_to_report_partial_fill_and_perm_fallback() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 0,
status: OrderStatusKind::Submitted,
filled: 3.0,
remaining: 7.0,
average_fill_price: Some(150.25),
perm_id: 123_456,
parent_id: 0,
last_fill_price: Some(150.25),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let order = Order {
action: Action::Buy,
total_quantity: 10.0,
order_type: "LMT".to_string(),
limit_price: Some(150.25),
order_ref: "O-20260527-001:123".to_string(),
..Default::default()
};
let report = parse_order_status_to_report(
&order_status,
Some(&order),
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
)
.unwrap();
assert_eq!(report.order_status, NautilusOrderStatus::PartiallyFilled);
assert_eq!(report.venue_order_id.to_string(), "PERM-123456");
assert_eq!(
report.client_order_id,
Some(ClientOrderId::from("O-20260527-001"))
);
}
#[rstest]
fn test_ib_venue_order_id_prefers_perm_id_and_falls_back_to_order_id() {
assert_eq!(ib_venue_order_id(123, 456).to_string(), "PERM-456");
assert_eq!(ib_venue_order_id(123, 0).to_string(), "123");
}
#[rstest]
fn test_normalized_order_ref_strips_ib_suffix() {
assert_eq!(normalized_order_ref("O-001:123"), Some("O-001"));
assert_eq!(normalized_order_ref("O-001"), Some("O-001"));
assert_eq!(normalized_order_ref(""), None);
}
#[rstest]
#[case(
"MKT",
None,
None,
None,
None,
OrderType::Market,
None,
None,
None,
None,
TrailingOffsetType::NoTrailingOffset
)]
#[case(
"LMT",
Some(185.0),
None,
None,
None,
OrderType::Limit,
Some(Price::new(185.0, 0)),
None,
None,
None,
TrailingOffsetType::NoTrailingOffset
)]
#[case(
"MIT",
None,
Some(180.0),
None,
None,
OrderType::MarketIfTouched,
None,
Some(Price::new(180.0, 0)),
None,
None,
TrailingOffsetType::NoTrailingOffset
)]
#[case(
"LIT",
Some(179.0),
Some(180.0),
None,
None,
OrderType::LimitIfTouched,
Some(Price::new(179.0, 0)),
Some(Price::new(180.0, 0)),
None,
None,
TrailingOffsetType::NoTrailingOffset
)]
#[case(
"STP",
None,
Some(180.0),
None,
None,
OrderType::StopMarket,
None,
Some(Price::new(180.0, 0)),
None,
None,
TrailingOffsetType::NoTrailingOffset
)]
#[case(
"STP LMT",
Some(179.0),
Some(180.0),
None,
None,
OrderType::StopLimit,
Some(Price::new(179.0, 0)),
Some(Price::new(180.0, 0)),
None,
None,
TrailingOffsetType::NoTrailingOffset
)]
#[case(
"TRAIL LIMIT",
None,
Some(2.5),
Some(185.0),
Some(0.25),
OrderType::TrailingStopLimit,
None,
Some(Price::new(185.0, 0)),
Some(Decimal::from_str("0.25").unwrap()),
Some(Decimal::from_str("2.5").unwrap()),
TrailingOffsetType::Price,
)]
fn test_parse_order_status_to_report_maps_pricing_fields_by_order_type(
#[case] ib_order_type: &str,
#[case] limit_price: Option<f64>,
#[case] aux_price: Option<f64>,
#[case] trail_stop_price: Option<f64>,
#[case] limit_price_offset: Option<f64>,
#[case] expected_order_type: OrderType,
#[case] expected_price: Option<Price>,
#[case] expected_trigger_price: Option<Price>,
#[case] expected_limit_offset: Option<Decimal>,
#[case] expected_trailing_offset: Option<Decimal>,
#[case] expected_trailing_offset_type: TrailingOffsetType,
) {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 12345,
status: OrderStatusKind::Submitted,
filled: 0.0,
remaining: 5.0,
average_fill_price: Some(0.0),
perm_id: 0,
parent_id: 0,
last_fill_price: Some(0.0),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let order = Order {
action: Action::Buy,
total_quantity: 5.0,
order_type: ib_order_type.to_string(),
limit_price,
aux_price,
trail_stop_price,
limit_price_offset,
tif: ibapi::orders::TimeInForce::GoodTilCanceled,
..Default::default()
};
let report = parse_order_status_to_report(
&order_status,
Some(&order),
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
)
.unwrap();
assert_eq!(report.order_type, expected_order_type);
assert_eq!(report.price, expected_price);
assert_eq!(report.trigger_price, expected_trigger_price);
assert_eq!(report.limit_offset, expected_limit_offset);
assert_eq!(report.trailing_offset, expected_trailing_offset);
assert_eq!(report.trailing_offset_type, expected_trailing_offset_type);
}
#[rstest]
fn test_parse_order_status_to_report_maps_trailing_percent_to_basis_points() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let order_status = OrderStatus {
order_id: 12345,
status: OrderStatusKind::Submitted,
filled: 0.0,
remaining: 5.0,
average_fill_price: Some(0.0),
perm_id: 0,
parent_id: 0,
last_fill_price: Some(0.0),
client_id: 0,
why_held: String::new(),
market_cap_price: Some(0.0),
};
let order = Order {
action: Action::Buy,
total_quantity: 5.0,
order_type: "TRAIL".to_string(),
trail_stop_price: Some(185.0),
trailing_percent: Some(2.5),
tif: ibapi::orders::TimeInForce::GoodTilCanceled,
..Default::default()
};
let report = parse_order_status_to_report(
&order_status,
Some(&order),
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
)
.unwrap();
assert_eq!(report.order_type, OrderType::TrailingStopMarket);
assert_eq!(report.trigger_price, Some(Price::new(185.0, 0)));
assert_eq!(
report.trailing_offset,
Some(Decimal::from_str("250").unwrap())
);
assert_eq!(report.trailing_offset_type, TrailingOffsetType::BasisPoints);
assert_eq!(report.limit_offset, None);
}
#[rstest]
fn test_parse_execution_to_fill_report_buy() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let execution = Execution {
order_id: 12345,
client_id: 0,
execution_id: String::from("EXEC-001"),
time: String::from("20230223 00:43:36 Universal"),
account_number: String::new(),
exchange: String::new(),
side: ExecutionSide::Bought,
shares: 100.0,
price: 150.25,
perm_id: 0,
liquidation: 0,
cumulative_quantity: 100.0,
average_price: 150.25,
order_reference: String::from("ORDER-REF-001"),
ev_rule: String::new(),
ev_multiplier: None,
model_code: String::new(),
last_liquidity: Liquidity::None,
pending_price_revision: false,
submitter: String::new(),
};
let contract = Contract::default();
let result = parse_execution_to_fill_report(
&execution,
&contract,
1.0,
"USD",
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
None, );
match result {
Err(e) => {
let error_msg = e.to_string();
assert!(
error_msg.contains("not found") || error_msg.contains("instrument"),
"Unexpected error: {}",
error_msg
);
}
Ok(fill) => {
assert_eq!(fill.order_side, OrderSide::Buy);
assert_eq!(fill.trade_id.to_string(), "EXEC-001");
}
}
}
#[rstest]
fn test_parse_execution_to_fill_report_clamps_only_pending_commission_sentinel() {
let instrument_provider = create_test_instrument_provider();
let instrument = equity_aapl();
let instrument_id = instrument.id();
instrument_provider.insert_test_instrument(InstrumentAny::from(instrument), 265598, 1);
let account_id = AccountId::from("IB-001");
let contract = Contract::default();
for (commission, expected) in [(-1.0, 0.0), (-0.25, -0.25)] {
let execution = Execution {
order_id: 12345,
client_id: 0,
execution_id: format!("EXEC-{commission}"),
time: String::from("20230223 00:43:36 Universal"),
account_number: String::new(),
exchange: String::new(),
side: ExecutionSide::Bought,
shares: 100.0,
price: 150.25,
perm_id: 0,
liquidation: 0,
cumulative_quantity: 100.0,
average_price: 150.25,
order_reference: String::from("ORDER-REF-001"),
ev_rule: String::new(),
ev_multiplier: None,
model_code: String::new(),
last_liquidity: Liquidity::None,
pending_price_revision: false,
submitter: String::new(),
};
let report = parse_execution_to_fill_report(
&execution,
&contract,
commission,
"USD",
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
None,
)
.unwrap();
assert_eq!(report.commission, Money::new(expected, Currency::USD()));
}
}
#[rstest]
fn test_parse_execution_to_fill_report_sell() {
let instrument_provider = create_test_instrument_provider();
let instrument_id = create_test_instrument_id();
let account_id = AccountId::from("IB-001");
let execution = Execution {
order_id: 12345,
client_id: 0,
execution_id: String::from("EXEC-002"),
time: String::from("20230223 00:43:36 Universal"),
account_number: String::new(),
exchange: String::new(),
side: ExecutionSide::Sold,
shares: 50.0,
price: 151.0,
perm_id: 0,
liquidation: 0,
cumulative_quantity: 50.0,
average_price: 151.0,
order_reference: String::new(),
ev_rule: String::new(),
ev_multiplier: None,
model_code: String::new(),
last_liquidity: Liquidity::None,
pending_price_revision: false,
submitter: String::new(),
};
let contract = Contract::default();
let result = parse_execution_to_fill_report(
&execution,
&contract,
0.5,
"USD",
instrument_id,
account_id,
&instrument_provider,
UnixNanos::new(0),
None, );
match result {
Err(e) => {
let error_msg = e.to_string();
assert!(
error_msg.contains("not found") || error_msg.contains("instrument"),
"Unexpected error: {}",
error_msg
);
}
Ok(fill) => {
assert_eq!(fill.order_side, OrderSide::Sell);
}
}
}
}