1use 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
33pub const DEFAULT_SHORTABILITY_TIMEOUT: Duration = Duration::from_secs(10);
36
37const SHORTABLE_SHARES_GRACE: Duration = Duration::from_secs(2);
49
50const MAX_TICKERS_CODE: i32 = 101;
55
56const NO_MARKET_DATA_CODES: [i32; 3] = [354, 10089, 10168];
61
62impl ShortabilityClient for IbkrClient {
63 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 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 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
158fn 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#[derive(Debug, Default, PartialEq)]
179struct ShortabilityTicks {
180 level: Option<f64>,
182 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 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 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 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
247fn 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(¬ice.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
301fn 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)] mod 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 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 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 #[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}