Skip to main content

rustrade_execution/client/binance/margin/
records.rs

1//! A margin account's history read over REST: the interest Binance charged, and the orders it
2//! placed to liquidate the account.
3//!
4//! Neither arrives on the account stream, so the caller reads them when it wants them.
5
6use 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
32/// How far back Binance serves interest history.
33const INTEREST_LOOKBACK: TimeDelta = TimeDelta::days(90);
34
35/// Kept clear of [`INTEREST_LOOKBACK`]'s edge, so that a start at the edge is not refused for any
36/// skew between the local clock and Binance's.
37const INTEREST_LOOKBACK_MARGIN: TimeDelta = TimeDelta::minutes(1);
38
39/// Where an interest read asked to start at `start` at `now` starts instead, because Binance no
40/// longer serves charges that old: a minute inside [`INTEREST_LOOKBACK`]. `None` when it serves
41/// `start`.
42fn 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
47/// The span of one interest history request. Binance serves at most 30 days per request, and
48/// says the limit is needed for the data to be correct; a day less leaves room for how it counts.
49const INTEREST_WINDOW_MS: i64 = 29 * 24 * 60 * 60 * 1000;
50
51/// The most rows Binance returns in one page of either history.
52const PAGE_SIZE: i64 = 100;
53
54/// The most pages one query is read for: 50,000 rows. A query needing more fails rather than
55/// loop on, as it would were Binance to ignore the page asked for and serve the same full page
56/// each time.
57const MAX_PAGES: i64 = 500;
58
59/// How many isolated pairs are read at once.
60const CONCURRENT_PAIRS: usize = 8;
61
62/// An order Binance placed to liquidate a margin account, as its forced-liquidation record
63/// reports it.
64///
65/// Read by [`BinanceMargin::fetch_forced_liquidations`].
66#[non_exhaustive]
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
68pub struct MarginForcedLiquidation {
69    /// Binance's id for the liquidation order.
70    ///
71    /// Binance does not document that a fill of this order reaches the account stream, nor that
72    /// one would carry this id as its order id. Until a real liquidation shows it, matching the
73    /// two is the caller's assumption.
74    pub order_id: OrderId,
75    /// The pair the order traded.
76    pub instrument: InstrumentNameExchange,
77    /// The order's side: [`Sell`](Side::Sell) to close a long, [`Buy`](Side::Buy) a short.
78    pub side: Side,
79    /// The order's limit price, if Binance reports one.
80    pub price: Option<Decimal>,
81    /// The order's quantity, if Binance reports one.
82    pub quantity: Option<Decimal>,
83    /// How much of the order filled.
84    pub filled_quantity: Decimal,
85    /// The average price of the filled quantity. `None` when nothing filled.
86    pub avg_price: Option<Decimal>,
87    /// When Binance last updated the order.
88    pub time_exchange: DateTime<Utc>,
89}
90
91impl BinanceMargin {
92    /// Read the interest Binance charged the account from `start` to `end`, each charge as a
93    /// [`CashFlow`] of [`MarginInterest`](CashFlowKind::MarginInterest), oldest first, in
94    /// [`CashFlowsRead::flows`].
95    ///
96    /// A cross-margin client reads the cross account. An isolated one reads each pair in its
97    /// [`isolated_symbols`](super::BinanceMarginConfig::isolated_symbols), and attributes each
98    /// charge to its pair through [`CashFlow::instrument`]; a cross charge is attributed to none.
99    ///
100    /// # What a charge is
101    /// Binance charges interest hourly, and once when a loan is taken, by adding it to the loan
102    /// rather than taking it from the account's free balance. A charge is cash only once the loan
103    /// is repaid. The flow's negative [`amount`](CashFlow::amount) is the cost charged at
104    /// [`time_exchange`](CashFlow::time_exchange), not a balance that moved then: the account's
105    /// balance shows it as interest owed on the asset.
106    ///
107    /// - [`asset`](CashFlow::asset) is the `asset` field of Binance's record. Binance charges
108    ///   interest in the asset borrowed, or converts it into BNB for an account that pays interest
109    ///   in BNB: a `_CONVERTED` record's `asset` is BNB, so its flow does not name the asset
110    ///   borrowed. Binance names that asset in a separate `rawAsset` field, which this method does
111    ///   not read, and which Binance does not return on an isolated record.
112    /// - [`principal`](CashFlowKind::MarginInterest::principal) is the loan the charge was on. On a
113    ///   `_CONVERTED` record it can read 0, and Binance's documentation does not say when.
114    ///   Binance's support points to its borrow and repay history
115    ///   (`GET /sapi/v1/margin/borrow-repay`) for the loan. A converted record's flow does not
116    ///   carry the asset borrowed, so that history cannot be narrowed to it by asset from the flow
117    ///   alone.
118    /// - [`rate`](CashFlowKind::MarginInterest::rate) is Binance's `interestRate`, a **daily** rate
119    ///   even on an hourly charge: an hourly charge is about the rate divided by 24 of the
120    ///   principal. Binance's API documentation does not state the unit; it is read off the rates
121    ///   Binance publishes on its [margin fee page](https://www.binance.com/en/fee/marginFee),
122    ///   whose hourly figure is the daily one divided by 24 (BTC's daily rate matches the field,
123    ///   its hourly rate is about 24 times smaller).
124    /// - [`timing`](CashFlowKind::MarginInterest::timing) is read from Binance's `type`:
125    ///   `PERIODIC` (and `PERIODIC_CONVERTED`, and portfolio margin's daily `PORTFOLIO`) is
126    ///   [`Periodic`](InterestTiming::Periodic), `ON_BORROW` (and `ON_BORROW_CONVERTED`) is
127    ///   [`OnBorrow`](InterestTiming::OnBorrow), and any other value is
128    ///   [`Other`](InterestTiming::Other) with Binance's own name. `None` when the record has no
129    ///   `type`.
130    /// - [`time_exchange`](CashFlow::time_exchange) is Binance's `interestAccuredTime`, the period
131    ///   the charge is for, not when Binance posted it (see [Delivery](#delivery)): for an hourly
132    ///   charge, the start of its hour. An `ON_BORROW` charge is stamped at the start of the hour
133    ///   the loan was taken in, so it can precede the opening of the position it belongs to.
134    /// - [`id`](CashFlow::id) is Binance's transaction id for the charge.
135    ///
136    /// The engine adds a flow attributed to an instrument to the carry of its open position, in
137    /// the slot for the flow's asset: the quote asset's carry, the base asset's, or any other
138    /// asset's. Interest on a short's borrowed base asset is valued at the price the position
139    /// closes at, which is what buying the asset back costs. Interest in BNB, or any asset that is
140    /// neither the instrument's base nor quote, is kept as charged and is in no PnL figure.
141    ///
142    /// # Delivery
143    /// Nothing is pushed: the caller decides when to read, and must drop the charges it has
144    /// already counted, by [`id`](CashFlow::id), before applying a read's flows. Within one read
145    /// each charge appears once: a charge read twice, as one stamped on a window's boundary is, is
146    /// kept the first time its id is seen in its asset and pair, or, without an id, once per
147    /// identical record.
148    ///
149    /// A charge is posted some time after the period it is for, by a delay Binance does not
150    /// document and that may vary, and is then served stamped at that period. A read whose span
151    /// ends near the present can therefore miss a charge that a later read returns at a time
152    /// inside the earlier span. A caller that polls by time should start each read before the
153    /// previous read's `end`, by at least the longest posting delay it is prepared to allow for,
154    /// and drop the charges it has already counted by id within their asset and pair. A charge
155    /// with no id can be matched only by its whole record, which cannot tell it from an identical
156    /// charge.
157    ///
158    /// The records need not add up to the interest owed. On a live account, a loan was charged
159    /// interest when it was taken, yet a read served only the hourly charge that followed, with no
160    /// `ON_BORROW` record. Whether such a record is posted later, or never, is not known. The
161    /// account's balance, which reports the interest owed (see
162    /// [`MarginDetails::interest`](crate::balance::MarginDetails::interest)), is the figure to rely
163    /// on for what is owed.
164    ///
165    /// # Span
166    /// - Binance serves interest charged in the last 90 days only. A `start` before that is moved
167    ///   up to a minute inside that limit, clear of any skew between the clocks, and
168    ///   [`held_since`](CashFlowsRead::held_since) is where the read then starts: charges before
169    ///   it may exist but were not read. A caller adding up the interest on a position opened
170    ///   before it has only part of it. `held_since` is `None` when `start` is at or after that
171    ///   point, a minute inside the 90 days; a request for the last 90 days reports it.
172    /// - Charges are read up to `end`, or up to the local clock at the call if that is earlier.
173    /// - The whole span is read in one call, so [`resume`](CashFlowsRead::resume) is always
174    ///   `None`.
175    /// - Binance serves at most 30 days per request, so a span is read in windows of 29 days,
176    ///   each paged by 100 charges. A read costs a request weight of 1 per page, at least 1 per
177    ///   window, and, on an isolated client, as much again for each pair.
178    ///
179    /// # Records left out
180    /// A record without an asset, an amount or a time that parses is left out, and logged at WARN.
181    /// A rate or principal that does not parse is left `None`, and logged at WARN.
182    ///
183    /// # Errors
184    /// Fails if any request fails, or if one window of one account holds more than 50,000
185    /// charges. Nothing is returned from a read that failed part way.
186    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            // Consecutive windows share their boundary, and a page can repeat a row when a new
222            // charge lands mid-walk and shifts the pages after it.
223            .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    /// Read the orders Binance placed from `start` to `end` to liquidate the account, oldest
230    /// first.
231    ///
232    /// A cross-margin client reads the cross account. An isolated one reads each pair in its
233    /// [`isolated_symbols`](super::BinanceMarginConfig::isolated_symbols).
234    ///
235    /// Binance marks no liquidation on the account stream, so this client reports a liquidation
236    /// order's fills as [`TradeOrigin::Order`](crate::trade::TradeOrigin::Order). A caller that
237    /// learns the account is being liquidated, as from a margin-level notice, can read these
238    /// records and match their [`order_id`](MarginForcedLiquidation::order_id)s against the fills
239    /// it has, with the caveat on that field.
240    ///
241    /// # Span
242    /// Records are read up to `end`, or up to the local clock at the call if that is earlier, in
243    /// pages of 100. Binance documents no limit on the span or on how far back it serves; a
244    /// two-year span was accepted when tried in October 2026, which is no guarantee. A read
245    /// costs a request weight of 1 per page and, on an isolated client, as much again for each
246    /// pair.
247    ///
248    /// # Records left out
249    /// - A record without an order id, a pair, a side, a filled quantity or a time that parses is
250    ///   left out, and logged at WARN. A price or quantity that does not parse is left `None`, and
251    ///   logged at WARN.
252    /// - On a cross client, a record Binance marks as isolated is left out, and logged at WARN:
253    ///   the cross query should not return one. An isolated client asks for one pair at a time,
254    ///   and keeps what Binance returns for it.
255    ///
256    /// # Errors
257    /// Fails if any request fails, or if one account holds more than 50,000 records in the span.
258    /// Nothing is returned from a read that failed part way.
259    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            // A page can repeat a row when a new record lands mid-walk and shifts the pages after
286            // it. Pages run newest first, so the copy kept is the latest.
287            .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    /// Run `read` once per account this client covers: once for the cross account (`None`), or
296    /// once per configured isolated pair. Returns each account's rows with the pair.
297    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    /// Every interest row of one account from `start_ms` to `end_ms`, window by window.
324    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            // The next window starts on this one's end, so a charge stamped on the boundary is
345            // read whether Binance treats `endTime` as inclusive or not. The copy is dropped later.
346            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    /// One page of `interestHistory`.
357    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    /// One page of `forceLiquidationRec`.
386    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
415/// Read pages `1, 2, …` of a paged history query of [`PAGE_SIZE`] rows each, up to the first
416/// short page. Fails once [`MAX_PAGES`] pages were full.
417async 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/// What tells two interest charges in one read apart: Binance's transaction id, in the charge's
439/// asset and pair, or, for a record without one, the whole flow.
440#[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
459/// An optional decimal field of a `record`: `None` when absent or empty, or, logged at WARN, when
460/// it does not parse.
461fn 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
476/// The [`InterestTiming`] of an `interestHistory` row's `type`. A `_CONVERTED` type is the same
477/// charge settled in BNB, and `PORTFOLIO` is portfolio margin's daily charge on a negative balance.
478fn 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
486/// One `interestHistory` row as a [`CashFlow`], or `None`, with a warning, if it lacks what a flow
487/// needs. `pair` is the isolated pair the row was read for, if any.
488fn 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
543/// One `forceLiquidationRec` row as a [`MarginForcedLiquidation`], or `None`, with a warning, if
544/// it lacks what one needs. `None` too, logged at WARN, on a cross client (`is_isolated` false)
545/// when Binance marks the row as isolated.
546fn 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    // An isolated client's query names its pair, so what it returns is that pair's. A cross
562    // query names none, so an isolated pair's record there is not the cross account's.
563    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)] // Test code: panics on bad input are acceptable
617mod 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    /// Answer page `current` of the query at `at` with `response`.
671    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    /// The query of every request `server` received, in order.
681    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        // Every window answers with the same rows: one stamped on the windows' boundary, one
706        // outside the span, and one that cannot be a flow.
707        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    /// A charge converted into BNB is in BNB, from `asset` rather than the `rawAsset` borrowed,
813    /// and its principal of 0 is read as served.
814    #[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        // In whole milliseconds, as Binance stamps a charge, so the charge is inside the span.
904        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        // As were Binance to ignore the page asked for.
958        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                // Binance names the pair on an isolated row; one that does not keeps the pair read.
986                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                    // Marked isolated: not the cross account's, but the isolated pair's.
1149                    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}