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