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}