Skip to main content

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}