brokapi_bcs/raw.rs
1//! Direct access to any BCS API method (the "back door"): same refresh token and access token, same
2//! endpoint, same [`BrokerLimiter`] as the connector it came from.
3//!
4//! The connectors wrap what the shared traits can express. Everything else (quotes snapshot,
5//! non-trade operations, limits, instrument discounts, the limits and marginal-indicators
6//! WebSockets, ...) is reachable here. Get a handle with `raw()` on either connector.
7//!
8//! Rules of a call:
9//!
10//! 1. The quota is taken first, by the operation name (`"GetOperationHistory"`), from the route in
11//! `limits.toml`. A name without a route spends the `"*"` default route, so a call is
12//! never free. A refusal is [`Error::RateLimited`] and the request is not sent.
13//! 2. The closure gets the connector's [`BcsHttpClient`]: it holds the access token, refreshes it and
14//! repeats a request once after a 401. One closure is one API method.
15//! 3. [`call`](BcsRaw::call) is for reads. [`call_command`](BcsRaw::call_command) is for anything that
16//! changes the account: a break, a timeout or a 5xx is [`Error::Unknown`], and it is never retried.
17//! 4. [`connect_ws`](BcsRaw::connect_ws) opens one of the WebSocket channels with the same token.
18//!
19//! The raw layer does not know about the connector's order state: orders placed through it are not
20//! tracked (no events, no `reconcile`). Use the shared traits for orders.
21//!
22//! # Examples
23//!
24//! A read that no shared trait wraps, the quotes snapshot, through the market data connector:
25//!
26//! ```
27//! use brokapi_bcs_protocol::http::models::{QuoteInstrument, QuotesRequest};
28//! use brokapi_bcs::limits::default_limits;
29//! use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
30//! use brokapi_core::ShutdownMode;
31//! use brokapi_runtime::BrokerLimiter;
32//! use tokio::sync::mpsc;
33//! # use brokapi_bcs_protocol::config::BcsConfig;
34//!
35//! # #[tokio::main(flavor = "current_thread")]
36//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
37//! # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
38//! # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base);
39//! let (events_tx, _events) = mpsc::channel(256);
40//! let limiter = BrokerLimiter::new(&default_limits());
41//! let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), limiter, events_tx).await?;
42//!
43//! let raw = md.raw();
44//! // "GetQuotes" is the name of the quota route. The closure is one API method.
45//! let quotes = raw
46//! .call("GetQuotes", |http| async move {
47//! http.get_quotes(&QuotesRequest { instruments: vec![QuoteInstrument::new("SBER", "TQBR")] }).await
48//! })
49//! .await?;
50//! for q in "es.records {
51//! println!("{:?} bid {:?} offer {:?}", q.ticker, q.bid, q.offer);
52//! }
53//! md.shutdown(ShutdownMode::Immediate).await;
54//! # Ok(())
55//! # }
56//! ```
57
58use std::future::Future;
59use std::sync::Arc;
60
61use brokapi_bcs_protocol::error::BcsApiError;
62use brokapi_bcs_protocol::http::client::BcsHttpClient;
63use brokapi_bcs_protocol::models::messages::IncomingMessage;
64use brokapi_bcs_protocol::ws::client::BcsWsChannel;
65use brokapi_bcs_protocol::ws::session::WsSession;
66use brokapi_core::Error;
67use brokapi_runtime::BrokerLimiter;
68use kanal::AsyncSender;
69
70use crate::error::{map_command_err, map_err};
71
72/// A handle for calling any BCS API method with a connector's token and rate limiter.
73///
74/// Get it from `raw()` on [`MarketDataConnector`](crate::MarketDataConnector) or
75/// [`UserDataConnector`](crate::UserDataConnector). It is cheap to clone, and all clones share the
76/// token and the limiter. See the [module docs](self) for the rules of a call.
77#[derive(Clone)]
78pub struct BcsRaw {
79 http: BcsHttpClient,
80 limiter: Arc<BrokerLimiter>,
81}
82
83impl BcsRaw {
84 pub(crate) fn new(http: BcsHttpClient, limiter: Arc<BrokerLimiter>) -> Self {
85 Self { http, limiter }
86 }
87
88 /// Calls a read-only API method: takes the quota for `method`, then runs `f`.
89 ///
90 /// `method` is the name of the quota route, not a URL. Take a name from `limits.toml`
91 /// (for example `GetQuotes`). An unknown name spends the default route `"*"`. `f` receives the
92 /// connector's [`BcsHttpClient`] and returns the future of one API call. Do not use this for
93 /// anything that changes the account, use [`call_command`](Self::call_command).
94 ///
95 /// # Errors
96 ///
97 /// - [`Error::RateLimited`] when the quota is spent. Nothing was sent. Also when the broker
98 /// answers HTTP 429.
99 /// - [`Error::Rejected`] with code `validation` when the broker answers HTTP 400 with a
100 /// validation error.
101 /// - [`Error::Transport`] for everything else: no connection, a timeout, an unreadable answer, HTTP
102 /// 404 (`not found`), a failed token refresh, any other HTTP status. A read can be repeated.
103 /// - [`Error::Config`] when the limits file routes the name to a bucket that does not exist.
104 ///
105 /// The full table is in [`crate::error::map_err`].
106 ///
107 /// # Examples
108 ///
109 /// A quota that is spent refuses the call before anything is sent:
110 ///
111 /// ```
112 /// use brokapi_bcs_protocol::http::models::{QuoteInstrument, QuotesRequest};
113 /// use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
114 /// use brokapi_core::{Error, ShutdownMode};
115 /// use brokapi_runtime::{BrokerLimiter, BrokerLimits};
116 /// use tokio::sync::mpsc;
117 /// # use brokapi_bcs_protocol::config::BcsConfig;
118 ///
119 /// # #[tokio::main(flavor = "current_thread")]
120 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
121 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
122 /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base);
123 /// // One call per minute for GetQuotes.
124 /// let limits = BrokerLimits::from_toml_str(
125 /// r#"
126 /// [bcs.buckets.one]
127 /// rate = 1
128 /// per = "60s"
129 /// burst = 1
130 /// [bcs.route]
131 /// "*" = ["one"]
132 /// [bcs.conns]
133 /// max_conns = 4
134 /// max_subs_per_conn = 50
135 /// supports_multiplex = false
136 /// "#,
137 /// )?;
138 /// # let (events_tx, _events) = mpsc::channel(16);
139 /// let md = MarketDataConnector::start(
140 /// MarketDataConfig::new(cfg, "bcs-md"),
141 /// BrokerLimiter::new(&limits),
142 /// events_tx,
143 /// )
144 /// .await?;
145 /// let raw = md.raw();
146 /// let quotes = || QuotesRequest { instruments: vec![QuoteInstrument::new("SBER", "TQBR")] };
147 ///
148 /// raw.call("GetQuotes", |http| async move { http.get_quotes("es()).await }).await?;
149 /// let second = raw.call("GetQuotes", |http| async move { http.get_quotes("es()).await }).await;
150 /// assert!(matches!(second, Err(Error::RateLimited { .. })));
151 /// md.shutdown(ShutdownMode::Immediate).await;
152 /// # Ok(())
153 /// # }
154 /// ```
155 pub async fn call<T, F, Fut>(&self, method: &str, f: F) -> Result<T, Error>
156 where
157 F: FnOnce(BcsHttpClient) -> Fut,
158 Fut: Future<Output = Result<T, BcsApiError>>,
159 {
160 self.limiter.acquire(method, brokapi_runtime::now())?;
161 f(self.http.clone()).await.map_err(map_err)
162 }
163
164 /// Calls an API method that changes the account: takes the quota for `method`, then runs `f`.
165 ///
166 /// Like [`call`](Self::call), but anything that may have reached the broker is
167 /// [`Error::Unknown`]. The library never repeats the call: a retry of a lost place request could
168 /// create a second order. Find out what happened (read the order, run `reconcile`) before you
169 /// decide to send it again. The order is not tracked by the connector (see the module docs).
170 ///
171 /// # Errors
172 ///
173 /// - [`Error::Unknown`] when there was no answer, a break or a timeout, HTTP 500 or above, or an
174 /// answer that cannot be read. The command may have been done.
175 /// - [`Error::Rejected`] when the broker answered and said no: code `validation` for a validation
176 /// error, code `http_<status>` for another status from 400 to 499 except 429. Nothing was done.
177 /// - [`Error::RateLimited`] when the quota is spent (nothing was sent) or the broker answers HTTP 429.
178 /// - [`Error::Transport`] for a failed token refresh and for HTTP 404 (`not found`).
179 /// - [`Error::Config`] when the limits file routes the name to a bucket that does not exist.
180 ///
181 /// The full table is in [`crate::error::map_command_err`].
182 ///
183 /// # Examples
184 ///
185 /// An order sent through the raw layer, refused by the broker (the mock refuses a limit order at
186 /// `REJECT_PRICE`). The refusal is a definite [`Error::Rejected`], not an unknown outcome:
187 ///
188 /// ```
189 /// use brokapi_bcs_protocol::http::models::CreateOrderRequest;
190 /// use brokapi_bcs_protocol::models::enums::OrderSide;
191 /// use brokapi_bcs::limits::default_limits;
192 /// use brokapi_bcs::{UserDataConfig, UserDataConnector};
193 /// use brokapi_conformance::REJECT_PRICE;
194 /// use brokapi_core::{ClientOrderId, Error, ShutdownMode};
195 /// use brokapi_runtime::BrokerLimiter;
196 /// use tokio::sync::mpsc;
197 /// # use brokapi_bcs_protocol::config::BcsConfig;
198 ///
199 /// # #[tokio::main(flavor = "current_thread")]
200 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
201 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
202 /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base).with_write(true);
203 /// # let (events_tx, _events) = mpsc::channel(256);
204 /// # let limiter = BrokerLimiter::new(&default_limits());
205 /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "bcs-ud"), limiter, events_tx).await?;
206 ///
207 /// let order = CreateOrderRequest::limit(
208 /// ClientOrderId::new_v4(), "SBER", "TQBR", OrderSide::Buy, 1, REJECT_PRICE.decimal(),
209 /// );
210 /// let result = ud
211 /// .raw()
212 /// .call_command("PlaceOrder", |http| async move { http.create_order_sync(&order).await })
213 /// .await;
214 /// assert!(matches!(result, Err(Error::Rejected { .. })));
215 /// ud.shutdown(ShutdownMode::Immediate).await;
216 /// # Ok(())
217 /// # }
218 /// ```
219 pub async fn call_command<T, F, Fut>(&self, method: &str, f: F) -> Result<T, Error>
220 where
221 F: FnOnce(BcsHttpClient) -> Fut,
222 Fut: Future<Output = Result<T, BcsApiError>>,
223 {
224 self.limiter.acquire(method, brokapi_runtime::now())?;
225 f(self.http.clone()).await.map_err(map_command_err)
226 }
227
228 /// Opens a WebSocket channel (`Limits`, `MarginalIndicators`, ...) with the connector's token.
229 ///
230 /// The quota name is `Connect<Channel>` (`ConnectLimits`). The session reconnects and
231 /// resubscribes on its own. Messages arrive on `event_tx`, parsed by the protocol layer. The
232 /// returned [`WsSession`] connects in the background: it does not wait for the connection.
233 /// Read `event_tx`'s receiver, or the session drops messages when the channel is full (see
234 /// `WsSession::dropped_events`).
235 ///
236 /// # Errors
237 ///
238 /// - [`Error::RateLimited`] when the quota `Connect<Channel>` is spent. No socket is opened.
239 /// - [`Error::Config`] when the limits file routes the name to a bucket that does not exist.
240 ///
241 /// # Examples
242 ///
243 /// ```
244 /// use std::time::Duration;
245 /// use brokapi_bcs_protocol::ws::client::BcsWsChannel;
246 /// use brokapi_bcs::limits::default_limits;
247 /// use brokapi_bcs::{MarketDataConfig, MarketDataConnector};
248 /// use brokapi_core::ShutdownMode;
249 /// use brokapi_runtime::BrokerLimiter;
250 /// use tokio::sync::mpsc;
251 /// # use brokapi_bcs_protocol::config::BcsConfig;
252 ///
253 /// # #[tokio::main(flavor = "current_thread")]
254 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
255 /// # let server = brokapi_mocks::bcs::MockServer::start(Default::default()).await;
256 /// # let cfg = BcsConfig::for_mock("mock-refresh-token", &server.http_base);
257 /// # let (events_tx, _events) = mpsc::channel(256);
258 /// # let limiter = BrokerLimiter::new(&default_limits());
259 /// let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "bcs-md"), limiter, events_tx).await?;
260 ///
261 /// let (tx, _rx) = kanal::bounded_async(64);
262 /// let session = md.raw().connect_ws(BcsWsChannel::Limits, tx)?;
263 /// // The session connects in the background: wait for it, with a limit.
264 /// tokio::time::timeout(Duration::from_secs(5), async {
265 /// while !session.is_connected() {
266 /// tokio::time::sleep(Duration::from_millis(10)).await;
267 /// }
268 /// })
269 /// .await?;
270 /// md.shutdown(ShutdownMode::Immediate).await;
271 /// # Ok(())
272 /// # }
273 /// ```
274 pub fn connect_ws(
275 &self,
276 channel: BcsWsChannel,
277 event_tx: AsyncSender<IncomingMessage>,
278 ) -> Result<WsSession, Error> {
279 self.limiter
280 .acquire(&format!("Connect{channel:?}"), brokapi_runtime::now())?;
281 Ok(WsSession::spawn(channel, self.http.clone(), event_tx))
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 fn http(status: Option<u16>) -> BcsApiError {
290 BcsApiError::HttpError {
291 status,
292 message: "x".into(),
293 trace_id: None,
294 }
295 }
296
297 #[test]
298 fn lost_commands_are_unknown_refused_ones_are_not() {
299 assert!(matches!(map_command_err(http(None)), Error::Unknown));
300 assert!(matches!(map_command_err(http(Some(503))), Error::Unknown));
301 assert!(matches!(
302 map_command_err(BcsApiError::ParseError("x".into())),
303 Error::Unknown
304 ));
305 assert!(matches!(
306 map_command_err(http(Some(429))),
307 Error::RateLimited { .. }
308 ));
309 assert!(matches!(
310 map_command_err(BcsApiError::ValidationError {
311 trace_id: None,
312 errors: vec![]
313 }),
314 Error::Rejected { .. }
315 ));
316 }
317}