Skip to main content

rustrade_execution/
margin.rs

1//! Margin borrowing: the borrow and repay policy a margin account applies to its orders, the
2//! simulated venue's spot margin configuration, and the interest it charges on its loans.
3//!
4//! [`MarginEffect`] is what an order does to a margin account's loans: borrow what it lacks, repay
5//! from what it receives, or neither. A live margin client sends it with each order, and the
6//! simulated venue applies it when [`SpotMarginConfig`] turns its spot margin on. A
7//! [`MarginInterestModel`] prices those loans over time; [`PeriodicMarginInterest`] is the
8//! built-in one. A margin client that learns what an order borrowed reports it as a
9//! [`MarginBorrow`], in a [`MarginPlacement`].
10
11use crate::holding_cost::{DayCount, RateSeries, next_whole_interval};
12use crate::order::{Order, state::UnindexedOrderState};
13use chrono::{DateTime, TimeDelta, Utc};
14use derive_more::Constructor;
15use fnv::FnvHashMap;
16use rust_decimal::Decimal;
17use rustrade_instrument::{
18    asset::name::AssetNameExchange, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
19};
20use serde::{Deserialize, Serialize};
21use std::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: the shortfall, and only the
51    /// shortfall, of the asset it pays with (base for a sell, which opens or extends a short).
52    ///
53    /// The loan is taken when the order is placed, not when it fills, and nothing repays it from
54    /// the order's proceeds: close it with a [`Repay`](Self::Repay) order. Whether a loan
55    /// is repaid when the order is cancelled before it fills is the client's `auto_repay_at_cancel`
56    /// option.
57    ///
58    /// # Warning
59    /// A **mis-sized order takes on debt**: one larger than the free balance borrows the shortfall
60    /// without any further opt-in, and interest accrues on it.
61    Borrow,
62    /// Repay the loan of the asset the order receives from what it receives, interest first, as
63    /// Binance's `AUTO_REPAY` does. It borrows nothing: an order the free balance cannot pay for
64    /// is refused.
65    Repay,
66}
67
68/// A loan a margin order took out when it was placed: `amount` of `asset`.
69///
70/// `amount` is the loan's principal, as the venue reports lending it: a reported zero is kept as
71/// zero, not read as no loan, and the amount need not equal what the order lacked. It is not the
72/// order's net
73/// effect on the balance: a fee the order pays in `asset` is not in it, and interest accrues on
74/// it from the moment it is lent. `asset` is the asset the order pays with, which may be the base
75/// or the quote asset of the pair.
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
77pub struct MarginBorrow {
78    pub asset: AssetNameExchange,
79    pub amount: Decimal,
80}
81
82/// The answer to placing a margin order: the order, and what it borrowed.
83///
84/// Returned by a margin client's own placement method (for example `BinanceMargin`'s
85/// `open_margin_order`), whose venue reports the loan in its answer to the order.
86/// `ExecutionClient::open_order` returns only the order.
87#[derive(Debug, Clone, PartialEq)]
88pub struct MarginPlacement {
89    /// The order, as [`ExecutionClient::open_order`](crate::client::ExecutionClient::open_order)
90    /// returns it.
91    pub order: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
92    /// What the order borrowed when it was placed.
93    ///
94    /// - `Some` when the venue's answer reports a loan.
95    /// - `None` when the answer reports none: the order borrowed nothing (its
96    ///   [`MarginEffect`] is not [`Borrow`](MarginEffect::Borrow), or its free balance covered
97    ///   it), or the order was refused.
98    /// - When [`order.state`](Order::state) is an unknown outcome
99    ///   ([`OrderState::is_outcome_unknown`](crate::order::state::OrderState::is_outcome_unknown)),
100    ///   the venue may have placed the order and lent to it without the client learning how
101    ///   much. `None` then says nothing about a loan: read the account's balances.
102    pub borrowed: Option<MarginBorrow>,
103}
104
105/// Spot margin on the simulated venue: what it lends a spot order, how much, and what it repays.
106///
107/// Off by default: `MockExecutionConfig::spot_margin` is `None`, and the venue is a cash account
108/// whose spot orders must be paid for with what it holds. With a configuration, it is a cross
109/// margin account, with one pool of balances shared by every instrument, which lends a spot order
110/// what it lacks and reports each balance's loan as Binance's margin account does.
111///
112/// # What it lends, and when
113/// Each spot order has a [`MarginEffect`]: its request's `margin_effect`, else this
114/// configuration's [`margin_effect`](Self::margin_effect). An order with [`MarginEffect::Borrow`]
115/// that the account cannot pay for with its free balance borrows the shortfall, and only the
116/// shortfall, of the asset it pays with: base for a sell, quote for a buy. It borrows when the
117/// venue accepts the order, as Binance does, so a resting limit order or a held market order is
118/// lent its shortfall at once, and the venue holds it against the order as it holds any other
119/// balance. An asset the account has never held is opened with the loan.
120///
121/// An order that is cancelled, expires or is refused at its fill before it fills repays what it
122/// borrowed, interest first, and a held market order that fills for less than was held repays the
123/// part of its loan it no longer needs, if [`auto_repay_at_cancel`](Self::auto_repay_at_cancel)
124/// is on (the default, as on Binance). One that fills in part and rests the remainder pays for the
125/// part that filled with the account's own balance first, so the loan is held against the
126/// remainder and repaid if the remainder is cancelled.
127///
128/// An order with [`MarginEffect::NoSideEffect`] or [`MarginEffect::Repay`] lends nothing: one it
129/// cannot pay for is rejected as on a cash account. A loan already in the venue's initial state
130/// is left as it is, and balances still report their loans.
131///
132/// Only spot orders borrow. A CFD's margin is posted from the quote asset's free balance, as
133/// without this configuration, and a CFD order carrying [`MarginEffect::Borrow`] or
134/// [`MarginEffect::Repay`] is refused with
135/// [`OrderError::UnsupportedMarginEffect`](crate::error::OrderError::UnsupportedMarginEffect).
136///
137/// # How much it lends
138/// A borrow is allowed while the account's debt, after it, is at most `max_leverage − 1` times
139/// the account's equity. Both are valued in [`valuation_asset`](Self::valuation_asset):
140/// - Each asset is valued at the mid of an instrument on the venue that pairs it with the
141///   valuation asset, else that market's last price, in either direction (`BTC/USDT`, or the
142///   inverse of `USDT/BTC`). The valuation asset is worth one. A market priced at zero values
143///   nothing.
144/// - **Equity** is the sum over assets of `total − borrowed − interest`, valued that way. An asset
145///   the venue cannot value counts as no equity, which understates what the account can borrow
146///   rather than overstating it.
147/// - **Debt** is the sum over assets of `borrowed + interest`, valued the same way. The venue
148///   lends nothing while it cannot value a debt, and never lends an asset it cannot value.
149///
150/// An order that needs more than that is rejected as unaffordable, with a reason that says how
151/// much the venue would lend. A `max_leverage` of one lends nothing, but still reports each
152/// balance's loan.
153///
154/// # What it repays
155/// Under [`MarginEffect::Repay`], whatever a fill delivers into an asset the account owes repays
156/// that asset's loan, interest first, then principal, and only the rest becomes free balance. What
157/// a buy delivers is its quantity less any fee charged in base, so a buy of exactly what is owed,
158/// with a base fee, leaves a fee-sized loan. Gross the buy up by the fee to clear it. Under the
159/// other two effects a fill repays nothing: as on Binance, a repay is per order, and nothing
160/// repays a loan automatically.
161///
162/// # What it does not model
163/// - **Liquidation.** An account whose debt grows past its equity keeps trading; nothing closes
164///   its positions.
165/// - **Isolated margin.** One pool of balances backs every instrument.
166/// - **Per-asset borrow limits and the venue's lending pool.** Only the leverage limit applies.
167/// - **Interest**, by this configuration alone. A venue given a [`MarginInterestModel`] charges
168///   it; see that trait.
169///
170/// [`new`](Self::new) refuses a `max_leverage` below one, and so does deserialising one, so a
171/// configuration that exists is one the venue can apply. Serialised, `max_leverage` is a decimal
172/// string (`"3"`), as the crate writes decimals elsewhere, so no precision is lost to a float.
173#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
174#[serde(try_from = "SpotMarginRepr", into = "SpotMarginRepr")]
175pub struct SpotMarginConfig(SpotMarginRepr);
176
177/// The serialised form of a [`SpotMarginConfig`], before validation.
178#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
179struct SpotMarginRepr {
180    valuation_asset: AssetNameExchange,
181    #[serde(with = "rust_decimal::serde::str")]
182    max_leverage: Decimal,
183    margin_effect: MarginEffect,
184    #[serde(default = "default_auto_repay_at_cancel")]
185    auto_repay_at_cancel: bool,
186}
187
188/// Serde default for `auto_repay_at_cancel`, here and on `BinanceMarginConfig`: on, as Binance's
189/// `autoRepayAtCancel` is.
190pub(crate) fn default_auto_repay_at_cancel() -> bool {
191    true
192}
193
194impl SpotMarginConfig {
195    /// Spot margin applying `margin_effect` to each order that does not carry its own, up to
196    /// `max_leverage` over the account's equity, valued in `valuation_asset`.
197    ///
198    /// The effect has no default, so that a configuration says whether the venue may take on debt
199    /// for an order. Unused loans are repaid when their order ends unfilled; see
200    /// [`with_auto_repay_at_cancel`](Self::with_auto_repay_at_cancel).
201    ///
202    /// # Errors
203    /// Returns [`SpotMarginConfigError`] if `max_leverage` is below one.
204    pub fn new(
205        valuation_asset: AssetNameExchange,
206        max_leverage: Decimal,
207        margin_effect: MarginEffect,
208    ) -> Result<Self, SpotMarginConfigError> {
209        Self::try_from(SpotMarginRepr {
210            valuation_asset,
211            max_leverage,
212            margin_effect,
213            auto_repay_at_cancel: default_auto_repay_at_cancel(),
214        })
215    }
216
217    /// This configuration, repaying a loan its order no longer needs (the order was cancelled,
218    /// expired or refused at its fill, or a held market order filled for less than was held) when
219    /// `auto_repay_at_cancel` is true, and leaving it owed when false.
220    ///
221    /// On by default, as Binance's `autoRepayAtCancel` is. With it off, an unused loan stays owed
222    /// until a [`MarginEffect::Repay`] order repays it.
223    #[must_use]
224    pub fn with_auto_repay_at_cancel(mut self, auto_repay_at_cancel: bool) -> Self {
225        self.0.auto_repay_at_cancel = auto_repay_at_cancel;
226        self
227    }
228
229    /// The asset equity and debt are valued in.
230    pub fn valuation_asset(&self) -> &AssetNameExchange {
231        &self.0.valuation_asset
232    }
233
234    /// The most the account may hold, as a multiple of its equity: its debt may reach
235    /// `max_leverage − 1` times its equity.
236    pub fn max_leverage(&self) -> Decimal {
237        self.0.max_leverage
238    }
239
240    /// The effect of an order whose request carries none.
241    pub fn margin_effect(&self) -> MarginEffect {
242        self.0.margin_effect
243    }
244
245    /// Whether a loan its order no longer needs is repaid. See
246    /// [`with_auto_repay_at_cancel`](Self::with_auto_repay_at_cancel).
247    pub fn auto_repay_at_cancel(&self) -> bool {
248        self.0.auto_repay_at_cancel
249    }
250}
251
252impl TryFrom<SpotMarginRepr> for SpotMarginConfig {
253    type Error = SpotMarginConfigError;
254
255    fn try_from(repr: SpotMarginRepr) -> Result<Self, Self::Error> {
256        if repr.max_leverage < Decimal::ONE {
257            return Err(SpotMarginConfigError::LeverageBelowOne(repr.max_leverage));
258        }
259        Ok(Self(repr))
260    }
261}
262
263impl From<SpotMarginConfig> for SpotMarginRepr {
264    fn from(config: SpotMarginConfig) -> Self {
265        config.0
266    }
267}
268
269/// Why a [`SpotMarginConfig`] was refused.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
271#[non_exhaustive]
272pub enum SpotMarginConfigError {
273    /// A `max_leverage` below one, which would allow less than the account's own equity.
274    #[error("spot margin max_leverage {0} is below 1; a leverage of 1 lends nothing")]
275    LeverageBelowOne(Decimal),
276}
277
278/// Charges interest on a simulated margin loan: once when the loan is taken, and at boundaries in
279/// time while it stays open.
280///
281/// The simulated venue asks for a charge on each loan its spot margin takes
282/// ([`InterestDue::Borrow`], on the amount just lent), and, through
283/// [`next_boundary`](Self::next_boundary), at each boundary its clock reaches while the account
284/// owes the asset ([`InterestDue::Boundary`], on the principal owed when it falls). Each charge is
285/// added to the asset's loan as interest, which the account repays before principal, and is
286/// reported as a [`CashFlowKind::MarginInterest`](crate::cash_flow::CashFlowKind::MarginInterest)
287/// in the borrowed asset.
288///
289/// Interest is charged on principal only: interest already owed earns none.
290///
291/// # Contract
292/// - `next_boundary` must return an instant strictly after `after`, or `None` when the model
293///   never charges the asset at a boundary. A boundary at or before `after` would charge one
294///   instant forever, so the venue **panics** on one.
295/// - A charge's [`interest`](InterestCharge::interest) must not be negative: interest is owed,
296///   never paid out. The venue **panics** on a negative one.
297/// - Both methods must be pure functions of their arguments: the venue may ask for the same
298///   boundary more than once, and charges it once.
299///
300/// In return, the venue asks [`charge`](Self::charge) only about a positive `principal`: an order
301/// that lends nothing, and an asset that owes nothing, is never charged.
302///
303/// `Send + Sync` because a venue holds its model behind an `Arc`, so that one model, and the
304/// rate history it carries, can be shared by every run of a backtest sweep without being copied.
305pub trait MarginInterestModel: Debug + Send + Sync {
306    /// The first boundary strictly after `after` at which this model charges a loan of `asset`,
307    /// or `None` if it never does.
308    fn next_boundary(
309        &self,
310        asset: &AssetNameExchange,
311        after: DateTime<Utc>,
312    ) -> Option<DateTime<Utc>>;
313
314    /// What a loan of `principal` of `asset` is charged when `due`, or `None` for nothing.
315    fn charge(
316        &self,
317        asset: &AssetNameExchange,
318        principal: Decimal,
319        due: InterestDue,
320    ) -> Option<InterestCharge>;
321}
322
323/// When a [`MarginInterestModel`] is asked to charge a loan.
324#[non_exhaustive]
325#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
326pub enum InterestDue {
327    /// The loan was taken at this instant, and the principal is the amount just lent.
328    Borrow(DateTime<Utc>),
329    /// A boundary the model named, and the principal is what the account owed when it fell.
330    Boundary(DateTime<Utc>),
331}
332
333impl InterestDue {
334    /// The instant the charge is due.
335    pub fn time(self) -> DateTime<Utc> {
336        match self {
337            Self::Borrow(time) | Self::Boundary(time) => time,
338        }
339    }
340}
341
342/// One charge a [`MarginInterestModel`] makes on one loan.
343#[non_exhaustive]
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub struct InterestCharge {
346    /// The interest owed, in the borrowed asset. Never negative; zero charges nothing.
347    pub interest: Decimal,
348    /// The rate it was charged at, reported as the cash flow's `rate` and used for nothing else,
349    /// so a model may quote it annualised or per its own period. [`PeriodicMarginInterest`]
350    /// reports the annualised rate.
351    pub rate: Option<Decimal>,
352}
353
354impl InterestCharge {
355    /// A charge of `interest`, in the borrowed asset, at `rate`.
356    pub fn new(interest: Decimal, rate: Option<Decimal>) -> Self {
357        Self { interest, rate }
358    }
359}
360
361/// When a [`PeriodicMarginInterest`] charges a loan.
362#[derive(
363    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
364)]
365#[serde(rename_all = "snake_case")]
366#[non_exhaustive]
367pub enum InterestSchedule {
368    /// One period when the loan is taken, then one at every boundary while it is open.
369    #[default]
370    AtBorrowAndEachBoundary,
371    /// One period when the loan is taken, and nothing afterwards however long it is held.
372    AtBorrowOnly,
373}
374
375/// Margin interest charged a period at a time, at an annualised rate per asset: a crypto margin
376/// account's hourly interest.
377///
378/// - **When.** One period when a loan is taken, whenever in the period that is, then one at every
379///   boundary while the loan is open, unless [`InterestSchedule::AtBorrowOnly`]. Boundaries fall
380///   at whole multiples of the period since the Unix epoch, so an hourly period charges on the
381///   hour, UTC. Each charge pays in advance for the period it falls in: the one a borrow is taken
382///   in, or the one a boundary begins. So a loan repaid mid-period has already paid for that
383///   period, and one repaid just before a boundary is not charged there.
384/// - **How much.** `principal × rate × period / basis`, where the rate is annualised, read from
385///   the asset's [`RateSeries`] at the instant charged, and the basis is the [`DayCount`]'s year.
386///   Each charge is rounded **up** to the asset's unit, `1e-8` unless set by
387///   [`with_unit`](Self::with_unit), so a small loan is charged at least one unit, as a venue
388///   that books interest in whole units charges it.
389/// - **Which assets.** Only those given rates. A loan of any other asset, or one charged before its
390///   series begins, is charged nothing.
391///
392/// A venue that quotes a daily rate is reproduced by an annual rate of `daily × 365` on
393/// [`DayCount::Act365`].
394#[derive(Debug, Clone)]
395pub struct PeriodicMarginInterest {
396    period: TimeDelta,
397    day_count: DayCount,
398    schedule: InterestSchedule,
399    rates: FnvHashMap<AssetNameExchange, RateSeries>,
400    units: FnvHashMap<AssetNameExchange, Decimal>,
401}
402
403/// The unit a [`PeriodicMarginInterest`] rounds an asset's charge up to unless told otherwise: one
404/// satoshi, the unit crypto venues book interest in.
405const DEFAULT_INTEREST_UNIT: Decimal = Decimal::from_parts(1, 0, 0, false, 8);
406
407impl PeriodicMarginInterest {
408    /// Interest charged every `period` on the `day_count` basis, at borrow and each boundary, with
409    /// no asset's rates yet.
410    ///
411    /// # Panics
412    /// If `period` is not positive.
413    pub fn new(period: TimeDelta, day_count: DayCount) -> Self {
414        assert!(
415            period > TimeDelta::zero(),
416            "PeriodicMarginInterest period must be positive, got {period}"
417        );
418        Self {
419            period,
420            day_count,
421            schedule: InterestSchedule::default(),
422            rates: FnvHashMap::default(),
423            units: FnvHashMap::default(),
424        }
425    }
426
427    /// Charges a loan of `asset` at the annualised rates in `rates`.
428    ///
429    /// # Panics
430    /// If any rate in `rates` is negative: a borrower is never paid interest, so a negative rate is
431    /// a fault in the data, refused here rather than when the venue first charges it.
432    #[must_use]
433    pub fn with_rates(mut self, asset: AssetNameExchange, rates: RateSeries) -> Self {
434        if let Some(rate) = rates.rates().find(|rate| *rate < Decimal::ZERO) {
435            panic!("PeriodicMarginInterest rate for {asset} must not be negative, got {rate}");
436        }
437        self.rates.insert(asset, rates);
438        self
439    }
440
441    /// Rounds each charge on `asset` up to a multiple of `unit` instead of `1e-8`.
442    ///
443    /// # Panics
444    /// If `unit` is not positive.
445    #[must_use]
446    pub fn with_unit(mut self, asset: AssetNameExchange, unit: Decimal) -> Self {
447        assert!(
448            unit > Decimal::ZERO,
449            "PeriodicMarginInterest unit for {asset} must be positive, got {unit}"
450        );
451        self.units.insert(asset, unit);
452        self
453    }
454
455    /// Charges on `schedule` instead of at borrow and each boundary.
456    #[must_use]
457    pub fn with_schedule(mut self, schedule: InterestSchedule) -> Self {
458        self.schedule = schedule;
459        self
460    }
461}
462
463impl MarginInterestModel for PeriodicMarginInterest {
464    fn next_boundary(
465        &self,
466        asset: &AssetNameExchange,
467        after: DateTime<Utc>,
468    ) -> Option<DateTime<Utc>> {
469        match self.schedule {
470            InterestSchedule::AtBorrowOnly => None,
471            InterestSchedule::AtBorrowAndEachBoundary if !self.rates.contains_key(asset) => None,
472            InterestSchedule::AtBorrowAndEachBoundary => next_whole_interval(self.period, after),
473        }
474    }
475
476    fn charge(
477        &self,
478        asset: &AssetNameExchange,
479        principal: Decimal,
480        due: InterestDue,
481    ) -> Option<InterestCharge> {
482        let rate = self.rates.get(asset)?.at(due.time())?;
483        let unit = self
484            .units
485            .get(asset)
486            .copied()
487            .unwrap_or(DEFAULT_INTEREST_UNIT);
488        let exact = self.day_count.accrue_span(principal * rate, self.period);
489
490        Some(InterestCharge::new(
491            (exact / unit).ceil() * unit,
492            Some(rate),
493        ))
494    }
495}
496
497#[cfg(test)]
498#[allow(clippy::unwrap_used)] // Test code: panics are the correct failure mode
499mod tests {
500    use super::*;
501    use rust_decimal_macros::dec;
502
503    #[test]
504    fn a_leverage_below_one_is_refused_by_the_constructor_and_by_deserialising() {
505        assert_eq!(
506            SpotMarginConfig::new(
507                AssetNameExchange::new("usdt"),
508                dec!(0.5),
509                MarginEffect::NoSideEffect
510            ),
511            Err(SpotMarginConfigError::LeverageBelowOne(dec!(0.5)))
512        );
513
514        let json = r#"{"valuation_asset":"usdt","max_leverage":"0.5","margin_effect":"borrow"}"#;
515        assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
516    }
517
518    #[test]
519    fn the_margin_effect_has_no_default_and_a_retired_side_effect_is_refused() {
520        let omitted = r#"{"valuation_asset":"usdt","max_leverage":"3"}"#;
521        assert!(serde_json::from_str::<SpotMarginConfig>(omitted).is_err());
522
523        // The retired key alone is no substitute for the effect.
524        let retired =
525            r#"{"valuation_asset":"usdt","max_leverage":"3","side_effect":"auto_borrow_repay"}"#;
526        assert!(serde_json::from_str::<SpotMarginConfig>(retired).is_err());
527    }
528
529    #[test]
530    fn auto_repay_at_cancel_defaults_to_true_when_omitted() {
531        let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"repay"}"#;
532        let config: SpotMarginConfig = serde_json::from_str(json).unwrap();
533
534        assert_eq!(config.margin_effect(), MarginEffect::Repay);
535        assert!(config.auto_repay_at_cancel());
536        assert_eq!(config.max_leverage(), dec!(3));
537        assert_eq!(config.valuation_asset(), &AssetNameExchange::new("usdt"));
538    }
539
540    #[test]
541    fn a_configuration_round_trips_through_its_serialised_form() {
542        let config = SpotMarginConfig::new(
543            AssetNameExchange::new("usdt"),
544            dec!(3),
545            MarginEffect::Borrow,
546        )
547        .unwrap()
548        .with_auto_repay_at_cancel(false);
549
550        let json = serde_json::to_string(&config).unwrap();
551        assert_eq!(
552            json,
553            r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":false}"#
554        );
555        assert_eq!(
556            serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
557            config
558        );
559    }
560
561    #[test]
562    fn the_effect_wire_names_are_snake_case_and_the_default_borrows_nothing() {
563        assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
564        for (effect, name) in [
565            (MarginEffect::NoSideEffect, r#""no_side_effect""#),
566            (MarginEffect::Borrow, r#""borrow""#),
567            (MarginEffect::Repay, r#""repay""#),
568        ] {
569            assert_eq!(serde_json::to_string(&effect).unwrap(), name);
570            assert_eq!(serde_json::from_str::<MarginEffect>(name).unwrap(), effect);
571        }
572    }
573
574    fn btc() -> AssetNameExchange {
575        AssetNameExchange::new("btc")
576    }
577
578    fn usdt() -> AssetNameExchange {
579        AssetNameExchange::new("usdt")
580    }
581
582    fn utc(text: &str) -> DateTime<Utc> {
583        text.parse().unwrap()
584    }
585
586    /// Hourly interest on BTC at `annual`, on the Act365 basis.
587    fn hourly_btc(annual: Decimal) -> PeriodicMarginInterest {
588        PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
589            .with_rates(btc(), RateSeries::constant(annual))
590    }
591
592    #[test]
593    fn a_small_loan_is_charged_one_unit_for_its_first_period() {
594        // 0.00063 BTC at 0.39% a year is about 2.8e-10 an hour, rounded up to one satoshi.
595        let model = hourly_btc(dec!(0.0039));
596
597        let charge = model.charge(
598            &btc(),
599            dec!(0.00063),
600            InterestDue::Borrow(utc("2026-10-15T01:02:33Z")),
601        );
602
603        assert_eq!(
604            charge,
605            Some(InterestCharge::new(dec!(0.00000001), Some(dec!(0.0039))))
606        );
607    }
608
609    #[test]
610    fn a_daily_rate_times_365_on_act365_charges_a_24th_of_it_an_hour_without_rounding_up() {
611        // 0.01% a day on 24 BTC is 0.0024 a day, exactly 0.0001 an hour.
612        let model = hourly_btc(dec!(0.0001) * dec!(365));
613
614        let charge = model
615            .charge(
616                &btc(),
617                dec!(24),
618                InterestDue::Boundary(utc("2026-10-15T02:00:00Z")),
619            )
620            .unwrap();
621
622        assert_eq!(charge.interest, dec!(0.0001));
623    }
624
625    #[test]
626    fn a_charge_is_rounded_up_to_the_asset_s_own_unit() {
627        let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
628            .with_rates(usdt(), RateSeries::constant(dec!(0.0876)))
629            .with_unit(usdt(), dec!(0.01));
630        let at = InterestDue::Borrow(utc("2026-10-15T01:00:00Z"));
631
632        // 8.76% a year is 0.001% an hour: 0.01 on 1,000, and 0.01001 on 1,001.
633        let charged = |principal| model.charge(&usdt(), principal, at).unwrap().interest;
634        assert_eq!(charged(dec!(1000)), dec!(0.01));
635        assert_eq!(charged(dec!(1001)), dec!(0.02));
636    }
637
638    #[test]
639    fn boundaries_fall_on_whole_periods_and_one_on_a_boundary_steps_to_the_next() {
640        let model = hourly_btc(dec!(0.01));
641
642        assert_eq!(
643            model.next_boundary(&btc(), utc("2026-10-15T01:02:33Z")),
644            Some(utc("2026-10-15T02:00:00Z"))
645        );
646        assert_eq!(
647            model.next_boundary(&btc(), utc("2026-10-15T02:00:00Z")),
648            Some(utc("2026-10-15T03:00:00Z"))
649        );
650    }
651
652    #[test]
653    fn at_borrow_only_has_no_boundaries_but_still_charges_the_borrow() {
654        let model = hourly_btc(dec!(0.01)).with_schedule(InterestSchedule::AtBorrowOnly);
655        let at = utc("2026-10-15T01:02:33Z");
656
657        assert_eq!(model.next_boundary(&btc(), at), None);
658        assert!(
659            model
660                .charge(&btc(), dec!(1), InterestDue::Borrow(at))
661                .is_some()
662        );
663    }
664
665    #[test]
666    fn an_asset_without_rates_or_before_its_rates_begin_is_charged_nothing() {
667        let start = utc("2026-10-15T00:00:00Z");
668        let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
669            .with_rates(btc(), RateSeries::new(vec![(start, dec!(0.01))]).unwrap());
670        let before = InterestDue::Borrow(start - TimeDelta::seconds(1));
671
672        assert_eq!(model.next_boundary(&usdt(), start), None);
673        assert_eq!(
674            model.charge(&usdt(), dec!(1), InterestDue::Borrow(start)),
675            None
676        );
677        assert_eq!(model.charge(&btc(), dec!(1), before), None);
678        assert!(
679            model
680                .charge(&btc(), dec!(1), InterestDue::Borrow(start))
681                .is_some()
682        );
683    }
684
685    #[test]
686    #[should_panic(expected = "period must be positive")]
687    fn a_period_that_is_not_positive_panics() {
688        let _ = PeriodicMarginInterest::new(TimeDelta::zero(), DayCount::Act365);
689    }
690
691    #[test]
692    #[should_panic(expected = "unit for btc must be positive")]
693    fn a_unit_that_is_not_positive_panics() {
694        let _ = hourly_btc(dec!(0.01)).with_unit(btc(), Decimal::ZERO);
695    }
696
697    #[test]
698    #[should_panic(expected = "rate for btc must not be negative, got -0.01")]
699    fn a_negative_rate_anywhere_in_the_series_panics() {
700        let rates = RateSeries::new(vec![
701            (utc("2026-10-01T00:00:00Z"), dec!(0.02)),
702            (utc("2026-10-02T00:00:00Z"), dec!(-0.01)),
703        ])
704        .unwrap();
705        let _ = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
706            .with_rates(btc(), rates);
707    }
708
709    #[test]
710    fn a_zero_rate_is_accepted_and_charges_nothing() {
711        let model = hourly_btc(Decimal::ZERO);
712
713        let charge = model.charge(
714            &btc(),
715            dec!(1),
716            InterestDue::Borrow(utc("2026-10-15T01:00:00Z")),
717        );
718
719        assert_eq!(charge.map(|charge| charge.interest), Some(Decimal::ZERO));
720    }
721}