1use super::BinanceMargin;
7use crate::{
8 cash_flow::{CashFlow, CashFlowId, CashFlowKind},
9 client::binance::shared::{
10 RequestKind, classify_rest_query_error, parse_side, response_decode_error,
11 rest_call_with_retry,
12 },
13 error::UnindexedClientError,
14 order::id::OrderId,
15};
16use binance_sdk::margin_trading::rest_api::{
17 GetForceLiquidationRecordParams, GetForceLiquidationRecordResponseRowsInner,
18 GetInterestHistoryParams, GetInterestHistoryResponseRowsInner,
19};
20use chrono::{DateTime, TimeDelta, TimeZone, Utc};
21use fnv::FnvHashSet;
22use futures::{StreamExt as _, TryStreamExt as _};
23use rust_decimal::Decimal;
24use rustrade_instrument::{
25 Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
26};
27use serde::{Deserialize, Serialize};
28use smol_str::format_smolstr;
29use std::{future::Future, str::FromStr};
30use tracing::{debug, warn};
31
32const INTEREST_LOOKBACK: TimeDelta = TimeDelta::days(90);
34
35const INTEREST_LOOKBACK_MARGIN: TimeDelta = TimeDelta::minutes(1);
38
39const INTEREST_WINDOW_MS: i64 = 29 * 24 * 60 * 60 * 1000;
42
43const PAGE_SIZE: i64 = 100;
45
46const MAX_PAGES: i64 = 500;
50
51const CONCURRENT_PAIRS: usize = 8;
53
54#[non_exhaustive]
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
60pub struct MarginForcedLiquidation {
61 pub order_id: OrderId,
67 pub instrument: InstrumentNameExchange,
69 pub side: Side,
71 pub price: Option<Decimal>,
73 pub quantity: Option<Decimal>,
75 pub filled_quantity: Decimal,
77 pub avg_price: Option<Decimal>,
79 pub time_exchange: DateTime<Utc>,
81}
82
83impl BinanceMargin {
84 pub async fn fetch_margin_interest(
138 &self,
139 start: DateTime<Utc>,
140 end: DateTime<Utc>,
141 ) -> Result<Vec<CashFlow<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError>
142 {
143 let now = Utc::now();
144 let horizon = now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN;
145 let start = if start < horizon {
146 if start < now - INTEREST_LOOKBACK - INTEREST_LOOKBACK_MARGIN {
147 warn!(
148 requested = %start,
149 served_from = %horizon,
150 "BinanceMargin fetch_margin_interest: Binance serves 90 days of interest \
151 history — reading from the earliest time it serves"
152 );
153 }
154 horizon
155 } else {
156 start
157 };
158 let end = end.min(now);
159 if start > end {
160 return Ok(Vec::new());
161 }
162 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
163
164 let rows = self
165 .read_each_account(|pair| self.interest_rows(pair, start_ms, end_ms))
166 .await?;
167
168 let mut seen = FnvHashSet::default();
169 let mut flows: Vec<_> = rows
170 .into_iter()
171 .flat_map(|(pair, rows)| {
172 rows.into_iter()
173 .filter_map(move |row| convert_interest(row, pair.as_ref()))
174 })
175 .filter(|flow| (start..=end).contains(&flow.time_exchange))
176 .filter(|flow| seen.insert(ChargeKey::of(flow)))
179 .collect();
180 flows.sort_by_key(|flow| flow.time_exchange);
181 Ok(flows)
182 }
183
184 pub async fn fetch_forced_liquidations(
215 &self,
216 start: DateTime<Utc>,
217 end: DateTime<Utc>,
218 ) -> Result<Vec<MarginForcedLiquidation>, UnindexedClientError> {
219 let end = end.min(Utc::now());
220 if start > end {
221 return Ok(Vec::new());
222 }
223 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
224
225 let is_isolated = self.config.is_isolated;
226 let rows = self
227 .read_each_account(|pair| {
228 read_all_pages(move |current| {
229 self.forced_liquidation_page(pair.clone(), start_ms, end_ms, current)
230 })
231 })
232 .await?;
233
234 let mut seen = FnvHashSet::default();
235 let mut liquidations: Vec<_> = rows
236 .into_iter()
237 .flat_map(|(_, rows)| rows)
238 .filter_map(|row| convert_forced_liquidation(row, is_isolated))
239 .filter(|liquidation| (start..=end).contains(&liquidation.time_exchange))
240 .filter(|liquidation| {
243 seen.insert((liquidation.instrument.clone(), liquidation.order_id.clone()))
244 })
245 .collect();
246 liquidations.sort_by_key(|liquidation| liquidation.time_exchange);
247 Ok(liquidations)
248 }
249
250 async fn read_each_account<'a, Row, Fut>(
253 &'a self,
254 read: impl Fn(Option<InstrumentNameExchange>) -> Fut,
255 ) -> Result<Vec<(Option<InstrumentNameExchange>, Vec<Row>)>, UnindexedClientError>
256 where
257 Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>> + 'a,
258 {
259 let accounts: Vec<Option<InstrumentNameExchange>> = if self.config.is_isolated {
260 self.config
261 .isolated_symbols
262 .iter()
263 .cloned()
264 .map(Some)
265 .collect()
266 } else {
267 vec![None]
268 };
269 futures::stream::iter(accounts.into_iter().map(|pair| {
270 let rows = read(pair.clone());
271 async move { Ok::<_, UnindexedClientError>((pair, rows.await?)) }
272 }))
273 .buffer_unordered(CONCURRENT_PAIRS)
274 .try_collect()
275 .await
276 }
277
278 async fn interest_rows(
280 &self,
281 pair: Option<InstrumentNameExchange>,
282 start_ms: i64,
283 end_ms: i64,
284 ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
285 let mut rows = Vec::new();
286 let mut window_start = start_ms;
287 loop {
288 let window_end = window_start.saturating_add(INTEREST_WINDOW_MS).min(end_ms);
289 let pair = &pair;
290 rows.extend(
291 read_all_pages(|current| {
292 self.interest_page(pair.clone(), window_start, window_end, current)
293 })
294 .await?,
295 );
296 if window_end >= end_ms {
297 break;
298 }
299 window_start = window_end;
302 }
303 debug!(
304 pair = ?pair,
305 rows = rows.len(),
306 "BinanceMargin fetch_margin_interest: account read"
307 );
308 Ok(rows)
309 }
310
311 async fn interest_page(
313 &self,
314 pair: Option<InstrumentNameExchange>,
315 start_ms: i64,
316 end_ms: i64,
317 current: i64,
318 ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
319 let symbol = pair.as_ref().map(|pair| pair.name().to_string());
320 let response =
321 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
322 let symbol = symbol.clone();
323 Box::pin(async move {
324 let params = GetInterestHistoryParams::builder()
325 .isolated_symbol(symbol)
326 .start_time(start_ms)
327 .end_time(end_ms)
328 .current(current)
329 .size(PAGE_SIZE)
330 .build()?;
331 rest.get_interest_history(params).await
332 })
333 })
334 .await
335 .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
336 let page = response.data().await.map_err(response_decode_error)?;
337 Ok(page.rows.unwrap_or_default())
338 }
339
340 async fn forced_liquidation_page(
342 &self,
343 pair: Option<InstrumentNameExchange>,
344 start_ms: i64,
345 end_ms: i64,
346 current: i64,
347 ) -> Result<Vec<GetForceLiquidationRecordResponseRowsInner>, UnindexedClientError> {
348 let symbol = pair.as_ref().map(|pair| pair.name().to_string());
349 let response =
350 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
351 let symbol = symbol.clone();
352 Box::pin(async move {
353 let params = GetForceLiquidationRecordParams::builder()
354 .isolated_symbol(symbol)
355 .start_time(start_ms)
356 .end_time(end_ms)
357 .current(current)
358 .size(PAGE_SIZE)
359 .build()?;
360 rest.get_force_liquidation_record(params).await
361 })
362 })
363 .await
364 .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
365 let page = response.data().await.map_err(response_decode_error)?;
366 Ok(page.rows.unwrap_or_default())
367 }
368}
369
370async fn read_all_pages<Row, Fut>(
373 mut page: impl FnMut(i64) -> Fut,
374) -> Result<Vec<Row>, UnindexedClientError>
375where
376 Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>>,
377{
378 let mut rows = Vec::new();
379 for current in 1..=MAX_PAGES {
380 let batch = page(current).await?;
381 let full = i64::try_from(batch.len()).is_ok_and(|len| len >= PAGE_SIZE);
382 rows.extend(batch);
383 if !full {
384 return Ok(rows);
385 }
386 }
387 Err(UnindexedClientError::Internal(format!(
388 "Binance margin history query still full after {MAX_PAGES} pages of {PAGE_SIZE}: \
389 narrow the span"
390 )))
391}
392
393#[derive(PartialEq, Eq, Hash)]
396enum ChargeKey {
397 Id(
398 AssetNameExchange,
399 Option<InstrumentNameExchange>,
400 CashFlowId,
401 ),
402 Flow(CashFlow<AssetNameExchange, InstrumentNameExchange>),
403}
404
405impl ChargeKey {
406 fn of(flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>) -> Self {
407 match &flow.id {
408 Some(id) => Self::Id(flow.asset.clone(), flow.instrument.clone(), id.clone()),
409 None => Self::Flow(flow.clone()),
410 }
411 }
412}
413
414fn optional_decimal(record: &str, field: &str, value: Option<&str>) -> Option<Decimal> {
417 let value = value.filter(|value| !value.is_empty())?;
418 Decimal::from_str(value)
419 .inspect_err(|error| {
420 warn!(
421 record,
422 field,
423 value,
424 %error,
425 "BinanceMargin record field does not parse — left out"
426 );
427 })
428 .ok()
429}
430
431fn convert_interest(
434 row: GetInterestHistoryResponseRowsInner,
435 pair: Option<&InstrumentNameExchange>,
436) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
437 let tx_id = row.tx_id;
438 let Some(asset) = row.asset.as_deref() else {
439 warn!(
440 ?tx_id,
441 ?pair,
442 "BinanceMargin interest record missing asset — skipped"
443 );
444 return None;
445 };
446 let Some(interest) = row
447 .interest
448 .as_deref()
449 .and_then(|s| Decimal::from_str(s).ok())
450 else {
451 warn!(
452 ?tx_id,
453 ?pair,
454 "BinanceMargin interest record missing/unparseable interest — skipped"
455 );
456 return None;
457 };
458 let Some(time_exchange) = row
459 .interest_accured_time
460 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
461 else {
462 warn!(
463 ?tx_id,
464 ?pair,
465 "BinanceMargin interest record missing/unparseable time — skipped"
466 );
467 return None;
468 };
469 let instrument = row
470 .isolated_symbol
471 .as_deref()
472 .map(InstrumentNameExchange::new)
473 .or_else(|| pair.cloned());
474 Some(CashFlow::new(
475 CashFlowKind::MarginInterest {
476 rate: optional_decimal("interest", "interestRate", row.interest_rate.as_deref()),
477 principal: optional_decimal("interest", "principal", row.principal.as_deref()),
478 },
479 AssetNameExchange::new(asset),
480 -interest,
481 instrument,
482 time_exchange,
483 tx_id.map(|id| CashFlowId(format_smolstr!("{id}"))),
484 ))
485}
486
487fn convert_forced_liquidation(
491 row: GetForceLiquidationRecordResponseRowsInner,
492 is_isolated: bool,
493) -> Option<MarginForcedLiquidation> {
494 let Some(order_id) = row.order_id else {
495 warn!(symbol = ?row.symbol, "BinanceMargin forced liquidation record missing orderId — skipped");
496 return None;
497 };
498 let Some(symbol) = row.symbol.as_deref() else {
499 warn!(
500 order_id,
501 "BinanceMargin forced liquidation record missing symbol — skipped"
502 );
503 return None;
504 };
505 if !is_isolated && row.is_isolated == Some(true) {
508 warn!(
509 order_id,
510 symbol,
511 "BinanceMargin cross forced liquidation read returned an isolated record — left out"
512 );
513 return None;
514 }
515 let Some(side) = row.side.as_deref().and_then(parse_side) else {
516 warn!(
517 order_id,
518 symbol, "BinanceMargin forced liquidation record missing/unknown side — skipped"
519 );
520 return None;
521 };
522 let Some(filled_quantity) = row
523 .executed_qty
524 .as_deref()
525 .and_then(|s| Decimal::from_str(s).ok())
526 else {
527 warn!(
528 order_id,
529 symbol,
530 "BinanceMargin forced liquidation record missing/unparseable executedQty — skipped"
531 );
532 return None;
533 };
534 let Some(time_exchange) = row
535 .updated_time
536 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
537 else {
538 warn!(
539 order_id,
540 symbol,
541 "BinanceMargin forced liquidation record missing/unparseable updatedTime — skipped"
542 );
543 return None;
544 };
545 Some(MarginForcedLiquidation {
546 order_id: OrderId(format_smolstr!("{order_id}")),
547 instrument: InstrumentNameExchange::new(symbol),
548 side,
549 price: optional_decimal("forced liquidation", "price", row.price.as_deref()),
550 quantity: optional_decimal("forced liquidation", "qty", row.qty.as_deref()),
551 avg_price: (!filled_quantity.is_zero())
552 .then(|| optional_decimal("forced liquidation", "avgPrice", row.avg_price.as_deref()))
553 .flatten(),
554 filled_quantity,
555 time_exchange,
556 })
557}
558
559#[cfg(test)]
560#[allow(clippy::unwrap_used)] mod tests {
562 use super::{super::tests::margin_client_at, *};
563 use crate::error::ApiError;
564 use rust_decimal_macros::dec;
565 use serde_json::{Value, json};
566 use std::collections::HashMap;
567 use wiremock::{
568 Mock, MockServer, ResponseTemplate,
569 matchers::{method, path, query_param},
570 };
571
572 const INTEREST_PATH: &str = "/sapi/v1/margin/interestHistory";
573 const LIQUIDATION_PATH: &str = "/sapi/v1/margin/forceLiquidationRec";
574
575 fn interest_row(tx_id: i64, time: DateTime<Utc>, isolated: Option<&str>) -> Value {
576 let mut row = json!({
577 "txId": tx_id, "interestAccuredTime": time.timestamp_millis(), "asset": "USDT",
578 "rawAsset": "USDT", "principal": "1000", "interest": "0.0125",
579 "interestRate": "0.0000125", "type": "PERIODIC",
580 });
581 if let Some(symbol) = isolated {
582 row["isolatedSymbol"] = json!(symbol);
583 }
584 row
585 }
586
587 fn liquidation_row(
588 order_id: i64,
589 side: &str,
590 filled: &str,
591 time: DateTime<Utc>,
592 is_isolated: bool,
593 ) -> Value {
594 json!({
595 "avgPrice": "60000", "executedQty": filled, "orderId": order_id, "price": "59000",
596 "qty": "0.5", "side": side, "symbol": "BTCUSDT", "timeInForce": "GTC",
597 "isIsolated": is_isolated, "updatedTime": time.timestamp_millis(),
598 })
599 }
600
601 fn page(rows: Vec<Value>) -> ResponseTemplate {
602 let total = rows.len();
603 ResponseTemplate::new(200).set_body_json(json!({ "rows": rows, "total": total }))
604 }
605
606 async fn mount(server: &MockServer, at: &str, response: ResponseTemplate) {
607 Mock::given(method("GET"))
608 .and(path(at))
609 .respond_with(response)
610 .mount(server)
611 .await;
612 }
613
614 async fn mount_page(server: &MockServer, at: &str, current: &str, response: ResponseTemplate) {
616 Mock::given(method("GET"))
617 .and(path(at))
618 .and(query_param("current", current))
619 .respond_with(response)
620 .mount(server)
621 .await;
622 }
623
624 async fn queries(server: &MockServer) -> Vec<HashMap<String, String>> {
626 server
627 .received_requests()
628 .await
629 .unwrap()
630 .iter()
631 .map(|request| request.url.query_pairs().into_owned().collect())
632 .collect()
633 }
634
635 fn millis(query: &HashMap<String, String>, key: &str) -> i64 {
636 query[key].parse().unwrap()
637 }
638
639 #[tokio::test]
640 async fn interest_is_read_window_by_window_as_negative_flows_oldest_first_once_each() {
641 let server = MockServer::start().await;
642 let now = Utc::now();
643 let start = now - TimeDelta::days(40);
644 let boundary = start + TimeDelta::milliseconds(INTEREST_WINDOW_MS);
645 let mut no_interest = interest_row(9, now - TimeDelta::days(2), None);
646 no_interest.as_object_mut().unwrap().remove("interest");
647 let mut bad_rate = interest_row(3, now - TimeDelta::days(3), None);
648 bad_rate["interestRate"] = json!("n/a");
649 mount(
652 &server,
653 INTEREST_PATH,
654 page(vec![
655 interest_row(2, now - TimeDelta::days(1), None),
656 no_interest,
657 bad_rate,
658 interest_row(4, boundary, None),
659 interest_row(1, now - TimeDelta::days(35), None),
660 interest_row(0, now - TimeDelta::days(50), None),
661 ]),
662 )
663 .await;
664 let client = margin_client_at(&server, false);
665
666 let flows = client
667 .fetch_margin_interest(start, now + TimeDelta::days(5))
668 .await
669 .unwrap();
670
671 let ids: Vec<_> = flows.iter().map(|flow| flow.id.clone().unwrap()).collect();
672 assert_eq!(ids, ["1", "4", "3", "2"].map(CashFlowId::new));
673 let flow = &flows[0];
674 assert_eq!(flow.amount, dec!(-0.0125), "a charge is paid");
675 assert_eq!(flow.asset, AssetNameExchange::new("USDT"));
676 assert_eq!(flow.instrument, None, "a cross charge names no pair");
677 assert_eq!(
678 flow.kind,
679 CashFlowKind::MarginInterest {
680 rate: Some(dec!(0.0000125)),
681 principal: Some(dec!(1000)),
682 }
683 );
684 assert_eq!(
685 flows[2].kind,
686 CashFlowKind::MarginInterest {
687 rate: None,
688 principal: Some(dec!(1000)),
689 },
690 "a rate that does not parse is left out, not the charge"
691 );
692
693 let queries = queries(&server).await;
694 let [first, second] = queries.as_slice() else {
695 panic!("two windows: {queries:?}");
696 };
697 assert_eq!(millis(first, "startTime"), start.timestamp_millis());
698 assert_eq!(millis(first, "endTime"), boundary.timestamp_millis());
699 assert_eq!(millis(second, "startTime"), boundary.timestamp_millis());
700 assert!(
701 millis(second, "endTime") <= Utc::now().timestamp_millis(),
702 "read up to the local clock, not `end`"
703 );
704 for query in [first, second] {
705 assert_eq!(query["current"], "1");
706 assert_eq!(query["size"], "100");
707 assert!(!query.contains_key("isolatedSymbol"), "{query:?}");
708 }
709 }
710
711 #[tokio::test]
712 async fn a_charge_repeated_with_its_id_is_kept_once() {
713 let server = MockServer::start().await;
714 let now = Utc::now();
715 let mut corrected = interest_row(1, now - TimeDelta::hours(1), None);
716 corrected["interest"] = json!("0.02");
717 mount(
718 &server,
719 INTEREST_PATH,
720 page(vec![
721 corrected,
722 interest_row(1, now - TimeDelta::hours(1), None),
723 ]),
724 )
725 .await;
726 let client = margin_client_at(&server, false);
727
728 let flows = client
729 .fetch_margin_interest(now - TimeDelta::days(1), now)
730 .await
731 .unwrap();
732
733 let [flow] = flows.as_slice() else {
734 panic!("one charge: {flows:?}");
735 };
736 assert_eq!(flow.amount, dec!(-0.02), "the first copy read is kept");
737 }
738
739 #[tokio::test]
740 async fn a_span_of_one_instant_is_read_in_one_request() {
741 let server = MockServer::start().await;
742 let at =
744 DateTime::from_timestamp_millis((Utc::now() - TimeDelta::hours(1)).timestamp_millis())
745 .unwrap();
746 mount(
747 &server,
748 INTEREST_PATH,
749 page(vec![interest_row(1, at, None)]),
750 )
751 .await;
752 let client = margin_client_at(&server, false);
753
754 let flows = client.fetch_margin_interest(at, at).await.unwrap();
755
756 assert_eq!(flows.len(), 1);
757 let queries = queries(&server).await;
758 let [query] = queries.as_slice() else {
759 panic!("one request: {queries:?}");
760 };
761 assert_eq!(millis(query, "startTime"), at.timestamp_millis());
762 assert_eq!(millis(query, "endTime"), at.timestamp_millis());
763 }
764
765 #[tokio::test]
766 async fn a_full_page_reads_the_next() {
767 let server = MockServer::start().await;
768 let now = Utc::now();
769 let time = now - TimeDelta::hours(1);
770 let full = (0..100).map(|id| interest_row(id, time, None)).collect();
771 mount_page(&server, INTEREST_PATH, "1", page(full)).await;
772 mount_page(
773 &server,
774 INTEREST_PATH,
775 "2",
776 page(vec![interest_row(100, time, None)]),
777 )
778 .await;
779 let client = margin_client_at(&server, false);
780
781 let flows = client
782 .fetch_margin_interest(now - TimeDelta::days(1), now)
783 .await
784 .unwrap();
785
786 assert_eq!(flows.len(), 101);
787 assert_eq!(queries(&server).await.len(), 2);
788 }
789
790 #[tokio::test]
791 async fn a_query_still_full_after_the_most_pages_fails() {
792 let server = MockServer::start().await;
793 let now = Utc::now();
794 let time = now - TimeDelta::hours(1);
795 let full = (0..100).map(|id| interest_row(id, time, None)).collect();
797 mount(&server, INTEREST_PATH, page(full)).await;
798 let client = margin_client_at(&server, false);
799
800 let result = client
801 .fetch_margin_interest(now - TimeDelta::days(1), now)
802 .await;
803
804 assert!(
805 matches!(result, Err(UnindexedClientError::Internal(_))),
806 "{result:?}"
807 );
808 assert_eq!(
809 i64::try_from(queries(&server).await.len()).unwrap(),
810 MAX_PAGES
811 );
812 }
813
814 #[tokio::test]
815 async fn isolated_interest_is_read_per_pair_and_attributed_to_it() {
816 let server = MockServer::start().await;
817 let now = Utc::now();
818 mount(
819 &server,
820 INTEREST_PATH,
821 page(vec![
822 interest_row(2, now - TimeDelta::hours(1), Some("BTCUSDT")),
823 interest_row(1, now - TimeDelta::hours(2), None),
825 ]),
826 )
827 .await;
828 let client = margin_client_at(&server, true);
829
830 let flows = client
831 .fetch_margin_interest(now - TimeDelta::days(1), now)
832 .await
833 .unwrap();
834
835 assert_eq!(flows.len(), 2);
836 for flow in flows {
837 assert_eq!(
838 flow.instrument,
839 Some(InstrumentNameExchange::new("BTCUSDT"))
840 );
841 }
842 let queries = queries(&server).await;
843 assert_eq!(queries.len(), 1);
844 assert_eq!(queries[0]["isolatedSymbol"], "BTCUSDT");
845 }
846
847 #[tokio::test]
848 async fn interest_older_than_binance_serves_is_read_from_its_horizon() {
849 let server = MockServer::start().await;
850 mount(&server, INTEREST_PATH, page(Vec::new())).await;
851 let client = margin_client_at(&server, false);
852 let before = Utc::now();
853
854 let flows = client
855 .fetch_margin_interest(before - TimeDelta::days(200), before)
856 .await
857 .unwrap();
858 let after = Utc::now();
859
860 assert!(flows.is_empty());
861 let queries = queries(&server).await;
862 let first = millis(&queries[0], "startTime");
863 let horizon = |now: DateTime<Utc>| {
864 (now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN).timestamp_millis()
865 };
866 assert!(
867 (horizon(before)..=horizon(after)).contains(&first),
868 "{first}"
869 );
870 assert_eq!(queries.len(), 4, "90 days in windows of 29");
871 }
872
873 #[tokio::test]
874 async fn an_empty_span_reads_nothing() {
875 let server = MockServer::start().await;
876 let client = margin_client_at(&server, false);
877 let now = Utc::now();
878
879 let interest = client
880 .fetch_margin_interest(now, now - TimeDelta::hours(1))
881 .await
882 .unwrap();
883 let liquidations = client
884 .fetch_forced_liquidations(now + TimeDelta::hours(1), now + TimeDelta::hours(2))
885 .await
886 .unwrap();
887
888 assert!(interest.is_empty() && liquidations.is_empty());
889 assert!(queries(&server).await.is_empty());
890 }
891
892 #[tokio::test]
893 async fn a_refused_read_fails_the_whole_read() {
894 let server = MockServer::start().await;
895 let refused =
896 ResponseTemplate::new(400).set_body_json(json!({"code": -1100, "msg": "refused"}));
897 mount(&server, INTEREST_PATH, refused.clone()).await;
898 mount(&server, LIQUIDATION_PATH, refused).await;
899 let client = margin_client_at(&server, false);
900 let now = Utc::now();
901
902 let interest = client
903 .fetch_margin_interest(now - TimeDelta::days(1), now)
904 .await;
905 let liquidations = client
906 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
907 .await;
908
909 assert!(
910 matches!(
911 interest,
912 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
913 ),
914 "{interest:?}"
915 );
916 assert!(
917 matches!(
918 liquidations,
919 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
920 ),
921 "{liquidations:?}"
922 );
923 }
924
925 #[tokio::test]
926 async fn forced_liquidations_of_the_clients_account_are_read_oldest_first_once_each() {
927 for is_isolated in [false, true] {
928 let server = MockServer::start().await;
929 let now = Utc::now();
930 let row = |order_id, side, filled, time| {
931 liquidation_row(order_id, side, filled, time, is_isolated)
932 };
933 let mut no_order_id = row(9, "SELL", "0.5", now);
934 no_order_id.as_object_mut().unwrap().remove("orderId");
935 mount(
936 &server,
937 LIQUIDATION_PATH,
938 page(vec![
939 row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
940 row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
941 no_order_id,
942 liquidation_row(8, "SELL", "0.5", now, true),
944 row(1, "BUY", "0", now - TimeDelta::hours(2)),
945 row(0, "BUY", "0.5", now - TimeDelta::days(3)),
946 ]),
947 )
948 .await;
949 let client = margin_client_at(&server, is_isolated);
950
951 let liquidations = client
952 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
953 .await
954 .unwrap();
955
956 let (unfilled, filled) = match (is_isolated, liquidations.as_slice()) {
957 (false, [unfilled, filled]) => (unfilled, filled),
958 (true, [unfilled, filled, marked]) => {
959 assert_eq!(marked.order_id, OrderId::new("8"));
960 (unfilled, filled)
961 }
962 _ => panic!("is_isolated = {is_isolated}: {liquidations:?}"),
963 };
964 assert_eq!(unfilled.order_id, OrderId::new("1"));
965 assert_eq!(unfilled.side, Side::Buy);
966 assert_eq!(unfilled.avg_price, None, "nothing filled");
967 assert_eq!(
968 *filled,
969 MarginForcedLiquidation {
970 order_id: OrderId::new("2"),
971 instrument: InstrumentNameExchange::new("BTCUSDT"),
972 side: Side::Sell,
973 price: Some(dec!(59000)),
974 quantity: Some(dec!(0.5)),
975 filled_quantity: dec!(0.5),
976 avg_price: Some(dec!(60000)),
977 time_exchange: Utc
978 .timestamp_millis_opt((now - TimeDelta::hours(1)).timestamp_millis())
979 .unwrap(),
980 }
981 );
982
983 let queries = queries(&server).await;
984 let [query] = queries.as_slice() else {
985 panic!("one page: {queries:?}");
986 };
987 assert_eq!(query["size"], "100");
988 assert_eq!(
989 query.get("isolatedSymbol").map(String::as_str),
990 is_isolated.then_some("BTCUSDT"),
991 );
992 }
993 }
994
995 #[tokio::test]
996 async fn a_full_page_of_forced_liquidations_reads_the_next() {
997 let server = MockServer::start().await;
998 let now = Utc::now();
999 let time = now - TimeDelta::hours(1);
1000 let full = (0..100)
1001 .map(|id| liquidation_row(id, "SELL", "0.5", time, false))
1002 .collect();
1003 mount_page(&server, LIQUIDATION_PATH, "1", page(full)).await;
1004 mount_page(
1005 &server,
1006 LIQUIDATION_PATH,
1007 "2",
1008 page(vec![liquidation_row(100, "SELL", "0.5", time, false)]),
1009 )
1010 .await;
1011 let client = margin_client_at(&server, false);
1012
1013 let liquidations = client
1014 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1015 .await
1016 .unwrap();
1017
1018 assert_eq!(liquidations.len(), 101);
1019 assert_eq!(queries(&server).await.len(), 2);
1020 }
1021}