use crate::holding_cost::{DayCount, RateSeries, next_whole_interval};
use crate::order::{Order, state::UnindexedOrderState};
use chrono::{DateTime, TimeDelta, Utc};
use derive_more::Constructor;
use fnv::FnvHashMap;
use rust_decimal::Decimal;
use rustrade_instrument::{
asset::name::AssetNameExchange, exchange::ExchangeId, instrument::name::InstrumentNameExchange,
};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, fmt::Debug};
use thiserror::Error;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MarginEffect {
#[default]
NoSideEffect,
Borrow,
Repay,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, Constructor)]
pub struct MarginBorrow {
pub asset: AssetNameExchange,
pub amount: Decimal,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MarginPlacement {
pub order: Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>,
pub borrowed: Option<MarginBorrow>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(try_from = "SpotMarginRepr", into = "SpotMarginRepr")]
pub struct SpotMarginConfig(SpotMarginRepr);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
struct SpotMarginRepr {
valuation_asset: AssetNameExchange,
#[serde(with = "rust_decimal::serde::str")]
max_leverage: Decimal,
margin_effect: MarginEffect,
#[serde(default = "default_auto_repay_at_cancel")]
auto_repay_at_cancel: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
min_borrow: BTreeMap<AssetNameExchange, Decimal>,
#[serde(default, skip_serializing_if = "Option::is_none")]
liquidation: Option<SpotMarginLiquidation>,
}
pub(crate) fn default_auto_repay_at_cancel() -> bool {
true
}
impl SpotMarginConfig {
pub fn new(
valuation_asset: AssetNameExchange,
max_leverage: Decimal,
margin_effect: MarginEffect,
) -> Result<Self, SpotMarginConfigError> {
Self::try_from(SpotMarginRepr {
valuation_asset,
max_leverage,
margin_effect,
auto_repay_at_cancel: default_auto_repay_at_cancel(),
min_borrow: BTreeMap::new(),
liquidation: None,
})
}
#[must_use]
pub fn with_auto_repay_at_cancel(mut self, auto_repay_at_cancel: bool) -> Self {
self.0.auto_repay_at_cancel = auto_repay_at_cancel;
self
}
pub fn valuation_asset(&self) -> &AssetNameExchange {
&self.0.valuation_asset
}
pub fn max_leverage(&self) -> Decimal {
self.0.max_leverage
}
pub fn margin_effect(&self) -> MarginEffect {
self.0.margin_effect
}
pub fn auto_repay_at_cancel(&self) -> bool {
self.0.auto_repay_at_cancel
}
pub fn with_min_borrow(
mut self,
asset: AssetNameExchange,
min_borrow: Decimal,
) -> Result<Self, SpotMarginConfigError> {
self.0.min_borrow.insert(asset, min_borrow);
Self::try_from(self.0)
}
pub fn min_borrow(&self, asset: &AssetNameExchange) -> Decimal {
self.0
.min_borrow
.get(asset)
.copied()
.unwrap_or(Decimal::ZERO)
}
pub fn with_liquidation(
mut self,
liquidation: SpotMarginLiquidation,
) -> Result<Self, SpotMarginConfigError> {
self.0.liquidation = Some(liquidation);
Self::try_from(self.0)
}
pub fn liquidation(&self) -> Option<SpotMarginLiquidation> {
self.0.liquidation
}
}
impl TryFrom<SpotMarginRepr> for SpotMarginConfig {
type Error = SpotMarginConfigError;
fn try_from(mut repr: SpotMarginRepr) -> Result<Self, Self::Error> {
if repr.max_leverage < Decimal::ONE {
return Err(SpotMarginConfigError::LeverageBelowOne(repr.max_leverage));
}
if let Some((asset, min_borrow)) = repr
.min_borrow
.iter()
.find(|(_, min_borrow)| **min_borrow < Decimal::ZERO)
{
return Err(SpotMarginConfigError::NegativeMinBorrow {
asset: asset.clone(),
min_borrow: *min_borrow,
});
}
if let Some(liquidation) = &repr.liquidation {
if liquidation.level() * (repr.max_leverage - Decimal::ONE) >= repr.max_leverage {
return Err(SpotMarginConfigError::LiquidationWithinLeverage {
level: liquidation.level(),
max_leverage: repr.max_leverage,
});
}
}
repr.min_borrow
.retain(|_, min_borrow| !min_borrow.is_zero());
Ok(Self(repr))
}
}
impl From<SpotMarginConfig> for SpotMarginRepr {
fn from(config: SpotMarginConfig) -> Self {
config.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum SpotMarginConfigError {
#[error("spot margin max_leverage {0} is below 1; a leverage of 1 lends nothing")]
LeverageBelowOne(Decimal),
#[error("spot margin min_borrow {min_borrow} of {asset} is negative")]
#[non_exhaustive]
NegativeMinBorrow {
asset: AssetNameExchange,
min_borrow: Decimal,
},
#[error("spot margin liquidation level {0} is below 1")]
LiquidationLevelBelowOne(Decimal),
#[error("spot margin liquidation fee rate {0} is outside [0, 1)")]
LiquidationFeeOutOfRange(Decimal),
#[error(
"spot margin margin-call level {margin_call} is not above the liquidation level {level}"
)]
#[non_exhaustive]
MarginCallNotAboveLiquidation {
margin_call: Decimal,
level: Decimal,
},
#[error(
"spot margin liquidation level {level} is reached by a borrow max_leverage {max_leverage} \
allows; it must be below max_leverage / (max_leverage - 1)"
)]
#[non_exhaustive]
LiquidationWithinLeverage {
level: Decimal,
max_leverage: Decimal,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(
try_from = "SpotMarginLiquidationRepr",
into = "SpotMarginLiquidationRepr"
)]
pub struct SpotMarginLiquidation(SpotMarginLiquidationRepr);
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
struct SpotMarginLiquidationRepr {
#[serde(with = "rust_decimal::serde::str")]
level: Decimal,
#[serde(with = "rust_decimal::serde::str")]
fee_rate: Decimal,
#[serde(
default,
with = "rust_decimal::serde::str_option",
skip_serializing_if = "Option::is_none"
)]
margin_call_level: Option<Decimal>,
}
impl SpotMarginLiquidation {
pub fn new(level: Decimal, fee_rate: Decimal) -> Result<Self, SpotMarginConfigError> {
Self::try_from(SpotMarginLiquidationRepr {
level,
fee_rate,
margin_call_level: None,
})
}
pub fn with_margin_call(
mut self,
margin_call_level: Decimal,
) -> Result<Self, SpotMarginConfigError> {
self.0.margin_call_level = Some(margin_call_level);
Self::try_from(self.0)
}
pub fn level(&self) -> Decimal {
self.0.level
}
pub fn fee_rate(&self) -> Decimal {
self.0.fee_rate
}
pub fn margin_call_level(&self) -> Option<Decimal> {
self.0.margin_call_level
}
}
impl TryFrom<SpotMarginLiquidationRepr> for SpotMarginLiquidation {
type Error = SpotMarginConfigError;
fn try_from(repr: SpotMarginLiquidationRepr) -> Result<Self, Self::Error> {
if repr.level < Decimal::ONE {
return Err(SpotMarginConfigError::LiquidationLevelBelowOne(repr.level));
}
if repr.fee_rate < Decimal::ZERO || repr.fee_rate >= Decimal::ONE {
return Err(SpotMarginConfigError::LiquidationFeeOutOfRange(
repr.fee_rate,
));
}
if let Some(margin_call) = repr.margin_call_level
&& margin_call <= repr.level
{
return Err(SpotMarginConfigError::MarginCallNotAboveLiquidation {
margin_call,
level: repr.level,
});
}
Ok(Self(repr))
}
}
impl From<SpotMarginLiquidation> for SpotMarginLiquidationRepr {
fn from(liquidation: SpotMarginLiquidation) -> Self {
liquidation.0
}
}
pub trait MarginInterestModel: Debug + Send + Sync {
fn next_boundary(
&self,
asset: &AssetNameExchange,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>>;
fn charge(
&self,
asset: &AssetNameExchange,
principal: Decimal,
due: InterestDue,
) -> Option<InterestCharge>;
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum InterestDue {
Borrow(DateTime<Utc>),
Boundary(DateTime<Utc>),
}
impl InterestDue {
pub fn time(self) -> DateTime<Utc> {
match self {
Self::Borrow(time) | Self::Boundary(time) => time,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InterestCharge {
pub interest: Decimal,
pub rate: Option<Decimal>,
}
impl InterestCharge {
pub fn new(interest: Decimal, rate: Option<Decimal>) -> Self {
Self { interest, rate }
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum InterestSchedule {
#[default]
AtBorrowAndEachBoundary,
AtBorrowOnly,
}
#[derive(Debug, Clone)]
pub struct PeriodicMarginInterest {
period: TimeDelta,
day_count: DayCount,
schedule: InterestSchedule,
rates: FnvHashMap<AssetNameExchange, RateSeries>,
units: FnvHashMap<AssetNameExchange, Decimal>,
}
const DEFAULT_INTEREST_UNIT: Decimal = Decimal::from_parts(1, 0, 0, false, 8);
impl PeriodicMarginInterest {
pub fn new(period: TimeDelta, day_count: DayCount) -> Self {
assert!(
period > TimeDelta::zero(),
"PeriodicMarginInterest period must be positive, got {period}"
);
Self {
period,
day_count,
schedule: InterestSchedule::default(),
rates: FnvHashMap::default(),
units: FnvHashMap::default(),
}
}
#[must_use]
pub fn with_rates(mut self, asset: AssetNameExchange, rates: RateSeries) -> Self {
if let Some(rate) = rates.rates().find(|rate| *rate < Decimal::ZERO) {
panic!("PeriodicMarginInterest rate for {asset} must not be negative, got {rate}");
}
self.rates.insert(asset, rates);
self
}
#[must_use]
pub fn with_unit(mut self, asset: AssetNameExchange, unit: Decimal) -> Self {
assert!(
unit > Decimal::ZERO,
"PeriodicMarginInterest unit for {asset} must be positive, got {unit}"
);
self.units.insert(asset, unit);
self
}
#[must_use]
pub fn with_schedule(mut self, schedule: InterestSchedule) -> Self {
self.schedule = schedule;
self
}
}
impl MarginInterestModel for PeriodicMarginInterest {
fn next_boundary(
&self,
asset: &AssetNameExchange,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
match self.schedule {
InterestSchedule::AtBorrowOnly => None,
InterestSchedule::AtBorrowAndEachBoundary if !self.rates.contains_key(asset) => None,
InterestSchedule::AtBorrowAndEachBoundary => next_whole_interval(self.period, after),
}
}
fn charge(
&self,
asset: &AssetNameExchange,
principal: Decimal,
due: InterestDue,
) -> Option<InterestCharge> {
let rate = self.rates.get(asset)?.at(due.time())?;
let unit = self
.units
.get(asset)
.copied()
.unwrap_or(DEFAULT_INTEREST_UNIT);
let exact = self.day_count.accrue_span(principal * rate, self.period);
Some(InterestCharge::new(
(exact / unit).ceil() * unit,
Some(rate),
))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn a_leverage_below_one_is_refused_by_the_constructor_and_by_deserialising() {
assert_eq!(
SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(0.5),
MarginEffect::NoSideEffect
),
Err(SpotMarginConfigError::LeverageBelowOne(dec!(0.5)))
);
let json = r#"{"valuation_asset":"usdt","max_leverage":"0.5","margin_effect":"borrow"}"#;
assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
}
#[test]
fn the_margin_effect_has_no_default_and_a_retired_side_effect_is_refused() {
let omitted = r#"{"valuation_asset":"usdt","max_leverage":"3"}"#;
assert!(serde_json::from_str::<SpotMarginConfig>(omitted).is_err());
let retired =
r#"{"valuation_asset":"usdt","max_leverage":"3","side_effect":"auto_borrow_repay"}"#;
assert!(serde_json::from_str::<SpotMarginConfig>(retired).is_err());
}
#[test]
fn auto_repay_at_cancel_defaults_to_true_when_omitted() {
let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"repay"}"#;
let config: SpotMarginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.margin_effect(), MarginEffect::Repay);
assert!(config.auto_repay_at_cancel());
assert_eq!(config.max_leverage(), dec!(3));
assert_eq!(config.valuation_asset(), &AssetNameExchange::new("usdt"));
}
#[test]
fn a_minimum_borrow_is_per_asset_and_zero_where_none_is_set() {
let config = SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(3),
MarginEffect::Borrow,
)
.unwrap()
.with_min_borrow(AssetNameExchange::new("btc"), dec!(0.00006))
.unwrap();
assert_eq!(
config.min_borrow(&AssetNameExchange::new("btc")),
dec!(0.00006)
);
assert_eq!(
config.min_borrow(&AssetNameExchange::new("usdt")),
Decimal::ZERO
);
}
#[test]
fn a_negative_minimum_borrow_is_refused_by_the_builder_and_by_deserialising() {
let config = SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(3),
MarginEffect::Borrow,
)
.unwrap();
assert_eq!(
config.with_min_borrow(AssetNameExchange::new("btc"), dec!(-0.1)),
Err(SpotMarginConfigError::NegativeMinBorrow {
asset: AssetNameExchange::new("btc"),
min_borrow: dec!(-0.1),
})
);
let json = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","min_borrow":{"btc":"-0.1"}}"#;
assert!(serde_json::from_str::<SpotMarginConfig>(json).is_err());
}
#[test]
fn a_zero_minimum_borrow_removes_the_minimum() {
let plain = SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(3),
MarginEffect::Borrow,
)
.unwrap();
let cleared = plain
.clone()
.with_min_borrow(AssetNameExchange::new("btc"), dec!(5))
.unwrap()
.with_min_borrow(AssetNameExchange::new("btc"), Decimal::ZERO)
.unwrap();
assert_eq!(cleared, plain);
assert_eq!(
cleared.min_borrow(&AssetNameExchange::new("btc")),
Decimal::ZERO
);
}
#[test]
fn a_minimum_borrow_round_trips_as_a_map_of_decimal_strings() {
let config = SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(3),
MarginEffect::Borrow,
)
.unwrap()
.with_min_borrow(AssetNameExchange::new("btc"), dec!(0.00006))
.unwrap();
let json = serde_json::to_string(&config).unwrap();
assert_eq!(
json,
r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"min_borrow":{"btc":"0.00006"}}"#
);
assert_eq!(
serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
config
);
}
#[test]
fn a_configuration_round_trips_through_its_serialised_form() {
let config = SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(3),
MarginEffect::Borrow,
)
.unwrap()
.with_auto_repay_at_cancel(false);
let json = serde_json::to_string(&config).unwrap();
assert_eq!(
json,
r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":false}"#
);
assert_eq!(
serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
config
);
}
#[test]
fn a_liquidation_round_trips_and_is_left_out_when_off() {
let config = SpotMarginConfig::new(
AssetNameExchange::new("usdt"),
dec!(3),
MarginEffect::Borrow,
)
.unwrap()
.with_liquidation(
SpotMarginLiquidation::new(dec!(1.1), dec!(0.02))
.unwrap()
.with_margin_call(dec!(1.3))
.unwrap(),
)
.unwrap();
let json = serde_json::to_string(&config).unwrap();
assert_eq!(
json,
r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","auto_repay_at_cancel":true,"liquidation":{"level":"1.1","fee_rate":"0.02","margin_call_level":"1.3"}}"#
);
assert_eq!(
serde_json::from_str::<SpotMarginConfig>(&json).unwrap(),
config
);
let without_call = r#"{"level":"1.1","fee_rate":"0"}"#;
let liquidation = serde_json::from_str::<SpotMarginLiquidation>(without_call).unwrap();
assert_eq!(liquidation.margin_call_level(), None);
assert_eq!(serde_json::to_string(&liquidation).unwrap(), without_call);
}
#[test]
fn a_liquidation_out_of_range_is_refused_by_its_constructors_and_by_deserialising() {
assert_eq!(
SpotMarginLiquidation::new(dec!(0.99), dec!(0)),
Err(SpotMarginConfigError::LiquidationLevelBelowOne(dec!(0.99)))
);
assert!(SpotMarginLiquidation::new(dec!(1), dec!(0)).is_ok());
for fee_rate in [dec!(-0.01), dec!(1)] {
assert_eq!(
SpotMarginLiquidation::new(dec!(1.1), fee_rate),
Err(SpotMarginConfigError::LiquidationFeeOutOfRange(fee_rate))
);
}
assert_eq!(
SpotMarginLiquidation::new(dec!(1.1), dec!(0))
.unwrap()
.with_margin_call(dec!(1.1)),
Err(SpotMarginConfigError::MarginCallNotAboveLiquidation {
margin_call: dec!(1.1),
level: dec!(1.1),
})
);
for json in [
r#"{"level":"0.5","fee_rate":"0"}"#,
r#"{"level":"1.1","fee_rate":"1"}"#,
r#"{"level":"1.1","fee_rate":"0","margin_call_level":"1"}"#,
] {
assert!(
serde_json::from_str::<SpotMarginLiquidation>(json).is_err(),
"{json}"
);
}
let within_leverage = r#"{"valuation_asset":"usdt","max_leverage":"3","margin_effect":"borrow","liquidation":{"level":"1.5","fee_rate":"0"}}"#;
assert!(serde_json::from_str::<SpotMarginConfig>(within_leverage).is_err());
}
#[test]
fn the_effect_wire_names_are_snake_case_and_the_default_borrows_nothing() {
assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
for (effect, name) in [
(MarginEffect::NoSideEffect, r#""no_side_effect""#),
(MarginEffect::Borrow, r#""borrow""#),
(MarginEffect::Repay, r#""repay""#),
] {
assert_eq!(serde_json::to_string(&effect).unwrap(), name);
assert_eq!(serde_json::from_str::<MarginEffect>(name).unwrap(), effect);
}
}
fn btc() -> AssetNameExchange {
AssetNameExchange::new("btc")
}
fn usdt() -> AssetNameExchange {
AssetNameExchange::new("usdt")
}
fn utc(text: &str) -> DateTime<Utc> {
text.parse().unwrap()
}
fn hourly_btc(annual: Decimal) -> PeriodicMarginInterest {
PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
.with_rates(btc(), RateSeries::constant(annual))
}
#[test]
fn a_small_loan_is_charged_one_unit_for_its_first_period() {
let model = hourly_btc(dec!(0.0039));
let charge = model.charge(
&btc(),
dec!(0.00063),
InterestDue::Borrow(utc("2026-10-15T01:02:33Z")),
);
assert_eq!(
charge,
Some(InterestCharge::new(dec!(0.00000001), Some(dec!(0.0039))))
);
}
#[test]
fn a_daily_rate_times_365_on_act365_charges_a_24th_of_it_an_hour_without_rounding_up() {
let model = hourly_btc(dec!(0.0001) * dec!(365));
let charge = model
.charge(
&btc(),
dec!(24),
InterestDue::Boundary(utc("2026-10-15T02:00:00Z")),
)
.unwrap();
assert_eq!(charge.interest, dec!(0.0001));
}
#[test]
fn a_charge_is_rounded_up_to_the_asset_s_own_unit() {
let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
.with_rates(usdt(), RateSeries::constant(dec!(0.0876)))
.with_unit(usdt(), dec!(0.01));
let at = InterestDue::Borrow(utc("2026-10-15T01:00:00Z"));
let charged = |principal| model.charge(&usdt(), principal, at).unwrap().interest;
assert_eq!(charged(dec!(1000)), dec!(0.01));
assert_eq!(charged(dec!(1001)), dec!(0.02));
}
#[test]
fn boundaries_fall_on_whole_periods_and_one_on_a_boundary_steps_to_the_next() {
let model = hourly_btc(dec!(0.01));
assert_eq!(
model.next_boundary(&btc(), utc("2026-10-15T01:02:33Z")),
Some(utc("2026-10-15T02:00:00Z"))
);
assert_eq!(
model.next_boundary(&btc(), utc("2026-10-15T02:00:00Z")),
Some(utc("2026-10-15T03:00:00Z"))
);
}
#[test]
fn at_borrow_only_has_no_boundaries_but_still_charges_the_borrow() {
let model = hourly_btc(dec!(0.01)).with_schedule(InterestSchedule::AtBorrowOnly);
let at = utc("2026-10-15T01:02:33Z");
assert_eq!(model.next_boundary(&btc(), at), None);
assert!(
model
.charge(&btc(), dec!(1), InterestDue::Borrow(at))
.is_some()
);
}
#[test]
fn an_asset_without_rates_or_before_its_rates_begin_is_charged_nothing() {
let start = utc("2026-10-15T00:00:00Z");
let model = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
.with_rates(btc(), RateSeries::new(vec![(start, dec!(0.01))]).unwrap());
let before = InterestDue::Borrow(start - TimeDelta::seconds(1));
assert_eq!(model.next_boundary(&usdt(), start), None);
assert_eq!(
model.charge(&usdt(), dec!(1), InterestDue::Borrow(start)),
None
);
assert_eq!(model.charge(&btc(), dec!(1), before), None);
assert!(
model
.charge(&btc(), dec!(1), InterestDue::Borrow(start))
.is_some()
);
}
#[test]
#[should_panic(expected = "period must be positive")]
fn a_period_that_is_not_positive_panics() {
let _ = PeriodicMarginInterest::new(TimeDelta::zero(), DayCount::Act365);
}
#[test]
#[should_panic(expected = "unit for btc must be positive")]
fn a_unit_that_is_not_positive_panics() {
let _ = hourly_btc(dec!(0.01)).with_unit(btc(), Decimal::ZERO);
}
#[test]
#[should_panic(expected = "rate for btc must not be negative, got -0.01")]
fn a_negative_rate_anywhere_in_the_series_panics() {
let rates = RateSeries::new(vec![
(utc("2026-10-01T00:00:00Z"), dec!(0.02)),
(utc("2026-10-02T00:00:00Z"), dec!(-0.01)),
])
.unwrap();
let _ = PeriodicMarginInterest::new(TimeDelta::hours(1), DayCount::Act365)
.with_rates(btc(), rates);
}
#[test]
fn a_zero_rate_is_accepted_and_charges_nothing() {
let model = hourly_btc(Decimal::ZERO);
let charge = model.charge(
&btc(),
dec!(1),
InterestDue::Borrow(utc("2026-10-15T01:00:00Z")),
);
assert_eq!(charge.map(|charge| charge.interest), Some(Decimal::ZERO));
}
}