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}