1use super::BinanceMargin;
7use super::loans::{LoanLink, Loans};
8use crate::margin::OrderLoan;
9use crate::{
10 cash_flow::{
11 CashFlow, CashFlowId, CashFlowKind, CashFlowPeriod, CashFlowsRead, InterestTiming,
12 },
13 client::binance::shared::{
14 RequestKind, classify_rest_query_error, parse_side, response_decode_error,
15 rest_call_with_retry,
16 },
17 error::UnindexedClientError,
18 order::id::OrderId,
19};
20use binance_sdk::margin_trading::rest_api::{
21 GetForceLiquidationRecordParams, GetForceLiquidationRecordResponseRowsInner,
22 GetInterestHistoryParams, GetInterestHistoryResponseRowsInner,
23};
24use chrono::{DateTime, TimeDelta, TimeZone, Utc};
25use fnv::FnvHashSet;
26use futures::{StreamExt as _, TryStreamExt as _};
27use rust_decimal::Decimal;
28use rustrade_instrument::{
29 Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
30};
31use serde::{Deserialize, Serialize};
32use smol_str::{SmolStr, format_smolstr};
33use std::{future::Future, str::FromStr};
34use tracing::{debug, warn};
35
36const INTEREST_LOOKBACK: TimeDelta = TimeDelta::days(90);
38
39const INTEREST_LOOKBACK_MARGIN: TimeDelta = TimeDelta::minutes(1);
42
43fn interest_held_since(start: DateTime<Utc>, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
47 let held_since = now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN;
48 (start < held_since).then_some(held_since)
49}
50
51const INTEREST_WINDOW_MS: i64 = 29 * 24 * 60 * 60 * 1000;
54
55const PAGE_SIZE: i64 = 100;
57
58const MAX_PAGES: i64 = 500;
62
63const CONCURRENT_PAIRS: usize = 8;
65
66#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
72pub struct MarginForcedLiquidation {
73 pub order_id: OrderId,
79 pub instrument: InstrumentNameExchange,
81 pub side: Side,
83 pub price: Option<Decimal>,
85 pub quantity: Option<Decimal>,
87 pub filled_quantity: Decimal,
89 pub avg_price: Option<Decimal>,
91 pub time_exchange: DateTime<Utc>,
93}
94
95impl BinanceMargin {
96 pub async fn fetch_margin_interest(
224 &self,
225 start: DateTime<Utc>,
226 end: DateTime<Utc>,
227 ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
228 {
229 let now = Utc::now();
230 let held_since = interest_held_since(start, now);
231 if let Some(held_since) = held_since {
232 debug!(
233 requested = %start,
234 %held_since,
235 "BinanceMargin fetch_margin_interest: Binance serves 90 days of interest \
236 history — reading from the earliest time it serves"
237 );
238 }
239 let start = held_since.unwrap_or(start);
240 let end = end.min(now);
241 if start > end {
242 return Ok(CashFlowsRead::complete(Vec::new()).with_held_since(held_since));
243 }
244 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
245
246 let rows = self
247 .read_each_account(|pair| self.interest_rows(pair, start_ms, end_ms))
248 .await?;
249
250 let mut seen = FnvHashSet::default();
251 let charges: Vec<_> = rows
252 .into_iter()
253 .flat_map(|(pair, rows)| {
254 rows.into_iter().filter_map(move |row| {
255 let on_borrow = row.r#type.as_deref() == Some(ON_BORROW);
256 let flow = convert_interest(row, pair.as_ref())?;
257 Some((flow, on_borrow.then(|| pair.clone())))
258 })
259 })
260 .filter(|(flow, _)| (start..=end).contains(&flow.time_exchange))
261 .filter(|(flow, _)| seen.insert(ChargeKey::of(flow)))
265 .collect();
266 let fits: Vec<_> = {
269 let loans = self.loans.lock();
270 charges
271 .iter()
272 .map(|(flow, on_borrow)| {
273 on_borrow
274 .as_ref()
275 .and_then(|pair| LoanFit::of(flow, &loans, pair.as_ref()))
276 })
277 .collect()
278 };
279 let mut flows: Vec<_> = charges
280 .into_iter()
281 .zip(fits)
282 .map(|((flow, on_borrow), fit)| match fit {
283 Some(fit) => fit.link(flow, on_borrow.flatten().as_ref()),
284 None => flow,
285 })
286 .collect();
287 flows.sort_by_key(|flow| flow.time_exchange);
288 Ok(CashFlowsRead::complete(flows).with_held_since(held_since))
289 }
290
291 pub async fn fetch_forced_liquidations(
322 &self,
323 start: DateTime<Utc>,
324 end: DateTime<Utc>,
325 ) -> Result<Vec<MarginForcedLiquidation>, UnindexedClientError> {
326 let end = end.min(Utc::now());
327 if start > end {
328 return Ok(Vec::new());
329 }
330 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
331
332 let is_isolated = self.config.is_isolated;
333 let rows = self
334 .read_each_account(|pair| {
335 read_all_pages(move |current| {
336 self.forced_liquidation_page(pair.clone(), start_ms, end_ms, current)
337 })
338 })
339 .await?;
340
341 let mut seen = FnvHashSet::default();
342 let mut liquidations: Vec<_> = rows
343 .into_iter()
344 .flat_map(|(_, rows)| rows)
345 .filter_map(|row| convert_forced_liquidation(row, is_isolated))
346 .filter(|liquidation| (start..=end).contains(&liquidation.time_exchange))
347 .filter(|liquidation| {
350 seen.insert((liquidation.instrument.clone(), liquidation.order_id.clone()))
351 })
352 .collect();
353 liquidations.sort_by_key(|liquidation| liquidation.time_exchange);
354 Ok(liquidations)
355 }
356
357 async fn read_each_account<'a, Row, Fut>(
360 &'a self,
361 read: impl Fn(Option<InstrumentNameExchange>) -> Fut,
362 ) -> Result<Vec<(Option<InstrumentNameExchange>, Vec<Row>)>, UnindexedClientError>
363 where
364 Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>> + 'a,
365 {
366 let accounts: Vec<Option<InstrumentNameExchange>> = if self.config.is_isolated {
367 self.config
368 .isolated_symbols
369 .iter()
370 .cloned()
371 .map(Some)
372 .collect()
373 } else {
374 vec![None]
375 };
376 futures::stream::iter(accounts.into_iter().map(|pair| {
377 let rows = read(pair.clone());
378 async move { Ok::<_, UnindexedClientError>((pair, rows.await?)) }
379 }))
380 .buffer_unordered(CONCURRENT_PAIRS)
381 .try_collect()
382 .await
383 }
384
385 async fn interest_rows(
387 &self,
388 pair: Option<InstrumentNameExchange>,
389 start_ms: i64,
390 end_ms: i64,
391 ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
392 let mut rows = Vec::new();
393 let mut window_start = start_ms;
394 loop {
395 let window_end = window_start.saturating_add(INTEREST_WINDOW_MS).min(end_ms);
396 let pair = &pair;
397 rows.extend(
398 read_all_pages(|current| {
399 self.interest_page(pair.clone(), window_start, window_end, current)
400 })
401 .await?,
402 );
403 if window_end >= end_ms {
404 break;
405 }
406 window_start = window_end;
409 }
410 debug!(
411 pair = ?pair,
412 rows = rows.len(),
413 "BinanceMargin fetch_margin_interest: account read"
414 );
415 Ok(rows)
416 }
417
418 async fn interest_page(
420 &self,
421 pair: Option<InstrumentNameExchange>,
422 start_ms: i64,
423 end_ms: i64,
424 current: i64,
425 ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
426 let symbol = pair.as_ref().map(|pair| pair.name().to_string());
427 let response =
428 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
429 let symbol = symbol.clone();
430 Box::pin(async move {
431 let params = GetInterestHistoryParams::builder()
432 .isolated_symbol(symbol)
433 .start_time(start_ms)
434 .end_time(end_ms)
435 .current(current)
436 .size(PAGE_SIZE)
437 .build()?;
438 rest.get_interest_history(params).await
439 })
440 })
441 .await
442 .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
443 let page = response.data().await.map_err(response_decode_error)?;
444 Ok(page.rows.unwrap_or_default())
445 }
446
447 async fn forced_liquidation_page(
449 &self,
450 pair: Option<InstrumentNameExchange>,
451 start_ms: i64,
452 end_ms: i64,
453 current: i64,
454 ) -> Result<Vec<GetForceLiquidationRecordResponseRowsInner>, UnindexedClientError> {
455 let symbol = pair.as_ref().map(|pair| pair.name().to_string());
456 let response =
457 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
458 let symbol = symbol.clone();
459 Box::pin(async move {
460 let params = GetForceLiquidationRecordParams::builder()
461 .isolated_symbol(symbol)
462 .start_time(start_ms)
463 .end_time(end_ms)
464 .current(current)
465 .size(PAGE_SIZE)
466 .build()?;
467 rest.get_force_liquidation_record(params).await
468 })
469 })
470 .await
471 .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
472 let page = response.data().await.map_err(response_decode_error)?;
473 Ok(page.rows.unwrap_or_default())
474 }
475}
476
477async fn read_all_pages<Row, Fut>(
480 mut page: impl FnMut(i64) -> Fut,
481) -> Result<Vec<Row>, UnindexedClientError>
482where
483 Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>>,
484{
485 let mut rows = Vec::new();
486 for current in 1..=MAX_PAGES {
487 let batch = page(current).await?;
488 let full = i64::try_from(batch.len()).is_ok_and(|len| len >= PAGE_SIZE);
489 rows.extend(batch);
490 if !full {
491 return Ok(rows);
492 }
493 }
494 Err(UnindexedClientError::Internal(format!(
495 "Binance margin history query still full after {MAX_PAGES} pages of {PAGE_SIZE}: \
496 narrow the span"
497 )))
498}
499
500#[derive(PartialEq, Eq, Hash)]
503enum ChargeKey {
504 Id(
505 AssetNameExchange,
506 Option<InstrumentNameExchange>,
507 CashFlowId,
508 ),
509 Flow(CashFlow<AssetNameExchange, InstrumentNameExchange>),
510}
511
512impl ChargeKey {
513 fn of(flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>) -> Self {
514 match &flow.id {
515 Some(id) => Self::Id(flow.asset.clone(), flow.instrument.clone(), id.clone()),
516 None => Self::Flow(flow.clone()),
517 }
518 }
519}
520
521fn optional_decimal(record: &str, field: &str, value: Option<&str>) -> Option<Decimal> {
524 let value = value.filter(|value| !value.is_empty())?;
525 Decimal::from_str(value)
526 .inspect_err(|error| {
527 warn!(
528 record,
529 field,
530 value,
531 %error,
532 "BinanceMargin record field does not parse — left out"
533 );
534 })
535 .ok()
536}
537
538fn interest_timing(r#type: &str) -> InterestTiming {
541 match r#type {
542 "PERIODIC" | "PERIODIC_CONVERTED" | "PORTFOLIO" => InterestTiming::Periodic,
543 "ON_BORROW" | "ON_BORROW_CONVERTED" => InterestTiming::OnBorrow,
544 other => InterestTiming::Other(SmolStr::new(other)),
545 }
546}
547
548const INTEREST_HOUR: TimeDelta = TimeDelta::hours(1);
550
551fn interest_period(
556 r#type: Option<&str>,
557 time: DateTime<Utc>,
558 tx_id: Option<i64>,
559) -> Option<CashFlowPeriod> {
560 let hourly = matches!(
561 r#type,
562 Some("PERIODIC" | "PERIODIC_CONVERTED" | "ON_BORROW" | "ON_BORROW_CONVERTED")
563 );
564 if !hourly {
565 return None;
566 }
567 if time
568 .timestamp_millis()
569 .rem_euclid(INTEREST_HOUR.num_milliseconds())
570 != 0
571 {
572 warn!(
573 ?tx_id,
574 r#type,
575 %time,
576 "BinanceMargin hourly interest record not stamped on the hour — no period given"
577 );
578 return None;
579 }
580 CashFlowPeriod::new(time, time + INTEREST_HOUR)
581}
582
583const ON_BORROW: &str = "ON_BORROW";
586
587struct LoanFit {
590 principal: Decimal,
591 period: CashFlowPeriod,
592 fit: Fit,
593}
594
595enum Fit {
596 One(OrderLoan),
597 Several(usize),
598 Unheld,
599}
600
601impl LoanFit {
602 fn of(
605 flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>,
606 loans: &Loans,
607 pair: Option<&InstrumentNameExchange>,
608 ) -> Option<Self> {
609 let (
610 CashFlowKind::MarginInterest {
611 principal: Some(principal),
612 ..
613 },
614 Some(period),
615 ) = (&flow.kind, flow.period)
616 else {
617 return None;
618 };
619 let fit = match loans.link(&flow.asset, *principal, period, pair) {
620 LoanLink::One(loan) => Fit::One(loan.clone()),
621 LoanLink::Ambiguous(fits) => Fit::Several(fits),
622 LoanLink::None => Fit::Unheld,
623 };
624 Some(Self {
625 principal: *principal,
626 period,
627 fit,
628 })
629 }
630
631 fn link(
636 self,
637 mut flow: CashFlow<AssetNameExchange, InstrumentNameExchange>,
638 pair: Option<&InstrumentNameExchange>,
639 ) -> CashFlow<AssetNameExchange, InstrumentNameExchange> {
640 let Self {
641 principal,
642 period,
643 fit,
644 } = self;
645 match fit {
646 Fit::One(loan) => {
647 flow.instrument = Some(loan.instrument);
648 flow.loan = Some(loan.loan);
649 }
650 Fit::Several(fits) => warn!(
651 id = ?flow.id,
652 asset = %flow.asset,
653 %principal,
654 period_start = %period.start(),
655 ?pair,
656 fits,
657 "BinanceMargin ON_BORROW charge fits several held loans — linked to none"
658 ),
659 Fit::Unheld => debug!(
660 id = ?flow.id,
661 asset = %flow.asset,
662 %principal,
663 period_start = %period.start(),
664 ?pair,
665 "BinanceMargin ON_BORROW charge fits no held loan — linked to none"
666 ),
667 }
668 flow
669 }
670}
671
672fn convert_interest(
675 row: GetInterestHistoryResponseRowsInner,
676 pair: Option<&InstrumentNameExchange>,
677) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
678 let tx_id = row.tx_id;
679 let Some(asset) = row.asset.as_deref() else {
680 warn!(
681 ?tx_id,
682 ?pair,
683 "BinanceMargin interest record missing asset — skipped"
684 );
685 return None;
686 };
687 let Some(interest) = row
688 .interest
689 .as_deref()
690 .and_then(|s| Decimal::from_str(s).ok())
691 else {
692 warn!(
693 ?tx_id,
694 ?pair,
695 "BinanceMargin interest record missing/unparseable interest — skipped"
696 );
697 return None;
698 };
699 let Some(time_exchange) = row
700 .interest_accured_time
701 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
702 else {
703 warn!(
704 ?tx_id,
705 ?pair,
706 "BinanceMargin interest record missing/unparseable time — skipped"
707 );
708 return None;
709 };
710 let instrument = row
711 .isolated_symbol
712 .as_deref()
713 .map(InstrumentNameExchange::new)
714 .or_else(|| pair.cloned());
715 let period = interest_period(row.r#type.as_deref(), time_exchange, tx_id);
716 Some(
717 CashFlow::new(
718 CashFlowKind::MarginInterest {
719 rate: optional_decimal("interest", "interestRate", row.interest_rate.as_deref()),
720 principal: optional_decimal("interest", "principal", row.principal.as_deref()),
721 timing: row.r#type.as_deref().map(interest_timing),
722 },
723 AssetNameExchange::new(asset),
724 -interest,
725 instrument,
726 time_exchange,
727 tx_id.map(|id| CashFlowId(format_smolstr!("{id}"))),
728 )
729 .with_period(period),
730 )
731}
732
733fn convert_forced_liquidation(
737 row: GetForceLiquidationRecordResponseRowsInner,
738 is_isolated: bool,
739) -> Option<MarginForcedLiquidation> {
740 let Some(order_id) = row.order_id else {
741 warn!(symbol = ?row.symbol, "BinanceMargin forced liquidation record missing orderId — skipped");
742 return None;
743 };
744 let Some(symbol) = row.symbol.as_deref() else {
745 warn!(
746 order_id,
747 "BinanceMargin forced liquidation record missing symbol — skipped"
748 );
749 return None;
750 };
751 if !is_isolated && row.is_isolated == Some(true) {
754 warn!(
755 order_id,
756 symbol,
757 "BinanceMargin cross forced liquidation read returned an isolated record — left out"
758 );
759 return None;
760 }
761 let Some(side) = row.side.as_deref().and_then(parse_side) else {
762 warn!(
763 order_id,
764 symbol, "BinanceMargin forced liquidation record missing/unknown side — skipped"
765 );
766 return None;
767 };
768 let Some(filled_quantity) = row
769 .executed_qty
770 .as_deref()
771 .and_then(|s| Decimal::from_str(s).ok())
772 else {
773 warn!(
774 order_id,
775 symbol,
776 "BinanceMargin forced liquidation record missing/unparseable executedQty — skipped"
777 );
778 return None;
779 };
780 let Some(time_exchange) = row
781 .updated_time
782 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
783 else {
784 warn!(
785 order_id,
786 symbol,
787 "BinanceMargin forced liquidation record missing/unparseable updatedTime — skipped"
788 );
789 return None;
790 };
791 Some(MarginForcedLiquidation {
792 order_id: OrderId(format_smolstr!("{order_id}")),
793 instrument: InstrumentNameExchange::new(symbol),
794 side,
795 price: optional_decimal("forced liquidation", "price", row.price.as_deref()),
796 quantity: optional_decimal("forced liquidation", "qty", row.qty.as_deref()),
797 avg_price: (!filled_quantity.is_zero())
798 .then(|| optional_decimal("forced liquidation", "avgPrice", row.avg_price.as_deref()))
799 .flatten(),
800 filled_quantity,
801 time_exchange,
802 })
803}
804
805#[cfg(test)]
806#[allow(clippy::unwrap_used)] mod tests {
808 use super::{super::tests::margin_client_at, *};
809 use crate::error::ApiError;
810 use chrono::DurationRound;
811 use rust_decimal_macros::dec;
812 use serde_json::{Value, json};
813 use std::collections::HashMap;
814 use wiremock::{
815 Mock, MockServer, ResponseTemplate,
816 matchers::{method, path, query_param},
817 };
818
819 const INTEREST_PATH: &str = "/sapi/v1/margin/interestHistory";
820 const LIQUIDATION_PATH: &str = "/sapi/v1/margin/forceLiquidationRec";
821
822 fn interest_row(tx_id: i64, time: DateTime<Utc>, isolated: Option<&str>) -> Value {
823 let mut row = json!({
824 "txId": tx_id, "interestAccuredTime": time.timestamp_millis(), "asset": "USDT",
825 "rawAsset": "USDT", "principal": "1000", "interest": "0.0125",
826 "interestRate": "0.0000125", "type": "PERIODIC",
827 });
828 if let Some(symbol) = isolated {
829 row["isolatedSymbol"] = json!(symbol);
830 }
831 row
832 }
833
834 fn liquidation_row(
835 order_id: i64,
836 side: &str,
837 filled: &str,
838 time: DateTime<Utc>,
839 is_isolated: bool,
840 ) -> Value {
841 json!({
842 "avgPrice": "60000", "executedQty": filled, "orderId": order_id, "price": "59000",
843 "qty": "0.5", "side": side, "symbol": "BTCUSDT", "timeInForce": "GTC",
844 "isIsolated": is_isolated, "updatedTime": time.timestamp_millis(),
845 })
846 }
847
848 fn page(rows: Vec<Value>) -> ResponseTemplate {
849 let total = rows.len();
850 ResponseTemplate::new(200).set_body_json(json!({ "rows": rows, "total": total }))
851 }
852
853 async fn mount(server: &MockServer, at: &str, response: ResponseTemplate) {
854 Mock::given(method("GET"))
855 .and(path(at))
856 .respond_with(response)
857 .mount(server)
858 .await;
859 }
860
861 async fn mount_page(server: &MockServer, at: &str, current: &str, response: ResponseTemplate) {
863 Mock::given(method("GET"))
864 .and(path(at))
865 .and(query_param("current", current))
866 .respond_with(response)
867 .mount(server)
868 .await;
869 }
870
871 async fn queries(server: &MockServer) -> Vec<HashMap<String, String>> {
873 server
874 .received_requests()
875 .await
876 .unwrap()
877 .iter()
878 .map(|request| request.url.query_pairs().into_owned().collect())
879 .collect()
880 }
881
882 fn millis(query: &HashMap<String, String>, key: &str) -> i64 {
883 query[key].parse().unwrap()
884 }
885
886 #[tokio::test]
887 async fn interest_is_read_window_by_window_as_negative_flows_oldest_first_once_each() {
888 let server = MockServer::start().await;
889 let now = Utc::now();
890 let start = now - TimeDelta::days(40);
891 let boundary = start + TimeDelta::milliseconds(INTEREST_WINDOW_MS);
892 let mut no_interest = interest_row(9, now - TimeDelta::days(2), None);
893 no_interest.as_object_mut().unwrap().remove("interest");
894 let mut bad_rate = interest_row(3, now - TimeDelta::days(3), None);
895 bad_rate["interestRate"] = json!("n/a");
896 mount(
899 &server,
900 INTEREST_PATH,
901 page(vec![
902 interest_row(2, now - TimeDelta::days(1), None),
903 no_interest,
904 bad_rate,
905 interest_row(4, boundary, None),
906 interest_row(1, now - TimeDelta::days(35), None),
907 interest_row(0, now - TimeDelta::days(50), None),
908 ]),
909 )
910 .await;
911 let client = margin_client_at(&server, false);
912
913 let read = client
914 .fetch_margin_interest(start, now + TimeDelta::days(5))
915 .await
916 .unwrap();
917 assert_eq!((read.resume, read.held_since), (None, None));
918 let flows = read.flows;
919
920 let ids: Vec<_> = flows.iter().map(|flow| flow.id.clone().unwrap()).collect();
921 assert_eq!(ids, ["1", "4", "3", "2"].map(CashFlowId::new));
922 let flow = &flows[0];
923 assert_eq!(flow.amount, dec!(-0.0125), "a charge is paid");
924 assert_eq!(flow.asset, AssetNameExchange::new("USDT"));
925 assert_eq!(flow.instrument, None, "a cross charge names no pair");
926 assert_eq!(
927 flow.kind,
928 CashFlowKind::MarginInterest {
929 rate: Some(dec!(0.0000125)),
930 principal: Some(dec!(1000)),
931 timing: Some(InterestTiming::Periodic),
932 }
933 );
934 assert_eq!(
935 flows[2].kind,
936 CashFlowKind::MarginInterest {
937 rate: None,
938 principal: Some(dec!(1000)),
939 timing: Some(InterestTiming::Periodic),
940 },
941 "a rate that does not parse is left out, not the charge"
942 );
943
944 let queries = queries(&server).await;
945 let [first, second] = queries.as_slice() else {
946 panic!("two windows: {queries:?}");
947 };
948 assert_eq!(millis(first, "startTime"), start.timestamp_millis());
949 assert_eq!(millis(first, "endTime"), boundary.timestamp_millis());
950 assert_eq!(millis(second, "startTime"), boundary.timestamp_millis());
951 assert!(
952 millis(second, "endTime") <= Utc::now().timestamp_millis(),
953 "read up to the local clock, not `end`"
954 );
955 for query in [first, second] {
956 assert_eq!(query["current"], "1");
957 assert_eq!(query["size"], "100");
958 assert!(!query.contains_key("isolatedSymbol"), "{query:?}");
959 }
960 }
961
962 #[test]
963 fn interest_timing_is_read_from_the_record_type() {
964 for (r#type, expected) in [
965 ("PERIODIC", InterestTiming::Periodic),
966 ("PERIODIC_CONVERTED", InterestTiming::Periodic),
967 ("PORTFOLIO", InterestTiming::Periodic),
968 ("ON_BORROW", InterestTiming::OnBorrow),
969 ("ON_BORROW_CONVERTED", InterestTiming::OnBorrow),
970 (
971 "ON_LIQUIDATION",
972 InterestTiming::Other("ON_LIQUIDATION".into()),
973 ),
974 ] {
975 assert_eq!(interest_timing(r#type), expected, "{type}");
976 }
977 }
978
979 #[test]
980 fn an_hourly_charge_has_the_hour_from_its_stamp_as_its_period() {
981 let hour = Utc.with_ymd_and_hms(2026, 10, 22, 9, 0, 0).unwrap();
982 for r#type in [
983 "PERIODIC",
984 "PERIODIC_CONVERTED",
985 "ON_BORROW",
986 "ON_BORROW_CONVERTED",
987 ] {
988 assert_eq!(
989 interest_period(Some(r#type), hour, Some(1)),
990 CashFlowPeriod::new(hour, hour + TimeDelta::hours(1)),
991 "{type}"
992 );
993 }
994 }
995
996 #[test]
997 fn a_charge_of_another_type_or_off_the_hour_has_no_period() {
998 let hour = Utc.with_ymd_and_hms(2026, 10, 22, 9, 0, 0).unwrap();
999 for r#type in [Some("PORTFOLIO"), Some("ON_LIQUIDATION"), None] {
1000 assert_eq!(interest_period(r#type, hour, Some(1)), None, "{type:?}");
1001 }
1002 for off in [TimeDelta::milliseconds(1), TimeDelta::minutes(31)] {
1003 assert_eq!(
1004 interest_period(Some("ON_BORROW"), hour + off, Some(1)),
1005 None,
1006 "{off}"
1007 );
1008 }
1009 }
1010
1011 #[tokio::test]
1012 async fn a_charge_on_borrow_read_from_binance_has_its_hour_as_its_period() {
1013 let server = MockServer::start().await;
1014 let now = Utc::now();
1015 let hour = now.duration_trunc(TimeDelta::hours(1)).unwrap() - TimeDelta::hours(2);
1017 let mut row = interest_row(1, hour, None);
1018 row["type"] = json!("ON_BORROW");
1019 mount(&server, INTEREST_PATH, page(vec![row])).await;
1020 let client = margin_client_at(&server, false);
1021
1022 let flows = client
1023 .fetch_margin_interest(now - TimeDelta::days(1), now)
1024 .await
1025 .unwrap()
1026 .flows;
1027
1028 assert_eq!(flows.len(), 1);
1029 assert_eq!(flows[0].time_exchange, hour);
1030 assert_eq!(
1031 flows[0].period,
1032 CashFlowPeriod::new(hour, hour + TimeDelta::hours(1))
1033 );
1034 }
1035
1036 #[tokio::test]
1037 async fn a_charge_on_borrow_has_its_timing() {
1038 let server = MockServer::start().await;
1039 let now = Utc::now();
1040 let mut row = interest_row(1, now - TimeDelta::hours(1), None);
1041 row["type"] = json!("ON_BORROW");
1042 mount(&server, INTEREST_PATH, page(vec![row])).await;
1043 let client = margin_client_at(&server, false);
1044
1045 let flows = client
1046 .fetch_margin_interest(now - TimeDelta::days(1), now)
1047 .await
1048 .unwrap()
1049 .flows;
1050
1051 let [flow] = flows.as_slice() else {
1052 panic!("one flow: {flows:?}");
1053 };
1054 let CashFlowKind::MarginInterest { timing, .. } = &flow.kind else {
1055 panic!("margin interest: {flow:?}");
1056 };
1057 assert_eq!(*timing, Some(InterestTiming::OnBorrow));
1058 }
1059
1060 #[tokio::test]
1063 async fn a_charge_converted_into_bnb_is_in_bnb() {
1064 let server = MockServer::start().await;
1065 let now = Utc::now();
1066 let mut row = interest_row(1, now - TimeDelta::hours(1), None);
1067 row["asset"] = json!("BNB");
1068 row["principal"] = json!("0");
1069 row["interest"] = json!("0.00002");
1070 row["type"] = json!("PERIODIC_CONVERTED");
1071 mount(&server, INTEREST_PATH, page(vec![row])).await;
1072 let client = margin_client_at(&server, false);
1073
1074 let flows = client
1075 .fetch_margin_interest(now - TimeDelta::days(1), now)
1076 .await
1077 .unwrap()
1078 .flows;
1079
1080 let [flow] = flows.as_slice() else {
1081 panic!("one flow: {flows:?}");
1082 };
1083 assert_eq!(flow.asset, AssetNameExchange::new("BNB"));
1084 assert_eq!(flow.amount, dec!(-0.00002));
1085 let CashFlowKind::MarginInterest {
1086 principal, timing, ..
1087 } = &flow.kind
1088 else {
1089 panic!("margin interest: {flow:?}");
1090 };
1091 assert_eq!(*principal, Some(Decimal::ZERO));
1092 assert_eq!(*timing, Some(InterestTiming::Periodic));
1093 }
1094
1095 fn held_loan(
1097 order: &str,
1098 pair: &str,
1099 asset: &str,
1100 amount: Decimal,
1101 time: DateTime<Utc>,
1102 ) -> crate::margin::OrderLoan {
1103 crate::margin::OrderLoan::new(
1104 InstrumentNameExchange::new(pair),
1105 crate::margin::MarginBorrow::new(AssetNameExchange::new(asset), amount),
1106 crate::cash_flow::CashFlowLoan::new(
1107 time,
1108 OrderId::new(order),
1109 crate::order::id::ClientOrderId::new(format!("cid-{order}")),
1110 ),
1111 )
1112 }
1113
1114 fn charge_row(
1116 tx_id: i64,
1117 hour: DateTime<Utc>,
1118 kind: &str,
1119 asset: &str,
1120 principal: &str,
1121 ) -> Value {
1122 let mut row = interest_row(tx_id, hour, None);
1123 row["type"] = json!(kind);
1124 row["asset"] = json!(asset);
1125 row["principal"] = json!(principal);
1126 row
1127 }
1128
1129 fn past_hour() -> (DateTime<Utc>, DateTime<Utc>) {
1131 let now = Utc::now();
1132 (
1134 now.duration_trunc(TimeDelta::hours(1)).unwrap() - TimeDelta::hours(2),
1135 now,
1136 )
1137 }
1138
1139 #[tokio::test]
1144 async fn a_cross_charge_on_borrow_is_linked_to_the_held_loan_it_is_on() {
1145 let server = MockServer::start().await;
1146 let (hour, now) = past_hour();
1147 mount(
1148 &server,
1149 INTEREST_PATH,
1150 page(vec![
1151 charge_row(1, hour, "ON_BORROW", "USDT", "6.23292076"),
1152 charge_row(2, hour, "PERIODIC", "USDT", "6.23292076"),
1153 charge_row(3, hour, "ON_BORROW_CONVERTED", "USDT", "6.23292076"),
1154 charge_row(4, hour, "ON_BORROW", "USDT", "9"),
1155 ]),
1156 )
1157 .await;
1158 let client = margin_client_at(&server, false);
1159 let loan = held_loan(
1160 "11",
1161 "BTCUSDT",
1162 "USDT",
1163 dec!(6.23292076),
1164 hour + TimeDelta::minutes(5),
1165 );
1166 client.recall_loans([loan.clone()]);
1167
1168 for _ in 0..2 {
1169 let flows = client
1170 .fetch_margin_interest(now - TimeDelta::days(1), now)
1171 .await
1172 .unwrap()
1173 .flows;
1174
1175 let by_id = |id: &str| {
1176 flows
1177 .iter()
1178 .find(|flow| flow.id == Some(CashFlowId::new(id)))
1179 .unwrap()
1180 };
1181 let linked = by_id("1");
1182 assert_eq!(linked.loan, Some(loan.loan.clone()));
1183 assert_eq!(linked.instrument, Some(loan.instrument.clone()));
1184 for id in ["2", "3", "4"] {
1185 assert_eq!(by_id(id).loan, None, "{id}");
1186 assert_eq!(by_id(id).instrument, None, "{id}");
1187 }
1188 }
1189 }
1190
1191 #[tokio::test]
1194 async fn a_charge_on_borrow_fitting_several_loans_or_none_is_linked_to_none() {
1195 let server = MockServer::start().await;
1196 let (hour, now) = past_hour();
1197 mount(
1198 &server,
1199 INTEREST_PATH,
1200 page(vec![
1201 charge_row(1, hour, "ON_BORROW", "USDT", "6"),
1202 charge_row(2, hour, "ON_BORROW", "USDT", "7"),
1203 ]),
1204 )
1205 .await;
1206 let client = margin_client_at(&server, false);
1207 client.recall_loans([
1208 held_loan(
1209 "11",
1210 "BTCUSDT",
1211 "USDT",
1212 dec!(6),
1213 hour + TimeDelta::minutes(5),
1214 ),
1215 held_loan(
1216 "12",
1217 "ETHUSDT",
1218 "USDT",
1219 dec!(6),
1220 hour + TimeDelta::minutes(6),
1221 ),
1222 held_loan("13", "BTCUSDT", "USDT", dec!(7), hour + TimeDelta::hours(1)),
1223 ]);
1224
1225 let flows = client
1226 .fetch_margin_interest(now - TimeDelta::days(1), now)
1227 .await
1228 .unwrap()
1229 .flows;
1230
1231 assert_eq!(flows.len(), 2);
1232 for flow in flows {
1233 assert_eq!(flow.loan, None, "{flow:?}");
1234 assert_eq!(flow.instrument, None, "{flow:?}");
1235 }
1236 }
1237
1238 #[tokio::test]
1240 async fn an_isolated_charge_on_borrow_is_linked_to_a_loan_on_its_pair() {
1241 let server = MockServer::start().await;
1242 let (hour, now) = past_hour();
1243 let mut row = charge_row(1, hour, "ON_BORROW", "BTC", "0.00007");
1244 row["isolatedSymbol"] = json!("BTCUSDT");
1245 mount(&server, INTEREST_PATH, page(vec![row])).await;
1246 let client = margin_client_at(&server, true);
1247 let loan = held_loan("11", "BTCUSDT", "BTC", dec!(0.00007), hour);
1248 client.recall_loans([
1249 loan.clone(),
1250 held_loan("12", "ETHBTC", "BTC", dec!(0.00007), hour),
1251 ]);
1252
1253 let flows = client
1254 .fetch_margin_interest(now - TimeDelta::days(1), now)
1255 .await
1256 .unwrap()
1257 .flows;
1258
1259 let [flow] = flows.as_slice() else {
1260 panic!("one flow: {flows:?}");
1261 };
1262 assert_eq!(flow.loan, Some(loan.loan));
1263 assert_eq!(flow.instrument, Some(loan.instrument));
1264 }
1265
1266 #[tokio::test]
1267 async fn a_charge_without_a_type_has_no_timing() {
1268 let server = MockServer::start().await;
1269 let now = Utc::now();
1270 let mut row = interest_row(1, now - TimeDelta::hours(1), None);
1271 row.as_object_mut().unwrap().remove("type");
1272 mount(&server, INTEREST_PATH, page(vec![row])).await;
1273 let client = margin_client_at(&server, false);
1274
1275 let flows = client
1276 .fetch_margin_interest(now - TimeDelta::days(1), now)
1277 .await
1278 .unwrap()
1279 .flows;
1280
1281 let [flow] = flows.as_slice() else {
1282 panic!("one flow: {flows:?}");
1283 };
1284 let CashFlowKind::MarginInterest { timing, .. } = &flow.kind else {
1285 panic!("margin interest: {flow:?}");
1286 };
1287 assert_eq!(*timing, None);
1288 }
1289
1290 #[tokio::test]
1291 async fn a_charge_repeated_with_its_id_is_kept_once() {
1292 let server = MockServer::start().await;
1293 let now = Utc::now();
1294 let mut corrected = interest_row(1, now - TimeDelta::hours(1), None);
1295 corrected["interest"] = json!("0.02");
1296 mount(
1297 &server,
1298 INTEREST_PATH,
1299 page(vec![
1300 corrected,
1301 interest_row(1, now - TimeDelta::hours(1), None),
1302 ]),
1303 )
1304 .await;
1305 let client = margin_client_at(&server, false);
1306
1307 let flows = client
1308 .fetch_margin_interest(now - TimeDelta::days(1), now)
1309 .await
1310 .unwrap()
1311 .flows;
1312
1313 let [flow] = flows.as_slice() else {
1314 panic!("one charge: {flows:?}");
1315 };
1316 assert_eq!(flow.amount, dec!(-0.02), "the first copy read is kept");
1317 }
1318
1319 #[tokio::test]
1320 async fn a_span_of_one_instant_is_read_in_one_request() {
1321 let server = MockServer::start().await;
1322 let at =
1324 DateTime::from_timestamp_millis((Utc::now() - TimeDelta::hours(1)).timestamp_millis())
1325 .unwrap();
1326 mount(
1327 &server,
1328 INTEREST_PATH,
1329 page(vec![interest_row(1, at, None)]),
1330 )
1331 .await;
1332 let client = margin_client_at(&server, false);
1333
1334 let flows = client.fetch_margin_interest(at, at).await.unwrap().flows;
1335
1336 assert_eq!(flows.len(), 1);
1337 let queries = queries(&server).await;
1338 let [query] = queries.as_slice() else {
1339 panic!("one request: {queries:?}");
1340 };
1341 assert_eq!(millis(query, "startTime"), at.timestamp_millis());
1342 assert_eq!(millis(query, "endTime"), at.timestamp_millis());
1343 }
1344
1345 #[tokio::test]
1346 async fn a_full_page_reads_the_next() {
1347 let server = MockServer::start().await;
1348 let now = Utc::now();
1349 let time = now - TimeDelta::hours(1);
1350 let full = (0..100).map(|id| interest_row(id, time, None)).collect();
1351 mount_page(&server, INTEREST_PATH, "1", page(full)).await;
1352 mount_page(
1353 &server,
1354 INTEREST_PATH,
1355 "2",
1356 page(vec![interest_row(100, time, None)]),
1357 )
1358 .await;
1359 let client = margin_client_at(&server, false);
1360
1361 let flows = client
1362 .fetch_margin_interest(now - TimeDelta::days(1), now)
1363 .await
1364 .unwrap()
1365 .flows;
1366
1367 assert_eq!(flows.len(), 101);
1368 assert_eq!(queries(&server).await.len(), 2);
1369 }
1370
1371 #[tokio::test]
1372 async fn a_query_still_full_after_the_most_pages_fails() {
1373 let server = MockServer::start().await;
1374 let now = Utc::now();
1375 let time = now - TimeDelta::hours(1);
1376 let full = (0..100).map(|id| interest_row(id, time, None)).collect();
1378 mount(&server, INTEREST_PATH, page(full)).await;
1379 let client = margin_client_at(&server, false);
1380
1381 let result = client
1382 .fetch_margin_interest(now - TimeDelta::days(1), now)
1383 .await;
1384
1385 assert!(
1386 matches!(result, Err(UnindexedClientError::Internal(_))),
1387 "{result:?}"
1388 );
1389 assert_eq!(
1390 i64::try_from(queries(&server).await.len()).unwrap(),
1391 MAX_PAGES
1392 );
1393 }
1394
1395 #[tokio::test]
1396 async fn isolated_interest_is_read_per_pair_and_attributed_to_it() {
1397 let server = MockServer::start().await;
1398 let now = Utc::now();
1399 mount(
1400 &server,
1401 INTEREST_PATH,
1402 page(vec![
1403 interest_row(2, now - TimeDelta::hours(1), Some("BTCUSDT")),
1404 interest_row(1, now - TimeDelta::hours(2), None),
1406 ]),
1407 )
1408 .await;
1409 let client = margin_client_at(&server, true);
1410
1411 let flows = client
1412 .fetch_margin_interest(now - TimeDelta::days(1), now)
1413 .await
1414 .unwrap()
1415 .flows;
1416
1417 assert_eq!(flows.len(), 2);
1418 for flow in flows {
1419 assert_eq!(
1420 flow.instrument,
1421 Some(InstrumentNameExchange::new("BTCUSDT"))
1422 );
1423 }
1424 let queries = queries(&server).await;
1425 assert_eq!(queries.len(), 1);
1426 assert_eq!(queries[0]["isolatedSymbol"], "BTCUSDT");
1427 }
1428
1429 #[tokio::test]
1430 async fn interest_older_than_binance_serves_is_read_from_its_horizon() {
1431 let server = MockServer::start().await;
1432 mount(&server, INTEREST_PATH, page(Vec::new())).await;
1433 let client = margin_client_at(&server, false);
1434 let before = Utc::now();
1435
1436 let read = client
1437 .fetch_margin_interest(before - TimeDelta::days(200), before)
1438 .await
1439 .unwrap();
1440 let after = Utc::now();
1441
1442 assert!(read.flows.is_empty());
1443 let queries = queries(&server).await;
1444 let first = millis(&queries[0], "startTime");
1445 let horizon = |now: DateTime<Utc>| {
1446 (now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN).timestamp_millis()
1447 };
1448 assert!(
1449 (horizon(before)..=horizon(after)).contains(&first),
1450 "{first}"
1451 );
1452 assert_eq!(
1453 read.held_since
1454 .map(|held_since| held_since.timestamp_millis()),
1455 Some(first),
1456 "held_since is where the read started"
1457 );
1458 assert_eq!(queries.len(), 4, "90 days in windows of 29");
1459 }
1460
1461 #[test]
1462 fn interest_is_held_since_a_minute_inside_ninety_days_only_for_an_older_start() {
1463 let now = Utc.with_ymd_and_hms(2026, 10, 25, 12, 0, 0).unwrap();
1464 let held_since = now - TimeDelta::days(90) + TimeDelta::minutes(1);
1465 let before = held_since - TimeDelta::milliseconds(1);
1466
1467 assert_eq!(interest_held_since(before, now), Some(held_since));
1468 assert_eq!(
1469 interest_held_since(now - TimeDelta::days(90), now),
1470 Some(held_since),
1471 "a request for the last 90 days misses its first minute"
1472 );
1473 assert_eq!(interest_held_since(held_since, now), None);
1474 assert_eq!(interest_held_since(now, now), None);
1475 }
1476
1477 #[tokio::test]
1478 async fn interest_entirely_older_than_binance_serves_reads_nothing_and_says_so() {
1479 let server = MockServer::start().await;
1480 let client = margin_client_at(&server, false);
1481 let end = Utc::now() - TimeDelta::days(100);
1482
1483 let read = client
1484 .fetch_margin_interest(end - TimeDelta::days(1), end)
1485 .await
1486 .unwrap();
1487
1488 assert!(read.flows.is_empty());
1489 assert!(
1490 read.held_since.is_some_and(|held_since| held_since > end),
1491 "{:?}",
1492 read.held_since
1493 );
1494 assert!(queries(&server).await.is_empty());
1495 }
1496
1497 #[tokio::test]
1498 async fn an_empty_span_reads_nothing() {
1499 let server = MockServer::start().await;
1500 let client = margin_client_at(&server, false);
1501 let now = Utc::now();
1502
1503 let interest = client
1504 .fetch_margin_interest(now, now - TimeDelta::hours(1))
1505 .await
1506 .unwrap();
1507 let liquidations = client
1508 .fetch_forced_liquidations(now + TimeDelta::hours(1), now + TimeDelta::hours(2))
1509 .await
1510 .unwrap();
1511
1512 assert!(interest.flows.is_empty() && liquidations.is_empty());
1513 assert_eq!((interest.resume, interest.held_since), (None, None));
1514 assert!(queries(&server).await.is_empty());
1515 }
1516
1517 #[tokio::test]
1518 async fn a_refused_read_fails_the_whole_read() {
1519 let server = MockServer::start().await;
1520 let refused =
1521 ResponseTemplate::new(400).set_body_json(json!({"code": -1100, "msg": "refused"}));
1522 mount(&server, INTEREST_PATH, refused.clone()).await;
1523 mount(&server, LIQUIDATION_PATH, refused).await;
1524 let client = margin_client_at(&server, false);
1525 let now = Utc::now();
1526
1527 let interest = client
1528 .fetch_margin_interest(now - TimeDelta::days(1), now)
1529 .await;
1530 let liquidations = client
1531 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1532 .await;
1533
1534 assert!(
1535 matches!(
1536 interest,
1537 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
1538 ),
1539 "{interest:?}"
1540 );
1541 assert!(
1542 matches!(
1543 liquidations,
1544 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
1545 ),
1546 "{liquidations:?}"
1547 );
1548 }
1549
1550 #[tokio::test]
1551 async fn forced_liquidations_of_the_clients_account_are_read_oldest_first_once_each() {
1552 for is_isolated in [false, true] {
1553 let server = MockServer::start().await;
1554 let now = Utc::now();
1555 let row = |order_id, side, filled, time| {
1556 liquidation_row(order_id, side, filled, time, is_isolated)
1557 };
1558 let mut no_order_id = row(9, "SELL", "0.5", now);
1559 no_order_id.as_object_mut().unwrap().remove("orderId");
1560 mount(
1561 &server,
1562 LIQUIDATION_PATH,
1563 page(vec![
1564 row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
1565 row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
1566 no_order_id,
1567 liquidation_row(8, "SELL", "0.5", now, true),
1569 row(1, "BUY", "0", now - TimeDelta::hours(2)),
1570 row(0, "BUY", "0.5", now - TimeDelta::days(3)),
1571 ]),
1572 )
1573 .await;
1574 let client = margin_client_at(&server, is_isolated);
1575
1576 let liquidations = client
1577 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1578 .await
1579 .unwrap();
1580
1581 let (unfilled, filled) = match (is_isolated, liquidations.as_slice()) {
1582 (false, [unfilled, filled]) => (unfilled, filled),
1583 (true, [unfilled, filled, marked]) => {
1584 assert_eq!(marked.order_id, OrderId::new("8"));
1585 (unfilled, filled)
1586 }
1587 _ => panic!("is_isolated = {is_isolated}: {liquidations:?}"),
1588 };
1589 assert_eq!(unfilled.order_id, OrderId::new("1"));
1590 assert_eq!(unfilled.side, Side::Buy);
1591 assert_eq!(unfilled.avg_price, None, "nothing filled");
1592 assert_eq!(
1593 *filled,
1594 MarginForcedLiquidation {
1595 order_id: OrderId::new("2"),
1596 instrument: InstrumentNameExchange::new("BTCUSDT"),
1597 side: Side::Sell,
1598 price: Some(dec!(59000)),
1599 quantity: Some(dec!(0.5)),
1600 filled_quantity: dec!(0.5),
1601 avg_price: Some(dec!(60000)),
1602 time_exchange: Utc
1603 .timestamp_millis_opt((now - TimeDelta::hours(1)).timestamp_millis())
1604 .unwrap(),
1605 }
1606 );
1607
1608 let queries = queries(&server).await;
1609 let [query] = queries.as_slice() else {
1610 panic!("one page: {queries:?}");
1611 };
1612 assert_eq!(query["size"], "100");
1613 assert_eq!(
1614 query.get("isolatedSymbol").map(String::as_str),
1615 is_isolated.then_some("BTCUSDT"),
1616 );
1617 }
1618 }
1619
1620 #[tokio::test]
1621 async fn a_full_page_of_forced_liquidations_reads_the_next() {
1622 let server = MockServer::start().await;
1623 let now = Utc::now();
1624 let time = now - TimeDelta::hours(1);
1625 let full = (0..100)
1626 .map(|id| liquidation_row(id, "SELL", "0.5", time, false))
1627 .collect();
1628 mount_page(&server, LIQUIDATION_PATH, "1", page(full)).await;
1629 mount_page(
1630 &server,
1631 LIQUIDATION_PATH,
1632 "2",
1633 page(vec![liquidation_row(100, "SELL", "0.5", time, false)]),
1634 )
1635 .await;
1636 let client = margin_client_at(&server, false);
1637
1638 let liquidations = client
1639 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1640 .await
1641 .unwrap();
1642
1643 assert_eq!(liquidations.len(), 101);
1644 assert_eq!(queries(&server).await.len(), 2);
1645 }
1646}