ibapi 5.2.0

An unofficial Rust client for the Interactive Brokers TWS API. Async (Tokio) and blocking clients for TWS and IB Gateway.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [5.2.0] - 2026-10-20

### Added

- `examples/{sync,async}/wait_for_bracket.rs`: wait for every leg of a bracket order with three `wait_for_fill` calls sharing one deadline (#993).
- `ContractDetails::settlement_method`: how the product settles on expiry (`"Physical"` or `"Cash"`).
- `Order::conditions_include_overnight`: evaluate order conditions during the OVERNIGHT session (with `include_overnight`). Needs server version 226 (`server_versions::CONDITIONS_INCLUDE_OVERNIGHT`); older servers reject the order with `Error::ServerVersion`. The client now advertises max server version 226.

### Changed

- Dropping a clone of an async `Subscription` no longer cancels the request for the other clones: the request is cancelled when the last clone is dropped, and a shared stream's clones release their count then. Previously the first clone dropped wrote TWS's cancel, and the remaining clones waited forever for frames that no longer came. `cancel()` on any clone still ends the request for every clone (#1049).
- Examples renamed: `v2_contract_builder` → `contract_builders`, `async_historical_ticks_midpoint` → `async_historical_ticks_mid_point`, `tick_by_tick_midpoint` → `tick_by_tick_mid_point` (matching `.mid_point()`), `async_test_clone_subscription` → `async_subscription_fan_out` (rewritten as a fan-out example). `async_test_multiple_calls` removed (#1041). `async_historical_ticks_mid_point` no longer fails with TWS error 10187: its second request now has an end time, and its first starts three days back instead of at a 2024 date older than IBKR's tick history.
- `wait_for_fill` on an order this client has no status for asks TWS for this client's open orders (`open_orders`) before waiting, so an order placed on an earlier connection and still working is found instead of timing out. It reads that reply to its end before returning, so a later `open_orders` cannot end on it; this can add a round trip to the timeout. With client id 0, that request also binds manual TWS orders to the client (#993).
- `wait_for_fill` on an order missing from that open-orders reply looks it up in `completed_orders` and, if found, ends at once with `Filled` or `Ended`, so an order that finished before this connection no longer waits out the timeout. The status is built from the completed order: its fill prices (`average_fill_price`, `last_fill_price`) are `None` (#1020).

### Fixed

- Async `Subscription::cancel()` wakes clones already waiting in `next()`, which then yield `Err(Error::Cancelled)`. Previously a waiting clone of an id-routed subscription (market data, for example) waited until the next frame, which after the cancel never came, so a task reading it could hang forever (#1047).
- One-shot requests answered on a shared channel (`managed_accounts`, `server_time`, `family_codes`, `request_fa`, `next_valid_order_id` and the other request-id-less calls) fail with `Error::Timeout` after 30 seconds instead of blocking forever when TWS never answers (#1029).
- `request_fa` returns the dataset it asked for. Previously, with a `request_fa` for another dataset in flight, it could return that dataset's `FaConfig`. Likewise `market_rule` returns the rule it asked for, not a concurrent `market_rule`'s (#1029).
- Another client's order frames no longer reach this client's order routes, `order_update_stream`, `wait_for_fill` or `order_status_stream`. Order ids are unique per client only, so with `all_open_orders` (or a master client id) another client's `OpenOrder`/`OrderStatus` for an id this client also used went to this client's `place_order` subscription and could end a wait with that order's fill or cancel, and `all_open_orders` came back without it. Those frames now go only to `all_open_orders`; another client's executions still answer an `executions()` request. With a master client id, `order_update_stream` no longer reports other clients' orders: use `all_open_orders` and `executions` for those (#993, #1021).
- `open_orders`, `all_open_orders` and `auto_open_orders` include this client's orders that have a live `place_order` subscription. Previously that subscription took their `OpenOrder`/`OrderStatus` frames and the open-orders reply came back without them, so `wait_for_fill` on such an order also sent a needless `completed_orders` request. Those frames now reach both; the `place_order` subscription receives the same frames as before. `auto_open_orders` now also sees updates for those orders (#1033).
- After a reconnect, `wait_for_fill` no longer trusts a working order's cached status: TWS does not replay what happened while disconnected, so the cache keeps only statuses of orders that ended (and rejections), and the wait looks other orders up again. Previously an order cached as `Submitted` that filled during the outage waited out the timeout. A new `order_status_stream` on such an order starts empty until its next status (#1031).
- The market data type set with `switch_market_data_type` now survives an automatic reconnect: TWS does not keep it across reconnects, and the client re-sends it before the `TRANSPORT_RECONNECT_CODE` notice. Previously a subscription on the new session without a realtime entitlement got error 354 or the 10089 advisory instead of delayed ticks (#1016).
- The blocking client no longer logs `error sending notification` at shutdown, or on an item arriving, for a subscription dropped just before (#999).

## [5.1.0] - 2026-10-11

### Added

- `Client::wait_for_fill(order_id, timeout)` (async and blocking) waits until an order fills or ends and returns `orders::OrderOutcome`: `Filled`, `Ended` (cancelled or inactive first), `Rejected` with TWS's error when TWS refuses the order without a status (e.g. 110, a price off the minimum tick), or `TimedOut` with the last status seen; a timeout leaves the order working. It answers from the statuses this client has seen since connecting, so an order placed by another client, or one that finished before this connection, waits out the timeout. See `examples/{sync,async}/wait_for_fill.rs` (#951).
- `Client::order_status_stream(order_id)` (async and blocking): the `OrderStatus` updates for one order, starting with the latest status this client has seen. A rejection without a status is yielded as `Err(Error::Notice)`, ending the stream. Nothing is sent to TWS (#951).
- `OrderStatus::outcome()`, the rule `wait_for_fill` uses: `Filled` for status `Filled` or nothing remaining after a fill, `Ended` for a terminal status, otherwise `None`. `OrderOutcome` is in the prelude (#951).
- `Client::account_summary_snapshots(&group, tags, quiet)` (async and blocking) wraps `account_summary` and yields `AccountSummarySnapshot`, the latest value of every row by account, tag and currency. TWS sends one `End` after the first snapshot and none after the rows it pushes later, so a snapshot completes at an `End` or once no row has arrived for `quiet`, and only when a row changed a value. The blocking type is at `client::blocking::AccountSummarySnapshots` when both features are enabled (#957).
- `Subscription::next_batch(quiet)` (async and blocking) returns the items that arrive together, closed by an end marker or by `quiet` without an item; for streams that dump then push without an end marker (account summary, account updates, positions and their multi variants). `account_summary_snapshots` is built on it (#977).
- `AccountSummarySnapshot::apply` folds rows for callers with their own batching; `AccountSummarySnapshots::cancel` (async and blocking) cancels before resubscribing.
- `AccountSummary` implements `Clone` and `PartialEq` (#957).
- `generic_tick::ETF_FROZEN_NAV_LAST` (`"623"`, frozen-data ETF NAV last price, tick 97) (#946).
- `OptionChainBuilder::request_id()` and `OptionChainBuilder::buffer_limit(n)`, as on `ContractDetailsBuilder`. The id is allocated when the builder is made, so it is known before anything is sent; `subscribe()` sends once (no retry). TWS has no cancel for this request (#902).
- `HistoricalDataBuilder::buffer_limit(n)` caps unread items on `stream()`, as on `ContractDetailsBuilder`. The stream has no end marker (`HistoricalBarUpdate::End` after the initial bars is an item and counts), so a reader that stops reading always overflows eventually. `fetch()` returns `InvalidArgument` when it is set (#902).
- `subscriptions::MAX_BUFFER_LIMIT`, the largest `buffer_limit` for every builder (#902).
- `RateLimiter` and `ClientBuilder::rate_limiter`: opt-in cap on the messages a client sends to TWS. `RateLimiter::per_second(n)` sends at most `n` messages in any one-second window (`Default` is 50); over-budget sends are delayed (sync blocks, async awaits), never rejected. Cancels count but go out at once. Clones share one budget, so clients of one gateway can be capped together. Off by default (#950).
- Built-in time zone aliases `BRT` and `Brasilia Standard Time` (`America/Sao_Paulo`), sent by pt-BR IB Gateway builds 10.45 and 10.51. `E. South America Standard Time` already resolved. The troubleshooting guide now documents where the gateway time zone is used (#964).
- `Subscription::collect_to_end_within(timeout)` (sync and async): collects a request's rows until TWS's end marker, or fails with the new `Error::Timeout`. Bounds `contract_details` and other requests that end; `contract_details()` itself still waits with no bound. A stream closed without the end marker is `UnexpectedEndOfStream`, and a terminal error is returned as is (#965).
- `client::blocking` exports the blocking `TickSubscription`, its four iterator types and `DisplayGroupSubscription`, previously unnameable or hidden when both `sync` and `async` are enabled (#958).

### Deprecated

- `contracts::MAX_BUFFER_LIMIT`. Use `subscriptions::MAX_BUFFER_LIMIT`; the limit applies to every builder with `buffer_limit`, not only contracts (#902).
- `generic_tick::ETF_NAV_FROZEN_LAST`. Its value, `"578"`, is the generic tick IB's earlier tick table gives for the ETF NAV close and prior close (ticks 92 and 93), not the frozen NAV last price (tick 97) its name and docs promised, and TWS rejects it (error 321). Use `ETF_FROZEN_NAV_LAST` for tick 97 (#946).
- `generic_tick::ETF_NAV_BID` (`"576"`). TWS rejects it (error 321), and IB's current tick-type page no longer lists it; there is no replacement.
- 53 `protocol::Features` constants whose server version is at or below 213, such as `POSITIONS`, `TICK_BY_TICK` and `WSH_EVENT_DATA_FILTERS`. The client refuses servers below 213 at connect, so `check_version` with any of them always passes; the library no longer checks them. They will be removed in 6.0 (#990). `CANCEL_CONTRACT_DATA`, `CONFIG` and `UPDATE_CONFIG` stay (#978).

### Fixed

- Async client: `disconnect()` and dropping the client now close the TCP connection even while subscriptions or client clones are still held (#966).
- `place_order` / `submit_order` (sync and async) return `Error::ServerVersion` for `Order::deactivate` below server version 216 and `Order::route_marketable_to_bbo` below 217, as the C# client does. Previously both were sent unchecked (#978).
- Blocking client: `disconnect()` and dropping the client now close the TCP connection even while subscriptions are still held. Previously only the read side was shut down, so TWS kept the session open (#979).
- `historical_data(..).fetch()` (sync and async) waits for TWS's end marker. A response that closed after the bars returned them as complete, with `start`/`end` left at the epoch; it is now `UnexpectedEndOfStream`, and a wrong second message is `UnexpectedResponse` (#967).
- Blocking `order_update_stream()` can be reopened right after `cancel()`, while the old handle is still held; it used to return `AlreadySubscribed` until the cleanup thread caught up (#932).
- Blocking client: cancelling or dropping a `contract_news` or `broad_tape_news` subscription now sends `cancelMktData`, as the async client already did. Previously no cancel was sent, and TWS kept the news subscription open until disconnect (#947).

## [5.0.0] - 2026-10-04

### Added

- `OrderBuilder::peg_best(limit, CompeteAgainstBest)` and `peg_mid(limit, MidOffsets)` for IBKRATS pegs, with `orders::builder::CompeteAgainstBest` (`Offset` or `UpToMid(MidOffsets)`) and `MidOffsets { at_whole, at_half }`. Both set `not_held`, which TWS requires for them (#936).
- `orders::order_builder::non_guaranteed(order)`, which tags a combo order non-guaranteed, `attach_adjustable_stop` with `AdjustTo` / `Adjustment`, and `orders::builder::BracketPrices` (#901).
- `Order::builder()` starts the fluent `OrderBuilder` without a client; `build()` returns the `Order` to pass to `place_order` / `submit_order`. Same setters and validation as `client.order(&contract)`, without `submit()` / `analyze()` / `bracket()` / preset legs, which need the client to allocate order ids (#901).
- `OrderBuilder` setters for order fields it carried but could not set: `pegged_to_stock(delta, starting_price)`, `stock_reference_price`, `stock_range`, `pegged_to_benchmark(starting_price)` with `reference_contract`, `pegged_change_amount`, `pegged_change_amount_decrease`, `reference_change_amount` and `reference_range`, plus `non_guaranteed`, `combo_leg_prices`, `manual_order_time` and `cash_qty`. `build()` requires `delta` and `starting_price` for PEG STK, and `starting_price` and `reference_contract` for PEG BENCH. With `cash_qty`, the order quantity may be `0` (#901).
- `OrderBuilder::pegged_to_market(offset)`, `pegged_to_midpoint(offset, limit_price)` and `box_top()`, for `OrderType` variants that had no named method. `pegged_to_midpoint` takes its arguments in the same order as `order_builder::pegged_to_midpoint` (#901).
- `TickSubscription::request_id()` (sync and async): the id of the historical-ticks request, so `cancel_historical_ticks` can be called from code that does not hold the subscription.
- `rust-version = "1.88"` in `Cargo.toml`, the floor set by `time` 0.3.47+. Cargo now reports an older toolchain up front instead of failing partway through the build, and CI checks the library builds on 1.88.
- `ContractDetailsBuilder::buffer_limit(n)` caps unread items (rows and notices): when `n` are waiting and another arrives, the subscription yields every queued item, then the new `Error::BufferLimitExceeded { limit }`, then ends, and later rows are discarded. TWS's end marker and errors always get through, so a result that fills the cap exactly still ends normally. Without it nothing changes (async: lossy `channel_capacity`; sync: unbounded). `n` must be `1..=contracts::MAX_BUFFER_LIMIT` (#876).
- `Subscription::cancel_and_drain(deadline)` (sync and async) writes the native cancel and waits for TWS to finish the request, returning `Drained::Ended`, `Drained::Rejected(notice)` or `Drained::Unconfirmed`; session errors are `Err`. Observed live, TWS keeps sending contract details after a cancel, so the drain usually waits out the whole result (#876).
- `Client::contract_details_stream(&contract)` returns a `ContractDetailsBuilder`: `request_id()` is known before anything is sent, and `subscribe()` sends once (no retry) and returns a `Subscription<ContractDetails>`. Rows can be read as they arrive; dropping the subscription early sends the native `cancelContractData` (server 215+), and rows TWS sends after that are discarded. See `examples/{sync,async}/contract_details_stream.rs` (#876).
- Preset attached orders: `OrderBuilder::preset_stop_loss()` / `preset_profit_taker()` ask TWS to attach a stop-loss / profit-taker priced from its order presets, and `AttachedOrdersBuilder::submit()` returns the parent and child ids as `AttachedOrderIds`. The raw path is the new `Order` fields below. Requires server version 218; below it, placing such an order fails with `Error::ServerVersion`. If no preset is defined, TWS discards the parent as well (error 10355). See `examples/async/preset_attached_orders.rs` (#842).
- `Error::AccountUpdatesInUse { active, requested }`, returned by `account_updates` (#847).
- `Error::OrderIdInRequestRange { order_id }`, returned by the order methods for an order id at or above 1,500,000,000, the range now reserved for request ids, whether you passed it, TWS returned it from `next_valid_order_id`, or a reconnect moved the client's next order id there (#789, #913).
- `Notice::is_client_synthesized()`: true for every notice rust-ibapi synthesizes (any negative code), which `category()` cannot tell apart from TWS errors (#906, #927).
- `From<market_data::historical::HistoricalParseError> for Error`, so `s.parse::<BarSize>()?` (and `Duration`, `WhatToShow`) works in a function returning `ibapi::Error` (#838).

### Changed

- `Client::exercise_options` (blocking and async) takes only the contract and returns `orders::ExerciseOptionsBuilder`: `.exercise(n)` or `.lapse(n)`, optional `.account(..)`, `.override_natural_action()` and `.manual_order_time(..)`, then `.submit()`. Invalid requests fail before an order id is used. Added `examples/async/options_exercise.rs`. See `docs/migration-5.0.md` §27 (#901).
- `order_builder::bracket_order` takes `BracketPrices { entry, take_profit, stop_loss }` instead of three `f64`s and returns `Result<Vec<Order>, ValidationError>`, rejecting a non-finite price or a take profit or stop loss on the wrong side of the entry. `BracketOrderBuilder::build()` is crate-private: its orders carried placeholder ids only `submit_all()` fills in. See `docs/migration-5.0.md` §26 (#901).
- `order_builder::combo_limit_order`, `combo_market_order`, `limit_order_for_combo_with_leg_prices`, `relative_limit_combo` and `relative_market_combo` drop their `non_guaranteed: bool` argument; wrap the order in `non_guaranteed(..)` instead. See `docs/migration-5.0.md` §24 (#901).
- `OrderBuilder::build()` sends `limit_price`, `aux_price`, `trailing_percent`, `trail_stop_price` and `limit_price_offset` only for the order types that use them, so a field left by an earlier order-type setter (e.g. a trail percent before `.limit(..)`) no longer reaches the order. MIT, LIT, REL, PASSV REL and PEG MKT without a trigger or offset fail with `ValidationError::MissingRequiredField("aux_price")`. See `docs/migration-5.0.md` Behavioral changes (#901).
- `OrderBuilder::trailing_stop` and `trailing_stop_limit` take `orders::builder::TrailBy` (`Amount` or `Percent`) instead of an `f64` percent, so the fluent builder can trail by amount as well. Write `TrailBy::Percent(x)` for the old behavior. `OrderBuilder<'a, C>` becomes `OrderBuilder<T>` (`orders::ClientBound<'a, C>` from `Client::order`, `orders::Detached` from `Order::builder()`); only code that names the type changes. See `docs/migration-5.0.md` §22 (#901).
- `contracts::SpreadBuilder::add_leg` and `vertical` (and the new `long_calendar` / `short_calendar`) take `impl Into<ContractId>` instead of `i32`. Literals, `i32` and `ContractId` compile; a `.into()` / `.try_into()` / `.parse()` argument that relied on the `i32` parameter to pick its type now needs the type named. See `docs/migration-5.0.md` §21 (#901).
- Async client: order channels no longer follow `ClientBuilder::channel_capacity` down. `place_order`, `cancel_order`, `exercise_options` and `executions` subscriptions hold at least 1024 frames, and `order_update_stream` and the open/completed-order streams at least 8192, so a small capacity set for market data cannot make them drop order updates; a larger `channel_capacity` still raises them. A lag on an order channel now logs at `error!` and its `SUBSCRIPTION_LAG_CODE` notice says to resync with `open_orders()` / `executions()`. Shared channels are allocated on first use rather than at connect (#896).
- Sync client: the notice stream logs a backlog warning every 10,000 unread notices, like the subscription queues (#896).
- Async client: shutdown (`disconnect()` or dropping the `Client`) ends live subscriptions with `Err(Error::Shutdown)` before end-of-stream, as the blocking client does; they used to end with no error. See `docs/migration-5.0.md` (#895).
- A handshake frame whose message id maps to no known type raises `UNKNOWN_MESSAGE_TYPE_CODE` (-5), as it does after the handshake, instead of `HANDSHAKE_UNKNOWN_FRAME_CODE` (-3), so a desynchronized stream reads the same during a reconnect. `-3` still covers recognized kinds with no `StartupMessage` variant, and its text no longer carries the message id (#906, #927).
- `Notice::category()` reports request errors in the 200..=399 band that never answer an order (`REQUEST_ERROR_CODES`, e.g. 316 depth HALTED, 354 not subscribed, 366 no historical query) as the new `NoticeCategory::RequestError` instead of `OrderRejection`; they still end the request. `Notice::is_warning()` and `is_order_rejection()` follow `category()` instead of testing a code range, so `is_warning()` is false for 2188 and `is_order_rejection()` is false for 202, 317, warning-form 399 and the request errors. Added `Notice::is_request_error()`. See `docs/migration-5.0.md` §19 (#898, #897).
- `market_depth` subscriptions yield code 317 (depth book RESET) as `MarketDepths::Reset` instead of `SubscriptionItem::Notice`, so it survives `filter_data()` / `iter_data()`, which used to drop it and leave the caller merging rebuilt rows into a stale book. Exhaustive matches on `MarketDepths` need a `Reset` arm, which should empty the book. See `docs/migration-5.0.md` §18 (#899).
- `place_order`, `submit_order`, `cancel_order`, `order_builder::market_f_hedge`, `order_builder::bracket_order` and `OrderBuilder::parent` take `impl Into<OrderId>`, so the ids in `BracketOrderIds` and `AttachedOrderIds` pass straight in. `i32` values still compile; an argument converted with `.try_into()`, `.into()` or `.parse()` that relied on the old `i32` parameter to pick its type now needs the type named. `orders::OrderId` is unchanged, and is now also the type the client uses internally. See `docs/migration-5.0.md` §16.
- `Error::ProtobufDecode` carries the opaque `errors::ProtobufDecodeError` instead of `prost::DecodeError`, and `Error` no longer implements `From<prost::DecodeError>`, so prost upgrades no longer break `ibapi`'s API. `Display` is unchanged; `source()` is now `None`, since the decode detail is already in the message. See `docs/migration-5.0.md` §15 (#885).
- The `utoipa` feature is renamed `utoipa-6` and requires utoipa 6 (was 5). Public types derive utoipa 6's `ToSchema`, which a crate on utoipa 5 can't use in its `#[derive(OpenApi)]`; enable `utoipa-6` and upgrade to utoipa 6 (Rust 1.88+). A later utoipa major will be a new `utoipa-<major>` feature in a minor release; if several are enabled, the newest wins. See `docs/migration-5.0.md` §14 (#884).
- `contract_details` now collects over `contract_details_stream`. On server 215+ it writes no cancel after TWS's end marker, but does write `cancelContractData` when TWS answers with an error (e.g. 200), as dropping any subscription after an error does; the old path wrote neither. The guard that skips a cancel after the end marker sits in `Subscription` but changes no other existing stream (#876).
- `contract_details` skips a frame of a type the request doesn't expect (logged at `trace!`) rather than failing with `Error::UnexpectedResponse`; TWS warning notices on the request are logged at `warn!` (#876).
- `matching_symbols` docs describe its retry: on a connection reset it waits for the reconnect and retries with a fresh request id, up to 3 times (#876).
- `account_updates` for a second account while one is live returns `Error::AccountUpdatesInUse` and sends nothing. TWS keeps one account-updates stream per connection, so the request used to switch every live subscription to the new account's data, and since #836 a live subscription could keep receiving it after the request that switched it ended. The same account again still shares the stream; cancel every subscription to switch accounts (async: `cancel().await`, since a drop releases in a spawned task), or use `account_updates_multi` for several accounts at once (#847).
- `orders::Order` gains `preset_stop_loss_order_id` and `preset_profit_taker_order_id` (`Option<i32>`, default `None`), sent as `PlaceOrderRequest.attached_orders`. Exhaustive `Order` struct literals need the two fields. See `docs/migration-5.0.md` §6 (#842).
- `orders::OrderCondition` gains `Unknown(UnknownCondition)`: a condition type this crate does not model (IB leaves `2` unassigned) keeps its type code and every wire field instead of decoding as a zeroed `PriceCondition`, and goes back out unchanged, so an order read from TWS and placed again keeps its condition. Exhaustive matches need the new arm. See `docs/migration-5.0.md` §1 (#827).
- `contracts::SecurityIdType` gains `Unknown(String)`: an identifier scheme this crate does not model keeps its wire value instead of failing the `Contract` decode, which ended `order_update_stream` (and any other subscription carrying the contract). `SecurityIdType` is no longer `Copy` or `#[non_exhaustive]`, and `as_str()` returns `&str`. See `docs/migration-5.0.md` §7 (#840).
- An inbound order condition with no `type` fails to decode with `Error::Parse` instead of reading as a price condition; as with any decode error, the subscription that received the frame yields the error and ends (#827).
- `BracketOrderIds` converts from a `Vec<i32>` through `TryFrom` instead of `From`. The `From` impl panicked on any length but three; `try_from` returns `ValidationError::InvalidBracketOrder` instead. `From<[i32; 3]>` and `new` are unchanged. See `docs/migration-5.0.md` §12 (#868).
- `SecurityType` parses through `FromStr` (`s.parse()`): `Display`, `ToField` and a new `as_str()` come from one wire table, and an unrecognized non-empty value is still preserved as `Other(String)`, now without a log line. See `docs/migration-5.0.md` §10 (#855).
- A `ContractData` frame with no `contract` or no `contract_details` submessage fails to decode with `Error::Parse` naming the missing submessage, instead of yielding a default-constructed contract or details; the reference client (`EDecoder.cs`) drops such a frame. See `docs/migration-5.0.md` §10 (#855).
- A `Position`, `PositionMulti` or `PortfolioValue` frame with no `contract` submessage fails to decode with `Error::Parse` instead of decoding with `Contract::default()`; `positions`, `positions_multi` and `account_updates` yield the error and end. See `docs/migration-5.0.md` Behavioral changes (#863).
- Price, volume and percent-change conditions take `contract_id: impl Into<ContractId>` instead of `i32` / `impl Into<i32>`, in the `orders::builder::{price, volume, percent_change}` helpers and in `PriceCondition`, `VolumeCondition` and `PercentChangeCondition`'s `builder` and their builders' `new`. `impl Into<i32>` on `price` also accepted `OrderId`, `bool`, the narrower integer types and this crate's order enums (`TriggerMethod`, `OcaType`, …), none of which is a contract id; those no longer compile. Integer literals, `i32` values and `ContractId` all still do. See `docs/migration-5.0.md` §5 (#845).

### Removed

- `contracts::SpreadBuilder::calendar`. It bought the near leg and sold the far one, so buying the combo was short the calendar. Use `long_calendar(near, far)` (sell near, buy far) or `short_calendar(near, far)` (the old legs). See `docs/migration-5.0.md` §29 (#935).
- `order_builder::peg_best_order`, `peg_best_up_to_mid_order` and `peg_mid_order`, and the `OrderBuilder` setters `compete_against_best_offset`, `mid_offset_at_whole` and `mid_offset_at_half`. Use `peg_best` / `peg_mid`. See `docs/migration-5.0.md` §28 (#936).
- `order_builder::stop_limit`, `limit_if_touched`, `relative_pegged_to_primary`, `trailing_stop_limit`, `pegged_to_stock`, `auction_pegged_to_stock` and `PeggedToBenchmark`. Each took same-typed prices in a different order from the fluent method of the same name, so a swap compiled. Use `Order::builder()`. See `docs/migration-5.0.md` §23 (#901).
- `order_builder::attach_adjustable_to_stop`, `attach_adjustable_to_stop_limit` and `attach_adjustable_to_trail`, replaced by `attach_adjustable_stop`; the trail unit is a `TrailBy` instead of a magic `0` / `100`. See `docs/migration-5.0.md` §25 (#901).
- `contracts::SpreadBuilder::iron_condor`. Its four same-typed contract ids compiled in any order. Use `.vertical(long_put, short_put).vertical(long_call, short_call)`. See `docs/migration-5.0.md` §21 (#901).
- `Client::next_request_id()` (blocking and async). It minted a request id no request used, and no API accepts a caller-chosen request id. Use `Subscription::request_id()`, `ContractDetailsBuilder::request_id()` or `TickSubscription::request_id()` for a live request's id. See `docs/migration-5.0.md` §20.
- `orders::order_builder::auction_limit`, which built the same `Order` as `limit_order` once 4.2 dropped its strategy parameter. Routing the contract to `BOX` is what makes it an auction order. Also `orders::builder::OrderType::AuctionLimit` and `AuctionRelative`, which sent `LMT` and `REL` just like `Limit` and `Relative`. See `docs/migration-5.0.md` §17 (#903).
- `orders::builder::OrderAnalysis`. No API produced or accepted it: `OrderBuilder::analyze()` has returned `OrderState` since the builder was added (#311). Use `OrderState`. See `docs/migration-5.0.md` §13 (#883).
- `From<i32> for OrderCondition`, which built a default-valued condition from a type code and panicked on any other. Use the condition builders. Also `ToField for OrderCondition` / `ToField for Option<OrderCondition>`, text-wire leftovers with no caller. See `docs/migration-5.0.md` §1 (#827).
- `From<&str>` and `From<String>` for `market_data::historical::BarSize`, `Duration` and `WhatToShow`. Each called `from_str(..).unwrap()`, so an unrecognized string panicked through an infallible conversion. Use `s.parse()?` (the `FromStr` impls are unchanged). See `docs/migration-5.0.md` §2 (#838).
- `market_data::realtime::Trade::tick_type`. It held the wire code `"1"` / `"2"` and was constant per stream; the method that opened the stream (`tick_by_tick(..).last()` / `.all_last()`) names the feed. See `docs/migration-5.0.md` §3 (#844).
- The blocking client's `SharesChannel` marker trait (`ibapi::subscriptions::SharesChannel`, `ibapi::client::blocking::SharesChannel`). It was inert: the only bound naming it was on a crate-private helper whose single caller already satisfied it. Its real function was to flag shared-channel subscriptions in rustdoc, and it marked `positions` and `news_bulletins` but not `account_updates` or the `open_orders` family, so an unmarked type read as safe for concurrent use when it was not. The hazards it could have flagged are gone with the shared-stream fixes in this release; what remains applies to both clients. Delete any bound or impl on it. See `docs/migration-5.0.md` §4 (#836).
- `ibapi::parser_registry` (a `#[doc(hidden)]` re-export of `messages::parser_registry`), with the `record_interactions` example and the `tws_interactions.yaml` it produced. The registry mapped message types to field indices of the text protocol the crate stopped supporting in #452; the example could no longer produce output. There is no replacement: `IBAPI_RECORDING_DIR` and `IBAPI_RAW_CAPTURE_DIR` are the supported ways to capture traffic. See `docs/migration-5.0.md` §8 (#854).
- The `trace` module (`Interaction`, `last_interaction`, `record_request`, `record_response`, `trace::blocking`) and the three `trace_test` examples. The client has not called `record_request` since the transport went protobuf-only (#452), so `last_interaction()` always returned `None`. Capture traffic with `IBAPI_RECORDING_DIR` / `IBAPI_RAW_CAPTURE_DIR`. See `docs/migration-5.0.md` §9 (#862).
- `SecurityType::from(&str)`. Use `str::parse` / `FromStr`; see `docs/migration-5.0.md` §10 (#855).
- `market_data::historical::WhatToShowParseError`. No API returns it, so no caller can hold one; `WhatToShow`'s `FromStr` error is `HistoricalParseError`, unchanged. See `docs/migration-5.0.md` §11 (#864).
- `market_data::realtime::BarSize` and the prelude alias `RealtimeBarSize`. No API has accepted it since 3.0 dropped the `realtime_bars` argument; real-time bars are always 5 seconds. `historical::BarSize` is unchanged. See `docs/migration-5.0.md` §11 (#864).

### Fixed

- `OptionComputation` values that TWS has not computed yet are `None`, from `calculate_option_price`, `calculate_implied_volatility` and `TickTypes::OptionComputation` ticks. Previously they were returned as TWS's sentinels, `Some(-1.0)` / `Some(-2.0)`. See `docs/migration-5.0.md` Behavioral changes (#941).
- `OrderBuilder` sent a stop price left by an earlier `.stop(..)`, `.stop_limit(..)` or `.stop_with_protection(..)` as `aux_price` on any non-stop order type, replacing a trigger price, offset or trail amount (e.g. `.stop(100.0).market_if_touched(105.0)` sent 100). Only stop, stop-limit and stop-with-protection orders read it now; `order_type(OrderType::StopWithProtection)` without a stop price fails with `MissingRequiredField("stop_price")` (#901).
- Blocking client: `order_update_stream()` can be called again right after the previous stream is dropped. It returned `Error::AlreadySubscribed` until the cleanup thread had processed the drop, so the retry noted in 4.0's #778 entry is no longer needed (#893).
- `Subscription::cancel()` ends the subscription on both clients, whether or not a cancel is written to TWS. When none was written (order subscriptions such as `place_order`, `order_update_stream`, types without a TWS cancel such as `executions` and `option_chain`, or a stream that had already ended or finished its snapshot), the subscription kept receiving until dropped. Blocking: it now yields what was queued, then `Err(Error::Cancelled)`, then ends, and a cancelled `order_update_stream` frees the slot for a new one. Async: every clone yields `Err(Error::Cancelled)` at its next poll, then ends. Cancelling an order subscription does not cancel the order; use `cancel_order` (#911).
- `TickSubscription::cancel()` after the last batch writes no `cancelHistoricalTicks`, as documented, on both clients; only `Drop` skipped it (#911).
- Error frames no longer go to the wrong place when a request id and an order id are the same number. Request ids and order ids shared one range (request ids started at 9000, order ids grow from the account's `nextValidId`), and an error frame carries one id, so an order's cancellation (202) or rejection (201) could reach an unrelated data subscription instead of the order, and a late error for a finished request could appear on the order-update stream. Request ids now start at 1,500,000,000 and every order id must be below that: `place_order`, `submit_order` and `cancel_order` return `Error::OrderIdInRequestRange` for an order id at or above it (including `parent_id` and the preset attached-order ids), as do `exercise_options` and `next_valid_order_id` when the next order id reaches it, and a connection whose `nextValidId` is already there fails with `Error::ConnectionRejected`. Frames are routed by which range their id falls in. See `docs/migration-5.0.md` (#789).
- Blocking client: a frame that straddled the 1 s socket read timeout lost the bytes already read, and every later frame was misread with no error (wrong prices, as in #891). Reads now keep partial progress across the timeout. A frame stalled more than 30 s after its first byte fails with `Error::InvalidFrame` and the client reconnects (#892).
- Dropping an order or `executions` subscription releases the execution-id entries that route commission reports to it. They used to stay until reconnect or shutdown, one per fill, each holding the dropped subscription's channel open (#880).
- Cloning an `Error::ParseTime` keeps the variant and its payload; it used to become an `Error::Simple` with the same message (#885).
- Scanner subscriptions keep receiving after IB 165 messages containing "no items retrieved". Both clients surface the message as a nonterminal notice without synthesizing an empty batch; actual empty `ScannerData` responses still produce empty batches. Other scanner errors and 165 on other request types remain terminal (#886).
- Bond contract details: `contract_details` for a bond returned an empty `Vec`. TWS answers bond queries with `BondContractData` (msg 18), which was never routed to the request, so every row was dropped. It is now routed and decoded, and, as in the C# client, a bond's `maturity`, `last_trade_time` and `time_zone_id` are filled from `last_trade_date_or_contract_month` when TWS sends it (#876).
- Gateway time zone names resolve by exact match. The lookup was a substring match that took whichever containing zone hashed first, so `PST` resolved to `PST8PDT`, `MST` to either `MST` or `MST7MDT`, and a partial name to an unrelated zone. The US abbreviations `EST`/`EDT`, `CST`/`CDT`, `MST`/`MDT` and `PST`/`PDT` now map to `America/New_York`, `America/Chicago`, `America/Denver` and `America/Los_Angeles`, so `Client::time_zone()` names the same DST-observing zone whichever season the client connected in (it was fixed-offset `EST` for a winter connection). A name that only partially matches a zone is now unrecognized; map it with `register_timezone_alias` or `IBAPI_TIMEZONE_ALIASES` (#809).
- An unrecognized gateway time zone no longer fails `Client::connect`. The zone only feeds `Client::time_zone()` and `Client::connection_time()`, which are now `None`, and the connect logs a warning naming the zone and how to map it. Historical data (`HistoricalDataEnd`, `Schedule`) still fails with `Error::UnsupportedTimeZone`, since its instants cannot be resolved without the zone (#809).
- Dropping one of several live subscriptions to the same shared stream (`positions`, `account_updates`, `news_bulletins`) no longer cancels the stream at TWS for the others. TWS keeps one such stream per client and its cancel carries no id, so both clients now count live subscriptions per request type and send the cancel only when the last one ends (#836).
- An async subscription can be dropped on any thread, including one with no Tokio runtime: its cancel is sent on the client's runtime. Historical-ticks subscriptions (`TickSubscription`) used to panic when dropped there (#836, #848).
- On the blocking client, every live subscription to a shared stream (`positions`, `account_updates`, `news_bulletins`, `open_orders`, ...) now receives every response, as on the async client. Previously subscriptions of the same type read one shared queue, so each response reached whichever of them read it first and the stream was split between them at random. A subscription could also start with items left over from before it was made: responses to an earlier request of the type, or a `ConnectionReset` from a socket drop while nothing was subscribed. Two concurrent one-shot calls of the same type (`server_time`, `managed_accounts`) each receive the reply; same-type calls with different arguments, such as `request_fa`, still cannot tell the replies apart. A shared subscription that was cancelled or failed by a reconnect no longer keeps collecting frames until its handle is dropped (#836).
- Log severity for unrouted notices and for notices received during the connection handshake is derived from `Notice::category()` instead of `is_warning()` plus per-code checks. Data advisories (317, 2188, 10xxx) log at `warn` on both paths; they logged at `error` or `warn` depending on the numeric band. Cancellation confirmations (202) log at `info` instead of `error`. Data-farm inactive/connecting notices (2107, 2108, 2119), routine on connect, log at `info` instead of `warn`. The handshake path now grades like the unrouted path: other warning-band notices, code 0 and 1101 at `warn`, 1100 and 1300 at `error`, where it logged all of them at `info` (#846).
- Blocking client: shutdown (`disconnect()`, drop, or a reconnect that gives up) now ends a live `order_update_stream` with `Err(Error::Shutdown)`; a reader blocked in `next()` / `iter_data()` used to wait forever. After shutdown, `order_update_stream()` returns `Error::Shutdown` on both clients, and `notice_stream()` is already ended, instead of returning a stream nothing would end. Async client: shutdown now ends live `notice_stream`s (including the one from `connect_with_notice_stream`), as documented, and dropping the `Client` now ends live subscriptions such as `order_update_stream`; both used to wait forever (#871).
- Async client: dropping the `Client` or calling `disconnect()` now also ends a `place_order` or `executions` subscription that has received an execution. The execution-id mapping kept for commission reports held a second handle to its channel, so the subscription stayed open and a reader in `next()` waited forever; the blocking client already cleared that mapping on shutdown (#874).
- Async client: a request or order abandoned while its write is still pending (its future dropped, e.g. by a `timeout` or a losing `select!` branch) no longer leaves its channel registered for the rest of the session. A failed write already released it (#875).
- Async client: a write cut off mid-frame (its future dropped by a `timeout` or a losing `select!` once part of the frame was sent, or failed partway) now resets the connection, and the client reconnects. The session's later requests used to be appended to the fragment, so TWS misread them. A write abandoned before any byte was sent still sends nothing (#881).

## [4.2.0] - 2026-09-21

### Added

- `orders::TimeInForce::GoodTillCrossing` (`GTX`), and `OrderBuilder::good_till_crossing()` / `OrderBuilder::day_till_canceled()` so GTX and DTC are reachable without `time_in_force()` (#822).
- `orders::TimeInForce::Unknown(String)`: a time-in-force string this crate does not model now keeps its raw wire value instead of decoding as `Day`, and goes back out unchanged, so an order read from TWS round-trips. `TimeInForce` also gains `as_str()` and `FromStr` (#822).
- `OrderBuilder` setters for the integer-coded order enums that had no builder entry point: `trigger_method(TriggerMethod)`, `origin(OrderOrigin)`, `short_sale_slot(ShortSaleSlot)`, `designated_location(..)`, `volatility_type(VolatilityType)` and `reference_price_type(ReferencePriceType)`. `Order::trigger_method`, `origin`, `short_sale_slot`, `designated_location`, `volatility_type` and `reference_price_type` were public and reached the wire, but the fluent builder could not set any of them — the only path was constructing `Order` by hand, which is the surface the builder exists to replace. `volatility_type` had a private builder field whose value `build()` applied only when `volatility()` had also been called; the field had no setter, so it was always `None` and the guard never ran. The new setter is applied unconditionally (#832).
- `OrderBuilder::rule_80_a(Rule80A)` and `OrderBuilder::open_close(OrderOpenClose)`, the two string-typed order enums the #832 audit left out. Both are public `Order` fields that reach TWS (`Order.proto` `rule80A = 29` / `openClose = 68`) and had no builder entry point. That closes the audit: all ten enum-typed `Order` fields are now reachable from the builder — seven through same-named setters, `oca_type` through `.oca_group(group, OcaType)`, `action` through `.buy()` / `.sell()` / `.sell_short()` / `.sell_long()`, and `tif` through `.time_in_force()` and its named siblings (#833).

### Changed

- `orders::TimeInForce` variants are spelled "till", matching IB's `goodTillDate`, `Order.good_till_date` and the builder setters: `GoodTilCanceled` → `GoodTillCanceled`, `GoodTilDate` → `GoodTillDate`, `DayTilCanceled` → `DayTillCanceled`. Wire strings are unchanged (#822).
- `OrderBuilder::time_in_force` takes `orders::TimeInForce`; exhaustive matches on `orders::TimeInForce` need `GoodTillCrossing` and `Unknown(raw)` arms. See `docs/migration-4.0.md` §13 (#822).
- `OrderBuilder::good_till_cancel()` is renamed `good_till_canceled()`, matching the `TimeInForce::GoodTillCanceled` variant it sets and the neighbouring `day_till_canceled()` (#826).
- `OrderBuilder::oca_group` takes `orders::OcaType` instead of a bare `i32`: `.oca_group("MyOCA", 1)` becomes `.oca_group("MyOCA", OcaType::CancelWithBlock)`. The enum was public and the `Order` field already typed; only the setter still took the raw code. See `docs/migration-4.0.md` §15 (#832).
- `orders::order_builder::auction_limit` drops its `auction_strategy` parameter: `auction_limit(action, quantity, price)`. See `docs/migration-4.0.md` §15 (#832).
- `orders::TimeInForce` serializes as the TWS wire string (`"GTC"`) rather than the variant name (`"GoodTilCanceled"`), in both directions, and its `utoipa` schema is a plain string — matching `OrderStatusKind`. Stored JSON and downstream consumers that read the old variant names need updating (#822).
- Order enums no longer mishandle a value IB adds later: `Action`, `Rule80A` and `OrderOpenClose` parse through `FromStr` (`s.parse()`), and the last two gain `Unknown(String)` so an unrecognized inbound value is preserved instead of read as `None`; `Action` stays closed and rejects one with `Error::Parse` where it panicked. See `docs/migration-4.0.md` §14 (#825).
- `OcaType`, `OrderOrigin`, `ShortSaleSlot`, `VolatilityType`, `ReferencePriceType` and `TriggerMethod` gain `Unknown(i32)`; `From<i32>` preserves an unrecognized code instead of collapsing it to a known variant. Exhaustive matches need the new arm, and `variant as i32` becomes `i32::from(variant)`. See `docs/migration-4.0.md` §14 (#825).
- An inbound order with a missing or empty `action` fails to decode with `Error::Parse` instead of reading as `Buy`; as with any decode error, the subscription that received the frame yields the error and ends rather than skipping the frame (#825).
- An `OpenOrder`, `CompletedOrder` or `ExecutionDetails` frame whose `contract` / `order` / `order_state` / `execution` submessage is absent fails to decode with `Error::Parse` instead of yielding a default-constructed one. #825 made a present-but-empty `action` an error while this layer still handed back `Order::default()` — `action == Buy` — for a wholly absent `order`; the reference client drops such a frame outright (#829).

### Removed

- The async `Subscription::new(receiver)` constructor, which wrapped a channel of already-decoded items and had no cancel, no clone (it panicked), and no bus behind it. Every async `Subscription<T>` is now built by the typed `Client` methods and decodes through `T` directly, as the blocking one does, so `T` must be one of the crate's stream item types; `Subscription::clone()` no longer has a panicking path. See `docs/migration-4.0.md` §12 (#823).
- `orders::builder::TimeInForce`. Use `orders::TimeInForce`: `GoodTillCancel` → `GoodTillCanceled`, `OpeningAuction` → `OnOpen`, `GoodTillDate { date }` → `GoodTillDate` with the date passed to `.good_till_date(..)`; `GoodTillCrossing` and `DayTillCanceled` keep their names; see `docs/migration-4.0.md` §13 (#822).
- `Action::from(&str)`, `Rule80A::from(&str)` and `OrderOpenClose::from(&str)`. Use `str::parse` / `FromStr`; see `docs/migration-4.0.md` §14 (#825).
- `orders::AuctionStrategy` and `Order::auction_strategy`. TWS's protobuf `Order` message has no auction-strategy field — IBKR's `Order.proto` does not declare one and the reference client's `createOrderProto` never sets one — so at the protobuf floor (server version 213) the field was written by `auction_limit` and dropped by `encode_order`, and no decoder ever produced one. It was unreachable in both directions, as `TickEFP` was in 3.0. See `docs/migration-4.0.md` §15 (#832).
- `orders::builder::AuctionType` and its `to_strategy()`. A public enum with no caller anywhere in the crate, whose codes (`1` / `2` / `4`) match no TWS auction field; it read as the setter enum for the auction gap above and was neither (#832).
- `#[repr(i32)]` and the explicit discriminants on `Liquidity`, `OcaType`, `OrderOrigin`, `ShortSaleSlot`, `VolatilityType`, `ReferencePriceType` and `TriggerMethod`. Nothing read them once each enum gained an `Unknown(i32)` payload variant — `variant as i32` no longer compiles either way — so the hand-written `From<T> for i32` is now the only source of truth, and each variant's wire code is documented in its rustdoc (#829).

### Fixed

- `historical_data(..).between(start, end)` returns the requested range. It sent the wall-clock span as an `N S` duration, which IBKR counts in trading seconds, so a one-day range of hourly bars returned about 3.7 regular-hours sessions ending at `end`, and any range over one day was rejected with code 321 (`S` durations are capped at 86400). It now requests `S` up to one day, `N+1 D` sessions up to 365, and calendar `Y` beyond, then drops bars timestamped outside `[start, end)` and sets `HistoricalData::start` / `end` to the requested range (#835).
- `Order::rule_80_a` and `Order::open_close` carrying an `Unknown("")` are omitted from the wire instead of sent as an empty string, matching `action` / `tif` and the reference client's `if (!Util.StringIsEmpty(..))`. A decode cannot produce an empty `Unknown` (`FromStr` rejects it), but the new setters put one a single call away (#833).
- `GTX` was not modeled at all: an order built through `OrderBuilder` reached TWS as `DAY`, and a `GTX` order read back from TWS was reported as `TimeInForce::Day` on `open_orders()` and the order streams. Both directions now carry `GoodTillCrossing` (#822).
- In-flight subscriptions and one-shot requests now fail with `Error::ConnectionReset` the moment the socket read fails, on both clients. Previously the channel reset ran only after the reconnect finished (up to 7.5 minutes of backoff at the defaults, forever with `reconnect_forever`), and a request registered between the session being marked connected and that late reset was wiped, its replies orphaned (#816).
- Sends made while the session is down are refused with `Error::ConnectionReset` instead of being written to the socket the reconnect is replacing, and `is_connected()` reports false until the replayed handshake completes (#816).
- One-shot requests still recover across a TWS restart: the retry waits for the reconnect before resending, and gives up with `Error::Shutdown` if the session does not return (#816).
- `TRANSPORT_RECONNECT_CODE` is published once the reconnected session is live, so a resubscribe from its handler lands on the new session. On the blocking client, a resubscribe to a stream without a request id (for example `open_orders`) can still read a stale reset left on its shared channel as its first item (#816).

### Security

- `time` minimum raised to 0.3.47, so a stale downstream lockfile can no longer resolve a version affected by RUSTSEC-2026-0009 (denial of service via stack exhaustion) (#820).

## [4.1.0] - 2026-09-11

### Added

- `NOTICE_STREAM_LAG_CODE` (`-7`), a synthesized notice code delivered in-band on the async notice stream when its consumer fell behind the notice fan-out (fixed capacity 1024; `ClientBuilder::channel_capacity` does not reach it). The notice names the dropped count where the missed notices were previously skipped with only a `warn!`. Because the stream carries the connection-status notices (1100/1101/1102) a stateful consumer derives durable conclusions from, the consumer must resynchronize on this notice rather than resume — see the constant's docs. The notice-stream instance of `SUBSCRIPTION_LAG_CODE`, closing the step-1 leftover in `plans/broadcast-lag-visibility.md` (#779). The sync notice fan-out is unbounded and cannot lag (#813).
- `TRANSPORT_RECONNECT_CODE` (`-8`), a synthesized notice code published to the notice stream (sync and async) after the transport finishes reconnecting its socket to TWS/Gateway. The reconnect previously reached only live request/order/shared subscriptions (as `Error::ConnectionReset`); a consumer using the notice stream as its connection-state authority could hold a recorded 1100 across a successful reconnect forever, since TWS never frames the reconnect and does not replay 1101/1102 on the new connection. On receiving it, resubscribe and re-baseline connection state; see the constant's docs (#812).

### Changed

- `OrderUpdate` includes `OrderBound`, exposing the permanent order ID, API client ID, and API order ID reported by TWS. Exhaustive matches must handle this variant. Sync and async transports deliver these notifications only to the order-update stream, so bindings for another client cannot reach a subscription sharing its raw order ID; see `docs/migration-4.0.md` §11 (#814).

- `DATA_ADVISORY_CODES` is a `&[i32]` slice instead of a fixed-size array, so adding an advisory code is no longer a type change. Code binding the constant with an explicit array type, or iterating it by value, must adjust; see `docs/migration-4.0.md` §6 (#807).

- `WARNING_CODE_RANGE` widens from `2100..=2169` to `2100..=2199`: IB keeps adding warnings above the old ceiling (2176, 2187), and each one was a hard error that failed in-flight one-shots and ended subscriptions. Codes 2170–2199 now route as non-terminal notices and `Notice::category()` reports them as `Warning` (#805).

- `Notice::category()` resolves `DATA_ADVISORY_CODES` ahead of the warning and order-rejection bands instead of after them, so 2188 stays `DataAdvisory` inside the widened warning band (and 317 below categorises the same way). The range predicates are unchanged: `Notice::is_warning()` is now true for 2188 and `Notice::is_order_rejection()` remains true for 317 (#806).

### Fixed

- `CommissionReport` docs no longer claim it "may arrive in either order" relative to its `ExecutionData`. A live ES fill shows TWS sends the execution first and the commission a few frames later; the routing that keys commissions off the execution's `execution_id` mapping relies on exactly that order. The `Orders` stream decoder also dropped a dead arm that would have decoded a `CommissionsReport` frame as an open order (#788).

- A successful automatic reconnect now re-seeds the client's order-id generator from the `NextValidId` frame the reconnect handshake re-receives. Previously only the initial connection seeded the generator: every reconnect stored the fresh server floor in `ConnectionMetadata` and then discarded it, so after a TWS/IB Gateway restart the client could resume allocating below the server's counter and hit error 103. The re-seed is a monotonic raise and never lowers the generator below IDs allocated before the disconnect (#803).

- `next_valid_order_id()` no longer rewinds the order-id generator: the server's value is applied as a lower bound (`fetch_max`) instead of an overwrite, so an ID already allocated locally — including one whose order has not reached the server yet — is never reissued. Previously a response at or below the local counter, which is what the server returns whenever it has not yet seen the allocated IDs, made the next `next_order_id()` hand out a duplicate and TWS rejected the second order with error 103 (#802).

- Error 317 ("Market depth data has been RESET. Please empty deep book contents before applying any new entries.") is a data advisory: it is published as a non-terminal `SubscriptionItem::Notice` on the market-depth subscription instead of ending it, so the rows that rebuild the book still arrive. Consumers discard their book on this notice and apply the updates that follow. Its sibling 316 (market depth HALTED) remains terminal (#806).

- Error 10091 ("Part of requested market data requires additional subscription for API") is classified as a data advisory like 10089, 10090, and 10167: it is published as a non-terminal notice instead of ending the market-data subscription, so ticks that follow it — including delayed option computations — still arrive (#804).

- Disconnecting while the client is reconnecting no longer hangs: the reconnect backoff now observes the shutdown request and the dispatcher exits with `Error::Shutdown`. Previously the sync `Client::drop` / `disconnect()` blocked until every reconnect attempt was exhausted (forever with `reconnect_forever`), and the async dispatcher task leaked (#795).

- System messages (codes 1100, 1101, 1102, 1300) no longer fail every in-flight one-shot request. They are routed as non-terminal notices, like the order-cancellation confirmation (202); `Notice::is_warning()` and `Notice::category()` are unchanged for these codes (#800).

- `HistoricalDataEnd` start/end decoding accepts the zone-less UTC format (`YYYYMMDD-HH:MM:SS`) a gateway sends when its API date/time setting is "UTC format". Previously the string failed to parse and ended the historical-data subscription, so `keep_up_to_date` streams never saw their completion marker. Multi-word zone names in the instrument-timezone rendering (`20260101 09:30:00 China Standard Time`) now resolve as well; before, only the last word was looked up (#808).

## [4.0.1] - 2026-09-06

### Changed

- `TCP_NODELAY` is now on by default (`ClientBuilder::tcp_no_delay` defaults to `true`), matching the official IB clients. With Nagle's algorithm on, a request written while the previous one is still unacknowledged waits for that ACK — up to ~40 ms on Linux and ~200 ms on macOS against TWS's delayed ACK — so a burst of orders placed in quick succession was serialised on the ACK of each preceding one. A single request was never affected. Pass `.tcp_no_delay(false)` to restore the old behaviour.

## [4.0.0] - 2026-09-03

### Added

- The prelude now re-exports the account-parameter newtypes `AccountGroup`, `AccountId`, `ContractId`, and `ModelCode` (from `ibapi::accounts::types`). These are required arguments of `account_summary`, `account_updates`, `pnl`, `pnl_single`, and `positions_multi`, but were the only such parameter types missing from `ibapi::prelude` — the quick-start example could not be written with prelude-only imports.

- `SUBSCRIPTION_LAG_CODE` (`-6`), a synthesized notice code delivered **in-band** on an async subscription whose consumer fell behind its broadcast channel: the channel evicts the oldest frames, and the subscription now receives a non-terminal `SubscriptionItem::Notice` naming the dropped count (plus a `warn!`) where it previously resumed silently — the frames were simply gone with no signal at any level. Reconcile as after a reconnect gap (order streams: `open_orders()`; market data self-corrects with the next tick). Step 1 of the #779 plan (`plans/broadcast-lag-visibility.md`): loss is now observable; the bounded-lossy semantic itself is unchanged (#779).

- `ClientBuilder::channel_capacity` (async only), setting the per-subscription broadcast channel capacity (default unchanged: 1024). Raise it if consumers legitimately fall behind during bursts; `0` is rejected as `Error::InvalidArgument`. The notice fan-out keeps the default (#779).

- `ClientBuilder::max_reconnect_attempts` and `ClientBuilder::reconnect_forever` (sync and async), configuring how many times the automatic reconnect loop retries before giving up. The default is unchanged: 20 attempts, ~7.5 minutes with the capped Fibonacci backoff — too short to span a TWS/IB Gateway nightly restart that needs a manual re-login, which is what `reconnect_forever` (retry every 30 s until the connection returns) is for (#762, #763).

- `IBAPI_RAW_CAPTURE_DIR`, a byte-level tap on the inbound stream. Set it to a directory and every connection writes `<stamp>-<n>-inbound-<NNN>.bin`, a verbatim copy of what the socket delivered — **including the 4-byte length prefix** — alongside an `.idx` of `seq,utc_timestamp,offset,declared_length` per frame. `IBAPI_RECORDING_DIR` cannot substitute: it is handed an already-parsed message and re-frames it, so the prefix it writes is one this crate computed. That makes it blind to a framing desync, where the prefix is the corrupted field. The tap sits below validation, so a prefix rejected as `Error::InvalidFrame` still reaches the capture — that prefix is the evidence. A reconnect starts a new file, so no `.bin` splices two TCP streams. Because prefixes are preserved, a capture replays through the frame reader unchanged; `cargo run --example replay_raw_capture -- <file>.bin` walks one and reports the first frame that cannot be read. Captures are unredacted wire bytes, so they carry account ids, positions, and orders.

- `UNKNOWN_MESSAGE_TYPE_CODE` (`-5`), a synthesized notice code published to `Client::notice_stream` when a frame's message id maps to no known `IncomingMessages` kind. Joins the existing client-side sentinels (`HANDSHAKE_UNKNOWN_FRAME_CODE`, `HANDSHAKE_DECODE_FAILURE_CODE`); TWS itself only uses codes 0 and up. This is the observable form of a framing desynchronization. The notice text names the offending id, which is what separates the two explanations: a slipped stream yields scattered ids, while a message type IBKR has added repeats one.

- `Error::InvalidFrame`, returned when a frame's 4-byte length prefix cannot describe a TWS message — shorter than the message id, or larger than the 16 MiB cap the official client enforces as `Constants.MaxMsgSize`. Classified as `Error::is_connection_lost`, so both dispatchers reconnect: the framing is positional, with no delimiter to re-anchor on, and a reconnect is the only recovery. `Error` is `#[non_exhaustive]`, so the new variant is not a breaking change.

- `Error::UnexpectedWireFormat`, returned when a message arrives in the wrong wire format for the reader handling it — text framing at a proto-only decoder, or proto framing at a text-field accessor. Previously this shared `Error::UnexpectedResponse` with the unrelated "message is not for this decoder" case, which the dispatcher skips silently. `Error` is `#[non_exhaustive]`, so the new variant is not a breaking change (#731).

- `Notice.request_id`, the originating request or order id (`None` for request-less notices). `Notice` is not `#[non_exhaustive]`, so struct literals must add the field (#759).

- `ORDER_MESSAGE_CODE` (`399`), the generic order-message code whose message text decides severity (#759).

### Changed

- `Client::option_chain` returns a builder instead of taking `exchange` positionally: `client.option_chain(symbol, security_type, contract_id)` with an optional `.exchange(..)` setter and a `.subscribe()` terminal. The old signature's `exchange: &str` documented `""` as "all exchanges", and live checks against TWS show that is the only value that returns anything for a stock underlying — the field is TWS's `futFopExchange`, a futures-options filter, and `"SMART"` (which the async example passed) yields an empty chain. `contract_id` stays required: `0` (which two examples passed) is rejected with code 321 "Invalid contract id". An unset exchange is now omitted from the wire rather than sent as an empty string, matching the official client. See `docs/migration-4.0.md` §10.

- Sync transport: a stalled consumer now triggers `warn!` watermarks at every 10,000 queued messages on the subscription, shared-channel, and order-update send paths. Sync channels are unbounded and never drop; their failure mode — silent memory growth — is now loud. The notice fan-out (`NoticeBroadcaster`) is excluded for now; see `plans/broadcast-lag-visibility.md` (#779).

- `OrderStatusKind` gains an `Unknown(String)` variant preserving unrecognized status strings (see the Fixed entry below). Breaking: the enum stays deliberately exhaustive like `Liquidity::Unknown` (#760), so downstream exhaustive matches need a new arm; `Copy` is gone (still `Clone`); `as_str()` returns `&str`; `is_active()` / `is_terminal()` take `&self` and are `false` for `Unknown`. Serialization stays a plain string in both directions, and the `utoipa` schema is now a plain `string` to match. Details and examples in `docs/migration-4.0.md` §9 (#774).

- `MarketDataBuilder` moved from `market_data::builder` to `market_data::realtime`, alongside the sibling builders (`RealtimeBarsBuilder`, `MarketDepthBuilder`, `TickByTickBuilder`) it always belonged with; the `market_data::builder` module is gone. `MarketDataBuilder::new` is also `pub(crate)` now, matching its siblings — construct via `client.market_data(&contract)`. Only code naming the type — imports, signatures, or a direct `new` call — breaks; `client.market_data(&contract)` call sites are unaffected. The prelude now exports all four realtime builders (`MarketDataBuilder` and `RealtimeBarsBuilder` join the two already there). See `docs/migration-4.0.md` §7 (#772).

- A text-framed message reaching a proto-only decoder now fails the subscription instead of being skipped. At `server_versions::PROTOBUF_REST_MESSAGES_3` every message with a proto decoder arrives proto-framed, so reaching this means the gateway broke protocol — previously the message was dropped and the subscription silently yielded nothing. Wrong-message-type frames are still skipped, which is what shared channels need (#731).
- Historical tick and histogram sizes are now `Option<f64>` instead of `i32`: `TickMidpoint.size`, `TickLast.size`, `TickBidAsk.size_bid`/`size_ask`, and `HistogramEntry.size`. IBKR models these as decimals on the wire, and the old `i32` parse silently truncated fractional sizes — a crypto tick of `0.5` decoded as `0`. `None` means TWS sent no value (field absent, empty, or an "unset" sentinel); `Some(0.0)` is a real zero. This also changes the serialized shape — a size is now `100.0` rather than `100`, absent is `null` rather than `0`, and the `utoipa` schema becomes a nullable `number` (#716).
- `ContractDetails.min_size`, `size_increment`, and `suggested_size_increment` are now `Option<f64>` instead of `f64`. Contracts without size rules omit these on the wire, where the old `0.0` was indistinguishable from a real value and a `size_increment` of `0.0` is nonsense (#716).
- Every one-shot request now narrows the inbound frame to the message type it asked for before decoding it. Narrowing used to be opt-in — each domain hand-wrote a `decode_*_message` wrapper for it, and 26 of the 50 sites had no wrapper, decoding whatever arrived on the shared channel. A foreign frame now surfaces as `Error::UnexpectedResponse` naming both the expected and received type, rather than being fed to the wrong payload decoder — where overlapping protobuf field numbers usually produce a plausible struct full of wrong values instead of an error. Affects `next_valid_order_id` in particular, which reads the shared `RequestIds` channel (#738).
- `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 request. They previously surfaced `Error::ConnectionReset` to the caller on the first reset — a gateway reconnect mid-request failed the call outright. Nothing about these five made them unsafe to retry; they were the sites that had picked a helper without retry, `market_rule` and `family_codes` because the only helper bundling a server-version check was the non-retrying one (#741).
- `head_timestamp`, `histogram_data`, `market_depth_exchanges`, and `historical_schedules(..).fetch()` retry a connection reset at most three times instead of unboundedly. The async `head_timestamp` recursed on a closed stream and the async `histogram_data`, `market_depth_exchanges`, and schedule fetch looped on one; a gateway that keeps resetting would hang the call rather than return. They also now agree with their blocking twins on what a closed stream means: `Error::UnexpectedEndOfStream` for `head_timestamp` and the schedule fetch, an empty list for `histogram_data` and `market_depth_exchanges` (#738).

- `Liquidity` now preserves unrecognized execution liquidity codes as a new `Unknown(i32)` variant instead of collapsing them to `Liquidity::None`. The documented codes 0–3 decode as before; any other value surfaces as `Unknown(code)` so callers can log, store, or reject it — previously an unknown code was indistinguishable from a genuine "no liquidity information", which is silent data loss (the official C# client has the same coercion). No official client or `Execution.proto` defines a code outside 0–3 today, so this is forward compatibility. `Liquidity` is deliberately exhaustive (no `#[non_exhaustive]`), so downstream exhaustive matches need a new arm — breaking (#760).

- `Client::wsh_event_data_by_contract` and `Client::wsh_event_data_by_filter` return builders instead of taking optional arguments positionally. The first took one required argument and four `Option`s, the second one and two; every call site in this repository passed `None` for all of them. Narrowing is now `.starting(date)` / `.ending(date)` / `.limit(n)` / `.auto_fill(spec)`, with `.fetch()` (single result) and `.subscribe()` (subscription) as the terminals. See `docs/migration-4.0.md` §3 (#752).

- Code 399 order messages whose text carries a `Warning:` line now classify as warnings (`Notice::is_warning`, `NoticeCategory::Warning`) and route as non-terminal notices. TWS uses 399 for both severities — e.g. "order will not be placed at the exchange until …" for outside-RTH stops — and the warning form previously terminated the owning subscription as an order rejection (#759).

- The order-update stream delivers order-bound error frames as `SubscriptionItem::Notice` (carrying `request_id`, code, and message) instead of raw error frames that `OrderUpdate` could not decode — consumers previously saw `Err(Error::UnexpectedResponse)`. 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 (#759).

### Removed

- The free function `market_data::realtime::sync::market_data()` is crate-private, matching the `realtime_bars` / `market_depth` / `tick_by_tick` free functions beside it (in sync-only builds it was also reachable as `market_data::realtime::market_data` through the glob re-export). It was the low-level request under the `client.market_data(&contract)` builder, which is the supported path and takes the same inputs via setters; no call site in `examples/` or the integration crates used the free function. See `docs/migration-4.0.md` §8 (#780).

- `Client::check_server_version()` is crate-private. It was `pub` on the async client and `pub(crate)` on the blocking one — drift rather than design, since it is the internal guard every version-gated API already calls. Compare against `Client::server_version()` directly if you need to branch on gateway support (#728).

### Fixed

- `ComboLegOpenClose::from(i32)` no longer panics on a value outside `0..=3`; unrecognised values decode to `ComboLegOpenClose::Unknown`. The `From<i32>` impl runs inside contract-details decoding, so an unexpected `openClose` from TWS took the dispatcher down instead of surfacing as an `Unknown` leg.

- `HistoricalDataEnd` and historical-schedule decoding no longer panics when a wall-clock time from TWS falls in a DST fold or gap, and the connection time reported at handshake is no longer dropped for a fold. A reading in a repeated hour resolves to its earlier occurrence (the pre-transition offset). A reading in a skipped hour never showed on a clock, so it can only come from TWS doing date arithmetic on wall-clock time — a window start computed as "end minus 300 days", say — and is pushed forward by the gap: 02:30 on a US spring-forward day becomes 03:30 EDT. Previously a 300-day `historical_data()` request whose start edge landed on a fall-back night panicked with `OffsetResult::unwrap` (#790).

- An `OrderStatus` or `OpenOrder` frame carrying an unrecognized status string no longer terminates the subscription delivering it. Decoding failed with `Error::Parse`, which ended `place_order`, `cancel_order`, `open_orders`, and — worst — the long-lived `order_update_stream` the moment TWS shipped a status this crate had not modeled; the official IB client parses such statuses into an `Unknown` fallback instead of failing. The status now arrives as `OrderStatusKind::Unknown(raw)` and the stream continues (#774).

- Async `open_orders()`, `all_open_orders()`, `completed_orders()`, and the other streaming shared-channel subscriptions (positions, account data, news bulletins) no longer hang forever when a reconnect happens mid-request. After a reconnect, `reset_channels` notified request-id and order-id subscriptions with `Error::ConnectionReset` but left shared channels untouched — and the end marker such a subscription awaits is a response to a request sent on the dead connection, so nothing ever arrived. The async reset now notifies shared-channel senders too, mirroring the sync transport (which already did this). Subscriptions created after the reset are unaffected: they subscribe at the channel's current tail and never see the injected error (#776).

- Dropping a subscription no longer unregisters a newer subscription under the same key. Drop-signal cleanup crosses a channel to a separate task/thread, so it can run arbitrarily late — after `place_order` then `cancel_order` re-registered the same order id, or after the order-update stream was recreated — and removal was unconditional, silently disconnecting the live replacement (data and error frames for that order went nowhere). The async cleanup now removes a registration only when its channel has no receivers left (a dropping subscription detaches its receivers first, so the count is authoritative); the sync drop signal now carries the dropped subscription's sender and cleanup removes only the matching registration. Dropping a clone while a sibling is still consuming no longer unregisters the shared channel either (#773).

- `order_update_stream()` can be dropped and immediately recreated. Recreation returned `Error::AlreadySubscribed` until the asynchronous cleanup ran, and a stale cleanup signal could then clear the replacement's registration, after which the new stream silently received nothing. The async transport now replaces a registration whose channel has no receivers instead of refusing, and stale signals cannot clear a live replacement on either transport. Sync note: recreation still returns `AlreadySubscribed` for the instant between drop and the cleanup thread running (crossbeam senders expose no receiver count); retry on `AlreadySubscribed` there (#778).

- The order cancellation confirmation (code 202) is routed to request-bound subscriptions as a non-terminal `SubscriptionItem::Notice` instead of a stream-terminating `Err`. Successfully cancelling an order ended the `cancel_order` (or `place_order`) subscription with an error whose payload the crate itself classifies as informational (`Notice::is_cancellation()`, `NoticeCategory::Cancellation`), while the order-update stream received the identical frame as a non-terminal notice. Routing now agrees with that classification. Behavior note: the 202-`Err` was also the only thing that ended a `cancel_order` subscription; it now stays open until dropped — consume it like the order-update stream and break on `notice.is_cancellation()` (#775).

- `FibonacciBackoff` no longer overflows `u64` on the 93rd consecutive `next_delay()` call. The backoff state kept growing past `max` (only the returned delay was capped), so a reconnect loop with a large `max_reconnect_attempts` or `reconnect_forever` would panic with `attempt to add with overflow` in debug builds, or wrap to nonsense delays in release, once an outage outlasted ~92 attempts. The state is now clamped at `max`. Returned delays are unchanged.

- Notice 2188 ("Up-to-the-second historical data requires additional subscription for the API.") is classified as a data advisory instead of a hard error. TWS sends it and then delivers the historical bars anyway — the account merely lacks the up-to-the-second tail — but `historical_data()` returned `Err` on the notice and discarded the bars that followed, so accounts without the real-time entitlement for a listing got no history at all for those symbols. `DATA_ADVISORY_CODES` widens from `[i32; 2]` to `[i32; 3]`, which is breaking only for code binding the const with an explicit array type (#765).

- Error 10090 ("Part of requested market data is not subscribed. Subscription-independent ticks are still active") is classified as a data advisory like 10089 and 10167: it is published as a non-terminal notice instead of ending the market-data subscription. TWS sends it on partial entitlement — commonly an options subscription without the underlying — and keeps delivering the ticks the account is entitled to, but the subscription was torn down before they could arrive. `DATA_ADVISORY_CODES` widens again to `[i32; 4]` (#768).

- A notice whose protobuf `error_code` field is absent no longer fails every in-flight one-shot shared request (`server_time`, `managed_accounts`, `next_valid_order_id`, ...). The absent field decodes to code 0 — outside every warning range — so IB Gateway's code-less informational notices (e.g. "Warning: Approaching max rate of 50 messages per second (42)") were treated as request-less hard errors. Code 0 now classifies as a warning throughout: request-less frames reach `Client::notice_stream` without failing anything, frames carrying a request id are delivered to that subscription as a notice instead of terminating it, and `Notice::is_warning()` / `Notice::category()` agree with the routing. An error frame that fails proto decode falls back to the same code-0 path and now logs a warning naming the byte length (#766).

- The frame reader now validates the 4-byte length prefix before using it, instead of trusting it to size an allocation and a `read_exact`. Nothing bounded it: four garbage bytes were read as a body length of up to 4 GiB, which allocated that much and then blocked until that many bytes arrived — consuming and destroying every real message in between, then yielding one bogus frame with the stream left permanently mis-framed. Because the framing is positional, nothing re-synchronizes it, and a mis-framed protobuf payload still decodes without error (prost skips unrecognized field numbers), so the visible symptom was plausible-looking wrong field values that never recovered. Out-of-range prefixes now raise `Error::InvalidFrame` and drive a reconnect. The cap matches the official client's `Constants.MaxMsgSize` (`EReader.readSingleMessage`, which raises `BAD_LENGTH`).

- A frame that no channel claims is now reported instead of dropped. An unrecognized message id raises a `UNKNOWN_MESSAGE_TYPE_CODE` notice and a warning; a known type with no current subscriber stays at `info`, as before, since that is ordinary steady state. Previously the blocking client logged every such frame at `info` without distinguishing the two, and the async client logged nothing at all — so a desynchronized stream was indistinguishable from an idle one, which is why the incident that prompted this work surfaced data-farm notices and no decode error.

- A frame whose body is too short to hold the 4-byte message id no longer panics the dispatcher. `parse_raw_message` indexed the first four bytes unguarded, so a body of 0–3 bytes aborted with `index out of bounds` — killing the dispatcher thread on the blocking client and the dispatcher task on the async one. It returns `Error::InvalidFrame` now.

- `IBAPI_RECORDING_DIR` pointing at an unwritable path no longer panics during `Client::connect`. Creating the recording directory was `unwrap`ed, so a typo or a read-only mount aborted the process rather than the recording. It now warns and disables recording, matching `IBAPI_RAW_CAPTURE_DIR` — a diagnostic aid must not be the reason a connection fails.

- `IBAPI_RECORDING_DIR` records an unrecognized message id as itself rather than as `-1`. The recorder reconstructed the frame's id from the resolved `IncomingMessages` kind, and every unrecognized id resolves to `NotValid`, whose discriminant is `-1` — so the capture of a framing desync, which is exactly the capture worth replaying, carried a fabricated id where the offending one should have been. Recognized ids are unaffected: `IncomingMessages::from` maps a value to the variant with that discriminant, so the two agreed for every frame the client understands.

- `IBAPI_RECORDING_DIR` records the actual response payload again. Recording wrote a response's *parsed text fields*, and a protobuf frame has none — so since the protobuf-only transition every `NNNN-response.msg` held the bare message id and nothing else. Responses are now written as their wire frame (4-byte big-endian message id followed by the payload), matching what `record_request` already wrote for outbound messages. Text-framed responses keep their pipe-delimited form (#748).

- `historical_data(..).fetch()` on the async client now retries a connection reset instead of surfacing it, and no longer retries a closed stream. It had the two cases backwards relative to its blocking twin: a routed `Error::ConnectionReset` returned to the caller on the first occurrence, while an empty stream was re-sent five times before failing as `Error::ConnectionReset`. Both sides now share `retry_on_connection_reset` — up to three retries on a reset, `Error::UnexpectedEndOfStream` on a closed stream, on the first attempt. An error on the follow-on `HistoricalDataEnd` frame is also propagated rather than dropped, on both sides (#744).

- `OrderBuilder::analyze()` (what-if orders, blocking and async) now returns the TWS rejection instead of `Error::UnexpectedEndOfStream`. A rejected what-if order arrives as a routed error, which the response read discarded — the blocking path via `if let Ok(..)` inside the loop, the async path by ending its `while let Some(Ok(..))` loop — so the caller lost the reason (e.g. code 201, `Order rejected - reason:...`) and got a generic end-of-stream error. Rejection is a routine outcome for a what-if order, so this was the likeliest path to hit it (#735).

- Reconnection now survives the window where TWS accepts the TCP connection but its API handshake is not yet ready — common during an automated restart. A session-establishment failure (handshake, `startAPI`, account info) used to abort the reconnect loop on the first occurrence; it now consumes one attempt and follows the same fibonacci backoff as a socket failure, on both the blocking and async clients. When every attempt fails, `reconnect` returns the last attempt's error instead of a bare `Error::ConnectionFailed`, so a permanent cause — say, an incompatible server version — is named rather than hidden behind a generic failure (#761).

- `matching_symbols()` on the blocking client now returns the TWS error instead of an empty list. A routed error arrives as `Some(Err(_))`, which the `if let Some(Ok(_))` read discarded, so a rejected pattern silently returned `Ok(vec![])` — indistinguishable from "no symbols matched". The async client already propagated it (#735).

- `TickTypes::MarketDataType` now reaches `Client::market_data` subscriptions. The message type was missing from the request-id routing allow-list, so TWS's market-data-type notifications (real-time / frozen / delayed / delayed-frozen, sent on subscribe and whenever the feed switches) were routed to a shared channel nobody subscribes to and dropped. The decoder has produced the variant since #516; nothing could ever yield it (#730).

- Decimal-typed wire fields no longer fall back to `0` when the value fails to parse; a malformed value now surfaces as `Error::Parse` and fails the request or subscription instead of being silently swallowed. Covers order quantities, execution shares, positions, contract-detail sizes, bar volume/WAP, tick and market-depth sizes (#716).
- TWS "unset" sentinels (`2147483647`, `9223372036854775807`, `-9223372036854775808`, `1.7976931348623157E308`) are now recognized on those same fields rather than only `OrderState.suggested_size` and `OrderAllocation.*`. They decode to `None`, or to `0.0` on fields still typed `f64`, instead of leaking as a literal 2.1-billion size (#716).

## [3.3.0] - 2026-07-16

### Added

- `Client::config()` (sync + async) to read the TWS/Gateway configuration (API settings, order precautions, smart-routing, lock-and-exit) added upstream for server version 219 (TWS 10.43); returns the new `config::Config` snapshot type and is gated behind `server_versions::CONFIG`.
- `Client::update_config()` (sync + async) to write partial edits to the TWS/Gateway configuration via a fluent `UpdateConfigBuilder` (`.api()`/`.orders()`/`.lock_and_exit()`/`.message()`/`.messages()`/`.accept_warning()`/`.accept_warnings()`/`.reset_api_order_sequence()` → `.submit()`), added upstream for server version 221 (TWS 10.44); returns the new `config::UpdateConfigResponse` (with `config::ConfigWarning`) and is gated behind `server_versions::UPDATE_CONFIG`.
- `Order.deactivate` (`bool`) flagging a de-activated (inactive) order; since TWS 10.48 `reqOpenOrders` returns de-activated orders, so this distinguishes them from active ones. Round-trips through the order proto in both directions; no server-version gate (#709).
- `Order.hedge_max_size` (`Option<i32>`) for the maximum size of a hedge order, added upstream for server version 223 (TWS 10.45); gated behind `server_versions::HEDGE_MAX_SIZE` when placing orders (#706).
- Server-version support advertised through 225 (`ODD_LOT_BID_ASK_QUOTES`), with new `server_versions` constants `FRACTIONAL_LAST_SIZE` (222), `HEDGE_MAX_SIZE` (223), `USE_PRECISION_FROM_SEC_DEF` (224), and `ODD_LOT_BID_ASK_QUOTES` (225) (#706).
- `generic_tick::ODD_LOT` (`"787"`) request-side generic tick to subscribe odd-lot bid/ask quotes (server 225 / TWS 10.46) via `reqMktData` (#706).
- `BarSize::Min4` (`"4 mins"`) historical bar size, added upstream in TWS API v10.44 (#704).
- Odd-lot bid/ask `TickType` variants (`OddLotBid`, `OddLotAsk`, `OddLotBidSize`, `OddLotAskSize`, `OddLotBidExch`, `OddLotAskExch`, ids 105–110) so odd-lot market-data ticks (server 225 / TWS 10.46) decode to typed variants instead of `Unknown` (#703).

### Removed

- Fundamental data support: the `fundamental` module (`FundamentalData`, `FundamentalReportType`) and `Client::fundamental_data`. IBKR removed the fundamental-data feature (`reqFundamentalData`/`cancelFundamentalData`) from the TWS API in 10.47 with no replacement. The `TickType::FundamentalRatios` variant (tick id 47) is also removed; id 47 now decodes to `TickType::Unknown`.

## [3.2.1] - 2026-07-13

### Fixed

- Request-less TWS errors on shared one-shot requests (e.g. read-only-mode 321, unknown market rule 322) now fail the awaiting call fast with the real error instead of hanging; streaming shared subscriptions are unaffected (#698).

## [3.2.0] - 2026-07-06

### Added

- `AggTrades` variant (wire value `AGGTRADES`) on the historical and realtime `WhatToShow` enums, required to request trade bars for crypto contracts (TWS rejects `TRADES` with error 10299) (#693).
- `ConnectivityStatus` enum with `ConnectivityStatus::from_code()` and `Notice::connectivity_status()` to expose data-farm connectivity sub-states (Ok / Broken / Inactive / Connecting) within the 2100–2169 warning band (#684).
- `Error::is_connection_lost()` predicate so reconnect loops can branch on connection loss without matching internal error variants (#690).
- `Subscription::collect_for(timeout)` / `collect_until(timeout, predicate)` terminals and `MarketDataBuilder::snapshot_once(timeout)` to collect a one-shot snapshot into a `Vec` without hand-writing a collect-with-timeout loop (#686).

### Fixed

- Connectivity restored notices no longer log at `error`: code 1102 ("data maintained") now logs at `info` and code 1101 ("data lost — resubscribe") at `warn`, so routine overnight reconnects stop tripping error-level alerting (#695).
- Async snapshot market-data subscriptions no longer send a redundant cancel after the snapshot completes, matching the sync side (#686).

## [3.1.0] - 2026-06-19

### Added

- `DATA_ADVISORY_CODES`, `Notice::is_data_advisory()`, and the `NoticeCategory::DataAdvisory` variant for delayed-market-data advisory codes (#680).

### Changed

- Benign data-farm connectivity notices (codes 2104/2106/2158, "…connection is OK") now log at `info` instead of `warn`, removing warn-level spam on long-running sessions (#678).

### Fixed

- Delayed-data advisories (codes 10089/10167) no longer terminate a market-data subscription before its data arrives (#677).
- `TickSubscription` now carries the `SubscriptionItem` envelope (#675).

## Prior releases

Versions up to and including [3.0.1] predate this changelog; see the
[GitHub Releases page](https://github.com/wboayue/rust-ibapi/releases) for their notes.

[Unreleased]: https://github.com/wboayue/rust-ibapi/compare/v5.2.0...HEAD
[5.2.0]: https://github.com/wboayue/rust-ibapi/compare/v5.1.0...v5.2.0
[5.1.0]: https://github.com/wboayue/rust-ibapi/compare/v5.0.0...v5.1.0
[5.0.0]: https://github.com/wboayue/rust-ibapi/compare/v4.2.0...v5.0.0
[4.2.0]: https://github.com/wboayue/rust-ibapi/compare/v4.1.0...v4.2.0
[4.1.0]: https://github.com/wboayue/rust-ibapi/compare/v4.0.1...v4.1.0
[4.0.1]: https://github.com/wboayue/rust-ibapi/compare/v4.0.0...v4.0.1
[4.0.0]: https://github.com/wboayue/rust-ibapi/compare/v3.3.0...v4.0.0
[3.3.0]: https://github.com/wboayue/rust-ibapi/compare/v3.2.1...v3.3.0
[3.2.1]: https://github.com/wboayue/rust-ibapi/compare/v3.2.0...v3.2.1
[3.2.0]: https://github.com/wboayue/rust-ibapi/compare/v3.1.0...v3.2.0
[3.1.0]: https://github.com/wboayue/rust-ibapi/compare/v3.0.1...v3.1.0
[3.0.1]: https://github.com/wboayue/rust-ibapi/releases/tag/v3.0.1