Skip to main content

rustrade_execution/client/binance/
commission.rs

1//! An account's commission rates on a Binance symbol, which both clients read from the spot
2//! `GET /api/v3/account/commission`.
3
4use 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/// One of the three parts of a Binance commission: the rates it charges a trade, as fractions of
18/// the amount the trade gives the account.
19///
20/// A trade pays [`maker`](Self::maker) or [`taker`](Self::taker) by its liquidity, plus
21/// [`buyer`](Self::buyer) or [`seller`](Self::seller) by its side: see [`rate`](Self::rate).
22#[non_exhaustive]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct CommissionPart {
25    /// The rate on a trade that supplied liquidity.
26    pub maker: Decimal,
27    /// The rate on a trade that took liquidity.
28    pub taker: Decimal,
29    /// The rate added on a buy.
30    pub buyer: Decimal,
31    /// The rate added on a sell.
32    pub seller: Decimal,
33}
34
35impl CommissionPart {
36    /// A part with these rates.
37    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    /// This part's rate on a trade on `side` with `liquidity`: [`maker`](Self::maker) or
47    /// [`taker`](Self::taker), plus [`buyer`](Self::buyer) or [`seller`](Self::seller).
48    ///
49    /// `None` only when a `Decimal` cannot hold the sum.
50    #[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/// The discount Binance gives a commission paid in its discount asset (BNB).
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67pub struct CommissionDiscount {
68    /// Whether the account pays its commissions in [`asset`](Self::asset), a setting the account
69    /// holder chooses.
70    pub enabled_for_account: bool,
71    /// Whether the symbol allows paying in [`asset`](Self::asset).
72    pub enabled_for_symbol: bool,
73    /// The asset a discounted commission is paid in. `None` when Binance names none, as Spot
74    /// Testnet does for an account not charged commissions there.
75    pub asset: Option<AssetNameExchange>,
76    /// What the standard part is multiplied by when paid in [`asset`](Self::asset): 0.75 charges
77    /// 75% of it. Binance's `discount`, which is this multiplier, not the share taken off.
78    pub multiplier: Decimal,
79}
80
81impl CommissionDiscount {
82    /// A discount with these terms.
83    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/// An account's commission rates on one symbol, as Binance's `GET /api/v3/account/commission`
99/// reports them, its three parts kept apart.
100///
101/// # How Binance charges them
102///
103/// - **Each part is charged on every trade:** the amount the trade gives the account (the base
104///   quantity on a buy, the quote notional on a sell) times the part's
105///   [`rate`](CommissionPart::rate) for the trade's side and liquidity.
106/// - **Not paid in the discount asset,** the three are charged in the asset the trade gives, and
107///   [`rate`](Self::rate) is their sum.
108/// - **Paid in the discount asset** (both of [`discount`](Self::discount)'s flags set and enough of
109///   it held), only [`standard`](Self::standard) is multiplied by
110///   [`multiplier`](CommissionDiscount::multiplier). [`special`](Self::special) and
111///   [`tax`](Self::tax) are not discounted. All three are converted into the discount asset at a
112///   price Binance does not report, so no rate here gives that commission. With too little of the
113///   discount asset, the whole commission is charged in the asset the trade gives, undiscounted.
114///
115/// These are the account's rates when read, at its VIP level and any promotion, and they can
116/// change. A fill's own fee is what it was charged.
117#[non_exhaustive]
118#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub struct CommissionRates {
120    /// The symbol the rates are for.
121    pub instrument: InstrumentNameExchange,
122    /// The standard commission, the only part the discount applies to.
123    pub standard: CommissionPart,
124    /// The extra commission Binance adds in specific circumstances.
125    pub special: CommissionPart,
126    /// The tax commission.
127    pub tax: CommissionPart,
128    /// The discount for paying in the discount asset. `None` when Binance leaves it out of its
129    /// answer, which says nothing about whether one applies.
130    pub discount: Option<CommissionDiscount>,
131}
132
133impl CommissionRates {
134    /// The rate Binance charges a trade on `side` with `liquidity` when the commission is not
135    /// paid in the discount asset: the sum of [`standard`](Self::standard)'s,
136    /// [`special`](Self::special)'s and [`tax`](Self::tax)'s [`rate`](CommissionPart::rate), a
137    /// fraction of the amount the trade gives the account.
138    ///
139    /// `None` only when a `Decimal` cannot hold the sum.
140    #[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
149/// Read the account's commission rates on `instrument` over the spot `rest`, with the rate-limit
150/// retry the clients' other requests have. It costs 20 of Binance's per-IP spot request weight.
151pub(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
170/// `answer`'s rates on `instrument`. Every part and rate must be present and read as a decimal;
171/// only the discount block, and the discount asset in it, may be left out.
172fn 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    // The SDK has a type of its own for each part, with the same four fields.
184    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
216/// The part named `name` from its maker, taker, buyer and seller rates, each required.
217fn 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
234/// A decimal Binance must report: absent or unreadable is an error.
235fn 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)] // Test code: panics on bad input are acceptable
245mod 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    /// Binance's documented example answer, with distinct rates in every slot.
258    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        // Binance's worked example: a taker sell pays taker + seller of each part.
318        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        // Spot Testnet's answer for an account not charged commissions there: no discount asset.
386        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}