Skip to main content

rustrade_execution/client/ibkr/
shortability.rs

1//! [`ShortabilityClient`] for IBKR: what IB says about lending a stock, read from market data.
2//!
3//! IB reports it as two ticks of a streaming market data request for generic tick 236: tick 46,
4//! a level saying how readily the stock can be borrowed, and tick 89, how many shares IB has to
5//! lend. A snapshot request cannot carry generic ticks, so the request streams until both have
6//! arrived or the wait runs out, and is then cancelled.
7//!
8//! The request asks for IB's delayed market data, which IB serves only where the account has no
9//! live subscription for the stock: an account with one still gets live data. An account with
10//! neither, such as a paper account without market data sharing, would otherwise get no ticks at
11//! all.
12
13use super::{IbkrClient, is_transient_transport_loss};
14use crate::{
15    client::ShortabilityClient,
16    error::{ApiError, ConnectivityError, UnindexedClientError},
17    shortability::Shortability,
18};
19use ibapi::{
20    Notice,
21    contracts::{SecurityType, tick_types::TickType},
22    market_data::{
23        MarketDataType,
24        realtime::{TickTypes, generic_tick},
25    },
26    subscriptions::SubscriptionItem,
27};
28use rust_decimal::Decimal;
29use rustrade_instrument::instrument::name::InstrumentNameExchange;
30use std::time::{Duration, Instant};
31use tracing::{debug, warn};
32
33/// How long [`IbkrClient::fetch_shortability`] waits for IB's shortability ticks, unless
34/// [`IbkrConfig::shortability_timeout`](super::IbkrConfig::shortability_timeout) says otherwise.
35pub const DEFAULT_SHORTABILITY_TIMEOUT: Duration = Duration::from_secs(10);
36
37/// How long [`IbkrClient::fetch_shortability`] waits for tick 89 once tick 46 has arrived without
38/// it.
39///
40/// For a stock IB has shares of, it sends both ticks together. For one it has few or none of, it
41/// can send tick 46 and no tick 89 at all, and the read would otherwise wait out the whole
42/// timeout for it. The grace still takes a tick 89 that IB sends just after tick 46.
43///
44/// Measured on a paper account in regular trading hours: a stock IB had shares of got both ticks
45/// in the same message, and stocks at level 2.0 got no tick 89 within a minute. With no gap to
46/// scale from, the grace is a small margin for a tick 89 that trails tick 46 rather than a
47/// measured bound.
48const SHORTABLE_SHARES_GRACE: Duration = Duration::from_secs(2);
49
50/// IB's error code for a request that would exceed the account's market data lines ("Max number
51/// of tickers has been reached"). The lines are a cap on concurrent subscriptions, shared by every
52/// TWS session and API client on the account, and one frees only when a subscription is
53/// cancelled: this is not IB's rate limit, which is code 100.
54const MAX_TICKERS_CODE: i32 = 101;
55
56/// IB's notices that the account may not have the stock's market data, after which no tick comes:
57/// 354 (not subscribed), 10089 (the subscription does not extend to the API) and 10168 (not
58/// subscribed, and delayed data is not enabled). They end the read at once rather than waiting
59/// out the timeout. 10167, delayed data shown in place of live, is not one: the ticks follow it.
60const NO_MARKET_DATA_CODES: [i32; 3] = [354, 10089, 10168];
61
62impl ShortabilityClient for IbkrClient {
63    /// Reads IB's shortability ticks with a streaming market data request for generic tick 236,
64    /// which IB answers with:
65    ///
66    /// - tick 46, a level: above 2.5, at least 1000 shares are available to borrow, so the stock
67    ///   is `shortable` and `easy_to_borrow`; above 1.5, it is `shortable` once shares are
68    ///   located, so not easy to borrow; 1.5 or below, it is not `shortable`, and
69    ///   `easy_to_borrow` is left unknown.
70    /// - tick 89, the shares IB has to lend, as `available`.
71    ///
72    /// A stock that is `shortable` but not `easy_to_borrow` needs a locate. IB accepts a short
73    /// sale of it and holds the order while it looks for shares (IB's code 404, which this client
74    /// reports as a working order, not a rejection), and may never find them. So `shortable`
75    /// says a short sale may be tried, not that shares are on hand.
76    ///
77    /// `fee_rate` is unknown: IB reports no borrow fee to the API. `delisting_at` is unknown too.
78    ///
79    /// The request is read until both ticks have arrived, or for
80    /// [`IbkrConfig::shortability_timeout`](super::IbkrConfig::shortability_timeout) (10 s by
81    /// default); one that arrives alone is returned on its own. Once tick 46 has arrived, tick 89
82    /// is waited for at most 2 s more: IB sends both together for a stock it has shares of, and
83    /// can send no tick 89 at all for one it has few or none of, which then comes back with
84    /// `available` unknown. While it runs it holds one of the account's market data lines, and it
85    /// is cancelled when the read ends. Calls made at once each hold their own line, so checking
86    /// many stocks together can fill the account's lines. The read runs on a blocking thread, so dropping the
87    /// returned future does not end it early: it runs to its end, holding the line until then.
88    ///
89    /// The request asks for IB's delayed market data, which IB serves only where the account has
90    /// no live subscription for the stock, so an account without one still gets the ticks. IB
91    /// does not say how far behind delayed shortability ticks are.
92    ///
93    /// # Errors
94    ///
95    /// - [`ApiError::InstrumentInvalid`] for an instrument whose contract is not registered, and
96    ///   for one that is not a stock, as only a stock is sold short by borrowing it.
97    /// - [`ConnectivityError::Timeout`] if neither tick arrives in time, or
98    ///   [`ApiError::RequestRejected`] when IB sent notices instead. The message carries IB's
99    ///   notices. A notice that the account may not have the stock's market data, live or
100    ///   delayed, ends the read at once.
101    /// - [`ApiError::CapacityExceeded`] if every one of the account's market data lines is in use,
102    ///   by this client, another API client or TWS. Waiting does not free one; cancelling a
103    ///   subscription does.
104    /// - [`ApiError::RequestRejected`] if IB refuses the request, with IB's code and message.
105    /// - [`ConnectivityError::Socket`] if the connection to TWS/Gateway drops.
106    async fn fetch_shortability(
107        &self,
108        instrument: &InstrumentNameExchange,
109    ) -> Result<Shortability, UnindexedClientError> {
110        let Some(contract) = self.contracts.get_contract(instrument) else {
111            return Err(instrument_invalid(instrument, "contract not registered"));
112        };
113        if contract.security_type != SecurityType::Stock {
114            return Err(instrument_invalid(
115                instrument,
116                &format!(
117                    "shortability does not apply to security type {}: only a stock is sold \
118                     short by borrowing it",
119                    contract.security_type
120                ),
121            ));
122        }
123        let client = self.client.clone();
124        let timeout = self.config.shortability_timeout;
125        let instrument = instrument.clone();
126
127        tokio::task::spawn_blocking(move || {
128            // Applies to this connection's later market data requests, and this client makes no
129            // other kind, so setting it on every request costs one message and changes nothing
130            // else.
131            client
132                .switch_market_data_type(MarketDataType::Delayed)
133                .map_err(|e| request_error(&instrument, e))?;
134            let subscription = client
135                .market_data(&contract)
136                .generic_ticks(&[generic_tick::SHORTABLE])
137                .subscribe()
138                .map_err(|e| request_error(&instrument, e))?;
139            // Dropping the subscription at the end of this closure cancels the request.
140            let ticks = read_shortability_ticks(timeout, SHORTABLE_SHARES_GRACE, |wait| {
141                subscription.next_timeout(wait)
142            })
143            .map_err(|e| request_error(&instrument, e))?;
144            ticks.into_shortability(&instrument, client.is_connected())
145        })
146        .await
147        .map_err(|e| UnindexedClientError::TaskFailed(format!("task join: {e}")))?
148    }
149}
150
151fn instrument_invalid(instrument: &InstrumentNameExchange, reason: &str) -> UnindexedClientError {
152    UnindexedClientError::Api(ApiError::InstrumentInvalid(
153        instrument.clone(),
154        reason.to_owned(),
155    ))
156}
157
158/// What an `ibapi` error answering the shortability request means for the caller.
159fn request_error(instrument: &InstrumentNameExchange, e: ibapi::Error) -> UnindexedClientError {
160    match e {
161        ibapi::Error::Notice(notice) if notice.code == MAX_TICKERS_CODE => {
162            UnindexedClientError::Api(ApiError::CapacityExceeded(format!(
163                "{instrument}: {} {}",
164                notice.code, notice.message
165            )))
166        }
167        ibapi::Error::Notice(notice) => UnindexedClientError::Api(ApiError::RequestRejected(
168            format!("{instrument}: {} {}", notice.code, notice.message),
169        )),
170        e if is_transient_transport_loss(&e) => {
171            UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
172        }
173        e => UnindexedClientError::Internal(format!("{instrument} shortability: {e}")),
174    }
175}
176
177/// The shortability ticks IB sent, and the notices it sent instead of or alongside them.
178#[derive(Debug, Default, PartialEq)]
179struct ShortabilityTicks {
180    /// Tick 46.
181    level: Option<f64>,
182    /// Tick 89.
183    shares: Option<f64>,
184    notices: Vec<Notice>,
185}
186
187impl ShortabilityTicks {
188    fn is_complete(&self) -> bool {
189        self.level.is_some() && self.shares.is_some()
190    }
191
192    /// Keep `tick` if it is one of the two, and a finite number: anything else is no answer.
193    fn record(&mut self, tick: &TickTypes) {
194        let (slot, value) = match tick {
195            TickTypes::Generic(generic) if generic.tick_type == TickType::Shortable => {
196                (&mut self.level, generic.value)
197            }
198            // IB documents tick 89 as a generic tick, but `ibapi` decodes it as a size; read it
199            // from either.
200            TickTypes::Generic(generic) if generic.tick_type == TickType::ShortableShares => {
201                (&mut self.shares, generic.value)
202            }
203            TickTypes::Size(size) if size.tick_type == TickType::ShortableShares => {
204                (&mut self.shares, size.size)
205            }
206            _ => return,
207        };
208        if value.is_finite() {
209            *slot = Some(value);
210        } else {
211            warn!(
212                ?tick,
213                "IBKR shortability tick is not a finite number, ignoring it"
214            );
215        }
216    }
217
218    /// What the ticks say, or why there is no answer when neither arrived.
219    fn into_shortability(
220        self,
221        instrument: &InstrumentNameExchange,
222        connected: bool,
223    ) -> Result<Shortability, UnindexedClientError> {
224        if self.level.is_none() && self.shares.is_none() {
225            return Err(if !connected {
226                UnindexedClientError::Connectivity(ConnectivityError::Socket(
227                    "the connection to TWS/Gateway dropped before IB sent shortability".to_owned(),
228                ))
229            } else if self.notices.is_empty() {
230                UnindexedClientError::Connectivity(ConnectivityError::Timeout)
231            } else {
232                let notices = self
233                    .notices
234                    .iter()
235                    .map(|notice| format!("{} {}", notice.code, notice.message))
236                    .collect::<Vec<_>>()
237                    .join("; ");
238                UnindexedClientError::Api(ApiError::RequestRejected(format!(
239                    "{instrument}: IB sent no shortability, only: {notices}"
240                )))
241            });
242        }
243        Ok(shortability_from_ticks(self.level, self.shares))
244    }
245}
246
247/// Read the shortability request's ticks until both have arrived or `timeout` has passed since
248/// the read began. Once tick 46 has arrived, tick 89 is waited for at most `grace` more.
249///
250/// `next` is the subscription's `next_timeout`, which answers `None` when its wait runs out and
251/// when the request ends. A streaming request ends only on an error, which comes first, or on the
252/// connection dropping, which the caller checks.
253///
254/// A notice that the account may not have the stock's market data ends the read with the notices
255/// gathered so far.
256///
257/// # Errors
258/// The error IB or `ibapi` ended the request with, or IB's notice that the account has no market
259/// data line free, after which no tick comes.
260fn read_shortability_ticks(
261    timeout: Duration,
262    grace: Duration,
263    mut next: impl FnMut(Duration) -> Option<Result<SubscriptionItem<TickTypes>, ibapi::Error>>,
264) -> Result<ShortabilityTicks, ibapi::Error> {
265    let mut deadline = Instant::now() + timeout;
266    let mut ticks = ShortabilityTicks::default();
267    while !ticks.is_complete() {
268        let remaining = deadline.saturating_duration_since(Instant::now());
269        if remaining.is_zero() {
270            break;
271        }
272        match next(remaining) {
273            Some(Ok(SubscriptionItem::Data(tick))) => {
274                let had_level = ticks.level.is_some();
275                ticks.record(&tick);
276                if !had_level && ticks.level.is_some() && ticks.shares.is_none() {
277                    deadline = deadline.min(Instant::now() + grace);
278                }
279            }
280            Some(Ok(SubscriptionItem::Notice(notice))) if notice.code == MAX_TICKERS_CODE => {
281                return Err(ibapi::Error::Notice(notice));
282            }
283            Some(Ok(SubscriptionItem::Notice(notice)))
284                if NO_MARKET_DATA_CODES.contains(&notice.code) =>
285            {
286                debug!(%notice, "IBKR shortability request has no market data");
287                ticks.notices.push(notice);
288                break;
289            }
290            Some(Ok(SubscriptionItem::Notice(notice))) => {
291                debug!(%notice, "Notice on an IBKR shortability request");
292                ticks.notices.push(notice);
293            }
294            Some(Err(e)) => return Err(e),
295            None => break,
296        }
297    }
298    Ok(ticks)
299}
300
301/// The [`Shortability`] IB's tick 46 `level` and tick 89 `shares` describe; either may be
302/// missing. See [`IbkrClient::fetch_shortability`] for the mapping.
303fn shortability_from_ticks(level: Option<f64>, shares: Option<f64>) -> Shortability {
304    let mut shortability = Shortability::new();
305    if let Some(level) = level {
306        shortability = if level > 2.5 {
307            shortability.with_shortable(true).with_easy_to_borrow(true)
308        } else if level > 1.5 {
309            shortability.with_shortable(true).with_easy_to_borrow(false)
310        } else {
311            shortability.with_shortable(false)
312        };
313    }
314    match shares.map(Decimal::try_from) {
315        Some(Ok(shares)) if !shares.is_sign_negative() => shortability.with_available(shares),
316        Some(_) => {
317            warn!(?shares, "IBKR shortable shares is not a count, ignoring it");
318            shortability
319        }
320        None => shortability,
321    }
322}
323
324#[cfg(test)]
325#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
326mod tests {
327    use super::*;
328    use ibapi::market_data::realtime::{TickGeneric, TickSize};
329    use rust_decimal_macros::dec;
330
331    fn generic(
332        tick_type: TickType,
333        value: f64,
334    ) -> Result<SubscriptionItem<TickTypes>, ibapi::Error> {
335        Ok(SubscriptionItem::Data(TickTypes::Generic(TickGeneric {
336            tick_type,
337            value,
338        })))
339    }
340
341    fn size(tick_type: TickType, size: f64) -> Result<SubscriptionItem<TickTypes>, ibapi::Error> {
342        Ok(SubscriptionItem::Data(TickTypes::Size(TickSize {
343            tick_type,
344            size,
345        })))
346    }
347
348    fn notice(code: i32, message: &str) -> Notice {
349        Notice {
350            request_id: Some(7),
351            code,
352            message: message.to_owned(),
353            error_time: None,
354            advanced_order_reject_json: String::new(),
355        }
356    }
357
358    /// A `next_timeout` that hands out `items`, then waits out every later call, as a streaming
359    /// request that has nothing more to send does.
360    fn subscription(
361        items: Vec<Result<SubscriptionItem<TickTypes>, ibapi::Error>>,
362    ) -> impl FnMut(Duration) -> Option<Result<SubscriptionItem<TickTypes>, ibapi::Error>> {
363        let mut items = items.into_iter();
364        move |wait| {
365            items.next().or_else(|| {
366                std::thread::sleep(wait);
367                None
368            })
369        }
370    }
371
372    fn instrument() -> InstrumentNameExchange {
373        InstrumentNameExchange::new("AAPL")
374    }
375
376    #[test]
377    fn levels_map_to_shortable_and_easy_to_borrow() {
378        let easy = shortability_from_ticks(Some(3.0), None);
379        assert_eq!(easy.shortable, Some(true));
380        assert_eq!(easy.easy_to_borrow, Some(true));
381
382        let hard = shortability_from_ticks(Some(2.0), None);
383        assert_eq!(hard.shortable, Some(true));
384        assert_eq!(hard.easy_to_borrow, Some(false));
385
386        let none = shortability_from_ticks(Some(1.0), None);
387        assert_eq!(none.shortable, Some(false));
388        assert_eq!(none.easy_to_borrow, None);
389    }
390
391    #[test]
392    fn level_boundaries_belong_to_the_level_below() {
393        // IB's thresholds are "greater than", so each boundary value is the lower level.
394        let at_easy = shortability_from_ticks(Some(2.5), None);
395        assert_eq!(
396            (at_easy.shortable, at_easy.easy_to_borrow),
397            (Some(true), Some(false))
398        );
399        let at_hard = shortability_from_ticks(Some(1.5), None);
400        assert_eq!(at_hard.shortable, Some(false));
401    }
402
403    #[test]
404    fn shares_are_available_and_the_fee_is_unknown() {
405        let shortability = shortability_from_ticks(Some(3.0), Some(1_500_000.0));
406        assert_eq!(shortability.available, Some(dec!(1500000)));
407        assert_eq!(shortability.fee_rate, None);
408
409        let only_shares = shortability_from_ticks(None, Some(0.0));
410        assert_eq!(
411            only_shares,
412            Shortability::new().with_available(Decimal::ZERO)
413        );
414    }
415
416    #[test]
417    fn negative_shares_are_unknown() {
418        assert_eq!(
419            shortability_from_ticks(None, Some(-1.0)),
420            Shortability::new()
421        );
422    }
423
424    #[test]
425    fn read_stops_once_both_ticks_have_arrived() {
426        let timeout = Duration::from_secs(5);
427        let started = Instant::now();
428        let ticks = read_shortability_ticks(
429            timeout,
430            SHORTABLE_SHARES_GRACE,
431            subscription(vec![
432                generic(TickType::Shortable, 3.0),
433                generic(TickType::Bid, 1.0),
434                size(TickType::ShortableShares, 200.0),
435            ]),
436        )
437        .unwrap();
438        assert!(started.elapsed() < timeout, "waited for the timeout");
439        assert_eq!(ticks.level, Some(3.0));
440        assert_eq!(ticks.shares, Some(200.0));
441    }
442
443    #[test]
444    fn shares_are_read_from_a_generic_tick_too() {
445        let ticks = read_shortability_ticks(
446            Duration::from_secs(5),
447            SHORTABLE_SHARES_GRACE,
448            subscription(vec![
449                generic(TickType::ShortableShares, 100.0),
450                generic(TickType::Shortable, 2.0),
451            ]),
452        )
453        .unwrap();
454        assert!(ticks.is_complete());
455        assert_eq!(ticks.shares, Some(100.0));
456    }
457
458    #[test]
459    fn a_tick_that_arrives_alone_is_returned_after_the_timeout() {
460        let ticks = read_shortability_ticks(
461            Duration::from_millis(40),
462            SHORTABLE_SHARES_GRACE,
463            subscription(vec![generic(TickType::Shortable, 2.0)]),
464        )
465        .unwrap();
466        let shortability = ticks.into_shortability(&instrument(), true).unwrap();
467        assert_eq!(
468            shortability,
469            Shortability::new()
470                .with_shortable(true)
471                .with_easy_to_borrow(false)
472        );
473    }
474
475    /// Tick 46 alone ends the read once the grace for tick 89 has passed, well before the
476    /// timeout, and is returned on its own. Tick 89 alone still waits for tick 46.
477    #[test]
478    fn tick_46_alone_waits_only_the_grace_for_tick_89() {
479        let timeout = Duration::from_secs(5);
480        let started = Instant::now();
481        let ticks = read_shortability_ticks(
482            timeout,
483            Duration::from_millis(40),
484            subscription(vec![
485                generic(TickType::Shortable, 2.0),
486                generic(TickType::Bid, 1.0),
487            ]),
488        )
489        .unwrap();
490        assert!(
491            started.elapsed() < Duration::from_secs(1),
492            "waited past the grace"
493        );
494        assert_eq!((ticks.level, ticks.shares), (Some(2.0), None));
495
496        let started = Instant::now();
497        let ticks = read_shortability_ticks(
498            Duration::from_millis(300),
499            Duration::from_millis(40),
500            subscription(vec![size(TickType::ShortableShares, 100.0)]),
501        )
502        .unwrap();
503        assert!(
504            started.elapsed() >= Duration::from_millis(300),
505            "tick 89 alone starts no grace"
506        );
507        assert_eq!((ticks.level, ticks.shares), (None, Some(100.0)));
508    }
509
510    #[test]
511    fn a_non_finite_tick_is_no_answer() {
512        let ticks = read_shortability_ticks(
513            Duration::from_millis(40),
514            SHORTABLE_SHARES_GRACE,
515            subscription(vec![generic(TickType::Shortable, f64::NAN)]),
516        )
517        .unwrap();
518        assert_eq!(ticks.level, None);
519    }
520
521    #[test]
522    fn nothing_in_time_is_a_timeout() {
523        let ticks = read_shortability_ticks(
524            Duration::from_millis(40),
525            SHORTABLE_SHARES_GRACE,
526            subscription(vec![]),
527        )
528        .unwrap();
529        let error = ticks.into_shortability(&instrument(), true).unwrap_err();
530        assert!(matches!(
531            error,
532            UnindexedClientError::Connectivity(ConnectivityError::Timeout)
533        ));
534    }
535
536    #[test]
537    fn nothing_but_notices_is_a_rejection_naming_them() {
538        let started = Instant::now();
539        let ticks = read_shortability_ticks(
540            Duration::from_secs(5),
541            SHORTABLE_SHARES_GRACE,
542            subscription(vec![Ok(SubscriptionItem::Notice(notice(
543                10089,
544                "Requested market data requires additional subscription for API.",
545            )))]),
546        )
547        .unwrap();
548        assert!(
549            started.elapsed() < Duration::from_secs(5),
550            "a no-data notice should end the read"
551        );
552        let error = ticks.into_shortability(&instrument(), true).unwrap_err();
553        let UnindexedClientError::Api(ApiError::RequestRejected(message)) = error else {
554            panic!("expected RequestRejected, got {error:?}");
555        };
556        assert!(message.starts_with("AAPL: "), "{message}");
557        assert!(message.contains("10089 Requested market data"), "{message}");
558    }
559
560    #[test]
561    fn a_delayed_data_notice_is_followed_by_the_ticks() {
562        let ticks = read_shortability_ticks(
563            Duration::from_secs(5),
564            SHORTABLE_SHARES_GRACE,
565            subscription(vec![
566                Ok(SubscriptionItem::Notice(notice(
567                    10167,
568                    "Requested market data is not subscribed. Displaying delayed market data.",
569                ))),
570                generic(TickType::Shortable, 3.0),
571                size(TickType::ShortableShares, 188_524_117.0),
572            ]),
573        )
574        .unwrap();
575        assert_eq!(ticks.level, Some(3.0));
576        assert_eq!(ticks.shares, Some(188_524_117.0));
577        assert_eq!(ticks.notices.len(), 1);
578    }
579
580    #[test]
581    fn a_notice_that_no_line_is_free_ends_the_read() {
582        let started = Instant::now();
583        let error = read_shortability_ticks(
584            Duration::from_secs(5),
585            SHORTABLE_SHARES_GRACE,
586            subscription(vec![Ok(SubscriptionItem::Notice(notice(
587                101,
588                "Max number of tickers has been reached",
589            )))]),
590        )
591        .unwrap_err();
592        assert!(started.elapsed() < Duration::from_secs(5), "waited it out");
593        assert!(matches!(
594            request_error(&instrument(), error),
595            UnindexedClientError::Api(ApiError::CapacityExceeded(_))
596        ));
597    }
598
599    #[test]
600    fn nothing_after_a_drop_is_a_socket_error() {
601        let error = ShortabilityTicks::default()
602            .into_shortability(&instrument(), false)
603            .unwrap_err();
604        assert!(matches!(
605            error,
606            UnindexedClientError::Connectivity(ConnectivityError::Socket(_))
607        ));
608    }
609
610    #[test]
611    fn an_error_ends_the_read() {
612        let error = read_shortability_ticks(
613            Duration::from_secs(5),
614            SHORTABLE_SHARES_GRACE,
615            subscription(vec![
616                generic(TickType::Shortable, 3.0),
617                Err(ibapi::Error::Notice(notice(354, "Not subscribed"))),
618            ]),
619        )
620        .unwrap_err();
621        let error = request_error(&instrument(), error);
622        let UnindexedClientError::Api(ApiError::RequestRejected(message)) = error else {
623            panic!("expected RequestRejected, got {error:?}");
624        };
625        assert_eq!(message, "AAPL: 354 Not subscribed");
626    }
627
628    #[test]
629    fn no_free_market_data_line_is_capacity_exceeded_not_a_rate_limit() {
630        let error = request_error(
631            &instrument(),
632            ibapi::Error::Notice(notice(101, "Max number of tickers has been reached")),
633        );
634        assert_eq!(
635            error,
636            UnindexedClientError::Api(ApiError::CapacityExceeded(
637                "AAPL: 101 Max number of tickers has been reached".to_owned()
638            ))
639        );
640        assert!(!error.is_transient());
641    }
642
643    #[test]
644    fn config_timeout_is_milliseconds_with_a_default() {
645        use super::super::IbkrConfig;
646
647        let minimal = r#"{"host":"127.0.0.1","port":4002,"client_id":1,"account":""}"#;
648        let config: IbkrConfig = serde_json::from_str(minimal).unwrap();
649        assert_eq!(config.shortability_timeout, DEFAULT_SHORTABILITY_TIMEOUT);
650
651        let config = config.with_shortability_timeout(Duration::from_millis(2500));
652        let json = serde_json::to_value(&config).unwrap();
653        assert_eq!(json["shortability_timeout_ms"], 2500);
654        let back: IbkrConfig = serde_json::from_value(json).unwrap();
655        assert_eq!(back.shortability_timeout, Duration::from_millis(2500));
656    }
657
658    #[test]
659    fn a_dropped_socket_is_a_connectivity_error() {
660        let error = request_error(&instrument(), ibapi::Error::ConnectionReset);
661        assert!(matches!(
662            error,
663            UnindexedClientError::Connectivity(ConnectivityError::Socket(_))
664        ));
665    }
666}