Skip to main content

brokapi_bcs/
limits.rs

1//! Loads `limits.toml` (section `[bcs]`) through `BrokerLimits::from_toml_section`.
2//!
3//! BCS publishes no numbers of its own, so the bundled values are cautious starting points, to be
4//! lowered if `RateLimited` comes too early. One `BrokerLimiter` per token: give every
5//! connector of one BCS account the same `Arc<BrokerLimiter>`.
6
7use brokapi_core::ConfigError;
8use brokapi_runtime::BrokerLimits;
9
10/// The text of the bundled limits file `limits.toml`, compiled into the crate.
11///
12/// Parse it with [`limits_from_toml_str`], or take the result directly from [`default_limits`].
13pub const DEFAULT_LIMITS_TOML: &str = include_str!("../limits.toml");
14
15/// Reads the `[bcs]` section of a limits file given as text.
16///
17/// Other sections of the file (other brokers) are ignored. Give every connector of one BCS account
18/// the same `BrokerLimiter` built from the result, because the limits belong to the token.
19///
20/// # Errors
21///
22/// [`brokapi_core::ConfigError::Message`] when the text is not valid TOML, when there is no `[bcs]`
23/// section, or when the section is not a valid set of limits (see
24/// [`BrokerLimits::from_toml_section`]).
25///
26/// # Examples
27///
28/// ```
29/// use brokapi_bcs::limits::{limits_from_toml_str, DEFAULT_LIMITS_TOML};
30///
31/// assert!(limits_from_toml_str(DEFAULT_LIMITS_TOML).is_ok());
32/// // A file for another broker has no [bcs] section.
33/// assert!(limits_from_toml_str("[tbank.buckets.global]\nrate = 1").is_err());
34/// ```
35pub fn limits_from_toml_str(raw: &str) -> Result<BrokerLimits, ConfigError> {
36    BrokerLimits::from_toml_section(raw, "bcs")
37}
38
39/// Reads the `[bcs]` section of a limits file on disk.
40///
41/// Use it when you tune the limits for your account without recompiling. Same as
42/// [`limits_from_toml_str`] after reading the file.
43///
44/// # Errors
45///
46/// [`brokapi_core::ConfigError::Message`] when the file cannot be read (the message names the path),
47/// and for every reason [`limits_from_toml_str`] lists.
48///
49/// # Examples
50///
51/// ```
52/// use brokapi_bcs::limits::limits_from_toml_path;
53///
54/// let err = limits_from_toml_path("/definitely/not/here.toml").unwrap_err();
55/// assert!(err.to_string().contains("/definitely/not/here.toml"));
56/// ```
57pub fn limits_from_toml_path(path: &str) -> Result<BrokerLimits, ConfigError> {
58    let raw = std::fs::read_to_string(path)
59        .map_err(|e| ConfigError::Message(format!("read {path}: {e}")))?;
60    limits_from_toml_str(&raw)
61}
62
63/// The bundled BCS limits, parsed.
64///
65/// BCS publishes no numbers of its own, so these are cautious starting values, not the broker's
66/// real limits (not verified against the live API). A method with no route of its own spends the
67/// default route `"*"`, so no call is free. Cancel has its own bucket, separate from placing, so a
68/// burst of new orders cannot block a cancel.
69///
70/// # Panics
71///
72/// Never in practice: the bundled file is checked by a test of this crate.
73///
74/// # Examples
75///
76/// ```
77/// use brokapi_bcs::limits::default_limits;
78/// use brokapi_runtime::BrokerLimiter;
79/// use std::time::Instant;
80///
81/// let limiter = BrokerLimiter::new(&default_limits());
82/// assert!(limiter.acquire("PlaceOrder", Instant::now()).is_ok());
83/// ```
84pub fn default_limits() -> BrokerLimits {
85    limits_from_toml_str(DEFAULT_LIMITS_TOML).expect("bundled bcs_limits.toml is valid")
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn bundled_limits_parse_and_every_route_points_to_a_bucket() {
94        let l = default_limits();
95        // Every method a connector or raw() names.
96        for method in [
97            "PlaceOrder",
98            "CancelOrder",
99            "ReplaceOrder",
100            "PlaceStopOrder",
101            "CancelStopOrder",
102            "GetOrder",
103            "SearchOrders",
104            "SearchTrades",
105            "GetOrderBook",
106            "GetLastTrades",
107            "GetCandles",
108            "GetPortfolio",
109            "Reconcile",
110            "GetTradingStatus",
111            "GetDailySchedule",
112            "GetInstrumentsByTickers",
113            "GetInstrumentsByIsins",
114            "GetInstrumentsByType",
115        ] {
116            let buckets = l.buckets_for(method);
117            assert!(!buckets.is_empty(), "{method} is not routed");
118            for b in buckets {
119                assert!(l.buckets.contains_key(&b), "{method}: unknown bucket {b}");
120            }
121        }
122        // A method with no route of its own spends the default route.
123        assert!(!l.buckets_for("SomethingNew").is_empty());
124        // Placing goes through a soft gate of its own that cancel does not pass: a flood of placements cannot
125        // use up the whole budget of the orders service, a cancel still has room.
126        assert!(l.route["PlaceOrder"].contains(&"place".to_string()));
127        assert!(!l.route["CancelOrder"].contains(&"place".to_string()));
128        let (place, orders) = (&l.buckets["place"], &l.buckets["orders"]);
129        assert!(place.rate < orders.rate && place.burst < orders.burst);
130    }
131
132    #[test]
133    fn a_file_with_no_bcs_section_is_refused() {
134        assert!(limits_from_toml_str("[tbank.buckets.global]\nrate=1").is_err());
135    }
136}