Skip to main content

brokapi_tbank/
raw.rs

1//! Direct access to any T-Bank API method ("back door"): same token, same endpoint, same
2//! [`BrokerLimiter`] as the connector it came from.
3//!
4//! The connectors wrap what the shared traits can express. Everything else the API offers (bond
5//! coupons, forecasts, favorites, reports, signals, sandbox, ...) is reachable here, so nothing the
6//! broker supports is out of reach.
7//!
8//! # Examples
9//!
10//! The example runs against the in-process mock. With a real connector the call is the same, only the
11//! config differs. For a method of your own, pass its proto name and call the generated client.
12//!
13//! ```
14//! use brokapi_runtime::BrokerLimiter;
15//! use brokapi_tbank::limits::default_limits;
16//! use brokapi_tbank::{MarketDataConfig, MarketDataConnector};
17//! use brokapi_tbank_protocol::v1 as pb;
18//! use tokio::sync::mpsc;
19//! # use brokapi_core::ShutdownMode;
20//! # use brokapi_mocks::tbank::{MockServer, TOKEN};
21//! # use brokapi_tbank_protocol::TbankConfig;
22//!
23//! # #[tokio::main(flavor = "current_thread")]
24//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
25//! # let server = MockServer::start(Default::default()).await;
26//! # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint);
27//! let (events_tx, _events) = mpsc::channel(64);
28//! let limiter = BrokerLimiter::new(&default_limits());
29//! let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "tbank-md"), limiter, events_tx).await?;
30//! let raw = md.raw();
31//!
32//! // The quota is taken by the proto method name, then the call runs with the connector's token.
33//! let accounts = raw
34//!     .call("GetAccounts", |c| async move {
35//!         c.users().get_accounts(pb::GetAccountsRequest { status: None }).await
36//!     })
37//!     .await?;
38//! assert!(!accounts.accounts.is_empty());
39//! # md.shutdown(ShutdownMode::Immediate).await;
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! Rules of a call:
45//! 1. The quota is taken first, by the proto method name (`"GetBondCoupons"`), from the route in
46//!    `limits.toml`. A name without a route spends the `"*"` default route, so a call
47//!    is never free. A refusal is `Error::RateLimited` and the request is not sent.
48//! 2. One closure is one API method. Do not call several methods inside one closure: only one quota
49//!    unit is taken for it.
50//! 3. The answer is cut by the request timeout of the connector.
51//! 4. Status codes become [`brokapi_core::Error`] like everywhere else in the connector.
52//!
53//! [`call`](TbankRaw::call) is for reads: timeout and transport trouble are retryable errors.
54//! [`call_command`](TbankRaw::call_command) is for anything that changes the account: a timeout or a
55//! break is [`Error::Unknown`] (the broker may have acted), and the library never repeats it.
56//!
57//! The raw layer does not know about the connector's order registry: orders placed through it are
58//! not tracked (no events, no `reconcile`). Use the shared traits for orders.
59
60use std::future::Future;
61use std::sync::Arc;
62use std::time::Duration;
63
64use brokapi_core::Error;
65use brokapi_runtime::BrokerLimiter;
66use brokapi_tbank_protocol::TbankClient;
67use tonic::{Response, Status};
68
69use crate::util::{CommandOutcome, command, query};
70
71/// Direct access to any T-Bank API method, made by [`MarketDataConnector::raw`] or
72/// [`UserDataConnector::raw`]. See the [module docs](self). Cheap to clone.
73///
74/// Reads go through [`call`](Self::call). Anything that changes the account goes through
75/// [`call_command`](Self::call_command), which never lets a lost answer look like a refusal.
76///
77/// [`MarketDataConnector::raw`]: crate::MarketDataConnector::raw
78/// [`UserDataConnector::raw`]: crate::UserDataConnector::raw
79///
80/// # Examples
81///
82/// A command that the broker refuses is [`Error::Rejected`]. Here the order does not exist.
83///
84/// ```
85/// use brokapi_core::Error;
86/// use brokapi_runtime::BrokerLimiter;
87/// use brokapi_tbank::limits::default_limits;
88/// use brokapi_tbank::{UserDataConfig, UserDataConnector};
89/// use brokapi_tbank_protocol::v1 as pb;
90/// use tokio::sync::mpsc;
91/// # use brokapi_core::ShutdownMode;
92/// # use brokapi_mocks::tbank::{MockServer, ACCOUNT_A, TOKEN};
93/// # use brokapi_tbank_protocol::TbankConfig;
94///
95/// # #[tokio::main(flavor = "current_thread")]
96/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
97/// # let server = MockServer::start(Default::default()).await;
98/// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint).with_account(ACCOUNT_A);
99/// let (events_tx, _events) = mpsc::channel(64);
100/// let limiter = BrokerLimiter::new(&default_limits());
101/// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "tbank-ud"), limiter, events_tx).await?;
102/// let raw = ud.raw();
103/// let account = raw.account_id().expect("a user data connector has an account").to_string();
104///
105/// let result = raw
106///     .call_command("CancelOrder", |c| async move {
107///         c.orders()
108///             .cancel_order(pb::CancelOrderRequest {
109///                 account_id: account,
110///                 order_id: "no-such-order".into(),
111///                 order_id_type: Some(pb::OrderIdType::Request as i32),
112///             })
113///             .await
114///     })
115///     .await;
116/// assert!(matches!(result, Err(Error::Rejected { .. })));
117/// # ud.shutdown(ShutdownMode::Immediate).await;
118/// # Ok(())
119/// # }
120/// ```
121#[derive(Clone)]
122pub struct TbankRaw {
123    client: TbankClient,
124    limiter: Arc<BrokerLimiter>,
125    timeout: Duration,
126    account: Option<String>,
127}
128
129impl TbankRaw {
130    pub(crate) fn new(
131        client: TbankClient,
132        limiter: Arc<BrokerLimiter>,
133        timeout: Duration,
134        account: Option<String>,
135    ) -> Self {
136        Self {
137            client,
138            limiter,
139            timeout,
140            account,
141        }
142    }
143
144    /// Account of the connector this came from (user data connector only).
145    ///
146    /// `None` for a market data connector, which has no account. The raw layer does not add the
147    /// account to your requests: put it into the request yourself.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use brokapi_runtime::BrokerLimiter;
153    /// use brokapi_tbank::limits::default_limits;
154    /// use brokapi_tbank::{MarketDataConfig, MarketDataConnector};
155    /// use tokio::sync::mpsc;
156    /// # use brokapi_core::ShutdownMode;
157    /// # use brokapi_mocks::tbank::{MockServer, TOKEN};
158    /// # use brokapi_tbank_protocol::TbankConfig;
159    ///
160    /// # #[tokio::main(flavor = "current_thread")]
161    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
162    /// # let server = MockServer::start(Default::default()).await;
163    /// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint);
164    /// let (events_tx, _events) = mpsc::channel(64);
165    /// let md = MarketDataConnector::start(
166    ///     MarketDataConfig::new(cfg, "tbank-md"),
167    ///     BrokerLimiter::new(&default_limits()),
168    ///     events_tx,
169    /// )
170    /// .await?;
171    /// assert_eq!(md.raw().account_id(), None);
172    /// # md.shutdown(ShutdownMode::Immediate).await;
173    /// # Ok(())
174    /// # }
175    /// ```
176    pub fn account_id(&self) -> Option<&str> {
177        self.account.as_deref()
178    }
179
180    /// The deadline applied to every call: the `request_timeout` of the connector's config (30 s by
181    /// default).
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use std::time::Duration;
187    /// use brokapi_runtime::BrokerLimiter;
188    /// use brokapi_tbank::limits::default_limits;
189    /// use brokapi_tbank::{MarketDataConfig, MarketDataConnector};
190    /// use tokio::sync::mpsc;
191    /// # use brokapi_core::ShutdownMode;
192    /// # use brokapi_mocks::tbank::{MockServer, TOKEN};
193    /// # use brokapi_tbank_protocol::TbankConfig;
194    ///
195    /// # #[tokio::main(flavor = "current_thread")]
196    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
197    /// # let server = MockServer::start(Default::default()).await;
198    /// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint).with_request_timeout(Duration::from_secs(5));
199    /// let (events_tx, _events) = mpsc::channel(64);
200    /// let md = MarketDataConnector::start(
201    ///     MarketDataConfig::new(cfg, "tbank-md"),
202    ///     BrokerLimiter::new(&default_limits()),
203    ///     events_tx,
204    /// )
205    /// .await?;
206    /// assert_eq!(md.raw().timeout(), Duration::from_secs(5));
207    /// # md.shutdown(ShutdownMode::Immediate).await;
208    /// # Ok(())
209    /// # }
210    /// ```
211    pub fn timeout(&self) -> Duration {
212        self.timeout
213    }
214
215    /// Reads: takes quota for `method`, runs the call, maps errors.
216    ///
217    /// For a server-streaming method the result type is `tonic::Streaming<_>`: the quota covers
218    /// opening the stream, the stream is then yours to read.
219    ///
220    /// # Errors
221    ///
222    /// - [`Error::RateLimited`] when the limiter has no quota for `method`. The request is not sent.
223    ///   Wait `retry_after` and call again.
224    /// - [`Error::Transport`] on a timeout or a network status. A read may be repeated.
225    /// - [`Error::Rejected`] when the broker refuses the request (`InvalidArgument`,
226    ///   `NotFound`, which is the code `not_found`, `PermissionDenied`, ...).
227    /// - [`Error::Config`] when the token is not accepted (`Unauthenticated`).
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use brokapi_core::Error;
233    /// use brokapi_runtime::BrokerLimiter;
234    /// use brokapi_tbank::limits::limits_from_toml_str;
235    /// use brokapi_tbank::{MarketDataConfig, MarketDataConnector};
236    /// use brokapi_tbank_protocol::v1 as pb;
237    /// use tokio::sync::mpsc;
238    /// # use brokapi_core::ShutdownMode;
239    /// # use brokapi_mocks::tbank::{MockServer, TOKEN};
240    /// # use brokapi_tbank_protocol::TbankConfig;
241    ///
242    /// # #[tokio::main(flavor = "current_thread")]
243    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
244    /// # let server = MockServer::start(Default::default()).await;
245    /// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint);
246    /// // A tiny quota for the demo: one call per minute for every method.
247    /// let limits = limits_from_toml_str(
248    ///     r#"
249    /// [tbank.buckets.one]
250    /// rate = 1
251    /// per = "60s"
252    /// burst = 1
253    /// [tbank.route]
254    /// "*" = ["one"]
255    /// [tbank.conns]
256    /// max_conns = 1
257    /// max_subs_per_conn = 1
258    /// supports_multiplex = false
259    /// "#,
260    /// )?;
261    /// let (events_tx, _events) = mpsc::channel(64);
262    /// let md = MarketDataConnector::start(MarketDataConfig::new(cfg, "tbank-md"), BrokerLimiter::new(&limits), events_tx).await?;
263    /// let raw = md.raw();
264    ///
265    /// let list = |c: brokapi_tbank_protocol::TbankClient| async move {
266    ///     c.users().get_accounts(pb::GetAccountsRequest { status: None }).await
267    /// };
268    /// assert!(raw.call("GetAccounts", list).await.is_ok());
269    /// // The second call finds the bucket empty: it is refused locally and never sent.
270    /// assert!(matches!(raw.call("GetAccounts", list).await, Err(Error::RateLimited { .. })));
271    /// # md.shutdown(ShutdownMode::Immediate).await;
272    /// # Ok(())
273    /// # }
274    /// ```
275    pub async fn call<T, F, Fut>(&self, method: &str, f: F) -> Result<T, Error>
276    where
277        F: FnOnce(TbankClient) -> Fut,
278        Fut: Future<Output = Result<Response<T>, Status>>,
279    {
280        self.limiter.acquire(method, brokapi_runtime::now())?;
281        query(self.timeout, f(self.client.clone())).await
282    }
283
284    /// Commands: like [`call`](Self::call), but a timeout, a break or a server fault is
285    /// `Error::Unknown`, because the broker may have executed the command. Never retried.
286    ///
287    /// # Errors
288    ///
289    /// - [`Error::RateLimited`] when the limiter has no quota. Nothing was sent.
290    /// - [`Error::Rejected`] when the broker refuses (`InvalidArgument`, `NotFound`,
291    ///   `PermissionDenied`, `FailedPrecondition`, ...). The broker did not act.
292    /// - [`Error::Config`] when the token is not accepted. The broker did not act.
293    /// - [`Error::Unknown`] on a timeout, a broken connection or a server fault (`Unavailable`,
294    ///   `Internal`, ...). The command may or may not have been done. Do not repeat it. Ask the
295    ///   broker for the state of the order or account first.
296    ///
297    /// # Examples
298    ///
299    /// ```
300    /// use brokapi_core::Error;
301    /// use brokapi_runtime::BrokerLimiter;
302    /// use brokapi_tbank::limits::default_limits;
303    /// use brokapi_tbank::{UserDataConfig, UserDataConnector};
304    /// use brokapi_tbank_protocol::v1 as pb;
305    /// use tokio::sync::mpsc;
306    /// # use brokapi_core::ShutdownMode;
307    /// # use brokapi_mocks::tbank::{MockServer, ACCOUNT_A, TOKEN};
308    /// # use brokapi_tbank_protocol::TbankConfig;
309    ///
310    /// # #[tokio::main(flavor = "current_thread")]
311    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
312    /// # let server = MockServer::start(Default::default()).await;
313    /// # let cfg = TbankConfig::for_mock(TOKEN, &server.endpoint).with_account(ACCOUNT_A);
314    /// let (events_tx, _events) = mpsc::channel(64);
315    /// let limiter = BrokerLimiter::new(&default_limits());
316    /// let ud = UserDataConnector::start(UserDataConfig::new(cfg, "tbank-ud"), limiter, events_tx).await?;
317    ///
318    /// // Cancelling an order the broker does not know is a definite refusal.
319    /// let outcome = ud
320    ///     .raw()
321    ///     .call_command("CancelOrder", |c| async move {
322    ///         c.orders()
323    ///             .cancel_order(pb::CancelOrderRequest {
324    ///                 account_id: ACCOUNT_A.into(),
325    ///                 order_id: "no-such-order".into(),
326    ///                 order_id_type: Some(pb::OrderIdType::Request as i32),
327    ///             })
328    ///             .await
329    ///     })
330    ///     .await;
331    /// match outcome {
332    ///     Err(Error::Rejected { code, .. }) => assert_eq!(code, "not_found"),
333    ///     other => panic!("expected a refusal, got {other:?}"),
334    /// }
335    /// # ud.shutdown(ShutdownMode::Immediate).await;
336    /// # Ok(())
337    /// # }
338    /// ```
339    pub async fn call_command<T, F, Fut>(&self, method: &str, f: F) -> Result<T, Error>
340    where
341        F: FnOnce(TbankClient) -> Fut,
342        Fut: Future<Output = Result<Response<T>, Status>>,
343    {
344        self.limiter.acquire(method, brokapi_runtime::now())?;
345        match command(self.timeout, f(self.client.clone())).await {
346            CommandOutcome::Done(v) => Ok(v),
347            CommandOutcome::Refused(_, err) => Err(err),
348            CommandOutcome::Unknown => Err(Error::Unknown),
349        }
350    }
351}