ibapi 4.1.0

A Rust implementation of the Interactive Brokers TWS API, providing a reliable and user friendly interface for TWS and IB Gateway. Designed with a focus on simplicity and performance.
Documentation
# Migration Guide: 3.x to 4.0

Version 4.0 is a breaking release. This guide walks through the changes required to upgrade from `ibapi` 3.x to 4.0. For 2.x → 3.x, see [`migration-3.0.md`](migration-3.0.md); for 1.x → 2.x, see [`MIGRATION.md`](../MIGRATION.md).

## Highlights

- Market-data sizes that IBKR models as decimals are now `Option<f64>` instead of `i32` / `f64` — fractional sizes (crypto, fractional shares) no longer truncate to `0`, and "TWS sent no value" is distinguishable from a real zero.
- `Liquidity` preserves unrecognized execution liquidity codes as `Liquidity::Unknown(i32)` instead of collapsing them to `Liquidity::None`. Exhaustive matches need a new arm.
- `Client::wsh_event_data_by_contract` / `wsh_event_data_by_filter` return builders instead of taking positional `Option` arguments.
- `Client::check_server_version` is crate-private; compare against `Client::server_version()` directly.
- `Notice` gains a `request_id` field — the originating request or order id, `None` for request-less notices.
- `MarketDataBuilder` moves from `market_data::builder` to `market_data::realtime`, beside its sibling builders.
- Transport hardening (behavioral, not API-breaking): frame-length validation with automatic reconnect, wrong-wire-format detection, unified retry-on-reset across one-shot requests, a configurable reconnect attempt limit (`ClientBuilder::max_reconnect_attempts` / `reconnect_forever`), observable consumer lag (`SUBSCRIPTION_LAG_CODE` notices, `ClientBuilder::channel_capacity`, sync backlog watermarks), and byte-level stream capture via `IBAPI_RAW_CAPTURE_DIR`.

## Breaking changes

### 1. Market-data sizes are `Option<f64>`

IBKR models market-data sizes as decimals and ships them as strings on the wire. Historical tick and histogram sizes were typed `i32`, so a fractional wire value such as `"0.5"` failed integer parsing and silently decoded as `0` — real data loss on crypto and fractional-share feeds. Those fields, plus the `ContractDetails` size rules, are now `Option<f64>`.

| Type | Field | 3.x | 4.0 |
|---|---|---|---|
| `TickMidpoint` | `size` | `i32` | `Option<f64>` |
| `TickLast` | `size` | `i32` | `Option<f64>` |
| `TickBidAsk` | `size_bid`, `size_ask` | `i32` | `Option<f64>` |
| `HistogramEntry` | `size` | `i32` | `Option<f64>` |
| `ContractDetails` | `min_size`, `size_increment`, `suggested_size_increment` | `f64` | `Option<f64>` |

`None` means TWS sent no value — the field was absent, empty, or carried one of TWS's "unset" sentinels (`2147483647`, `9223372036854775807`, `-9223372036854775808`, `1.7976931348623157E308`). `Some(0.0)` is a real zero. A malformed size is no longer swallowed: it surfaces as `Error::Parse` and fails the request or subscription.

**The `Ord` gotcha.** `f64` does not implement `Ord`, so `max_by_key` / `min_by_key` on a size no longer compiles. This is the most likely break in existing code:

```rust,ignore
// 3.x — compiled against i32
let mode = histogram.iter().max_by_key(|e| e.size);

// 4.0 — pick out the present sizes, then compare with total_cmp
let mode = histogram
    .iter()
    .filter_map(|e| e.size.map(|s| (e, s)))
    .max_by(|a, b| a.1.total_cmp(&b.1));
```

**Accumulating.** Treat "unset" as contributing nothing. Use `unwrap_or(0.0)` when you are folding one value at a time into a running total, and `filter_map` when you are reducing a collection — the latter also matters for `min`/`max`, where a substituted `0.0` would skew the result:

```rust,ignore
// 3.x
let mut total_volume = 0;
total_volume += tick.size;

// 4.0
let mut total_volume = 0.0;
total_volume += tick.size.unwrap_or(0.0);

// Or, over a collection:
let total: f64 = histogram.iter().filter_map(|e| e.size).sum();
```

**Displaying.** `Option<f64>` is not `Display`, so `{}` no longer works. Prefer surfacing the missing case over hiding it behind a zero:

```rust,ignore
// 3.x
println!("Size: {}", tick.size);

// 4.0
fn fmt_size(size: Option<f64>) -> String {
    size.map_or_else(|| "n/a".to_string(), |s| format!("{s:.0}"))
}
println!("Size: {}", fmt_size(tick.size));
```

**Serialized shape changes too.** These types derive `Serialize`/`Deserialize` (and `utoipa::ToSchema` under the `utoipa` feature), so the JSON changes in two ways beyond the compile errors: a present size now serializes as `100.0` rather than `100`, and an absent one as `null` rather than `0`. Under `utoipa` the generated schema goes from `integer` to a nullable `number`. If you publish an OpenAPI contract or have strict JSON consumers downstream, that is a breaking change with no compile-time signal.

`Option<f64>` is an intermediate step. A dedicated decimal quantity type is planned, so sizes will eventually round-trip the wire's decimal representation exactly rather than through binary floating point.

### 2. `Liquidity` gains `Unknown(i32)`

`Execution.last_liquidity` used to collapse any liquidity code outside the documented 0–3 range to `Liquidity::None` — indistinguishable from a genuine "no liquidity information". Unrecognized codes are now preserved:

```rust,ignore
// 3.x — exhaustive match compiled
match execution.last_liquidity {
    Liquidity::None => {}
    Liquidity::AddedLiquidity => {}
    Liquidity::RemovedLiquidity => {}
    Liquidity::LiquidityRoutedOut => {}
}

// 4.0 — add an arm for the new variant
match execution.last_liquidity {
    Liquidity::None => {}
    Liquidity::AddedLiquidity => {}
    Liquidity::RemovedLiquidity => {}
    Liquidity::LiquidityRoutedOut => {}
    Liquidity::Unknown(code) => log::warn!("unrecognized liquidity code {code}"),
}
```

The documented codes 0–3 decode exactly as before. No official client or `Execution.proto` defines a code outside that range today, so `Unknown` is forward compatibility — a code IBKR adds later surfaces as `Unknown(code)` you can log, store, or reject, instead of silently masquerading as `None`. `Liquidity` is deliberately exhaustive (no `#[non_exhaustive]`), so the compiler points at every match that needs the new arm.

### 3. WSH event data goes through builders

`wsh_event_data_by_contract` took one required argument and four `Option`s; `wsh_event_data_by_filter` took one and two. Every call site in this repository — both examples and both integration tests — passed `None` for all of them. They now return builders, with one setter per optional value:

```rust,ignore
// 3.x
let events = client.wsh_event_data_by_contract(contract_id, None, None, None, None)?;
let events = client.wsh_event_data_by_contract(
    contract_id,
    Some(date!(2024 - 01 - 01)),
    Some(date!(2024 - 03 - 31)),
    Some(50),
    Some(auto_fill),
)?;
let subscription = client.wsh_event_data_by_filter(filter, None, None)?;

// 4.0
let events = client.wsh_event_data_by_contract(contract_id).fetch()?;
let events = client
    .wsh_event_data_by_contract(contract_id)
    .starting(date!(2024 - 01 - 01))
    .ending(date!(2024 - 03 - 31))
    .limit(50)
    .auto_fill(auto_fill)
    .fetch()?;
let subscription = client.wsh_event_data_by_filter(filter).subscribe()?;
```

The terminals differ because the requests do: `.fetch()` returns a single `WshEventData`, `.subscribe()` returns a `Subscription<WshEventData>`. Async is identical with `.await` on the terminal.

Each setter carries its own server-version requirement — `.starting()` / `.ending()` / `.limit()` need `WSH_EVENT_DATA_FILTERS_DATE`, `.auto_fill()` needs `WSH_EVENT_DATA_FILTERS` — so a bare request still works against a gateway that supports none of them. That was true before too; the builder just makes it visible which argument costs which version.

### 4. `Client::check_server_version` is crate-private

The async `Client` exposed `check_server_version(required_version, feature)` as `pub` while the blocking `Client` kept the same method `pub(crate)`. That was drift, not design — the method is the internal guard each version-gated API calls before encoding a request, and nothing in `examples/` or the integration crates ever called it. Both are now `pub(crate)`.

If you were calling it to branch on server support, compare against `Client::server_version()` directly:

```rust,ignore
// 3.x — async only
client.check_server_version(server_versions::SIZE_RULES, "size rules")?;

// 4.0 — the constants are public; the guard is not
if client.server_version() < server_versions::SIZE_RULES {
    // fall back
}
```

The version-gated methods still perform this check themselves and return `Error::ServerVersion` when the gateway is too old, so an explicit pre-check is only needed when you want to branch instead of erroring.

### 5. `Notice` gains `request_id`

`Notice` now carries the originating request or order id — `None` for request-less notices. `Notice` is not `#[non_exhaustive]`, so struct literals must add the field:

```rust,ignore
// 3.x
let notice = Notice {
    code: 2104,
    message: "Market data farm connection is OK".into(),
    error_time: None,
    advanced_order_reject_json: String::new(),
};

// 4.0
let notice = Notice {
    request_id: None,
    code: 2104,
    message: "Market data farm connection is OK".into(),
    error_time: None,
    advanced_order_reject_json: String::new(),
};
```

Consumers are unaffected at compile time but gain information: a notice delivered to a subscription or the order-update stream now names the request or order it belongs to. The serde shape is backward compatible — `request_id` is `#[serde(default, skip_serializing_if = "Option::is_none")]`, so 3.x JSON still deserializes and the field only appears in output when present.

### 6. `DATA_ADVISORY_CODES` is a `&[i32]` slice

The advisory list grew from `[10089, 10167]` in 3.x to `[2188, 10089, 10090, 10167]` in 4.0.0 and `[2188, 10089, 10090, 10091, 10167]` after — see the notice-classification changes under [Behavioral changes](#behavioral-changes). Each addition changed the array type, so the constant is now a slice and future additions are value changes only. This is breaking only for code binding the const with an explicit type:

```rust,ignore
// 3.x
let advisories: [i32; 2] = ibapi::DATA_ADVISORY_CODES;

// 4.x — let the type follow the const
let advisories = ibapi::DATA_ADVISORY_CODES;
```

Iteration yields `&i32`: write `for &code in ibapi::DATA_ADVISORY_CODES` where 3.x wrote `for code in ...`.

### 7. `MarketDataBuilder` moves to `market_data::realtime`

`MarketDataBuilder` lived at `market_data::builder` — a module whose only content was that one type — while its three siblings (`RealtimeBarsBuilder`, `MarketDepthBuilder`, `TickByTickBuilder`) lived under `market_data::realtime`. It now lives beside them, and the `market_data::builder` module is gone:

```rust,ignore
// 3.x
use ibapi::market_data::builder::MarketDataBuilder;

// 4.0
use ibapi::market_data::realtime::MarketDataBuilder;
```

Only code that names the type breaks — imports, or a function signature taking/returning the builder. The usual form, `client.market_data(&contract).subscribe()`, never names it and is unaffected. It is also in `ibapi::prelude` now, along with the other three realtime builders.

`MarketDataBuilder::new` is `pub(crate)` in the same move — the sibling builders never exposed a public constructor, and `client.market_data(&contract)` is the supported way to obtain one.

### 8. The `realtime::sync::market_data` free function is crate-private

The low-level request function under the market-data builder was `pub` where its siblings (`realtime_bars`, `market_depth`, `tick_by_tick`) were already `pub(crate)` — reachable as `market_data::realtime::sync::market_data(...)`, and in sync-only builds also as `market_data::realtime::market_data(...)`. It is crate-private now. The builder takes the same inputs:

```rust,ignore
// 3.x (sync-only build)
let subscription = ibapi::market_data::realtime::market_data(&client, &contract, &["233"], false, false)?;

// 4.0
let subscription = client.market_data(&contract).generic_ticks(&["233"]).subscribe()?;
```

The `snapshot` and `regulatory_snapshot` booleans map to the `.snapshot()` / `.regulatory_snapshot()` setters. Nothing in `examples/` or the integration crates called the free function, and the async side never had a public one.

### 9. `OrderStatusKind` gains `Unknown(String)`

An `OrderStatus` frame whose status string this crate did not model used to fail decoding with `Error::Parse` — which terminated the subscription delivering it, including the long-lived `order_update_stream()`. A status IBKR ships in the future would have taken down the one stream a trading application cannot afford to lose. Unrecognized statuses are now preserved (the official IB client does the same — its `OrderStatus` enum has an `Unknown` fallback):

```rust,ignore
// 3.x — exhaustive match compiled
match status.status {
    OrderStatusKind::Filled => {}
    OrderStatusKind::Cancelled => {}
    // ... the other seven
}

// 4.0 — add an arm for the new variant
match status.status {
    OrderStatusKind::Filled => {}
    OrderStatusKind::Cancelled => {}
    // ... the other seven
    OrderStatusKind::Unknown(raw) => log::warn!("unrecognized order status {raw:?}"),
}
```

What changes for compiling code:

- **`Copy` is gone** (the variant carries a `String`); `OrderStatusKind` is still `Clone`. Code that relied on implicit copies needs `.clone()` or a borrow.
- **`as_str()` returns `&str`**, not `&'static str` — for `Unknown` it borrows the raw value.
- **`is_active()` / `is_terminal()` take `&self`** and both return `false` for `Unknown`, as they do for `ApiPending` — don't assume `!is_active() ⇒ is_terminal()`.
- **Exhaustive matches need the new arm.** Like `Liquidity`, the enum is deliberately exhaustive (no `#[non_exhaustive]`), so the compiler points at every site.

The nine known statuses parse exactly as before; matching stays exact and case-sensitive, so a case-variant of a known status also lands in `Unknown` rather than being coerced. Empty input is still `Error::Parse` — absence of a value remains an error. Serialization is unchanged in shape: the enum still serializes as the plain status string in both directions (`Unknown("X")` round-trips as `"X"`), and the `utoipa` schema is a plain `string` to match (hand-written, since the derive would describe the tagged `{"Unknown":"X"}` shape the serde impls avoid).

### 10. `option_chain` goes through a builder

`option_chain` took four positional arguments. `exchange` documented `""` as "all exchanges" and was the one with a default; it is now a setter, and the three required arguments stay positional:

```rust,ignore
// 3.x
let subscription = client.option_chain("AAPL", "", SecurityType::Stock, 265598)?;

// 4.0
let subscription = client.option_chain("AAPL", SecurityType::Stock, 265598).subscribe()?;
```

Async is identical with `.await` on `.subscribe()`.

Two things the old signature let callers get wrong, both verified against a live paper gateway while making this change:

- **`exchange` is TWS's `futFopExchange`** — a filter for *futures* options. For a stock underlying, leave it unset: TWS returns one `OptionChain` per listing exchange (twenty for AAPL). Naming any exchange, `"SMART"` included, returns an empty chain. Only call `.exchange("CME")` for a futures underlying.
- **`contract_id` is required.** `0` is rejected with code 321 "Invalid contract id". The crate's own async example passed `0` (and `"SMART"`), so it had been receiving nothing.

An unset exchange is now absent from the request rather than sent as `""`; TWS treats the two identically.

### 11. `OrderUpdate` gains `OrderBound`

TWS sends an `OrderBound` notification when it binds a permanent order ID to an API client ID and an order ID in that client's namespace. The official client documents it as the response to an order-binding request: client ID 0 can take over orders submitted manually in TWS via `reqAutoOpenOrders`, and each order it takes over is announced this way. The wire type existed in 3.x but no transport delivered it, so it was silently dropped. `order_update_stream()` now yields it as `OrderUpdate::OrderBound(OrderBound { perm_id, client_id, order_id })`:

```rust,ignore
// 3.x — exhaustive match compiled
match update? {
    OrderUpdate::OrderStatus(status) => {}
    OrderUpdate::OpenOrder(order) => {}
    OrderUpdate::ExecutionData(exec) => {}
    OrderUpdate::CommissionReport(report) => {}
}

// 4.0 — add an arm for the new variant
match update? {
    OrderUpdate::OrderStatus(status) => {}
    OrderUpdate::OpenOrder(order) => {}
    OrderUpdate::ExecutionData(exec) => {}
    OrderUpdate::CommissionReport(report) => {}
    OrderUpdate::OrderBound(binding) => println!("perm {} is client {} order {}", binding.perm_id, binding.client_id, binding.order_id),
}
```

What changes for compiling code:

- **Exhaustive matches need the new arm.** Like `Liquidity` and `OrderStatusKind`, the enum stays exhaustive (no `#[non_exhaustive]`), so the compiler points at every site. Matches with a `_` arm compile unchanged.
- **Bindings arrive only on `order_update_stream()`.** They do not reach the per-order `place_order` subscription, even for this client's own orders: API order IDs are scoped to a client ID, and the transport does not filter on it, so a binding for another client's order 42 must not land on a local subscription for order 42. This matches the official client, which delivers `orderBound` only to the global wrapper callback. Consume bindings from the update stream and key on `perm_id` when you need an account-wide identity.
- **A binding grants nothing.** Knowing another client's `(client_id, order_id)` does not let this client modify or cancel that order.

## Behavioral changes

No code changes required, but observable at runtime:

- **Wrong wire format is an error, not a skip.** A text-framed message reaching a proto-only decoder fails the subscription with the new `Error::UnexpectedWireFormat` instead of being silently dropped — at server version 213+ that framing means the gateway broke protocol, and previously the subscription just yielded nothing. Wrong-*message-type* frames are still skipped, which is what shared channels need.
- **One-shot requests narrow to the message type they asked for.** A foreign frame surfaces as `Error::UnexpectedResponse` naming both the expected and received type, instead of being fed to the wrong payload decoder — where overlapping protobuf field numbers usually produced a plausible struct full of wrong values.
- **Retry-on-reset is uniform.** `market_rule`, `family_codes`, `calculate_option_price`, `calculate_implied_volatility`, and `next_valid_order_id` now retry a connection reset up to three times like every other one-shot; `head_timestamp`, `histogram_data`, `market_depth_exchanges`, `historical_data(..).fetch()`, and `historical_schedules(..).fetch()` now retry *at most* three times instead of unboundedly (or, on the async side, not at all), and sync/async agree on what a closed stream returns.
- **Frames are validated.** A length prefix that cannot describe a TWS message (shorter than the message id, or over the official 16 MiB cap) raises `Error::InvalidFrame` and drives a reconnect instead of a multi-gigabyte allocation and a permanently mis-framed stream; a body too short for the message id no longer panics the dispatcher. A frame whose message id maps to no known type raises an `UNKNOWN_MESSAGE_TYPE_CODE` (`-5`) notice on `Client::notice_stream` — the observable form of a framing desynchronization. Both new `Error` variants arrive via `#[non_exhaustive]`, so they are not compile-breaking.
- **Notices reclassified.** Codes 2188 and 10090 are data advisories (TWS keeps delivering data after sending them, but the subscription used to be torn down); code-399 order messages whose text carries a `Warning:` line classify as warnings instead of order rejections; a notice with no `error_code` field (code 0) classifies as a warning instead of failing every in-flight shared one-shot. After 4.0.1, `WARNING_CODE_RANGE` covers the whole `21xx` band (`2100..=2199`), 317 (market depth RESET) is a data advisory, and `DATA_ADVISORY_CODES` resolves before the ranges in `category()` — see the CHANGELOG entries for #805 / #806.
- **The order-update stream delivers order-bound errors as notices.** Order-bound error frames arrive as `SubscriptionItem::Notice` (with `request_id`, code, and message) instead of raw frames that failed to decode as `OrderUpdate`. Note `filter_data()` / `iter_data()` drop notices — match on `SubscriptionItem::Notice` to observe rejections of fire-and-forget orders. Request-less errors and errors owned by a data-request subscription no longer reach the stream at all.
- **Real errors instead of empty results.** `OrderBuilder::analyze()` returns the TWS rejection (e.g. code 201) instead of `Error::UnexpectedEndOfStream`; blocking `matching_symbols()` returns the TWS error instead of `Ok(vec![])`.
- **Malformed decimals fail instead of decoding as `0`.** Beyond the size fields whose types changed in [§1](#1-market-data-sizes-are-optionf64), every decimal-typed wire field — order quantities, execution shares, positions, bar volume/WAP, market-depth sizes — now surfaces a malformed value as `Error::Parse` instead of silently substituting `0`. TWS's "unset" sentinels are also recognized on all of these fields (previously only a few), decoding to `None` — or `0.0` where the field stays `f64` — instead of leaking as a literal 2.1-billion value.
- **`TickTypes::MarketDataType` actually arrives.** The variant existed but was never routed to `Client::market_data` subscriptions; TWS's market-data-type notifications (real-time / frozen / delayed / delayed-frozen, sent on subscribe and whenever the feed switches) now reach them, so a match that never saw this variant will start seeing it.
- **Falling behind is observable.** An async subscription whose consumer lags its broadcast channel (capacity 1024, now settable via `ClientBuilder::channel_capacity`) receives a non-terminal `SUBSCRIPTION_LAG_CODE` (`-6`) notice naming the number of evicted frames, plus a `warn!`, where it previously resumed with no signal at any level; reconcile as after a reconnect gap. The async notice stream (`Client::notice_stream`) rides the same bounded fan-out at the fixed default capacity, and a lagging consumer receives a `NOTICE_STREAM_LAG_CODE` (`-7`) notice in place of the evicted notices; because that stream carries the 1100/1101/1102 connection-status notices, resynchronize (re-baseline link state, re-establish subscriptions) rather than resume. A finished socket reconnect publishes a `TRANSPORT_RECONNECT_CODE` (`-8`) notice to the notice stream on both sides, since TWS never frames the reconnect and does not replay 1101/1102 on the new connection; treat it the same way. On the sync side, whose channels are unbounded and never drop, a stalled consumer now triggers `warn!` watermarks at every 10,000 queued messages on the subscription, shared, and order-update send paths (the sync notice fan-out has no watermark).
- **Reconnection is configurable and more resilient.** `ClientBuilder::max_reconnect_attempts` / `reconnect_forever` control the retry budget (default unchanged: 20 attempts, ~7.5 minutes). A session-establishment failure (handshake, `startAPI`, account info) consumes one attempt and backs off instead of aborting the loop — common during an automated TWS restart — and when every attempt fails, `reconnect` returns the last real error instead of a generic `Error::ConnectionFailed`.

## Quick migration checklist

1. Add a `Liquidity::Unknown(code)` arm to exhaustive matches on `Execution.last_liquidity` — the compiler finds them for you.
2. Unwrap market-data sizes: historical tick / histogram sizes and the `ContractDetails` size rules are `Option<f64>`. Watch for `max_by_key` on a size (`f64` isn't `Ord`), `+=` into an integer accumulator, and `{}` formatting — see [§1](#1-market-data-sizes-are-optionf64).
3. Rewrite `wsh_event_data_by_contract` / `wsh_event_data_by_filter` calls as builder chains ending in `.fetch()` / `.subscribe()`, and `option_chain(symbol, exchange, security_type, contract_id)` as `option_chain(symbol, security_type, contract_id).subscribe()` — drop the exchange unless the underlying is a future; see [§10](#10-option_chain-goes-through-a-builder).
4. Replace `client.check_server_version(..)` with a comparison against `client.server_version()`.
5. Add `request_id: None` (or a real id) to any `Notice` struct literals.
6. Re-point `use ibapi::market_data::builder::MarketDataBuilder` imports at `ibapi::market_data::realtime::MarketDataBuilder`.
7. Replace direct `realtime::sync::market_data(..)` calls with the `client.market_data(&contract)` builder — see [§8](#8-the-realtimesyncmarket_data-free-function-is-crate-private).
8. If you consume the order-update stream through `filter_data()` / `iter_data()`, decide whether you need a `SubscriptionItem::Notice` arm to observe order rejections.
9. Add an `OrderStatusKind::Unknown(raw)` arm to exhaustive matches on order statuses, and `.clone()` (or borrow) where code relied on the removed `Copy` — see [§9](#9-orderstatuskind-gains-unknownstring).
10. If you serialize market-data types to JSON, update downstream consumers: sizes are now `number | null` instead of `integer`, and notices may carry `request_id`.
11. Add an `OrderUpdate::OrderBound(binding)` arm to exhaustive matches on order updates, and read bindings from `order_update_stream()` — they never reach `place_order` subscriptions; see [§11](#11-orderupdate-gains-orderbound).
12. Re-run `cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings`, and your test suite for each feature flag you support.

## Need help?

- Examples: `examples/async` and `examples/sync`
- README: [Handling notifications](../README.md#handling-notifications)
- Issues: <https://github.com/wboayue/rust-ibapi/issues>