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