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 super::loans::{LoanLink, Loans};
8use crate::margin::OrderLoan;
9use crate::{
10    cash_flow::{
11        CashFlow, CashFlowId, CashFlowKind, CashFlowPeriod, CashFlowsRead, InterestTiming,
12    },
13    client::binance::shared::{
14        RequestKind, classify_rest_query_error, parse_side, response_decode_error,
15        rest_call_with_retry,
16    },
17    error::UnindexedClientError,
18    order::id::OrderId,
19};
20use binance_sdk::margin_trading::rest_api::{
21    GetForceLiquidationRecordParams, GetForceLiquidationRecordResponseRowsInner,
22    GetInterestHistoryParams, GetInterestHistoryResponseRowsInner,
23};
24use chrono::{DateTime, TimeDelta, TimeZone, Utc};
25use fnv::FnvHashSet;
26use futures::{StreamExt as _, TryStreamExt as _};
27use rust_decimal::Decimal;
28use rustrade_instrument::{
29    Side, asset::name::AssetNameExchange, instrument::name::InstrumentNameExchange,
30};
31use serde::{Deserialize, Serialize};
32use smol_str::{SmolStr, format_smolstr};
33use std::{future::Future, str::FromStr};
34use tracing::{debug, warn};
35
36/// How far back Binance serves interest history.
37const INTEREST_LOOKBACK: TimeDelta = TimeDelta::days(90);
38
39/// Kept clear of [`INTEREST_LOOKBACK`]'s edge, so that a start at the edge is not refused for any
40/// skew between the local clock and Binance's.
41const INTEREST_LOOKBACK_MARGIN: TimeDelta = TimeDelta::minutes(1);
42
43/// Where an interest read asked to start at `start` at `now` starts instead, because Binance no
44/// longer serves charges that old: a minute inside [`INTEREST_LOOKBACK`]. `None` when it serves
45/// `start`.
46fn interest_held_since(start: DateTime<Utc>, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
47    let held_since = now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN;
48    (start < held_since).then_some(held_since)
49}
50
51/// The span of one interest history request. Binance serves at most 30 days per request, and
52/// says the limit is needed for the data to be correct; a day less leaves room for how it counts.
53const INTEREST_WINDOW_MS: i64 = 29 * 24 * 60 * 60 * 1000;
54
55/// The most rows Binance returns in one page of either history.
56const PAGE_SIZE: i64 = 100;
57
58/// The most pages one query is read for: 50,000 rows. A query needing more fails rather than
59/// loop on, as it would were Binance to ignore the page asked for and serve the same full page
60/// each time.
61const MAX_PAGES: i64 = 500;
62
63/// How many isolated pairs are read at once.
64const CONCURRENT_PAIRS: usize = 8;
65
66/// An order Binance placed to liquidate a margin account, as its forced-liquidation record
67/// reports it.
68///
69/// Read by [`BinanceMargin::fetch_forced_liquidations`].
70#[non_exhaustive]
71#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
72pub struct MarginForcedLiquidation {
73    /// Binance's id for the liquidation order.
74    ///
75    /// Binance does not document that a fill of this order reaches the account stream, nor that
76    /// one would carry this id as its order id. Until a real liquidation shows it, matching the
77    /// two is the caller's assumption.
78    pub order_id: OrderId,
79    /// The pair the order traded.
80    pub instrument: InstrumentNameExchange,
81    /// The order's side: [`Sell`](Side::Sell) to close a long, [`Buy`](Side::Buy) a short.
82    pub side: Side,
83    /// The order's limit price, if Binance reports one.
84    pub price: Option<Decimal>,
85    /// The order's quantity, if Binance reports one.
86    pub quantity: Option<Decimal>,
87    /// How much of the order filled.
88    pub filled_quantity: Decimal,
89    /// The average price of the filled quantity. `None` when nothing filled.
90    pub avg_price: Option<Decimal>,
91    /// When Binance last updated the order.
92    pub time_exchange: DateTime<Utc>,
93}
94
95impl BinanceMargin {
96    /// Read the interest Binance charged the account from `start` to `end`, each charge as a
97    /// [`CashFlow`] of [`MarginInterest`](CashFlowKind::MarginInterest), oldest first, in
98    /// [`CashFlowsRead::flows`].
99    ///
100    /// A cross-margin client reads the cross account. An isolated one reads each pair in its
101    /// [`isolated_symbols`](super::BinanceMarginConfig::isolated_symbols), and attributes each
102    /// charge to its pair through [`CashFlow::instrument`]. A cross charge is attributed to none,
103    /// unless it is linked to the loan of one of this client's orders, as below.
104    ///
105    /// # What a charge is
106    /// Binance charges interest hourly, and once when a loan is taken, by adding it to the loan
107    /// rather than taking it from the account's free balance. A charge is cash only once the loan
108    /// is repaid. The flow's negative [`amount`](CashFlow::amount) is the cost charged at
109    /// [`time_exchange`](CashFlow::time_exchange), not a balance that moved then: the account's
110    /// balance shows it as interest owed on the asset.
111    ///
112    /// - [`asset`](CashFlow::asset) is the `asset` field of Binance's record. Binance charges
113    ///   interest in the asset borrowed, or converts it into BNB for an account that pays interest
114    ///   in BNB: a `_CONVERTED` record's `asset` is BNB, so its flow does not name the asset
115    ///   borrowed. Binance names that asset in a separate `rawAsset` field, which this method does
116    ///   not read, and which Binance does not return on an isolated record.
117    /// - [`principal`](CashFlowKind::MarginInterest::principal) is the loan the charge was on. On a
118    ///   `_CONVERTED` record it can read 0, and Binance's documentation does not say when.
119    ///   Binance's support points to its borrow and repay history
120    ///   (`GET /sapi/v1/margin/borrow-repay`) for the loan. A converted record's flow does not
121    ///   carry the asset borrowed, so that history cannot be narrowed to it by asset from the flow
122    ///   alone.
123    /// - [`rate`](CashFlowKind::MarginInterest::rate) is Binance's `interestRate`, a **daily** rate
124    ///   even on an hourly charge: an hourly charge is about the rate divided by 24 of the
125    ///   principal. Binance's API documentation does not state the unit; it is read off the rates
126    ///   Binance publishes on its [margin fee page](https://www.binance.com/en/fee/marginFee),
127    ///   whose hourly figure is the daily one divided by 24 (BTC's daily rate matches the field,
128    ///   its hourly rate is about 24 times smaller).
129    /// - [`timing`](CashFlowKind::MarginInterest::timing) is read from Binance's `type`:
130    ///   `PERIODIC` (and `PERIODIC_CONVERTED`, and portfolio margin's daily `PORTFOLIO`) is
131    ///   [`Periodic`](InterestTiming::Periodic), `ON_BORROW` (and `ON_BORROW_CONVERTED`) is
132    ///   [`OnBorrow`](InterestTiming::OnBorrow), and any other value is
133    ///   [`Other`](InterestTiming::Other) with Binance's own name. `None` when the record has no
134    ///   `type`.
135    /// - [`time_exchange`](CashFlow::time_exchange) is Binance's `interestAccuredTime`, the period
136    ///   the charge is for, not when Binance posted it (see [Delivery](#delivery)): for an hourly
137    ///   charge, the start of its hour. An `ON_BORROW` charge is stamped at the start of the hour
138    ///   the loan was taken in, so it can precede the opening of the position it belongs to.
139    /// - [`period`](CashFlow::period) is that hour, from `interestAccuredTime` to an hour after
140    ///   it, for an hourly charge: `PERIODIC`, `ON_BORROW` and their `_CONVERTED` forms. The engine
141    ///   reads it to give an `ON_BORROW` charge to a position opened later in its hour (see
142    ///   `InstrumentState::update_from_cash_flow` in `rustrade`). `None` for any other `type`,
143    ///   whose period Binance does not document, `PORTFOLIO` included, and, logged at WARN, for an
144    ///   hourly charge not stamped on the hour.
145    /// - [`loan`](CashFlow::loan) is the loan an `ON_BORROW` charge is on, when it is the loan of
146    ///   an order this client placed (see [Linking a charge to its loan](#linking-a-charge-to-its-loan)),
147    ///   and `None` otherwise.
148    /// - [`id`](CashFlow::id) is Binance's transaction id for the charge.
149    ///
150    /// The engine adds a flow attributed to an instrument to the carry of its open position, in
151    /// the slot for the flow's asset: the quote asset's carry, the base asset's, or any other
152    /// asset's. Interest on a short's borrowed base asset is valued at the price the position
153    /// closes at, which is what buying the asset back costs. Interest in BNB, or any asset that is
154    /// neither the instrument's base nor quote, is kept as charged and is in no PnL figure.
155    ///
156    /// # Linking a charge to its loan
157    /// Binance's record of a charge names no order or loan. The client holds the loan each order
158    /// it places reports ([`MarginPlacement::loan`](crate::margin::MarginPlacement::loan)), up to
159    /// 4096, and links an `ON_BORROW` charge to the one held loan of the charge's asset whose
160    /// principal is exactly the charge's `principal` and that was taken in the charge's hour, on
161    /// the same pair for an isolated client. Binance's `principal` has been seen to equal the
162    /// amount the order's answer reported borrowing. A linked charge carries the loan in
163    /// [`loan`](CashFlow::loan) and the order's instrument in [`instrument`](CashFlow::instrument),
164    /// on a cross client too.
165    ///
166    /// - A charge that fits several held loans is linked to none, logged at WARN, and one that fits
167    ///   none, logged at DEBUG.
168    /// - Linking only reads the held loans, so a charge read again is linked again, to the same
169    ///   loan.
170    /// - `ON_BORROW_CONVERTED` charges, in BNB, are not linked: their asset is not the loan's.
171    /// - The held loans do not survive a restart: hand them back with
172    ///   [`recall_loans`](Self::recall_loans), or charges on loans taken before it are linked to
173    ///   none.
174    ///
175    /// # Delivery
176    /// Nothing is pushed: the caller decides when to read, and must drop the charges it has
177    /// already counted, by [`id`](CashFlow::id), before applying a read's flows. Within one read
178    /// each charge appears once: a charge read twice, as one stamped on a window's boundary is, is
179    /// kept the first time its id is seen in its asset and the account it was read from, or,
180    /// without an id, once per identical record before linking.
181    ///
182    /// A cross charge read once linked and once not, as across a restart, names an instrument
183    /// only the first time. Drop a cross client's charges by id within their asset, not their
184    /// [`instrument`](CashFlow::instrument).
185    ///
186    /// A charge is posted some time after the period it is for, by a delay Binance does not
187    /// document and that may vary, and is then served stamped at that period. A read whose span
188    /// ends near the present can therefore miss a charge that a later read returns at a time
189    /// inside the earlier span. A caller that polls by time should start each read before the
190    /// previous read's `end`, by at least the longest posting delay it is prepared to allow for,
191    /// and drop the charges it has already counted by id within their asset and account. A charge
192    /// with no id can be matched only by its whole record, which cannot tell it from an identical
193    /// charge.
194    ///
195    /// The records need not add up to the interest owed. On a live account, a loan was charged
196    /// interest when it was taken, yet a read served only the hourly charge that followed, with no
197    /// `ON_BORROW` record. Whether such a record is posted later, or never, is not known. The
198    /// account's balance, which reports the interest owed (see
199    /// [`MarginDetails::interest`](crate::balance::MarginDetails::interest)), is the figure to rely
200    /// on for what is owed.
201    ///
202    /// # Span
203    /// - Binance serves interest charged in the last 90 days only. A `start` before that is moved
204    ///   up to a minute inside that limit, clear of any skew between the clocks, and
205    ///   [`held_since`](CashFlowsRead::held_since) is where the read then starts: charges before
206    ///   it may exist but were not read. A caller adding up the interest on a position opened
207    ///   before it has only part of it. `held_since` is `None` when `start` is at or after that
208    ///   point, a minute inside the 90 days; a request for the last 90 days reports it.
209    /// - Charges are read up to `end`, or up to the local clock at the call if that is earlier.
210    /// - The whole span is read in one call, so [`resume`](CashFlowsRead::resume) is always
211    ///   `None`.
212    /// - Binance serves at most 30 days per request, so a span is read in windows of 29 days,
213    ///   each paged by 100 charges. A read costs a request weight of 1 per page, at least 1 per
214    ///   window, and, on an isolated client, as much again for each pair.
215    ///
216    /// # Records left out
217    /// A record without an asset, an amount or a time that parses is left out, and logged at WARN.
218    /// A rate or principal that does not parse is left `None`, and logged at WARN.
219    ///
220    /// # Errors
221    /// Fails if any request fails, or if one window of one account holds more than 50,000
222    /// charges. Nothing is returned from a read that failed part way.
223    pub async fn fetch_margin_interest(
224        &self,
225        start: DateTime<Utc>,
226        end: DateTime<Utc>,
227    ) -> Result<CashFlowsRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError>
228    {
229        let now = Utc::now();
230        let held_since = interest_held_since(start, now);
231        if let Some(held_since) = held_since {
232            debug!(
233                requested = %start,
234                %held_since,
235                "BinanceMargin fetch_margin_interest: Binance serves 90 days of interest \
236                 history — reading from the earliest time it serves"
237            );
238        }
239        let start = held_since.unwrap_or(start);
240        let end = end.min(now);
241        if start > end {
242            return Ok(CashFlowsRead::complete(Vec::new()).with_held_since(held_since));
243        }
244        let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
245
246        let rows = self
247            .read_each_account(|pair| self.interest_rows(pair, start_ms, end_ms))
248            .await?;
249
250        let mut seen = FnvHashSet::default();
251        let charges: Vec<_> = rows
252            .into_iter()
253            .flat_map(|(pair, rows)| {
254                rows.into_iter().filter_map(move |row| {
255                    let on_borrow = row.r#type.as_deref() == Some(ON_BORROW);
256                    let flow = convert_interest(row, pair.as_ref())?;
257                    Some((flow, on_borrow.then(|| pair.clone())))
258                })
259            })
260            .filter(|(flow, _)| (start..=end).contains(&flow.time_exchange))
261            // Consecutive windows share their boundary, and a page can repeat a row when a new
262            // charge lands mid-walk and shifts the pages after it. Before linking, which can name
263            // a cross charge's instrument.
264            .filter(|(flow, _)| seen.insert(ChargeKey::of(flow)))
265            .collect();
266        // Fitted under the lock and linked, with any log, after it, so that a slow log holds up
267        // no order placement waiting to hold its loan.
268        let fits: Vec<_> = {
269            let loans = self.loans.lock();
270            charges
271                .iter()
272                .map(|(flow, on_borrow)| {
273                    on_borrow
274                        .as_ref()
275                        .and_then(|pair| LoanFit::of(flow, &loans, pair.as_ref()))
276                })
277                .collect()
278        };
279        let mut flows: Vec<_> = charges
280            .into_iter()
281            .zip(fits)
282            .map(|((flow, on_borrow), fit)| match fit {
283                Some(fit) => fit.link(flow, on_borrow.flatten().as_ref()),
284                None => flow,
285            })
286            .collect();
287        flows.sort_by_key(|flow| flow.time_exchange);
288        Ok(CashFlowsRead::complete(flows).with_held_since(held_since))
289    }
290
291    /// Read the orders Binance placed from `start` to `end` to liquidate the account, oldest
292    /// first.
293    ///
294    /// A cross-margin client reads the cross account. An isolated one reads each pair in its
295    /// [`isolated_symbols`](super::BinanceMarginConfig::isolated_symbols).
296    ///
297    /// Binance marks no liquidation on the account stream, so this client reports a liquidation
298    /// order's fills as [`TradeOrigin::Order`](crate::trade::TradeOrigin::Order). A caller that
299    /// learns the account is being liquidated, as from a margin-level notice, can read these
300    /// records and match their [`order_id`](MarginForcedLiquidation::order_id)s against the fills
301    /// it has, with the caveat on that field.
302    ///
303    /// # Span
304    /// Records are read up to `end`, or up to the local clock at the call if that is earlier, in
305    /// pages of 100. Binance documents no limit on the span or on how far back it serves; a
306    /// two-year span was accepted when tried in October 2026, which is no guarantee. A read
307    /// costs a request weight of 1 per page and, on an isolated client, as much again for each
308    /// pair.
309    ///
310    /// # Records left out
311    /// - A record without an order id, a pair, a side, a filled quantity or a time that parses is
312    ///   left out, and logged at WARN. A price or quantity that does not parse is left `None`, and
313    ///   logged at WARN.
314    /// - On a cross client, a record Binance marks as isolated is left out, and logged at WARN:
315    ///   the cross query should not return one. An isolated client asks for one pair at a time,
316    ///   and keeps what Binance returns for it.
317    ///
318    /// # Errors
319    /// Fails if any request fails, or if one account holds more than 50,000 records in the span.
320    /// Nothing is returned from a read that failed part way.
321    pub async fn fetch_forced_liquidations(
322        &self,
323        start: DateTime<Utc>,
324        end: DateTime<Utc>,
325    ) -> Result<Vec<MarginForcedLiquidation>, UnindexedClientError> {
326        let end = end.min(Utc::now());
327        if start > end {
328            return Ok(Vec::new());
329        }
330        let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
331
332        let is_isolated = self.config.is_isolated;
333        let rows = self
334            .read_each_account(|pair| {
335                read_all_pages(move |current| {
336                    self.forced_liquidation_page(pair.clone(), start_ms, end_ms, current)
337                })
338            })
339            .await?;
340
341        let mut seen = FnvHashSet::default();
342        let mut liquidations: Vec<_> = rows
343            .into_iter()
344            .flat_map(|(_, rows)| rows)
345            .filter_map(|row| convert_forced_liquidation(row, is_isolated))
346            .filter(|liquidation| (start..=end).contains(&liquidation.time_exchange))
347            // A page can repeat a row when a new record lands mid-walk and shifts the pages after
348            // it. Pages run newest first, so the copy kept is the latest.
349            .filter(|liquidation| {
350                seen.insert((liquidation.instrument.clone(), liquidation.order_id.clone()))
351            })
352            .collect();
353        liquidations.sort_by_key(|liquidation| liquidation.time_exchange);
354        Ok(liquidations)
355    }
356
357    /// Run `read` once per account this client covers: once for the cross account (`None`), or
358    /// once per configured isolated pair. Returns each account's rows with the pair.
359    async fn read_each_account<'a, Row, Fut>(
360        &'a self,
361        read: impl Fn(Option<InstrumentNameExchange>) -> Fut,
362    ) -> Result<Vec<(Option<InstrumentNameExchange>, Vec<Row>)>, UnindexedClientError>
363    where
364        Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>> + 'a,
365    {
366        let accounts: Vec<Option<InstrumentNameExchange>> = if self.config.is_isolated {
367            self.config
368                .isolated_symbols
369                .iter()
370                .cloned()
371                .map(Some)
372                .collect()
373        } else {
374            vec![None]
375        };
376        futures::stream::iter(accounts.into_iter().map(|pair| {
377            let rows = read(pair.clone());
378            async move { Ok::<_, UnindexedClientError>((pair, rows.await?)) }
379        }))
380        .buffer_unordered(CONCURRENT_PAIRS)
381        .try_collect()
382        .await
383    }
384
385    /// Every interest row of one account from `start_ms` to `end_ms`, window by window.
386    async fn interest_rows(
387        &self,
388        pair: Option<InstrumentNameExchange>,
389        start_ms: i64,
390        end_ms: i64,
391    ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
392        let mut rows = Vec::new();
393        let mut window_start = start_ms;
394        loop {
395            let window_end = window_start.saturating_add(INTEREST_WINDOW_MS).min(end_ms);
396            let pair = &pair;
397            rows.extend(
398                read_all_pages(|current| {
399                    self.interest_page(pair.clone(), window_start, window_end, current)
400                })
401                .await?,
402            );
403            if window_end >= end_ms {
404                break;
405            }
406            // The next window starts on this one's end, so a charge stamped on the boundary is
407            // read whether Binance treats `endTime` as inclusive or not. The copy is dropped later.
408            window_start = window_end;
409        }
410        debug!(
411            pair = ?pair,
412            rows = rows.len(),
413            "BinanceMargin fetch_margin_interest: account read"
414        );
415        Ok(rows)
416    }
417
418    /// One page of `interestHistory`.
419    async fn interest_page(
420        &self,
421        pair: Option<InstrumentNameExchange>,
422        start_ms: i64,
423        end_ms: i64,
424        current: i64,
425    ) -> Result<Vec<GetInterestHistoryResponseRowsInner>, UnindexedClientError> {
426        let symbol = pair.as_ref().map(|pair| pair.name().to_string());
427        let response =
428            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
429                let symbol = symbol.clone();
430                Box::pin(async move {
431                    let params = GetInterestHistoryParams::builder()
432                        .isolated_symbol(symbol)
433                        .start_time(start_ms)
434                        .end_time(end_ms)
435                        .current(current)
436                        .size(PAGE_SIZE)
437                        .build()?;
438                    rest.get_interest_history(params).await
439                })
440            })
441            .await
442            .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
443        let page = response.data().await.map_err(response_decode_error)?;
444        Ok(page.rows.unwrap_or_default())
445    }
446
447    /// One page of `forceLiquidationRec`.
448    async fn forced_liquidation_page(
449        &self,
450        pair: Option<InstrumentNameExchange>,
451        start_ms: i64,
452        end_ms: i64,
453        current: i64,
454    ) -> Result<Vec<GetForceLiquidationRecordResponseRowsInner>, UnindexedClientError> {
455        let symbol = pair.as_ref().map(|pair| pair.name().to_string());
456        let response =
457            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
458                let symbol = symbol.clone();
459                Box::pin(async move {
460                    let params = GetForceLiquidationRecordParams::builder()
461                        .isolated_symbol(symbol)
462                        .start_time(start_ms)
463                        .end_time(end_ms)
464                        .current(current)
465                        .size(PAGE_SIZE)
466                        .build()?;
467                    rest.get_force_liquidation_record(params).await
468                })
469            })
470            .await
471            .map_err(|e| classify_rest_query_error(&e, pair.as_ref()))?;
472        let page = response.data().await.map_err(response_decode_error)?;
473        Ok(page.rows.unwrap_or_default())
474    }
475}
476
477/// Read pages `1, 2, …` of a paged history query of [`PAGE_SIZE`] rows each, up to the first
478/// short page. Fails once [`MAX_PAGES`] pages were full.
479async fn read_all_pages<Row, Fut>(
480    mut page: impl FnMut(i64) -> Fut,
481) -> Result<Vec<Row>, UnindexedClientError>
482where
483    Fut: Future<Output = Result<Vec<Row>, UnindexedClientError>>,
484{
485    let mut rows = Vec::new();
486    for current in 1..=MAX_PAGES {
487        let batch = page(current).await?;
488        let full = i64::try_from(batch.len()).is_ok_and(|len| len >= PAGE_SIZE);
489        rows.extend(batch);
490        if !full {
491            return Ok(rows);
492        }
493    }
494    Err(UnindexedClientError::Internal(format!(
495        "Binance margin history query still full after {MAX_PAGES} pages of {PAGE_SIZE}: \
496         narrow the span"
497    )))
498}
499
500/// What tells two interest charges in one read apart: Binance's transaction id, in the charge's
501/// asset and pair, or, for a record without one, the whole flow.
502#[derive(PartialEq, Eq, Hash)]
503enum ChargeKey {
504    Id(
505        AssetNameExchange,
506        Option<InstrumentNameExchange>,
507        CashFlowId,
508    ),
509    Flow(CashFlow<AssetNameExchange, InstrumentNameExchange>),
510}
511
512impl ChargeKey {
513    fn of(flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>) -> Self {
514        match &flow.id {
515            Some(id) => Self::Id(flow.asset.clone(), flow.instrument.clone(), id.clone()),
516            None => Self::Flow(flow.clone()),
517        }
518    }
519}
520
521/// An optional decimal field of a `record`: `None` when absent or empty, or, logged at WARN, when
522/// it does not parse.
523fn optional_decimal(record: &str, field: &str, value: Option<&str>) -> Option<Decimal> {
524    let value = value.filter(|value| !value.is_empty())?;
525    Decimal::from_str(value)
526        .inspect_err(|error| {
527            warn!(
528                record,
529                field,
530                value,
531                %error,
532                "BinanceMargin record field does not parse — left out"
533            );
534        })
535        .ok()
536}
537
538/// The [`InterestTiming`] of an `interestHistory` row's `type`. A `_CONVERTED` type is the same
539/// charge settled in BNB, and `PORTFOLIO` is portfolio margin's daily charge on a negative balance.
540fn interest_timing(r#type: &str) -> InterestTiming {
541    match r#type {
542        "PERIODIC" | "PERIODIC_CONVERTED" | "PORTFOLIO" => InterestTiming::Periodic,
543        "ON_BORROW" | "ON_BORROW_CONVERTED" => InterestTiming::OnBorrow,
544        other => InterestTiming::Other(SmolStr::new(other)),
545    }
546}
547
548/// The length of an hourly charge's period.
549const INTEREST_HOUR: TimeDelta = TimeDelta::hours(1);
550
551/// The period of an `interestHistory` row of `type` `r#type` stamped at `time`: the hour from
552/// `time` for an hourly charge, which Binance stamps at the start of its hour. `None` for another
553/// type, whose period Binance does not document, and, logged at WARN, for an hourly charge whose
554/// stamp is not on the hour, since then the stamp is not what this assumes.
555fn interest_period(
556    r#type: Option<&str>,
557    time: DateTime<Utc>,
558    tx_id: Option<i64>,
559) -> Option<CashFlowPeriod> {
560    let hourly = matches!(
561        r#type,
562        Some("PERIODIC" | "PERIODIC_CONVERTED" | "ON_BORROW" | "ON_BORROW_CONVERTED")
563    );
564    if !hourly {
565        return None;
566    }
567    if time
568        .timestamp_millis()
569        .rem_euclid(INTEREST_HOUR.num_milliseconds())
570        != 0
571    {
572        warn!(
573            ?tx_id,
574            r#type,
575            %time,
576            "BinanceMargin hourly interest record not stamped on the hour — no period given"
577        );
578        return None;
579    }
580    CashFlowPeriod::new(time, time + INTEREST_HOUR)
581}
582
583/// The `interestHistory` `type` of the charge on a new loan, in the asset borrowed. Its
584/// `_CONVERTED` form is in BNB, so its asset is not the loan's.
585const ON_BORROW: &str = "ON_BORROW";
586
587/// What an `ON_BORROW` charge fits among the held loans, owned so that the charge is linked, and
588/// any log written, once the loans are unlocked.
589struct LoanFit {
590    principal: Decimal,
591    period: CashFlowPeriod,
592    fit: Fit,
593}
594
595enum Fit {
596    One(OrderLoan),
597    Several(usize),
598    Unheld,
599}
600
601impl LoanFit {
602    /// What `flow` fits among `loans`, or `None` for a charge without the principal and period
603    /// it is fitted by. `pair` is the isolated pair the charge was read for, if any.
604    fn of(
605        flow: &CashFlow<AssetNameExchange, InstrumentNameExchange>,
606        loans: &Loans,
607        pair: Option<&InstrumentNameExchange>,
608    ) -> Option<Self> {
609        let (
610            CashFlowKind::MarginInterest {
611                principal: Some(principal),
612                ..
613            },
614            Some(period),
615        ) = (&flow.kind, flow.period)
616        else {
617            return None;
618        };
619        let fit = match loans.link(&flow.asset, *principal, period, pair) {
620            LoanLink::One(loan) => Fit::One(loan.clone()),
621            LoanLink::Ambiguous(fits) => Fit::Several(fits),
622            LoanLink::None => Fit::Unheld,
623        };
624        Some(Self {
625            principal: *principal,
626            period,
627            fit,
628        })
629    }
630
631    /// `flow`, an `ON_BORROW` charge read from `pair`'s account (`None` for cross), linked to the
632    /// held loan it is the charge on, if exactly one fits: with that loan in [`CashFlow::loan`]
633    /// and its order's instrument in [`CashFlow::instrument`]. Unchanged when none fits, logged at
634    /// DEBUG, or when several do, logged at WARN.
635    fn link(
636        self,
637        mut flow: CashFlow<AssetNameExchange, InstrumentNameExchange>,
638        pair: Option<&InstrumentNameExchange>,
639    ) -> CashFlow<AssetNameExchange, InstrumentNameExchange> {
640        let Self {
641            principal,
642            period,
643            fit,
644        } = self;
645        match fit {
646            Fit::One(loan) => {
647                flow.instrument = Some(loan.instrument);
648                flow.loan = Some(loan.loan);
649            }
650            Fit::Several(fits) => warn!(
651                id = ?flow.id,
652                asset = %flow.asset,
653                %principal,
654                period_start = %period.start(),
655                ?pair,
656                fits,
657                "BinanceMargin ON_BORROW charge fits several held loans — linked to none"
658            ),
659            Fit::Unheld => debug!(
660                id = ?flow.id,
661                asset = %flow.asset,
662                %principal,
663                period_start = %period.start(),
664                ?pair,
665                "BinanceMargin ON_BORROW charge fits no held loan — linked to none"
666            ),
667        }
668        flow
669    }
670}
671
672/// One `interestHistory` row as a [`CashFlow`], or `None`, with a warning, if it lacks what a flow
673/// needs. `pair` is the isolated pair the row was read for, if any.
674fn convert_interest(
675    row: GetInterestHistoryResponseRowsInner,
676    pair: Option<&InstrumentNameExchange>,
677) -> Option<CashFlow<AssetNameExchange, InstrumentNameExchange>> {
678    let tx_id = row.tx_id;
679    let Some(asset) = row.asset.as_deref() else {
680        warn!(
681            ?tx_id,
682            ?pair,
683            "BinanceMargin interest record missing asset — skipped"
684        );
685        return None;
686    };
687    let Some(interest) = row
688        .interest
689        .as_deref()
690        .and_then(|s| Decimal::from_str(s).ok())
691    else {
692        warn!(
693            ?tx_id,
694            ?pair,
695            "BinanceMargin interest record missing/unparseable interest — skipped"
696        );
697        return None;
698    };
699    let Some(time_exchange) = row
700        .interest_accured_time
701        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
702    else {
703        warn!(
704            ?tx_id,
705            ?pair,
706            "BinanceMargin interest record missing/unparseable time — skipped"
707        );
708        return None;
709    };
710    let instrument = row
711        .isolated_symbol
712        .as_deref()
713        .map(InstrumentNameExchange::new)
714        .or_else(|| pair.cloned());
715    let period = interest_period(row.r#type.as_deref(), time_exchange, tx_id);
716    Some(
717        CashFlow::new(
718            CashFlowKind::MarginInterest {
719                rate: optional_decimal("interest", "interestRate", row.interest_rate.as_deref()),
720                principal: optional_decimal("interest", "principal", row.principal.as_deref()),
721                timing: row.r#type.as_deref().map(interest_timing),
722            },
723            AssetNameExchange::new(asset),
724            -interest,
725            instrument,
726            time_exchange,
727            tx_id.map(|id| CashFlowId(format_smolstr!("{id}"))),
728        )
729        .with_period(period),
730    )
731}
732
733/// One `forceLiquidationRec` row as a [`MarginForcedLiquidation`], or `None`, with a warning, if
734/// it lacks what one needs. `None` too, logged at WARN, on a cross client (`is_isolated` false)
735/// when Binance marks the row as isolated.
736fn convert_forced_liquidation(
737    row: GetForceLiquidationRecordResponseRowsInner,
738    is_isolated: bool,
739) -> Option<MarginForcedLiquidation> {
740    let Some(order_id) = row.order_id else {
741        warn!(symbol = ?row.symbol, "BinanceMargin forced liquidation record missing orderId — skipped");
742        return None;
743    };
744    let Some(symbol) = row.symbol.as_deref() else {
745        warn!(
746            order_id,
747            "BinanceMargin forced liquidation record missing symbol — skipped"
748        );
749        return None;
750    };
751    // An isolated client's query names its pair, so what it returns is that pair's. A cross
752    // query names none, so an isolated pair's record there is not the cross account's.
753    if !is_isolated && row.is_isolated == Some(true) {
754        warn!(
755            order_id,
756            symbol,
757            "BinanceMargin cross forced liquidation read returned an isolated record — left out"
758        );
759        return None;
760    }
761    let Some(side) = row.side.as_deref().and_then(parse_side) else {
762        warn!(
763            order_id,
764            symbol, "BinanceMargin forced liquidation record missing/unknown side — skipped"
765        );
766        return None;
767    };
768    let Some(filled_quantity) = row
769        .executed_qty
770        .as_deref()
771        .and_then(|s| Decimal::from_str(s).ok())
772    else {
773        warn!(
774            order_id,
775            symbol,
776            "BinanceMargin forced liquidation record missing/unparseable executedQty — skipped"
777        );
778        return None;
779    };
780    let Some(time_exchange) = row
781        .updated_time
782        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
783    else {
784        warn!(
785            order_id,
786            symbol,
787            "BinanceMargin forced liquidation record missing/unparseable updatedTime — skipped"
788        );
789        return None;
790    };
791    Some(MarginForcedLiquidation {
792        order_id: OrderId(format_smolstr!("{order_id}")),
793        instrument: InstrumentNameExchange::new(symbol),
794        side,
795        price: optional_decimal("forced liquidation", "price", row.price.as_deref()),
796        quantity: optional_decimal("forced liquidation", "qty", row.qty.as_deref()),
797        avg_price: (!filled_quantity.is_zero())
798            .then(|| optional_decimal("forced liquidation", "avgPrice", row.avg_price.as_deref()))
799            .flatten(),
800        filled_quantity,
801        time_exchange,
802    })
803}
804
805#[cfg(test)]
806#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
807mod tests {
808    use super::{super::tests::margin_client_at, *};
809    use crate::error::ApiError;
810    use chrono::DurationRound;
811    use rust_decimal_macros::dec;
812    use serde_json::{Value, json};
813    use std::collections::HashMap;
814    use wiremock::{
815        Mock, MockServer, ResponseTemplate,
816        matchers::{method, path, query_param},
817    };
818
819    const INTEREST_PATH: &str = "/sapi/v1/margin/interestHistory";
820    const LIQUIDATION_PATH: &str = "/sapi/v1/margin/forceLiquidationRec";
821
822    fn interest_row(tx_id: i64, time: DateTime<Utc>, isolated: Option<&str>) -> Value {
823        let mut row = json!({
824            "txId": tx_id, "interestAccuredTime": time.timestamp_millis(), "asset": "USDT",
825            "rawAsset": "USDT", "principal": "1000", "interest": "0.0125",
826            "interestRate": "0.0000125", "type": "PERIODIC",
827        });
828        if let Some(symbol) = isolated {
829            row["isolatedSymbol"] = json!(symbol);
830        }
831        row
832    }
833
834    fn liquidation_row(
835        order_id: i64,
836        side: &str,
837        filled: &str,
838        time: DateTime<Utc>,
839        is_isolated: bool,
840    ) -> Value {
841        json!({
842            "avgPrice": "60000", "executedQty": filled, "orderId": order_id, "price": "59000",
843            "qty": "0.5", "side": side, "symbol": "BTCUSDT", "timeInForce": "GTC",
844            "isIsolated": is_isolated, "updatedTime": time.timestamp_millis(),
845        })
846    }
847
848    fn page(rows: Vec<Value>) -> ResponseTemplate {
849        let total = rows.len();
850        ResponseTemplate::new(200).set_body_json(json!({ "rows": rows, "total": total }))
851    }
852
853    async fn mount(server: &MockServer, at: &str, response: ResponseTemplate) {
854        Mock::given(method("GET"))
855            .and(path(at))
856            .respond_with(response)
857            .mount(server)
858            .await;
859    }
860
861    /// Answer page `current` of the query at `at` with `response`.
862    async fn mount_page(server: &MockServer, at: &str, current: &str, response: ResponseTemplate) {
863        Mock::given(method("GET"))
864            .and(path(at))
865            .and(query_param("current", current))
866            .respond_with(response)
867            .mount(server)
868            .await;
869    }
870
871    /// The query of every request `server` received, in order.
872    async fn queries(server: &MockServer) -> Vec<HashMap<String, String>> {
873        server
874            .received_requests()
875            .await
876            .unwrap()
877            .iter()
878            .map(|request| request.url.query_pairs().into_owned().collect())
879            .collect()
880    }
881
882    fn millis(query: &HashMap<String, String>, key: &str) -> i64 {
883        query[key].parse().unwrap()
884    }
885
886    #[tokio::test]
887    async fn interest_is_read_window_by_window_as_negative_flows_oldest_first_once_each() {
888        let server = MockServer::start().await;
889        let now = Utc::now();
890        let start = now - TimeDelta::days(40);
891        let boundary = start + TimeDelta::milliseconds(INTEREST_WINDOW_MS);
892        let mut no_interest = interest_row(9, now - TimeDelta::days(2), None);
893        no_interest.as_object_mut().unwrap().remove("interest");
894        let mut bad_rate = interest_row(3, now - TimeDelta::days(3), None);
895        bad_rate["interestRate"] = json!("n/a");
896        // Every window answers with the same rows: one stamped on the windows' boundary, one
897        // outside the span, and one that cannot be a flow.
898        mount(
899            &server,
900            INTEREST_PATH,
901            page(vec![
902                interest_row(2, now - TimeDelta::days(1), None),
903                no_interest,
904                bad_rate,
905                interest_row(4, boundary, None),
906                interest_row(1, now - TimeDelta::days(35), None),
907                interest_row(0, now - TimeDelta::days(50), None),
908            ]),
909        )
910        .await;
911        let client = margin_client_at(&server, false);
912
913        let read = client
914            .fetch_margin_interest(start, now + TimeDelta::days(5))
915            .await
916            .unwrap();
917        assert_eq!((read.resume, read.held_since), (None, None));
918        let flows = read.flows;
919
920        let ids: Vec<_> = flows.iter().map(|flow| flow.id.clone().unwrap()).collect();
921        assert_eq!(ids, ["1", "4", "3", "2"].map(CashFlowId::new));
922        let flow = &flows[0];
923        assert_eq!(flow.amount, dec!(-0.0125), "a charge is paid");
924        assert_eq!(flow.asset, AssetNameExchange::new("USDT"));
925        assert_eq!(flow.instrument, None, "a cross charge names no pair");
926        assert_eq!(
927            flow.kind,
928            CashFlowKind::MarginInterest {
929                rate: Some(dec!(0.0000125)),
930                principal: Some(dec!(1000)),
931                timing: Some(InterestTiming::Periodic),
932            }
933        );
934        assert_eq!(
935            flows[2].kind,
936            CashFlowKind::MarginInterest {
937                rate: None,
938                principal: Some(dec!(1000)),
939                timing: Some(InterestTiming::Periodic),
940            },
941            "a rate that does not parse is left out, not the charge"
942        );
943
944        let queries = queries(&server).await;
945        let [first, second] = queries.as_slice() else {
946            panic!("two windows: {queries:?}");
947        };
948        assert_eq!(millis(first, "startTime"), start.timestamp_millis());
949        assert_eq!(millis(first, "endTime"), boundary.timestamp_millis());
950        assert_eq!(millis(second, "startTime"), boundary.timestamp_millis());
951        assert!(
952            millis(second, "endTime") <= Utc::now().timestamp_millis(),
953            "read up to the local clock, not `end`"
954        );
955        for query in [first, second] {
956            assert_eq!(query["current"], "1");
957            assert_eq!(query["size"], "100");
958            assert!(!query.contains_key("isolatedSymbol"), "{query:?}");
959        }
960    }
961
962    #[test]
963    fn interest_timing_is_read_from_the_record_type() {
964        for (r#type, expected) in [
965            ("PERIODIC", InterestTiming::Periodic),
966            ("PERIODIC_CONVERTED", InterestTiming::Periodic),
967            ("PORTFOLIO", InterestTiming::Periodic),
968            ("ON_BORROW", InterestTiming::OnBorrow),
969            ("ON_BORROW_CONVERTED", InterestTiming::OnBorrow),
970            (
971                "ON_LIQUIDATION",
972                InterestTiming::Other("ON_LIQUIDATION".into()),
973            ),
974        ] {
975            assert_eq!(interest_timing(r#type), expected, "{type}");
976        }
977    }
978
979    #[test]
980    fn an_hourly_charge_has_the_hour_from_its_stamp_as_its_period() {
981        let hour = Utc.with_ymd_and_hms(2026, 10, 22, 9, 0, 0).unwrap();
982        for r#type in [
983            "PERIODIC",
984            "PERIODIC_CONVERTED",
985            "ON_BORROW",
986            "ON_BORROW_CONVERTED",
987        ] {
988            assert_eq!(
989                interest_period(Some(r#type), hour, Some(1)),
990                CashFlowPeriod::new(hour, hour + TimeDelta::hours(1)),
991                "{type}"
992            );
993        }
994    }
995
996    #[test]
997    fn a_charge_of_another_type_or_off_the_hour_has_no_period() {
998        let hour = Utc.with_ymd_and_hms(2026, 10, 22, 9, 0, 0).unwrap();
999        for r#type in [Some("PORTFOLIO"), Some("ON_LIQUIDATION"), None] {
1000            assert_eq!(interest_period(r#type, hour, Some(1)), None, "{type:?}");
1001        }
1002        for off in [TimeDelta::milliseconds(1), TimeDelta::minutes(31)] {
1003            assert_eq!(
1004                interest_period(Some("ON_BORROW"), hour + off, Some(1)),
1005                None,
1006                "{off}"
1007            );
1008        }
1009    }
1010
1011    #[tokio::test]
1012    async fn a_charge_on_borrow_read_from_binance_has_its_hour_as_its_period() {
1013        let server = MockServer::start().await;
1014        let now = Utc::now();
1015        // An hour divides a day, so truncating to one cannot fail.
1016        let hour = now.duration_trunc(TimeDelta::hours(1)).unwrap() - TimeDelta::hours(2);
1017        let mut row = interest_row(1, hour, None);
1018        row["type"] = json!("ON_BORROW");
1019        mount(&server, INTEREST_PATH, page(vec![row])).await;
1020        let client = margin_client_at(&server, false);
1021
1022        let flows = client
1023            .fetch_margin_interest(now - TimeDelta::days(1), now)
1024            .await
1025            .unwrap()
1026            .flows;
1027
1028        assert_eq!(flows.len(), 1);
1029        assert_eq!(flows[0].time_exchange, hour);
1030        assert_eq!(
1031            flows[0].period,
1032            CashFlowPeriod::new(hour, hour + TimeDelta::hours(1))
1033        );
1034    }
1035
1036    #[tokio::test]
1037    async fn a_charge_on_borrow_has_its_timing() {
1038        let server = MockServer::start().await;
1039        let now = Utc::now();
1040        let mut row = interest_row(1, now - TimeDelta::hours(1), None);
1041        row["type"] = json!("ON_BORROW");
1042        mount(&server, INTEREST_PATH, page(vec![row])).await;
1043        let client = margin_client_at(&server, false);
1044
1045        let flows = client
1046            .fetch_margin_interest(now - TimeDelta::days(1), now)
1047            .await
1048            .unwrap()
1049            .flows;
1050
1051        let [flow] = flows.as_slice() else {
1052            panic!("one flow: {flows:?}");
1053        };
1054        let CashFlowKind::MarginInterest { timing, .. } = &flow.kind else {
1055            panic!("margin interest: {flow:?}");
1056        };
1057        assert_eq!(*timing, Some(InterestTiming::OnBorrow));
1058    }
1059
1060    /// A charge converted into BNB is in BNB, from `asset` rather than the `rawAsset` borrowed,
1061    /// and its principal of 0 is read as served.
1062    #[tokio::test]
1063    async fn a_charge_converted_into_bnb_is_in_bnb() {
1064        let server = MockServer::start().await;
1065        let now = Utc::now();
1066        let mut row = interest_row(1, now - TimeDelta::hours(1), None);
1067        row["asset"] = json!("BNB");
1068        row["principal"] = json!("0");
1069        row["interest"] = json!("0.00002");
1070        row["type"] = json!("PERIODIC_CONVERTED");
1071        mount(&server, INTEREST_PATH, page(vec![row])).await;
1072        let client = margin_client_at(&server, false);
1073
1074        let flows = client
1075            .fetch_margin_interest(now - TimeDelta::days(1), now)
1076            .await
1077            .unwrap()
1078            .flows;
1079
1080        let [flow] = flows.as_slice() else {
1081            panic!("one flow: {flows:?}");
1082        };
1083        assert_eq!(flow.asset, AssetNameExchange::new("BNB"));
1084        assert_eq!(flow.amount, dec!(-0.00002));
1085        let CashFlowKind::MarginInterest {
1086            principal, timing, ..
1087        } = &flow.kind
1088        else {
1089            panic!("margin interest: {flow:?}");
1090        };
1091        assert_eq!(*principal, Some(Decimal::ZERO));
1092        assert_eq!(*timing, Some(InterestTiming::Periodic));
1093    }
1094
1095    /// A loan the client holds: `amount` of `asset` on `pair`, taken at `time` by order `order`.
1096    fn held_loan(
1097        order: &str,
1098        pair: &str,
1099        asset: &str,
1100        amount: Decimal,
1101        time: DateTime<Utc>,
1102    ) -> crate::margin::OrderLoan {
1103        crate::margin::OrderLoan::new(
1104            InstrumentNameExchange::new(pair),
1105            crate::margin::MarginBorrow::new(AssetNameExchange::new(asset), amount),
1106            crate::cash_flow::CashFlowLoan::new(
1107                time,
1108                OrderId::new(order),
1109                crate::order::id::ClientOrderId::new(format!("cid-{order}")),
1110            ),
1111        )
1112    }
1113
1114    /// `interest_row` of `type` `kind` on a loan of `principal` of `asset`.
1115    fn charge_row(
1116        tx_id: i64,
1117        hour: DateTime<Utc>,
1118        kind: &str,
1119        asset: &str,
1120        principal: &str,
1121    ) -> Value {
1122        let mut row = interest_row(tx_id, hour, None);
1123        row["type"] = json!(kind);
1124        row["asset"] = json!(asset);
1125        row["principal"] = json!(principal);
1126        row
1127    }
1128
1129    /// An hour that ended at least an hour before now.
1130    fn past_hour() -> (DateTime<Utc>, DateTime<Utc>) {
1131        let now = Utc::now();
1132        // An hour divides a day, so truncating to one cannot fail.
1133        (
1134            now.duration_trunc(TimeDelta::hours(1)).unwrap() - TimeDelta::hours(2),
1135            now,
1136        )
1137    }
1138
1139    /// A cross `ON_BORROW` charge is linked to the held loan of its asset and principal taken in
1140    /// its hour, and named by its order's instrument. Other charges are linked to nothing: a
1141    /// `PERIODIC` one, a converted one, and one whose loan is not held. A second read links the
1142    /// same charge again.
1143    #[tokio::test]
1144    async fn a_cross_charge_on_borrow_is_linked_to_the_held_loan_it_is_on() {
1145        let server = MockServer::start().await;
1146        let (hour, now) = past_hour();
1147        mount(
1148            &server,
1149            INTEREST_PATH,
1150            page(vec![
1151                charge_row(1, hour, "ON_BORROW", "USDT", "6.23292076"),
1152                charge_row(2, hour, "PERIODIC", "USDT", "6.23292076"),
1153                charge_row(3, hour, "ON_BORROW_CONVERTED", "USDT", "6.23292076"),
1154                charge_row(4, hour, "ON_BORROW", "USDT", "9"),
1155            ]),
1156        )
1157        .await;
1158        let client = margin_client_at(&server, false);
1159        let loan = held_loan(
1160            "11",
1161            "BTCUSDT",
1162            "USDT",
1163            dec!(6.23292076),
1164            hour + TimeDelta::minutes(5),
1165        );
1166        client.recall_loans([loan.clone()]);
1167
1168        for _ in 0..2 {
1169            let flows = client
1170                .fetch_margin_interest(now - TimeDelta::days(1), now)
1171                .await
1172                .unwrap()
1173                .flows;
1174
1175            let by_id = |id: &str| {
1176                flows
1177                    .iter()
1178                    .find(|flow| flow.id == Some(CashFlowId::new(id)))
1179                    .unwrap()
1180            };
1181            let linked = by_id("1");
1182            assert_eq!(linked.loan, Some(loan.loan.clone()));
1183            assert_eq!(linked.instrument, Some(loan.instrument.clone()));
1184            for id in ["2", "3", "4"] {
1185                assert_eq!(by_id(id).loan, None, "{id}");
1186                assert_eq!(by_id(id).instrument, None, "{id}");
1187            }
1188        }
1189    }
1190
1191    /// A charge that fits two held loans is linked to neither, and one whose loan was taken in
1192    /// another hour is linked to none.
1193    #[tokio::test]
1194    async fn a_charge_on_borrow_fitting_several_loans_or_none_is_linked_to_none() {
1195        let server = MockServer::start().await;
1196        let (hour, now) = past_hour();
1197        mount(
1198            &server,
1199            INTEREST_PATH,
1200            page(vec![
1201                charge_row(1, hour, "ON_BORROW", "USDT", "6"),
1202                charge_row(2, hour, "ON_BORROW", "USDT", "7"),
1203            ]),
1204        )
1205        .await;
1206        let client = margin_client_at(&server, false);
1207        client.recall_loans([
1208            held_loan(
1209                "11",
1210                "BTCUSDT",
1211                "USDT",
1212                dec!(6),
1213                hour + TimeDelta::minutes(5),
1214            ),
1215            held_loan(
1216                "12",
1217                "ETHUSDT",
1218                "USDT",
1219                dec!(6),
1220                hour + TimeDelta::minutes(6),
1221            ),
1222            held_loan("13", "BTCUSDT", "USDT", dec!(7), hour + TimeDelta::hours(1)),
1223        ]);
1224
1225        let flows = client
1226            .fetch_margin_interest(now - TimeDelta::days(1), now)
1227            .await
1228            .unwrap()
1229            .flows;
1230
1231        assert_eq!(flows.len(), 2);
1232        for flow in flows {
1233            assert_eq!(flow.loan, None, "{flow:?}");
1234            assert_eq!(flow.instrument, None, "{flow:?}");
1235        }
1236    }
1237
1238    /// An isolated pair's `ON_BORROW` charge is linked only to a held loan on that pair.
1239    #[tokio::test]
1240    async fn an_isolated_charge_on_borrow_is_linked_to_a_loan_on_its_pair() {
1241        let server = MockServer::start().await;
1242        let (hour, now) = past_hour();
1243        let mut row = charge_row(1, hour, "ON_BORROW", "BTC", "0.00007");
1244        row["isolatedSymbol"] = json!("BTCUSDT");
1245        mount(&server, INTEREST_PATH, page(vec![row])).await;
1246        let client = margin_client_at(&server, true);
1247        let loan = held_loan("11", "BTCUSDT", "BTC", dec!(0.00007), hour);
1248        client.recall_loans([
1249            loan.clone(),
1250            held_loan("12", "ETHBTC", "BTC", dec!(0.00007), hour),
1251        ]);
1252
1253        let flows = client
1254            .fetch_margin_interest(now - TimeDelta::days(1), now)
1255            .await
1256            .unwrap()
1257            .flows;
1258
1259        let [flow] = flows.as_slice() else {
1260            panic!("one flow: {flows:?}");
1261        };
1262        assert_eq!(flow.loan, Some(loan.loan));
1263        assert_eq!(flow.instrument, Some(loan.instrument));
1264    }
1265
1266    #[tokio::test]
1267    async fn a_charge_without_a_type_has_no_timing() {
1268        let server = MockServer::start().await;
1269        let now = Utc::now();
1270        let mut row = interest_row(1, now - TimeDelta::hours(1), None);
1271        row.as_object_mut().unwrap().remove("type");
1272        mount(&server, INTEREST_PATH, page(vec![row])).await;
1273        let client = margin_client_at(&server, false);
1274
1275        let flows = client
1276            .fetch_margin_interest(now - TimeDelta::days(1), now)
1277            .await
1278            .unwrap()
1279            .flows;
1280
1281        let [flow] = flows.as_slice() else {
1282            panic!("one flow: {flows:?}");
1283        };
1284        let CashFlowKind::MarginInterest { timing, .. } = &flow.kind else {
1285            panic!("margin interest: {flow:?}");
1286        };
1287        assert_eq!(*timing, None);
1288    }
1289
1290    #[tokio::test]
1291    async fn a_charge_repeated_with_its_id_is_kept_once() {
1292        let server = MockServer::start().await;
1293        let now = Utc::now();
1294        let mut corrected = interest_row(1, now - TimeDelta::hours(1), None);
1295        corrected["interest"] = json!("0.02");
1296        mount(
1297            &server,
1298            INTEREST_PATH,
1299            page(vec![
1300                corrected,
1301                interest_row(1, now - TimeDelta::hours(1), None),
1302            ]),
1303        )
1304        .await;
1305        let client = margin_client_at(&server, false);
1306
1307        let flows = client
1308            .fetch_margin_interest(now - TimeDelta::days(1), now)
1309            .await
1310            .unwrap()
1311            .flows;
1312
1313        let [flow] = flows.as_slice() else {
1314            panic!("one charge: {flows:?}");
1315        };
1316        assert_eq!(flow.amount, dec!(-0.02), "the first copy read is kept");
1317    }
1318
1319    #[tokio::test]
1320    async fn a_span_of_one_instant_is_read_in_one_request() {
1321        let server = MockServer::start().await;
1322        // In whole milliseconds, as Binance stamps a charge, so the charge is inside the span.
1323        let at =
1324            DateTime::from_timestamp_millis((Utc::now() - TimeDelta::hours(1)).timestamp_millis())
1325                .unwrap();
1326        mount(
1327            &server,
1328            INTEREST_PATH,
1329            page(vec![interest_row(1, at, None)]),
1330        )
1331        .await;
1332        let client = margin_client_at(&server, false);
1333
1334        let flows = client.fetch_margin_interest(at, at).await.unwrap().flows;
1335
1336        assert_eq!(flows.len(), 1);
1337        let queries = queries(&server).await;
1338        let [query] = queries.as_slice() else {
1339            panic!("one request: {queries:?}");
1340        };
1341        assert_eq!(millis(query, "startTime"), at.timestamp_millis());
1342        assert_eq!(millis(query, "endTime"), at.timestamp_millis());
1343    }
1344
1345    #[tokio::test]
1346    async fn a_full_page_reads_the_next() {
1347        let server = MockServer::start().await;
1348        let now = Utc::now();
1349        let time = now - TimeDelta::hours(1);
1350        let full = (0..100).map(|id| interest_row(id, time, None)).collect();
1351        mount_page(&server, INTEREST_PATH, "1", page(full)).await;
1352        mount_page(
1353            &server,
1354            INTEREST_PATH,
1355            "2",
1356            page(vec![interest_row(100, time, None)]),
1357        )
1358        .await;
1359        let client = margin_client_at(&server, false);
1360
1361        let flows = client
1362            .fetch_margin_interest(now - TimeDelta::days(1), now)
1363            .await
1364            .unwrap()
1365            .flows;
1366
1367        assert_eq!(flows.len(), 101);
1368        assert_eq!(queries(&server).await.len(), 2);
1369    }
1370
1371    #[tokio::test]
1372    async fn a_query_still_full_after_the_most_pages_fails() {
1373        let server = MockServer::start().await;
1374        let now = Utc::now();
1375        let time = now - TimeDelta::hours(1);
1376        // As were Binance to ignore the page asked for.
1377        let full = (0..100).map(|id| interest_row(id, time, None)).collect();
1378        mount(&server, INTEREST_PATH, page(full)).await;
1379        let client = margin_client_at(&server, false);
1380
1381        let result = client
1382            .fetch_margin_interest(now - TimeDelta::days(1), now)
1383            .await;
1384
1385        assert!(
1386            matches!(result, Err(UnindexedClientError::Internal(_))),
1387            "{result:?}"
1388        );
1389        assert_eq!(
1390            i64::try_from(queries(&server).await.len()).unwrap(),
1391            MAX_PAGES
1392        );
1393    }
1394
1395    #[tokio::test]
1396    async fn isolated_interest_is_read_per_pair_and_attributed_to_it() {
1397        let server = MockServer::start().await;
1398        let now = Utc::now();
1399        mount(
1400            &server,
1401            INTEREST_PATH,
1402            page(vec![
1403                interest_row(2, now - TimeDelta::hours(1), Some("BTCUSDT")),
1404                // Binance names the pair on an isolated row; one that does not keeps the pair read.
1405                interest_row(1, now - TimeDelta::hours(2), None),
1406            ]),
1407        )
1408        .await;
1409        let client = margin_client_at(&server, true);
1410
1411        let flows = client
1412            .fetch_margin_interest(now - TimeDelta::days(1), now)
1413            .await
1414            .unwrap()
1415            .flows;
1416
1417        assert_eq!(flows.len(), 2);
1418        for flow in flows {
1419            assert_eq!(
1420                flow.instrument,
1421                Some(InstrumentNameExchange::new("BTCUSDT"))
1422            );
1423        }
1424        let queries = queries(&server).await;
1425        assert_eq!(queries.len(), 1);
1426        assert_eq!(queries[0]["isolatedSymbol"], "BTCUSDT");
1427    }
1428
1429    #[tokio::test]
1430    async fn interest_older_than_binance_serves_is_read_from_its_horizon() {
1431        let server = MockServer::start().await;
1432        mount(&server, INTEREST_PATH, page(Vec::new())).await;
1433        let client = margin_client_at(&server, false);
1434        let before = Utc::now();
1435
1436        let read = client
1437            .fetch_margin_interest(before - TimeDelta::days(200), before)
1438            .await
1439            .unwrap();
1440        let after = Utc::now();
1441
1442        assert!(read.flows.is_empty());
1443        let queries = queries(&server).await;
1444        let first = millis(&queries[0], "startTime");
1445        let horizon = |now: DateTime<Utc>| {
1446            (now - INTEREST_LOOKBACK + INTEREST_LOOKBACK_MARGIN).timestamp_millis()
1447        };
1448        assert!(
1449            (horizon(before)..=horizon(after)).contains(&first),
1450            "{first}"
1451        );
1452        assert_eq!(
1453            read.held_since
1454                .map(|held_since| held_since.timestamp_millis()),
1455            Some(first),
1456            "held_since is where the read started"
1457        );
1458        assert_eq!(queries.len(), 4, "90 days in windows of 29");
1459    }
1460
1461    #[test]
1462    fn interest_is_held_since_a_minute_inside_ninety_days_only_for_an_older_start() {
1463        let now = Utc.with_ymd_and_hms(2026, 10, 25, 12, 0, 0).unwrap();
1464        let held_since = now - TimeDelta::days(90) + TimeDelta::minutes(1);
1465        let before = held_since - TimeDelta::milliseconds(1);
1466
1467        assert_eq!(interest_held_since(before, now), Some(held_since));
1468        assert_eq!(
1469            interest_held_since(now - TimeDelta::days(90), now),
1470            Some(held_since),
1471            "a request for the last 90 days misses its first minute"
1472        );
1473        assert_eq!(interest_held_since(held_since, now), None);
1474        assert_eq!(interest_held_since(now, now), None);
1475    }
1476
1477    #[tokio::test]
1478    async fn interest_entirely_older_than_binance_serves_reads_nothing_and_says_so() {
1479        let server = MockServer::start().await;
1480        let client = margin_client_at(&server, false);
1481        let end = Utc::now() - TimeDelta::days(100);
1482
1483        let read = client
1484            .fetch_margin_interest(end - TimeDelta::days(1), end)
1485            .await
1486            .unwrap();
1487
1488        assert!(read.flows.is_empty());
1489        assert!(
1490            read.held_since.is_some_and(|held_since| held_since > end),
1491            "{:?}",
1492            read.held_since
1493        );
1494        assert!(queries(&server).await.is_empty());
1495    }
1496
1497    #[tokio::test]
1498    async fn an_empty_span_reads_nothing() {
1499        let server = MockServer::start().await;
1500        let client = margin_client_at(&server, false);
1501        let now = Utc::now();
1502
1503        let interest = client
1504            .fetch_margin_interest(now, now - TimeDelta::hours(1))
1505            .await
1506            .unwrap();
1507        let liquidations = client
1508            .fetch_forced_liquidations(now + TimeDelta::hours(1), now + TimeDelta::hours(2))
1509            .await
1510            .unwrap();
1511
1512        assert!(interest.flows.is_empty() && liquidations.is_empty());
1513        assert_eq!((interest.resume, interest.held_since), (None, None));
1514        assert!(queries(&server).await.is_empty());
1515    }
1516
1517    #[tokio::test]
1518    async fn a_refused_read_fails_the_whole_read() {
1519        let server = MockServer::start().await;
1520        let refused =
1521            ResponseTemplate::new(400).set_body_json(json!({"code": -1100, "msg": "refused"}));
1522        mount(&server, INTEREST_PATH, refused.clone()).await;
1523        mount(&server, LIQUIDATION_PATH, refused).await;
1524        let client = margin_client_at(&server, false);
1525        let now = Utc::now();
1526
1527        let interest = client
1528            .fetch_margin_interest(now - TimeDelta::days(1), now)
1529            .await;
1530        let liquidations = client
1531            .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1532            .await;
1533
1534        assert!(
1535            matches!(
1536                interest,
1537                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
1538            ),
1539            "{interest:?}"
1540        );
1541        assert!(
1542            matches!(
1543                liquidations,
1544                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
1545            ),
1546            "{liquidations:?}"
1547        );
1548    }
1549
1550    #[tokio::test]
1551    async fn forced_liquidations_of_the_clients_account_are_read_oldest_first_once_each() {
1552        for is_isolated in [false, true] {
1553            let server = MockServer::start().await;
1554            let now = Utc::now();
1555            let row = |order_id, side, filled, time| {
1556                liquidation_row(order_id, side, filled, time, is_isolated)
1557            };
1558            let mut no_order_id = row(9, "SELL", "0.5", now);
1559            no_order_id.as_object_mut().unwrap().remove("orderId");
1560            mount(
1561                &server,
1562                LIQUIDATION_PATH,
1563                page(vec![
1564                    row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
1565                    row(2, "SELL", "0.5", now - TimeDelta::hours(1)),
1566                    no_order_id,
1567                    // Marked isolated: not the cross account's, but the isolated pair's.
1568                    liquidation_row(8, "SELL", "0.5", now, true),
1569                    row(1, "BUY", "0", now - TimeDelta::hours(2)),
1570                    row(0, "BUY", "0.5", now - TimeDelta::days(3)),
1571                ]),
1572            )
1573            .await;
1574            let client = margin_client_at(&server, is_isolated);
1575
1576            let liquidations = client
1577                .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1578                .await
1579                .unwrap();
1580
1581            let (unfilled, filled) = match (is_isolated, liquidations.as_slice()) {
1582                (false, [unfilled, filled]) => (unfilled, filled),
1583                (true, [unfilled, filled, marked]) => {
1584                    assert_eq!(marked.order_id, OrderId::new("8"));
1585                    (unfilled, filled)
1586                }
1587                _ => panic!("is_isolated = {is_isolated}: {liquidations:?}"),
1588            };
1589            assert_eq!(unfilled.order_id, OrderId::new("1"));
1590            assert_eq!(unfilled.side, Side::Buy);
1591            assert_eq!(unfilled.avg_price, None, "nothing filled");
1592            assert_eq!(
1593                *filled,
1594                MarginForcedLiquidation {
1595                    order_id: OrderId::new("2"),
1596                    instrument: InstrumentNameExchange::new("BTCUSDT"),
1597                    side: Side::Sell,
1598                    price: Some(dec!(59000)),
1599                    quantity: Some(dec!(0.5)),
1600                    filled_quantity: dec!(0.5),
1601                    avg_price: Some(dec!(60000)),
1602                    time_exchange: Utc
1603                        .timestamp_millis_opt((now - TimeDelta::hours(1)).timestamp_millis())
1604                        .unwrap(),
1605                }
1606            );
1607
1608            let queries = queries(&server).await;
1609            let [query] = queries.as_slice() else {
1610                panic!("one page: {queries:?}");
1611            };
1612            assert_eq!(query["size"], "100");
1613            assert_eq!(
1614                query.get("isolatedSymbol").map(String::as_str),
1615                is_isolated.then_some("BTCUSDT"),
1616            );
1617        }
1618    }
1619
1620    #[tokio::test]
1621    async fn a_full_page_of_forced_liquidations_reads_the_next() {
1622        let server = MockServer::start().await;
1623        let now = Utc::now();
1624        let time = now - TimeDelta::hours(1);
1625        let full = (0..100)
1626            .map(|id| liquidation_row(id, "SELL", "0.5", time, false))
1627            .collect();
1628        mount_page(&server, LIQUIDATION_PATH, "1", page(full)).await;
1629        mount_page(
1630            &server,
1631            LIQUIDATION_PATH,
1632            "2",
1633            page(vec![liquidation_row(100, "SELL", "0.5", time, false)]),
1634        )
1635        .await;
1636        let client = margin_client_at(&server, false);
1637
1638        let liquidations = client
1639            .fetch_forced_liquidations(now - TimeDelta::days(1), now)
1640            .await
1641            .unwrap();
1642
1643        assert_eq!(liquidations.len(), 101);
1644        assert_eq!(queries(&server).await.len(), 2);
1645    }
1646}