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