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. An asset
156/// the venue cannot value counts as no equity, which understates what the account can borrow
157/// rather than overstating it.
158/// - **Debt** is the sum over assets of `borrowed + interest`, valued the same way. The venue
159/// lends nothing while it cannot value a debt, and never lends an asset it cannot value.
160///
161/// An order that needs more than that is rejected as unaffordable, with a reason that says how
162/// much the venue would lend. So is one whose asset's [`min_borrow`](Self::min_borrow) is more
163/// than that, since the venue never lends less than the minimum, and its reason names the minimum
164/// too. A `max_leverage` of one lends nothing, but still reports each balance's loan.
165///
166/// It lends nothing while a liquidation is under way: see [`SpotMarginLiquidation`].
167///
168/// # What it repays
169/// Under [`MarginEffect::Repay`], whatever a fill delivers into an asset the account owes repays
170/// that asset's loan, interest first, then principal, and only the rest becomes free balance. What
171/// a buy delivers is its quantity less any fee charged in base, so a buy of exactly what is owed,
172/// with a base fee, leaves a fee-sized loan. Gross the buy up by the fee to clear it. Under the
173/// other two effects a fill repays nothing: as on Binance, a repay is per order, and nothing
174/// repays a loan automatically.
175///
176/// # Liquidation
177/// Off by default: an account whose debt grows past its equity keeps trading, and nothing closes
178/// its positions. Configured with [`with_liquidation`](Self::with_liquidation), the venue repays
179/// every debt once the account's margin level falls to the configured level, selling what the
180/// account holds as needed. See [`SpotMarginLiquidation`].
181///
182/// # What it does not model
183/// - **Isolated margin.** One pool of balances backs every instrument.
184/// - **Per-asset borrow limits and the venue's lending pool.** Only the leverage limit, and each
185/// asset's minimum borrow, apply.
186/// - **Interest**, by this configuration alone. A venue given a [`MarginInterestModel`] charges
187/// it; see that trait.
188///
189/// [`new`](Self::new) refuses a `max_leverage` below one, [`with_min_borrow`](Self::with_min_borrow)
190/// a negative minimum, [`with_liquidation`](Self::with_liquidation) a liquidation the leverage
191/// limit could reach, and so does deserialising any of them, so a configuration that exists is
192/// one the venue can apply. Serialised, `max_leverage` and each minimum borrow are decimal strings
193/// (`"3"`), as the crate writes decimals elsewhere, so no precision is lost to a float.
194/// `min_borrow` is a map from asset to minimum, left out when empty, and `liquidation` is left out
195/// when off.
196#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
197#[serde(try_from = "SpotMarginRepr", into = "SpotMarginRepr")]
198pub struct SpotMarginConfig(SpotMarginRepr);
199
200/// The serialised form of a [`SpotMarginConfig`], before validation.
201#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
202struct SpotMarginRepr {
203 valuation_asset: AssetNameExchange,
204 #[serde(with = "rust_decimal::serde::str")]
205 max_leverage: Decimal,
206 margin_effect: MarginEffect,
207 #[serde(default = "default_auto_repay_at_cancel")]
208 auto_repay_at_cancel: bool,
209 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
210 min_borrow: BTreeMap<AssetNameExchange, Decimal>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 liquidation: Option<SpotMarginLiquidation>,
213}
214
215/// Serde default for `auto_repay_at_cancel`, here and on `BinanceMarginConfig`: on, as Binance's
216/// `autoRepayAtCancel` is.
217pub(crate) fn default_auto_repay_at_cancel() -> bool {
218 true
219}
220
221impl SpotMarginConfig {
222 /// Spot margin applying `margin_effect` to each order that does not carry its own, up to
223 /// `max_leverage` over the account's equity, valued in `valuation_asset`.
224 ///
225 /// The effect has no default, so that a configuration says whether the venue may take on debt
226 /// for an order. Unused loans are repaid when their order ends unfilled; see
227 /// [`with_auto_repay_at_cancel`](Self::with_auto_repay_at_cancel).
228 ///
229 /// # Errors
230 /// Returns [`SpotMarginConfigError`] if `max_leverage` is below one.
231 pub fn new(
232 valuation_asset: AssetNameExchange,
233 max_leverage: Decimal,
234 margin_effect: MarginEffect,
235 ) -> Result<Self, SpotMarginConfigError> {
236 Self::try_from(SpotMarginRepr {
237 valuation_asset,
238 max_leverage,
239 margin_effect,
240 auto_repay_at_cancel: default_auto_repay_at_cancel(),
241 min_borrow: BTreeMap::new(),
242 liquidation: None,
243 })
244 }
245
246 /// This configuration, repaying a loan its order no longer needs (the order was cancelled,
247 /// expired or refused at its fill, or a held market order filled for less than was held) when
248 /// `auto_repay_at_cancel` is true, and leaving it owed when false.
249 ///
250 /// On by default, as Binance's `autoRepayAtCancel` is. With it off, an unused loan stays owed
251 /// until a [`MarginEffect::Repay`] order repays it.
252 #[must_use]
253 pub fn with_auto_repay_at_cancel(mut self, auto_repay_at_cancel: bool) -> Self {
254 self.0.auto_repay_at_cancel = auto_repay_at_cancel;
255 self
256 }
257
258 /// The asset equity and debt are valued in.
259 pub fn valuation_asset(&self) -> &AssetNameExchange {
260 &self.0.valuation_asset
261 }
262
263 /// The most the account may hold, as a multiple of its equity: its debt may reach
264 /// `max_leverage − 1` times its equity.
265 pub fn max_leverage(&self) -> Decimal {
266 self.0.max_leverage
267 }
268
269 /// The effect of an order whose request carries none.
270 pub fn margin_effect(&self) -> MarginEffect {
271 self.0.margin_effect
272 }
273
274 /// Whether a loan its order no longer needs is repaid. See
275 /// [`with_auto_repay_at_cancel`](Self::with_auto_repay_at_cancel).
276 pub fn auto_repay_at_cancel(&self) -> bool {
277 self.0.auto_repay_at_cancel
278 }
279
280 /// This configuration, never lending less than `min_borrow` of `asset`: a loan of `asset`
281 /// below it is raised to it, as Binance raises a `MARGIN_BUY` loan to the asset's
282 /// `userMinBorrow`. A minimum of zero removes it. Replaces any minimum `asset` had.
283 ///
284 /// No asset has one by default, so each loan is exactly its order's shortfall. An order its
285 /// free balance covers borrows nothing, whatever the minimum.
286 ///
287 /// # Errors
288 /// Returns [`SpotMarginConfigError::NegativeMinBorrow`] if `min_borrow` is negative.
289 pub fn with_min_borrow(
290 mut self,
291 asset: AssetNameExchange,
292 min_borrow: Decimal,
293 ) -> Result<Self, SpotMarginConfigError> {
294 self.0.min_borrow.insert(asset, min_borrow);
295 Self::try_from(self.0)
296 }
297
298 /// The least the venue lends of `asset` in one loan, zero if it has no minimum. See
299 /// [`with_min_borrow`](Self::with_min_borrow).
300 pub fn min_borrow(&self, asset: &AssetNameExchange) -> Decimal {
301 self.0
302 .min_borrow
303 .get(asset)
304 .copied()
305 .unwrap_or(Decimal::ZERO)
306 }
307
308 /// This configuration, liquidating the account as `liquidation` says. Replaces any
309 /// liquidation it had.
310 ///
311 /// # Errors
312 /// Returns [`SpotMarginConfigError::LiquidationWithinLeverage`] if a borrow the leverage limit
313 /// allows could leave the account at the liquidation level: if `max_leverage` is above one
314 /// and the level is at least `max_leverage / (max_leverage − 1)`, the margin level of an
315 /// account borrowed to the limit.
316 pub fn with_liquidation(
317 mut self,
318 liquidation: SpotMarginLiquidation,
319 ) -> Result<Self, SpotMarginConfigError> {
320 self.0.liquidation = Some(liquidation);
321 Self::try_from(self.0)
322 }
323
324 /// When the venue liquidates the account, or `None` if it never does. See
325 /// [`with_liquidation`](Self::with_liquidation).
326 pub fn liquidation(&self) -> Option<SpotMarginLiquidation> {
327 self.0.liquidation
328 }
329}
330
331impl TryFrom<SpotMarginRepr> for SpotMarginConfig {
332 type Error = SpotMarginConfigError;
333
334 fn try_from(mut repr: SpotMarginRepr) -> Result<Self, Self::Error> {
335 if repr.max_leverage < Decimal::ONE {
336 return Err(SpotMarginConfigError::LeverageBelowOne(repr.max_leverage));
337 }
338 if let Some((asset, min_borrow)) = repr
339 .min_borrow
340 .iter()
341 .find(|(_, min_borrow)| **min_borrow < Decimal::ZERO)
342 {
343 return Err(SpotMarginConfigError::NegativeMinBorrow {
344 asset: asset.clone(),
345 min_borrow: *min_borrow,
346 });
347 }
348 if let Some(liquidation) = &repr.liquidation {
349 // An account borrowed to the limit has `debt = (max_leverage − 1) × equity`, so a
350 // margin level of `max_leverage / (max_leverage − 1)`. One lending nothing, at a
351 // `max_leverage` of one, has no limit to reach, and passes: the product is zero.
352 if liquidation.level() * (repr.max_leverage - Decimal::ONE) >= repr.max_leverage {
353 return Err(SpotMarginConfigError::LiquidationWithinLeverage {
354 level: liquidation.level(),
355 max_leverage: repr.max_leverage,
356 });
357 }
358 }
359 // A zero minimum is no minimum, so it is not kept: two configurations that lend alike
360 // compare equal.
361 repr.min_borrow
362 .retain(|_, min_borrow| !min_borrow.is_zero());
363 Ok(Self(repr))
364 }
365}
366
367impl From<SpotMarginConfig> for SpotMarginRepr {
368 fn from(config: SpotMarginConfig) -> Self {
369 config.0
370 }
371}
372
373/// Why a [`SpotMarginConfig`] was refused.
374#[derive(Debug, Clone, PartialEq, Eq, Error)]
375#[non_exhaustive]
376pub enum SpotMarginConfigError {
377 /// A `max_leverage` below one, which would allow less than the account's own equity.
378 #[error("spot margin max_leverage {0} is below 1; a leverage of 1 lends nothing")]
379 LeverageBelowOne(Decimal),
380 /// A negative minimum borrow for `asset`.
381 #[error("spot margin min_borrow {min_borrow} of {asset} is negative")]
382 #[non_exhaustive]
383 NegativeMinBorrow {
384 /// The asset given the minimum.
385 asset: AssetNameExchange,
386 /// The minimum given, below zero.
387 min_borrow: Decimal,
388 },
389 /// A liquidation level below one, at which an account would already owe more than it holds.
390 #[error("spot margin liquidation level {0} is below 1")]
391 LiquidationLevelBelowOne(Decimal),
392 /// A liquidation fee rate outside `[0, 1)`.
393 #[error("spot margin liquidation fee rate {0} is outside [0, 1)")]
394 LiquidationFeeOutOfRange(Decimal),
395 /// A margin-call level not above the liquidation level, so it could never be reached first.
396 #[error(
397 "spot margin margin-call level {margin_call} is not above the liquidation level {level}"
398 )]
399 #[non_exhaustive]
400 MarginCallNotAboveLiquidation {
401 /// The margin-call level given.
402 margin_call: Decimal,
403 /// The liquidation level it must be above.
404 level: Decimal,
405 },
406 /// A liquidation level an account borrowed to the leverage limit would already be at.
407 #[error(
408 "spot margin liquidation level {level} is reached by a borrow max_leverage {max_leverage} \
409 allows; it must be below max_leverage / (max_leverage - 1)"
410 )]
411 #[non_exhaustive]
412 LiquidationWithinLeverage {
413 /// The liquidation level given.
414 level: Decimal,
415 /// The configuration's leverage limit.
416 max_leverage: Decimal,
417 },
418}
419
420/// When the simulated venue liquidates a spot margin account, what it charges for it, and whether
421/// it warns first. Set by [`SpotMarginConfig::with_liquidation`].
422///
423/// # When
424/// The venue checks the account's **margin level** after each of its inputs, a market tick or a
425/// move of its clock, since those are what change it: prices move, and interest falls due. The
426/// level is the value of everything the account holds over the value of what it owes, loans plus
427/// interest, both valued as the leverage limit values them (see [`SpotMarginConfig`]'s
428/// `# How much it lends`): at the mid of a spot instrument pairing each asset with the valuation
429/// asset, else its last price. There is no index price, so a market's own mid stands in for the
430/// one a venue liquidates on. An asset the venue cannot value counts for nothing, which can bring
431/// a liquidation forward but never put one off. While it cannot value a debt it does not check,
432/// and warns once.
433///
434/// At or below [`level`](Self::level) it liquidates.
435///
436/// # How
437/// It sends an [`AccountNotice`](crate::notice::AccountNotice) of
438/// [`NoticeKind::Liquidation`](crate::notice::NoticeKind::Liquidation), then closes every debt:
439/// 1. It cancels every open order, resting or held, releasing what each held.
440/// 2. It repays each debt from the asset's own free balance, interest first.
441/// 3. It repays what is still owed of the valuation asset with the proceeds of selling the
442/// account's other assets, each on a spot instrument pairing it with the valuation asset, in
443/// name order, as much of each as is needed.
444/// 4. It buys back what is still owed of each other asset, in name order, on a spot instrument
445/// pairing it with the valuation asset, selling other assets for the valuation asset first as
446/// in step 3 when the account holds too little of it. What a buy delivers repays the debt.
447/// 5. It charges the fee: [`fee_rate`](Self::fee_rate) times the value of the debt this repaid,
448/// in the valuation asset, raised as in step 3 if the account holds too little of it.
449///
450/// Each forced trade fills as a market order would on arrival, as a taker: priced by the venue's
451/// fill model at its current market, slipped, limited by the depth on offer, and charged the
452/// venue's fee. It is reported as `[balance.., trade]`, the [`Trade`](crate::trade::Trade)
453/// carrying [`TradeOrigin::Liquidation`](crate::trade::TradeOrigin::Liquidation), no client order
454/// id and an unknown strategy, with no order snapshot, since no client placed it. It is never
455/// held for a bar or a fresh market. Once priced it has drawn from the venue's slippage stream,
456/// even if it then trades nothing, so a liquidation that runs over several inputs draws on each.
457/// The fee is reported as `[balance, cash flow]`, a
458/// [`CashFlowKind::LiquidationFee`](crate::cash_flow::CashFlowKind::LiquidationFee) with no
459/// instrument.
460///
461/// # When it cannot close every debt
462/// A debt can outlast an input when the depth on offer is too thin, when an asset has no market
463/// to trade on, or when the account holds too little to repay it. The venue then warns once, and
464/// leaves the rest owed: it has no insurance fund, and nothing floors the account's balances.
465///
466/// The liquidation continues on each later input until no debt is left, without another notice:
467/// each input cancels whatever the account has since placed, repays what it can, and charges the
468/// fee on what it repaid.
469///
470/// Each pass's fee is charged out of the account's remaining assets after that pass's
471/// repayments, selling for it as in step 3, as a venue takes its fee out of the proceeds of the
472/// liquidation's trades. So a debt a pass leaves owed has that much less to be repaid from on the
473/// next pass. A fee the account cannot pay takes the valuation asset's free balance below zero.
474///
475/// While the liquidation is under way the venue lends nothing. An order that would need a loan is
476/// rejected as unaffordable, its reason showing nothing borrowable, and so is the fill of a held
477/// order that would need one. One the account can pay for from its own balance is accepted, and
478/// cancelled by the next input if it is still open. A debt the venue cannot trade out of, for want
479/// of a market or of anything left to sell, keeps lending off until the account repays it.
480///
481/// # Margin call
482/// Off by default. With [`with_margin_call`](Self::with_margin_call), the venue sends
483/// [`NoticeKind::MarginCall`](crate::notice::NoticeKind::MarginCall) when the margin level falls
484/// to or below that level, and
485/// [`NoticeKind::MarginRestored`](crate::notice::NoticeKind::MarginRestored) when it rises back
486/// above it or the account no longer owes anything, once per crossing. A liquidation ends a margin
487/// call without a notice.
488///
489/// Each notice carries the margin level that caused it, `None` for an account that owes nothing,
490/// and its kind's name as its status.
491///
492/// # Not liquidated
493/// A CFD position: its margin is posted from the quote asset, outside the spot margin account.
494///
495/// # Serialised form
496/// `level`, `fee_rate` and `margin_call_level` are decimal strings, and `margin_call_level` is
497/// left out when off. Deserialising refuses what the constructors refuse.
498#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
499#[serde(
500 try_from = "SpotMarginLiquidationRepr",
501 into = "SpotMarginLiquidationRepr"
502)]
503pub struct SpotMarginLiquidation(SpotMarginLiquidationRepr);
504
505/// The serialised form of a [`SpotMarginLiquidation`], before validation.
506#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
507struct SpotMarginLiquidationRepr {
508 #[serde(with = "rust_decimal::serde::str")]
509 level: Decimal,
510 #[serde(with = "rust_decimal::serde::str")]
511 fee_rate: Decimal,
512 #[serde(
513 default,
514 with = "rust_decimal::serde::str_option",
515 skip_serializing_if = "Option::is_none"
516 )]
517 margin_call_level: Option<Decimal>,
518}
519
520impl SpotMarginLiquidation {
521 /// Liquidation at a margin level of `level` or below, charging `fee_rate` of the debt it
522 /// repays, with no margin call.
523 ///
524 /// For example, a level of 1.1 and a fee rate of 0.02 liquidate an account whose holdings are
525 /// worth 1.1 times its debt or less, and charge 2 % of what it repays.
526 ///
527 /// # Errors
528 /// [`SpotMarginConfigError::LiquidationLevelBelowOne`] for a `level` below one, and
529 /// [`SpotMarginConfigError::LiquidationFeeOutOfRange`] for a `fee_rate` below zero or not
530 /// below one.
531 pub fn new(level: Decimal, fee_rate: Decimal) -> Result<Self, SpotMarginConfigError> {
532 Self::try_from(SpotMarginLiquidationRepr {
533 level,
534 fee_rate,
535 margin_call_level: None,
536 })
537 }
538
539 /// This liquidation, with a margin call at `margin_call_level`. See `# Margin call`.
540 ///
541 /// # Errors
542 /// [`SpotMarginConfigError::MarginCallNotAboveLiquidation`] if `margin_call_level` is not
543 /// above the liquidation [`level`](Self::level).
544 pub fn with_margin_call(
545 mut self,
546 margin_call_level: Decimal,
547 ) -> Result<Self, SpotMarginConfigError> {
548 self.0.margin_call_level = Some(margin_call_level);
549 Self::try_from(self.0)
550 }
551
552 /// The margin level at or below which the venue liquidates.
553 pub fn level(&self) -> Decimal {
554 self.0.level
555 }
556
557 /// The fee, as a fraction of the value of the debt a liquidation repays.
558 pub fn fee_rate(&self) -> Decimal {
559 self.0.fee_rate
560 }
561
562 /// The margin level at or below which the venue sends a margin call, or `None` for none.
563 pub fn margin_call_level(&self) -> Option<Decimal> {
564 self.0.margin_call_level
565 }
566}
567
568impl TryFrom<SpotMarginLiquidationRepr> for SpotMarginLiquidation {
569 type Error = SpotMarginConfigError;
570
571 fn try_from(repr: SpotMarginLiquidationRepr) -> Result<Self, Self::Error> {
572 if repr.level < Decimal::ONE {
573 return Err(SpotMarginConfigError::LiquidationLevelBelowOne(repr.level));
574 }
575 if repr.fee_rate < Decimal::ZERO || repr.fee_rate >= Decimal::ONE {
576 return Err(SpotMarginConfigError::LiquidationFeeOutOfRange(
577 repr.fee_rate,
578 ));
579 }
580 if let Some(margin_call) = repr.margin_call_level
581 && margin_call <= repr.level
582 {
583 return Err(SpotMarginConfigError::MarginCallNotAboveLiquidation {
584 margin_call,
585 level: repr.level,
586 });
587 }
588 Ok(Self(repr))
589 }
590}
591
592impl From<SpotMarginLiquidation> for SpotMarginLiquidationRepr {
593 fn from(liquidation: SpotMarginLiquidation) -> Self {
594 liquidation.0
595 }
596}
597
598/// Charges interest on a simulated margin loan: once when the loan is taken, and at boundaries in
599/// time while it stays open.
600///
601/// The simulated venue asks for a charge on each loan its spot margin takes
602/// ([`InterestDue::Borrow`], on the amount just lent), and, through
603/// [`next_boundary`](Self::next_boundary), at each boundary its clock reaches while the account
604/// owes the asset ([`InterestDue::Boundary`], on the principal owed when it falls). Each charge is
605/// added to the asset's loan as interest, which the account repays before principal, and is
606/// reported as a [`CashFlowKind::MarginInterest`](crate::cash_flow::CashFlowKind::MarginInterest)
607/// in the borrowed asset.
608///
609/// Interest is charged on principal only: interest already owed earns none.
610///
611/// # Contract
612/// - `next_boundary` must return an instant strictly after `after`, or `None` when the model
613/// never charges the asset at a boundary. A boundary at or before `after` would charge one
614/// instant forever, so the venue **panics** on one.
615/// - A charge's [`interest`](InterestCharge::interest) must not be negative: interest is owed,
616/// never paid out. The venue **panics** on a negative one.
617/// - Both methods must be pure functions of their arguments: the venue may ask for the same
618/// boundary more than once, and charges it once.
619///
620/// In return, the venue asks [`charge`](Self::charge) only about a positive `principal`: an order
621/// that lends nothing, and an asset that owes nothing, is never charged.
622///
623/// `Send + Sync` because a venue holds its model behind an `Arc`, so that one model, and the
624/// rate history it carries, can be shared by every run of a backtest sweep without being copied.
625pub trait MarginInterestModel: Debug + Send + Sync {
626 /// The first boundary strictly after `after` at which this model charges a loan of `asset`,
627 /// or `None` if it never does.
628 fn next_boundary(
629 &self,
630 asset: &AssetNameExchange,
631 after: DateTime<Utc>,
632 ) -> Option<DateTime<Utc>>;
633
634 /// What a loan of `principal` of `asset` is charged when `due`, or `None` for nothing.
635 fn charge(
636 &self,
637 asset: &AssetNameExchange,
638 principal: Decimal,
639 due: InterestDue,
640 ) -> Option<InterestCharge>;
641}
642
643/// When a [`MarginInterestModel`] is asked to charge a loan.
644#[non_exhaustive]
645#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
646pub enum InterestDue {
647 /// The loan was taken at this instant, and the principal is the amount just lent.
648 Borrow(DateTime<Utc>),
649 /// A boundary the model named, and the principal is what the account owed when it fell.
650 Boundary(DateTime<Utc>),
651}
652
653impl InterestDue {
654 /// The instant the charge is due.
655 pub fn time(self) -> DateTime<Utc> {
656 match self {
657 Self::Borrow(time) | Self::Boundary(time) => time,
658 }
659 }
660}
661
662/// One charge a [`MarginInterestModel`] makes on one loan.
663#[non_exhaustive]
664#[derive(Debug, Clone, Copy, PartialEq, Eq)]
665pub struct InterestCharge {
666 /// The interest owed, in the borrowed asset. Never negative; zero charges nothing.
667 pub interest: Decimal,
668 /// The rate it was charged at, reported as the cash flow's `rate` and used for nothing else,
669 /// so a model may quote it annualised or per its own period. [`PeriodicMarginInterest`]
670 /// reports the annualised rate.
671 pub rate: Option<Decimal>,
672}
673
674impl InterestCharge {
675 /// A charge of `interest`, in the borrowed asset, at `rate`.
676 pub fn new(interest: Decimal, rate: Option<Decimal>) -> Self {
677 Self { interest, rate }
678 }
679}
680
681/// When a [`PeriodicMarginInterest`] charges a loan.
682#[derive(
683 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
684)]
685#[serde(rename_all = "snake_case")]
686#[non_exhaustive]
687pub enum InterestSchedule {
688 /// One period when the loan is taken, then one at every boundary while it is open.
689 #[default]
690 AtBorrowAndEachBoundary,
691 /// One period when the loan is taken, and nothing afterwards however long it is held.
692 AtBorrowOnly,
693}
694
695/// Margin interest charged a period at a time, at an annualised rate per asset: a crypto margin
696/// account's hourly interest.
697///
698/// - **When.** One period when a loan is taken, whenever in the period that is, then one at every
699/// boundary while the loan is open, unless [`InterestSchedule::AtBorrowOnly`]. Boundaries fall
700/// at whole multiples of the period since the Unix epoch, so an hourly period charges on the
701/// hour, UTC. Each charge pays in advance for the period it falls in: the one a borrow is taken
702/// in, or the one a boundary begins. So a loan repaid mid-period has already paid for that
703/// period, and one repaid just before a boundary is not charged there.
704/// - **How much.** `principal × rate × period / basis`, where the rate is annualised, read from
705/// the asset's [`RateSeries`] at the instant charged, and the basis is the [`DayCount`]'s year.
706/// Each charge is rounded **up** to the asset's unit, `1e-8` unless set by
707/// [`with_unit`](Self::with_unit), so a small loan is charged at least one unit, as a venue
708/// that books interest in whole units charges it.
709/// - **Which assets.** Only those given rates. A loan of any other asset, or one charged before its
710/// series begins, is charged nothing.
711///
712/// A venue that quotes a daily rate is reproduced by an annual rate of `daily × 365` on
713/// [`DayCount::Act365`].
714#[derive(Debug, Clone)]
715pub struct PeriodicMarginInterest {
716 period: TimeDelta,
717 day_count: DayCount,
718 schedule: InterestSchedule,
719 rates: FnvHashMap<AssetNameExchange, RateSeries>,
720 units: FnvHashMap<AssetNameExchange, Decimal>,
721}
722
723/// The unit a [`PeriodicMarginInterest`] rounds an asset's charge up to unless told otherwise: one
724/// satoshi, the unit crypto venues book interest in.
725const DEFAULT_INTEREST_UNIT: Decimal = Decimal::from_parts(1, 0, 0, false, 8);
726
727impl PeriodicMarginInterest {
728 /// Interest charged every `period` on the `day_count` basis, at borrow and each boundary, with
729 /// no asset's rates yet.
730 ///
731 /// # Panics
732 /// If `period` is not positive.
733 pub fn new(period: TimeDelta, day_count: DayCount) -> Self {
734 assert!(
735 period > TimeDelta::zero(),
736 "PeriodicMarginInterest period must be positive, got {period}"
737 );
738 Self {
739 period,
740 day_count,
741 schedule: InterestSchedule::default(),
742 rates: FnvHashMap::default(),
743 units: FnvHashMap::default(),
744 }
745 }
746
747 /// Charges a loan of `asset` at the annualised rates in `rates`.
748 ///
749 /// # Panics
750 /// If any rate in `rates` is negative: a borrower is never paid interest, so a negative rate is
751 /// a fault in the data, refused here rather than when the venue first charges it.
752 #[must_use]
753 pub fn with_rates(mut self, asset: AssetNameExchange, rates: RateSeries) -> Self {
754 if let Some(rate) = rates.rates().find(|rate| *rate < Decimal::ZERO) {
755 panic!("PeriodicMarginInterest rate for {asset} must not be negative, got {rate}");
756 }
757 self.rates.insert(asset, rates);
758 self
759 }
760
761 /// Rounds each charge on `asset` up to a multiple of `unit` instead of `1e-8`.
762 ///
763 /// # Panics
764 /// If `unit` is not positive.
765 #[must_use]
766 pub fn with_unit(mut self, asset: AssetNameExchange, unit: Decimal) -> Self {
767 assert!(
768 unit > Decimal::ZERO,
769 "PeriodicMarginInterest unit for {asset} must be positive, got {unit}"
770 );
771 self.units.insert(asset, unit);
772 self
773 }
774
775 /// Charges on `schedule` instead of at borrow and each boundary.
776 #[must_use]
777 pub fn with_schedule(mut self, schedule: InterestSchedule) -> Self {
778 self.schedule = schedule;
779 self
780 }
781}
782
783impl MarginInterestModel for PeriodicMarginInterest {
784 fn next_boundary(
785 &self,
786 asset: &AssetNameExchange,
787 after: DateTime<Utc>,
788 ) -> Option<DateTime<Utc>> {
789 match self.schedule {
790 InterestSchedule::AtBorrowOnly => None,
791 InterestSchedule::AtBorrowAndEachBoundary if !self.rates.contains_key(asset) => None,
792 InterestSchedule::AtBorrowAndEachBoundary => next_whole_interval(self.period, after),
793 }
794 }
795
796 fn charge(
797 &self,
798 asset: &AssetNameExchange,
799 principal: Decimal,
800 due: InterestDue,
801 ) -> Option<InterestCharge> {
802 let rate = self.rates.get(asset)?.at(due.time())?;
803 let unit = self
804 .units
805 .get(asset)
806 .copied()
807 .unwrap_or(DEFAULT_INTEREST_UNIT);
808 let exact = self.day_count.accrue_span(principal * rate, self.period);
809
810 Some(InterestCharge::new(
811 (exact / unit).ceil() * unit,
812 Some(rate),
813 ))
814 }
815}
816
817#[cfg(test)]
818#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
819mod tests {
820 use super::*;
821 use rust_decimal_macros::dec;
822
823 #[test]
824 fn a_leverage_below_one_is_refused_by_the_constructor_and_by_deserialising() {
825 assert_eq!(
826 SpotMarginConfig::new(
827 AssetNameExchange::new("usdt"),
828 dec!(0.5),
829 MarginEffect::NoSideEffect
830 ),
831 Err(SpotMarginConfigError::LeverageBelowOne(dec!(0.5)))
832 );
833
834 let json = r#"{"valuation_asset":"usdt","max_leverage":"0.5","margin_effect":"borrow"}"#;
835 assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
836 }
837
838 #[test]
839 fn the_margin_effect_has_no_default_and_a_retired_side_effect_is_refused() {
840 let omitted = r#"{"valuation_asset":"usdt","max_leverage":"3"}"#;
841 assert!(serde_json::from_str::<SpotMarginConfig>(omitted).is_err());
842
843 // The retired key alone is no substitute for the effect.
844 let retired =
845 r#"{"valuation_asset":"usdt","max_leverage":"3","side_effect":"auto_borrow_repay"}"#;
846 assert!(serde_json::from_str::<SpotMarginConfig>(retired).is_err());
847 }
848
849 #[test]
850 fn auto_repay_at_cancel_defaults_to_true_when_omitted() {
851 let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"repay"}"#;
852 let config: SpotMarginConfig = serde_json::from_str(json).unwrap();
853
854 assert_eq!(config.margin_effect(), MarginEffect::Repay);
855 assert!(config.auto_repay_at_cancel());
856 assert_eq!(config.max_leverage(), dec!(3));
857 assert_eq!(config.valuation_asset(), &AssetNameExchange::new("usdt"));
858 }
859
860 #[test]
861 fn a_minimum_borrow_is_per_asset_and_zero_where_none_is_set() {
862 let config = SpotMarginConfig::new(
863 AssetNameExchange::new("usdt"),
864 dec!(3),
865 MarginEffect::Borrow,
866 )
867 .unwrap()
868 .with_min_borrow(AssetNameExchange::new("btc"), dec!(0.00006))
869 .unwrap();
870
871 assert_eq!(
872 config.min_borrow(&AssetNameExchange::new("btc")),
873 dec!(0.00006)
874 );
875 assert_eq!(
876 config.min_borrow(&AssetNameExchange::new("usdt")),
877 Decimal::ZERO
878 );
879 }
880
881 #[test]
882 fn a_negative_minimum_borrow_is_refused_by_the_builder_and_by_deserialising() {
883 let config = SpotMarginConfig::new(
884 AssetNameExchange::new("usdt"),
885 dec!(3),
886 MarginEffect::Borrow,
887 )
888 .unwrap();
889 assert_eq!(
890 config.with_min_borrow(AssetNameExchange::new("btc"), dec!(-0.1)),
891 Err(SpotMarginConfigError::NegativeMinBorrow {
892 asset: AssetNameExchange::new("btc"),
893 min_borrow: dec!(-0.1),
894 })
895 );
896
897 let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","min_borrow":{"btc":"-0.1"}}"#;
898 assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
899 }
900
901 #[test]
902 fn a_zero_minimum_borrow_removes_the_minimum() {
903 let plain = SpotMarginConfig::new(
904 AssetNameExchange::new("usdt"),
905 dec!(3),
906 MarginEffect::Borrow,
907 )
908 .unwrap();
909 let cleared = plain
910 .clone()
911 .with_min_borrow(AssetNameExchange::new("btc"), dec!(5))
912 .unwrap()
913 .with_min_borrow(AssetNameExchange::new("btc"), Decimal::ZERO)
914 .unwrap();
915
916 assert_eq!(cleared, plain);
917 assert_eq!(
918 cleared.min_borrow(&AssetNameExchange::new("btc")),
919 Decimal::ZERO
920 );
921 }
922
923 #[test]
924 fn a_minimum_borrow_round_trips_as_a_map_of_decimal_strings() {
925 let config = SpotMarginConfig::new(
926 AssetNameExchange::new("usdt"),
927 dec!(3),
928 MarginEffect::Borrow,
929 )
930 .unwrap()
931 .with_min_borrow(AssetNameExchange::new("btc"), dec!(0.00006))
932 .unwrap();
933
934 let json = serde_json::to_string(&config).unwrap();
935 assert_eq!(
936 json,
937 r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"min_borrow":{"btc":"0.00006"}}"#
938 );
939 assert_eq!(
940 serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
941 config
942 );
943 }
944
945 #[test]
946 fn a_configuration_round_trips_through_its_serialised_form() {
947 let config = SpotMarginConfig::new(
948 AssetNameExchange::new("usdt"),
949 dec!(3),
950 MarginEffect::Borrow,
951 )
952 .unwrap()
953 .with_auto_repay_at_cancel(false);
954
955 let json = serde_json::to_string(&config).unwrap();
956 assert_eq!(
957 json,
958 r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":false}"#
959 );
960 assert_eq!(
961 serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
962 config
963 );
964 }
965
966 #[test]
967 fn a_liquidation_round_trips_and_is_left_out_when_off() {
968 let config = SpotMarginConfig::new(
969 AssetNameExchange::new("usdt"),
970 dec!(3),
971 MarginEffect::Borrow,
972 )
973 .unwrap()
974 .with_liquidation(
975 SpotMarginLiquidation::new(dec!(1.1), dec!(0.02))
976 .unwrap()
977 .with_margin_call(dec!(1.3))
978 .unwrap(),
979 )
980 .unwrap();
981
982 let json = serde_json::to_string(&config).unwrap();
983 assert_eq!(
984 json,
985 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"}}"#
986 );
987 assert_eq!(
988 serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
989 config
990 );
991
992 let without_call = r#"{"level":"1.1","fee_rate":"0"}"#;
993 let liquidation = serde_json::from_str::<SpotMarginLiquidation>(without_call).unwrap();
994 assert_eq!(liquidation.margin_call_level(), None);
995 assert_eq!(serde_json::to_string(&liquidation).unwrap(), without_call);
996 }
997
998 #[test]
999 fn a_liquidation_out_of_range_is_refused_by_its_constructors_and_by_deserialising() {
1000 assert_eq!(
1001 SpotMarginLiquidation::new(dec!(0.99), dec!(0)),
1002 Err(SpotMarginConfigError::LiquidationLevelBelowOne(dec!(0.99)))
1003 );
1004 assert!(SpotMarginLiquidation::new(dec!(1), dec!(0)).is_ok());
1005 for fee_rate in [dec!(-0.01), dec!(1)] {
1006 assert_eq!(
1007 SpotMarginLiquidation::new(dec!(1.1), fee_rate),
1008 Err(SpotMarginConfigError::LiquidationFeeOutOfRange(fee_rate))
1009 );
1010 }
1011 assert_eq!(
1012 SpotMarginLiquidation::new(dec!(1.1), dec!(0))
1013 .unwrap()
1014 .with_margin_call(dec!(1.1)),
1015 Err(SpotMarginConfigError::MarginCallNotAboveLiquidation {
1016 margin_call: dec!(1.1),
1017 level: dec!(1.1),
1018 })
1019 );
1020
1021 for json in [
1022 r#"{"level":"0.5","fee_rate":"0"}"#,
1023 r#"{"level":"1.1","fee_rate":"1"}"#,
1024 r#"{"level":"1.1","fee_rate":"0","margin_call_level":"1"}"#,
1025 ] {
1026 assert!(
1027 serde_json::from_str::<SpotMarginLiquidation>(json).is_err(),
1028 "{json}"
1029 );
1030 }
1031 let within_leverage = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","liquidation":{"level":"1.5","fee_rate":"0"}}"#;
1032 assert!(serde_json::from_str::<SpotMarginConfig>(within_leverage).is_err());
1033 }
1034
1035 #[test]
1036 fn the_effect_wire_names_are_snake_case_and_the_default_borrows_nothing() {
1037 assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
1038 for (effect, name) in [
1039 (MarginEffect::NoSideEffect, r#""no_side_effect""#),
1040 (MarginEffect::Borrow, r#""borrow""#),
1041 (MarginEffect::Repay, r#""repay""#),
1042 ] {
1043 assert_eq!(serde_json::to_string(&effect).unwrap(), name);
1044 assert_eq!(serde_json::from_str::<MarginEffect>(name).unwrap(), effect);
1045 }
1046 }
1047
1048 fn btc() -> AssetNameExchange {
1049 AssetNameExchange::new("btc")
1050 }
1051
1052 fn usdt() -> AssetNameExchange {
1053 AssetNameExchange::new("usdt")
1054 }
1055
1056 fn utc(text: &str) -> DateTime<Utc> {
1057 text.parse().unwrap()
1058 }
1059
1060 /// Hourly interest on BTC at `annual`, on the Act365 basis.
1061 fn hourly_btc(annual: Decimal) -> PeriodicMarginInterest {
1062 PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1063 .with_rates(btc(), RateSeries::constant(annual))
1064 }
1065
1066 #[test]
1067 fn a_small_loan_is_charged_one_unit_for_its_first_period() {
1068 // 0.00063 BTC at 0.39% a year is about 2.8e-10 an hour, rounded up to one satoshi.
1069 let model = hourly_btc(dec!(0.0039));
1070
1071 let charge = model.charge(
1072 &btc(),
1073 dec!(0.00063),
1074 InterestDue::Borrow(utc("2026-10-15T01:02:33Z")),
1075 );
1076
1077 assert_eq!(
1078 charge,
1079 Some(InterestCharge::new(dec!(0.00000001), Some(dec!(0.0039))))
1080 );
1081 }
1082
1083 #[test]
1084 fn a_daily_rate_times_365_on_act365_charges_a_24th_of_it_an_hour_without_rounding_up() {
1085 // 0.01% a day on 24 BTC is 0.0024 a day, exactly 0.0001 an hour.
1086 let model = hourly_btc(dec!(0.0001) * dec!(365));
1087
1088 let charge = model
1089 .charge(
1090 &btc(),
1091 dec!(24),
1092 InterestDue::Boundary(utc("2026-10-15T02:00:00Z")),
1093 )
1094 .unwrap();
1095
1096 assert_eq!(charge.interest, dec!(0.0001));
1097 }
1098
1099 #[test]
1100 fn a_charge_is_rounded_up_to_the_asset_s_own_unit() {
1101 let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1102 .with_rates(usdt(), RateSeries::constant(dec!(0.0876)))
1103 .with_unit(usdt(), dec!(0.01));
1104 let at = InterestDue::Borrow(utc("2026-10-15T01:00:00Z"));
1105
1106 // 8.76% a year is 0.001% an hour: 0.01 on 1,000, and 0.01001 on 1,001.
1107 let charged = |principal| model.charge(&usdt(), principal, at).unwrap().interest;
1108 assert_eq!(charged(dec!(1000)), dec!(0.01));
1109 assert_eq!(charged(dec!(1001)), dec!(0.02));
1110 }
1111
1112 #[test]
1113 fn boundaries_fall_on_whole_periods_and_one_on_a_boundary_steps_to_the_next() {
1114 let model = hourly_btc(dec!(0.01));
1115
1116 assert_eq!(
1117 model.next_boundary(&btc(), utc("2026-10-15T01:02:33Z")),
1118 Some(utc("2026-10-15T02:00:00Z"))
1119 );
1120 assert_eq!(
1121 model.next_boundary(&btc(), utc("2026-10-15T02:00:00Z")),
1122 Some(utc("2026-10-15T03:00:00Z"))
1123 );
1124 }
1125
1126 #[test]
1127 fn at_borrow_only_has_no_boundaries_but_still_charges_the_borrow() {
1128 let model = hourly_btc(dec!(0.01)).with_schedule(InterestSchedule::AtBorrowOnly);
1129 let at = utc("2026-10-15T01:02:33Z");
1130
1131 assert_eq!(model.next_boundary(&btc(), at), None);
1132 assert!(
1133 model
1134 .charge(&btc(), dec!(1), InterestDue::Borrow(at))
1135 .is_some()
1136 );
1137 }
1138
1139 #[test]
1140 fn an_asset_without_rates_or_before_its_rates_begin_is_charged_nothing() {
1141 let start = utc("2026-10-15T00:00:00Z");
1142 let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1143 .with_rates(btc(), RateSeries::new(vec![(start, dec!(0.01))]).unwrap());
1144 let before = InterestDue::Borrow(start - TimeDelta::seconds(1));
1145
1146 assert_eq!(model.next_boundary(&usdt(), start), None);
1147 assert_eq!(
1148 model.charge(&usdt(), dec!(1), InterestDue::Borrow(start)),
1149 None
1150 );
1151 assert_eq!(model.charge(&btc(), dec!(1), before), None);
1152 assert!(
1153 model
1154 .charge(&btc(), dec!(1), InterestDue::Borrow(start))
1155 .is_some()
1156 );
1157 }
1158
1159 #[test]
1160 #[should_panic(expected = "period must be positive")]
1161 fn a_period_that_is_not_positive_panics() {
1162 let _ = PeriodicMarginInterest::new(TimeDelta::zero(), DayCount::Act365);
1163 }
1164
1165 #[test]
1166 #[should_panic(expected = "unit for btc must be positive")]
1167 fn a_unit_that_is_not_positive_panics() {
1168 let _ = hourly_btc(dec!(0.01)).with_unit(btc(), Decimal::ZERO);
1169 }
1170
1171 #[test]
1172 #[should_panic(expected = "rate for btc must not be negative, got -0.01")]
1173 fn a_negative_rate_anywhere_in_the_series_panics() {
1174 let rates = RateSeries::new(vec![
1175 (utc("2026-10-01T00:00:00Z"), dec!(0.02)),
1176 (utc("2026-10-02T00:00:00Z"), dec!(-0.01)),
1177 ])
1178 .unwrap();
1179 let _ = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
1180 .with_rates(btc(), rates);
1181 }
1182
1183 #[test]
1184 fn a_zero_rate_is_accepted_and_charges_nothing() {
1185 let model = hourly_btc(Decimal::ZERO);
1186
1187 let charge = model.charge(
1188 &btc(),
1189 dec!(1),
1190 InterestDue::Borrow(utc("2026-10-15T01:00:00Z")),
1191 );
1192
1193 assert_eq!(charge.map(|charge| charge.interest), Some(Decimal::ZERO));
1194 }
1195}