use std::{collections::HashSet, env, time::Duration};
use nautilus_common::{enums::Environment, live::get_runtime};
use nautilus_interactive_brokers::{
common::consts::{DEFAULT_CLIENT_ID, DEFAULT_HOST, DEFAULT_TWS_PORT, IB},
config::{
InteractiveBrokersDataClientConfig, InteractiveBrokersExecClientConfig,
InteractiveBrokersInstrumentProviderConfig, MarketDataType,
},
factories::{InteractiveBrokersDataClientFactory, InteractiveBrokersExecutionClientFactory},
};
use nautilus_live::{
config::{LiveExecEngineConfig, RoutingConfig},
node::LiveNode,
};
use nautilus_model::{
enums::{OrderType, TimeInForce},
identifiers::{AccountId, ClientId, InstrumentId, StrategyId, TraderId},
types::Quantity,
};
use nautilus_testkit::testers::{ExecTester, ExecTesterConfig};
use nautilus_trading::strategy::StrategyConfig;
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IbExecSpecProfile {
Lifecycle,
CancelModify,
Rejection,
Options,
UnsupportedFlags,
}
const TRADER_ID: &str = "IB-EXEC-TESTER-001";
const NODE_NAME: &str = "IB-EXEC-TESTER-001";
const STRATEGY_ID: &str = "IB-EXEC-TESTER-001";
const HOST: &str = DEFAULT_HOST;
const PORT: u16 = DEFAULT_TWS_PORT;
const CLIENT_ID: i32 = DEFAULT_CLIENT_ID;
const INSTRUMENT_ID: &str = "AAPL=STK.SMART";
const MARKET_DATA_TYPE: &str = "realtime";
const ORDER_QTY: &str = "1";
const AUTO_STOP_SECS: u64 = 0;
const EXEC_SPEC_PROFILE: IbExecSpecProfile = IbExecSpecProfile::Lifecycle;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let account_id_raw = env::var("NAUTILUS_IB_ACCOUNT_ID")?;
let account_id = account_id_from_ib_account(&account_id_raw);
let trader_id = TraderId::from(TRADER_ID);
let instrument_id = InstrumentId::from(INSTRUMENT_ID);
let market_data_type = parse_market_data_type(MARKET_DATA_TYPE);
let order_qty = Quantity::from(ORDER_QTY);
let routing = RoutingConfig::builder().default(true).build();
let data_config = InteractiveBrokersDataClientConfig {
host: HOST.to_string(),
port: PORT,
client_id: CLIENT_ID,
market_data_type,
instrument_provider: instrument_provider_config(instrument_id),
..Default::default()
};
let exec_config = InteractiveBrokersExecClientConfig {
host: HOST.to_string(),
port: PORT,
client_id: CLIENT_ID,
account_id: Some(account_id_raw),
instrument_provider: instrument_provider_config(instrument_id),
..Default::default()
};
let exec_engine_config = LiveExecEngineConfig {
open_check_interval_secs: Some(10.0),
position_check_interval_secs: Some(30.0),
..Default::default()
};
let mut node = LiveNode::builder(trader_id, Environment::Live)?
.with_name(NODE_NAME.to_string())
.with_exec_engine_config(exec_engine_config)
.with_delay_post_stop_secs(5)
.with_reconciliation(true)
.add_data_client_with_routing(
None,
Box::new(InteractiveBrokersDataClientFactory::new()),
Box::new(data_config),
routing.clone(),
)?
.add_exec_client_with_routing(
None,
Box::new(InteractiveBrokersExecutionClientFactory::new(
trader_id, account_id,
)),
Box::new(exec_config),
routing,
)?
.build()?;
let tester_config = exec_tester_config_for_profile(
EXEC_SPEC_PROFILE,
instrument_id,
ClientId::new(IB),
order_qty,
);
node.add_strategy(ExecTester::new(tester_config))?;
schedule_auto_stop(&node, AUTO_STOP_SECS);
node.run().await?;
Ok(())
}
fn account_id_from_ib_account(account_id: &str) -> AccountId {
if account_id.contains('-') {
AccountId::from(account_id)
} else {
AccountId::from(format!("{IB}-{account_id}"))
}
}
fn parse_market_data_type(value: &str) -> MarketDataType {
match value {
"realtime" => MarketDataType::Realtime,
"frozen" => MarketDataType::Frozen,
"delayed" => MarketDataType::Delayed,
"delayed-frozen" | "delayed_frozen" => MarketDataType::DelayedFrozen,
value => panic!("invalid NAUTILUS_IB_MARKET_DATA_TYPE={value}"),
}
}
fn instrument_provider_config(
instrument_id: InstrumentId,
) -> InteractiveBrokersInstrumentProviderConfig {
let mut load_ids = HashSet::new();
load_ids.insert(instrument_id);
InteractiveBrokersInstrumentProviderConfig {
load_ids,
..Default::default()
}
}
fn schedule_auto_stop(node: &LiveNode, delay_secs: u64) {
if delay_secs == 0 {
return;
}
let handle = node.handle();
get_runtime().spawn(async move {
tokio::time::sleep(Duration::from_secs(delay_secs)).await;
handle.stop();
});
}
fn exec_tester_config_for_profile(
profile: IbExecSpecProfile,
instrument_id: InstrumentId,
client_id: ClientId,
order_qty: Quantity,
) -> ExecTesterConfig {
let builder = ExecTesterConfig::builder()
.base(StrategyConfig {
strategy_id: Some(StrategyId::from(STRATEGY_ID)),
external_order_claims: Some(vec![instrument_id]),
..Default::default()
})
.instrument_id(instrument_id)
.client_id(client_id)
.order_qty(order_qty)
.log_data(false);
match profile {
IbExecSpecProfile::Lifecycle => builder
.open_position_on_start_qty(order_qty.as_decimal())
.enable_limit_buys(false)
.enable_limit_sells(false)
.close_positions_on_stop(true)
.build()
.unwrap(),
IbExecSpecProfile::CancelModify => builder
.enable_limit_buys(true)
.enable_limit_sells(true)
.modify_orders_to_maintain_tob_offset(true)
.modify_stop_orders_to_maintain_offset(true)
.use_individual_cancels_on_stop(true)
.build()
.unwrap(),
IbExecSpecProfile::Rejection => builder
.enable_limit_buys(true)
.enable_limit_sells(true)
.test_reject_post_only(true)
.build()
.unwrap(),
IbExecSpecProfile::Options => builder
.open_position_on_start_qty(order_qty.as_decimal())
.enable_limit_buys(false)
.enable_limit_sells(false)
.close_positions_on_stop(true)
.build()
.unwrap(),
IbExecSpecProfile::UnsupportedFlags => builder
.open_position_on_start_qty(order_qty.as_decimal())
.enable_limit_buys(true)
.enable_limit_sells(false)
.limit_time_in_force(TimeInForce::Ioc)
.stop_order_type(OrderType::TrailingStopMarket)
.test_reject_post_only(true)
.test_reject_reduce_only(true)
.use_quote_quantity(true)
.use_batch_cancel_on_stop(true)
.build()
.unwrap(),
}
}
#[cfg(test)]
mod tests {
use rust_decimal::Decimal;
use super::*;
fn instrument_id() -> InstrumentId {
InstrumentId::from("AAPL=STK.SMART")
}
fn config(profile: IbExecSpecProfile) -> ExecTesterConfig {
exec_tester_config_for_profile(
profile,
instrument_id(),
ClientId::new(IB),
Quantity::from("1"),
)
}
#[rstest::rstest]
fn test_lifecycle_exec_spec_profile_opens_and_closes_position() {
let config = config(IbExecSpecProfile::Lifecycle);
assert_eq!(config.open_position_on_start_qty, Some(Decimal::ONE));
assert!(!config.enable_limit_buys);
assert!(!config.enable_limit_sells);
assert!(config.close_positions_on_stop);
}
#[rstest::rstest]
fn test_cancel_modify_exec_spec_profile_enables_amend_and_cancel_paths() {
let config = config(IbExecSpecProfile::CancelModify);
assert!(config.enable_limit_buys);
assert!(config.enable_limit_sells);
assert!(config.modify_orders_to_maintain_tob_offset);
assert!(config.modify_stop_orders_to_maintain_offset);
assert!(config.use_individual_cancels_on_stop);
}
#[rstest::rstest]
fn test_rejection_exec_spec_profile_exercises_post_only_rejection() {
let config = config(IbExecSpecProfile::Rejection);
assert!(config.enable_limit_buys);
assert!(config.enable_limit_sells);
assert!(config.test_reject_post_only);
}
#[rstest::rstest]
fn test_options_exec_spec_profile_reuses_lifecycle_order_path() {
let config = config(IbExecSpecProfile::Options);
assert_eq!(config.open_position_on_start_qty, Some(Decimal::ONE));
assert!(!config.enable_limit_buys);
assert!(!config.enable_limit_sells);
assert!(config.close_positions_on_stop);
}
#[rstest::rstest]
fn test_unsupported_flags_exec_spec_profile_exercises_rejection_and_batch_cancel_flags() {
let config = config(IbExecSpecProfile::UnsupportedFlags);
assert_eq!(config.open_position_on_start_qty, Some(Decimal::ONE));
assert_eq!(config.limit_time_in_force, Some(TimeInForce::Ioc));
assert_eq!(config.stop_order_type, OrderType::TrailingStopMarket);
assert!(config.test_reject_post_only);
assert!(config.test_reject_reduce_only);
assert!(config.use_quote_quantity);
assert!(config.use_batch_cancel_on_stop);
}
#[rstest::rstest]
fn test_instrument_provider_config_preloads_test_instrument() {
let config = instrument_provider_config(instrument_id());
assert_eq!(config.load_ids.len(), 1);
assert!(config.load_ids.contains(&instrument_id()));
}
#[rstest::rstest]
#[case("realtime", MarketDataType::Realtime)]
#[case("frozen", MarketDataType::Frozen)]
#[case("delayed", MarketDataType::Delayed)]
#[case("delayed-frozen", MarketDataType::DelayedFrozen)]
#[case("delayed_frozen", MarketDataType::DelayedFrozen)]
fn test_parse_market_data_type(#[case] value: &str, #[case] expected: MarketDataType) {
assert_eq!(parse_market_data_type(value), expected);
}
}