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}