1use crate::cash_flow::CashFlowKind;
29use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, TimeDelta, TimeZone, Utc, Weekday};
30use fnv::FnvHashMap;
31use rust_decimal::Decimal;
32use rustrade_instrument::instrument::name::InstrumentNameExchange;
33use serde::{Deserialize, Serialize};
34use std::{collections::BTreeSet, fmt::Debug, sync::Arc};
35
36pub trait HoldingCostModel: Debug + Send + Sync {
60 fn next_boundary(
63 &self,
64 instrument: &InstrumentNameExchange,
65 after: DateTime<Utc>,
66 ) -> Option<DateTime<Utc>>;
67
68 fn charge(&self, position: &HeldPosition<'_>, boundary: DateTime<Utc>)
70 -> Option<HoldingCharge>;
71}
72
73#[non_exhaustive]
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct HeldPosition<'a> {
77 pub instrument: &'a InstrumentNameExchange,
79 pub quantity: Decimal,
81 pub contract_size: Decimal,
83 pub entry_price: Decimal,
85 pub price: Decimal,
88}
89
90impl<'a> HeldPosition<'a> {
91 pub fn new(
94 instrument: &'a InstrumentNameExchange,
95 quantity: Decimal,
96 contract_size: Decimal,
97 entry_price: Decimal,
98 price: Decimal,
99 ) -> Self {
100 Self {
101 instrument,
102 quantity,
103 contract_size,
104 entry_price,
105 price,
106 }
107 }
108
109 pub fn notional(&self) -> Decimal {
112 self.quantity.abs() * self.contract_size * self.price
113 }
114
115 pub fn is_short(&self) -> bool {
117 self.quantity.is_sign_negative() && !self.quantity.is_zero()
118 }
119}
120
121#[non_exhaustive]
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct HoldingCharge {
125 pub kind: CashFlowKind,
127 pub amount: Decimal,
131}
132
133impl HoldingCharge {
134 pub fn new(kind: CashFlowKind, amount: Decimal) -> Self {
136 Self { kind, amount }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(
151 try_from = "Vec<(DateTime<Utc>, Decimal)>",
152 into = "Vec<(DateTime<Utc>, Decimal)>"
153)]
154pub struct RateSeries {
155 points: Vec<(DateTime<Utc>, Decimal)>,
156}
157
158impl TryFrom<Vec<(DateTime<Utc>, Decimal)>> for RateSeries {
159 type Error = RateSeriesUnsorted;
160
161 fn try_from(points: Vec<(DateTime<Utc>, Decimal)>) -> Result<Self, Self::Error> {
162 Self::new(points)
163 }
164}
165
166impl From<RateSeries> for Vec<(DateTime<Utc>, Decimal)> {
167 fn from(series: RateSeries) -> Self {
168 series.points
169 }
170}
171
172impl RateSeries {
173 pub fn new(points: Vec<(DateTime<Utc>, Decimal)>) -> Result<Self, RateSeriesUnsorted> {
180 if let Some(index) = points.windows(2).position(|pair| pair[1].0 <= pair[0].0) {
181 return Err(RateSeriesUnsorted {
182 index: index + 1,
183 previous: points[index].0,
184 time: points[index + 1].0,
185 });
186 }
187
188 Ok(Self { points })
189 }
190
191 pub fn constant(rate: Decimal) -> Self {
193 Self {
194 points: vec![(DateTime::<Utc>::MIN_UTC, rate)],
195 }
196 }
197
198 pub fn at(&self, time: DateTime<Utc>) -> Option<Decimal> {
201 let after = self.points.partition_point(|(start, _)| *start <= time);
202 after.checked_sub(1).map(|index| self.points[index].1)
203 }
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
208#[error(
209 "rate series instants must be strictly ascending: point {index} at {time} follows {previous}"
210)]
211pub struct RateSeriesUnsorted {
212 pub index: usize,
214 pub previous: DateTime<Utc>,
216 pub time: DateTime<Utc>,
218}
219
220#[derive(
223 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
224)]
225pub enum DayCount {
226 #[default]
228 Act360,
229 Act365,
231}
232
233impl DayCount {
234 pub fn accrue(self, annual: Decimal, days: u32) -> Decimal {
240 let basis = match self {
241 Self::Act360 => Decimal::from(360),
242 Self::Act365 => Decimal::from(365),
243 };
244 annual * Decimal::from(days) / basis
245 }
246}
247
248pub trait SettlementCalendar: Debug + Send + Sync {
252 fn is_business_day(&self, date: NaiveDate) -> bool;
254}
255
256#[derive(
258 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
259)]
260pub struct WeekendsOnly;
261
262impl SettlementCalendar for WeekendsOnly {
263 fn is_business_day(&self, date: NaiveDate) -> bool {
264 !matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
273pub struct HolidayCalendar {
274 holidays: BTreeSet<NaiveDate>,
275}
276
277impl HolidayCalendar {
278 pub fn new(holidays: impl IntoIterator<Item = NaiveDate>) -> Self {
280 Self {
281 holidays: holidays.into_iter().collect(),
282 }
283 }
284}
285
286impl SettlementCalendar for HolidayCalendar {
287 fn is_business_day(&self, date: NaiveDate) -> bool {
288 WeekendsOnly.is_business_day(date) && !self.holidays.contains(&date)
289 }
290}
291
292#[derive(Debug, Clone)]
294pub enum AccrualDays {
295 Calendar,
298 Settlement {
308 calendar: Arc<dyn SettlementCalendar>,
310 lag: u32,
312 },
313}
314
315const CUTOFF_SEARCH_DAYS: u32 = 3660;
317
318#[derive(Debug, Clone)]
325pub struct DailyAccrual<Tz: TimeZone = Utc> {
326 cutoff: NaiveTime,
327 zone: Tz,
328 days: AccrualDays,
329 day_count: DayCount,
330}
331
332impl<Tz: TimeZone> DailyAccrual<Tz> {
333 pub fn new(cutoff: NaiveTime, zone: Tz, days: AccrualDays, day_count: DayCount) -> Self {
336 Self {
337 cutoff,
338 zone,
339 days,
340 day_count,
341 }
342 }
343
344 pub fn next_boundary(&self, after: DateTime<Utc>) -> DateTime<Utc> {
350 let mut date = after.with_timezone(&self.zone).date_naive();
351
352 for _ in 0..CUTOFF_SEARCH_DAYS {
353 if self.has_cutoff(date) {
354 let boundary = self.cutoff_on(date);
355 if boundary > after {
356 return boundary;
357 }
358 }
359 date = next_date(date);
360 }
361
362 panic!(
363 "DailyAccrual found no cutoff within {CUTOFF_SEARCH_DAYS} days of {after}: its \
364 settlement calendar has no business day"
365 )
366 }
367
368 pub fn accrue(&self, annual: Decimal, boundary: DateTime<Utc>) -> Decimal {
371 self.day_count.accrue(annual, self.days(boundary))
372 }
373
374 pub fn days(&self, boundary: DateTime<Utc>) -> u32 {
376 match &self.days {
377 AccrualDays::Calendar => 1,
378 AccrualDays::Settlement { calendar, lag } => {
379 let date = boundary.with_timezone(&self.zone).date_naive();
380 let settles = add_business_days(calendar.as_ref(), date, *lag);
381 let next_settles = add_business_days(
382 calendar.as_ref(),
383 next_business_day(calendar.as_ref(), date),
384 *lag,
385 );
386 u32::try_from((next_settles - settles).num_days()).unwrap_or(u32::MAX)
388 }
389 }
390 }
391
392 fn has_cutoff(&self, date: NaiveDate) -> bool {
393 match &self.days {
394 AccrualDays::Calendar => true,
395 AccrualDays::Settlement { calendar, .. } => calendar.is_business_day(date),
396 }
397 }
398
399 fn cutoff_on(&self, date: NaiveDate) -> DateTime<Utc> {
401 let local = date.and_time(self.cutoff);
402
403 (0..=24 * 60)
405 .find_map(|minutes| {
406 self.zone
407 .from_local_datetime(&(local + TimeDelta::minutes(minutes)))
408 .earliest()
409 })
410 .map(|instant| instant.with_timezone(&Utc))
411 .unwrap_or_else(|| panic!("DailyAccrual cannot place a cutoff on {date} in its zone"))
412 }
413}
414
415fn next_date(date: NaiveDate) -> NaiveDate {
416 date.succ_opt()
417 .unwrap_or_else(|| panic!("DailyAccrual ran past the last representable date"))
418}
419
420fn next_business_day(calendar: &dyn SettlementCalendar, date: NaiveDate) -> NaiveDate {
422 let mut next = next_date(date);
423 for _ in 0..CUTOFF_SEARCH_DAYS {
424 if calendar.is_business_day(next) {
425 return next;
426 }
427 next = next_date(next);
428 }
429 panic!("SettlementCalendar has no business day within {CUTOFF_SEARCH_DAYS} days after {date}")
430}
431
432fn add_business_days(calendar: &dyn SettlementCalendar, date: NaiveDate, days: u32) -> NaiveDate {
434 (0..days).fold(date, |date, _| next_business_day(calendar, date))
435}
436
437#[derive(Debug, Clone)]
448pub struct FundingModel {
449 interval: TimeDelta,
450 rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
451}
452
453impl FundingModel {
454 pub fn new(interval: TimeDelta) -> Self {
459 assert!(
460 interval > TimeDelta::zero(),
461 "FundingModel interval must be positive, got {interval}"
462 );
463 Self {
464 interval,
465 rates: FnvHashMap::default(),
466 }
467 }
468
469 #[must_use]
471 pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: RateSeries) -> Self {
472 self.rates.insert(instrument, rates);
473 self
474 }
475}
476
477impl HoldingCostModel for FundingModel {
478 fn next_boundary(
479 &self,
480 instrument: &InstrumentNameExchange,
481 after: DateTime<Utc>,
482 ) -> Option<DateTime<Utc>> {
483 if !self.rates.contains_key(instrument) {
484 return None;
485 }
486
487 let since_epoch = after - DateTime::UNIX_EPOCH;
489 let interval_ns = self.interval.num_nanoseconds()?;
490 let elapsed_ns = since_epoch.num_nanoseconds()?;
491 let next = elapsed_ns.div_euclid(interval_ns).checked_add(1)?;
492 DateTime::UNIX_EPOCH
493 .checked_add_signed(TimeDelta::nanoseconds(next.checked_mul(interval_ns)?))
494 }
495
496 fn charge(
497 &self,
498 position: &HeldPosition<'_>,
499 boundary: DateTime<Utc>,
500 ) -> Option<HoldingCharge> {
501 let rate = self.rates.get(position.instrument)?.at(boundary)?;
502 let amount = -(position.quantity * position.contract_size * position.price * rate);
503
504 Some(HoldingCharge::new(
505 CashFlowKind::Funding {
506 rate: Some(rate),
507 position_quantity: Some(position.quantity),
508 },
509 amount,
510 ))
511 }
512}
513
514#[derive(Debug, Clone)]
527pub struct BorrowFeeModel<Tz: TimeZone = Utc> {
528 accrual: DailyAccrual<Tz>,
529 rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
530}
531
532impl<Tz: TimeZone> BorrowFeeModel<Tz> {
533 pub fn new(accrual: DailyAccrual<Tz>) -> Self {
535 Self {
536 accrual,
537 rates: FnvHashMap::default(),
538 }
539 }
540
541 #[must_use]
543 pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: RateSeries) -> Self {
544 self.rates.insert(instrument, rates);
545 self
546 }
547}
548
549impl<Tz> HoldingCostModel for BorrowFeeModel<Tz>
550where
551 Tz: TimeZone + Debug + Send + Sync,
552{
553 fn next_boundary(
554 &self,
555 instrument: &InstrumentNameExchange,
556 after: DateTime<Utc>,
557 ) -> Option<DateTime<Utc>> {
558 self.rates
559 .contains_key(instrument)
560 .then(|| self.accrual.next_boundary(after))
561 }
562
563 fn charge(
564 &self,
565 position: &HeldPosition<'_>,
566 boundary: DateTime<Utc>,
567 ) -> Option<HoldingCharge> {
568 if !position.is_short() {
569 return None;
570 }
571
572 let rate = self.rates.get(position.instrument)?.at(boundary)?;
573 let amount = -self.accrual.accrue(position.notional() * rate, boundary);
574
575 Some(HoldingCharge::new(
576 CashFlowKind::BorrowFee {
577 rate: Some(rate),
578 quantity: Some(position.quantity.abs() * position.contract_size),
579 },
580 amount,
581 ))
582 }
583}
584
585#[non_exhaustive]
592#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
593pub struct FinancingRates {
594 pub long: RateSeries,
596 pub short: RateSeries,
598}
599
600impl FinancingRates {
601 pub fn new(long: RateSeries, short: RateSeries) -> Self {
603 Self { long, short }
604 }
605}
606
607#[derive(Debug, Clone)]
616pub struct FinancingModel<Tz: TimeZone = Utc> {
617 accrual: DailyAccrual<Tz>,
618 rates: FnvHashMap<InstrumentNameExchange, FinancingRates>,
619}
620
621impl<Tz: TimeZone> FinancingModel<Tz> {
622 pub fn new(accrual: DailyAccrual<Tz>) -> Self {
624 Self {
625 accrual,
626 rates: FnvHashMap::default(),
627 }
628 }
629
630 #[must_use]
632 pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: FinancingRates) -> Self {
633 self.rates.insert(instrument, rates);
634 self
635 }
636}
637
638impl<Tz> HoldingCostModel for FinancingModel<Tz>
639where
640 Tz: TimeZone + Debug + Send + Sync,
641{
642 fn next_boundary(
643 &self,
644 instrument: &InstrumentNameExchange,
645 after: DateTime<Utc>,
646 ) -> Option<DateTime<Utc>> {
647 self.rates
648 .contains_key(instrument)
649 .then(|| self.accrual.next_boundary(after))
650 }
651
652 fn charge(
653 &self,
654 position: &HeldPosition<'_>,
655 boundary: DateTime<Utc>,
656 ) -> Option<HoldingCharge> {
657 let rates = self.rates.get(position.instrument)?;
658 let series = match position.is_short() {
659 true => &rates.short,
660 false => &rates.long,
661 };
662 let rate = series.at(boundary)?;
663 let notional = position.notional();
664 let amount = -self.accrual.accrue(notional * rate, boundary);
665
666 Some(HoldingCharge::new(
667 CashFlowKind::Financing {
668 rate: Some(rate),
669 notional: Some(notional),
670 },
671 amount,
672 ))
673 }
674}
675
676#[cfg(test)]
677#[allow(clippy::unwrap_used)] mod tests {
679 use super::*;
680 use rust_decimal_macros::dec;
681
682 fn utc(text: &str) -> DateTime<Utc> {
683 DateTime::parse_from_rfc3339(text).unwrap().to_utc()
684 }
685
686 fn date(text: &str) -> NaiveDate {
687 NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
688 }
689
690 fn name() -> InstrumentNameExchange {
691 InstrumentNameExchange::new("AAPL")
692 }
693
694 fn held(instrument: &InstrumentNameExchange, quantity: Decimal) -> HeldPosition<'_> {
695 HeldPosition::new(instrument, quantity, Decimal::ONE, dec!(90), dec!(100))
696 }
697
698 fn cutoff(hour: u32) -> NaiveTime {
699 NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
700 }
701
702 fn settlement(lag: u32, holidays: &[&str]) -> AccrualDays {
703 AccrualDays::Settlement {
704 calendar: Arc::new(HolidayCalendar::new(holidays.iter().map(|d| date(d)))),
705 lag,
706 }
707 }
708
709 #[test]
710 fn a_rate_series_steps_at_each_point_and_has_none_before_the_first() {
711 let series = RateSeries::new(vec![
712 (utc("2026-01-01T00:00:00Z"), dec!(0.01)),
713 (utc("2026-01-03T00:00:00Z"), dec!(0.02)),
714 ])
715 .unwrap();
716
717 assert_eq!(series.at(utc("2025-12-31T23:59:59Z")), None);
718 assert_eq!(series.at(utc("2026-01-01T00:00:00Z")), Some(dec!(0.01)));
719 assert_eq!(series.at(utc("2026-01-02T23:59:59Z")), Some(dec!(0.01)));
720 assert_eq!(series.at(utc("2026-01-03T00:00:00Z")), Some(dec!(0.02)));
721 assert_eq!(series.at(utc("2030-01-01T00:00:00Z")), Some(dec!(0.02)));
722 assert_eq!(
723 RateSeries::constant(dec!(0.05)).at(DateTime::<Utc>::MIN_UTC),
724 Some(dec!(0.05))
725 );
726 }
727
728 #[test]
729 fn a_rate_series_out_of_order_or_repeating_an_instant_is_refused() {
730 let first = utc("2026-01-02T00:00:00Z");
731 let unsorted = RateSeries::new(vec![
732 (first, dec!(0.01)),
733 (utc("2026-01-01T00:00:00Z"), dec!(0.02)),
734 ]);
735 assert_eq!(
736 unsorted,
737 Err(RateSeriesUnsorted {
738 index: 1,
739 previous: first,
740 time: utc("2026-01-01T00:00:00Z"),
741 })
742 );
743
744 let repeated = RateSeries::new(vec![(first, dec!(0.01)), (first, dec!(0.02))]);
745 assert!(repeated.is_err(), "two rates at one instant are ambiguous");
746 }
747
748 #[test]
749 fn a_rate_series_loaded_out_of_order_is_refused_as_one_built_out_of_order_is() {
750 let sorted = RateSeries::new(vec![
751 (utc("2026-01-01T00:00:00Z"), dec!(0.01)),
752 (utc("2026-01-02T00:00:00Z"), dec!(0.02)),
753 ])
754 .unwrap();
755 let json = serde_json::to_string(&sorted).unwrap();
756 assert_eq!(serde_json::from_str::<RateSeries>(&json).unwrap(), sorted);
757
758 let unsorted = r#"[["2026-01-02T00:00:00Z","0.02"],["2026-01-01T00:00:00Z","0.01"]]"#;
759 let error = serde_json::from_str::<RateSeries>(unsorted).unwrap_err();
760 assert!(
761 error.to_string().contains("strictly ascending"),
762 "unexpected error: {error}"
763 );
764 }
765
766 #[test]
767 fn a_calendar_cutoff_falls_every_day_and_charges_one_day() {
768 let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
769
770 let friday = accrual.next_boundary(utc("2026-10-09T12:00:00Z"));
772 assert_eq!(friday, utc("2026-10-09T21:00:00Z"));
773 let saturday = accrual.next_boundary(friday);
774 assert_eq!(saturday, utc("2026-10-10T21:00:00Z"));
775 assert_eq!(accrual.next_boundary(saturday), utc("2026-10-11T21:00:00Z"));
776 assert_eq!(accrual.days(friday), 1);
777 assert_eq!(accrual.accrue(dec!(360), friday), Decimal::ONE);
778 }
779
780 #[test]
781 fn a_settlement_cutoff_skips_non_business_days() {
782 let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(1, &[]), DayCount::Act360);
783
784 let friday = utc("2026-10-09T21:00:00Z");
785 assert_eq!(accrual.next_boundary(friday), utc("2026-10-12T21:00:00Z"));
786 }
787
788 #[test]
791 fn a_t_plus_1_short_held_over_thursday_pays_three_days() {
792 let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(1, &[]), DayCount::Act360);
793
794 assert_eq!(accrual.days(utc("2026-10-07T21:00:00Z")), 1, "Wednesday");
795 assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 3, "Thursday");
796 assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 1, "Friday");
797 }
798
799 #[test]
800 fn a_t_plus_1_short_held_over_thursday_before_a_monday_holiday_pays_four_days() {
801 let accrual = DailyAccrual::new(
802 cutoff(21),
803 Utc,
804 settlement(1, &["2026-10-12"]),
805 DayCount::Act360,
806 );
807
808 assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 4, "Thursday");
809 assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 1, "Friday");
810 }
811
812 #[test]
813 fn with_no_settlement_lag_friday_pays_the_weekend() {
814 let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(0, &[]), DayCount::Act360);
815
816 assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 1, "Thursday");
817 assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 3, "Friday");
818 }
819
820 #[test]
822 fn a_cutoff_in_a_zone_with_daylight_saving_follows_local_time() {
823 let accrual = DailyAccrual::new(
824 cutoff(17),
825 chrono_tz::America::New_York,
826 AccrualDays::Calendar,
827 DayCount::Act360,
828 );
829
830 assert_eq!(
831 accrual.next_boundary(utc("2026-10-09T12:00:00Z")),
832 utc("2026-10-09T21:00:00Z")
833 );
834 assert_eq!(
835 accrual.next_boundary(utc("2026-12-09T12:00:00Z")),
836 utc("2026-12-09T22:00:00Z")
837 );
838 }
839
840 #[test]
842 fn a_cutoff_in_a_daylight_saving_gap_falls_after_the_gap() {
843 let accrual = DailyAccrual::new(
844 NaiveTime::from_hms_opt(2, 30, 0).unwrap(),
845 chrono_tz::America::New_York,
846 AccrualDays::Calendar,
847 DayCount::Act360,
848 );
849
850 assert_eq!(
852 accrual.next_boundary(utc("2026-03-08T00:00:00Z")),
853 utc("2026-03-08T07:00:00Z")
854 );
855 }
856
857 #[test]
859 fn a_cutoff_that_falls_twice_is_taken_the_first_time() {
860 let accrual = DailyAccrual::new(
861 NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
862 chrono_tz::America::New_York,
863 AccrualDays::Calendar,
864 DayCount::Act360,
865 );
866
867 assert_eq!(
869 accrual.next_boundary(utc("2026-11-01T00:00:00Z")),
870 utc("2026-11-01T05:30:00Z")
871 );
872 }
873
874 #[test]
875 #[should_panic(expected = "no business day")]
876 fn a_calendar_with_no_business_day_panics_rather_than_searching_forever() {
877 #[derive(Debug)]
878 struct Never;
879 impl SettlementCalendar for Never {
880 fn is_business_day(&self, _: NaiveDate) -> bool {
881 false
882 }
883 }
884
885 let days = AccrualDays::Settlement {
886 calendar: Arc::new(Never),
887 lag: 0,
888 };
889 let accrual = DailyAccrual::new(cutoff(21), Utc, days, DayCount::Act360);
890 let _ = accrual.next_boundary(utc("2026-10-09T12:00:00Z"));
891 }
892
893 #[test]
894 fn funding_falls_on_whole_intervals_since_the_epoch() {
895 let instrument = name();
896 let model = FundingModel::new(TimeDelta::hours(8))
897 .with_rates(instrument.clone(), RateSeries::constant(dec!(0.0001)));
898
899 assert_eq!(
900 model.next_boundary(&instrument, utc("2026-10-09T05:00:00Z")),
901 Some(utc("2026-10-09T08:00:00Z"))
902 );
903 assert_eq!(
904 model.next_boundary(&instrument, utc("2026-10-09T08:00:00Z")),
905 Some(utc("2026-10-09T16:00:00Z")),
906 "a boundary steps strictly forward"
907 );
908 assert_eq!(
909 model.next_boundary(
910 &InstrumentNameExchange::new("MSFT"),
911 utc("2026-10-09T05:00:00Z")
912 ),
913 None,
914 "an instrument with no rates is never charged"
915 );
916 }
917
918 #[test]
919 fn funding_is_paid_by_a_long_and_received_by_a_short_at_a_positive_rate() {
920 let instrument = name();
921 let model = FundingModel::new(TimeDelta::hours(1))
922 .with_rates(instrument.clone(), RateSeries::constant(dec!(0.0001)));
923 let boundary = utc("2026-10-09T08:00:00Z");
924
925 let long = model
926 .charge(&held(&instrument, dec!(10)), boundary)
927 .unwrap();
928 assert_eq!(long.amount, dec!(-0.1), "10 × 100 × 0.0001 paid");
929 assert_eq!(
930 long.kind,
931 CashFlowKind::Funding {
932 rate: Some(dec!(0.0001)),
933 position_quantity: Some(dec!(10)),
934 }
935 );
936
937 let short = model
938 .charge(&held(&instrument, dec!(-10)), boundary)
939 .unwrap();
940 assert_eq!(short.amount, dec!(0.1));
941 }
942
943 #[test]
944 fn a_borrow_fee_charges_a_short_only() {
945 let instrument = name();
946 let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
947 let model = BorrowFeeModel::new(accrual)
948 .with_rates(instrument.clone(), RateSeries::constant(dec!(0.36)));
949 let boundary = utc("2026-10-09T21:00:00Z");
950
951 assert_eq!(model.charge(&held(&instrument, dec!(10)), boundary), None);
952
953 let short = model
954 .charge(&held(&instrument, dec!(-10)), boundary)
955 .unwrap();
956 assert_eq!(short.amount, dec!(-1));
958 assert_eq!(
959 short.kind,
960 CashFlowKind::BorrowFee {
961 rate: Some(dec!(0.36)),
962 quantity: Some(dec!(10)),
963 }
964 );
965 }
966
967 #[test]
968 fn a_borrow_fee_before_its_rates_begin_is_not_charged() {
969 let instrument = name();
970 let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
971 let rates = RateSeries::new(vec![(utc("2026-10-10T00:00:00Z"), dec!(0.36))]).unwrap();
972 let model = BorrowFeeModel::new(accrual).with_rates(instrument.clone(), rates);
973
974 let before = utc("2026-10-09T21:00:00Z");
975 assert_eq!(model.charge(&held(&instrument, dec!(-10)), before), None);
976 }
977
978 #[test]
979 fn financing_charges_each_side_at_its_own_rate_and_pays_a_negative_one() {
980 let instrument = name();
981 let accrual = DailyAccrual::new(cutoff(22), Utc, AccrualDays::Calendar, DayCount::Act365);
982 let rates = FinancingRates::new(
983 RateSeries::constant(dec!(0.073)),
984 RateSeries::constant(dec!(-0.0365)),
985 );
986 let model = FinancingModel::new(accrual).with_rates(instrument.clone(), rates);
987 let boundary = utc("2026-10-09T22:00:00Z");
988
989 let long = model
990 .charge(&held(&instrument, dec!(10)), boundary)
991 .unwrap();
992 assert_eq!(long.amount, dec!(-0.2));
994 assert_eq!(
995 long.kind,
996 CashFlowKind::Financing {
997 rate: Some(dec!(0.073)),
998 notional: Some(dec!(1000)),
999 }
1000 );
1001
1002 let short = model
1003 .charge(&held(&instrument, dec!(-10)), boundary)
1004 .unwrap();
1005 assert_eq!(short.amount, dec!(0.1), "a negative short rate is received");
1006 }
1007}