polyoxide_binance/spot/
mod.rs1pub 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
47pub const DEFAULT_BASE_URL: &str = "https://api.binance.com";
49
50pub const DEFAULT_MAX_CONCURRENT: usize = 4;
52
53#[derive(Debug, Clone)]
55pub struct Spot {
56 http: HttpClient,
57 budget: WeightBudget,
58}
59
60impl Spot {
61 pub fn new() -> Result<Self, SpotError> {
63 Self::builder().build()
64 }
65
66 pub fn builder() -> SpotBuilder {
68 SpotBuilder::new()
69 }
70
71 polyoxide_core::namespaces! { http;
72 health: Health,
74 exchange: ExchangeApi,
76 market: MarketApi,
79 }
80
81 pub fn weight_budget(&self) -> &WeightBudget {
83 &self.budget
84 }
85}
86
87#[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 pub fn weight_budget(mut self, budget: WeightBudget) -> Self {
114 self.budget = Some(budget);
115 self
116 }
117
118 pub fn build(self) -> Result<Spot, SpotError> {
124 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}