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}