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}