Skip to main content

rustrade_execution/
margin.rs

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