brokapi_alor/raw.rs
1//! Direct access to any ALOR API method (the "back door"): same refresh token and access token, same endpoint,
2//! same [`BrokerLimiter`] as the connector it came from.
3//!
4//! The connectors wrap what the shared traits can express. Everything else (quotations of many instruments, the
5//! summary and the risks of the portfolio, order groups, estimates, order cancel for all, the legacy stop-loss
6//! family, the other subscriptions of the data channel, the trading channel) is reachable here. Get a handle with
7//! `raw()` on either connector.
8//!
9//! Rules of a call:
10//!
11//! 1. The quota is taken first, by the operation name (`"GetQuotes"`), from the route in `limits.toml`. A name
12//! without a route spends the `"*"` default route, so a call is never free. A refusal is
13//! [`Error::RateLimited`] and the request is not sent.
14//! 2. The closure gets the connector's [`AlorHttpClient`]: it holds the access token, refreshes it and repeats a
15//! read once after a 401. One closure is one API method.
16//! 3. [`call`](AlorRaw::call) is for reads. [`call_command`](AlorRaw::call_command) is for anything that changes
17//! the account: a break, a timeout or a 5xx is [`Error::Unknown`], and it is never retried.
18//! 4. [`connect_ws`](AlorRaw::connect_ws) opens one more session of the data channel and
19//! [`connect_cws`](AlorRaw::connect_cws) one of the trading channel, with the same token.
20//!
21//! The raw layer does not know about the connector's order state: orders placed through it are not tracked (no
22//! events, no `reconcile`). Use the shared traits for orders.
23//!
24//! # Examples
25//!
26//! A read that no shared trait wraps, the quotations of several instruments, through the market data connector:
27//!
28//! ```
29//! use brokapi_alor::limits::default_limits;
30//! use brokapi_alor::{MarketDataConfig, MarketDataConnector};
31//! use brokapi_core::ShutdownMode;
32//! use brokapi_runtime::BrokerLimiter;
33//! use tokio::sync::mpsc;
34//! # use brokapi_alor_protocol::config::AlorConfig;
35//!
36//! # #[tokio::main(flavor = "current_thread")]
37//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
38//! # let server = brokapi_mocks::alor::MockServer::start(Default::default()).await;
39//! # let cfg = AlorConfig::for_mock("mock-refresh-token", &server.http_base);
40//! let (events_tx, _events) = mpsc::channel(256);
41//! let limiter = BrokerLimiter::new(&default_limits());
42//! let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "alor-md"), limiter, events_tx).await?;
43//!
44//! let raw = md.raw();
45//! // "GetQuotes" is the name of the quota route. The closure is one API method.
46//! let quotes = raw
47//! .call("GetQuotes", |http| async move { http.get_quotes(&[("MOEX", "SBER")]).await })
48//! .await?;
49//! for q in "es {
50//! println!("{} bid {:?} ask {:?}", q.symbol, q.bid, q.ask);
51//! }
52//! md.shutdown(ShutdownMode::Immediate).await;
53//! # Ok(())
54//! # }
55//! ```
56
57use std::future::Future;
58use std::sync::Arc;
59use std::time::Duration;
60
61use brokapi_alor_protocol::error::AlorApiError;
62use brokapi_alor_protocol::http::client::AlorHttpClient;
63use brokapi_alor_protocol::ws::cws::CwsSession;
64use brokapi_alor_protocol::ws::models::WsIncoming;
65use brokapi_alor_protocol::ws::session::{WsOptions, 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 ALOR 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 token and the
76/// limiter. See the [module docs](self) for the rules of a call.
77#[derive(Clone)]
78pub struct AlorRaw {
79 http: AlorHttpClient,
80 limiter: Arc<BrokerLimiter>,
81}
82
83impl AlorRaw {
84 pub(crate) fn new(http: AlorHttpClient, 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` (for example
91 /// `GetOrders`). An unknown name spends the default route `"*"`. `f` receives the connector's
92 /// [`AlorHttpClient`] and returns the future of one API call. Do not use this for anything that changes the
93 /// account, use [`call_command`](Self::call_command).
94 ///
95 /// # Errors
96 ///
97 /// - [`Error::RateLimited`] when the quota is spent (nothing was sent), or the broker answers HTTP 429.
98 /// - [`Error::Rejected`] with code `not_found` for HTTP 404.
99 /// - [`Error::Transport`] for everything else: no connection, a timeout, an unreadable answer, any other HTTP
100 /// status. A read can be repeated.
101 /// - [`Error::Config`] when the limits file routes the name to a bucket that does not exist, or the broker
102 /// refuses the token.
103 ///
104 /// The full table is in [`crate::error::map_err`].
105 pub async fn call<T, F, Fut>(&self, method: &str, f: F) -> Result<T, Error>
106 where
107 F: FnOnce(AlorHttpClient) -> Fut,
108 Fut: Future<Output = Result<T, AlorApiError>>,
109 {
110 self.limiter.acquire(method, brokapi_runtime::now())?;
111 f(self.http.clone()).await.map_err(map_err)
112 }
113
114 /// Calls an API method that changes the account: takes the quota for `method`, then runs `f`.
115 ///
116 /// Like [`call`](Self::call), but anything that may have reached the broker is [`Error::Unknown`]. The
117 /// library never repeats the call: a retry of a lost place request could create a second order. Find out
118 /// what happened (read the order, run `reconcile`) before you decide to send it again. The order is not
119 /// tracked by the connector (see the module docs).
120 ///
121 /// # Errors
122 ///
123 /// - [`Error::Unknown`] when there was no answer, a break or a timeout, HTTP 500 or above, or an answer that
124 /// cannot be read. The command may have been done.
125 /// - [`Error::Rejected`] when the broker answered and said no: code `http_<status>` for a status from 400 to
126 /// 499 except 429, `not_found` for 404. Nothing was done.
127 /// - [`Error::RateLimited`] when the quota is spent (nothing was sent) or the broker answers HTTP 429.
128 /// - [`Error::Config`] when the limits file routes the name to a bucket that does not exist.
129 ///
130 /// The full table is in [`crate::error::map_command_err`].
131 ///
132 /// # Examples
133 ///
134 /// An order sent through the raw layer, refused by the broker (the mock refuses a limit order at
135 /// `REJECT_PRICE`). The refusal is a definite [`Error::Rejected`], not an unknown outcome:
136 ///
137 /// ```
138 /// use brokapi_alor_protocol::http::models::{InstrumentRef, LimitOrderRequest, UserRef};
139 /// use brokapi_alor::limits::default_limits;
140 /// use brokapi_alor::{UserDataConfig, UserDataConnector};
141 /// use brokapi_conformance::REJECT_PRICE;
142 /// use brokapi_core::{Error, ShutdownMode};
143 /// use brokapi_runtime::BrokerLimiter;
144 /// use tokio::sync::mpsc;
145 /// # use brokapi_alor_protocol::config::AlorConfig;
146 ///
147 /// # #[tokio::main(flavor = "current_thread")]
148 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
149 /// # let server = brokapi_mocks::alor::MockServer::start(Default::default()).await;
150 /// # let cfg = AlorConfig::for_mock("mock-refresh-token", &server.http_base);
151 /// # let (events_tx, _events) = mpsc::channel(256);
152 /// # let limiter = BrokerLimiter::new(&default_limits());
153 /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "alor-ud"), limiter, events_tx).await?;
154 ///
155 /// let order = LimitOrderRequest {
156 /// side: "buy".into(),
157 /// quantity: 1,
158 /// price: REJECT_PRICE.decimal(),
159 /// instrument: InstrumentRef { symbol: "SBER".into(), exchange: "MOEX".into(), instrument_group: Some("TQBR".into()) },
160 /// user: UserRef { portfolio: "D00001".into() },
161 /// comment: None,
162 /// time_in_force: None,
163 /// allow_margin: None,
164 /// iceberg_fixed: None,
165 /// iceberg_variance: None,
166 /// };
167 /// let result = ud
168 /// .raw()
169 /// .call_command("PlaceOrder", |http| async move {
170 /// http.create_limit_order("my-own-request-id-1", &order).await
171 /// })
172 /// .await;
173 /// assert!(matches!(result, Err(Error::Rejected { .. })));
174 /// ud.shutdown(ShutdownMode::Immediate).await;
175 /// # Ok(())
176 /// # }
177 /// ```
178 pub async fn call_command<T, F, Fut>(&self, method: &str, f: F) -> Result<T, Error>
179 where
180 F: FnOnce(AlorHttpClient) -> Fut,
181 Fut: Future<Output = Result<T, AlorApiError>>,
182 {
183 self.limiter.acquire(method, brokapi_runtime::now())?;
184 f(self.http.clone()).await.map_err(map_command_err)
185 }
186
187 /// Opens one more session of the data channel `/ws` (for the subscriptions the connectors do not make:
188 /// risks, summaries, instruments, ...) with the connector's token.
189 ///
190 /// The quota name is `ConnectWs`. The session reconnects and resubscribes on its own. Frames arrive on
191 /// `event_tx`, parsed. The returned [`WsSession`] connects in the background: it does not wait for the
192 /// connection. Read the receiver of `event_tx`, or the session drops frames when the channel is full (see
193 /// `WsSession::dropped_events`). The broker allows 10 connections of a user.
194 ///
195 /// # Errors
196 ///
197 /// - [`Error::RateLimited`] when the quota `ConnectWs` is spent. No socket is opened.
198 /// - [`Error::Config`] when the limits file routes the name to a bucket that does not exist.
199 pub fn connect_ws(
200 &self,
201 event_tx: AsyncSender<WsIncoming>,
202 options: WsOptions,
203 ) -> Result<WsSession, Error> {
204 self.limiter.acquire("ConnectWs", brokapi_runtime::now())?;
205 Ok(WsSession::spawn_with(self.http.clone(), event_tx, options))
206 }
207
208 /// Opens the trading channel `/cws` with the connector's token and authorizes it. `answer_timeout` is how
209 /// long a command waits for its answer.
210 ///
211 /// The quota name is `ConnectCws`. The channel gives the order commands that HTTP gives; the connectors use
212 /// HTTP. See [`CwsSession`].
213 ///
214 /// # Errors
215 ///
216 /// - [`Error::RateLimited`] when the quota `ConnectCws` is spent.
217 /// - [`Error::Transport`] when the connection or the authorization fails.
218 pub async fn connect_cws(&self, answer_timeout: Duration) -> Result<CwsSession, Error> {
219 self.limiter.acquire("ConnectCws", brokapi_runtime::now())?;
220 CwsSession::connect(self.http.clone(), answer_timeout)
221 .await
222 .map_err(map_err)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn lost_commands_are_unknown_refused_ones_are_not() {
232 let http = |status| AlorApiError::Http {
233 status,
234 message: "x".into(),
235 };
236 assert!(matches!(map_command_err(http(None)), Error::Unknown));
237 assert!(matches!(map_command_err(http(Some(502))), Error::Unknown));
238 assert!(matches!(
239 map_command_err(http(Some(400))),
240 Error::Rejected { .. }
241 ));
242 }
243}