use crate::{DEFAULT_SYMBOL, state};
use clap::Args as ClapArgs;
use ibapi::{
Client,
accounts::{AccountSummaryResult, AccountSummaryTags, PositionUpdate, types::AccountGroup},
contracts::{Contract, tick_types::TickType},
market_data::{MarketDataType, TradingHours, realtime::TickTypes},
orders::{OrderUpdate, Orders},
prelude::{StreamExt, Subscription, SubscriptionItemStreamExt},
};
use std::{collections::HashSet, error::Error, io, sync::RwLock};
use time::OffsetDateTime;
use uuid::Uuid;
const RUN_DELAY: tokio::time::Duration = tokio::time::Duration::from_secs(1);
const RETRY_DELAY: tokio::time::Duration = tokio::time::Duration::from_secs(10);
const ORDER_REF_PREFIX: &str = "stockholm:";
const SMART_EXCHANGE: &str = "SMART";
const OVERNIGHT_EXCHANGE: &str = "OVERNIGHT";
#[derive(ClapArgs)]
pub struct Args {
#[arg(long, default_value = DEFAULT_SYMBOL)]
symbol: String,
}
struct VolatileState {
available_funds: Option<f64>,
bid_price: Option<f64>,
ask_price: Option<f64>,
}
impl Default for Args {
fn default() -> Self {
Self {
symbol: DEFAULT_SYMBOL.to_string(),
}
}
}
pub async fn run(address: &str, client_id: i32, args: &Args) -> Result<(), Box<dyn Error>> {
let persistent_state = RwLock::new(state::load().unwrap_or_else(|error| {
warn!("Unable to load state from disk. Proceeding with initial state. Details: {error}");
state::initial()
}));
loop {
match Client::connect(address, client_id).await {
Ok(client) => {
if let Err(error) =
run_with_connection(&client, &args.symbol, &persistent_state).await
{
error!("{error}");
}
}
Err(error) => error!("Connection to Interactive Brokers Gateway failed: {error}"),
}
tokio::time::sleep(RETRY_DELAY).await;
}
}
async fn run_with_connection(
client: &Client,
symbol: &str,
persistent_state: &RwLock<state::State>,
) -> Result<(), Box<dyn Error>> {
let volatile_state = RwLock::new(VolatileState {
available_funds: None,
bid_price: None,
ask_price: None,
});
client
.switch_market_data_type(MarketDataType::Realtime)
.await?;
let order_updates = client.order_update_stream().await?;
list_orders(client, persistent_state).await?;
tokio::try_join!(
control_loop(client, &volatile_state),
stream_account_summary(client, &volatile_state),
stream_live_data(client, symbol, &volatile_state),
stream_order_updates(order_updates, persistent_state),
stream_realtime_bars(client, symbol, OVERNIGHT_EXCHANGE),
stream_realtime_bars(client, symbol, SMART_EXCHANGE),
)?;
Ok(())
}
async fn control_loop(
client: &Client,
volatile_state: &RwLock<VolatileState>,
) -> Result<(), Box<dyn Error>> {
loop {
run_step(client, volatile_state).await?;
tokio::time::sleep(RUN_DELAY).await;
}
}
async fn stream_account_summary(
client: &Client,
state: &RwLock<VolatileState>,
) -> Result<(), Box<dyn Error>> {
info!("Requesting account summary…");
let subscription = client
.account_summary(&AccountGroup("All".to_string()), AccountSummaryTags::ALL)
.await?;
let mut summaries = subscription.filter_data();
while let Some(update) = summaries.next().await {
match update? {
AccountSummaryResult::Summary(summary) => {
update_available_funds(state, &summary.tag, &summary.value)?;
if summary.currency.is_empty() {
debug!(
"Account summary for {}: {} = {}",
summary.account,
summary.tag,
summary.value,
);
} else {
debug!(
"Account summary for {}: {} = {} {}",
summary.account,
summary.tag,
summary.value,
summary.currency,
);
}
}
AccountSummaryResult::End => {
info!("Finished listing initial account summary.");
}
}
}
Err(ibapi::Error::UnexpectedEndOfStream.into())
}
fn update_available_funds(state: &RwLock<VolatileState>, tag: &str, value: &str) -> io::Result<()> {
if tag == AccountSummaryTags::AVAILABLE_FUNDS
&& let Ok(value) = value.parse::<f64>()
&& value.is_finite()
{
let mut state = state
.write()
.map_err(|_| io::Error::other("Volatile state lock was poisoned."))?;
state.available_funds = Some(value);
}
Ok(())
}
async fn stream_live_data(
client: &Client,
symbol: &str,
state: &RwLock<VolatileState>,
) -> Result<(), Box<dyn Error>> {
let contract = Contract::stock(symbol).build();
let subscription = client
.market_data(&contract)
.streaming()
.subscribe()
.await?;
let mut ticks = subscription.filter_data();
info!("Streaming {symbol} market data…");
while let Some(tick) = ticks.next().await {
let tick = tick?;
match &tick {
TickTypes::Price(tick) => {
update_locked_price(state, &tick.tick_type, tick.price)?;
}
TickTypes::PriceSize(tick) => {
update_locked_price(state, &tick.price_tick_type, tick.price)?;
}
_ => {}
}
debug!("Market data for {symbol}: {tick:?}");
}
Err(ibapi::Error::UnexpectedEndOfStream.into())
}
async fn stream_order_updates(
subscription: Subscription<OrderUpdate>,
state: &RwLock<state::State>,
) -> Result<(), Box<dyn Error>> {
let mut updates = subscription.filter_data();
info!("Streaming order updates…");
while let Some(update) = updates.next().await {
let update: OrderUpdate = update?;
match &update {
OrderUpdate::OpenOrder(data) if data.order.order_ref.starts_with(ORDER_REF_PREFIX) => {
update_open_order(
state,
data.order_id,
&data.order.order_ref,
data.order.perm_id,
)?;
}
OrderUpdate::OrderStatus(status) => {
update_order_status(
state,
status.order_id,
status.perm_id,
status.status.is_terminal(),
)?;
}
OrderUpdate::OpenOrder(_)
| OrderUpdate::ExecutionData(_)
| OrderUpdate::CommissionReport(_) => {}
}
debug!("Order update: {update:?}");
}
Err(ibapi::Error::UnexpectedEndOfStream.into())
}
async fn stream_realtime_bars(
client: &Client,
symbol: &str,
exchange: &str,
) -> Result<(), Box<dyn Error>> {
let contract = Contract::stock(symbol).on_exchange(exchange).build();
let subscription = client
.realtime_bars(&contract)
.trading_hours(TradingHours::Extended)
.subscribe()
.await?;
let mut bars = subscription.filter_data();
info!("Streaming {symbol} five-second bars from {exchange}…");
while let Some(bar) = bars.next().await {
debug!("Five-second bar for {symbol} ({exchange}): {:?}", bar?);
}
Err(ibapi::Error::UnexpectedEndOfStream.into())
}
async fn run_step(
client: &Client,
volatile_state: &RwLock<VolatileState>,
) -> Result<(), Box<dyn Error>> {
list_positions(client).await?;
let state = volatile_state
.read()
.map_err(|_| io::Error::other("Volatile state lock was poisoned."))?;
info!(
"Available funds: {}; current bid: {}; current ask: {}",
state
.available_funds
.map_or_else(|| "unavailable".to_string(), |funds| funds.to_string()),
state
.bid_price
.map_or_else(|| "unavailable".to_string(), |price| price.to_string()),
state
.ask_price
.map_or_else(|| "unavailable".to_string(), |price| price.to_string()),
);
Ok(())
}
#[allow(dead_code)]
async fn place_limit_buy(
client: &Client,
symbol: &str,
shares: i32,
limit: f64,
state: &RwLock<state::State>,
) -> Result<(), Box<dyn Error>> {
let contract = Contract::stock(symbol).build();
let order_ref = format!("{ORDER_REF_PREFIX}{}", Uuid::new_v4().simple());
let mut order = client
.order(&contract)
.buy(shares)
.limit(limit)
.outside_rth()
.build()?;
order.order_ref.clone_from(&order_ref);
order.include_overnight = true;
let order_id = client.next_order_id();
{
let mut state = state
.write()
.map_err(|_| io::Error::other("The persistent state lock is poisoned."))?;
state.open_orders.push(state::OpenOrder {
order_id,
order_ref: order_ref.clone(),
perm_id: None,
created_at: OffsetDateTime::now_utc(),
});
state::save(&state)?;
}
client.submit_order(order_id, &contract, &order).await?;
info!("Submitted limit buy {order_id} ({order_ref}): {shares} {symbol} @ ${limit:.2}");
Ok(())
}
#[allow(dead_code)]
async fn place_limit_sell(
client: &Client,
symbol: &str,
shares: i32,
limit: f64,
state: &RwLock<state::State>,
) -> Result<(), Box<dyn Error>> {
let contract = Contract::stock(symbol).build();
let order_ref = format!("{ORDER_REF_PREFIX}{}", Uuid::new_v4().simple());
let mut order = client
.order(&contract)
.sell(shares)
.limit(limit)
.outside_rth()
.build()?;
order.order_ref.clone_from(&order_ref);
order.include_overnight = true;
let order_id = client.next_order_id();
{
let mut state = state
.write()
.map_err(|_| io::Error::other("The persistent state lock is poisoned."))?;
state.open_orders.push(state::OpenOrder {
order_id,
order_ref: order_ref.clone(),
perm_id: None,
created_at: OffsetDateTime::now_utc(),
});
state::save(&state)?;
}
client.submit_order(order_id, &contract, &order).await?;
info!("Submitted limit sell {order_id} ({order_ref}): {shares} {symbol} @ ${limit:.2}");
Ok(())
}
fn update_open_order(
state: &RwLock<state::State>,
order_id: i32,
order_ref: &str,
perm_id: i64,
) -> io::Result<()> {
let mut state = state
.write()
.map_err(|_| io::Error::other("The persistent state lock is poisoned."))?;
let perm_id = (perm_id != 0).then_some(perm_id);
let changed = if let Some(order) = state
.open_orders
.iter_mut()
.find(|order| order.order_ref == order_ref)
{
let changed = order.order_id != order_id || order.perm_id != perm_id;
order.order_id = order_id;
order.perm_id = perm_id;
changed
} else {
state.open_orders.push(state::OpenOrder {
order_id,
order_ref: order_ref.to_string(),
perm_id,
created_at: OffsetDateTime::now_utc(),
});
true
};
if changed {
state::save(&state)?;
}
Ok(())
}
fn update_order_status(
state: &RwLock<state::State>,
order_id: i32,
perm_id: i64,
is_terminal: bool,
) -> io::Result<()> {
let mut state = state
.write()
.map_err(|_| io::Error::other("The persistent state lock is poisoned."))?;
let index = state
.open_orders
.iter()
.position(|order| order.perm_id == Some(perm_id));
let changed = if let Some(index) = index {
if is_terminal {
state.open_orders.remove(index);
true
} else {
let order = &mut state.open_orders[index];
let changed = order.order_id != order_id;
order.order_id = order_id;
changed
}
} else {
false
};
if changed {
state::save(&state)?;
}
Ok(())
}
fn update_locked_price(
state: &RwLock<VolatileState>,
tick_type: &TickType,
price: f64,
) -> io::Result<()> {
let mut state = state
.write()
.map_err(|_| io::Error::other("Volatile state lock was poisoned."))?;
update_price(&mut state, tick_type, price);
Ok(())
}
fn update_price(state: &mut VolatileState, tick_type: &TickType, price: f64) {
let price = (price > 0.0_f64).then_some(price);
match tick_type {
TickType::Bid => state.bid_price = price,
TickType::Ask => state.ask_price = price,
_ => {}
}
}
async fn list_orders(client: &Client, state: &RwLock<state::State>) -> Result<(), Box<dyn Error>> {
info!("Requesting Stockholm open orders…");
let subscription = client.all_open_orders().await?;
let mut orders = subscription.filter_data();
let mut order_count: usize = 0;
let mut open_order_refs = HashSet::new();
while let Some(order) = orders.next().await {
match order? {
Orders::OrderData(data) if data.order.order_ref.starts_with(ORDER_REF_PREFIX) => {
order_count += 1;
open_order_refs.insert(data.order.order_ref.clone());
update_open_order(
state,
data.order_id,
&data.order.order_ref,
data.order.perm_id,
)?;
debug!("Stockholm open order: {data:?}");
}
Orders::OrderStatus(status) => {
update_order_status(
state,
status.order_id,
status.perm_id,
status.status.is_terminal(),
)?;
}
Orders::OrderData(_) => {}
}
}
let mut state = state
.write()
.map_err(|_| io::Error::other("The persistent state lock is poisoned."))?;
let previous_len = state.open_orders.len();
state
.open_orders
.retain(|order| open_order_refs.contains(&order.order_ref));
if state.open_orders.len() != previous_len {
state::save(&state)?;
}
if order_count == 0 {
info!("No Stockholm open orders found.");
} else if order_count == 1 {
info!("Finished listing 1 Stockholm open order.");
} else {
info!("Finished listing {order_count} Stockholm open orders.");
}
Ok(())
}
async fn list_positions(client: &Client) -> Result<(), Box<dyn Error>> {
info!("Requesting all positions…");
let subscription = client.positions().await?;
let mut positions = subscription.filter_data();
let mut position_count: usize = 0;
while let Some(update) = positions.next().await {
match update? {
PositionUpdate::Position(position) => {
position_count += 1;
debug!("Position: {position:?}");
}
PositionUpdate::PositionEnd => break,
}
}
if position_count == 0 {
info!("No positions found.");
} else if position_count == 1 {
info!("Finished listing 1 position.");
} else {
info!("Finished listing {position_count} positions.");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Args, VolatileState, update_available_funds, update_price};
use clap::Parser;
use ibapi::accounts::AccountSummaryTags;
use ibapi::contracts::tick_types::TickType;
use std::sync::RwLock;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
args: Args,
}
#[test]
fn default_symbol() {
let cli = TestCli::try_parse_from(["run"]).unwrap();
assert_eq!(cli.args.symbol, "SOXL");
}
#[test]
fn explicit_symbol() {
let cli = TestCli::try_parse_from(["run", "--symbol", "AAPL"]).unwrap();
assert_eq!(cli.args.symbol, "AAPL");
}
#[test]
fn clear_nonpositive_bid_and_ask_prices() {
let mut state = VolatileState {
available_funds: None,
bid_price: None,
ask_price: None,
};
update_price(&mut state, &TickType::Bid, 100.0);
update_price(&mut state, &TickType::Ask, 101.0);
update_price(&mut state, &TickType::Bid, 0.0);
update_price(&mut state, &TickType::Ask, f64::NAN);
assert_eq!(state.bid_price, None);
assert_eq!(state.ask_price, None);
}
#[test]
fn retain_only_valid_available_funds() {
let state = RwLock::new(VolatileState {
available_funds: None,
bid_price: None,
ask_price: None,
});
update_available_funds(&state, AccountSummaryTags::AVAILABLE_FUNDS, "1234.5").unwrap();
update_available_funds(&state, AccountSummaryTags::AVAILABLE_FUNDS, "NaN").unwrap();
update_available_funds(&state, AccountSummaryTags::NET_LIQUIDATION, "9999").unwrap();
assert_eq!(state.read().unwrap().available_funds, Some(1234.5_f64));
}
}