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