Skip to main content

rustrade_execution/client/binance/margin/
collateral.rs

1//! Binance cross margin's collateral ratios: how much of each asset's value counts as collateral,
2//! read from `GET /sapi/v1/margin/crossMarginCollateralRatio`.
3
4use super::BinanceMargin;
5use crate::{
6    client::binance::shared::{
7        RequestKind, classify_rest_query_error, optional_decimal, response_decode_error,
8        rest_call_with_retry,
9    },
10    error::UnindexedClientError,
11};
12use binance_sdk::margin_trading::rest_api::{
13    CrossMarginCollateralRatioResponseInner,
14    CrossMarginCollateralRatioResponseInnerCollateralsInner,
15};
16use rust_decimal::Decimal;
17use rustrade_instrument::asset::name::AssetNameExchange;
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, hash_map::Entry};
20
21/// One tier of a Binance cross-margin collateral ratio: the ratio applied to an asset's value in
22/// a range of USD values.
23#[non_exhaustive]
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct CollateralRatioTier {
26    /// The lowest USD value the tier covers (Binance's `minUsdValue`).
27    pub min_usd_value: Decimal,
28    /// The highest USD value the tier covers (Binance's `maxUsdValue`). `None` when Binance sends
29    /// none, as it does for the open top tier.
30    pub max_usd_value: Option<Decimal>,
31    /// The fraction of the asset's value counted as collateral in this tier: 1 counts all of it,
32    /// 0.95 counts 95% (Binance's `discountRate`). Reported as Binance sends it, unchecked.
33    pub ratio: Decimal,
34}
35
36impl CollateralRatioTier {
37    /// A tier with these bounds and ratio.
38    pub fn new(min_usd_value: Decimal, max_usd_value: Option<Decimal>, ratio: Decimal) -> Self {
39        Self {
40            min_usd_value,
41            max_usd_value,
42            ratio,
43        }
44    }
45}
46
47impl BinanceMargin {
48    /// The collateral ratio tiers of every asset on Binance cross margin:
49    /// `GET /sapi/v1/margin/crossMarginCollateralRatio`.
50    ///
51    /// Each asset maps to its tiers in ascending
52    /// [`min_usd_value`](CollateralRatioTier::min_usd_value). Binance groups assets that share a
53    /// tier list; each asset of a group gets the group's tiers. An asset Binance does not list is
54    /// absent.
55    ///
56    /// The ratios are Binance's for cross margin, read whichever margin this client is
57    /// configured for. They are market data, not this account's: the request carries the API key
58    /// and is not signed.
59    ///
60    /// # What the answer holds
61    ///
62    /// Binance publishes three collateral ratios per asset on its Margin Data page (borrow,
63    /// transfer-out and liquidation), and the endpoint returns one. Binance does not document
64    /// which, nor how an asset with several tiers is weighted across them. What was observed on
65    /// 2026-11-02:
66    ///
67    /// - every asset had one tier with a ratio of 1, matching the page's liquidation ratios
68    ///   (100% for every asset) and not its borrow ratios (as low as 10%);
69    /// - that tier had no `maxUsdValue`, so it is reported with
70    ///   [`max_usd_value`](CollateralRatioTier::max_usd_value) `None`.
71    ///
72    /// Read the answer as observed, not guaranteed. Binance's API documents no endpoint for the
73    /// borrow ratios, which govern how much the account may borrow.
74    ///
75    /// To model these ratios on the simulated venue, a single-tier ratio as observed above maps to
76    /// [`SpotMarginConfig::with_liquidation_collateral_ratio`]. Take the borrow ratios
77    /// ([`SpotMarginConfig::with_borrow_collateral_ratio`]) from Binance's Margin Data page, not
78    /// from this answer: they can be far lower, and using this answer for them would let a backtest
79    /// borrow more than Binance lends.
80    ///
81    /// [`SpotMarginConfig::with_liquidation_collateral_ratio`]:
82    ///     crate::margin::SpotMarginConfig::with_liquidation_collateral_ratio
83    /// [`SpotMarginConfig::with_borrow_collateral_ratio`]:
84    ///     crate::margin::SpotMarginConfig::with_borrow_collateral_ratio
85    ///
86    /// # Cost
87    ///
88    /// One request, costing 100 of Binance's per-IP request weight.
89    ///
90    /// # Errors
91    ///
92    /// - Whatever error the request returns.
93    /// - [`UnindexedClientError::Internal`] for an answer that cannot be read whole, rather than
94    ///   return a map that silently lacks its assets or tiers: a group missing its assets or its
95    ///   tiers, a group with no tiers, a tier missing its `minUsdValue` or `discountRate`, a value
96    ///   that does not read as a decimal, or an asset listed twice.
97    pub async fn fetch_collateral_ratios(
98        &self,
99    ) -> Result<HashMap<AssetNameExchange, Vec<CollateralRatioTier>>, UnindexedClientError> {
100        let response =
101            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
102                Box::pin(async move { rest.cross_margin_collateral_ratio().await })
103            })
104            .await
105            .map_err(|e| classify_rest_query_error(&e, None))?;
106        collateral_ratios(response.data().await.map_err(response_decode_error)?)
107    }
108}
109
110/// Each asset of `groups` with its group's tiers, in ascending minimum USD value.
111fn collateral_ratios(
112    groups: Vec<CrossMarginCollateralRatioResponseInner>,
113) -> Result<HashMap<AssetNameExchange, Vec<CollateralRatioTier>>, UnindexedClientError> {
114    let mut ratios = HashMap::new();
115    for (index, group) in groups.into_iter().enumerate() {
116        let assets = group
117            .asset_names
118            .ok_or_else(|| decode_error(format!("group {index}: assetNames missing")))?;
119        let tiers = group
120            .collaterals
121            .ok_or_else(|| decode_error(format!("group {index}: collaterals missing")))?;
122        if tiers.is_empty() {
123            return Err(decode_error(format!("group {index}: no collaterals")));
124        }
125        let mut tiers = tiers
126            .into_iter()
127            .map(|tier| collateral_ratio_tier(index, tier))
128            .collect::<Result<Vec<_>, _>>()?;
129        tiers.sort_by_key(|tier| tier.min_usd_value);
130        for asset in assets {
131            match ratios.entry(AssetNameExchange::new(asset)) {
132                Entry::Vacant(entry) => {
133                    entry.insert(tiers.clone());
134                }
135                Entry::Occupied(entry) => {
136                    return Err(decode_error(format!("{} listed twice", entry.key())));
137                }
138            }
139        }
140    }
141    Ok(ratios)
142}
143
144/// The tier `tier` of group `index`. Its minimum and ratio are required, its maximum is not.
145fn collateral_ratio_tier(
146    index: usize,
147    tier: CrossMarginCollateralRatioResponseInnerCollateralsInner,
148) -> Result<CollateralRatioTier, UnindexedClientError> {
149    let required = |field: &str, value: Option<String>| {
150        let field = format!("group {index}: {field}");
151        optional_decimal(&field, value.as_deref())?
152            .ok_or_else(|| decode_error(format!("{field} missing")))
153    };
154    Ok(CollateralRatioTier {
155        min_usd_value: required("minUsdValue", tier.min_usd_value)?,
156        max_usd_value: optional_decimal(
157            &format!("group {index}: maxUsdValue"),
158            tier.max_usd_value.as_deref(),
159        )?,
160        ratio: required("discountRate", tier.discount_rate)?,
161    })
162}
163
164fn decode_error(detail: String) -> UnindexedClientError {
165    UnindexedClientError::Internal(format!("decoding Binance response: {detail}"))
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
170mod tests {
171    use super::{super::tests::margin_client_at, *};
172    use crate::error::ApiError;
173    use rust_decimal_macros::dec;
174
175    const ROUTE: &str = "/sapi/v1/margin/crossMarginCollateralRatio";
176
177    fn groups(body: serde_json::Value) -> Vec<CrossMarginCollateralRatioResponseInner> {
178        serde_json::from_value(body).unwrap()
179    }
180
181    fn asset(name: &str) -> AssetNameExchange {
182        AssetNameExchange::new(name)
183    }
184
185    /// Two groups: one with the single open tier Binance was seen to send, one with three tiers
186    /// listed out of order.
187    fn tiered() -> serde_json::Value {
188        serde_json::json!([
189            {
190                "collaterals": [{"minUsdValue": "0", "discountRate": "1"}],
191                "assetNames": ["USDT", "BTC"]
192            },
193            {
194                "collaterals": [
195                    {"minUsdValue": "5000", "maxUsdValue": "10000", "discountRate": "0.5"},
196                    {"minUsdValue": "10000", "discountRate": "0"},
197                    {"minUsdValue": "0", "maxUsdValue": "5000", "discountRate": "0.9"}
198                ],
199                "assetNames": ["SHIB"]
200            }
201        ])
202    }
203
204    #[test]
205    fn collateral_ratios_give_each_asset_its_groups_tiers_in_ascending_order() {
206        let ratios = collateral_ratios(groups(tiered())).unwrap();
207        let open = vec![CollateralRatioTier::new(dec!(0), None, dec!(1))];
208        assert_eq!(
209            ratios,
210            HashMap::from([
211                (asset("USDT"), open.clone()),
212                (asset("BTC"), open),
213                (
214                    asset("SHIB"),
215                    vec![
216                        CollateralRatioTier::new(dec!(0), Some(dec!(5000)), dec!(0.9)),
217                        CollateralRatioTier::new(dec!(5000), Some(dec!(10000)), dec!(0.5)),
218                        CollateralRatioTier::new(dec!(10000), None, dec!(0)),
219                    ]
220                ),
221            ])
222        );
223    }
224
225    #[test]
226    fn collateral_ratios_of_no_groups_or_a_group_of_no_assets_are_empty() {
227        assert!(collateral_ratios(Vec::new()).unwrap().is_empty());
228        let body = serde_json::json!([{
229            "collaterals": [{"minUsdValue": "0", "discountRate": "1"}],
230            "assetNames": []
231        }]);
232        assert!(collateral_ratios(groups(body)).unwrap().is_empty());
233    }
234
235    #[test]
236    fn collateral_ratios_refuse_an_answer_they_cannot_read_whole() {
237        let mut cases = Vec::new();
238        for field in ["assetNames", "collaterals"] {
239            let mut body = tiered();
240            body[1].as_object_mut().unwrap().remove(field);
241            cases.push((format!("group 1: {field} missing"), body));
242        }
243        let mut body = tiered();
244        body[1]["collaterals"] = serde_json::json!([]);
245        cases.push(("group 1: no collaterals".to_string(), body));
246        for field in ["minUsdValue", "discountRate"] {
247            let mut body = tiered();
248            body[1]["collaterals"][0]
249                .as_object_mut()
250                .unwrap()
251                .remove(field);
252            cases.push((format!("group 1: {field} missing"), body));
253        }
254        for field in ["minUsdValue", "maxUsdValue", "discountRate"] {
255            let mut body = tiered();
256            body[1]["collaterals"][0][field] = serde_json::json!("0.5.0");
257            cases.push((format!("group 1: {field} \"0.5.0\""), body));
258        }
259        let mut body = tiered();
260        body[1]["assetNames"] = serde_json::json!(["SHIB", "BTC"]);
261        cases.push(("BTC listed twice".to_string(), body));
262        let mut body = tiered();
263        body[1]["assetNames"] = serde_json::json!(["SHIB", "SHIB"]);
264        cases.push(("SHIB listed twice".to_string(), body));
265
266        for (expected, body) in cases {
267            match collateral_ratios(groups(body)) {
268                Err(UnindexedClientError::Internal(message)) => {
269                    assert!(message.contains(&expected), "{expected}: {message}");
270                }
271                other => panic!("{expected}: {other:?}"),
272            }
273        }
274    }
275
276    #[tokio::test]
277    async fn fetch_collateral_ratios_reads_the_endpoint_once_with_the_api_key_unsigned() {
278        for is_isolated in [false, true] {
279            let server = wiremock::MockServer::start().await;
280            wiremock::Mock::given(wiremock::matchers::method("GET"))
281                .and(wiremock::matchers::path(ROUTE))
282                .and(wiremock::matchers::header("X-MBX-APIKEY", "key"))
283                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(tiered()))
284                .expect(1)
285                .mount(&server)
286                .await;
287            let client = margin_client_at(&server, is_isolated);
288
289            let ratios = client.fetch_collateral_ratios().await.unwrap();
290            assert_eq!(ratios, collateral_ratios(groups(tiered())).unwrap());
291            let requests = server.received_requests().await.unwrap();
292            let [request] = requests.as_slice() else {
293                panic!("{requests:?}");
294            };
295            assert!(
296                !request.url.query_pairs().any(|(key, _)| key == "signature"),
297                "unsigned: {}",
298                request.url
299            );
300        }
301    }
302
303    #[tokio::test]
304    async fn fetch_collateral_ratios_reports_a_refusal_and_an_unreadable_answer() {
305        let server = wiremock::MockServer::start().await;
306        wiremock::Mock::given(wiremock::matchers::path(ROUTE))
307            .respond_with(
308                wiremock::ResponseTemplate::new(400)
309                    .set_body_json(serde_json::json!({"code": -1102, "msg": "refused"})),
310            )
311            .up_to_n_times(1)
312            .mount(&server)
313            .await;
314        wiremock::Mock::given(wiremock::matchers::path(ROUTE))
315            .respond_with(
316                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
317                    "collaterals": [{"minUsdValue": "0", "discountRate": "one"}],
318                    "assetNames": ["BTC"]
319                }])),
320            )
321            .mount(&server)
322            .await;
323        let client = margin_client_at(&server, false);
324
325        let refused = client.fetch_collateral_ratios().await;
326        assert!(
327            matches!(
328                refused,
329                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
330            ),
331            "{refused:?}"
332        );
333        match client.fetch_collateral_ratios().await {
334            Err(UnindexedClientError::Internal(message)) => {
335                assert!(message.contains("discountRate \"one\""), "{message}");
336            }
337            other => panic!("{other:?}"),
338        }
339    }
340}