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