Skip to main content

rustrade_execution/
margin.rs

1//! Margin borrowing: the borrow and repay policy a margin account applies to its orders, the
2//! simulated venue's spot margin configuration, and the interest it charges on its loans.
3//!
4//! [`MarginEffect`] is what an order does to a margin account's loans: borrow what it lacks, repay
5//! from what it receives, or neither. A live margin client sends it with each order, and the
6//! simulated venue applies it when [`SpotMarginConfig`] turns its spot margin on. A
7//! [`MarginInterestModel`] prices those loans over time; [`PeriodicMarginInterest`] is the
8//! built-in one. A margin client that learns what an order borrowed reports it as a
9//! [`MarginBorrow`], in a [`MarginPlacement`].
10
11use crate::cash_flow::CashFlowLoan;
12use crate::holding_cost::{DayCount, RateSeries, next_whole_interval};
13use crate::order::{
14    Order,
15    state::{ActiveOrderState, InactiveOrderState, OrderState, UnindexedOrderState},
16};
17use chrono::{DateTime, TimeDelta, Utc};
18use derive_more::Constructor;
19use fnv::FnvHashMap;
20use rust_decimal::Decimal;
21use rustrade_instrument::{
22    asset::name::AssetNameExchange, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
23};
24use serde::{Deserialize, Serialize};
25use std::{collections::BTreeMap, fmt::Debug};
26use thiserror::Error;
27
28/// What an order does to a margin account's loans: borrow what it lacks, repay from what it
29/// receives, or touch neither.
30///
31/// A margin client has one as its default and a request may carry its own
32/// (`RequestOpen::margin_effect`, where `None` takes the client's default). The three values are
33/// the ones a margin venue takes per order (Binance's `NO_SIDE_EFFECT`, `MARGIN_BUY` and
34/// `AUTO_REPAY`). Whether an order opens or closes a position decides which it should be, and
35/// that is position state the library deliberately does not track, so the caller says.
36///
37/// A client or simulated venue that cannot honour an effect refuses the order unsent with
38/// [`OrderError::UnsupportedMarginEffect`](crate::error::OrderError::UnsupportedMarginEffect),
39/// rather than place it without the effect.
40///
41/// The `Serialize`/`Deserialize` wire form is the config-file value (`"no_side_effect"`,
42/// `"borrow"`, `"repay"`), which is not any venue's API value.
43#[derive(
44    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
45)]
46#[serde(rename_all = "snake_case")]
47#[non_exhaustive]
48pub enum MarginEffect {
49    /// Neither borrow nor repay: an order that would need a loan is refused, and what it receives
50    /// stays free, as Binance's `NO_SIDE_EFFECT` does. The default, so that no order takes on
51    /// debt unless the caller asks.
52    #[default]
53    NoSideEffect,
54    /// Borrow what the order lacks, as Binance's `MARGIN_BUY` does, in the asset it pays with
55    /// (base for a sell, which opens or extends a short).
56    ///
57    /// Binance lends the shortfall, or the asset's minimum borrow if that is more: **loan =
58    /// max(shortfall, minimum borrow)**, where the minimum is the asset's `userMinBorrow` in
59    /// `GET /sapi/v1/margin/allAssets`. A sell's shortfall is its quantity less the free base; a
60    /// limit buy's is its quantity times its limit price less the free quote. Nothing caps the
61    /// loan at the order's size, so a small order on an empty balance can borrow more than it
62    /// pays, and what it does not pay stays free balance, owed.
63    ///
64    /// The loan is taken when the order is placed, not when it fills, and nothing repays it from
65    /// the order's proceeds: close it with a [`Repay`](Self::Repay) order. Whether a loan
66    /// is repaid when the order is cancelled before it fills is the client's `auto_repay_at_cancel`
67    /// option.
68    ///
69    /// # Warning
70    /// A **mis-sized order takes on debt**: one larger than the free balance borrows the shortfall,
71    /// or the asset's minimum borrow, without any further opt-in, and interest accrues on it.
72    Borrow,
73    /// Repay the loan of the asset the order receives from what it receives, interest first, as
74    /// Binance's `AUTO_REPAY` does. It borrows nothing: an order the free balance cannot pay for
75    /// is refused.
76    Repay,
77}
78
79/// A loan a margin order took out when it was placed: `amount` of `asset`.
80///
81/// `amount` is the loan's principal, as the venue reports lending it: a reported zero is kept as
82/// zero, not read as no loan, and the amount need not equal what the order lacked. It is not the
83/// order's net
84/// effect on the balance: a fee the order pays in `asset` is not in it, and interest accrues on
85/// it from the moment it is lent. `asset` is the asset the order pays with, which may be the base
86/// or the quote asset of the pair.
87#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
88pub struct MarginBorrow {
89    pub asset: AssetNameExchange,
90    pub amount: Decimal,
91}
92
93/// The answer to placing a margin order: the order, and what it borrowed.
94///
95/// Returned by a margin client's own placement method (for example `BinanceMargin`'s
96/// `open_margin_order`), whose venue reports the loan in its answer to the order.
97/// `ExecutionClient::open_order` returns only the order.
98#[derive(Debug, Clone, PartialEq)]
99pub struct MarginPlacement {
100    /// The order, as [`ExecutionClient::open_order`](crate::client::ExecutionClient::open_order)
101    /// returns it.
102    pub order: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
103    /// What the order borrowed when it was placed.
104    ///
105    /// - `Some` when the venue's answer reports a loan.
106    /// - `None` when the answer reports none: the order borrowed nothing (its
107    ///   [`MarginEffect`] is not [`Borrow`](MarginEffect::Borrow), or its free balance covered
108    ///   it), or the order was refused.
109    /// - When [`order.state`](Order::state) is an unknown outcome
110    ///   ([`OrderState::is_outcome_unknown`](crate::order::state::OrderState::is_outcome_unknown)),
111    ///   the venue may have placed the order and lent to it without the client learning how
112    ///   much. `None` then says nothing about a loan: read the account's balances.
113    pub borrowed: Option<MarginBorrow>,
114}
115
116impl MarginPlacement {
117    /// The loan the order took, with what identifies it, when the answer reports one: what
118    /// [`borrowed`](Self::borrowed) says, the order's instrument, its venue and client ids, and
119    /// the venue's time for the order's state, which for a new order is when the venue accepted
120    /// it and lent.
121    ///
122    /// `None` when [`borrowed`](Self::borrowed) is, or when the order's state has no venue order
123    /// id: one the venue acknowledged without assigning its own, or one still in flight or
124    /// refused.
125    pub fn loan(&self) -> Option<OrderLoan> {
126        let borrowed = self.borrowed.clone()?;
127        let (order_id, time) = match &self.order.state {
128            OrderState::Active(ActiveOrderState::Open(open)) => {
129                (open.id.assigned()?.clone(), open.time_exchange)
130            }
131            OrderState::Inactive(InactiveOrderState::FullyFilled(filled)) => {
132                (filled.id.clone(), filled.time_exchange)
133            }
134            OrderState::Inactive(InactiveOrderState::Cancelled(cancelled)) => {
135                (cancelled.id.clone(), cancelled.time_exchange)
136            }
137            OrderState::Inactive(InactiveOrderState::Expired(expired)) => {
138                (expired.id.clone(), expired.time_exchange)
139            }
140            OrderState::Active(
141                ActiveOrderState::OpenInFlight(_) | ActiveOrderState::CancelInFlight(_),
142            )
143            | OrderState::Inactive(InactiveOrderState::OpenFailed(_)) => return None,
144        };
145        Some(OrderLoan::new(
146            self.order.key.instrument.clone(),
147            borrowed,
148            CashFlowLoan::new(time, order_id, self.order.key.cid.clone()),
149        ))
150    }
151}
152
153/// A loan an order took when it was placed, and what identifies it: the order's instrument, what
154/// it borrowed, and the order and time it borrowed at.
155///
156/// A margin client that keeps the loans its orders took can link the venue's charge on a new loan
157/// to the order that took it, through [`CashFlow::loan`](crate::cash_flow::CashFlow::loan). Built
158/// by [`MarginPlacement::loan`], and handed back to a client after a restart by, for example,
159/// `BinanceMargin::recall_loans`.
160#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
161#[non_exhaustive]
162pub struct OrderLoan {
163    /// The instrument the order traded.
164    pub instrument: InstrumentNameExchange,
165    /// What the order borrowed: the asset and the loan's principal.
166    pub borrowed: MarginBorrow,
167    /// When the venue lent, and the order that borrowed.
168    pub loan: CashFlowLoan,
169}
170
171/// Spot margin on the simulated venue: what it lends a spot order, how much, and what it repays.
172///
173/// Off by default: `MockExecutionConfig::spot_margin` is `None`, and the venue is a cash account
174/// whose spot orders must be paid for with what it holds. With a configuration, it is a cross
175/// margin account, with one pool of balances shared by every instrument, which lends a spot order
176/// what it lacks and reports each balance's loan as Binance's margin account does.
177///
178/// # What it lends, and when
179/// Each spot order has a [`MarginEffect`]: its request's `margin_effect`, else this
180/// configuration's [`margin_effect`](Self::margin_effect). An order with [`MarginEffect::Borrow`]
181/// that the account cannot pay for with its free balance borrows the shortfall of the asset it
182/// pays with (base for a sell, quote for a buy), or that asset's
183/// [`min_borrow`](Self::min_borrow) if it is more, as Binance does. It borrows when the venue
184/// accepts the order, as Binance does, so a resting limit order or a held market order is lent its
185/// shortfall at once, and the venue holds it against the order as it holds any other balance. What
186/// a minimum borrow lends beyond the shortfall is not held: it is free balance, owed. An asset the
187/// account has never held is opened with the loan.
188///
189/// An order that is cancelled, expires or is refused at its fill before it fills repays what it
190/// borrowed, interest first, and a held market order that fills for less than was held repays the
191/// part of its loan it no longer needs, if [`auto_repay_at_cancel`](Self::auto_repay_at_cancel)
192/// is on (the default, as on Binance). One that fills in part and rests the remainder pays for the
193/// part that filled with the account's own balance first, so the loan is held against the
194/// remainder and repaid if the remainder is cancelled. What a minimum borrow lent beyond the
195/// shortfall is repaid with the rest if the order ends before it fills in full, and stays owed
196/// once it does.
197///
198/// An order with [`MarginEffect::NoSideEffect`] or [`MarginEffect::Repay`] lends nothing: one it
199/// cannot pay for is rejected as on a cash account. A loan already in the venue's initial state
200/// is left as it is, and balances still report their loans.
201///
202/// Only spot orders borrow. A CFD's margin is posted from the quote asset's free balance, as
203/// without this configuration, and a CFD order carrying [`MarginEffect::Borrow`] or
204/// [`MarginEffect::Repay`] is refused with
205/// [`OrderError::UnsupportedMarginEffect`](crate::error::OrderError::UnsupportedMarginEffect).
206///
207/// # How much it lends
208/// A borrow is allowed while the account's debt, after it, is at most `max_leverage − 1` times
209/// the account's equity. Both are valued in [`valuation_asset`](Self::valuation_asset):
210/// - Each asset is valued at the mid of an instrument on the venue that pairs it with the
211///   valuation asset, else that market's last price, in either direction (`BTC/USDT`, or the
212///   inverse of `USDT/BTC`). The valuation asset is worth one. A market priced at zero values
213///   nothing. A [borrow price window](Self::with_borrow_price_window) values each asset over a
214///   recent window of that price instead: see `# Borrow price window`.
215/// - **Equity** is the sum over assets of `total − borrowed − interest`, valued that way, where an
216///   asset whose net is positive counts at its
217///   [`borrow_collateral_ratio`](Self::borrow_collateral_ratio): see `# Collateral ratios`. An
218///   asset the venue cannot value counts as no equity, which understates what the account can
219///   borrow rather than overstating it.
220/// - **Debt** is the sum over assets of `borrowed + interest`, valued the same way. The venue
221///   lends nothing while it cannot value a debt, and never lends an asset it cannot value.
222///
223/// An asset given a [`max_borrow`](Self::max_borrow) is lent no more than that cap allows as
224/// well: what the account already owes of it, principal plus interest, plus the new loan, is at
225/// most the cap. Both limits apply, and the tighter one decides.
226///
227/// An order that needs more than the leverage limit allows is rejected as unaffordable, with a
228/// reason that says how much the venue would lend. So is one whose asset's
229/// [`min_borrow`](Self::min_borrow) is more than that, since the venue never lends less than the
230/// minimum, and its reason names the minimum too. One that needs more than an asset's cap allows,
231/// where the cap is the tighter limit, is rejected with
232/// [`ApiError::BorrowRejected`](crate::error::ApiError::BorrowRejected) instead: see
233/// [`with_max_borrow`](Self::with_max_borrow). A `max_leverage` of one lends nothing, but still
234/// reports each balance's loan.
235///
236/// It lends nothing while a liquidation is under way: see [`SpotMarginLiquidation`].
237///
238/// # Collateral ratios
239/// An asset can count for less than its full value as collateral, as Binance's cross margin
240/// weights each asset by a collateral ratio. Each asset has two, both one by default:
241/// - a [`borrow_collateral_ratio`](Self::borrow_collateral_ratio), which the leverage limit above
242///   uses, and so what the venue lends;
243/// - a [`liquidation_collateral_ratio`](Self::liquidation_collateral_ratio), which the margin level
244///   a [`SpotMarginLiquidation`] checks uses.
245///
246/// A ratio applies only to the part of an asset the account holds beyond what it owes of it,
247/// its positive net: `ratio × (total − borrowed − interest)`. What it holds up to what it owes
248/// counts in full, and so do the holdings of an asset it owes more of than it holds. So an
249/// account that holds 2 BTC and owes 1, at a ratio of 0.95, counts the first BTC in full and the
250/// second at 0.95. Debt is never weighted. A ratio of zero counts the asset for nothing beyond
251/// its debt.
252///
253/// In margin-level terms, the account's collateral is its debt plus its weighted equity, and the
254/// margin level is collateral over debt, as Binance defines it: a borrow is allowed while that
255/// level, with borrow ratios, stays at or above `max_leverage / (max_leverage − 1)`.
256///
257/// An asset's liquidation ratio may not be below its borrow ratio, so that the borrow ratios are
258/// the stricter ones, as on Binance. That keeps a borrow the leverage limit allows from leaving
259/// the account at its liquidation level.
260///
261/// # Borrow price window
262/// Off by default: the leverage limit values each asset at its current price, as the liquidation
263/// check does. Binance's borrow check is stricter. It values each asset over a recent window of
264/// its price, at the highest for an asset the account owes and the lowest for every other, so a
265/// price that just jumped lends no more at once, and one that just fell lends less at once. Set
266/// with [`with_borrow_price_window`](Self::with_borrow_price_window), the leverage limit does the
267/// same over the window given:
268/// - Each market observation the venue is fed adds its price to the window of that instrument:
269///   the mid, else the last price, at which the venue values the instrument, and the high and low
270///   of a bar the observation carries. The current price always counts.
271/// - What counts is what was observed in the window up to the latest instant the venue's clock
272///   has reached, from that instant less the window, inclusive.
273/// - An asset the account owes is valued at its highest price in the window, for every unit it
274///   holds as well as for its debt. So is the asset being borrowed, since the loan makes it one
275///   the account owes: an order's or a [`borrow_capacity`] query's. Every other asset is valued
276///   at its lowest. The valuation asset is worth one.
277/// - The venue lends the lesser of what the leverage limit allows at those prices and at the
278///   current ones. The window can only make the venue lend less. Valuing an asset the account owes
279///   at its highest raises the value of its holdings as well as its debt, so on its own it could
280///   lend more than the current prices would, and leave the account at its liquidation level the
281///   moment it borrowed.
282///
283/// The venue keeps, per spot instrument, every observed price that could still be the window's
284/// lowest or highest: in a market that only rises or only falls, every observation inside the
285/// window, so a long window over a fast feed holds that many.
286///
287/// The window changes only how much the venue lends: the margin level a [`SpotMarginLiquidation`]
288/// checks, and any interest, are valued at current prices. A venue no driver feeds the market,
289/// one priced by each request's own snapshot, has no window to look back on, and lends as if
290/// there were none.
291///
292/// [`borrow_capacity`]: crate::exchange::mock::venue::SimulatedVenue::borrow_capacity
293///
294/// # What it repays
295/// Under [`MarginEffect::Repay`], whatever a fill delivers into an asset the account owes repays
296/// that asset's loan, interest first, then principal, and only the rest becomes free balance. What
297/// a buy delivers is its quantity less any fee charged in base, so a buy of exactly what is owed,
298/// with a base fee, leaves a fee-sized loan. Gross the buy up by the fee to clear it. Under the
299/// other two effects a fill repays nothing: as on Binance, a repay is per order, and nothing
300/// repays a loan automatically.
301///
302/// # Liquidation
303/// Off by default: an account whose debt grows past its equity keeps trading, and nothing closes
304/// its positions. Configured with [`with_liquidation`](Self::with_liquidation), the venue repays
305/// every debt once the account's margin level falls to the configured level, selling what the
306/// account holds as needed. See [`SpotMarginLiquidation`].
307///
308/// # What it does not model
309/// - **Isolated margin.** One pool of balances backs every instrument.
310/// - **Tiered collateral ratios.** An asset has one ratio per check, whatever the size of its
311///   holding. Binance publishes each ratio in progressive tiers by the holding's value in USD:
312///   each slice of the value is weighted at its own tier's ratio. An asset with a single tier, as
313///   every asset had on 2026-11-04, is modelled exactly.
314/// - **Index prices.** Every check values an asset at a market's own mid or last price, where
315///   Binance values at an index over several venues. The borrow price window above stands in for
316///   the window Binance takes over that index.
317/// - **The venue's lending pool.** The venue has whatever it lends: no shared pool runs short, as
318///   one can on a real venue whatever the account's own limits allow.
319/// - **Interest**, by this configuration alone. A venue given a [`MarginInterestModel`] charges
320///   it; see that trait.
321///
322/// [`new`](Self::new) refuses a `max_leverage` below one, [`with_min_borrow`](Self::with_min_borrow)
323/// a negative minimum, [`with_max_borrow`](Self::with_max_borrow) a negative cap, the collateral
324/// ratio builders a ratio outside `[0, 1]` or a liquidation ratio below the borrow ratio,
325/// [`with_borrow_price_window`](Self::with_borrow_price_window) a window that is not a positive
326/// whole number of milliseconds, [`with_liquidation`](Self::with_liquidation) a liquidation the
327/// leverage limit could reach, and so does deserialising any of them, so a configuration that
328/// exists is one the venue can apply.
329/// Serialised, `max_leverage`, each minimum borrow, each cap and each ratio are decimal strings
330/// (`"3"`), as the crate writes decimals elsewhere, so no precision is lost to a float.
331/// `min_borrow`, `max_borrow`, `borrow_collateral_ratio` and `liquidation_collateral_ratio` are
332/// maps from asset to amount, each left out when empty. `borrow_price_window_ms` is the window
333/// in whole milliseconds, an integer, and `liquidation` is left out when off, as is the window.
334#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
335#[serde(try_from = "SpotMarginRepr", into = "SpotMarginRepr")]
336pub struct SpotMarginConfig(SpotMarginRepr);
337
338/// The serialised form of a [`SpotMarginConfig`], before validation.
339#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
340struct SpotMarginRepr {
341    valuation_asset: AssetNameExchange,
342    #[serde(with = "rust_decimal::serde::str")]
343    max_leverage: Decimal,
344    margin_effect: MarginEffect,
345    #[serde(default = "default_auto_repay_at_cancel")]
346    auto_repay_at_cancel: bool,
347    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
348    min_borrow: BTreeMap<AssetNameExchange, Decimal>,
349    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
350    max_borrow: BTreeMap<AssetNameExchange, Decimal>,
351    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
352    borrow_collateral_ratio: BTreeMap<AssetNameExchange, Decimal>,
353    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
354    liquidation_collateral_ratio: BTreeMap<AssetNameExchange, Decimal>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    borrow_price_window_ms: Option<i64>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    liquidation: Option<SpotMarginLiquidation>,
359}
360
361/// Serde default for `auto_repay_at_cancel`, here and on `BinanceMarginConfig`: on, as Binance's
362/// `autoRepayAtCancel` is.
363pub(crate) fn default_auto_repay_at_cancel() -> bool {
364    true
365}
366
367impl SpotMarginConfig {
368    /// Spot margin applying `margin_effect` to each order that does not carry its own, up to
369    /// `max_leverage` over the account's equity, valued in `valuation_asset`.
370    ///
371    /// The effect has no default, so that a configuration says whether the venue may take on debt
372    /// for an order. Unused loans are repaid when their order ends unfilled; see
373    /// [`with_auto_repay_at_cancel`](Self::with_auto_repay_at_cancel).
374    ///
375    /// # Errors
376    /// Returns [`SpotMarginConfigError`] if `max_leverage` is below one.
377    pub fn new(
378        valuation_asset: AssetNameExchange,
379        max_leverage: Decimal,
380        margin_effect: MarginEffect,
381    ) -> Result<Self, SpotMarginConfigError> {
382        Self::try_from(SpotMarginRepr {
383            valuation_asset,
384            max_leverage,
385            margin_effect,
386            auto_repay_at_cancel: default_auto_repay_at_cancel(),
387            min_borrow: BTreeMap::new(),
388            max_borrow: BTreeMap::new(),
389            borrow_collateral_ratio: BTreeMap::new(),
390            liquidation_collateral_ratio: BTreeMap::new(),
391            borrow_price_window_ms: None,
392            liquidation: None,
393        })
394    }
395
396    /// This configuration, repaying a loan its order no longer needs (the order was cancelled,
397    /// expired or refused at its fill, or a held market order filled for less than was held) when
398    /// `auto_repay_at_cancel` is true, and leaving it owed when false.
399    ///
400    /// On by default, as Binance's `autoRepayAtCancel` is. With it off, an unused loan stays owed
401    /// until a [`MarginEffect::Repay`] order repays it.
402    #[must_use]
403    pub fn with_auto_repay_at_cancel(mut self, auto_repay_at_cancel: bool) -> Self {
404        self.0.auto_repay_at_cancel = auto_repay_at_cancel;
405        self
406    }
407
408    /// The asset equity and debt are valued in.
409    pub fn valuation_asset(&self) -> &AssetNameExchange {
410        &self.0.valuation_asset
411    }
412
413    /// The most the account may hold, as a multiple of its equity: its debt may reach
414    /// `max_leverage − 1` times its equity.
415    pub fn max_leverage(&self) -> Decimal {
416        self.0.max_leverage
417    }
418
419    /// The effect of an order whose request carries none.
420    pub fn margin_effect(&self) -> MarginEffect {
421        self.0.margin_effect
422    }
423
424    /// Whether a loan its order no longer needs is repaid. See
425    /// [`with_auto_repay_at_cancel`](Self::with_auto_repay_at_cancel).
426    pub fn auto_repay_at_cancel(&self) -> bool {
427        self.0.auto_repay_at_cancel
428    }
429
430    /// This configuration, never lending less than `min_borrow` of `asset`: a loan of `asset`
431    /// below it is raised to it, as Binance raises a `MARGIN_BUY` loan to the asset's
432    /// `userMinBorrow`. A minimum of zero removes it. Replaces any minimum `asset` had.
433    ///
434    /// No asset has one by default, so each loan is exactly its order's shortfall. An order its
435    /// free balance covers borrows nothing, whatever the minimum.
436    ///
437    /// # Errors
438    /// Returns [`SpotMarginConfigError::NegativeMinBorrow`] if `min_borrow` is negative.
439    pub fn with_min_borrow(
440        mut self,
441        asset: AssetNameExchange,
442        min_borrow: Decimal,
443    ) -> Result<Self, SpotMarginConfigError> {
444        self.0.min_borrow.insert(asset, min_borrow);
445        Self::try_from(self.0)
446    }
447
448    /// The least the venue lends of `asset` in one loan, zero if it has no minimum. See
449    /// [`with_min_borrow`](Self::with_min_borrow).
450    pub fn min_borrow(&self, asset: &AssetNameExchange) -> Decimal {
451        self.0
452            .min_borrow
453            .get(asset)
454            .copied()
455            .unwrap_or(Decimal::ZERO)
456    }
457
458    /// This configuration, lending `asset` only while what the account owes of it, principal plus
459    /// interest, and the new loan come to at most `max_borrow`: a fixed cap on the account's debt
460    /// in `asset`, as Binance caps what a margin account may borrow of each asset
461    /// (`maxBorrowable`'s `borrowLimit`). Replaces any cap `asset` had.
462    ///
463    /// No asset has one by default, so only the leverage limit bounds a loan. A cap of zero lends
464    /// none of `asset`. Nor is `asset` lent once what the cap leaves is below its
465    /// [`min_borrow`](Self::min_borrow), since the venue never lends less than the minimum.
466    ///
467    /// # How the cap counts
468    /// A borrow is allowed while what the account already owes of `asset`, loan plus interest,
469    /// plus the new loan is at most the cap. Interest the new loan is charged when it is made is
470    /// not counted, as the leverage limit does not count it, so it can take the debt just past the
471    /// cap. Counting interest is this venue's rule: Binance does not document whether its limit
472    /// counts it.
473    ///
474    /// The cap is the account's own limit, not the venue's supply: Binance can also refuse a
475    /// borrow its limit allows because it has too little of the asset to lend, which this venue
476    /// does not model.
477    ///
478    /// The mock client reports the cap as
479    /// [`BorrowCapacity::account_limit`](crate::BorrowCapacity::account_limit), from
480    /// [`BorrowCapacityClient::fetch_borrow_capacity`](crate::client::BorrowCapacityClient::fetch_borrow_capacity).
481    ///
482    /// # Rejection
483    /// An order that would borrow past the cap is rejected, never lent less than it needs. Where
484    /// the cap allows less than the leverage limit, the rejection is
485    /// [`ApiError::BorrowRejected`](crate::error::ApiError::BorrowRejected) with
486    /// reason [`BorrowLimitExceeded`](crate::error::BorrowRejectReason::BorrowLimitExceeded),
487    /// as a live Binance margin client reports Binance's `-3006`. Its message names the cap, and
488    /// for a sell its [`available`](crate::error::BorrowReject::available) is how much more of the
489    /// base asset the venue would lend: zero when that is below the minimum borrow. A buy borrows
490    /// the quote asset, in units other than the order's, so it leaves `available` unset. Where
491    /// the leverage limit is the tighter, the order is rejected as unaffordable, as without a cap.
492    ///
493    /// # Errors
494    /// Returns [`SpotMarginConfigError::NegativeMaxBorrow`] if `max_borrow` is negative.
495    pub fn with_max_borrow(
496        mut self,
497        asset: AssetNameExchange,
498        max_borrow: Decimal,
499    ) -> Result<Self, SpotMarginConfigError> {
500        self.0.max_borrow.insert(asset, max_borrow);
501        Self::try_from(self.0)
502    }
503
504    /// The most the account may owe of `asset`, principal plus interest, or `None` if it has no
505    /// cap. See [`with_max_borrow`](Self::with_max_borrow).
506    pub fn max_borrow(&self, asset: &AssetNameExchange) -> Option<Decimal> {
507        self.0.max_borrow.get(asset).copied()
508    }
509
510    /// This configuration, counting `ratio` of what the account holds of `asset` beyond what it
511    /// owes of it as equity when the venue decides how much it lends: the asset's borrow
512    /// collateral ratio, as Binance's Margin Data page lists one per asset. Replaces any borrow
513    /// ratio `asset` had. See [`SpotMarginConfig`]'s `# Collateral ratios`.
514    ///
515    /// Every asset's is one by default. A ratio of one removes it.
516    ///
517    /// # Errors
518    /// [`SpotMarginConfigError::CollateralRatioOutOfRange`] if `ratio` is outside `[0, 1]`, and
519    /// [`SpotMarginConfigError::LiquidationRatioBelowBorrowRatio`] if it is above `asset`'s
520    /// [`liquidation_collateral_ratio`](Self::liquidation_collateral_ratio).
521    pub fn with_borrow_collateral_ratio(
522        mut self,
523        asset: AssetNameExchange,
524        ratio: Decimal,
525    ) -> Result<Self, SpotMarginConfigError> {
526        self.0.borrow_collateral_ratio.insert(asset, ratio);
527        Self::try_from(self.0)
528    }
529
530    /// The fraction of what the account holds of `asset` beyond what it owes of it that counts
531    /// as equity when the venue decides how much it lends, one if it has none. See
532    /// [`with_borrow_collateral_ratio`](Self::with_borrow_collateral_ratio).
533    pub fn borrow_collateral_ratio(&self, asset: &AssetNameExchange) -> Decimal {
534        self.0
535            .borrow_collateral_ratio
536            .get(asset)
537            .copied()
538            .unwrap_or(Decimal::ONE)
539    }
540
541    /// This configuration, counting `ratio` of what the account holds of `asset` beyond what it
542    /// owes of it as equity in the margin level a [`SpotMarginLiquidation`] checks: the asset's
543    /// liquidation collateral ratio, as Binance's Margin Data page lists one per asset. Replaces
544    /// any liquidation ratio `asset` had. See [`SpotMarginConfig`]'s `# Collateral ratios`.
545    ///
546    /// Every asset's is one by default. A ratio of one removes it. It has no effect without a
547    /// [`liquidation`](Self::with_liquidation).
548    ///
549    /// # Errors
550    /// [`SpotMarginConfigError::CollateralRatioOutOfRange`] if `ratio` is outside `[0, 1]`, and
551    /// [`SpotMarginConfigError::LiquidationRatioBelowBorrowRatio`] if it is below `asset`'s
552    /// [`borrow_collateral_ratio`](Self::borrow_collateral_ratio). To set a liquidation ratio
553    /// below the asset's current borrow ratio, lower the borrow ratio first, or set both at once
554    /// with [`with_collateral_ratios`](Self::with_collateral_ratios).
555    pub fn with_liquidation_collateral_ratio(
556        mut self,
557        asset: AssetNameExchange,
558        ratio: Decimal,
559    ) -> Result<Self, SpotMarginConfigError> {
560        self.0.liquidation_collateral_ratio.insert(asset, ratio);
561        Self::try_from(self.0)
562    }
563
564    /// The fraction of what the account holds of `asset` beyond what it owes of it that counts
565    /// as equity in the margin level a liquidation checks, one if it has none. See
566    /// [`with_liquidation_collateral_ratio`](Self::with_liquidation_collateral_ratio).
567    pub fn liquidation_collateral_ratio(&self, asset: &AssetNameExchange) -> Decimal {
568        self.0
569            .liquidation_collateral_ratio
570            .get(asset)
571            .copied()
572            .unwrap_or(Decimal::ONE)
573    }
574
575    /// This configuration, with `asset`'s
576    /// [`borrow_collateral_ratio`](Self::with_borrow_collateral_ratio) and
577    /// [`liquidation_collateral_ratio`](Self::with_liquidation_collateral_ratio) both replaced,
578    /// checked together once both are set.
579    ///
580    /// Setting the two one after the other checks the pair after each step, against the other
581    /// ratio's old value, so it can refuse a pair that is valid as a whole: raising a 0.5 borrow
582    /// ratio and a 0.6 liquidation ratio to 0.8 and 0.9 fails if the borrow ratio goes first, and
583    /// lowering them back fails the other way round. This setter does not depend on the order.
584    ///
585    /// A ratio of one removes it, as either setter does.
586    ///
587    /// # Errors
588    /// [`SpotMarginConfigError::CollateralRatioOutOfRange`] if either ratio is outside `[0, 1]`,
589    /// and [`SpotMarginConfigError::LiquidationRatioBelowBorrowRatio`] if `liquidation` is below
590    /// `borrow`.
591    pub fn with_collateral_ratios(
592        mut self,
593        asset: AssetNameExchange,
594        borrow: Decimal,
595        liquidation: Decimal,
596    ) -> Result<Self, SpotMarginConfigError> {
597        self.0.borrow_collateral_ratio.insert(asset.clone(), borrow);
598        self.0
599            .liquidation_collateral_ratio
600            .insert(asset, liquidation);
601        Self::try_from(self.0)
602    }
603
604    /// This configuration, valuing each asset over the trailing `window` of its price when the
605    /// venue decides how much it lends. Replaces any window it had. See [`SpotMarginConfig`]'s
606    /// `# Borrow price window`.
607    ///
608    /// Off by default, when each asset is valued at its current price.
609    ///
610    /// # Errors
611    /// [`SpotMarginConfigError::BorrowPriceWindowInvalid`] if `window` is not a positive whole
612    /// number of milliseconds, the unit it is serialised in.
613    pub fn with_borrow_price_window(
614        mut self,
615        window: TimeDelta,
616    ) -> Result<Self, SpotMarginConfigError> {
617        let millis = window.num_milliseconds();
618        if window <= TimeDelta::zero() || TimeDelta::milliseconds(millis) != window {
619            return Err(SpotMarginConfigError::BorrowPriceWindowInvalid(window));
620        }
621        self.0.borrow_price_window_ms = Some(millis);
622        Self::try_from(self.0)
623    }
624
625    /// How far back the venue looks at each asset's price when it decides how much it lends, or
626    /// `None` if it values each at its current price. See
627    /// [`with_borrow_price_window`](Self::with_borrow_price_window).
628    pub fn borrow_price_window(&self) -> Option<TimeDelta> {
629        // Validated positive, so within what a `TimeDelta` holds.
630        self.0.borrow_price_window_ms.map(TimeDelta::milliseconds)
631    }
632
633    /// This configuration, liquidating the account as `liquidation` says. Replaces any
634    /// liquidation it had.
635    ///
636    /// # Errors
637    /// Returns [`SpotMarginConfigError::LiquidationWithinLeverage`] if a borrow the leverage limit
638    /// allows could leave the account at the liquidation level: if `max_leverage` is above one
639    /// and the level is at least `max_leverage / (max_leverage − 1)`, the margin level of an
640    /// account borrowed to the limit.
641    pub fn with_liquidation(
642        mut self,
643        liquidation: SpotMarginLiquidation,
644    ) -> Result<Self, SpotMarginConfigError> {
645        self.0.liquidation = Some(liquidation);
646        Self::try_from(self.0)
647    }
648
649    /// When the venue liquidates the account, or `None` if it never does. See
650    /// [`with_liquidation`](Self::with_liquidation).
651    pub fn liquidation(&self) -> Option<SpotMarginLiquidation> {
652        self.0.liquidation
653    }
654}
655
656impl TryFrom<SpotMarginRepr> for SpotMarginConfig {
657    type Error = SpotMarginConfigError;
658
659    fn try_from(mut repr: SpotMarginRepr) -> Result<Self, Self::Error> {
660        if repr.max_leverage < Decimal::ONE {
661            return Err(SpotMarginConfigError::LeverageBelowOne(repr.max_leverage));
662        }
663        if let Some((asset, min_borrow)) = repr
664            .min_borrow
665            .iter()
666            .find(|(_, min_borrow)| **min_borrow < Decimal::ZERO)
667        {
668            return Err(SpotMarginConfigError::NegativeMinBorrow {
669                asset: asset.clone(),
670                min_borrow: *min_borrow,
671            });
672        }
673        if let Some((asset, max_borrow)) = repr
674            .max_borrow
675            .iter()
676            .find(|(_, max_borrow)| **max_borrow < Decimal::ZERO)
677        {
678            return Err(SpotMarginConfigError::NegativeMaxBorrow {
679                asset: asset.clone(),
680                max_borrow: *max_borrow,
681            });
682        }
683        if let Some((asset, ratio)) = repr
684            .borrow_collateral_ratio
685            .iter()
686            .chain(&repr.liquidation_collateral_ratio)
687            .find(|(_, ratio)| !(Decimal::ZERO..=Decimal::ONE).contains(*ratio))
688        {
689            return Err(SpotMarginConfigError::CollateralRatioOutOfRange {
690                asset: asset.clone(),
691                ratio: *ratio,
692            });
693        }
694        let ratio_of = |ratios: &BTreeMap<AssetNameExchange, Decimal>, asset| {
695            ratios.get(asset).copied().unwrap_or(Decimal::ONE)
696        };
697        if let Some(asset) = repr
698            .borrow_collateral_ratio
699            .keys()
700            .chain(repr.liquidation_collateral_ratio.keys())
701            .find(|asset| {
702                ratio_of(&repr.liquidation_collateral_ratio, *asset)
703                    < ratio_of(&repr.borrow_collateral_ratio, *asset)
704            })
705        {
706            return Err(SpotMarginConfigError::LiquidationRatioBelowBorrowRatio {
707                asset: asset.clone(),
708                borrow: ratio_of(&repr.borrow_collateral_ratio, asset),
709                liquidation: ratio_of(&repr.liquidation_collateral_ratio, asset),
710            });
711        }
712        if let Some(millis) = repr.borrow_price_window_ms.filter(|millis| *millis <= 0) {
713            // Only `i64::MIN` milliseconds is beyond a `TimeDelta`, whose range is symmetric; it
714            // is reported as the most negative one there is.
715            let window = TimeDelta::try_milliseconds(millis).unwrap_or(TimeDelta::MIN);
716            return Err(SpotMarginConfigError::BorrowPriceWindowInvalid(window));
717        }
718        if let Some(liquidation) = &repr.liquidation {
719            // An account borrowed to the limit has `debt = (max_leverage − 1) × equity`, so a
720            // margin level of `max_leverage / (max_leverage − 1)`. One lending nothing, at a
721            // `max_leverage` of one, has no limit to reach, and passes: the product is zero.
722            if liquidation.level() * (repr.max_leverage - Decimal::ONE) >= repr.max_leverage {
723                return Err(SpotMarginConfigError::LiquidationWithinLeverage {
724                    level: liquidation.level(),
725                    max_leverage: repr.max_leverage,
726                });
727            }
728        }
729        // A zero minimum is no minimum, so it is not kept: two configurations that lend alike
730        // compare equal.
731        repr.min_borrow
732            .retain(|_, min_borrow| !min_borrow.is_zero());
733        // Likewise a ratio of one, which counts the asset in full.
734        repr.borrow_collateral_ratio
735            .retain(|_, ratio| *ratio != Decimal::ONE);
736        repr.liquidation_collateral_ratio
737            .retain(|_, ratio| *ratio != Decimal::ONE);
738        Ok(Self(repr))
739    }
740}
741
742impl From<SpotMarginConfig> for SpotMarginRepr {
743    fn from(config: SpotMarginConfig) -> Self {
744        config.0
745    }
746}
747
748/// Why a [`SpotMarginConfig`] was refused.
749#[derive(Debug, Clone, PartialEq, Eq, Error)]
750#[non_exhaustive]
751pub enum SpotMarginConfigError {
752    /// A `max_leverage` below one, which would allow less than the account's own equity.
753    #[error("spot margin max_leverage {0} is below 1; a leverage of 1 lends nothing")]
754    LeverageBelowOne(Decimal),
755    /// A negative minimum borrow for `asset`.
756    #[error("spot margin min_borrow {min_borrow} of {asset} is negative")]
757    #[non_exhaustive]
758    NegativeMinBorrow {
759        /// The asset given the minimum.
760        asset: AssetNameExchange,
761        /// The minimum given, below zero.
762        min_borrow: Decimal,
763    },
764    /// A negative borrow cap for `asset`.
765    #[error("spot margin max_borrow {max_borrow} of {asset} is negative")]
766    #[non_exhaustive]
767    NegativeMaxBorrow {
768        /// The asset given the cap.
769        asset: AssetNameExchange,
770        /// The cap given, below zero.
771        max_borrow: Decimal,
772    },
773    /// A collateral ratio for `asset` outside `[0, 1]`.
774    #[error("spot margin collateral ratio {ratio} of {asset} is outside [0, 1]")]
775    #[non_exhaustive]
776    CollateralRatioOutOfRange {
777        /// The asset given the ratio.
778        asset: AssetNameExchange,
779        /// The ratio given.
780        ratio: Decimal,
781    },
782    /// A liquidation collateral ratio for `asset` below its borrow collateral ratio, which would
783    /// count the asset for less when liquidating than when lending.
784    #[error(
785        "spot margin liquidation collateral ratio {liquidation} of {asset} is below its borrow \
786         collateral ratio {borrow}"
787    )]
788    #[non_exhaustive]
789    LiquidationRatioBelowBorrowRatio {
790        /// The asset whose ratios disagree.
791        asset: AssetNameExchange,
792        /// Its borrow collateral ratio.
793        borrow: Decimal,
794        /// Its liquidation collateral ratio, below `borrow`.
795        liquidation: Decimal,
796    },
797    /// A borrow price window that is not a positive whole number of milliseconds, the unit it is
798    /// serialised in.
799    #[error("spot margin borrow price window {0} is not a positive whole number of milliseconds")]
800    BorrowPriceWindowInvalid(TimeDelta),
801    /// A liquidation level below one, at which an account would already owe more than it holds.
802    #[error("spot margin liquidation level {0} is below 1")]
803    LiquidationLevelBelowOne(Decimal),
804    /// A liquidation fee rate outside `[0, 1)`.
805    #[error("spot margin liquidation fee rate {0} is outside [0, 1)")]
806    LiquidationFeeOutOfRange(Decimal),
807    /// A margin-call level not above the liquidation level, so it could never be reached first.
808    #[error(
809        "spot margin margin-call level {margin_call} is not above the liquidation level {level}"
810    )]
811    #[non_exhaustive]
812    MarginCallNotAboveLiquidation {
813        /// The margin-call level given.
814        margin_call: Decimal,
815        /// The liquidation level it must be above.
816        level: Decimal,
817    },
818    /// A liquidation level an account borrowed to the leverage limit would already be at.
819    #[error(
820        "spot margin liquidation level {level} is reached by a borrow max_leverage {max_leverage} \
821         allows; it must be below max_leverage / (max_leverage - 1)"
822    )]
823    #[non_exhaustive]
824    LiquidationWithinLeverage {
825        /// The liquidation level given.
826        level: Decimal,
827        /// The configuration's leverage limit.
828        max_leverage: Decimal,
829    },
830}
831
832/// When the simulated venue liquidates a spot margin account, what it charges for it, and whether
833/// it warns first. Set by [`SpotMarginConfig::with_liquidation`].
834///
835/// # When
836/// The venue checks the account's **margin level** after each of its inputs, a market tick or a
837/// move of its clock, since those are what change it: prices move, and interest falls due. The
838/// level is the value of everything the account holds over the value of what it owes, loans plus
839/// interest, both valued as the leverage limit values them (see [`SpotMarginConfig`]'s
840/// `# How much it lends`): at the mid of a spot instrument pairing each asset with the valuation
841/// asset, else its last price. What it holds of an asset beyond what it owes of it counts at the
842/// asset's [`liquidation_collateral_ratio`](SpotMarginConfig::liquidation_collateral_ratio), one
843/// by default (see [`SpotMarginConfig`]'s `# Collateral ratios`). There is no index price, so a
844/// market's own mid stands in for the one a venue liquidates on. An asset the venue cannot value counts for nothing, which can bring
845/// a liquidation forward but never put one off. While it cannot value a debt it does not check,
846/// and warns once.
847///
848/// At or below [`level`](Self::level) it liquidates.
849///
850/// # How
851/// It sends an [`AccountNotice`](crate::notice::AccountNotice) of
852/// [`NoticeKind::Liquidation`](crate::notice::NoticeKind::Liquidation), then closes every debt:
853/// 1. It cancels every open order, resting or held, releasing what each held.
854/// 2. It repays each debt from the asset's own free balance, interest first.
855/// 3. It repays what is still owed of the valuation asset with the proceeds of selling the
856///    account's other assets, each on a spot instrument pairing it with the valuation asset, in
857///    name order, as much of each as is needed.
858/// 4. It buys back what is still owed of each other asset, in name order, on a spot instrument
859///    pairing it with the valuation asset, selling other assets for the valuation asset first as
860///    in step 3 when the account holds too little of it. What a buy delivers repays the debt.
861/// 5. It charges the fee: [`fee_rate`](Self::fee_rate) times the value of the debt this repaid,
862///    in the valuation asset, raised as in step 3 if the account holds too little of it.
863///
864/// Each forced trade fills as a market order would on arrival, as a taker: priced by the venue's
865/// fill model at its current market, slipped, limited by the depth on offer, and charged the
866/// venue's fee. It is reported as `[balance.., trade]`, the [`Trade`](crate::trade::Trade)
867/// carrying [`TradeOrigin::Liquidation`](crate::trade::TradeOrigin::Liquidation), no client order
868/// id and an unknown strategy, with no order snapshot, since no client placed it. It is never
869/// held for a bar or a fresh market. Once priced it has drawn from the venue's slippage stream,
870/// even if it then trades nothing, so a liquidation that runs over several inputs draws on each.
871/// The fee is reported as `[balance, cash flow]`, a
872/// [`CashFlowKind::LiquidationFee`](crate::cash_flow::CashFlowKind::LiquidationFee) with no
873/// instrument.
874///
875/// # When it cannot close every debt
876/// A debt can outlast an input when the depth on offer is too thin, when an asset has no market
877/// to trade on, or when the account holds too little to repay it. The venue then warns once, and
878/// leaves the rest owed: it has no insurance fund, and nothing floors the account's balances.
879///
880/// The liquidation continues on each later input until no debt is left, without another notice:
881/// each input cancels whatever the account has since placed, repays what it can, and charges the
882/// fee on what it repaid.
883///
884/// Each pass's fee is charged out of the account's remaining assets after that pass's
885/// repayments, selling for it as in step 3, as a venue takes its fee out of the proceeds of the
886/// liquidation's trades. So a debt a pass leaves owed has that much less to be repaid from on the
887/// next pass. A fee the account cannot pay takes the valuation asset's free balance below zero.
888///
889/// While the liquidation is under way the venue lends nothing. An order that would need a loan is
890/// rejected as unaffordable, its reason showing nothing borrowable, and so is the fill of a held
891/// order that would need one. One the account can pay for from its own balance is accepted, and
892/// cancelled by the next input if it is still open. A debt the venue cannot trade out of, for want
893/// of a market or of anything left to sell, keeps lending off until the account repays it.
894///
895/// # Margin call
896/// Off by default. With [`with_margin_call`](Self::with_margin_call), the venue sends
897/// [`NoticeKind::MarginCall`](crate::notice::NoticeKind::MarginCall) when the margin level falls
898/// to or below that level, and
899/// [`NoticeKind::MarginRestored`](crate::notice::NoticeKind::MarginRestored) when it rises back
900/// above it or the account no longer owes anything, once per crossing. A liquidation ends a margin
901/// call without a notice.
902///
903/// Each notice carries the margin level that caused it, `None` for an account that owes nothing,
904/// and its kind's name as its status.
905///
906/// # Not liquidated
907/// A CFD position: its margin is posted from the quote asset, outside the spot margin account.
908///
909/// # Serialised form
910/// `level`, `fee_rate` and `margin_call_level` are decimal strings, and `margin_call_level` is
911/// left out when off. Deserialising refuses what the constructors refuse.
912#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
913#[serde(
914    try_from = "SpotMarginLiquidationRepr",
915    into = "SpotMarginLiquidationRepr"
916)]
917pub struct SpotMarginLiquidation(SpotMarginLiquidationRepr);
918
919/// The serialised form of a [`SpotMarginLiquidation`], before validation.
920#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
921struct SpotMarginLiquidationRepr {
922    #[serde(with = "rust_decimal::serde::str")]
923    level: Decimal,
924    #[serde(with = "rust_decimal::serde::str")]
925    fee_rate: Decimal,
926    #[serde(
927        default,
928        with = "rust_decimal::serde::str_option",
929        skip_serializing_if = "Option::is_none"
930    )]
931    margin_call_level: Option<Decimal>,
932}
933
934impl SpotMarginLiquidation {
935    /// Liquidation at a margin level of `level` or below, charging `fee_rate` of the debt it
936    /// repays, with no margin call.
937    ///
938    /// For example, a level of 1.1 and a fee rate of 0.02 liquidate an account whose holdings are
939    /// worth 1.1 times its debt or less, and charge 2 % of what it repays.
940    ///
941    /// # Errors
942    /// [`SpotMarginConfigError::LiquidationLevelBelowOne`] for a `level` below one, and
943    /// [`SpotMarginConfigError::LiquidationFeeOutOfRange`] for a `fee_rate` below zero or not
944    /// below one.
945    pub fn new(level: Decimal, fee_rate: Decimal) -> Result<Self, SpotMarginConfigError> {
946        Self::try_from(SpotMarginLiquidationRepr {
947            level,
948            fee_rate,
949            margin_call_level: None,
950        })
951    }
952
953    /// This liquidation, with a margin call at `margin_call_level`. See `# Margin call`.
954    ///
955    /// # Errors
956    /// [`SpotMarginConfigError::MarginCallNotAboveLiquidation`] if `margin_call_level` is not
957    /// above the liquidation [`level`](Self::level).
958    pub fn with_margin_call(
959        mut self,
960        margin_call_level: Decimal,
961    ) -> Result<Self, SpotMarginConfigError> {
962        self.0.margin_call_level = Some(margin_call_level);
963        Self::try_from(self.0)
964    }
965
966    /// The margin level at or below which the venue liquidates.
967    pub fn level(&self) -> Decimal {
968        self.0.level
969    }
970
971    /// The fee, as a fraction of the value of the debt a liquidation repays.
972    pub fn fee_rate(&self) -> Decimal {
973        self.0.fee_rate
974    }
975
976    /// The margin level at or below which the venue sends a margin call, or `None` for none.
977    pub fn margin_call_level(&self) -> Option<Decimal> {
978        self.0.margin_call_level
979    }
980}
981
982impl TryFrom<SpotMarginLiquidationRepr> for SpotMarginLiquidation {
983    type Error = SpotMarginConfigError;
984
985    fn try_from(repr: SpotMarginLiquidationRepr) -> Result<Self, Self::Error> {
986        if repr.level < Decimal::ONE {
987            return Err(SpotMarginConfigError::LiquidationLevelBelowOne(repr.level));
988        }
989        if repr.fee_rate < Decimal::ZERO || repr.fee_rate >= Decimal::ONE {
990            return Err(SpotMarginConfigError::LiquidationFeeOutOfRange(
991                repr.fee_rate,
992            ));
993        }
994        if let Some(margin_call) = repr.margin_call_level
995            && margin_call <= repr.level
996        {
997            return Err(SpotMarginConfigError::MarginCallNotAboveLiquidation {
998                margin_call,
999                level: repr.level,
1000            });
1001        }
1002        Ok(Self(repr))
1003    }
1004}
1005
1006impl From<SpotMarginLiquidation> for SpotMarginLiquidationRepr {
1007    fn from(liquidation: SpotMarginLiquidation) -> Self {
1008        liquidation.0
1009    }
1010}
1011
1012/// Charges interest on a simulated margin loan: once when the loan is taken, and at boundaries in
1013/// time while it stays open.
1014///
1015/// The simulated venue asks for a charge on each loan its spot margin takes
1016/// ([`InterestDue::Borrow`], on the amount just lent), and, through
1017/// [`next_boundary`](Self::next_boundary), at each boundary its clock reaches while the account
1018/// owes the asset ([`InterestDue::Boundary`], on the principal owed when it falls). Each charge is
1019/// added to the asset's loan as interest, which the account repays before principal, and is
1020/// reported as a [`CashFlowKind::MarginInterest`](crate::cash_flow::CashFlowKind::MarginInterest)
1021/// in the borrowed asset.
1022///
1023/// Interest is charged on principal only: interest already owed earns none.
1024///
1025/// # Contract
1026/// - `next_boundary` must return an instant strictly after `after`, or `None` when the model
1027///   never charges the asset at a boundary. A boundary at or before `after` would charge one
1028///   instant forever, so the venue **panics** on one.
1029/// - A charge's [`interest`](InterestCharge::interest) must not be negative: interest is owed,
1030///   never paid out. The venue **panics** on a negative one.
1031/// - Both methods must be pure functions of their arguments: the venue may ask for the same
1032///   boundary more than once, and charges it once.
1033///
1034/// In return, the venue asks [`charge`](Self::charge) only about a positive `principal`: an order
1035/// that lends nothing, and an asset that owes nothing, is never charged.
1036///
1037/// `Send + Sync` because a venue holds its model behind an `Arc`, so that one model, and the
1038/// rate history it carries, can be shared by every run of a backtest sweep without being copied.
1039pub trait MarginInterestModel: Debug + Send + Sync {
1040    /// The first boundary strictly after `after` at which this model charges a loan of `asset`,
1041    /// or `None` if it never does.
1042    fn next_boundary(
1043        &self,
1044        asset: &AssetNameExchange,
1045        after: DateTime<Utc>,
1046    ) -> Option<DateTime<Utc>>;
1047
1048    /// What a loan of `principal` of `asset` is charged when `due`, or `None` for nothing.
1049    fn charge(
1050        &self,
1051        asset: &AssetNameExchange,
1052        principal: Decimal,
1053        due: InterestDue,
1054    ) -> Option<InterestCharge>;
1055}
1056
1057/// When a [`MarginInterestModel`] is asked to charge a loan.
1058#[non_exhaustive]
1059#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1060pub enum InterestDue {
1061    /// The loan was taken at this instant, and the principal is the amount just lent.
1062    Borrow(DateTime<Utc>),
1063    /// A boundary the model named, and the principal is what the account owed when it fell.
1064    Boundary(DateTime<Utc>),
1065}
1066
1067impl InterestDue {
1068    /// The instant the charge is due.
1069    pub fn time(self) -> DateTime<Utc> {
1070        match self {
1071            Self::Borrow(time) | Self::Boundary(time) => time,
1072        }
1073    }
1074}
1075
1076/// One charge a [`MarginInterestModel`] makes on one loan.
1077#[non_exhaustive]
1078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1079pub struct InterestCharge {
1080    /// The interest owed, in the borrowed asset. Never negative; zero charges nothing.
1081    pub interest: Decimal,
1082    /// The rate it was charged at, reported as the cash flow's `rate` and used for nothing else,
1083    /// so a model may quote it annualised or per its own period. [`PeriodicMarginInterest`]
1084    /// reports the annualised rate.
1085    pub rate: Option<Decimal>,
1086}
1087
1088impl InterestCharge {
1089    /// A charge of `interest`, in the borrowed asset, at `rate`.
1090    pub fn new(interest: Decimal, rate: Option<Decimal>) -> Self {
1091        Self { interest, rate }
1092    }
1093}
1094
1095/// When a [`PeriodicMarginInterest`] charges a loan.
1096#[derive(
1097    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
1098)]
1099#[serde(rename_all = "snake_case")]
1100#[non_exhaustive]
1101pub enum InterestSchedule {
1102    /// One period when the loan is taken, then one at every boundary while it is open.
1103    #[default]
1104    AtBorrowAndEachBoundary,
1105    /// One period when the loan is taken, and nothing afterwards however long it is held.
1106    AtBorrowOnly,
1107}
1108
1109/// Margin interest charged a period at a time, at an annualised rate per asset: a crypto margin
1110/// account's hourly interest.
1111///
1112/// - **When.** One period when a loan is taken, whenever in the period that is, then one at every
1113///   boundary while the loan is open, unless [`InterestSchedule::AtBorrowOnly`]. Boundaries fall
1114///   at whole multiples of the period since the Unix epoch, so an hourly period charges on the
1115///   hour, UTC. Each charge pays in advance for the period it falls in: the one a borrow is taken
1116///   in, or the one a boundary begins. So a loan repaid mid-period has already paid for that
1117///   period, and one repaid just before a boundary is not charged there.
1118/// - **How much.** `principal × rate × period / basis`, where the rate is annualised, read from
1119///   the asset's [`RateSeries`] at the instant charged, and the basis is the [`DayCount`]'s year.
1120///   Each charge is rounded **up** to the asset's unit, `1e-8` unless set by
1121///   [`with_unit`](Self::with_unit), so a small loan is charged at least one unit, as a venue
1122///   that books interest in whole units charges it.
1123/// - **Which assets.** Only those given rates. A loan of any other asset, or one charged before its
1124///   series begins, is charged nothing.
1125///
1126/// A venue that quotes a daily rate is reproduced by an annual rate of `daily × 365` on
1127/// [`DayCount::Act365`].
1128#[derive(Debug, Clone)]
1129pub struct PeriodicMarginInterest {
1130    period: TimeDelta,
1131    day_count: DayCount,
1132    schedule: InterestSchedule,
1133    rates: FnvHashMap<AssetNameExchange, RateSeries>,
1134    units: FnvHashMap<AssetNameExchange, Decimal>,
1135}
1136
1137/// The unit a [`PeriodicMarginInterest`] rounds an asset's charge up to unless told otherwise: one
1138/// satoshi, the unit crypto venues book interest in.
1139const DEFAULT_INTEREST_UNIT: Decimal = Decimal::from_parts(1, 0, 0, false, 8);
1140
1141impl PeriodicMarginInterest {
1142    /// Interest charged every `period` on the `day_count` basis, at borrow and each boundary, with
1143    /// no asset's rates yet.
1144    ///
1145    /// # Panics
1146    /// If `period` is not positive.
1147    pub fn new(period: TimeDelta, day_count: DayCount) -> Self {
1148        assert!(
1149            period > TimeDelta::zero(),
1150            "PeriodicMarginInterest period must be positive, got {period}"
1151        );
1152        Self {
1153            period,
1154            day_count,
1155            schedule: InterestSchedule::default(),
1156            rates: FnvHashMap::default(),
1157            units: FnvHashMap::default(),
1158        }
1159    }
1160
1161    /// Charges a loan of `asset` at the annualised rates in `rates`.
1162    ///
1163    /// # Panics
1164    /// If any rate in `rates` is negative: a borrower is never paid interest, so a negative rate is
1165    /// a fault in the data, refused here rather than when the venue first charges it.
1166    #[must_use]
1167    pub fn with_rates(mut self, asset: AssetNameExchange, rates: RateSeries) -> Self {
1168        if let Some(rate) = rates.rates().find(|rate| *rate < Decimal::ZERO) {
1169            panic!("PeriodicMarginInterest rate for {asset} must not be negative, got {rate}");
1170        }
1171        self.rates.insert(asset, rates);
1172        self
1173    }
1174
1175    /// Rounds each charge on `asset` up to a multiple of `unit` instead of `1e-8`.
1176    ///
1177    /// # Panics
1178    /// If `unit` is not positive.
1179    #[must_use]
1180    pub fn with_unit(mut self, asset: AssetNameExchange, unit: Decimal) -> Self {
1181        assert!(
1182            unit > Decimal::ZERO,
1183            "PeriodicMarginInterest unit for {asset} must be positive, got {unit}"
1184        );
1185        self.units.insert(asset, unit);
1186        self
1187    }
1188
1189    /// Charges on `schedule` instead of at borrow and each boundary.
1190    #[must_use]
1191    pub fn with_schedule(mut self, schedule: InterestSchedule) -> Self {
1192        self.schedule = schedule;
1193        self
1194    }
1195}
1196
1197impl MarginInterestModel for PeriodicMarginInterest {
1198    fn next_boundary(
1199        &self,
1200        asset: &AssetNameExchange,
1201        after: DateTime<Utc>,
1202    ) -> Option<DateTime<Utc>> {
1203        match self.schedule {
1204            InterestSchedule::AtBorrowOnly => None,
1205            InterestSchedule::AtBorrowAndEachBoundary if !self.rates.contains_key(asset) => None,
1206            InterestSchedule::AtBorrowAndEachBoundary => next_whole_interval(self.period, after),
1207        }
1208    }
1209
1210    fn charge(
1211        &self,
1212        asset: &AssetNameExchange,
1213        principal: Decimal,
1214        due: InterestDue,
1215    ) -> Option<InterestCharge> {
1216        let rate = self.rates.get(asset)?.at(due.time())?;
1217        let unit = self
1218            .units
1219            .get(asset)
1220            .copied()
1221            .unwrap_or(DEFAULT_INTEREST_UNIT);
1222        let exact = self.day_count.accrue_span(principal * rate, self.period);
1223
1224        Some(InterestCharge::new(
1225            (exact / unit).ceil() * unit,
1226            Some(rate),
1227        ))
1228    }
1229}
1230
1231#[cfg(test)]
1232#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
1233mod tests {
1234    use super::*;
1235    use rust_decimal_macros::dec;
1236
1237    /// A placement's loan is its order's, at its state's time, when the answer reports a loan and
1238    /// the state names a venue order id, and none otherwise.
1239    #[test]
1240    fn a_placement_s_loan_is_its_order_s_when_its_state_names_a_venue_id() {
1241        use crate::{
1242            error::{ApiError, OrderError},
1243            order::{
1244                OrderKey, OrderKind, TimeInForce,
1245                id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
1246                state::{Cancelled, Expired, Filled, Open, OpenInFlight},
1247            },
1248        };
1249        use chrono::TimeZone;
1250        use rustrade_instrument::Side;
1251
1252        let time = Utc.with_ymd_and_hms(2026, 11, 2, 10, 5, 0).unwrap();
1253        let id = OrderId::new("7");
1254        let borrowed = MarginBorrow::new(AssetNameExchange::new("USDT"), dec!(6.23292076));
1255        let placement =
1256            |state: UnindexedOrderState, borrowed: Option<MarginBorrow>| MarginPlacement {
1257                order: Order {
1258                    key: OrderKey {
1259                        exchange: ExchangeId::BinanceMargin,
1260                        instrument: InstrumentNameExchange::new("BTCUSDT"),
1261                        strategy: StrategyId::new("strategy"),
1262                        cid: ClientOrderId::new("borrow"),
1263                    },
1264                    side: Side::Buy,
1265                    price: None,
1266                    quantity: dec!(1),
1267                    kind: OrderKind::Market,
1268                    time_in_force: TimeInForce::ImmediateOrCancel,
1269                    state,
1270                },
1271                borrowed,
1272            };
1273        let loan = Some(OrderLoan::new(
1274            InstrumentNameExchange::new("BTCUSDT"),
1275            borrowed.clone(),
1276            CashFlowLoan::new(time, id.clone(), ClientOrderId::new("borrow")),
1277        ));
1278
1279        for (case, state, expected) in [
1280            (
1281                "open",
1282                OrderState::active(Open::new(
1283                    VenueOrderId::Assigned(id.clone()),
1284                    time,
1285                    Decimal::ZERO,
1286                )),
1287                loan.clone(),
1288            ),
1289            (
1290                "filled",
1291                OrderState::fully_filled(Filled::new(id.clone(), time, dec!(1), None)),
1292                loan.clone(),
1293            ),
1294            (
1295                "cancelled",
1296                OrderState::inactive(Cancelled::new(id.clone(), time, None)),
1297                loan.clone(),
1298            ),
1299            (
1300                "expired",
1301                OrderState::expired(Expired::new(id.clone(), time, None)),
1302                loan.clone(),
1303            ),
1304            (
1305                "open without a venue id",
1306                OrderState::active(Open::new(VenueOrderId::ClientAssigned, time, Decimal::ZERO)),
1307                None,
1308            ),
1309            (
1310                "in flight",
1311                OrderState::active(OpenInFlight::new(time)),
1312                None,
1313            ),
1314            (
1315                "refused",
1316                OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1317                    "refused".to_owned(),
1318                ))),
1319                None,
1320            ),
1321        ] {
1322            assert_eq!(
1323                placement(state.clone(), Some(borrowed.clone())).loan(),
1324                expected,
1325                "{case}"
1326            );
1327            assert_eq!(
1328                placement(state, None).loan(),
1329                None,
1330                "{case}, no loan reported"
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn a_leverage_below_one_is_refused_by_the_constructor_and_by_deserialising() {
1337        assert_eq!(
1338            SpotMarginConfig::new(
1339                AssetNameExchange::new("usdt"),
1340                dec!(0.5),
1341                MarginEffect::NoSideEffect
1342            ),
1343            Err(SpotMarginConfigError::LeverageBelowOne(dec!(0.5)))
1344        );
1345
1346        let json = r#"{"valuation_asset":"usdt","max_leverage":"0.5","margin_effect":"borrow"}"#;
1347        assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
1348    }
1349
1350    #[test]
1351    fn the_margin_effect_has_no_default_and_a_retired_side_effect_is_refused() {
1352        let omitted = r#"{"valuation_asset":"usdt","max_leverage":"3"}"#;
1353        assert!(serde_json::from_str::<SpotMarginConfig>(omitted).is_err());
1354
1355        // The retired key alone is no substitute for the effect.
1356        let retired =
1357            r#"{"valuation_asset":"usdt","max_leverage":"3","side_effect":"auto_borrow_repay"}"#;
1358        assert!(serde_json::from_str::<SpotMarginConfig>(retired).is_err());
1359    }
1360
1361    #[test]
1362    fn auto_repay_at_cancel_defaults_to_true_when_omitted() {
1363        let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"repay"}"#;
1364        let config: SpotMarginConfig = serde_json::from_str(json).unwrap();
1365
1366        assert_eq!(config.margin_effect(), MarginEffect::Repay);
1367        assert!(config.auto_repay_at_cancel());
1368        assert_eq!(config.max_leverage(), dec!(3));
1369        assert_eq!(config.valuation_asset(), &AssetNameExchange::new("usdt"));
1370    }
1371
1372    #[test]
1373    fn a_minimum_borrow_is_per_asset_and_zero_where_none_is_set() {
1374        let config = SpotMarginConfig::new(
1375            AssetNameExchange::new("usdt"),
1376            dec!(3),
1377            MarginEffect::Borrow,
1378        )
1379        .unwrap()
1380        .with_min_borrow(AssetNameExchange::new("btc"), dec!(0.00006))
1381        .unwrap();
1382
1383        assert_eq!(
1384            config.min_borrow(&AssetNameExchange::new("btc")),
1385            dec!(0.00006)
1386        );
1387        assert_eq!(
1388            config.min_borrow(&AssetNameExchange::new("usdt")),
1389            Decimal::ZERO
1390        );
1391    }
1392
1393    #[test]
1394    fn a_negative_minimum_borrow_is_refused_by_the_builder_and_by_deserialising() {
1395        let config = SpotMarginConfig::new(
1396            AssetNameExchange::new("usdt"),
1397            dec!(3),
1398            MarginEffect::Borrow,
1399        )
1400        .unwrap();
1401        assert_eq!(
1402            config.with_min_borrow(AssetNameExchange::new("btc"), dec!(-0.1)),
1403            Err(SpotMarginConfigError::NegativeMinBorrow {
1404                asset: AssetNameExchange::new("btc"),
1405                min_borrow: dec!(-0.1),
1406            })
1407        );
1408
1409        let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","min_borrow":{"btc":"-0.1"}}"#;
1410        assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
1411    }
1412
1413    #[test]
1414    fn a_zero_minimum_borrow_removes_the_minimum() {
1415        let plain = SpotMarginConfig::new(
1416            AssetNameExchange::new("usdt"),
1417            dec!(3),
1418            MarginEffect::Borrow,
1419        )
1420        .unwrap();
1421        let cleared = plain
1422            .clone()
1423            .with_min_borrow(AssetNameExchange::new("btc"), dec!(5))
1424            .unwrap()
1425            .with_min_borrow(AssetNameExchange::new("btc"), Decimal::ZERO)
1426            .unwrap();
1427
1428        assert_eq!(cleared, plain);
1429        assert_eq!(
1430            cleared.min_borrow(&AssetNameExchange::new("btc")),
1431            Decimal::ZERO
1432        );
1433    }
1434
1435    #[test]
1436    fn a_minimum_borrow_round_trips_as_a_map_of_decimal_strings() {
1437        let config = SpotMarginConfig::new(
1438            AssetNameExchange::new("usdt"),
1439            dec!(3),
1440            MarginEffect::Borrow,
1441        )
1442        .unwrap()
1443        .with_min_borrow(AssetNameExchange::new("btc"), dec!(0.00006))
1444        .unwrap();
1445
1446        let json = serde_json::to_string(&config).unwrap();
1447        assert_eq!(
1448            json,
1449            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"min_borrow":{"btc":"0.00006"}}"#
1450        );
1451        assert_eq!(
1452            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
1453            config
1454        );
1455    }
1456
1457    fn usdt_margin() -> SpotMarginConfig {
1458        SpotMarginConfig::new(
1459            AssetNameExchange::new("usdt"),
1460            dec!(3),
1461            MarginEffect::Borrow,
1462        )
1463        .unwrap()
1464    }
1465
1466    #[test]
1467    fn a_borrow_cap_is_per_asset_and_none_where_none_is_set() {
1468        let config = usdt_margin()
1469            .with_max_borrow(AssetNameExchange::new("btc"), dec!(100))
1470            .unwrap();
1471
1472        assert_eq!(
1473            config.max_borrow(&AssetNameExchange::new("btc")),
1474            Some(dec!(100))
1475        );
1476        assert_eq!(config.max_borrow(&AssetNameExchange::new("usdt")), None);
1477    }
1478
1479    #[test]
1480    fn a_borrow_cap_replaces_the_asset_s_last_and_a_zero_cap_is_kept() {
1481        let config = usdt_margin()
1482            .with_max_borrow(AssetNameExchange::new("btc"), dec!(100))
1483            .unwrap()
1484            .with_max_borrow(AssetNameExchange::new("btc"), Decimal::ZERO)
1485            .unwrap();
1486
1487        assert_eq!(
1488            config.max_borrow(&AssetNameExchange::new("btc")),
1489            Some(Decimal::ZERO),
1490            "a cap of zero lends none of the asset, which no cap would not say"
1491        );
1492        assert_ne!(config, usdt_margin());
1493    }
1494
1495    #[test]
1496    fn a_negative_borrow_cap_is_refused_by_the_builder_and_by_deserialising() {
1497        assert_eq!(
1498            usdt_margin().with_max_borrow(AssetNameExchange::new("btc"), dec!(-0.1)),
1499            Err(SpotMarginConfigError::NegativeMaxBorrow {
1500                asset: AssetNameExchange::new("btc"),
1501                max_borrow: dec!(-0.1),
1502            })
1503        );
1504
1505        let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","max_borrow":{"btc":"-0.1"}}"#;
1506        assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
1507    }
1508
1509    #[test]
1510    fn a_borrow_cap_round_trips_as_a_map_of_decimal_strings_left_out_when_empty() {
1511        let config = usdt_margin()
1512            .with_max_borrow(AssetNameExchange::new("btc"), dec!(150.5))
1513            .unwrap();
1514
1515        let json = serde_json::to_string(&config).unwrap();
1516        assert_eq!(
1517            json,
1518            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"max_borrow":{"btc":"150.5"}}"#
1519        );
1520        assert_eq!(
1521            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
1522            config
1523        );
1524        assert!(
1525            !serde_json::to_string(&usdt_margin())
1526                .unwrap()
1527                .contains("max_borrow")
1528        );
1529    }
1530
1531    #[test]
1532    fn collateral_ratios_are_per_asset_and_one_where_none_is_set() {
1533        let config = usdt_margin()
1534            .with_borrow_collateral_ratio(btc(), dec!(0.95))
1535            .unwrap()
1536            .with_liquidation_collateral_ratio(btc(), dec!(0.97))
1537            .unwrap();
1538
1539        assert_eq!(config.borrow_collateral_ratio(&btc()), dec!(0.95));
1540        assert_eq!(config.liquidation_collateral_ratio(&btc()), dec!(0.97));
1541        let usdt = AssetNameExchange::new("usdt");
1542        assert_eq!(config.borrow_collateral_ratio(&usdt), Decimal::ONE);
1543        assert_eq!(config.liquidation_collateral_ratio(&usdt), Decimal::ONE);
1544    }
1545
1546    #[test]
1547    fn a_ratio_of_one_is_no_ratio_and_zero_is_kept() {
1548        let config = usdt_margin()
1549            .with_borrow_collateral_ratio(btc(), dec!(0.5))
1550            .unwrap()
1551            .with_borrow_collateral_ratio(btc(), dec!(1.0))
1552            .unwrap();
1553        assert_eq!(
1554            config,
1555            usdt_margin(),
1556            "a ratio of one counts the asset in full"
1557        );
1558
1559        let zero = usdt_margin()
1560            .with_borrow_collateral_ratio(btc(), Decimal::ZERO)
1561            .unwrap()
1562            .with_liquidation_collateral_ratio(btc(), Decimal::ZERO)
1563            .unwrap();
1564        assert_eq!(zero.borrow_collateral_ratio(&btc()), Decimal::ZERO);
1565        assert_eq!(zero.liquidation_collateral_ratio(&btc()), Decimal::ZERO);
1566    }
1567
1568    #[test]
1569    fn a_ratio_outside_zero_to_one_is_refused_by_the_builders_and_by_deserialising() {
1570        for ratio in [dec!(-0.01), dec!(1.01)] {
1571            let refused = SpotMarginConfigError::CollateralRatioOutOfRange {
1572                asset: btc(),
1573                ratio,
1574            };
1575            assert_eq!(
1576                usdt_margin().with_borrow_collateral_ratio(btc(), ratio),
1577                Err(refused.clone())
1578            );
1579            assert_eq!(
1580                usdt_margin().with_liquidation_collateral_ratio(btc(), ratio),
1581                Err(refused)
1582            );
1583        }
1584
1585        for key in ["borrow_collateral_ratio", "liquidation_collateral_ratio"] {
1586            let json = format!(
1587                r#"{{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","{key}":{{"btc":"1.5"}}}}"#
1588            );
1589            assert!(
1590                serde_json::from_str::<SpotMarginConfig>(&json).is_err(),
1591                "{key}"
1592            );
1593        }
1594    }
1595
1596    #[test]
1597    fn a_liquidation_ratio_below_the_borrow_ratio_is_refused_whichever_is_set_last() {
1598        let below = SpotMarginConfigError::LiquidationRatioBelowBorrowRatio {
1599            asset: btc(),
1600            borrow: Decimal::ONE,
1601            liquidation: dec!(0.9),
1602        };
1603        assert_eq!(
1604            usdt_margin().with_liquidation_collateral_ratio(btc(), dec!(0.9)),
1605            Err(below),
1606            "the borrow ratio is one until set"
1607        );
1608
1609        let config = usdt_margin()
1610            .with_borrow_collateral_ratio(btc(), dec!(0.9))
1611            .unwrap()
1612            .with_liquidation_collateral_ratio(btc(), dec!(0.9))
1613            .unwrap();
1614        assert_eq!(
1615            config.with_borrow_collateral_ratio(btc(), dec!(0.95)),
1616            Err(SpotMarginConfigError::LiquidationRatioBelowBorrowRatio {
1617                asset: btc(),
1618                borrow: dec!(0.95),
1619                liquidation: dec!(0.9),
1620            })
1621        );
1622
1623        let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","liquidation_collateral_ratio":{"btc":"0.9"}}"#;
1624        assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
1625    }
1626
1627    #[test]
1628    fn with_collateral_ratios_sets_both_where_setting_them_in_turn_fails_in_one_order() {
1629        let low = usdt_margin()
1630            .with_collateral_ratios(btc(), dec!(0.5), dec!(0.6))
1631            .unwrap();
1632        assert_eq!(low.borrow_collateral_ratio(&btc()), dec!(0.5));
1633        assert_eq!(low.liquidation_collateral_ratio(&btc()), dec!(0.6));
1634        assert!(
1635            low.clone()
1636                .with_borrow_collateral_ratio(btc(), dec!(0.8))
1637                .is_err(),
1638            "raising the borrow ratio first passes the old liquidation ratio"
1639        );
1640
1641        let high = low
1642            .with_collateral_ratios(btc(), dec!(0.8), dec!(0.9))
1643            .unwrap();
1644        assert_eq!(high.borrow_collateral_ratio(&btc()), dec!(0.8));
1645        assert_eq!(high.liquidation_collateral_ratio(&btc()), dec!(0.9));
1646        assert!(
1647            high.clone()
1648                .with_liquidation_collateral_ratio(btc(), dec!(0.6))
1649                .is_err(),
1650            "lowering the liquidation ratio first passes the old borrow ratio"
1651        );
1652
1653        let back = high
1654            .with_collateral_ratios(btc(), dec!(0.5), dec!(0.6))
1655            .unwrap();
1656        assert_eq!(back.borrow_collateral_ratio(&btc()), dec!(0.5));
1657        assert_eq!(back.liquidation_collateral_ratio(&btc()), dec!(0.6));
1658        assert_eq!(
1659            back.with_collateral_ratios(btc(), Decimal::ONE, dec!(1.0)),
1660            Ok(usdt_margin()),
1661            "ratios of one count the asset in full"
1662        );
1663    }
1664
1665    #[test]
1666    fn with_collateral_ratios_refuses_what_either_setter_refuses() {
1667        for (borrow, liquidation, ratio) in [
1668            (dec!(-0.01), dec!(0.5), dec!(-0.01)),
1669            (dec!(0.5), dec!(1.01), dec!(1.01)),
1670        ] {
1671            assert_eq!(
1672                usdt_margin().with_collateral_ratios(btc(), borrow, liquidation),
1673                Err(SpotMarginConfigError::CollateralRatioOutOfRange {
1674                    asset: btc(),
1675                    ratio,
1676                })
1677            );
1678        }
1679        assert_eq!(
1680            usdt_margin().with_collateral_ratios(btc(), dec!(0.9), dec!(0.8)),
1681            Err(SpotMarginConfigError::LiquidationRatioBelowBorrowRatio {
1682                asset: btc(),
1683                borrow: dec!(0.9),
1684                liquidation: dec!(0.8),
1685            })
1686        );
1687    }
1688
1689    #[test]
1690    fn collateral_ratios_round_trip_as_maps_of_decimal_strings_left_out_when_empty() {
1691        let config = usdt_margin()
1692            .with_borrow_collateral_ratio(btc(), dec!(0.95))
1693            .unwrap()
1694            .with_borrow_collateral_ratio(AssetNameExchange::new("eth"), dec!(0.8))
1695            .unwrap()
1696            .with_liquidation_collateral_ratio(btc(), dec!(0.99))
1697            .unwrap();
1698
1699        let json = serde_json::to_string(&config).unwrap();
1700        assert_eq!(
1701            json,
1702            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"borrow_collateral_ratio":{"btc":"0.95","eth":"0.8"},"liquidation_collateral_ratio":{"btc":"0.99"}}"#
1703        );
1704        assert_eq!(
1705            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
1706            config
1707        );
1708        let plain = serde_json::to_string(&usdt_margin()).unwrap();
1709        assert!(!plain.contains("collateral_ratio"), "{plain}");
1710    }
1711
1712    #[test]
1713    fn a_borrow_price_window_is_off_by_default_and_replaced_by_the_builder() {
1714        assert_eq!(usdt_margin().borrow_price_window(), None);
1715
1716        let config = usdt_margin()
1717            .with_borrow_price_window(TimeDelta::hours(2))
1718            .unwrap()
1719            .with_borrow_price_window(TimeDelta::milliseconds(1))
1720            .unwrap();
1721
1722        assert_eq!(
1723            config.borrow_price_window(),
1724            Some(TimeDelta::milliseconds(1))
1725        );
1726    }
1727
1728    #[test]
1729    fn a_borrow_price_window_not_a_positive_whole_number_of_milliseconds_is_refused() {
1730        for window in [
1731            TimeDelta::zero(),
1732            TimeDelta::milliseconds(-1),
1733            TimeDelta::microseconds(1),
1734            TimeDelta::microseconds(1_500),
1735            TimeDelta::MIN,
1736        ] {
1737            assert_eq!(
1738                usdt_margin().with_borrow_price_window(window),
1739                Err(SpotMarginConfigError::BorrowPriceWindowInvalid(window)),
1740                "{window}"
1741            );
1742        }
1743        assert!(
1744            usdt_margin()
1745                .with_borrow_price_window(TimeDelta::MAX)
1746                .is_ok()
1747        );
1748
1749        let json = |millis: &str| {
1750            format!(
1751                r#"{{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","borrow_price_window_ms":{millis}}}"#
1752            )
1753        };
1754        for (millis, window) in [
1755            ("0", TimeDelta::zero()),
1756            ("-5", TimeDelta::milliseconds(-5)),
1757            // One past the most negative `TimeDelta`, reported as that.
1758            (&i64::MIN.to_string(), TimeDelta::MIN),
1759        ] {
1760            let refused = serde_json::from_str::<SpotMarginConfig>(&json(millis))
1761                .unwrap_err()
1762                .to_string();
1763            let expected = SpotMarginConfigError::BorrowPriceWindowInvalid(window).to_string();
1764            assert!(refused.contains(&expected), "{millis}: {refused}");
1765        }
1766    }
1767
1768    #[test]
1769    fn a_borrow_price_window_round_trips_as_whole_milliseconds_left_out_when_off() {
1770        let config = usdt_margin()
1771            .with_borrow_price_window(TimeDelta::hours(1))
1772            .unwrap();
1773
1774        let json = serde_json::to_string(&config).unwrap();
1775        assert_eq!(
1776            json,
1777            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"borrow_price_window_ms":3600000}"#
1778        );
1779        assert_eq!(
1780            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
1781            config
1782        );
1783        let plain = serde_json::to_string(&usdt_margin()).unwrap();
1784        assert!(!plain.contains("borrow_price_window"), "{plain}");
1785    }
1786
1787    #[test]
1788    fn a_configuration_round_trips_through_its_serialised_form() {
1789        let config = SpotMarginConfig::new(
1790            AssetNameExchange::new("usdt"),
1791            dec!(3),
1792            MarginEffect::Borrow,
1793        )
1794        .unwrap()
1795        .with_auto_repay_at_cancel(false);
1796
1797        let json = serde_json::to_string(&config).unwrap();
1798        assert_eq!(
1799            json,
1800            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":false}"#
1801        );
1802        assert_eq!(
1803            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
1804            config
1805        );
1806    }
1807
1808    #[test]
1809    fn a_liquidation_round_trips_and_is_left_out_when_off() {
1810        let config = SpotMarginConfig::new(
1811            AssetNameExchange::new("usdt"),
1812            dec!(3),
1813            MarginEffect::Borrow,
1814        )
1815        .unwrap()
1816        .with_liquidation(
1817            SpotMarginLiquidation::new(dec!(1.1), dec!(0.02))
1818                .unwrap()
1819                .with_margin_call(dec!(1.3))
1820                .unwrap(),
1821        )
1822        .unwrap();
1823
1824        let json = serde_json::to_string(&config).unwrap();
1825        assert_eq!(
1826            json,
1827            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"liquidation":{"level":"1.1","fee_rate":"0.02","margin_call_level":"1.3"}}"#
1828        );
1829        assert_eq!(
1830            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
1831            config
1832        );
1833
1834        let without_call = r#"{"level":"1.1","fee_rate":"0"}"#;
1835        let liquidation = serde_json::from_str::<SpotMarginLiquidation>(without_call).unwrap();
1836        assert_eq!(liquidation.margin_call_level(), None);
1837        assert_eq!(serde_json::to_string(&liquidation).unwrap(), without_call);
1838    }
1839
1840    #[test]
1841    fn a_liquidation_out_of_range_is_refused_by_its_constructors_and_by_deserialising() {
1842        assert_eq!(
1843            SpotMarginLiquidation::new(dec!(0.99), dec!(0)),
1844            Err(SpotMarginConfigError::LiquidationLevelBelowOne(dec!(0.99)))
1845        );
1846        assert!(SpotMarginLiquidation::new(dec!(1), dec!(0)).is_ok());
1847        for fee_rate in [dec!(-0.01), dec!(1)] {
1848            assert_eq!(
1849                SpotMarginLiquidation::new(dec!(1.1), fee_rate),
1850                Err(SpotMarginConfigError::LiquidationFeeOutOfRange(fee_rate))
1851            );
1852        }
1853        assert_eq!(
1854            SpotMarginLiquidation::new(dec!(1.1), dec!(0))
1855                .unwrap()
1856                .with_margin_call(dec!(1.1)),
1857            Err(SpotMarginConfigError::MarginCallNotAboveLiquidation {
1858                margin_call: dec!(1.1),
1859                level: dec!(1.1),
1860            })
1861        );
1862
1863        for json in [
1864            r#"{"level":"0.5","fee_rate":"0"}"#,
1865            r#"{"level":"1.1","fee_rate":"1"}"#,
1866            r#"{"level":"1.1","fee_rate":"0","margin_call_level":"1"}"#,
1867        ] {
1868            assert!(
1869                serde_json::from_str::<SpotMarginLiquidation>(json).is_err(),
1870                "{json}"
1871            );
1872        }
1873        let within_leverage = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","liquidation":{"level":"1.5","fee_rate":"0"}}"#;
1874        assert!(serde_json::from_str::<SpotMarginConfig>(within_leverage).is_err());
1875    }
1876
1877    #[test]
1878    fn the_effect_wire_names_are_snake_case_and_the_default_borrows_nothing() {
1879        assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
1880        for (effect, name) in [
1881            (MarginEffect::NoSideEffect, r#""no_side_effect""#),
1882            (MarginEffect::Borrow, r#""borrow""#),
1883            (MarginEffect::Repay, r#""repay""#),
1884        ] {
1885            assert_eq!(serde_json::to_string(&effect).unwrap(), name);
1886            assert_eq!(serde_json::from_str::<MarginEffect>(name).unwrap(), effect);
1887        }
1888    }
1889
1890    fn btc() -> AssetNameExchange {
1891        AssetNameExchange::new("btc")
1892    }
1893
1894    fn usdt() -> AssetNameExchange {
1895        AssetNameExchange::new("usdt")
1896    }
1897
1898    fn utc(text: &str) -> DateTime<Utc> {
1899        text.parse().unwrap()
1900    }
1901
1902    /// Hourly interest on BTC at `annual`, on the Act365 basis.
1903    fn hourly_btc(annual: Decimal) -> PeriodicMarginInterest {
1904        PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1905            .with_rates(btc(), RateSeries::constant(annual))
1906    }
1907
1908    #[test]
1909    fn a_small_loan_is_charged_one_unit_for_its_first_period() {
1910        // 0.00063 BTC at 0.39% a year is about 2.8e-10 an hour, rounded up to one satoshi.
1911        let model = hourly_btc(dec!(0.0039));
1912
1913        let charge = model.charge(
1914            &btc(),
1915            dec!(0.00063),
1916            InterestDue::Borrow(utc("2026-10-15T01:02:33Z")),
1917        );
1918
1919        assert_eq!(
1920            charge,
1921            Some(InterestCharge::new(dec!(0.00000001), Some(dec!(0.0039))))
1922        );
1923    }
1924
1925    #[test]
1926    fn a_daily_rate_times_365_on_act365_charges_a_24th_of_it_an_hour_without_rounding_up() {
1927        // 0.01% a day on 24 BTC is 0.0024 a day, exactly 0.0001 an hour.
1928        let model = hourly_btc(dec!(0.0001) * dec!(365));
1929
1930        let charge = model
1931            .charge(
1932                &btc(),
1933                dec!(24),
1934                InterestDue::Boundary(utc("2026-10-15T02:00:00Z")),
1935            )
1936            .unwrap();
1937
1938        assert_eq!(charge.interest, dec!(0.0001));
1939    }
1940
1941    #[test]
1942    fn a_charge_is_rounded_up_to_the_asset_s_own_unit() {
1943        let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1944            .with_rates(usdt(), RateSeries::constant(dec!(0.0876)))
1945            .with_unit(usdt(), dec!(0.01));
1946        let at = InterestDue::Borrow(utc("2026-10-15T01:00:00Z"));
1947
1948        // 8.76% a year is 0.001% an hour: 0.01 on 1,000, and 0.01001 on 1,001.
1949        let charged = |principal| model.charge(&usdt(), principal, at).unwrap().interest;
1950        assert_eq!(charged(dec!(1000)), dec!(0.01));
1951        assert_eq!(charged(dec!(1001)), dec!(0.02));
1952    }
1953
1954    #[test]
1955    fn boundaries_fall_on_whole_periods_and_one_on_a_boundary_steps_to_the_next() {
1956        let model = hourly_btc(dec!(0.01));
1957
1958        assert_eq!(
1959            model.next_boundary(&btc(), utc("2026-10-15T01:02:33Z")),
1960            Some(utc("2026-10-15T02:00:00Z"))
1961        );
1962        assert_eq!(
1963            model.next_boundary(&btc(), utc("2026-10-15T02:00:00Z")),
1964            Some(utc("2026-10-15T03:00:00Z"))
1965        );
1966    }
1967
1968    #[test]
1969    fn at_borrow_only_has_no_boundaries_but_still_charges_the_borrow() {
1970        let model = hourly_btc(dec!(0.01)).with_schedule(InterestSchedule::AtBorrowOnly);
1971        let at = utc("2026-10-15T01:02:33Z");
1972
1973        assert_eq!(model.next_boundary(&btc(), at), None);
1974        assert!(
1975            model
1976                .charge(&btc(), dec!(1), InterestDue::Borrow(at))
1977                .is_some()
1978        );
1979    }
1980
1981    #[test]
1982    fn an_asset_without_rates_or_before_its_rates_begin_is_charged_nothing() {
1983        let start = utc("2026-10-15T00:00:00Z");
1984        let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1985            .with_rates(btc(), RateSeries::new(vec![(start, dec!(0.01))]).unwrap());
1986        let before = InterestDue::Borrow(start - TimeDelta::seconds(1));
1987
1988        assert_eq!(model.next_boundary(&usdt(), start), None);
1989        assert_eq!(
1990            model.charge(&usdt(), dec!(1), InterestDue::Borrow(start)),
1991            None
1992        );
1993        assert_eq!(model.charge(&btc(), dec!(1), before), None);
1994        assert!(
1995            model
1996                .charge(&btc(), dec!(1), InterestDue::Borrow(start))
1997                .is_some()
1998        );
1999    }
2000
2001    #[test]
2002    #[should_panic(expected = "period must be positive")]
2003    fn a_period_that_is_not_positive_panics() {
2004        let _ = PeriodicMarginInterest::new(TimeDelta::zero(), DayCount::Act365);
2005    }
2006
2007    #[test]
2008    #[should_panic(expected = "unit for btc must be positive")]
2009    fn a_unit_that_is_not_positive_panics() {
2010        let _ = hourly_btc(dec!(0.01)).with_unit(btc(), Decimal::ZERO);
2011    }
2012
2013    #[test]
2014    #[should_panic(expected = "rate for btc must not be negative, got -0.01")]
2015    fn a_negative_rate_anywhere_in_the_series_panics() {
2016        let rates = RateSeries::new(vec![
2017            (utc("2026-10-01T00:00:00Z"), dec!(0.02)),
2018            (utc("2026-10-02T00:00:00Z"), dec!(-0.01)),
2019        ])
2020        .unwrap();
2021        let _ = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
2022            .with_rates(btc(), rates);
2023    }
2024
2025    #[test]
2026    fn a_zero_rate_is_accepted_and_charges_nothing() {
2027        let model = hourly_btc(Decimal::ZERO);
2028
2029        let charge = model.charge(
2030            &btc(),
2031            dec!(1),
2032            InterestDue::Borrow(utc("2026-10-15T01:00:00Z")),
2033        );
2034
2035        assert_eq!(charge.map(|charge| charge.interest), Some(Decimal::ZERO));
2036    }
2037}