brokapi_tbank/limits.rs
1//! Loads `limits.toml` (section `[tbank]`) through `BrokerLimits::from_toml_section`.
2//!
3//! One `BrokerLimiter` per user (all accounts, all tokens, one IP): see the scope notes in the toml.
4//!
5//! T-Bank counts its limits per user, not per account and not per token. So every connector of one
6//! user must share ONE limiter, or the sum of their requests can pass the broker's cap while each
7//! connector thinks it is within its own quota. Build the limiter once and give each connector a
8//! clone of the `Arc`.
9//!
10//! The bundled defaults are the figures from the broker's documentation. T-Bank makes the real
11//! limits dynamic (they depend on the user's tariff), so the figures here are a safe start and not
12//! verified against the live API.
13//!
14//! # Examples
15//!
16//! ```
17//! use std::sync::Arc;
18//! use brokapi_runtime::BrokerLimiter;
19//! use brokapi_tbank::limits::default_limits;
20//!
21//! // One limiter for everything one user runs. Give `Arc::clone(&limiter)` to every connector:
22//! // all market data connectors and all user data connectors, whatever their account.
23//! let limiter = BrokerLimiter::new(&default_limits());
24//! let for_account_a = Arc::clone(&limiter);
25//! let for_account_b = Arc::clone(&limiter);
26//! assert!(Arc::ptr_eq(&for_account_a, &for_account_b));
27//! ```
28
29use brokapi_core::ConfigError;
30use brokapi_runtime::BrokerLimits;
31
32/// The bundled default limits, as documented by T-Bank (the text of `limits.toml`).
33///
34/// Parse it with [`limits_from_toml_str`], or call [`default_limits`].
35pub const DEFAULT_LIMITS_TOML: &str = include_str!("../limits.toml");
36
37/// Reads the `[tbank]` section of a limits file given as text.
38///
39/// Use it to replace the bundled figures, for example after `GetUserTariff` showed that your tariff
40/// has other limits.
41///
42/// # Errors
43///
44/// [`ConfigError::Message`] when the text is not valid TOML, the `[tbank]` section is missing, or
45/// it is invalid.
46///
47/// # Examples
48///
49/// ```
50/// use brokapi_tbank::limits::{limits_from_toml_str, DEFAULT_LIMITS_TOML};
51///
52/// assert!(limits_from_toml_str(DEFAULT_LIMITS_TOML).is_ok());
53/// // A file without a `[tbank]` section is refused.
54/// assert!(limits_from_toml_str("[bcs.conns]").is_err());
55/// ```
56pub fn limits_from_toml_str(raw: &str) -> Result<BrokerLimits, ConfigError> {
57 BrokerLimits::from_toml_section(raw, "tbank")
58}
59
60/// Reads the `[tbank]` section of a limits file on disk.
61///
62/// # Errors
63///
64/// [`ConfigError::Message`] when the file cannot be read (the message names the path), and for
65/// every reason [`limits_from_toml_str`] lists.
66///
67/// # Examples
68///
69/// ```
70/// let err = brokapi_tbank::limits::limits_from_toml_path("/no/such/limits.toml").unwrap_err();
71/// assert!(err.to_string().contains("/no/such/limits.toml"));
72/// ```
73pub fn limits_from_toml_path(path: &str) -> Result<BrokerLimits, ConfigError> {
74 let raw = std::fs::read_to_string(path)
75 .map_err(|e| ConfigError::Message(format!("read {path}: {e}")))?;
76 limits_from_toml_str(&raw)
77}
78
79/// The bundled default limits of T-Bank, ready for [`BrokerLimiter::new`](brokapi_runtime::BrokerLimiter::new).
80///
81/// Every unary method of the API has a route (placing and cancelling orders have buckets of their
82/// own). A method name without a route spends the `"*"` default route, so no call is free.
83///
84/// # Panics
85///
86/// Never in practice: the bundled file is checked by this crate's tests.
87///
88/// # Examples
89///
90/// ```
91/// use brokapi_tbank::limits::default_limits;
92///
93/// let limits = default_limits();
94/// // Placing and cancelling spend different buckets: a busy cancel loop cannot starve placing.
95/// assert!(limits.buckets_for("PostOrder").contains(&"order_post".to_string()));
96/// assert!(limits.buckets_for("CancelOrder").contains(&"order_cancel".to_string()));
97/// ```
98pub fn default_limits() -> BrokerLimits {
99 limits_from_toml_str(DEFAULT_LIMITS_TOML).expect("bundled tbank_limits.toml is valid")
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn bundled_limits_parse_and_route() {
108 let l = default_limits();
109 for method in [
110 "PostOrder",
111 "CancelOrder",
112 "ReplaceOrder",
113 "GetOrders",
114 "GetOrderState",
115 "GetOrderBook",
116 "GetLastTrades",
117 "GetCandles",
118 "GetTradingStatus",
119 "SubscribeMarketData",
120 "FindInstrument",
121 "GetInstrumentBy",
122 "TradingSchedules",
123 "GetPortfolio",
124 "GetPositions",
125 "GetOperationsByCursor",
126 "GetAccounts",
127 "Shares",
128 "Bonds",
129 "Etfs",
130 "Futures",
131 "Options",
132 "Currencies",
133 ] {
134 let buckets = l.buckets_for(method);
135 assert!(buckets.len() >= 2, "{method} not routed");
136 for b in buckets {
137 assert!(l.buckets.contains_key(&b), "{method}: unknown bucket {b}");
138 }
139 }
140 assert!(l.conns.supports_multiplex);
141 }
142
143 #[test]
144 fn cancel_has_its_own_bucket() {
145 let l = default_limits();
146 let place = l.buckets_for("PostOrder");
147 let cancel = l.buckets_for("CancelOrder");
148 assert!(place.contains(&"order_post".to_string()));
149 assert!(cancel.contains(&"order_cancel".to_string()));
150 assert!(!cancel.contains(&"order_post".to_string()));
151 }
152
153 /// Every unary method of every .proto has a route of its own (the documented bucket of its
154 /// service). Streams are opened rarely and use the "*" default route.
155 #[test]
156 fn every_unary_method_of_the_api_is_routed() {
157 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tbank-protocol/proto");
158 let l = default_limits();
159 let mut missing = Vec::new();
160 let mut seen = 0;
161 for entry in std::fs::read_dir(dir).expect("proto dir") {
162 let path = entry.expect("entry").path();
163 if path.extension().is_none_or(|e| e != "proto") {
164 continue;
165 }
166 for line in std::fs::read_to_string(&path).expect("read proto").lines() {
167 let line = line.trim();
168 let Some(rest) = line.strip_prefix("rpc ") else {
169 continue;
170 };
171 if rest.contains("stream ") {
172 continue;
173 }
174 let name = rest.split('(').next().expect("rpc name").trim();
175 seen += 1;
176 if !l.route.contains_key(name) {
177 missing.push(name.to_string());
178 }
179 }
180 }
181 assert!(seen > 80, "proto files not found ({seen} methods)");
182 assert!(
183 missing.is_empty(),
184 "unary methods without a route: {missing:?}"
185 );
186 }
187
188 #[test]
189 fn unnamed_method_uses_the_default_route() {
190 let l = default_limits();
191 let b = l.buckets_for("SomethingTheApiAddsNextYear");
192 assert_eq!(b, vec!["default".to_string(), "global".to_string()]);
193 }
194}