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    margin::{SpotMarginConfig, SpotMarginConfigError},
12};
13use rust_decimal::Decimal;
14use rustrade_instrument::asset::name::AssetNameExchange;
15use serde::{Deserialize, Serialize};
16use std::{
17    collections::{BTreeMap, HashMap, hash_map::Entry},
18    fmt,
19};
20
21/// The collateral ratio endpoint, joined onto the REST config's base path.
22const COLLATERAL_RATIO_PATH: &str = "/sapi/v1/margin/crossMarginCollateralRatio";
23
24/// One tier of a Binance cross-margin collateral ratio: the ratio applied to the slice of an
25/// asset's value that falls in a range of USD values.
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct CollateralRatioTier {
29    /// The lowest USD value the tier covers (Binance's `minUsdValue`).
30    pub min_usd_value: Decimal,
31    /// The highest USD value the tier covers (Binance's `maxUsdValue`). `None` for the open top
32    /// tier, which has no upper limit: Binance leaves its `maxUsdValue` out.
33    pub max_usd_value: Option<Decimal>,
34    /// The fraction of the slice's value counted as collateral in this tier: 1 counts all of it,
35    /// 0.95 counts 95% (Binance's `discountRate`). Reported as Binance sends it, unchecked.
36    pub ratio: Decimal,
37}
38
39impl CollateralRatioTier {
40    /// A tier with these bounds and ratio.
41    pub fn new(min_usd_value: Decimal, max_usd_value: Option<Decimal>, ratio: Decimal) -> Self {
42        Self {
43            min_usd_value,
44            max_usd_value,
45            ratio,
46        }
47    }
48}
49
50/// An asset's three Binance cross-margin collateral ratios, one per check, each as its tiers in
51/// ascending [`min_usd_value`](CollateralRatioTier::min_usd_value).
52///
53/// The tiers are progressive: each slice of the asset's USD value is weighted at the ratio of the
54/// tier that slice falls in, as income tax brackets are, not the whole value at the ratio of the
55/// tier its total reaches.
56#[non_exhaustive]
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub struct CollateralRatios {
59    /// The ratio for the account's margin level, which decides margin calls and liquidation
60    /// (Binance's `collaterals`).
61    pub liquidation: Vec<CollateralRatioTier>,
62    /// The ratio for how much the account can borrow (Binance's `borrowCollaterals`).
63    pub borrow: Vec<CollateralRatioTier>,
64    /// The ratio for how much the account can transfer out of margin (Binance's
65    /// `withdrawCollaterals`).
66    pub transfer_out: Vec<CollateralRatioTier>,
67}
68
69impl CollateralRatios {
70    /// An asset's ratios with these tiers, each as given.
71    pub fn new(
72        liquidation: Vec<CollateralRatioTier>,
73        borrow: Vec<CollateralRatioTier>,
74        transfer_out: Vec<CollateralRatioTier>,
75    ) -> Self {
76        Self {
77            liquidation,
78            borrow,
79            transfer_out,
80        }
81    }
82}
83
84/// `config` with the borrow and liquidation collateral ratios Binance lists for each of `assets`,
85/// as [`BinanceMargin::fetch_collateral_ratios`] reads them, so that a simulated venue weights
86/// those assets as Binance cross margin does.
87///
88/// Each asset's single-tier [`borrow`](CollateralRatios::borrow) ratio becomes its
89/// [`borrow_collateral_ratio`](SpotMarginConfig::borrow_collateral_ratio), and its
90/// [`liquidation`](CollateralRatios::liquidation) ratio its
91/// [`liquidation_collateral_ratio`](SpotMarginConfig::liquidation_collateral_ratio), both set
92/// together by [`SpotMarginConfig::with_collateral_ratios`], replacing any the asset had. Assets
93/// left out of `assets` keep what `config` gave them, so an asset Binance lists in a form this
94/// cannot apply fails the call only when it is named.
95///
96/// The [`transfer_out`](CollateralRatios::transfer_out) ratios are not read: the simulated
97/// venue does not check transfers out of margin.
98///
99/// # Errors
100///
101/// - [`ApplyCollateralRatiosError::AssetNotListed`] for an asset of `assets` that `ratios` lacks.
102/// - [`ApplyCollateralRatiosError::NotOneOpenTier`] for an asset whose borrow or liquidation
103///   ratio is not one tier covering every USD value, from zero with no upper limit. The simulated
104///   venue applies one ratio to an asset's whole value, and Binance's tiers are progressive, so
105///   no single ratio would match Binance for such an asset. The ratio is not approximated.
106/// - [`ApplyCollateralRatiosError::Config`] when `config` refuses a ratio: one outside `[0, 1]`,
107///   or a liquidation ratio below the borrow ratio. Binance's values are passed on unchanged, not
108///   clamped.
109///
110/// # Example
111///
112/// ```
113/// use rust_decimal_macros::dec;
114/// use rustrade_execution::{
115///     client::binance::{CollateralRatioTier, CollateralRatios, apply_collateral_ratios},
116///     margin::{MarginEffect, SpotMarginConfig},
117/// };
118/// use rustrade_instrument::asset::name::AssetNameExchange;
119/// use std::collections::HashMap;
120///
121/// let open = |ratio| vec![CollateralRatioTier::new(dec!(0), None, ratio)];
122/// let btc = AssetNameExchange::new("BTC");
123/// // As `BinanceMargin::fetch_collateral_ratios` returns them.
124/// let ratios = HashMap::from([(
125///     btc.clone(),
126///     CollateralRatios::new(open(dec!(1)), open(dec!(0.95)), open(dec!(0.95))),
127/// )]);
128///
129/// let usdt = AssetNameExchange::new("USDT");
130/// let config = SpotMarginConfig::new(usdt, dec!(3), MarginEffect::Borrow)?;
131/// let config = apply_collateral_ratios(config, &ratios, [&btc])?;
132/// assert_eq!(config.borrow_collateral_ratio(&btc), dec!(0.95));
133/// assert_eq!(config.liquidation_collateral_ratio(&btc), dec!(1));
134/// # Ok::<(), Box<dyn std::error::Error>>(())
135/// ```
136pub fn apply_collateral_ratios<'a>(
137    mut config: SpotMarginConfig,
138    ratios: &HashMap<AssetNameExchange, CollateralRatios>,
139    assets: impl IntoIterator<Item = &'a AssetNameExchange>,
140) -> Result<SpotMarginConfig, ApplyCollateralRatiosError> {
141    for asset in assets {
142        let asset_ratios = ratios
143            .get(asset)
144            .ok_or_else(|| ApplyCollateralRatiosError::AssetNotListed(asset.clone()))?;
145        let borrow = open_tier_ratio(asset, CollateralRatioSet::Borrow, &asset_ratios.borrow)?;
146        let liquidation = open_tier_ratio(
147            asset,
148            CollateralRatioSet::Liquidation,
149            &asset_ratios.liquidation,
150        )?;
151        config = config.with_collateral_ratios(asset.clone(), borrow, liquidation)?;
152    }
153    Ok(config)
154}
155
156/// The ratio of `tiers` if they are one tier covering every USD value, which is the only form
157/// one ratio over an asset's whole value matches exactly.
158fn open_tier_ratio(
159    asset: &AssetNameExchange,
160    set: CollateralRatioSet,
161    tiers: &[CollateralRatioTier],
162) -> Result<Decimal, ApplyCollateralRatiosError> {
163    match tiers {
164        [tier] if tier.min_usd_value.is_zero() && tier.max_usd_value.is_none() => Ok(tier.ratio),
165        _ => Err(ApplyCollateralRatiosError::NotOneOpenTier {
166            asset: asset.clone(),
167            set,
168            tiers: tiers.to_vec(),
169        }),
170    }
171}
172
173/// One of an asset's [`CollateralRatios`] sets, as [`ApplyCollateralRatiosError`] names it.
174#[non_exhaustive]
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum CollateralRatioSet {
177    /// [`CollateralRatios::liquidation`].
178    Liquidation,
179    /// [`CollateralRatios::borrow`].
180    Borrow,
181}
182
183impl fmt::Display for CollateralRatioSet {
184    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
185        f.write_str(match self {
186            Self::Liquidation => "liquidation",
187            Self::Borrow => "borrow",
188        })
189    }
190}
191
192/// Why [`apply_collateral_ratios`] could not apply Binance's ratios to a [`SpotMarginConfig`].
193#[non_exhaustive]
194#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
195pub enum ApplyCollateralRatiosError {
196    /// An asset to apply that the fetched ratios do not list.
197    #[error("Binance lists no collateral ratios for {0}")]
198    AssetNotListed(AssetNameExchange),
199    /// An asset whose `set` is not one tier from zero USD with no upper limit, so that no single
200    /// ratio over the asset's whole value matches it.
201    #[error("{asset}'s {set} collateral ratio is not one tier covering every USD value: {tiers:?}")]
202    #[non_exhaustive]
203    NotOneOpenTier {
204        /// The asset.
205        asset: AssetNameExchange,
206        /// Which of its sets.
207        set: CollateralRatioSet,
208        /// That set's tiers, as fetched.
209        tiers: Vec<CollateralRatioTier>,
210    },
211    /// The configuration refused a ratio: see [`SpotMarginConfig::with_collateral_ratios`].
212    #[error(transparent)]
213    Config(#[from] SpotMarginConfigError),
214}
215
216impl BinanceMargin {
217    /// The collateral ratios of every asset on Binance cross margin:
218    /// `GET /sapi/v1/margin/crossMarginCollateralRatio`.
219    ///
220    /// Each asset maps to its three ratios, one per check: liquidation, borrow and transfer-out
221    /// (see [`CollateralRatios`]). Binance groups assets that share their ratios; each asset of a
222    /// group gets the group's. An asset Binance does not list is absent.
223    ///
224    /// The ratios are Binance's for cross margin, read whichever margin this client is
225    /// configured for. They are market data, not this account's: the request carries the API key
226    /// and is not signed.
227    ///
228    /// # What the answer holds
229    ///
230    /// Binance's API documentation listed only the liquidation set as of 2026-11-04; Binance has
231    /// confirmed that the endpoint returns all three. What was observed on 2026-11-04:
232    ///
233    /// - every asset had one open tier per check, so its ratio applies to the asset's whole value;
234    /// - the liquidation ratio was 1 for every asset;
235    /// - the borrow and transfer-out ratios were equal, and matched the Margin Data page: 0.9999
236    ///   for the main stablecoins, 0.95 for the largest assets, and as low as 0.1.
237    ///
238    /// To model these ratios on the simulated venue, [`apply_collateral_ratios`] maps an asset's
239    /// single-tier liquidation and borrow ratios to
240    /// [`SpotMarginConfig::with_liquidation_collateral_ratio`] and
241    /// [`SpotMarginConfig::with_borrow_collateral_ratio`]. The simulated venue checks no
242    /// transfer-out, so that ratio has no counterpart there, and it applies one ratio per asset,
243    /// so an asset with several tiers has no exact counterpart either.
244    ///
245    /// The borrow check also values each asset at the lowest (held) or highest (owed) of its
246    /// recent prices. Binance publishes no API for those prices nor for the window they span.
247    ///
248    /// [`SpotMarginConfig::with_liquidation_collateral_ratio`]:
249    ///     crate::margin::SpotMarginConfig::with_liquidation_collateral_ratio
250    /// [`SpotMarginConfig::with_borrow_collateral_ratio`]:
251    ///     crate::margin::SpotMarginConfig::with_borrow_collateral_ratio
252    ///
253    /// # Cost
254    ///
255    /// One request, costing 100 of Binance's per-IP request weight.
256    ///
257    /// # Errors
258    ///
259    /// - Whatever error the request returns.
260    /// - [`UnindexedClientError::Internal`] for an answer that cannot be read whole, rather than
261    ///   return a map that silently lacks its assets, ratios or tiers: a group missing its assets
262    ///   or any of its three tier sets, a set with no tiers, a tier missing its `minUsdValue` or
263    ///   `discountRate`, a value that does not read as a decimal, or an asset listed twice.
264    pub async fn fetch_collateral_ratios(
265        &self,
266    ) -> Result<HashMap<AssetNameExchange, CollateralRatios>, UnindexedClientError> {
267        // Sent through `send_request` so that the answer decodes into `CollateralRatioGroup`: the
268        // SDK's response type has no borrow or transfer-out set, so the SDK's own call drops them.
269        let response = rest_call_with_retry(
270            &self.rest_config,
271            &self.rate_limiter,
272            RequestKind::Query,
273            |cfg| {
274                Box::pin(async move {
275                    binance_sdk::common::utils::send_request::<Vec<CollateralRatioGroup>>(
276                        &cfg,
277                        COLLATERAL_RATIO_PATH,
278                        reqwest::Method::GET,
279                        BTreeMap::new(),
280                        BTreeMap::new(),
281                        None,
282                        false,
283                    )
284                    .await
285                })
286            },
287        )
288        .await
289        .map_err(|e| classify_rest_query_error(&e, None))?;
290        collateral_ratios(response.data().await.map_err(response_decode_error)?)
291    }
292}
293
294/// One group of the collateral ratio answer: assets sharing their three tier sets. Every field is
295/// optional here so that a missing one is reported by name rather than as a bare decode error.
296#[derive(Debug, Deserialize)]
297#[serde(rename_all = "camelCase")]
298struct CollateralRatioGroup {
299    asset_names: Option<Vec<String>>,
300    collaterals: Option<Vec<CollateralRatioTierAnswer>>,
301    borrow_collaterals: Option<Vec<CollateralRatioTierAnswer>>,
302    withdraw_collaterals: Option<Vec<CollateralRatioTierAnswer>>,
303}
304
305/// One tier of the collateral ratio answer, its values as Binance's decimal strings.
306#[derive(Debug, Deserialize)]
307#[serde(rename_all = "camelCase")]
308struct CollateralRatioTierAnswer {
309    min_usd_value: Option<String>,
310    max_usd_value: Option<String>,
311    discount_rate: Option<String>,
312}
313
314/// Each asset of `groups` with its group's ratios.
315fn collateral_ratios(
316    groups: Vec<CollateralRatioGroup>,
317) -> Result<HashMap<AssetNameExchange, CollateralRatios>, UnindexedClientError> {
318    let mut ratios = HashMap::new();
319    for (index, group) in groups.into_iter().enumerate() {
320        let assets = group
321            .asset_names
322            .ok_or_else(|| decode_error(format!("group {index}: assetNames missing")))?;
323        let group_ratios = CollateralRatios {
324            liquidation: tier_set(index, "collaterals", group.collaterals)?,
325            borrow: tier_set(index, "borrowCollaterals", group.borrow_collaterals)?,
326            transfer_out: tier_set(index, "withdrawCollaterals", group.withdraw_collaterals)?,
327        };
328        for asset in assets {
329            match ratios.entry(AssetNameExchange::new(asset)) {
330                Entry::Vacant(entry) => {
331                    entry.insert(group_ratios.clone());
332                }
333                Entry::Occupied(entry) => {
334                    return Err(decode_error(format!("{} listed twice", entry.key())));
335                }
336            }
337        }
338    }
339    Ok(ratios)
340}
341
342/// The tier set `set` of group `index`, in ascending minimum USD value. It must be present and
343/// hold at least one tier.
344fn tier_set(
345    index: usize,
346    set: &str,
347    tiers: Option<Vec<CollateralRatioTierAnswer>>,
348) -> Result<Vec<CollateralRatioTier>, UnindexedClientError> {
349    let tiers = tiers.ok_or_else(|| decode_error(format!("group {index}: {set} missing")))?;
350    if tiers.is_empty() {
351        return Err(decode_error(format!("group {index}: no {set}")));
352    }
353    let mut tiers = tiers
354        .into_iter()
355        .map(|tier| collateral_ratio_tier(index, set, tier))
356        .collect::<Result<Vec<_>, _>>()?;
357    tiers.sort_by_key(|tier| tier.min_usd_value);
358    Ok(tiers)
359}
360
361/// A tier of set `set` of group `index`. Its minimum and ratio are required, its maximum is not.
362fn collateral_ratio_tier(
363    index: usize,
364    set: &str,
365    tier: CollateralRatioTierAnswer,
366) -> Result<CollateralRatioTier, UnindexedClientError> {
367    let field_name = |field: &str| format!("group {index} {set}: {field}");
368    let required = |field: &str, value: Option<String>| {
369        let field = field_name(field);
370        optional_decimal(&field, value.as_deref())?
371            .ok_or_else(|| decode_error(format!("{field} missing")))
372    };
373    Ok(CollateralRatioTier {
374        min_usd_value: required("minUsdValue", tier.min_usd_value)?,
375        max_usd_value: optional_decimal(&field_name("maxUsdValue"), tier.max_usd_value.as_deref())?,
376        ratio: required("discountRate", tier.discount_rate)?,
377    })
378}
379
380fn decode_error(detail: String) -> UnindexedClientError {
381    UnindexedClientError::Internal(format!("decoding Binance response: {detail}"))
382}
383
384#[cfg(test)]
385#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
386mod tests {
387    use super::{super::tests::margin_client_at, *};
388    use crate::error::ApiError;
389    use rust_decimal_macros::dec;
390
391    const SETS: [&str; 3] = ["collaterals", "borrowCollaterals", "withdrawCollaterals"];
392
393    fn groups(body: serde_json::Value) -> Vec<CollateralRatioGroup> {
394        serde_json::from_value(body).unwrap()
395    }
396
397    fn asset(name: &str) -> AssetNameExchange {
398        AssetNameExchange::new(name)
399    }
400
401    fn open(ratio: Decimal) -> Vec<CollateralRatioTier> {
402        vec![CollateralRatioTier::new(dec!(0), None, ratio)]
403    }
404
405    /// Two groups: one with the single open tier per set Binance was seen to send, each set's
406    /// ratio distinct so that a set read into the wrong field shows; and one with sets of several
407    /// tiers listed out of order.
408    fn tiered() -> serde_json::Value {
409        serde_json::json!([
410            {
411                "collaterals": [{"minUsdValue": "0", "discountRate": "1"}],
412                "borrowCollaterals": [{"minUsdValue": "0", "discountRate": "0.95"}],
413                "withdrawCollaterals": [{"minUsdValue": "0", "discountRate": "0.9"}],
414                "assetNames": ["USDT", "BTC"]
415            },
416            {
417                "collaterals": [
418                    {"minUsdValue": "5000", "maxUsdValue": "10000", "discountRate": "0.5"},
419                    {"minUsdValue": "10000", "discountRate": "0"},
420                    {"minUsdValue": "0", "maxUsdValue": "5000", "discountRate": "0.9"}
421                ],
422                "borrowCollaterals": [
423                    {"minUsdValue": "2000", "discountRate": "0.05"},
424                    {"minUsdValue": "0", "maxUsdValue": "2000", "discountRate": "0.1"}
425                ],
426                "withdrawCollaterals": [
427                    {"minUsdValue": "3000", "discountRate": "0.02"},
428                    {"minUsdValue": "0", "maxUsdValue": "3000", "discountRate": "0.2"}
429                ],
430                "assetNames": ["SHIB"]
431            }
432        ])
433    }
434
435    #[test]
436    fn collateral_ratios_give_each_asset_its_groups_three_sets_in_ascending_order() {
437        let ratios = collateral_ratios(groups(tiered())).unwrap();
438        let single = CollateralRatios::new(open(dec!(1)), open(dec!(0.95)), open(dec!(0.9)));
439        assert_eq!(
440            ratios,
441            HashMap::from([
442                (asset("USDT"), single.clone()),
443                (asset("BTC"), single),
444                (
445                    asset("SHIB"),
446                    CollateralRatios::new(
447                        vec![
448                            CollateralRatioTier::new(dec!(0), Some(dec!(5000)), dec!(0.9)),
449                            CollateralRatioTier::new(dec!(5000), Some(dec!(10000)), dec!(0.5)),
450                            CollateralRatioTier::new(dec!(10000), None, dec!(0)),
451                        ],
452                        vec![
453                            CollateralRatioTier::new(dec!(0), Some(dec!(2000)), dec!(0.1)),
454                            CollateralRatioTier::new(dec!(2000), None, dec!(0.05)),
455                        ],
456                        vec![
457                            CollateralRatioTier::new(dec!(0), Some(dec!(3000)), dec!(0.2)),
458                            CollateralRatioTier::new(dec!(3000), None, dec!(0.02)),
459                        ],
460                    )
461                ),
462            ])
463        );
464    }
465
466    #[test]
467    fn collateral_ratios_of_no_groups_or_a_group_of_no_assets_are_empty() {
468        assert!(collateral_ratios(Vec::new()).unwrap().is_empty());
469        let mut body = tiered();
470        body[0]["assetNames"] = serde_json::json!([]);
471        body.as_array_mut().unwrap().truncate(1);
472        assert!(collateral_ratios(groups(body)).unwrap().is_empty());
473    }
474
475    #[test]
476    fn collateral_ratios_refuse_an_answer_they_cannot_read_whole() {
477        let mut cases = Vec::new();
478        let mut body = tiered();
479        body[1].as_object_mut().unwrap().remove("assetNames");
480        cases.push(("group 1: assetNames missing".to_string(), body));
481        for set in SETS {
482            let mut body = tiered();
483            body[1].as_object_mut().unwrap().remove(set);
484            cases.push((format!("group 1: {set} missing"), body));
485            let mut body = tiered();
486            body[1][set] = serde_json::json!([]);
487            cases.push((format!("group 1: no {set}"), body));
488            for field in ["minUsdValue", "discountRate"] {
489                let mut body = tiered();
490                body[1][set][1].as_object_mut().unwrap().remove(field);
491                cases.push((format!("group 1 {set}: {field} missing"), body));
492            }
493            for field in ["minUsdValue", "maxUsdValue", "discountRate"] {
494                let mut body = tiered();
495                body[1][set][0][field] = serde_json::json!("0.5.0");
496                cases.push((format!("group 1 {set}: {field} \"0.5.0\""), body));
497            }
498        }
499        let mut body = tiered();
500        body[1]["assetNames"] = serde_json::json!(["SHIB", "BTC"]);
501        cases.push(("BTC listed twice".to_string(), body));
502        let mut body = tiered();
503        body[1]["assetNames"] = serde_json::json!(["SHIB", "SHIB"]);
504        cases.push(("SHIB listed twice".to_string(), body));
505
506        for (expected, body) in cases {
507            match collateral_ratios(groups(body)) {
508                Err(UnindexedClientError::Internal(message)) => {
509                    assert!(message.contains(&expected), "{expected}: {message}");
510                }
511                other => panic!("{expected}: {other:?}"),
512            }
513        }
514    }
515
516    fn usdt_margin() -> SpotMarginConfig {
517        SpotMarginConfig::new(asset("USDT"), dec!(3), crate::margin::MarginEffect::Borrow).unwrap()
518    }
519
520    #[test]
521    fn apply_collateral_ratios_sets_each_named_assets_borrow_and_liquidation_ratios() {
522        let mut ratios = collateral_ratios(groups(tiered())).unwrap();
523        ratios.insert(
524            asset("ETH"),
525            CollateralRatios::new(open(dec!(0.97)), open(dec!(0.8)), open(dec!(0.7))),
526        );
527        // BTC already has ratios that the fetched ones replace, which setting the two one at a
528        // time would refuse in one order or the other.
529        let config = usdt_margin()
530            .with_collateral_ratios(asset("BTC"), dec!(0.5), dec!(0.6))
531            .unwrap()
532            .with_collateral_ratios(asset("DOGE"), dec!(0.2), dec!(0.3))
533            .unwrap();
534
535        let applied =
536            apply_collateral_ratios(config, &ratios, [&asset("BTC"), &asset("ETH")]).unwrap();
537
538        assert_eq!(
539            applied,
540            usdt_margin()
541                .with_collateral_ratios(asset("BTC"), dec!(0.95), dec!(1))
542                .unwrap()
543                .with_collateral_ratios(asset("ETH"), dec!(0.8), dec!(0.97))
544                .unwrap()
545                .with_collateral_ratios(asset("DOGE"), dec!(0.2), dec!(0.3))
546                .unwrap(),
547            "the borrow and liquidation sets, not transfer-out; unnamed assets, SHIB's \
548             several tiers included, untouched"
549        );
550    }
551
552    #[test]
553    fn apply_collateral_ratios_of_no_assets_is_the_config_unchanged() {
554        let config = usdt_margin()
555            .with_collateral_ratios(asset("BTC"), dec!(0.5), dec!(0.6))
556            .unwrap();
557        let ratios = collateral_ratios(groups(tiered())).unwrap();
558        assert_eq!(
559            apply_collateral_ratios(config.clone(), &ratios, []),
560            Ok(config)
561        );
562    }
563
564    #[test]
565    fn apply_collateral_ratios_refuses_an_asset_not_listed() {
566        let ratios = collateral_ratios(groups(tiered())).unwrap();
567        assert_eq!(
568            apply_collateral_ratios(usdt_margin(), &ratios, [&asset("BTC"), &asset("DOGE")]),
569            Err(ApplyCollateralRatiosError::AssetNotListed(asset("DOGE")))
570        );
571    }
572
573    #[test]
574    fn apply_collateral_ratios_refuses_a_set_that_is_not_one_open_tier() {
575        let ratios = collateral_ratios(groups(tiered())).unwrap();
576        let shib = &ratios[&asset("SHIB")];
577        assert_eq!(
578            apply_collateral_ratios(usdt_margin(), &ratios, [&asset("SHIB")]),
579            Err(ApplyCollateralRatiosError::NotOneOpenTier {
580                asset: asset("SHIB"),
581                set: CollateralRatioSet::Borrow,
582                tiers: shib.borrow.clone(),
583            }),
584            "several tiers"
585        );
586
587        let one = CollateralRatioTier::new(dec!(0), None, dec!(0.9));
588        let not_open = [
589            vec![CollateralRatioTier::new(dec!(100), None, dec!(0.9))],
590            vec![CollateralRatioTier::new(
591                dec!(0),
592                Some(dec!(5000)),
593                dec!(0.9),
594            )],
595            Vec::new(),
596        ];
597        for tiers in not_open {
598            for set in [CollateralRatioSet::Borrow, CollateralRatioSet::Liquidation] {
599                let mut btc = CollateralRatios::new(vec![one], vec![one], vec![one]);
600                match set {
601                    CollateralRatioSet::Borrow => btc.borrow = tiers.clone(),
602                    CollateralRatioSet::Liquidation => btc.liquidation = tiers.clone(),
603                }
604                let ratios = HashMap::from([(asset("BTC"), btc)]);
605                assert_eq!(
606                    apply_collateral_ratios(usdt_margin(), &ratios, [&asset("BTC")]),
607                    Err(ApplyCollateralRatiosError::NotOneOpenTier {
608                        asset: asset("BTC"),
609                        set,
610                        tiers: tiers.clone(),
611                    }),
612                    "{set}: {tiers:?}"
613                );
614            }
615        }
616        // Transfer-out is not read, so its form does not matter.
617        let btc = CollateralRatios::new(vec![one], vec![one], Vec::new());
618        let ratios = HashMap::from([(asset("BTC"), btc)]);
619        assert!(apply_collateral_ratios(usdt_margin(), &ratios, [&asset("BTC")]).is_ok());
620    }
621
622    #[test]
623    fn apply_collateral_ratios_errors_name_the_asset_and_the_set() {
624        let not_open = ApplyCollateralRatiosError::NotOneOpenTier {
625            asset: asset("SHIB"),
626            set: CollateralRatioSet::Liquidation,
627            tiers: Vec::new(),
628        };
629        assert_eq!(
630            not_open.to_string(),
631            "SHIB's liquidation collateral ratio is not one tier covering every USD value: []"
632        );
633        assert_eq!(CollateralRatioSet::Borrow.to_string(), "borrow");
634        assert_eq!(
635            ApplyCollateralRatiosError::AssetNotListed(asset("DOGE")).to_string(),
636            "Binance lists no collateral ratios for DOGE"
637        );
638    }
639
640    #[test]
641    fn apply_collateral_ratios_passes_on_the_configs_refusal_unclamped() {
642        for (borrow, liquidation, refused) in [
643            (
644                dec!(0.9),
645                dec!(1.5),
646                SpotMarginConfigError::CollateralRatioOutOfRange {
647                    asset: asset("BTC"),
648                    ratio: dec!(1.5),
649                },
650            ),
651            (
652                dec!(0.9),
653                dec!(0.8),
654                SpotMarginConfigError::LiquidationRatioBelowBorrowRatio {
655                    asset: asset("BTC"),
656                    borrow: dec!(0.9),
657                    liquidation: dec!(0.8),
658                },
659            ),
660        ] {
661            let btc = CollateralRatios::new(open(liquidation), open(borrow), open(borrow));
662            let ratios = HashMap::from([(asset("BTC"), btc)]);
663            assert_eq!(
664                apply_collateral_ratios(usdt_margin(), &ratios, [&asset("BTC")]),
665                Err(ApplyCollateralRatiosError::Config(refused))
666            );
667        }
668    }
669
670    #[tokio::test]
671    async fn fetch_collateral_ratios_reads_the_endpoint_once_with_the_api_key_unsigned() {
672        for is_isolated in [false, true] {
673            let server = wiremock::MockServer::start().await;
674            wiremock::Mock::given(wiremock::matchers::method("GET"))
675                .and(wiremock::matchers::path(COLLATERAL_RATIO_PATH))
676                .and(wiremock::matchers::header("X-MBX-APIKEY", "key"))
677                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(tiered()))
678                .expect(1)
679                .mount(&server)
680                .await;
681            let client = margin_client_at(&server, is_isolated);
682
683            let ratios = client.fetch_collateral_ratios().await.unwrap();
684            assert_eq!(ratios, collateral_ratios(groups(tiered())).unwrap());
685            let requests = server.received_requests().await.unwrap();
686            let [request] = requests.as_slice() else {
687                panic!("{requests:?}");
688            };
689            assert_eq!(
690                request.url.query_pairs().count(),
691                0,
692                "unsigned, no parameters: {}",
693                request.url
694            );
695        }
696    }
697
698    #[tokio::test]
699    async fn fetch_collateral_ratios_reports_a_refusal_and_an_unreadable_answer() {
700        let server = wiremock::MockServer::start().await;
701        wiremock::Mock::given(wiremock::matchers::path(COLLATERAL_RATIO_PATH))
702            .respond_with(
703                wiremock::ResponseTemplate::new(400)
704                    .set_body_json(serde_json::json!({"code": -1102, "msg": "refused"})),
705            )
706            .up_to_n_times(1)
707            .mount(&server)
708            .await;
709        let mut unreadable = tiered();
710        unreadable[0]["borrowCollaterals"][0]["discountRate"] = serde_json::json!("one");
711        wiremock::Mock::given(wiremock::matchers::path(COLLATERAL_RATIO_PATH))
712            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(unreadable))
713            .mount(&server)
714            .await;
715        let client = margin_client_at(&server, false);
716
717        let refused = client.fetch_collateral_ratios().await;
718        assert!(
719            matches!(
720                refused,
721                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
722            ),
723            "{refused:?}"
724        );
725        match client.fetch_collateral_ratios().await {
726            Err(UnindexedClientError::Internal(message)) => {
727                assert!(
728                    message.contains("borrowCollaterals: discountRate \"one\""),
729                    "{message}"
730                );
731            }
732            other => panic!("{other:?}"),
733        }
734    }
735}