rustrade_execution/client/binance/margin/
collateral.rs1use 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#[non_exhaustive]
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25pub struct CollateralRatioTier {
26 pub min_usd_value: Decimal,
28 pub max_usd_value: Option<Decimal>,
31 pub ratio: Decimal,
34}
35
36impl CollateralRatioTier {
37 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 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
110fn 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
144fn 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)] mod 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 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}