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