use crate::cash_flow::CashFlowKind;
use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, TimeDelta, TimeZone, Utc, Weekday};
use fnv::FnvHashMap;
use rust_decimal::Decimal;
use rustrade_instrument::instrument::name::InstrumentNameExchange;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, fmt::Debug, sync::Arc};
pub trait HoldingCostModel: Debug + Send + Sync {
fn next_boundary(
&self,
instrument: &InstrumentNameExchange,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>>;
fn charge(&self, position: &HeldPosition<'_>, boundary: DateTime<Utc>)
-> Option<HoldingCharge>;
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HeldPosition<'a> {
pub instrument: &'a InstrumentNameExchange,
pub quantity: Decimal,
pub contract_size: Decimal,
pub entry_price: Decimal,
pub price: Decimal,
}
impl<'a> HeldPosition<'a> {
pub fn new(
instrument: &'a InstrumentNameExchange,
quantity: Decimal,
contract_size: Decimal,
entry_price: Decimal,
price: Decimal,
) -> Self {
Self {
instrument,
quantity,
contract_size,
entry_price,
price,
}
}
pub fn notional(&self) -> Decimal {
self.quantity.abs() * self.contract_size * self.price
}
pub fn is_short(&self) -> bool {
self.quantity.is_sign_negative() && !self.quantity.is_zero()
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HoldingCharge {
pub kind: CashFlowKind,
pub amount: Decimal,
}
impl HoldingCharge {
pub fn new(kind: CashFlowKind, amount: Decimal) -> Self {
Self { kind, amount }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(
try_from = "Vec<(DateTime<Utc>, Decimal)>",
into = "Vec<(DateTime<Utc>, Decimal)>"
)]
pub struct RateSeries {
points: Vec<(DateTime<Utc>, Decimal)>,
}
impl TryFrom<Vec<(DateTime<Utc>, Decimal)>> for RateSeries {
type Error = RateSeriesUnsorted;
fn try_from(points: Vec<(DateTime<Utc>, Decimal)>) -> Result<Self, Self::Error> {
Self::new(points)
}
}
impl From<RateSeries> for Vec<(DateTime<Utc>, Decimal)> {
fn from(series: RateSeries) -> Self {
series.points
}
}
impl RateSeries {
pub fn new(points: Vec<(DateTime<Utc>, Decimal)>) -> Result<Self, RateSeriesUnsorted> {
if let Some(index) = points.windows(2).position(|pair| pair[1].0 <= pair[0].0) {
return Err(RateSeriesUnsorted {
index: index + 1,
previous: points[index].0,
time: points[index + 1].0,
});
}
Ok(Self { points })
}
pub fn constant(rate: Decimal) -> Self {
Self {
points: vec![(DateTime::<Utc>::MIN_UTC, rate)],
}
}
pub fn at(&self, time: DateTime<Utc>) -> Option<Decimal> {
let after = self.points.partition_point(|(start, _)| *start <= time);
after.checked_sub(1).map(|index| self.points[index].1)
}
pub(crate) fn rates(&self) -> impl Iterator<Item = Decimal> + '_ {
self.points.iter().map(|(_, rate)| *rate)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error(
"rate series instants must be strictly ascending: point {index} at {time} follows {previous}"
)]
pub struct RateSeriesUnsorted {
pub index: usize,
pub previous: DateTime<Utc>,
pub time: DateTime<Utc>,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
)]
pub enum DayCount {
#[default]
Act360,
Act365,
}
impl DayCount {
pub fn accrue(self, annual: Decimal, days: u32) -> Decimal {
annual * Decimal::from(days) / self.basis()
}
pub fn accrue_span(self, annual: Decimal, span: TimeDelta) -> Decimal {
let seconds =
Decimal::from(span.num_seconds()) + Decimal::new(i64::from(span.subsec_nanos()), 9);
annual * seconds / (self.basis() * Decimal::from(SECONDS_PER_DAY))
}
fn basis(self) -> Decimal {
match self {
Self::Act360 => Decimal::from(360),
Self::Act365 => Decimal::from(365),
}
}
}
const SECONDS_PER_DAY: i64 = 86_400;
pub trait SettlementCalendar: Debug + Send + Sync {
fn is_business_day(&self, date: NaiveDate) -> bool;
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
)]
pub struct WeekendsOnly;
impl SettlementCalendar for WeekendsOnly {
fn is_business_day(&self, date: NaiveDate) -> bool {
!matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct HolidayCalendar {
holidays: BTreeSet<NaiveDate>,
}
impl HolidayCalendar {
pub fn new(holidays: impl IntoIterator<Item = NaiveDate>) -> Self {
Self {
holidays: holidays.into_iter().collect(),
}
}
}
impl SettlementCalendar for HolidayCalendar {
fn is_business_day(&self, date: NaiveDate) -> bool {
WeekendsOnly.is_business_day(date) && !self.holidays.contains(&date)
}
}
#[derive(Debug, Clone)]
pub enum AccrualDays {
Calendar,
Settlement {
calendar: Arc<dyn SettlementCalendar>,
lag: u32,
},
}
const CUTOFF_SEARCH_DAYS: u32 = 3660;
#[derive(Debug, Clone)]
pub struct DailyAccrual<Tz: TimeZone = Utc> {
cutoff: NaiveTime,
zone: Tz,
days: AccrualDays,
day_count: DayCount,
}
impl<Tz: TimeZone> DailyAccrual<Tz> {
pub fn new(cutoff: NaiveTime, zone: Tz, days: AccrualDays, day_count: DayCount) -> Self {
Self {
cutoff,
zone,
days,
day_count,
}
}
pub fn next_boundary(&self, after: DateTime<Utc>) -> DateTime<Utc> {
let mut date = after.with_timezone(&self.zone).date_naive();
for _ in 0..CUTOFF_SEARCH_DAYS {
if self.has_cutoff(date) {
let boundary = self.cutoff_on(date);
if boundary > after {
return boundary;
}
}
date = next_date(date);
}
panic!(
"DailyAccrual found no cutoff within {CUTOFF_SEARCH_DAYS} days of {after}: its \
settlement calendar has no business day"
)
}
pub fn accrue(&self, annual: Decimal, boundary: DateTime<Utc>) -> Decimal {
self.day_count.accrue(annual, self.days(boundary))
}
pub fn days(&self, boundary: DateTime<Utc>) -> u32 {
match &self.days {
AccrualDays::Calendar => 1,
AccrualDays::Settlement { calendar, lag } => {
let date = boundary.with_timezone(&self.zone).date_naive();
let settles = add_business_days(calendar.as_ref(), date, *lag);
let next_settles = add_business_days(
calendar.as_ref(),
next_business_day(calendar.as_ref(), date),
*lag,
);
u32::try_from((next_settles - settles).num_days()).unwrap_or(u32::MAX)
}
}
}
fn has_cutoff(&self, date: NaiveDate) -> bool {
match &self.days {
AccrualDays::Calendar => true,
AccrualDays::Settlement { calendar, .. } => calendar.is_business_day(date),
}
}
fn cutoff_on(&self, date: NaiveDate) -> DateTime<Utc> {
let local = date.and_time(self.cutoff);
(0..=24 * 60)
.find_map(|minutes| {
self.zone
.from_local_datetime(&(local + TimeDelta::minutes(minutes)))
.earliest()
})
.map(|instant| instant.with_timezone(&Utc))
.unwrap_or_else(|| panic!("DailyAccrual cannot place a cutoff on {date} in its zone"))
}
}
pub(crate) fn next_whole_interval(
interval: TimeDelta,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
let since_epoch = after - DateTime::UNIX_EPOCH;
let interval_ns = interval.num_nanoseconds()?;
let elapsed_ns = since_epoch.num_nanoseconds()?;
let next = elapsed_ns.div_euclid(interval_ns).checked_add(1)?;
DateTime::UNIX_EPOCH.checked_add_signed(TimeDelta::nanoseconds(next.checked_mul(interval_ns)?))
}
fn next_date(date: NaiveDate) -> NaiveDate {
date.succ_opt()
.unwrap_or_else(|| panic!("DailyAccrual ran past the last representable date"))
}
fn next_business_day(calendar: &dyn SettlementCalendar, date: NaiveDate) -> NaiveDate {
let mut next = next_date(date);
for _ in 0..CUTOFF_SEARCH_DAYS {
if calendar.is_business_day(next) {
return next;
}
next = next_date(next);
}
panic!("SettlementCalendar has no business day within {CUTOFF_SEARCH_DAYS} days after {date}")
}
fn add_business_days(calendar: &dyn SettlementCalendar, date: NaiveDate, days: u32) -> NaiveDate {
(0..days).fold(date, |date, _| next_business_day(calendar, date))
}
#[derive(Debug, Clone)]
pub struct FundingModel {
interval: TimeDelta,
rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
}
impl FundingModel {
pub fn new(interval: TimeDelta) -> Self {
assert!(
interval > TimeDelta::zero(),
"FundingModel interval must be positive, got {interval}"
);
Self {
interval,
rates: FnvHashMap::default(),
}
}
#[must_use]
pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: RateSeries) -> Self {
self.rates.insert(instrument, rates);
self
}
}
impl HoldingCostModel for FundingModel {
fn next_boundary(
&self,
instrument: &InstrumentNameExchange,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
if !self.rates.contains_key(instrument) {
return None;
}
next_whole_interval(self.interval, after)
}
fn charge(
&self,
position: &HeldPosition<'_>,
boundary: DateTime<Utc>,
) -> Option<HoldingCharge> {
let rate = self.rates.get(position.instrument)?.at(boundary)?;
let amount = -(position.quantity * position.contract_size * position.price * rate);
Some(HoldingCharge::new(
CashFlowKind::Funding {
rate: Some(rate),
position_quantity: Some(position.quantity),
},
amount,
))
}
}
#[derive(Debug, Clone)]
pub struct BorrowFeeModel<Tz: TimeZone = Utc> {
accrual: DailyAccrual<Tz>,
rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
}
impl<Tz: TimeZone> BorrowFeeModel<Tz> {
pub fn new(accrual: DailyAccrual<Tz>) -> Self {
Self {
accrual,
rates: FnvHashMap::default(),
}
}
#[must_use]
pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: RateSeries) -> Self {
self.rates.insert(instrument, rates);
self
}
}
impl<Tz> HoldingCostModel for BorrowFeeModel<Tz>
where
Tz: TimeZone + Debug + Send + Sync,
{
fn next_boundary(
&self,
instrument: &InstrumentNameExchange,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
self.rates
.contains_key(instrument)
.then(|| self.accrual.next_boundary(after))
}
fn charge(
&self,
position: &HeldPosition<'_>,
boundary: DateTime<Utc>,
) -> Option<HoldingCharge> {
if !position.is_short() {
return None;
}
let rate = self.rates.get(position.instrument)?.at(boundary)?;
let amount = -self.accrual.accrue(position.notional() * rate, boundary);
Some(HoldingCharge::new(
CashFlowKind::BorrowFee {
rate: Some(rate),
quantity: Some(position.quantity.abs() * position.contract_size),
},
amount,
))
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct FinancingRates {
pub long: RateSeries,
pub short: RateSeries,
}
impl FinancingRates {
pub fn new(long: RateSeries, short: RateSeries) -> Self {
Self { long, short }
}
}
#[derive(Debug, Clone)]
pub struct FinancingModel<Tz: TimeZone = Utc> {
accrual: DailyAccrual<Tz>,
rates: FnvHashMap<InstrumentNameExchange, FinancingRates>,
}
impl<Tz: TimeZone> FinancingModel<Tz> {
pub fn new(accrual: DailyAccrual<Tz>) -> Self {
Self {
accrual,
rates: FnvHashMap::default(),
}
}
#[must_use]
pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: FinancingRates) -> Self {
self.rates.insert(instrument, rates);
self
}
}
impl<Tz> HoldingCostModel for FinancingModel<Tz>
where
Tz: TimeZone + Debug + Send + Sync,
{
fn next_boundary(
&self,
instrument: &InstrumentNameExchange,
after: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
self.rates
.contains_key(instrument)
.then(|| self.accrual.next_boundary(after))
}
fn charge(
&self,
position: &HeldPosition<'_>,
boundary: DateTime<Utc>,
) -> Option<HoldingCharge> {
let rates = self.rates.get(position.instrument)?;
let series = match position.is_short() {
true => &rates.short,
false => &rates.long,
};
let rate = series.at(boundary)?;
let notional = position.notional();
let amount = -self.accrual.accrue(notional * rate, boundary);
Some(HoldingCharge::new(
CashFlowKind::Financing {
rate: Some(rate),
notional: Some(notional),
},
amount,
))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use rust_decimal_macros::dec;
fn utc(text: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(text).unwrap().to_utc()
}
fn date(text: &str) -> NaiveDate {
NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
}
fn name() -> InstrumentNameExchange {
InstrumentNameExchange::new("AAPL")
}
fn held(instrument: &InstrumentNameExchange, quantity: Decimal) -> HeldPosition<'_> {
HeldPosition::new(instrument, quantity, Decimal::ONE, dec!(90), dec!(100))
}
fn cutoff(hour: u32) -> NaiveTime {
NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
}
fn settlement(lag: u32, holidays: &[&str]) -> AccrualDays {
AccrualDays::Settlement {
calendar: Arc::new(HolidayCalendar::new(holidays.iter().map(|d| date(d)))),
lag,
}
}
#[test]
fn a_rate_series_steps_at_each_point_and_has_none_before_the_first() {
let series = RateSeries::new(vec![
(utc("2026-01-01T00:00:00Z"), dec!(0.01)),
(utc("2026-01-03T00:00:00Z"), dec!(0.02)),
])
.unwrap();
assert_eq!(series.at(utc("2025-12-31T23:59:59Z")), None);
assert_eq!(series.at(utc("2026-01-01T00:00:00Z")), Some(dec!(0.01)));
assert_eq!(series.at(utc("2026-01-02T23:59:59Z")), Some(dec!(0.01)));
assert_eq!(series.at(utc("2026-01-03T00:00:00Z")), Some(dec!(0.02)));
assert_eq!(series.at(utc("2030-01-01T00:00:00Z")), Some(dec!(0.02)));
assert_eq!(
RateSeries::constant(dec!(0.05)).at(DateTime::<Utc>::MIN_UTC),
Some(dec!(0.05))
);
}
#[test]
fn a_rate_series_out_of_order_or_repeating_an_instant_is_refused() {
let first = utc("2026-01-02T00:00:00Z");
let unsorted = RateSeries::new(vec![
(first, dec!(0.01)),
(utc("2026-01-01T00:00:00Z"), dec!(0.02)),
]);
assert_eq!(
unsorted,
Err(RateSeriesUnsorted {
index: 1,
previous: first,
time: utc("2026-01-01T00:00:00Z"),
})
);
let repeated = RateSeries::new(vec![(first, dec!(0.01)), (first, dec!(0.02))]);
assert!(repeated.is_err(), "two rates at one instant are ambiguous");
}
#[test]
fn a_rate_series_loaded_out_of_order_is_refused_as_one_built_out_of_order_is() {
let sorted = RateSeries::new(vec![
(utc("2026-01-01T00:00:00Z"), dec!(0.01)),
(utc("2026-01-02T00:00:00Z"), dec!(0.02)),
])
.unwrap();
let json = serde_json::to_string(&sorted).unwrap();
assert_eq!(serde_json::from_str::<RateSeries>(&json).unwrap(), sorted);
let unsorted = r#"[["2026-01-02T00:00:00Z","0.02"],["2026-01-01T00:00:00Z","0.01"]]"#;
let error = serde_json::from_str::<RateSeries>(unsorted).unwrap_err();
assert!(
error.to_string().contains("strictly ascending"),
"unexpected error: {error}"
);
}
#[test]
fn a_span_accrues_its_fraction_of_a_year_to_the_nanosecond() {
let annual = Decimal::from(360 * SECONDS_PER_DAY);
assert_eq!(
DayCount::Act360.accrue_span(annual, TimeDelta::milliseconds(1500)),
dec!(1.5)
);
assert_eq!(
DayCount::Act365.accrue_span(dec!(365), TimeDelta::hours(36)),
dec!(1.5)
);
}
#[test]
fn a_calendar_cutoff_falls_every_day_and_charges_one_day() {
let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
let friday = accrual.next_boundary(utc("2026-10-09T12:00:00Z"));
assert_eq!(friday, utc("2026-10-09T21:00:00Z"));
let saturday = accrual.next_boundary(friday);
assert_eq!(saturday, utc("2026-10-10T21:00:00Z"));
assert_eq!(accrual.next_boundary(saturday), utc("2026-10-11T21:00:00Z"));
assert_eq!(accrual.days(friday), 1);
assert_eq!(accrual.accrue(dec!(360), friday), Decimal::ONE);
}
#[test]
fn a_settlement_cutoff_skips_non_business_days() {
let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(1, &[]), DayCount::Act360);
let friday = utc("2026-10-09T21:00:00Z");
assert_eq!(accrual.next_boundary(friday), utc("2026-10-12T21:00:00Z"));
}
#[test]
fn a_t_plus_1_short_held_over_thursday_pays_three_days() {
let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(1, &[]), DayCount::Act360);
assert_eq!(accrual.days(utc("2026-10-07T21:00:00Z")), 1, "Wednesday");
assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 3, "Thursday");
assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 1, "Friday");
}
#[test]
fn a_t_plus_1_short_held_over_thursday_before_a_monday_holiday_pays_four_days() {
let accrual = DailyAccrual::new(
cutoff(21),
Utc,
settlement(1, &["2026-10-12"]),
DayCount::Act360,
);
assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 4, "Thursday");
assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 1, "Friday");
}
#[test]
fn with_no_settlement_lag_friday_pays_the_weekend() {
let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(0, &[]), DayCount::Act360);
assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 1, "Thursday");
assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 3, "Friday");
}
#[test]
fn a_cutoff_in_a_zone_with_daylight_saving_follows_local_time() {
let accrual = DailyAccrual::new(
cutoff(17),
chrono_tz::America::New_York,
AccrualDays::Calendar,
DayCount::Act360,
);
assert_eq!(
accrual.next_boundary(utc("2026-10-09T12:00:00Z")),
utc("2026-10-09T21:00:00Z")
);
assert_eq!(
accrual.next_boundary(utc("2026-12-09T12:00:00Z")),
utc("2026-12-09T22:00:00Z")
);
}
#[test]
fn a_cutoff_in_a_daylight_saving_gap_falls_after_the_gap() {
let accrual = DailyAccrual::new(
NaiveTime::from_hms_opt(2, 30, 0).unwrap(),
chrono_tz::America::New_York,
AccrualDays::Calendar,
DayCount::Act360,
);
assert_eq!(
accrual.next_boundary(utc("2026-03-08T00:00:00Z")),
utc("2026-03-08T07:00:00Z")
);
}
#[test]
fn a_cutoff_that_falls_twice_is_taken_the_first_time() {
let accrual = DailyAccrual::new(
NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
chrono_tz::America::New_York,
AccrualDays::Calendar,
DayCount::Act360,
);
assert_eq!(
accrual.next_boundary(utc("2026-11-01T00:00:00Z")),
utc("2026-11-01T05:30:00Z")
);
}
#[test]
#[should_panic(expected = "no business day")]
fn a_calendar_with_no_business_day_panics_rather_than_searching_forever() {
#[derive(Debug)]
struct Never;
impl SettlementCalendar for Never {
fn is_business_day(&self, _: NaiveDate) -> bool {
false
}
}
let days = AccrualDays::Settlement {
calendar: Arc::new(Never),
lag: 0,
};
let accrual = DailyAccrual::new(cutoff(21), Utc, days, DayCount::Act360);
let _ = accrual.next_boundary(utc("2026-10-09T12:00:00Z"));
}
#[test]
fn funding_falls_on_whole_intervals_since_the_epoch() {
let instrument = name();
let model = FundingModel::new(TimeDelta::hours(8))
.with_rates(instrument.clone(), RateSeries::constant(dec!(0.0001)));
assert_eq!(
model.next_boundary(&instrument, utc("2026-10-09T05:00:00Z")),
Some(utc("2026-10-09T08:00:00Z"))
);
assert_eq!(
model.next_boundary(&instrument, utc("2026-10-09T08:00:00Z")),
Some(utc("2026-10-09T16:00:00Z")),
"a boundary steps strictly forward"
);
assert_eq!(
model.next_boundary(
&InstrumentNameExchange::new("MSFT"),
utc("2026-10-09T05:00:00Z")
),
None,
"an instrument with no rates is never charged"
);
}
#[test]
fn funding_is_paid_by_a_long_and_received_by_a_short_at_a_positive_rate() {
let instrument = name();
let model = FundingModel::new(TimeDelta::hours(1))
.with_rates(instrument.clone(), RateSeries::constant(dec!(0.0001)));
let boundary = utc("2026-10-09T08:00:00Z");
let long = model
.charge(&held(&instrument, dec!(10)), boundary)
.unwrap();
assert_eq!(long.amount, dec!(-0.1), "10 × 100 × 0.0001 paid");
assert_eq!(
long.kind,
CashFlowKind::Funding {
rate: Some(dec!(0.0001)),
position_quantity: Some(dec!(10)),
}
);
let short = model
.charge(&held(&instrument, dec!(-10)), boundary)
.unwrap();
assert_eq!(short.amount, dec!(0.1));
}
#[test]
fn a_borrow_fee_charges_a_short_only() {
let instrument = name();
let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
let model = BorrowFeeModel::new(accrual)
.with_rates(instrument.clone(), RateSeries::constant(dec!(0.36)));
let boundary = utc("2026-10-09T21:00:00Z");
assert_eq!(model.charge(&held(&instrument, dec!(10)), boundary), None);
let short = model
.charge(&held(&instrument, dec!(-10)), boundary)
.unwrap();
assert_eq!(short.amount, dec!(-1));
assert_eq!(
short.kind,
CashFlowKind::BorrowFee {
rate: Some(dec!(0.36)),
quantity: Some(dec!(10)),
}
);
}
#[test]
fn a_borrow_fee_before_its_rates_begin_is_not_charged() {
let instrument = name();
let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
let rates = RateSeries::new(vec![(utc("2026-10-10T00:00:00Z"), dec!(0.36))]).unwrap();
let model = BorrowFeeModel::new(accrual).with_rates(instrument.clone(), rates);
let before = utc("2026-10-09T21:00:00Z");
assert_eq!(model.charge(&held(&instrument, dec!(-10)), before), None);
}
#[test]
fn financing_charges_each_side_at_its_own_rate_and_pays_a_negative_one() {
let instrument = name();
let accrual = DailyAccrual::new(cutoff(22), Utc, AccrualDays::Calendar, DayCount::Act365);
let rates = FinancingRates::new(
RateSeries::constant(dec!(0.073)),
RateSeries::constant(dec!(-0.0365)),
);
let model = FinancingModel::new(accrual).with_rates(instrument.clone(), rates);
let boundary = utc("2026-10-09T22:00:00Z");
let long = model
.charge(&held(&instrument, dec!(10)), boundary)
.unwrap();
assert_eq!(long.amount, dec!(-0.2));
assert_eq!(
long.kind,
CashFlowKind::Financing {
rate: Some(dec!(0.073)),
notional: Some(dec!(1000)),
}
);
let short = model
.charge(&held(&instrument, dec!(-10)), boundary)
.unwrap();
assert_eq!(short.amount, dec!(0.1), "a negative short rate is received");
}
}