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