use crate::holding_cost::{DayCount, RateSeries, next_whole_interval};
use chrono::{DateTime, TimeDelta, Utc};
use fnv::FnvHashMap;
use rust_decimal::Decimal;
use rustrade_instrument::asset::name::AssetNameExchange;
use serde::{Deserialize, Serialize};
use std::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, 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,
}
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(),
})
}
#[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
}
}
impl TryFrom<SpotMarginRepr> for SpotMarginConfig {
type Error = SpotMarginConfigError;
fn try_from(repr: SpotMarginRepr) -> Result<Self, Self::Error> {
if repr.max_leverage < Decimal::ONE {
return Err(SpotMarginConfigError::LeverageBelowOne(repr.max_leverage));
}
Ok(Self(repr))
}
}
impl From<SpotMarginConfig> for SpotMarginRepr {
fn from(config: SpotMarginConfig) -> Self {
config.0
}
}
#[derive(Debug, Clone, Copy, 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),
}
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_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 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));
}
}