Skip to main content

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 &quotes {
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}