Skip to main content

polyoxide_binance/spot/
mod.rs

1//! # Spot
2//!
3//! Client for Binance spot public market data (`api.binance.com`), behind the
4//! `spot` feature, which is on by default. No credentials are needed.
5//!
6//! Every request is charged against a [`WeightBudget`], because Binance limits
7//! each IP by request *weight*, which varies by route and parameters, rather
8//! than by request count. The budget is 5400 of the published 6000 per minute;
9//! the venue's `RAW_REQUESTS` limit is not modelled, because every request
10//! weighs at least 1 and the budget therefore caps a client at 27,000 requests
11//! per five minutes, under that limit's 300,000.
12//!
13//! ```no_run
14//! use polyoxide_binance::spot::{types::Symbol, Spot};
15//!
16//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
17//! let spot = Spot::new()?;
18//! let btc = Symbol::new("BTCUSDT")?;
19//! let info = spot.exchange().exchange_info().symbol(&btc).send().await?;
20//! println!("{} symbols listed", info.symbols.len());
21//! # Ok(())
22//! # }
23//! ```
24
25pub mod api;
26pub mod error;
27pub mod key;
28mod policy;
29pub mod request;
30pub mod types;
31pub mod venue;
32pub mod weight;
33#[cfg(feature = "spot-ws")]
34pub mod ws;
35
36pub use error::SpotError;
37pub use key::key;
38pub use weight::WeightBudget;
39
40use polyoxide_core::{ClientConfig, HttpClient};
41
42use crate::spot::{
43    api::{exchange::ExchangeApi, health::Health, market::MarketApi},
44    policy::SpotRetryPolicy,
45};
46
47/// Production spot REST host.
48pub const DEFAULT_BASE_URL: &str = "https://api.binance.com";
49
50/// In-flight requests the client allows by default, as in the sibling crates.
51pub const DEFAULT_MAX_CONCURRENT: usize = 4;
52
53/// Client for Binance spot public market data. No credentials are needed.
54#[derive(Debug, Clone)]
55pub struct Spot {
56    http: HttpClient,
57    budget: WeightBudget,
58}
59
60impl Spot {
61    /// A client with default settings and its own [`WeightBudget`].
62    pub fn new() -> Result<Self, SpotError> {
63        Self::builder().build()
64    }
65
66    /// Start configuring a client.
67    pub fn builder() -> SpotBuilder {
68        SpotBuilder::new()
69    }
70
71    polyoxide_core::namespaces! { http;
72        /// Liveness: `ping`, `time`.
73        health: Health,
74        /// Reference data: `exchangeInfo`.
75        exchange: ExchangeApi,
76        /// Market data: depth, trades, aggregate trades, klines, the average
77        /// price and the tickers.
78        market: MarketApi,
79    }
80
81    /// The budget this client charges.
82    pub fn weight_budget(&self) -> &WeightBudget {
83        &self.budget
84    }
85}
86
87/// Builder for [`Spot`].
88///
89/// It allows [`DEFAULT_MAX_CONCURRENT`] in-flight requests unless told
90/// otherwise. Keep the weight in flight under the budget's reserve of 600: at
91/// the default, one client has at most 320 in flight on a route at its default
92/// (4 × `tickers_24h`'s 80), and four `depth` calls with a `limit` above 1000
93/// (250 each, 1000 in all) pass it. `WeightBudget` explains why a request in
94/// flight across a minute boundary is not counted by it.
95#[derive(Debug)]
96pub struct SpotBuilder {
97    config: ClientConfig,
98    budget: Option<WeightBudget>,
99}
100
101impl SpotBuilder {
102    fn new() -> Self {
103        Self {
104            config: ClientConfig::new(DEFAULT_BASE_URL, DEFAULT_MAX_CONCURRENT),
105            budget: None,
106        }
107    }
108
109    polyoxide_core::client_config_setters!(config);
110
111    /// Charge this budget instead of a new one. Binance limits weight per IP,
112    /// so every client in a process should share one budget.
113    pub fn weight_budget(mut self, budget: WeightBudget) -> Self {
114        self.budget = Some(budget);
115        self
116    }
117
118    /// Build the client.
119    ///
120    /// It has no core `RateLimiter`: the [`WeightBudget`] is its throttle, and
121    /// paces every request instead. Its retry policy holds that budget on a
122    /// `429` or a `418`, so every client sharing it waits.
123    pub fn build(self) -> Result<Spot, SpotError> {
124        // The client and the budget it makes keep time on one runtime. A budget
125        // passed to `weight_budget` brings its own, which then stands.
126        let runtime = self.config.runtime_or_default();
127        let budget = self
128            .budget
129            .unwrap_or_else(|| WeightBudget::new_on(runtime.clone()));
130        let http = self
131            .config
132            .http_builder()
133            .runtime(runtime)
134            .with_throttle(budget.clone())
135            .with_retry_policy(SpotRetryPolicy {
136                budget: budget.clone(),
137            })
138            .build()?;
139        Ok(Spot { http, budget })
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn the_builders_runtime_is_the_clients_and_the_budgets() {
149        let runtime: std::sync::Arc<dyn polyoxide_rt::Runtime> =
150            std::sync::Arc::new(polyoxide_rt::TokioRuntime);
151        let spot = Spot::builder().runtime(runtime.clone()).build().unwrap();
152        assert!(std::sync::Arc::ptr_eq(spot.http.runtime(), &runtime));
153        assert!(std::sync::Arc::ptr_eq(
154            spot.weight_budget().runtime(),
155            &runtime
156        ));
157    }
158
159    #[test]
160    fn default_builder_targets_the_production_host() {
161        let spot = Spot::new().expect("client builds");
162        assert_eq!(spot.http.base_url.as_str(), "https://api.binance.com/");
163    }
164
165    #[test]
166    fn a_bad_base_url_is_a_url_error() {
167        let err = Spot::builder().base_url("not a url").build().unwrap_err();
168        assert!(matches!(
169            err,
170            SpotError::Api(polyoxide_core::ApiError::Url(_))
171        ));
172    }
173
174    #[test]
175    fn clients_given_one_budget_share_it() {
176        let budget = WeightBudget::new();
177        let a = Spot::builder()
178            .weight_budget(budget.clone())
179            .build()
180            .unwrap();
181        let b = Spot::builder().weight_budget(budget).build().unwrap();
182        assert!(a.weight_budget().is_shared_with(b.weight_budget()));
183        let c = Spot::new().unwrap();
184        assert!(!a.weight_budget().is_shared_with(c.weight_budget()));
185    }
186
187    #[tokio::test]
188    async fn test_default_concurrency_limit_is_4() {
189        let spot = Spot::new().unwrap();
190        let mut permits = Vec::new();
191        for _ in 0..4 {
192            permits.push(spot.http.acquire_concurrency().await);
193        }
194        assert!(permits.iter().all(|p| p.is_some()));
195
196        let result = tokio::time::timeout(
197            std::time::Duration::from_millis(50),
198            spot.http.acquire_concurrency(),
199        )
200        .await;
201        assert!(
202            result.is_err(),
203            "5th permit should block with default limit of 4"
204        );
205    }
206}