Skip to main content

rustrade_execution/client/ibkr/
contract.rs

1//! Contract builders for IB integration.
2
3use crate::error::ConnectivityError;
4use ibapi::contracts::{Contract, Currency, Exchange, OptionRight, SecurityType, Symbol};
5use thiserror::Error;
6
7/// Reasons a [`ContractConfig`](super::ContractConfig) cannot be mapped to a
8/// valid [`Contract`].
9///
10/// Every variant represents an input that would otherwise force the builder to
11/// silently fabricate a *wrong* contract (e.g. defaulting a missing option
12/// `right` to Call, or an unknown `security_type` to a stock). Surfacing these
13/// at construction — rather than deferring to an opaque IBKR submission
14/// rejection — keeps failures observable, per the library's contract.
15///
16/// `#[non_exhaustive]`: new validation reasons may be added without a breaking
17/// change as the contract builders grow.
18#[derive(Debug, Clone, PartialEq, Eq, Error)]
19#[non_exhaustive]
20pub enum ContractConfigError {
21    /// An `OPT` contract was configured without a `right`.
22    #[error("OPT contract requires a `right` field (expected one of C/CALL/P/PUT)")]
23    MissingOptionRight,
24
25    /// A `right` was supplied but is not one of `C`/`CALL`/`P`/`PUT`.
26    #[error("unrecognized option right {right:?} (expected one of C/CALL/P/PUT)")]
27    UnrecognizedOptionRight { right: String },
28
29    /// An `OPT` contract was configured without a `strike`.
30    #[error("OPT contract requires a `strike` field")]
31    MissingStrike,
32
33    /// A `FUT` or `OPT` contract was configured without a `last_trade_date`.
34    #[error("FUT/OPT contract requires a `last_trade_date` field")]
35    MissingLastTradeDate,
36
37    /// The `security_type` is not one of the supported `STK`/`FUT`/`OPT`/`CASH`.
38    #[error("unrecognized security_type {security_type:?} (expected one of STK/FUT/OPT/CASH)")]
39    UnrecognizedSecurityType { security_type: String },
40}
41
42/// Reasons [`IbkrClient::resolve_contract`](super::IbkrClient::resolve_contract) cannot
43/// resolve a contract description into one IB contract.
44///
45/// `#[non_exhaustive]`: new reasons may be added without a breaking change.
46#[derive(Debug, Clone, PartialEq, Error)]
47#[non_exhaustive]
48pub enum ResolveContractError {
49    /// No IB contract matches the description.
50    #[error("no IB contract matches the description")]
51    NoMatch,
52
53    /// Several IB contracts match the description, for example the same symbol on several
54    /// exchanges, or options of several trading classes. Narrow the description, or pick one of
55    /// `matches` and register it.
56    #[error(
57        "{} IB contracts match the description (contract ids {:?}); narrow it",
58        .matches.len(),
59        contract_ids(.matches)
60    )]
61    Ambiguous {
62        /// Every contract that matches, as IB resolved it.
63        matches: Vec<Contract>,
64    },
65
66    /// IB answered the request with an error other than finding no match, such as a description
67    /// it cannot validate. Retrying the same description usually fails the same way, so
68    /// [`is_transient`](Self::is_transient) is false. Some IB errors, such as a pacing
69    /// violation, can clear on a later retry: tell those apart by `code`.
70    #[error("IB refused the contract details request (error {code}): {message}")]
71    Refused {
72        /// IB's error code.
73        code: i32,
74        /// IB's error message.
75        message: String,
76    },
77
78    /// The connection dropped, or IB sent nothing for 10 s before finishing its answer.
79    /// Transient: retry once `ibapi` has reconnected.
80    #[error("contract details request failed: {0}")]
81    Connectivity(#[from] ConnectivityError),
82
83    /// The request could not be made or read for another reason: the client shut down or gave
84    /// up reconnecting, `ibapi` could not decode IB's answer, or the blocking task failed.
85    /// Retrying on this client does not help.
86    #[error("contract details request failed: {0}")]
87    Failed(String),
88}
89
90impl ResolveContractError {
91    /// Whether the same request may succeed if retried: a
92    /// [`Connectivity`](Self::Connectivity) failure.
93    pub fn is_transient(&self) -> bool {
94        matches!(self, Self::Connectivity(e) if e.is_transient())
95    }
96}
97
98fn contract_ids(contracts: &[Contract]) -> Vec<i32> {
99    contracts
100        .iter()
101        .map(|contract| contract.contract_id)
102        .collect()
103}
104
105/// Map a human/wire option-right string to `OptionRight`.
106///
107/// Accepts IBKR wire values (`"C"`/`"P"`) and common long forms
108/// (`"CALL"`/`"PUT"`), case-insensitively. Returns `None` for anything else.
109fn parse_option_right(right: &str) -> Option<OptionRight> {
110    let right = right.trim();
111    if right.eq_ignore_ascii_case("C") || right.eq_ignore_ascii_case("CALL") {
112        Some(OptionRight::Call)
113    } else if right.eq_ignore_ascii_case("P") || right.eq_ignore_ascii_case("PUT") {
114        Some(OptionRight::Put)
115    } else {
116        None
117    }
118}
119
120/// Build a stock contract for the given symbol.
121pub fn stock_contract(symbol: &str, exchange: &str, currency: &str) -> Contract {
122    Contract::stock(symbol)
123        .on_exchange(exchange)
124        .in_currency(currency)
125        .build()
126}
127
128/// Build a futures contract.
129pub fn futures_contract(
130    symbol: &str,
131    last_trade_date: &str,
132    exchange: &str,
133    currency: &str,
134) -> Contract {
135    Contract {
136        symbol: Symbol::new(symbol),
137        security_type: SecurityType::Future,
138        last_trade_date_or_contract_month: last_trade_date.to_string(),
139        exchange: Exchange::new(exchange),
140        currency: Currency::new(currency),
141        ..Default::default()
142    }
143}
144
145/// Build an options contract.
146///
147/// # Errors
148///
149/// Returns [`ContractConfigError::UnrecognizedOptionRight`] if `right` is not
150/// one of `C`/`CALL`/`P`/`PUT` (case-insensitive; leading and trailing
151/// whitespace is ignored). An option contract is invalid without a valid right,
152/// so this is surfaced at construction rather than as a later IBKR submission
153/// rejection.
154///
155/// Only `right` is validated here. `last_trade_date` and `strike` are forwarded
156/// to the IBKR contract as-is; an empty date or a `0.0` strike will build a
157/// (quietly wrong) contract. Presence of those fields is checked one level up in
158/// [`ContractConfig`](super::ContractConfig)'s `to_contract`, which is
159/// the intended entry point for config-driven construction.
160pub fn option_contract(
161    symbol: &str,
162    last_trade_date: &str,
163    strike: f64,
164    right: &str,
165    exchange: &str,
166    currency: &str,
167) -> Result<Contract, ContractConfigError> {
168    let right =
169        parse_option_right(right).ok_or_else(|| ContractConfigError::UnrecognizedOptionRight {
170            right: right.to_string(),
171        })?;
172    Ok(Contract {
173        symbol: Symbol::new(symbol),
174        security_type: SecurityType::Option,
175        last_trade_date_or_contract_month: last_trade_date.to_string(),
176        strike,
177        right: Some(right),
178        exchange: Exchange::new(exchange),
179        currency: Currency::new(currency),
180        ..Default::default()
181    })
182}
183
184/// Build a forex contract.
185pub fn forex_contract(symbol: &str, currency: &str) -> Contract {
186    Contract {
187        symbol: Symbol::new(symbol),
188        security_type: SecurityType::ForexPair,
189        exchange: Exchange::new("IDEALPRO"),
190        currency: Currency::new(currency),
191        ..Default::default()
192    }
193}