1use 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
21const COLLATERAL_RATIO_PATH: &str = "/sapi/v1/margin/crossMarginCollateralRatio";
23
24#[non_exhaustive]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct CollateralRatioTier {
29 pub min_usd_value: Decimal,
31 pub max_usd_value: Option<Decimal>,
34 pub ratio: Decimal,
37}
38
39impl CollateralRatioTier {
40 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#[non_exhaustive]
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub struct CollateralRatios {
59 pub liquidation: Vec<CollateralRatioTier>,
62 pub borrow: Vec<CollateralRatioTier>,
64 pub transfer_out: Vec<CollateralRatioTier>,
67}
68
69impl CollateralRatios {
70 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
84pub 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
156fn 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#[non_exhaustive]
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176pub enum CollateralRatioSet {
177 Liquidation,
179 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#[non_exhaustive]
194#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
195pub enum ApplyCollateralRatiosError {
196 #[error("Binance lists no collateral ratios for {0}")]
198 AssetNotListed(AssetNameExchange),
199 #[error("{asset}'s {set} collateral ratio is not one tier covering every USD value: {tiers:?}")]
202 #[non_exhaustive]
203 NotOneOpenTier {
204 asset: AssetNameExchange,
206 set: CollateralRatioSet,
208 tiers: Vec<CollateralRatioTier>,
210 },
211 #[error(transparent)]
213 Config(#[from] SpotMarginConfigError),
214}
215
216impl BinanceMargin {
217 pub async fn fetch_collateral_ratios(
265 &self,
266 ) -> Result<HashMap<AssetNameExchange, CollateralRatios>, UnindexedClientError> {
267 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#[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#[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
314fn 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
342fn 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
361fn 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)] mod 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 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 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 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}