Streaming L2 Market Depth

Subscribe to Level-2 depth on two contracts at once, keep a bid and ask book per contract from the update stream, and print both books at the end.

What this shows

  • req_mkt_depth(req_id, &contract, num_rows, is_smart_depth), one request id per contract.
  • Applying update_mkt_depth_l2 events to a book keyed by level position.
  • cancel_mkt_depth(req_id, is_smart_depth) for each subscription before disconnecting, naming the mode the book was asked in.

What comes back

update_mkt_depth_l2 per level change, carrying the level position, the market maker, and:

fieldvalues
operation0 insert, 1 update, 2 delete
side0 ask, 1 bid

A level carries the maker the venue names on it. An exchange-level book often names none — many venues quote no makers — and then the field is empty; on the aggregated book the exchange stands in where the venue names no maker. That is why the example keeps market_maker alongside price and size.

The example collects for the whole window and prints each book once at the end, not on every update. DURATION_SECS sets that window; the default is 15.

After a reconnect the venue restates the book from the top. The client reports that on error as 317, the TWS API's notice that a book was reset, before the first new level arrives — as a gateway does. Empty the book on it, or the levels that follow land on top of the old ones.

Limits

A depth subscription the venue will not serve is refused, and the refusal arrives on the error callback. A book that stays empty means the subscription never started, not that the market is empty.

The example asserts at the end that both books received updates and that neither is empty, so it panics rather than reporting success on a feed that never delivered. Run it while the market is open.

Run it

IB_USERNAME=... IB_PASSWORD=... cargo run --example l2_aapl_tsla

Collect for longer:

DURATION_SECS=30 cargo run --example l2_aapl_tsla

Source

//! Subscribe to L2 market depth for AAPL + TSLA and print the order book.
//!
//! Usage:
//!   IB_USERNAME=user IB_PASSWORD=pass cargo run --example l2_aapl_tsla
//!
//! Optional env vars:
//!   IB_HOST       — the venue host to log in to (default: the venue names it)
//!   DURATION_SECS — how long to collect data (default: 15)

use std::collections::HashMap;
use std::env;
use std::time::{Duration, Instant};

use ib_dx::api::client::{EClient, EClientConfig, Contract};
use ib_dx::api::wrapper::Wrapper;

// ── Book entry ──

#[derive(Debug, Clone)]
struct BookLevel {
    price: f64,
    size: f64,
    market_maker: String,
}

// ── Per-ticker book ──

struct TickerBook {
    symbol: String,
    bids: HashMap<i32, BookLevel>,  // position → level
    asks: HashMap<i32, BookLevel>,
    update_count: u64,
}

impl TickerBook {
    fn new(symbol: &str) -> Self {
        Self {
            symbol: symbol.into(),
            bids: HashMap::new(),
            asks: HashMap::new(),
            update_count: 0,
        }
    }

    fn apply(&mut self, position: i32, market_maker: &str, operation: i32, side: i32, price: f64, size: f64) {
        self.update_count += 1;
        let book = if side == 1 { &mut self.bids } else { &mut self.asks };
        match operation {
            0 | 1 => { book.insert(position, BookLevel { price, size, market_maker: market_maker.into() }); }
            2 => { book.remove(&position); }
            _ => {}
        }
    }

    fn print_summary(&self) {
        let mut bids: Vec<_> = self.bids.values().collect();
        let mut asks: Vec<_> = self.asks.values().collect();
        bids.sort_by(|a, b| b.price.partial_cmp(&a.price).unwrap());
        asks.sort_by(|a, b| a.price.partial_cmp(&b.price).unwrap());

        println!("\n── {} ── ({} updates)", self.symbol, self.update_count);
        println!("  Top 5 Asks:");
        for level in asks.iter().take(5).rev() {
            println!("    {:>10.2} x {:<10.0}  {}", level.price, level.size, level.market_maker);
        }
        println!("  ─────────────────────────────");
        println!("  Top 5 Bids:");
        for level in bids.iter().take(5) {
            println!("    {:>10.2} x {:<10.0}  {}", level.price, level.size, level.market_maker);
        }
    }
}

// ── Wrapper that collects depth for 2 tickers ──

struct DepthWrapper {
    books: HashMap<i64, TickerBook>,  // req_id → book
    errors: Vec<(i64, i64, String)>,
}

impl DepthWrapper {
    fn new(tickers: &[(i64, &str)]) -> Self {
        let mut books = HashMap::new();
        for &(req_id, symbol) in tickers {
            books.insert(req_id, TickerBook::new(symbol));
        }
        Self { books, errors: Vec::new() }
    }

    fn total_updates(&self) -> u64 {
        self.books.values().map(|b| b.update_count).sum()
    }
}

impl Wrapper for DepthWrapper {
    fn error(&mut self, req_id: i64, _error_time: i64, error_code: i64, error_string: &str, _: &str) {
        eprintln!("  error req_id={req_id} code={error_code} msg={error_string}");
        self.errors.push((req_id, error_code, error_string.into()));
    }

    fn update_mkt_depth(
        &mut self, req_id: i64, position: i32, operation: i32,
        side: i32, price: f64, size: ib_dx::api::Decimal,
    ) {
        if let Some(book) = self.books.get_mut(&req_id) {
            book.apply(position, "", operation, side, price, size.into());
        }
    }

    fn update_mkt_depth_l2(
        &mut self, req_id: i64, position: i32, market_maker: &str,
        operation: i32, side: i32, price: f64, size: ib_dx::api::Decimal, _is_smart_depth: bool,
    ) {
        if let Some(book) = self.books.get_mut(&req_id) {
            book.apply(position, market_maker, operation, side, price, size.into());
        }
    }
}

// ── Contracts ──

fn aapl() -> Contract {
    Contract {
        con_id: 265598,
        symbol: "AAPL".into(),
        sec_type: "STK".into(),
        exchange: "SMART".into(),
        currency: "USD".into(),
        ..Default::default()
    }
}

fn tsla() -> Contract {
    Contract {
        con_id: 76792991,
        symbol: "TSLA".into(),
        sec_type: "STK".into(),
        exchange: "SMART".into(),
        currency: "USD".into(),
        ..Default::default()
    }
}

fn main() {
    let username = env::var("IB_USERNAME").expect("IB_USERNAME required");
    let password = env::var("IB_PASSWORD").expect("IB_PASSWORD required");
    let host = env::var("IB_HOST").unwrap_or_default();
    let duration_secs: u64 = env::var("DURATION_SECS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(15);

    println!("Connecting to {host}...");
    let client = EClient::connect(&EClientConfig {
        username,
        password,
        host,
        paper: true,
        ..Default::default()
    }).expect("Failed to connect");
    println!("Connected.");

    // Subscribe to L2 depth for both tickers (5 rows, SmartDepth)
    let num_rows = 5;
    let is_smart = true;

    let aapl_id: i64 = 1;
    let tsla_id: i64 = 2;

    println!("Subscribing AAPL (req_id={aapl_id})");
    client.req_mkt_depth(aapl_id, &aapl(), num_rows, is_smart);

    println!("Subscribing TSLA (req_id={tsla_id})");
    client.req_mkt_depth(tsla_id, &tsla(), num_rows, is_smart);

    // Poll for updates
    let mut wrapper = DepthWrapper::new(&[(aapl_id, "AAPL"), (tsla_id, "TSLA")]);
    let start = Instant::now();
    let timeout = Duration::from_secs(duration_secs);

    println!("Collecting depth data for {duration_secs}s...");
    while start.elapsed() < timeout {
        client.process_msgs(&mut wrapper);
        std::thread::sleep(Duration::from_millis(50));
    }

    // Print results
    for book in wrapper.books.values() {
        book.print_summary();
    }

    let total = wrapper.total_updates();
    println!("\nTotal depth updates: {total}");

    // Cancel subscriptions
    client.cancel_mkt_depth(aapl_id, is_smart);
    client.cancel_mkt_depth(tsla_id, is_smart);

    // Validate
    for (req_id, book) in &wrapper.books {
        assert!(
            book.update_count > 0,
            "No depth updates received for {} (req_id={})", book.symbol, req_id
        );
        assert!(
            !book.bids.is_empty() || !book.asks.is_empty(),
            "Empty book for {} (req_id={})", book.symbol, req_id
        );
        println!("✓ {} — {} updates, {} bid levels, {} ask levels",
            book.symbol, book.update_count, book.bids.len(), book.asks.len());
    }
    assert!(total > 0, "Expected depth updates but got none");
    println!("\nAll validations passed.");
}