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}