Skip to main content

bezant/
helpers.rs

1//! Ergonomic helpers layered on top of the generated client.
2//!
3//! These are the sugar that most rebalance bots end up reimplementing: page
4//! walking, conid caching, etc. Nothing here changes the wire protocol — it's
5//! all composed from the typed methods [`bezant_api::IbRestApiClient`] already
6//! exposes.
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10
11use tracing::debug;
12
13use crate::client::Client;
14use crate::error::{Error, Result};
15
16/// Positions returned for an account. Alias over the generated type so callers
17/// can use `bezant::Position` without digging into `bezant_api`.
18pub type Position = bezant_api::IndividualPosition;
19
20/// Contract-search result, alias over the generated type.
21pub type ContractSummary = bezant_api::SecdefSearchResponseSecdefSearchResponse;
22
23/// Page size the Gateway returns for paginated position calls.
24///
25/// Exposed so CLIs / sidecars that replicate the page-walking loop don't
26/// have to hard-code `30`. Tracks the size documented in CPAPI —
27/// technically an IBKR-side constant rather than a bezant one, so if the
28/// Gateway ever changes this we'll update the constant and bump the minor.
29pub const POSITIONS_PAGE_SIZE: usize = 30;
30
31/// Safety limit on the number of pages [`Client::all_positions`] will walk.
32///
33/// Three thousand positions is dramatically more than any realistic
34/// rebalance-bot scope; this exists purely to stop a misbehaving Gateway
35/// from spinning us forever.
36pub const MAX_POSITION_PAGES: u32 = 100;
37
38impl Client {
39    /// Fetch every position across every page for `account_id`.
40    ///
41    /// CPAPI returns up to [`POSITIONS_PAGE_SIZE`] entries per page; this
42    /// helper walks pages starting from 0 and stops once a short page (or
43    /// an empty one) is returned. [`MAX_POSITION_PAGES`] caps runaway
44    /// loops.
45    ///
46    /// # Errors
47    /// Any transport / decode failure surfaces as [`Error`]. An
48    /// [`Error::NotAuthenticated`] is returned if the Gateway reports the
49    /// session is not live.
50    #[tracing::instrument(skip(self), fields(account = %account_id), level = "debug")]
51    pub async fn all_positions(&self, account_id: &str) -> Result<Vec<Position>> {
52        let mut all = Vec::new();
53        for page in 0..MAX_POSITION_PAGES {
54            let req = bezant_api::GetPaginatedPositionsRequest {
55                path: bezant_api::GetPaginatedPositionsRequestPath {
56                    account_id: account_id.to_owned(),
57                    // The generator models `page_id` as i32, so cast once
58                    // at the request boundary rather than polluting the
59                    // whole helper with a signed loop variable.
60                    page_id: i32::try_from(page).unwrap_or(i32::MAX),
61                },
62                query: bezant_api::GetPaginatedPositionsRequestQuery::default(),
63            };
64            let resp = self.api().get_paginated_positions(req).await?;
65            let batch = match resp {
66                bezant_api::GetPaginatedPositionsResponse::Ok(items) => items,
67                bezant_api::GetPaginatedPositionsResponse::Unauthorized => {
68                    return Err(Error::NotAuthenticated)
69                }
70                bezant_api::GetPaginatedPositionsResponse::BadRequest => {
71                    return Err(Error::BadRequest(format!(
72                        "portfolio/{account_id}/positions/{page} returned 400"
73                    )))
74                }
75                bezant_api::GetPaginatedPositionsResponse::InternalServerError => {
76                    return Err(Error::UpstreamStatus {
77                        endpoint: "portfolio/positions",
78                        status: 500,
79                        body_preview: None,
80                    })
81                }
82                bezant_api::GetPaginatedPositionsResponse::ServiceUnavailable => {
83                    return Err(Error::UpstreamStatus {
84                        endpoint: "portfolio/positions",
85                        status: 503,
86                        body_preview: None,
87                    })
88                }
89                bezant_api::GetPaginatedPositionsResponse::Unknown => {
90                    return Err(Error::Unknown {
91                        endpoint: "portfolio/positions",
92                    })
93                }
94            };
95            let n = batch.len();
96            debug!(page, fetched = n, "position page fetched");
97            all.extend(batch);
98            if n < POSITIONS_PAGE_SIZE {
99                break;
100            }
101        }
102        Ok(all)
103    }
104}
105
106/// Symbol → conid cache.
107///
108/// Resolving a ticker to IBKR's numeric `conid` is a search call, and it's
109/// stable across days — most bots do it once per ticker per session. Wrap
110/// your [`Client`] with a [`SymbolCache`] to memoise lookups.
111///
112/// The cache is deliberately simple: a `Mutex<HashMap>`. It's built for the
113/// low-volume rebalance-bot case (dozens of tickers, infrequent refreshes)
114/// rather than high-volume quote streaming.
115///
116/// # Example
117///
118/// ```no_run
119/// # async fn run() -> bezant::Result<()> {
120/// let client = bezant::Client::new("https://localhost:5000/v1/api")?;
121/// let cache = bezant::SymbolCache::new(client);
122/// let aapl = cache.conid_for("AAPL").await?;
123/// let msft = cache.conid_for("MSFT").await?;
124/// // further calls for AAPL/MSFT hit the in-memory cache.
125/// # Ok(())
126/// # }
127/// ```
128#[derive(Debug)]
129pub struct SymbolCache {
130    client: Client,
131    // `Option<i64>` so negative lookups (no such symbol) are remembered as
132    // well — repeated typos hit the network exactly once.
133    cache: Mutex<HashMap<String, Option<i64>>>,
134}
135
136/// Poisoned-mutex fallback: the protected state is a `HashMap<String, _>`
137/// with no invariants beyond what the type system already gives us, so if
138/// a panic ever poisoned the mutex we'd rather keep going than abort the
139/// whole process — acquire the lock by taking the inner guard regardless.
140fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
141    m.lock().unwrap_or_else(|e| e.into_inner())
142}
143
144impl SymbolCache {
145    /// Wrap a [`Client`] with an empty cache.
146    #[must_use]
147    pub fn new(client: Client) -> Self {
148        Self {
149            client,
150            cache: Mutex::new(HashMap::new()),
151        }
152    }
153
154    /// Return the cached conid for `symbol`, looking it up on first miss.
155    ///
156    /// Only `STK`-type matches are cached. If you need options / bonds /
157    /// futures, call [`Client::api`] directly. Both hits and misses are
158    /// memoised — if a symbol turns out to be invalid, subsequent calls
159    /// return [`Error::SymbolNotFound`] without touching the network.
160    ///
161    /// # Errors
162    /// [`Error::SymbolNotFound`] if the symbol doesn't resolve to any
163    /// contract, [`Error::BadConid`] if the upstream returned a contract
164    /// whose conid wasn't a parseable integer, plus any transport /
165    /// decode errors.
166    #[tracing::instrument(skip(self), fields(symbol = %symbol), level = "debug")]
167    pub async fn conid_for(&self, symbol: &str) -> Result<i64> {
168        if let Some(entry) = lock(&self.cache).get(symbol).copied() {
169            return match entry {
170                Some(conid) => Ok(conid),
171                None => Err(Error::SymbolNotFound {
172                    symbol: symbol.to_owned(),
173                }),
174            };
175        }
176
177        let req = bezant_api::GetContractSymbolsFromBodyRequest {
178            body: bezant_api::SearchRequestBody {
179                symbol: symbol.to_owned(),
180                sec_type: Some(bezant_api::GetContractSymbolsRequestQuerySecType::Stk),
181                name: Some(false),
182                more: Some(false),
183                ..bezant_api::SearchRequestBody::default()
184            },
185        };
186        let resp = self
187            .client
188            .api()
189            .get_contract_symbols_from_body(req)
190            .await?;
191        let items = match resp {
192            bezant_api::GetContractSymbolsResponse::Ok(items) => items,
193            bezant_api::GetContractSymbolsResponse::BadRequest => {
194                // BadRequest means the symbol itself is malformed — cache
195                // the negative so we don't hit the CDN over and over for
196                // a caller that's retrying. Surface as `SymbolNotFound`
197                // since the practical result is the same.
198                lock(&self.cache).insert(symbol.to_owned(), None);
199                return Err(Error::SymbolNotFound {
200                    symbol: symbol.to_owned(),
201                });
202            }
203            bezant_api::GetContractSymbolsResponse::Unauthorized => {
204                return Err(Error::NotAuthenticated)
205            }
206            bezant_api::GetContractSymbolsResponse::InternalServerError => {
207                return Err(Error::UpstreamStatus {
208                    endpoint: "iserver/secdef/search",
209                    status: 500,
210                    body_preview: None,
211                })
212            }
213            bezant_api::GetContractSymbolsResponse::ServiceUnavailable => {
214                return Err(Error::UpstreamStatus {
215                    endpoint: "iserver/secdef/search",
216                    status: 503,
217                    body_preview: None,
218                })
219            }
220            bezant_api::GetContractSymbolsResponse::Unknown => {
221                return Err(Error::Unknown {
222                    endpoint: "iserver/secdef/search",
223                })
224            }
225        };
226        let Some(first) = items.first() else {
227            lock(&self.cache).insert(symbol.to_owned(), None);
228            return Err(Error::SymbolNotFound {
229                symbol: symbol.to_owned(),
230            });
231        };
232        let conid_str = first
233            .conid
234            .as_deref()
235            .ok_or_else(|| Error::SymbolNotFound {
236                symbol: symbol.to_owned(),
237            })?;
238        let conid: i64 = conid_str.parse().map_err(|source| Error::BadConid {
239            symbol: symbol.to_owned(),
240            raw: conid_str.to_owned(),
241            source,
242        })?;
243
244        lock(&self.cache).insert(symbol.to_owned(), Some(conid));
245        Ok(conid)
246    }
247
248    /// Drop a single entry — useful after IBKR restructures a listing.
249    pub fn forget(&self, symbol: &str) {
250        lock(&self.cache).remove(symbol);
251    }
252
253    /// Clear the whole cache.
254    pub fn clear(&self) {
255        lock(&self.cache).clear();
256    }
257
258    /// Borrow the inner [`Client`] — useful when callers want both the cache
259    /// and direct API access from the same instance.
260    #[must_use]
261    pub fn client(&self) -> &Client {
262        &self.client
263    }
264}