1use super::shared::{
5 RateLimitTracker, RequestKind, classify_rest_query_error, optional_decimal,
6 response_decode_error, rest_call_with_retry,
7};
8use crate::{error::UnindexedClientError, fee::Liquidity};
9use binance_sdk::spot::rest_api::{AccountCommissionParams, AccountCommissionResponse, RestApi};
10use rust_decimal::Decimal;
11use rustrade_instrument::{
12 Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
13};
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct CommissionPart {
25 pub maker: Decimal,
27 pub taker: Decimal,
29 pub buyer: Decimal,
31 pub seller: Decimal,
33}
34
35impl CommissionPart {
36 pub fn new(maker: Decimal, taker: Decimal, buyer: Decimal, seller: Decimal) -> Self {
38 Self {
39 maker,
40 taker,
41 buyer,
42 seller,
43 }
44 }
45
46 #[must_use]
51 pub fn rate(&self, side: Side, liquidity: Liquidity) -> Option<Decimal> {
52 let by_liquidity = match liquidity {
53 Liquidity::Maker => self.maker,
54 Liquidity::Taker => self.taker,
55 };
56 let by_side = match side {
57 Side::Buy => self.buyer,
58 Side::Sell => self.seller,
59 };
60 by_liquidity.checked_add(by_side)
61 }
62}
63
64#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub struct CommissionDiscount {
68 pub enabled_for_account: bool,
71 pub enabled_for_symbol: bool,
73 pub asset: Option<AssetNameExchange>,
76 pub multiplier: Decimal,
79}
80
81impl CommissionDiscount {
82 pub fn new(
84 enabled_for_account: bool,
85 enabled_for_symbol: bool,
86 asset: Option<AssetNameExchange>,
87 multiplier: Decimal,
88 ) -> Self {
89 Self {
90 enabled_for_account,
91 enabled_for_symbol,
92 asset,
93 multiplier,
94 }
95 }
96}
97
98#[non_exhaustive]
118#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub struct CommissionRates {
120 pub instrument: InstrumentNameExchange,
122 pub standard: CommissionPart,
124 pub special: CommissionPart,
126 pub tax: CommissionPart,
128 pub discount: Option<CommissionDiscount>,
131}
132
133impl CommissionRates {
134 #[must_use]
141 pub fn rate(&self, side: Side, liquidity: Liquidity) -> Option<Decimal> {
142 self.standard
143 .rate(side, liquidity)?
144 .checked_add(self.special.rate(side, liquidity)?)?
145 .checked_add(self.tax.rate(side, liquidity)?)
146 }
147}
148
149pub(crate) async fn fetch_commission_rates(
152 rest: &Arc<RestApi>,
153 rate_limiter: &RateLimitTracker,
154 instrument: &InstrumentNameExchange,
155) -> Result<CommissionRates, UnindexedClientError> {
156 let symbol = instrument.name().to_string();
157 let response = rest_call_with_retry(rest, rate_limiter, RequestKind::Query, |rest| {
158 let symbol = symbol.clone();
159 Box::pin(async move {
160 let params = AccountCommissionParams::builder(symbol).build()?;
161 rest.account_commission(params).await
162 })
163 })
164 .await
165 .map_err(|error| classify_rest_query_error(&error, Some(instrument)))?;
166 let answer = response.data().await.map_err(response_decode_error)?;
167 commission_rates(instrument, answer)
168}
169
170fn commission_rates(
173 instrument: &InstrumentNameExchange,
174 answer: AccountCommissionResponse,
175) -> Result<CommissionRates, UnindexedClientError> {
176 if let Some(symbol) = answer.symbol.as_deref()
177 && symbol != instrument.name().as_str()
178 {
179 return Err(decode_error(format!(
180 "account commission asked for {instrument} answers for {symbol}"
181 )));
182 }
183 macro_rules! rates_of {
185 ($part:expr) => {
186 $part.map(|part| [part.maker, part.taker, part.buyer, part.seller])
187 };
188 }
189 let standard = rates_of!(answer.standard_commission);
190 let special = rates_of!(answer.special_commission);
191 let tax = rates_of!(answer.tax_commission);
192 let discount = answer
193 .discount
194 .map(|discount| {
195 let discount = *discount;
196 let flag = |name: &str, value: Option<bool>| {
197 value.ok_or_else(|| decode_error(format!("discount.{name} missing")))
198 };
199 Ok::<_, UnindexedClientError>(CommissionDiscount {
200 enabled_for_account: flag("enabledForAccount", discount.enabled_for_account)?,
201 enabled_for_symbol: flag("enabledForSymbol", discount.enabled_for_symbol)?,
202 asset: discount.discount_asset.map(AssetNameExchange::new),
203 multiplier: required_decimal("discount.discount", discount.discount.as_deref())?,
204 })
205 })
206 .transpose()?;
207 Ok(CommissionRates {
208 instrument: instrument.clone(),
209 standard: commission_part("standardCommission", standard)?,
210 special: commission_part("specialCommission", special)?,
211 tax: commission_part("taxCommission", tax)?,
212 discount,
213 })
214}
215
216fn commission_part(
218 name: &str,
219 rates: Option<[Option<String>; 4]>,
220) -> Result<CommissionPart, UnindexedClientError> {
221 let [maker, taker, buyer, seller] =
222 rates.ok_or_else(|| decode_error(format!("{name} missing")))?;
223 let rate = |field: &str, value: Option<String>| {
224 required_decimal(&format!("{name}.{field}"), value.as_deref())
225 };
226 Ok(CommissionPart {
227 maker: rate("maker", maker)?,
228 taker: rate("taker", taker)?,
229 buyer: rate("buyer", buyer)?,
230 seller: rate("seller", seller)?,
231 })
232}
233
234fn required_decimal(field: &str, value: Option<&str>) -> Result<Decimal, UnindexedClientError> {
236 optional_decimal(field, value)?.ok_or_else(|| decode_error(format!("{field} missing")))
237}
238
239fn decode_error(detail: String) -> UnindexedClientError {
240 UnindexedClientError::Internal(format!("decoding Binance response: {detail}"))
241}
242
243#[cfg(test)]
244#[allow(clippy::unwrap_used)] mod tests {
246 use super::*;
247 use rust_decimal_macros::dec;
248
249 fn answer(body: serde_json::Value) -> AccountCommissionResponse {
250 serde_json::from_value(body).unwrap()
251 }
252
253 fn part(maker: &str, taker: &str, buyer: &str, seller: &str) -> serde_json::Value {
254 serde_json::json!({"maker": maker, "taker": taker, "buyer": buyer, "seller": seller})
255 }
256
257 fn documented() -> serde_json::Value {
259 serde_json::json!({
260 "symbol": "BTCUSDT",
261 "standardCommission": part("0.00000010", "0.00000020", "0.00000030", "0.00000040"),
262 "specialCommission": part("0.01000000", "0.02000000", "0.03000000", "0.04000000"),
263 "taxCommission": part("0.00000112", "0.00000114", "0.00000118", "0.00000116"),
264 "discount": {
265 "enabledForAccount": true,
266 "enabledForSymbol": false,
267 "discountAsset": "BNB",
268 "discount": "0.75000000"
269 }
270 })
271 }
272
273 fn btcusdt() -> InstrumentNameExchange {
274 InstrumentNameExchange::new("BTCUSDT")
275 }
276
277 #[test]
278 fn commission_rates_keep_every_part_and_the_discount() {
279 let rates = commission_rates(&btcusdt(), answer(documented())).unwrap();
280 assert_eq!(rates.instrument, btcusdt());
281 assert_eq!(
282 rates.standard,
283 CommissionPart::new(
284 dec!(0.0000001),
285 dec!(0.0000002),
286 dec!(0.0000003),
287 dec!(0.0000004)
288 )
289 );
290 assert_eq!(
291 rates.special,
292 CommissionPart::new(dec!(0.01), dec!(0.02), dec!(0.03), dec!(0.04))
293 );
294 assert_eq!(
295 rates.tax,
296 CommissionPart::new(
297 dec!(0.00000112),
298 dec!(0.00000114),
299 dec!(0.00000118),
300 dec!(0.00000116)
301 )
302 );
303 assert_eq!(
304 rates.discount,
305 Some(CommissionDiscount::new(
306 true,
307 false,
308 Some(AssetNameExchange::new("BNB")),
309 dec!(0.75)
310 ))
311 );
312 }
313
314 #[test]
315 fn commission_rate_adds_the_liquidity_and_side_rates_of_every_part() {
316 let rates = commission_rates(&btcusdt(), answer(documented())).unwrap();
317 let cases = [
319 (
320 Side::Sell,
321 Liquidity::Taker,
322 dec!(0.0000006) + dec!(0.06) + dec!(0.0000023),
323 ),
324 (
325 Side::Sell,
326 Liquidity::Maker,
327 dec!(0.0000005) + dec!(0.05) + dec!(0.00000228),
328 ),
329 (
330 Side::Buy,
331 Liquidity::Taker,
332 dec!(0.0000005) + dec!(0.05) + dec!(0.00000232),
333 ),
334 (
335 Side::Buy,
336 Liquidity::Maker,
337 dec!(0.0000004) + dec!(0.04) + dec!(0.0000023),
338 ),
339 ];
340 for (side, liquidity, expected) in cases {
341 assert_eq!(
342 rates.rate(side, liquidity),
343 Some(expected),
344 "{side:?} {liquidity:?}"
345 );
346 }
347 assert_eq!(
348 rates.standard.rate(Side::Buy, Liquidity::Maker),
349 Some(dec!(0.0000004))
350 );
351 }
352
353 #[test]
354 fn commission_rate_is_none_past_decimal_range() {
355 let max = Decimal::MAX;
356 let part = CommissionPart::new(max, max, Decimal::ONE, Decimal::ZERO);
357 assert_eq!(part.rate(Side::Buy, Liquidity::Maker), None);
358 assert_eq!(part.rate(Side::Sell, Liquidity::Maker), Some(max));
359 let zero = CommissionPart::new(Decimal::ZERO, Decimal::ZERO, Decimal::ZERO, Decimal::ZERO);
360 let one = CommissionPart::new(Decimal::ONE, Decimal::ONE, Decimal::ZERO, Decimal::ZERO);
361 let near_max = CommissionPart::new(max, max, Decimal::ZERO, Decimal::ZERO);
362 for (standard, special, tax) in [
363 (near_max, one, zero),
364 (near_max, zero, one),
365 (part, zero, zero),
366 ] {
367 let rates = CommissionRates {
368 instrument: btcusdt(),
369 standard,
370 special,
371 tax,
372 discount: None,
373 };
374 assert_eq!(rates.rate(Side::Buy, Liquidity::Maker), None);
375 }
376 }
377
378 #[test]
379 fn commission_rates_without_a_discount_block_or_asset_leave_them_unknown() {
380 let mut body = documented();
381 body.as_object_mut().unwrap().remove("discount");
382 let rates = commission_rates(&btcusdt(), answer(body)).unwrap();
383 assert_eq!(rates.discount, None);
384
385 let mut body = documented();
387 body["discount"]["discountAsset"] = serde_json::Value::Null;
388 let rates = commission_rates(&btcusdt(), answer(body)).unwrap();
389 assert_eq!(rates.discount.unwrap().asset, None);
390 }
391
392 #[test]
393 fn commission_rates_refuse_a_missing_or_unreadable_value() {
394 let mut cases = Vec::new();
395 for part in ["standardCommission", "specialCommission", "taxCommission"] {
396 let mut body = documented();
397 body.as_object_mut().unwrap().remove(part);
398 cases.push((format!("{part} missing"), body));
399 for field in ["maker", "taker", "buyer", "seller"] {
400 let mut body = documented();
401 body[part].as_object_mut().unwrap().remove(field);
402 cases.push((format!("{part}.{field} missing"), body));
403 let mut body = documented();
404 body[part][field] = serde_json::json!("not a rate");
405 cases.push((format!("{part}.{field} \"not a rate\""), body));
406 }
407 }
408 for field in ["enabledForAccount", "enabledForSymbol", "discount"] {
409 let mut body = documented();
410 body["discount"].as_object_mut().unwrap().remove(field);
411 cases.push((format!("discount.{field} missing"), body));
412 }
413 let mut body = documented();
414 body["discount"]["discount"] = serde_json::json!("0.7.5");
415 cases.push(("discount.discount \"0.7.5\"".to_string(), body));
416 let mut body = documented();
417 body["symbol"] = serde_json::json!("ETHUSDT");
418 cases.push(("answers for ETHUSDT".to_string(), body));
419
420 for (expected, body) in cases {
421 match commission_rates(&btcusdt(), answer(body)) {
422 Err(UnindexedClientError::Internal(message)) => {
423 assert!(message.contains(&expected), "{expected}: {message}");
424 }
425 other => panic!("{expected}: {other:?}"),
426 }
427 }
428 }
429
430 #[test]
431 fn commission_rates_without_a_symbol_are_the_requested_ones() {
432 let mut body = documented();
433 body.as_object_mut().unwrap().remove("symbol");
434 let rates = commission_rates(&btcusdt(), answer(body)).unwrap();
435 assert_eq!(rates.instrument, btcusdt());
436 }
437}