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