1use super::BinanceMargin;
7use crate::{
8 cash_flow::{CashFlow, CashFlowId, CashFlowKind, CashFlowsRead, 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
39fn interest_held_since(start: DateTime<Utc>, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
43 let held_since = now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN;
44 (start < held_since).then_some(held_since)
45}
46
47const INTEREST_WINDOW_MS: i64 = 29 * 24 * 60 * 60 * 1000;
50
51const PAGE_SIZE: i64 = 100;
53
54const MAX_PAGES: i64 = 500;
58
59const CONCURRENT_PAIRS: usize = 8;
61
62#[non_exhaustive]
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
68pub struct MarginForcedLiquidation {
69 pub order_id: OrderId,
75 pub instrument: InstrumentNameExchange,
77 pub side: Side,
79 pub price: Option<Decimal>,
81 pub quantity: Option<Decimal>,
83 pub filled_quantity: Decimal,
85 pub avg_price: Option<Decimal>,
87 pub time_exchange: DateTime<Utc>,
89}
90
91impl BinanceMargin {
92 pub async fn fetch_margin_interest(
187 &self,
188 start: DateTime<Utc>,
189 end: DateTime<Utc>,
190 ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
191 {
192 let now = Utc::now();
193 let held_since = interest_held_since(start, now);
194 if let Some(held_since) = held_since {
195 debug!(
196 requested = %start,
197 %held_since,
198 "BinanceMargin fetch_margin_interest: Binance serves 90 days of interest \
199 history — reading from the earliest time it serves"
200 );
201 }
202 let start = held_since.unwrap_or(start);
203 let end = end.min(now);
204 if start > end {
205 return Ok(CashFlowsRead::complete(Vec::new()).with_held_since(held_since));
206 }
207 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
208
209 let rows = self
210 .read_each_account(|pair| self.interest_rows(pair, start_ms, end_ms))
211 .await?;
212
213 let mut seen = FnvHashSet::default();
214 let mut flows: Vec<_> = rows
215 .into_iter()
216 .flat_map(|(pair, rows)| {
217 rows.into_iter()
218 .filter_map(move |row| convert_interest(row, pair.as_ref()))
219 })
220 .filter(|flow| (start..=end).contains(&flow.time_exchange))
221 .filter(|flow| seen.insert(ChargeKey::of(flow)))
224 .collect();
225 flows.sort_by_key(|flow| flow.time_exchange);
226 Ok(CashFlowsRead::complete(flows).with_held_since(held_since))
227 }
228
229 pub async fn fetch_forced_liquidations(
260 &self,
261 start: DateTime<Utc>,
262 end: DateTime<Utc>,
263 ) -> Result<Vec<MarginForcedLiquidation>, UnindexedClientError> {
264 let end = end.min(Utc::now());
265 if start > end {
266 return Ok(Vec::new());
267 }
268 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
269
270 let is_isolated = self.config.is_isolated;
271 let rows = self
272 .read_each_account(|pair| {
273 read_all_pages(move |current| {
274 self.forced_liquidation_page(pair.clone(), start_ms, end_ms, current)
275 })
276 })
277 .await?;
278
279 let mut seen = FnvHashSet::default();
280 let mut liquidations: Vec<_> = rows
281 .into_iter()
282 .flat_map(|(_, rows)| rows)
283 .filter_map(|row| convert_forced_liquidation(row, is_isolated))
284 .filter(|liquidation| (start..=end).contains(&liquidation.time_exchange))
285 .filter(|liquidation| {
288 seen.insert((liquidation.instrument.clone(), liquidation.order_id.clone()))
289 })
290 .collect();
291 liquidations.sort_by_key(|liquidation| liquidation.time_exchange);
292 Ok(liquidations)
293 }
294
295 async fn read_each_account<'a, Row, Fut>(
298 &'a self,
299 read: impl Fn(Option<InstrumentNameExchange>) -> Fut,
300 ) -> Result<Vec<(Option<InstrumentNameExchange>, Vec<Row>)>, UnindexedClientError>
301 where
302 Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>> + 'a,
303 {
304 let accounts: Vec<Option<InstrumentNameExchange>> = if self.config.is_isolated {
305 self.config
306 .isolated_symbols
307 .iter()
308 .cloned()
309 .map(Some)
310 .collect()
311 } else {
312 vec![None]
313 };
314 futures::stream::iter(accounts.into_iter().map(|pair| {
315 let rows = read(pair.clone());
316 async move { Ok::<_, UnindexedClientError>((pair, rows.await?)) }
317 }))
318 .buffer_unordered(CONCURRENT_PAIRS)
319 .try_collect()
320 .await
321 }
322
323 async fn interest_rows(
325 &self,
326 pair: Option<InstrumentNameExchange>,
327 start_ms: i64,
328 end_ms: i64,
329 ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
330 let mut rows = Vec::new();
331 let mut window_start = start_ms;
332 loop {
333 let window_end = window_start.saturating_add(INTEREST_WINDOW_MS).min(end_ms);
334 let pair = &pair;
335 rows.extend(
336 read_all_pages(|current| {
337 self.interest_page(pair.clone(), window_start, window_end, current)
338 })
339 .await?,
340 );
341 if window_end >= end_ms {
342 break;
343 }
344 window_start = window_end;
347 }
348 debug!(
349 pair = ?pair,
350 rows = rows.len(),
351 "BinanceMargin fetch_margin_interest: account read"
352 );
353 Ok(rows)
354 }
355
356 async fn interest_page(
358 &self,
359 pair: Option<InstrumentNameExchange>,
360 start_ms: i64,
361 end_ms: i64,
362 current: i64,
363 ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
364 let symbol = pair.as_ref().map(|pair| pair.name().to_string());
365 let response =
366 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
367 let symbol = symbol.clone();
368 Box::pin(async move {
369 let params = GetInterestHistoryParams::builder()
370 .isolated_symbol(symbol)
371 .start_time(start_ms)
372 .end_time(end_ms)
373 .current(current)
374 .size(PAGE_SIZE)
375 .build()?;
376 rest.get_interest_history(params).await
377 })
378 })
379 .await
380 .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
381 let page = response.data().await.map_err(response_decode_error)?;
382 Ok(page.rows.unwrap_or_default())
383 }
384
385 async fn forced_liquidation_page(
387 &self,
388 pair: Option<InstrumentNameExchange>,
389 start_ms: i64,
390 end_ms: i64,
391 current: i64,
392 ) -> Result<Vec<GetForceLiquidationRecordResponseRowsInner>, UnindexedClientError> {
393 let symbol = pair.as_ref().map(|pair| pair.name().to_string());
394 let response =
395 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
396 let symbol = symbol.clone();
397 Box::pin(async move {
398 let params = GetForceLiquidationRecordParams::builder()
399 .isolated_symbol(symbol)
400 .start_time(start_ms)
401 .end_time(end_ms)
402 .current(current)
403 .size(PAGE_SIZE)
404 .build()?;
405 rest.get_force_liquidation_record(params).await
406 })
407 })
408 .await
409 .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
410 let page = response.data().await.map_err(response_decode_error)?;
411 Ok(page.rows.unwrap_or_default())
412 }
413}
414
415async fn read_all_pages<Row, Fut>(
418 mut page: impl FnMut(i64) -> Fut,
419) -> Result<Vec<Row>, UnindexedClientError>
420where
421 Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>>,
422{
423 let mut rows = Vec::new();
424 for current in 1..=MAX_PAGES {
425 let batch = page(current).await?;
426 let full = i64::try_from(batch.len()).is_ok_and(|len| len >= PAGE_SIZE);
427 rows.extend(batch);
428 if !full {
429 return Ok(rows);
430 }
431 }
432 Err(UnindexedClientError::Internal(format!(
433 "Binance margin history query still full after {MAX_PAGES} pages of {PAGE_SIZE}: \
434 narrow the span"
435 )))
436}
437
438#[derive(PartialEq, Eq, Hash)]
441enum ChargeKey {
442 Id(
443 AssetNameExchange,
444 Option<InstrumentNameExchange>,
445 CashFlowId,
446 ),
447 Flow(CashFlow<AssetNameExchange, InstrumentNameExchange>),
448}
449
450impl ChargeKey {
451 fn of(flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>) -> Self {
452 match &flow.id {
453 Some(id) => Self::Id(flow.asset.clone(), flow.instrument.clone(), id.clone()),
454 None => Self::Flow(flow.clone()),
455 }
456 }
457}
458
459fn optional_decimal(record: &str, field: &str, value: Option<&str>) -> Option<Decimal> {
462 let value = value.filter(|value| !value.is_empty())?;
463 Decimal::from_str(value)
464 .inspect_err(|error| {
465 warn!(
466 record,
467 field,
468 value,
469 %error,
470 "BinanceMargin record field does not parse — left out"
471 );
472 })
473 .ok()
474}
475
476fn interest_timing(r#type: &str) -> InterestTiming {
479 match r#type {
480 "PERIODIC" | "PERIODIC_CONVERTED" | "PORTFOLIO" => InterestTiming::Periodic,
481 "ON_BORROW" | "ON_BORROW_CONVERTED" => InterestTiming::OnBorrow,
482 other => InterestTiming::Other(SmolStr::new(other)),
483 }
484}
485
486fn convert_interest(
489 row: GetInterestHistoryResponseRowsInner,
490 pair: Option<&InstrumentNameExchange>,
491) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
492 let tx_id = row.tx_id;
493 let Some(asset) = row.asset.as_deref() else {
494 warn!(
495 ?tx_id,
496 ?pair,
497 "BinanceMargin interest record missing asset — skipped"
498 );
499 return None;
500 };
501 let Some(interest) = row
502 .interest
503 .as_deref()
504 .and_then(|s| Decimal::from_str(s).ok())
505 else {
506 warn!(
507 ?tx_id,
508 ?pair,
509 "BinanceMargin interest record missing/unparseable interest — skipped"
510 );
511 return None;
512 };
513 let Some(time_exchange) = row
514 .interest_accured_time
515 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
516 else {
517 warn!(
518 ?tx_id,
519 ?pair,
520 "BinanceMargin interest record missing/unparseable time — skipped"
521 );
522 return None;
523 };
524 let instrument = row
525 .isolated_symbol
526 .as_deref()
527 .map(InstrumentNameExchange::new)
528 .or_else(|| pair.cloned());
529 Some(CashFlow::new(
530 CashFlowKind::MarginInterest {
531 rate: optional_decimal("interest", "interestRate", row.interest_rate.as_deref()),
532 principal: optional_decimal("interest", "principal", row.principal.as_deref()),
533 timing: row.r#type.as_deref().map(interest_timing),
534 },
535 AssetNameExchange::new(asset),
536 -interest,
537 instrument,
538 time_exchange,
539 tx_id.map(|id| CashFlowId(format_smolstr!("{id}"))),
540 ))
541}
542
543fn convert_forced_liquidation(
547 row: GetForceLiquidationRecordResponseRowsInner,
548 is_isolated: bool,
549) -> Option<MarginForcedLiquidation> {
550 let Some(order_id) = row.order_id else {
551 warn!(symbol = ?row.symbol, "BinanceMargin forced liquidation record missing orderId — skipped");
552 return None;
553 };
554 let Some(symbol) = row.symbol.as_deref() else {
555 warn!(
556 order_id,
557 "BinanceMargin forced liquidation record missing symbol — skipped"
558 );
559 return None;
560 };
561 if !is_isolated && row.is_isolated == Some(true) {
564 warn!(
565 order_id,
566 symbol,
567 "BinanceMargin cross forced liquidation read returned an isolated record — left out"
568 );
569 return None;
570 }
571 let Some(side) = row.side.as_deref().and_then(parse_side) else {
572 warn!(
573 order_id,
574 symbol, "BinanceMargin forced liquidation record missing/unknown side — skipped"
575 );
576 return None;
577 };
578 let Some(filled_quantity) = row
579 .executed_qty
580 .as_deref()
581 .and_then(|s| Decimal::from_str(s).ok())
582 else {
583 warn!(
584 order_id,
585 symbol,
586 "BinanceMargin forced liquidation record missing/unparseable executedQty — skipped"
587 );
588 return None;
589 };
590 let Some(time_exchange) = row
591 .updated_time
592 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
593 else {
594 warn!(
595 order_id,
596 symbol,
597 "BinanceMargin forced liquidation record missing/unparseable updatedTime — skipped"
598 );
599 return None;
600 };
601 Some(MarginForcedLiquidation {
602 order_id: OrderId(format_smolstr!("{order_id}")),
603 instrument: InstrumentNameExchange::new(symbol),
604 side,
605 price: optional_decimal("forced liquidation", "price", row.price.as_deref()),
606 quantity: optional_decimal("forced liquidation", "qty", row.qty.as_deref()),
607 avg_price: (!filled_quantity.is_zero())
608 .then(|| optional_decimal("forced liquidation", "avgPrice", row.avg_price.as_deref()))
609 .flatten(),
610 filled_quantity,
611 time_exchange,
612 })
613}
614
615#[cfg(test)]
616#[allow(clippy::unwrap_used)] mod tests {
618 use super::{super::tests::margin_client_at, *};
619 use crate::error::ApiError;
620 use rust_decimal_macros::dec;
621 use serde_json::{Value, json};
622 use std::collections::HashMap;
623 use wiremock::{
624 Mock, MockServer, ResponseTemplate,
625 matchers::{method, path, query_param},
626 };
627
628 const INTEREST_PATH: &str = "/sapi/v1/margin/interestHistory";
629 const LIQUIDATION_PATH: &str = "/sapi/v1/margin/forceLiquidationRec";
630
631 fn interest_row(tx_id: i64, time: DateTime<Utc>, isolated: Option<&str>) -> Value {
632 let mut row = json!({
633 "txId": tx_id, "interestAccuredTime": time.timestamp_millis(), "asset": "USDT",
634 "rawAsset": "USDT", "principal": "1000", "interest": "0.0125",
635 "interestRate": "0.0000125", "type": "PERIODIC",
636 });
637 if let Some(symbol) = isolated {
638 row["isolatedSymbol"] = json!(symbol);
639 }
640 row
641 }
642
643 fn liquidation_row(
644 order_id: i64,
645 side: &str,
646 filled: &str,
647 time: DateTime<Utc>,
648 is_isolated: bool,
649 ) -> Value {
650 json!({
651 "avgPrice": "60000", "executedQty": filled, "orderId": order_id, "price": "59000",
652 "qty": "0.5", "side": side, "symbol": "BTCUSDT", "timeInForce": "GTC",
653 "isIsolated": is_isolated, "updatedTime": time.timestamp_millis(),
654 })
655 }
656
657 fn page(rows: Vec<Value>) -> ResponseTemplate {
658 let total = rows.len();
659 ResponseTemplate::new(200).set_body_json(json!({ "rows": rows, "total": total }))
660 }
661
662 async fn mount(server: &MockServer, at: &str, response: ResponseTemplate) {
663 Mock::given(method("GET"))
664 .and(path(at))
665 .respond_with(response)
666 .mount(server)
667 .await;
668 }
669
670 async fn mount_page(server: &MockServer, at: &str, current: &str, response: ResponseTemplate) {
672 Mock::given(method("GET"))
673 .and(path(at))
674 .and(query_param("current", current))
675 .respond_with(response)
676 .mount(server)
677 .await;
678 }
679
680 async fn queries(server: &MockServer) -> Vec<HashMap<String, String>> {
682 server
683 .received_requests()
684 .await
685 .unwrap()
686 .iter()
687 .map(|request| request.url.query_pairs().into_owned().collect())
688 .collect()
689 }
690
691 fn millis(query: &HashMap<String, String>, key: &str) -> i64 {
692 query[key].parse().unwrap()
693 }
694
695 #[tokio::test]
696 async fn interest_is_read_window_by_window_as_negative_flows_oldest_first_once_each() {
697 let server = MockServer::start().await;
698 let now = Utc::now();
699 let start = now - TimeDelta::days(40);
700 let boundary = start + TimeDelta::milliseconds(INTEREST_WINDOW_MS);
701 let mut no_interest = interest_row(9, now - TimeDelta::days(2), None);
702 no_interest.as_object_mut().unwrap().remove("interest");
703 let mut bad_rate = interest_row(3, now - TimeDelta::days(3), None);
704 bad_rate["interestRate"] = json!("n/a");
705 mount(
708 &server,
709 INTEREST_PATH,
710 page(vec![
711 interest_row(2, now - TimeDelta::days(1), None),
712 no_interest,
713 bad_rate,
714 interest_row(4, boundary, None),
715 interest_row(1, now - TimeDelta::days(35), None),
716 interest_row(0, now - TimeDelta::days(50), None),
717 ]),
718 )
719 .await;
720 let client = margin_client_at(&server, false);
721
722 let read = client
723 .fetch_margin_interest(start, now + TimeDelta::days(5))
724 .await
725 .unwrap();
726 assert_eq!((read.resume, read.held_since), (None, None));
727 let flows = read.flows;
728
729 let ids: Vec<_> = flows.iter().map(|flow| flow.id.clone().unwrap()).collect();
730 assert_eq!(ids, ["1", "4", "3", "2"].map(CashFlowId::new));
731 let flow = &flows[0];
732 assert_eq!(flow.amount, dec!(-0.0125), "a charge is paid");
733 assert_eq!(flow.asset, AssetNameExchange::new("USDT"));
734 assert_eq!(flow.instrument, None, "a cross charge names no pair");
735 assert_eq!(
736 flow.kind,
737 CashFlowKind::MarginInterest {
738 rate: Some(dec!(0.0000125)),
739 principal: Some(dec!(1000)),
740 timing: Some(InterestTiming::Periodic),
741 }
742 );
743 assert_eq!(
744 flows[2].kind,
745 CashFlowKind::MarginInterest {
746 rate: None,
747 principal: Some(dec!(1000)),
748 timing: Some(InterestTiming::Periodic),
749 },
750 "a rate that does not parse is left out, not the charge"
751 );
752
753 let queries = queries(&server).await;
754 let [first, second] = queries.as_slice() else {
755 panic!("two windows: {queries:?}");
756 };
757 assert_eq!(millis(first, "startTime"), start.timestamp_millis());
758 assert_eq!(millis(first, "endTime"), boundary.timestamp_millis());
759 assert_eq!(millis(second, "startTime"), boundary.timestamp_millis());
760 assert!(
761 millis(second, "endTime") <= Utc::now().timestamp_millis(),
762 "read up to the local clock, not `end`"
763 );
764 for query in [first, second] {
765 assert_eq!(query["current"], "1");
766 assert_eq!(query["size"], "100");
767 assert!(!query.contains_key("isolatedSymbol"), "{query:?}");
768 }
769 }
770
771 #[test]
772 fn interest_timing_is_read_from_the_record_type() {
773 for (r#type, expected) in [
774 ("PERIODIC", InterestTiming::Periodic),
775 ("PERIODIC_CONVERTED", InterestTiming::Periodic),
776 ("PORTFOLIO", InterestTiming::Periodic),
777 ("ON_BORROW", InterestTiming::OnBorrow),
778 ("ON_BORROW_CONVERTED", InterestTiming::OnBorrow),
779 (
780 "ON_LIQUIDATION",
781 InterestTiming::Other("ON_LIQUIDATION".into()),
782 ),
783 ] {
784 assert_eq!(interest_timing(r#type), expected, "{type}");
785 }
786 }
787
788 #[tokio::test]
789 async fn a_charge_on_borrow_has_its_timing() {
790 let server = MockServer::start().await;
791 let now = Utc::now();
792 let mut row = interest_row(1, now - TimeDelta::hours(1), None);
793 row["type"] = json!("ON_BORROW");
794 mount(&server, INTEREST_PATH, page(vec![row])).await;
795 let client = margin_client_at(&server, false);
796
797 let flows = client
798 .fetch_margin_interest(now - TimeDelta::days(1), now)
799 .await
800 .unwrap()
801 .flows;
802
803 let [flow] = flows.as_slice() else {
804 panic!("one flow: {flows:?}");
805 };
806 let CashFlowKind::MarginInterest { timing, .. } = &flow.kind else {
807 panic!("margin interest: {flow:?}");
808 };
809 assert_eq!(*timing, Some(InterestTiming::OnBorrow));
810 }
811
812 #[tokio::test]
815 async fn a_charge_converted_into_bnb_is_in_bnb() {
816 let server = MockServer::start().await;
817 let now = Utc::now();
818 let mut row = interest_row(1, now - TimeDelta::hours(1), None);
819 row["asset"] = json!("BNB");
820 row["principal"] = json!("0");
821 row["interest"] = json!("0.00002");
822 row["type"] = json!("PERIODIC_CONVERTED");
823 mount(&server, INTEREST_PATH, page(vec![row])).await;
824 let client = margin_client_at(&server, false);
825
826 let flows = client
827 .fetch_margin_interest(now - TimeDelta::days(1), now)
828 .await
829 .unwrap()
830 .flows;
831
832 let [flow] = flows.as_slice() else {
833 panic!("one flow: {flows:?}");
834 };
835 assert_eq!(flow.asset, AssetNameExchange::new("BNB"));
836 assert_eq!(flow.amount, dec!(-0.00002));
837 let CashFlowKind::MarginInterest {
838 principal, timing, ..
839 } = &flow.kind
840 else {
841 panic!("margin interest: {flow:?}");
842 };
843 assert_eq!(*principal, Some(Decimal::ZERO));
844 assert_eq!(*timing, Some(InterestTiming::Periodic));
845 }
846
847 #[tokio::test]
848 async fn a_charge_without_a_type_has_no_timing() {
849 let server = MockServer::start().await;
850 let now = Utc::now();
851 let mut row = interest_row(1, now - TimeDelta::hours(1), None);
852 row.as_object_mut().unwrap().remove("type");
853 mount(&server, INTEREST_PATH, page(vec![row])).await;
854 let client = margin_client_at(&server, false);
855
856 let flows = client
857 .fetch_margin_interest(now - TimeDelta::days(1), now)
858 .await
859 .unwrap()
860 .flows;
861
862 let [flow] = flows.as_slice() else {
863 panic!("one flow: {flows:?}");
864 };
865 let CashFlowKind::MarginInterest { timing, .. } = &flow.kind else {
866 panic!("margin interest: {flow:?}");
867 };
868 assert_eq!(*timing, None);
869 }
870
871 #[tokio::test]
872 async fn a_charge_repeated_with_its_id_is_kept_once() {
873 let server = MockServer::start().await;
874 let now = Utc::now();
875 let mut corrected = interest_row(1, now - TimeDelta::hours(1), None);
876 corrected["interest"] = json!("0.02");
877 mount(
878 &server,
879 INTEREST_PATH,
880 page(vec![
881 corrected,
882 interest_row(1, now - TimeDelta::hours(1), None),
883 ]),
884 )
885 .await;
886 let client = margin_client_at(&server, false);
887
888 let flows = client
889 .fetch_margin_interest(now - TimeDelta::days(1), now)
890 .await
891 .unwrap()
892 .flows;
893
894 let [flow] = flows.as_slice() else {
895 panic!("one charge: {flows:?}");
896 };
897 assert_eq!(flow.amount, dec!(-0.02), "the first copy read is kept");
898 }
899
900 #[tokio::test]
901 async fn a_span_of_one_instant_is_read_in_one_request() {
902 let server = MockServer::start().await;
903 let at =
905 DateTime::from_timestamp_millis((Utc::now() - TimeDelta::hours(1)).timestamp_millis())
906 .unwrap();
907 mount(
908 &server,
909 INTEREST_PATH,
910 page(vec![interest_row(1, at, None)]),
911 )
912 .await;
913 let client = margin_client_at(&server, false);
914
915 let flows = client.fetch_margin_interest(at, at).await.unwrap().flows;
916
917 assert_eq!(flows.len(), 1);
918 let queries = queries(&server).await;
919 let [query] = queries.as_slice() else {
920 panic!("one request: {queries:?}");
921 };
922 assert_eq!(millis(query, "startTime"), at.timestamp_millis());
923 assert_eq!(millis(query, "endTime"), at.timestamp_millis());
924 }
925
926 #[tokio::test]
927 async fn a_full_page_reads_the_next() {
928 let server = MockServer::start().await;
929 let now = Utc::now();
930 let time = now - TimeDelta::hours(1);
931 let full = (0..100).map(|id| interest_row(id, time, None)).collect();
932 mount_page(&server, INTEREST_PATH, "1", page(full)).await;
933 mount_page(
934 &server,
935 INTEREST_PATH,
936 "2",
937 page(vec![interest_row(100, time, None)]),
938 )
939 .await;
940 let client = margin_client_at(&server, false);
941
942 let flows = client
943 .fetch_margin_interest(now - TimeDelta::days(1), now)
944 .await
945 .unwrap()
946 .flows;
947
948 assert_eq!(flows.len(), 101);
949 assert_eq!(queries(&server).await.len(), 2);
950 }
951
952 #[tokio::test]
953 async fn a_query_still_full_after_the_most_pages_fails() {
954 let server = MockServer::start().await;
955 let now = Utc::now();
956 let time = now - TimeDelta::hours(1);
957 let full = (0..100).map(|id| interest_row(id, time, None)).collect();
959 mount(&server, INTEREST_PATH, page(full)).await;
960 let client = margin_client_at(&server, false);
961
962 let result = client
963 .fetch_margin_interest(now - TimeDelta::days(1), now)
964 .await;
965
966 assert!(
967 matches!(result, Err(UnindexedClientError::Internal(_))),
968 "{result:?}"
969 );
970 assert_eq!(
971 i64::try_from(queries(&server).await.len()).unwrap(),
972 MAX_PAGES
973 );
974 }
975
976 #[tokio::test]
977 async fn isolated_interest_is_read_per_pair_and_attributed_to_it() {
978 let server = MockServer::start().await;
979 let now = Utc::now();
980 mount(
981 &server,
982 INTEREST_PATH,
983 page(vec![
984 interest_row(2, now - TimeDelta::hours(1), Some("BTCUSDT")),
985 interest_row(1, now - TimeDelta::hours(2), None),
987 ]),
988 )
989 .await;
990 let client = margin_client_at(&server, true);
991
992 let flows = client
993 .fetch_margin_interest(now - TimeDelta::days(1), now)
994 .await
995 .unwrap()
996 .flows;
997
998 assert_eq!(flows.len(), 2);
999 for flow in flows {
1000 assert_eq!(
1001 flow.instrument,
1002 Some(InstrumentNameExchange::new("BTCUSDT"))
1003 );
1004 }
1005 let queries = queries(&server).await;
1006 assert_eq!(queries.len(), 1);
1007 assert_eq!(queries[0]["isolatedSymbol"], "BTCUSDT");
1008 }
1009
1010 #[tokio::test]
1011 async fn interest_older_than_binance_serves_is_read_from_its_horizon() {
1012 let server = MockServer::start().await;
1013 mount(&server, INTEREST_PATH, page(Vec::new())).await;
1014 let client = margin_client_at(&server, false);
1015 let before = Utc::now();
1016
1017 let read = client
1018 .fetch_margin_interest(before - TimeDelta::days(200), before)
1019 .await
1020 .unwrap();
1021 let after = Utc::now();
1022
1023 assert!(read.flows.is_empty());
1024 let queries = queries(&server).await;
1025 let first = millis(&queries[0], "startTime");
1026 let horizon = |now: DateTime<Utc>| {
1027 (now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN).timestamp_millis()
1028 };
1029 assert!(
1030 (horizon(before)..=horizon(after)).contains(&first),
1031 "{first}"
1032 );
1033 assert_eq!(
1034 read.held_since
1035 .map(|held_since| held_since.timestamp_millis()),
1036 Some(first),
1037 "held_since is where the read started"
1038 );
1039 assert_eq!(queries.len(), 4, "90 days in windows of 29");
1040 }
1041
1042 #[test]
1043 fn interest_is_held_since_a_minute_inside_ninety_days_only_for_an_older_start() {
1044 let now = Utc.with_ymd_and_hms(2026, 10, 25, 12, 0, 0).unwrap();
1045 let held_since = now - TimeDelta::days(90) + TimeDelta::minutes(1);
1046 let before = held_since - TimeDelta::milliseconds(1);
1047
1048 assert_eq!(interest_held_since(before, now), Some(held_since));
1049 assert_eq!(
1050 interest_held_since(now - TimeDelta::days(90), now),
1051 Some(held_since),
1052 "a request for the last 90 days misses its first minute"
1053 );
1054 assert_eq!(interest_held_since(held_since, now), None);
1055 assert_eq!(interest_held_since(now, now), None);
1056 }
1057
1058 #[tokio::test]
1059 async fn interest_entirely_older_than_binance_serves_reads_nothing_and_says_so() {
1060 let server = MockServer::start().await;
1061 let client = margin_client_at(&server, false);
1062 let end = Utc::now() - TimeDelta::days(100);
1063
1064 let read = client
1065 .fetch_margin_interest(end - TimeDelta::days(1), end)
1066 .await
1067 .unwrap();
1068
1069 assert!(read.flows.is_empty());
1070 assert!(
1071 read.held_since.is_some_and(|held_since| held_since > end),
1072 "{:?}",
1073 read.held_since
1074 );
1075 assert!(queries(&server).await.is_empty());
1076 }
1077
1078 #[tokio::test]
1079 async fn an_empty_span_reads_nothing() {
1080 let server = MockServer::start().await;
1081 let client = margin_client_at(&server, false);
1082 let now = Utc::now();
1083
1084 let interest = client
1085 .fetch_margin_interest(now, now - TimeDelta::hours(1))
1086 .await
1087 .unwrap();
1088 let liquidations = client
1089 .fetch_forced_liquidations(now + TimeDelta::hours(1), now + TimeDelta::hours(2))
1090 .await
1091 .unwrap();
1092
1093 assert!(interest.flows.is_empty() && liquidations.is_empty());
1094 assert_eq!((interest.resume, interest.held_since), (None, None));
1095 assert!(queries(&server).await.is_empty());
1096 }
1097
1098 #[tokio::test]
1099 async fn a_refused_read_fails_the_whole_read() {
1100 let server = MockServer::start().await;
1101 let refused =
1102 ResponseTemplate::new(400).set_body_json(json!({"code": -1100, "msg": "refused"}));
1103 mount(&server, INTEREST_PATH, refused.clone()).await;
1104 mount(&server, LIQUIDATION_PATH, refused).await;
1105 let client = margin_client_at(&server, false);
1106 let now = Utc::now();
1107
1108 let interest = client
1109 .fetch_margin_interest(now - TimeDelta::days(1), now)
1110 .await;
1111 let liquidations = client
1112 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1113 .await;
1114
1115 assert!(
1116 matches!(
1117 interest,
1118 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
1119 ),
1120 "{interest:?}"
1121 );
1122 assert!(
1123 matches!(
1124 liquidations,
1125 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
1126 ),
1127 "{liquidations:?}"
1128 );
1129 }
1130
1131 #[tokio::test]
1132 async fn forced_liquidations_of_the_clients_account_are_read_oldest_first_once_each() {
1133 for is_isolated in [false, true] {
1134 let server = MockServer::start().await;
1135 let now = Utc::now();
1136 let row = |order_id, side, filled, time| {
1137 liquidation_row(order_id, side, filled, time, is_isolated)
1138 };
1139 let mut no_order_id = row(9, "SELL", "0.5", now);
1140 no_order_id.as_object_mut().unwrap().remove("orderId");
1141 mount(
1142 &server,
1143 LIQUIDATION_PATH,
1144 page(vec![
1145 row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
1146 row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
1147 no_order_id,
1148 liquidation_row(8, "SELL", "0.5", now, true),
1150 row(1, "BUY", "0", now - TimeDelta::hours(2)),
1151 row(0, "BUY", "0.5", now - TimeDelta::days(3)),
1152 ]),
1153 )
1154 .await;
1155 let client = margin_client_at(&server, is_isolated);
1156
1157 let liquidations = client
1158 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1159 .await
1160 .unwrap();
1161
1162 let (unfilled, filled) = match (is_isolated, liquidations.as_slice()) {
1163 (false, [unfilled, filled]) => (unfilled, filled),
1164 (true, [unfilled, filled, marked]) => {
1165 assert_eq!(marked.order_id, OrderId::new("8"));
1166 (unfilled, filled)
1167 }
1168 _ => panic!("is_isolated = {is_isolated}: {liquidations:?}"),
1169 };
1170 assert_eq!(unfilled.order_id, OrderId::new("1"));
1171 assert_eq!(unfilled.side, Side::Buy);
1172 assert_eq!(unfilled.avg_price, None, "nothing filled");
1173 assert_eq!(
1174 *filled,
1175 MarginForcedLiquidation {
1176 order_id: OrderId::new("2"),
1177 instrument: InstrumentNameExchange::new("BTCUSDT"),
1178 side: Side::Sell,
1179 price: Some(dec!(59000)),
1180 quantity: Some(dec!(0.5)),
1181 filled_quantity: dec!(0.5),
1182 avg_price: Some(dec!(60000)),
1183 time_exchange: Utc
1184 .timestamp_millis_opt((now - TimeDelta::hours(1)).timestamp_millis())
1185 .unwrap(),
1186 }
1187 );
1188
1189 let queries = queries(&server).await;
1190 let [query] = queries.as_slice() else {
1191 panic!("one page: {queries:?}");
1192 };
1193 assert_eq!(query["size"], "100");
1194 assert_eq!(
1195 query.get("isolatedSymbol").map(String::as_str),
1196 is_isolated.then_some("BTCUSDT"),
1197 );
1198 }
1199 }
1200
1201 #[tokio::test]
1202 async fn a_full_page_of_forced_liquidations_reads_the_next() {
1203 let server = MockServer::start().await;
1204 let now = Utc::now();
1205 let time = now - TimeDelta::hours(1);
1206 let full = (0..100)
1207 .map(|id| liquidation_row(id, "SELL", "0.5", time, false))
1208 .collect();
1209 mount_page(&server, LIQUIDATION_PATH, "1", page(full)).await;
1210 mount_page(
1211 &server,
1212 LIQUIDATION_PATH,
1213 "2",
1214 page(vec![liquidation_row(100, "SELL", "0.5", time, false)]),
1215 )
1216 .await;
1217 let client = margin_client_at(&server, false);
1218
1219 let liquidations = client
1220 .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1221 .await
1222 .unwrap();
1223
1224 assert_eq!(liquidations.len(), 101);
1225 assert_eq!(queries(&server).await.len(), 2);
1226 }
1227}