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