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},
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::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/// The span of one interest history request. Binance serves at most 30 days per request, and
40/// says the limit is needed for the data to be correct; a day less leaves room for how it counts.
41const INTEREST_WINDOW_MS: i64 = 29 * 24 * 60 * 60 * 1000;
42
43/// The most rows Binance returns in one page of either history.
44const PAGE_SIZE: i64 = 100;
45
46/// The most pages one query is read for: 50,000 rows. A query needing more fails rather than
47/// loop on, as it would were Binance to ignore the page asked for and serve the same full page
48/// each time.
49const MAX_PAGES: i64 = 500;
50
51/// How many isolated pairs are read at once.
52const CONCURRENT_PAIRS: usize = 8;
53
54/// An order Binance placed to liquidate a margin account, as its forced-liquidation record
55/// reports it.
56///
57/// Read by [`BinanceMargin::fetch_forced_liquidations`].
58#[non_exhaustive]
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
60pub struct MarginForcedLiquidation {
61    /// Binance's id for the liquidation order.
62    ///
63    /// Binance does not document that a fill of this order reaches the account stream, nor that
64    /// one would carry this id as its order id. Until a real liquidation shows it, matching the
65    /// two is the caller's assumption.
66    pub order_id: OrderId,
67    /// The pair the order traded.
68    pub instrument: InstrumentNameExchange,
69    /// The order's side: [`Sell`](Side::Sell) to close a long, [`Buy`](Side::Buy) a short.
70    pub side: Side,
71    /// The order's limit price, if Binance reports one.
72    pub price: Option<Decimal>,
73    /// The order's quantity, if Binance reports one.
74    pub quantity: Option<Decimal>,
75    /// How much of the order filled.
76    pub filled_quantity: Decimal,
77    /// The average price of the filled quantity. `None` when nothing filled.
78    pub avg_price: Option<Decimal>,
79    /// When Binance last updated the order.
80    pub time_exchange: DateTime<Utc>,
81}
82
83impl BinanceMargin {
84    /// Read the interest Binance charged the account from `start` to `end`, each charge as a
85    /// [`CashFlow`] of [`MarginInterest`](CashFlowKind::MarginInterest), oldest first.
86    ///
87    /// A cross-margin client reads the cross account. An isolated one reads each pair in its
88    /// [`isolated_symbols`](super::BinanceMarginConfig::isolated_symbols), and attributes each
89    /// charge to its pair through [`CashFlow::instrument`]; a cross charge is attributed to none.
90    ///
91    /// # What a charge is
92    /// Binance charges interest hourly, and once when a loan is taken, by adding it to the loan
93    /// rather than taking it from the account's free balance. A charge is cash only once the loan
94    /// is repaid. The flow's negative [`amount`](CashFlow::amount) is the cost charged at
95    /// [`time_exchange`](CashFlow::time_exchange), not a balance that moved then: the account's
96    /// balance shows it as interest owed on the asset.
97    ///
98    /// - [`asset`](CashFlow::asset) is the `asset` field of Binance's record. Binance charges
99    ///   interest in the asset borrowed, or converts it into BNB for an account that pays interest
100    ///   in BNB. A converted record also names the asset borrowed, in a separate field, and Binance
101    ///   does not document which of the two fields holds BNB. Check a converted record before
102    ///   relying on its asset.
103    /// - [`principal`](CashFlowKind::MarginInterest::principal) is the loan the charge was on.
104    /// - [`rate`](CashFlowKind::MarginInterest::rate) is the rate charged for the period, hourly
105    ///   for an hourly charge, as Binance quotes it.
106    /// - [`id`](CashFlow::id) is Binance's transaction id for the charge.
107    ///
108    /// The engine adds a flow attributed to an instrument to the carry of its open position only
109    /// when the flow is in the instrument's quote asset. Interest on a short's borrowed base asset,
110    /// or in BNB, is not: the engine leaves it out of carry and says so. To count it, convert it
111    /// into the quote asset first.
112    ///
113    /// # Delivery
114    /// Nothing is pushed: the caller decides when to read, and must drop the charges it has
115    /// already counted, by [`id`](CashFlow::id), before applying a read's flows. Within one read
116    /// each charge appears once: a charge read twice, as one stamped on a window's boundary is, is
117    /// kept the first time its id is seen in its asset and pair, or, without an id, once per
118    /// identical record.
119    ///
120    /// # Span
121    /// - Binance serves interest charged in the last 90 days only. A `start` before that is moved
122    ///   up to a minute inside that limit, clear of any skew between the clocks. A `start` more
123    ///   than a minute before the limit is logged at WARN; one within a minute of it, on either
124    ///   side, as a request for the last 90 days is, is moved silently.
125    /// - Charges are read up to `end`, or up to the local clock at the call if that is earlier.
126    /// - Binance serves at most 30 days per request, so a span is read in windows of 29 days,
127    ///   each paged by 100 charges. A read costs a request weight of 1 per page, at least 1 per
128    ///   window, and, on an isolated client, as much again for each pair.
129    ///
130    /// # Records left out
131    /// A record without an asset, an amount or a time that parses is left out, and logged at WARN.
132    /// A rate or principal that does not parse is left `None`, and logged at WARN.
133    ///
134    /// # Errors
135    /// Fails if any request fails, or if one window of one account holds more than 50,000
136    /// charges. Nothing is returned from a read that failed part way.
137    pub async fn fetch_margin_interest(
138        &self,
139        start: DateTime<Utc>,
140        end: DateTime<Utc>,
141    ) -> Result<Vec<CashFlow<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError>
142    {
143        let now = Utc::now();
144        let horizon = now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN;
145        let start = if start < horizon {
146            if start < now - INTEREST_LOOKBACK - INTEREST_LOOKBACK_MARGIN {
147                warn!(
148                    requested = %start,
149                    served_from = %horizon,
150                    "BinanceMargin fetch_margin_interest: Binance serves 90 days of interest \
151                     history — reading from the earliest time it serves"
152                );
153            }
154            horizon
155        } else {
156            start
157        };
158        let end = end.min(now);
159        if start > end {
160            return Ok(Vec::new());
161        }
162        let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
163
164        let rows = self
165            .read_each_account(|pair| self.interest_rows(pair, start_ms, end_ms))
166            .await?;
167
168        let mut seen = FnvHashSet::default();
169        let mut flows: Vec<_> = rows
170            .into_iter()
171            .flat_map(|(pair, rows)| {
172                rows.into_iter()
173                    .filter_map(move |row| convert_interest(row, pair.as_ref()))
174            })
175            .filter(|flow| (start..=end).contains(&flow.time_exchange))
176            // Consecutive windows share their boundary, and a page can repeat a row when a new
177            // charge lands mid-walk and shifts the pages after it.
178            .filter(|flow| seen.insert(ChargeKey::of(flow)))
179            .collect();
180        flows.sort_by_key(|flow| flow.time_exchange);
181        Ok(flows)
182    }
183
184    /// Read the orders Binance placed from `start` to `end` to liquidate the account, oldest
185    /// first.
186    ///
187    /// A cross-margin client reads the cross account. An isolated one reads each pair in its
188    /// [`isolated_symbols`](super::BinanceMarginConfig::isolated_symbols).
189    ///
190    /// Binance marks no liquidation on the account stream, so this client reports a liquidation
191    /// order's fills as [`TradeOrigin::Order`](crate::trade::TradeOrigin::Order). A caller that
192    /// learns the account is being liquidated, as from a margin-level notice, can read these
193    /// records and match their [`order_id`](MarginForcedLiquidation::order_id)s against the fills
194    /// it has, with the caveat on that field.
195    ///
196    /// # Span
197    /// Records are read up to `end`, or up to the local clock at the call if that is earlier, in
198    /// pages of 100. Binance documents no limit on the span or on how far back it serves; a
199    /// two-year span was accepted when tried in October 2026, which is no guarantee. A read
200    /// costs a request weight of 1 per page and, on an isolated client, as much again for each
201    /// pair.
202    ///
203    /// # Records left out
204    /// - A record without an order id, a pair, a side, a filled quantity or a time that parses is
205    ///   left out, and logged at WARN. A price or quantity that does not parse is left `None`, and
206    ///   logged at WARN.
207    /// - On a cross client, a record Binance marks as isolated is left out, and logged at WARN:
208    ///   the cross query should not return one. An isolated client asks for one pair at a time,
209    ///   and keeps what Binance returns for it.
210    ///
211    /// # Errors
212    /// Fails if any request fails, or if one account holds more than 50,000 records in the span.
213    /// Nothing is returned from a read that failed part way.
214    pub async fn fetch_forced_liquidations(
215        &self,
216        start: DateTime<Utc>,
217        end: DateTime<Utc>,
218    ) -> Result<Vec<MarginForcedLiquidation>, UnindexedClientError> {
219        let end = end.min(Utc::now());
220        if start > end {
221            return Ok(Vec::new());
222        }
223        let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
224
225        let is_isolated = self.config.is_isolated;
226        let rows = self
227            .read_each_account(|pair| {
228                read_all_pages(move |current| {
229                    self.forced_liquidation_page(pair.clone(), start_ms, end_ms, current)
230                })
231            })
232            .await?;
233
234        let mut seen = FnvHashSet::default();
235        let mut liquidations: Vec<_> = rows
236            .into_iter()
237            .flat_map(|(_, rows)| rows)
238            .filter_map(|row| convert_forced_liquidation(row, is_isolated))
239            .filter(|liquidation| (start..=end).contains(&liquidation.time_exchange))
240            // A page can repeat a row when a new record lands mid-walk and shifts the pages after
241            // it. Pages run newest first, so the copy kept is the latest.
242            .filter(|liquidation| {
243                seen.insert((liquidation.instrument.clone(), liquidation.order_id.clone()))
244            })
245            .collect();
246        liquidations.sort_by_key(|liquidation| liquidation.time_exchange);
247        Ok(liquidations)
248    }
249
250    /// Run `read` once per account this client covers: once for the cross account (`None`), or
251    /// once per configured isolated pair. Returns each account's rows with the pair.
252    async fn read_each_account<'a, Row, Fut>(
253        &'a self,
254        read: impl Fn(Option<InstrumentNameExchange>) -> Fut,
255    ) -> Result<Vec<(Option<InstrumentNameExchange>, Vec<Row>)>, UnindexedClientError>
256    where
257        Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>> + 'a,
258    {
259        let accounts: Vec<Option<InstrumentNameExchange>> = if self.config.is_isolated {
260            self.config
261                .isolated_symbols
262                .iter()
263                .cloned()
264                .map(Some)
265                .collect()
266        } else {
267            vec![None]
268        };
269        futures::stream::iter(accounts.into_iter().map(|pair| {
270            let rows = read(pair.clone());
271            async move { Ok::<_, UnindexedClientError>((pair, rows.await?)) }
272        }))
273        .buffer_unordered(CONCURRENT_PAIRS)
274        .try_collect()
275        .await
276    }
277
278    /// Every interest row of one account from `start_ms` to `end_ms`, window by window.
279    async fn interest_rows(
280        &self,
281        pair: Option<InstrumentNameExchange>,
282        start_ms: i64,
283        end_ms: i64,
284    ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
285        let mut rows = Vec::new();
286        let mut window_start = start_ms;
287        loop {
288            let window_end = window_start.saturating_add(INTEREST_WINDOW_MS).min(end_ms);
289            let pair = &pair;
290            rows.extend(
291                read_all_pages(|current| {
292                    self.interest_page(pair.clone(), window_start, window_end, current)
293                })
294                .await?,
295            );
296            if window_end >= end_ms {
297                break;
298            }
299            // The next window starts on this one's end, so a charge stamped on the boundary is
300            // read whether Binance treats `endTime` as inclusive or not. The copy is dropped later.
301            window_start = window_end;
302        }
303        debug!(
304            pair = ?pair,
305            rows = rows.len(),
306            "BinanceMargin fetch_margin_interest: account read"
307        );
308        Ok(rows)
309    }
310
311    /// One page of `interestHistory`.
312    async fn interest_page(
313        &self,
314        pair: Option<InstrumentNameExchange>,
315        start_ms: i64,
316        end_ms: i64,
317        current: i64,
318    ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
319        let symbol = pair.as_ref().map(|pair| pair.name().to_string());
320        let response =
321            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
322                let symbol = symbol.clone();
323                Box::pin(async move {
324                    let params = GetInterestHistoryParams::builder()
325                        .isolated_symbol(symbol)
326                        .start_time(start_ms)
327                        .end_time(end_ms)
328                        .current(current)
329                        .size(PAGE_SIZE)
330                        .build()?;
331                    rest.get_interest_history(params).await
332                })
333            })
334            .await
335            .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
336        let page = response.data().await.map_err(response_decode_error)?;
337        Ok(page.rows.unwrap_or_default())
338    }
339
340    /// One page of `forceLiquidationRec`.
341    async fn forced_liquidation_page(
342        &self,
343        pair: Option<InstrumentNameExchange>,
344        start_ms: i64,
345        end_ms: i64,
346        current: i64,
347    ) -> Result<Vec<GetForceLiquidationRecordResponseRowsInner>, UnindexedClientError> {
348        let symbol = pair.as_ref().map(|pair| pair.name().to_string());
349        let response =
350            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
351                let symbol = symbol.clone();
352                Box::pin(async move {
353                    let params = GetForceLiquidationRecordParams::builder()
354                        .isolated_symbol(symbol)
355                        .start_time(start_ms)
356                        .end_time(end_ms)
357                        .current(current)
358                        .size(PAGE_SIZE)
359                        .build()?;
360                    rest.get_force_liquidation_record(params).await
361                })
362            })
363            .await
364            .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
365        let page = response.data().await.map_err(response_decode_error)?;
366        Ok(page.rows.unwrap_or_default())
367    }
368}
369
370/// Read pages `1, 2, …` of a paged history query of [`PAGE_SIZE`] rows each, up to the first
371/// short page. Fails once [`MAX_PAGES`] pages were full.
372async fn read_all_pages<Row, Fut>(
373    mut page: impl FnMut(i64) -> Fut,
374) -> Result<Vec<Row>, UnindexedClientError>
375where
376    Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>>,
377{
378    let mut rows = Vec::new();
379    for current in 1..=MAX_PAGES {
380        let batch = page(current).await?;
381        let full = i64::try_from(batch.len()).is_ok_and(|len| len >= PAGE_SIZE);
382        rows.extend(batch);
383        if !full {
384            return Ok(rows);
385        }
386    }
387    Err(UnindexedClientError::Internal(format!(
388        "Binance margin history query still full after {MAX_PAGES} pages of {PAGE_SIZE}: \
389         narrow the span"
390    )))
391}
392
393/// What tells two interest charges in one read apart: Binance's transaction id, in the charge's
394/// asset and pair, or, for a record without one, the whole flow.
395#[derive(PartialEq, Eq, Hash)]
396enum ChargeKey {
397    Id(
398        AssetNameExchange,
399        Option<InstrumentNameExchange>,
400        CashFlowId,
401    ),
402    Flow(CashFlow<AssetNameExchange, InstrumentNameExchange>),
403}
404
405impl ChargeKey {
406    fn of(flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>) -> Self {
407        match &flow.id {
408            Some(id) => Self::Id(flow.asset.clone(), flow.instrument.clone(), id.clone()),
409            None => Self::Flow(flow.clone()),
410        }
411    }
412}
413
414/// An optional decimal field of a `record`: `None` when absent or empty, or, logged at WARN, when
415/// it does not parse.
416fn optional_decimal(record: &str, field: &str, value: Option<&str>) -> Option<Decimal> {
417    let value = value.filter(|value| !value.is_empty())?;
418    Decimal::from_str(value)
419        .inspect_err(|error| {
420            warn!(
421                record,
422                field,
423                value,
424                %error,
425                "BinanceMargin record field does not parse — left out"
426            );
427        })
428        .ok()
429}
430
431/// One `interestHistory` row as a [`CashFlow`], or `None`, with a warning, if it lacks what a flow
432/// needs. `pair` is the isolated pair the row was read for, if any.
433fn convert_interest(
434    row: GetInterestHistoryResponseRowsInner,
435    pair: Option<&InstrumentNameExchange>,
436) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
437    let tx_id = row.tx_id;
438    let Some(asset) = row.asset.as_deref() else {
439        warn!(
440            ?tx_id,
441            ?pair,
442            "BinanceMargin interest record missing asset — skipped"
443        );
444        return None;
445    };
446    let Some(interest) = row
447        .interest
448        .as_deref()
449        .and_then(|s| Decimal::from_str(s).ok())
450    else {
451        warn!(
452            ?tx_id,
453            ?pair,
454            "BinanceMargin interest record missing/unparseable interest — skipped"
455        );
456        return None;
457    };
458    let Some(time_exchange) = row
459        .interest_accured_time
460        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
461    else {
462        warn!(
463            ?tx_id,
464            ?pair,
465            "BinanceMargin interest record missing/unparseable time — skipped"
466        );
467        return None;
468    };
469    let instrument = row
470        .isolated_symbol
471        .as_deref()
472        .map(InstrumentNameExchange::new)
473        .or_else(|| pair.cloned());
474    Some(CashFlow::new(
475        CashFlowKind::MarginInterest {
476            rate: optional_decimal("interest", "interestRate", row.interest_rate.as_deref()),
477            principal: optional_decimal("interest", "principal", row.principal.as_deref()),
478        },
479        AssetNameExchange::new(asset),
480        -interest,
481        instrument,
482        time_exchange,
483        tx_id.map(|id| CashFlowId(format_smolstr!("{id}"))),
484    ))
485}
486
487/// One `forceLiquidationRec` row as a [`MarginForcedLiquidation`], or `None`, with a warning, if
488/// it lacks what one needs. `None` too, logged at WARN, on a cross client (`is_isolated` false)
489/// when Binance marks the row as isolated.
490fn convert_forced_liquidation(
491    row: GetForceLiquidationRecordResponseRowsInner,
492    is_isolated: bool,
493) -> Option<MarginForcedLiquidation> {
494    let Some(order_id) = row.order_id else {
495        warn!(symbol = ?row.symbol, "BinanceMargin forced liquidation record missing orderId — skipped");
496        return None;
497    };
498    let Some(symbol) = row.symbol.as_deref() else {
499        warn!(
500            order_id,
501            "BinanceMargin forced liquidation record missing symbol — skipped"
502        );
503        return None;
504    };
505    // An isolated client's query names its pair, so what it returns is that pair's. A cross
506    // query names none, so an isolated pair's record there is not the cross account's.
507    if !is_isolated && row.is_isolated == Some(true) {
508        warn!(
509            order_id,
510            symbol,
511            "BinanceMargin cross forced liquidation read returned an isolated record — left out"
512        );
513        return None;
514    }
515    let Some(side) = row.side.as_deref().and_then(parse_side) else {
516        warn!(
517            order_id,
518            symbol, "BinanceMargin forced liquidation record missing/unknown side — skipped"
519        );
520        return None;
521    };
522    let Some(filled_quantity) = row
523        .executed_qty
524        .as_deref()
525        .and_then(|s| Decimal::from_str(s).ok())
526    else {
527        warn!(
528            order_id,
529            symbol,
530            "BinanceMargin forced liquidation record missing/unparseable executedQty — skipped"
531        );
532        return None;
533    };
534    let Some(time_exchange) = row
535        .updated_time
536        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
537    else {
538        warn!(
539            order_id,
540            symbol,
541            "BinanceMargin forced liquidation record missing/unparseable updatedTime — skipped"
542        );
543        return None;
544    };
545    Some(MarginForcedLiquidation {
546        order_id: OrderId(format_smolstr!("{order_id}")),
547        instrument: InstrumentNameExchange::new(symbol),
548        side,
549        price: optional_decimal("forced liquidation", "price", row.price.as_deref()),
550        quantity: optional_decimal("forced liquidation", "qty", row.qty.as_deref()),
551        avg_price: (!filled_quantity.is_zero())
552            .then(|| optional_decimal("forced liquidation", "avgPrice", row.avg_price.as_deref()))
553            .flatten(),
554        filled_quantity,
555        time_exchange,
556    })
557}
558
559#[cfg(test)]
560#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
561mod tests {
562    use super::{super::tests::margin_client_at, *};
563    use crate::error::ApiError;
564    use rust_decimal_macros::dec;
565    use serde_json::{Value, json};
566    use std::collections::HashMap;
567    use wiremock::{
568        Mock, MockServer, ResponseTemplate,
569        matchers::{method, path, query_param},
570    };
571
572    const INTEREST_PATH: &str = "/sapi/v1/margin/interestHistory";
573    const LIQUIDATION_PATH: &str = "/sapi/v1/margin/forceLiquidationRec";
574
575    fn interest_row(tx_id: i64, time: DateTime<Utc>, isolated: Option<&str>) -> Value {
576        let mut row = json!({
577            "txId": tx_id, "interestAccuredTime": time.timestamp_millis(), "asset": "USDT",
578            "rawAsset": "USDT", "principal": "1000", "interest": "0.0125",
579            "interestRate": "0.0000125", "type": "PERIODIC",
580        });
581        if let Some(symbol) = isolated {
582            row["isolatedSymbol"] = json!(symbol);
583        }
584        row
585    }
586
587    fn liquidation_row(
588        order_id: i64,
589        side: &str,
590        filled: &str,
591        time: DateTime<Utc>,
592        is_isolated: bool,
593    ) -> Value {
594        json!({
595            "avgPrice": "60000", "executedQty": filled, "orderId": order_id, "price": "59000",
596            "qty": "0.5", "side": side, "symbol": "BTCUSDT", "timeInForce": "GTC",
597            "isIsolated": is_isolated, "updatedTime": time.timestamp_millis(),
598        })
599    }
600
601    fn page(rows: Vec<Value>) -> ResponseTemplate {
602        let total = rows.len();
603        ResponseTemplate::new(200).set_body_json(json!({ "rows": rows, "total": total }))
604    }
605
606    async fn mount(server: &MockServer, at: &str, response: ResponseTemplate) {
607        Mock::given(method("GET"))
608            .and(path(at))
609            .respond_with(response)
610            .mount(server)
611            .await;
612    }
613
614    /// Answer page `current` of the query at `at` with `response`.
615    async fn mount_page(server: &MockServer, at: &str, current: &str, response: ResponseTemplate) {
616        Mock::given(method("GET"))
617            .and(path(at))
618            .and(query_param("current", current))
619            .respond_with(response)
620            .mount(server)
621            .await;
622    }
623
624    /// The query of every request `server` received, in order.
625    async fn queries(server: &MockServer) -> Vec<HashMap<String, String>> {
626        server
627            .received_requests()
628            .await
629            .unwrap()
630            .iter()
631            .map(|request| request.url.query_pairs().into_owned().collect())
632            .collect()
633    }
634
635    fn millis(query: &HashMap<String, String>, key: &str) -> i64 {
636        query[key].parse().unwrap()
637    }
638
639    #[tokio::test]
640    async fn interest_is_read_window_by_window_as_negative_flows_oldest_first_once_each() {
641        let server = MockServer::start().await;
642        let now = Utc::now();
643        let start = now - TimeDelta::days(40);
644        let boundary = start + TimeDelta::milliseconds(INTEREST_WINDOW_MS);
645        let mut no_interest = interest_row(9, now - TimeDelta::days(2), None);
646        no_interest.as_object_mut().unwrap().remove("interest");
647        let mut bad_rate = interest_row(3, now - TimeDelta::days(3), None);
648        bad_rate["interestRate"] = json!("n/a");
649        // Every window answers with the same rows: one stamped on the windows' boundary, one
650        // outside the span, and one that cannot be a flow.
651        mount(
652            &server,
653            INTEREST_PATH,
654            page(vec![
655                interest_row(2, now - TimeDelta::days(1), None),
656                no_interest,
657                bad_rate,
658                interest_row(4, boundary, None),
659                interest_row(1, now - TimeDelta::days(35), None),
660                interest_row(0, now - TimeDelta::days(50), None),
661            ]),
662        )
663        .await;
664        let client = margin_client_at(&server, false);
665
666        let flows = client
667            .fetch_margin_interest(start, now + TimeDelta::days(5))
668            .await
669            .unwrap();
670
671        let ids: Vec<_> = flows.iter().map(|flow| flow.id.clone().unwrap()).collect();
672        assert_eq!(ids, ["1", "4", "3", "2"].map(CashFlowId::new));
673        let flow = &flows[0];
674        assert_eq!(flow.amount, dec!(-0.0125), "a charge is paid");
675        assert_eq!(flow.asset, AssetNameExchange::new("USDT"));
676        assert_eq!(flow.instrument, None, "a cross charge names no pair");
677        assert_eq!(
678            flow.kind,
679            CashFlowKind::MarginInterest {
680                rate: Some(dec!(0.0000125)),
681                principal: Some(dec!(1000)),
682            }
683        );
684        assert_eq!(
685            flows[2].kind,
686            CashFlowKind::MarginInterest {
687                rate: None,
688                principal: Some(dec!(1000)),
689            },
690            "a rate that does not parse is left out, not the charge"
691        );
692
693        let queries = queries(&server).await;
694        let [first, second] = queries.as_slice() else {
695            panic!("two windows: {queries:?}");
696        };
697        assert_eq!(millis(first, "startTime"), start.timestamp_millis());
698        assert_eq!(millis(first, "endTime"), boundary.timestamp_millis());
699        assert_eq!(millis(second, "startTime"), boundary.timestamp_millis());
700        assert!(
701            millis(second, "endTime") <= Utc::now().timestamp_millis(),
702            "read up to the local clock, not `end`"
703        );
704        for query in [first, second] {
705            assert_eq!(query["current"], "1");
706            assert_eq!(query["size"], "100");
707            assert!(!query.contains_key("isolatedSymbol"), "{query:?}");
708        }
709    }
710
711    #[tokio::test]
712    async fn a_charge_repeated_with_its_id_is_kept_once() {
713        let server = MockServer::start().await;
714        let now = Utc::now();
715        let mut corrected = interest_row(1, now - TimeDelta::hours(1), None);
716        corrected["interest"] = json!("0.02");
717        mount(
718            &server,
719            INTEREST_PATH,
720            page(vec![
721                corrected,
722                interest_row(1, now - TimeDelta::hours(1), None),
723            ]),
724        )
725        .await;
726        let client = margin_client_at(&server, false);
727
728        let flows = client
729            .fetch_margin_interest(now - TimeDelta::days(1), now)
730            .await
731            .unwrap();
732
733        let [flow] = flows.as_slice() else {
734            panic!("one charge: {flows:?}");
735        };
736        assert_eq!(flow.amount, dec!(-0.02), "the first copy read is kept");
737    }
738
739    #[tokio::test]
740    async fn a_span_of_one_instant_is_read_in_one_request() {
741        let server = MockServer::start().await;
742        // In whole milliseconds, as Binance stamps a charge, so the charge is inside the span.
743        let at =
744            DateTime::from_timestamp_millis((Utc::now() - TimeDelta::hours(1)).timestamp_millis())
745                .unwrap();
746        mount(
747            &server,
748            INTEREST_PATH,
749            page(vec![interest_row(1, at, None)]),
750        )
751        .await;
752        let client = margin_client_at(&server, false);
753
754        let flows = client.fetch_margin_interest(at, at).await.unwrap();
755
756        assert_eq!(flows.len(), 1);
757        let queries = queries(&server).await;
758        let [query] = queries.as_slice() else {
759            panic!("one request: {queries:?}");
760        };
761        assert_eq!(millis(query, "startTime"), at.timestamp_millis());
762        assert_eq!(millis(query, "endTime"), at.timestamp_millis());
763    }
764
765    #[tokio::test]
766    async fn a_full_page_reads_the_next() {
767        let server = MockServer::start().await;
768        let now = Utc::now();
769        let time = now - TimeDelta::hours(1);
770        let full = (0..100).map(|id| interest_row(id, time, None)).collect();
771        mount_page(&server, INTEREST_PATH, "1", page(full)).await;
772        mount_page(
773            &server,
774            INTEREST_PATH,
775            "2",
776            page(vec![interest_row(100, time, None)]),
777        )
778        .await;
779        let client = margin_client_at(&server, false);
780
781        let flows = client
782            .fetch_margin_interest(now - TimeDelta::days(1), now)
783            .await
784            .unwrap();
785
786        assert_eq!(flows.len(), 101);
787        assert_eq!(queries(&server).await.len(), 2);
788    }
789
790    #[tokio::test]
791    async fn a_query_still_full_after_the_most_pages_fails() {
792        let server = MockServer::start().await;
793        let now = Utc::now();
794        let time = now - TimeDelta::hours(1);
795        // As were Binance to ignore the page asked for.
796        let full = (0..100).map(|id| interest_row(id, time, None)).collect();
797        mount(&server, INTEREST_PATH, page(full)).await;
798        let client = margin_client_at(&server, false);
799
800        let result = client
801            .fetch_margin_interest(now - TimeDelta::days(1), now)
802            .await;
803
804        assert!(
805            matches!(result, Err(UnindexedClientError::Internal(_))),
806            "{result:?}"
807        );
808        assert_eq!(
809            i64::try_from(queries(&server).await.len()).unwrap(),
810            MAX_PAGES
811        );
812    }
813
814    #[tokio::test]
815    async fn isolated_interest_is_read_per_pair_and_attributed_to_it() {
816        let server = MockServer::start().await;
817        let now = Utc::now();
818        mount(
819            &server,
820            INTEREST_PATH,
821            page(vec![
822                interest_row(2, now - TimeDelta::hours(1), Some("BTCUSDT")),
823                // Binance names the pair on an isolated row; one that does not keeps the pair read.
824                interest_row(1, now - TimeDelta::hours(2), None),
825            ]),
826        )
827        .await;
828        let client = margin_client_at(&server, true);
829
830        let flows = client
831            .fetch_margin_interest(now - TimeDelta::days(1), now)
832            .await
833            .unwrap();
834
835        assert_eq!(flows.len(), 2);
836        for flow in flows {
837            assert_eq!(
838                flow.instrument,
839                Some(InstrumentNameExchange::new("BTCUSDT"))
840            );
841        }
842        let queries = queries(&server).await;
843        assert_eq!(queries.len(), 1);
844        assert_eq!(queries[0]["isolatedSymbol"], "BTCUSDT");
845    }
846
847    #[tokio::test]
848    async fn interest_older_than_binance_serves_is_read_from_its_horizon() {
849        let server = MockServer::start().await;
850        mount(&server, INTEREST_PATH, page(Vec::new())).await;
851        let client = margin_client_at(&server, false);
852        let before = Utc::now();
853
854        let flows = client
855            .fetch_margin_interest(before - TimeDelta::days(200), before)
856            .await
857            .unwrap();
858        let after = Utc::now();
859
860        assert!(flows.is_empty());
861        let queries = queries(&server).await;
862        let first = millis(&queries[0], "startTime");
863        let horizon = |now: DateTime<Utc>| {
864            (now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN).timestamp_millis()
865        };
866        assert!(
867            (horizon(before)..=horizon(after)).contains(&first),
868            "{first}"
869        );
870        assert_eq!(queries.len(), 4, "90 days in windows of 29");
871    }
872
873    #[tokio::test]
874    async fn an_empty_span_reads_nothing() {
875        let server = MockServer::start().await;
876        let client = margin_client_at(&server, false);
877        let now = Utc::now();
878
879        let interest = client
880            .fetch_margin_interest(now, now - TimeDelta::hours(1))
881            .await
882            .unwrap();
883        let liquidations = client
884            .fetch_forced_liquidations(now + TimeDelta::hours(1), now + TimeDelta::hours(2))
885            .await
886            .unwrap();
887
888        assert!(interest.is_empty() && liquidations.is_empty());
889        assert!(queries(&server).await.is_empty());
890    }
891
892    #[tokio::test]
893    async fn a_refused_read_fails_the_whole_read() {
894        let server = MockServer::start().await;
895        let refused =
896            ResponseTemplate::new(400).set_body_json(json!({"code": -1100, "msg": "refused"}));
897        mount(&server, INTEREST_PATH, refused.clone()).await;
898        mount(&server, LIQUIDATION_PATH, refused).await;
899        let client = margin_client_at(&server, false);
900        let now = Utc::now();
901
902        let interest = client
903            .fetch_margin_interest(now - TimeDelta::days(1), now)
904            .await;
905        let liquidations = client
906            .fetch_forced_liquidations(now - TimeDelta::days(1), now)
907            .await;
908
909        assert!(
910            matches!(
911                interest,
912                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
913            ),
914            "{interest:?}"
915        );
916        assert!(
917            matches!(
918                liquidations,
919                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
920            ),
921            "{liquidations:?}"
922        );
923    }
924
925    #[tokio::test]
926    async fn forced_liquidations_of_the_clients_account_are_read_oldest_first_once_each() {
927        for is_isolated in [false, true] {
928            let server = MockServer::start().await;
929            let now = Utc::now();
930            let row = |order_id, side, filled, time| {
931                liquidation_row(order_id, side, filled, time, is_isolated)
932            };
933            let mut no_order_id = row(9, "SELL", "0.5", now);
934            no_order_id.as_object_mut().unwrap().remove("orderId");
935            mount(
936                &server,
937                LIQUIDATION_PATH,
938                page(vec![
939                    row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
940                    row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
941                    no_order_id,
942                    // Marked isolated: not the cross account's, but the isolated pair's.
943                    liquidation_row(8, "SELL", "0.5", now, true),
944                    row(1, "BUY", "0", now - TimeDelta::hours(2)),
945                    row(0, "BUY", "0.5", now - TimeDelta::days(3)),
946                ]),
947            )
948            .await;
949            let client = margin_client_at(&server, is_isolated);
950
951            let liquidations = client
952                .fetch_forced_liquidations(now - TimeDelta::days(1), now)
953                .await
954                .unwrap();
955
956            let (unfilled, filled) = match (is_isolated, liquidations.as_slice()) {
957                (false, [unfilled, filled]) => (unfilled, filled),
958                (true, [unfilled, filled, marked]) => {
959                    assert_eq!(marked.order_id, OrderId::new("8"));
960                    (unfilled, filled)
961                }
962                _ => panic!("is_isolated = {is_isolated}: {liquidations:?}"),
963            };
964            assert_eq!(unfilled.order_id, OrderId::new("1"));
965            assert_eq!(unfilled.side, Side::Buy);
966            assert_eq!(unfilled.avg_price, None, "nothing filled");
967            assert_eq!(
968                *filled,
969                MarginForcedLiquidation {
970                    order_id: OrderId::new("2"),
971                    instrument: InstrumentNameExchange::new("BTCUSDT"),
972                    side: Side::Sell,
973                    price: Some(dec!(59000)),
974                    quantity: Some(dec!(0.5)),
975                    filled_quantity: dec!(0.5),
976                    avg_price: Some(dec!(60000)),
977                    time_exchange: Utc
978                        .timestamp_millis_opt((now - TimeDelta::hours(1)).timestamp_millis())
979                        .unwrap(),
980                }
981            );
982
983            let queries = queries(&server).await;
984            let [query] = queries.as_slice() else {
985                panic!("one page: {queries:?}");
986            };
987            assert_eq!(query["size"], "100");
988            assert_eq!(
989                query.get("isolatedSymbol").map(String::as_str),
990                is_isolated.then_some("BTCUSDT"),
991            );
992        }
993    }
994
995    #[tokio::test]
996    async fn a_full_page_of_forced_liquidations_reads_the_next() {
997        let server = MockServer::start().await;
998        let now = Utc::now();
999        let time = now - TimeDelta::hours(1);
1000        let full = (0..100)
1001            .map(|id| liquidation_row(id, "SELL", "0.5", time, false))
1002            .collect();
1003        mount_page(&server, LIQUIDATION_PATH, "1", page(full)).await;
1004        mount_page(
1005            &server,
1006            LIQUIDATION_PATH,
1007            "2",
1008            page(vec![liquidation_row(100, "SELL", "0.5", time, false)]),
1009        )
1010        .await;
1011        let client = margin_client_at(&server, false);
1012
1013        let liquidations = client
1014            .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1015            .await
1016            .unwrap();
1017
1018        assert_eq!(liquidations.len(), 101);
1019        assert_eq!(queries(&server).await.len(), 2);
1020    }
1021}