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 pub(crate) fn rates(&self) -> impl Iterator<Item = Decimal> + '_ {
207 self.points.iter().map(|(_, rate)| *rate)
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
213#[error(
214 "rate series instants must be strictly ascending: point {index} at {time} follows {previous}"
215)]
216pub struct RateSeriesUnsorted {
217 pub index: usize,
219 pub previous: DateTime<Utc>,
221 pub time: DateTime<Utc>,
223}
224
225#[derive(
228 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
229)]
230pub enum DayCount {
231 #[default]
233 Act360,
234 Act365,
236}
237
238impl DayCount {
239 pub fn accrue(self, annual: Decimal, days: u32) -> Decimal {
245 annual * Decimal::from(days) / self.basis()
246 }
247
248 pub fn accrue_span(self, annual: Decimal, span: TimeDelta) -> Decimal {
254 let seconds =
255 Decimal::from(span.num_seconds()) + Decimal::new(i64::from(span.subsec_nanos()), 9);
256 annual * seconds / (self.basis() * Decimal::from(SECONDS_PER_DAY))
257 }
258
259 fn basis(self) -> Decimal {
261 match self {
262 Self::Act360 => Decimal::from(360),
263 Self::Act365 => Decimal::from(365),
264 }
265 }
266}
267
268const SECONDS_PER_DAY: i64 = 86_400;
269
270pub trait SettlementCalendar: Debug + Send + Sync {
274 fn is_business_day(&self, date: NaiveDate) -> bool;
276}
277
278#[derive(
280 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
281)]
282pub struct WeekendsOnly;
283
284impl SettlementCalendar for WeekendsOnly {
285 fn is_business_day(&self, date: NaiveDate) -> bool {
286 !matches!(date.weekday(), Weekday::Sat | Weekday::Sun)
287 }
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
295pub struct HolidayCalendar {
296 holidays: BTreeSet<NaiveDate>,
297}
298
299impl HolidayCalendar {
300 pub fn new(holidays: impl IntoIterator<Item = NaiveDate>) -> Self {
302 Self {
303 holidays: holidays.into_iter().collect(),
304 }
305 }
306}
307
308impl SettlementCalendar for HolidayCalendar {
309 fn is_business_day(&self, date: NaiveDate) -> bool {
310 WeekendsOnly.is_business_day(date) && !self.holidays.contains(&date)
311 }
312}
313
314#[derive(Debug, Clone)]
316pub enum AccrualDays {
317 Calendar,
320 Settlement {
330 calendar: Arc<dyn SettlementCalendar>,
332 lag: u32,
334 },
335}
336
337const CUTOFF_SEARCH_DAYS: u32 = 3660;
339
340#[derive(Debug, Clone)]
347pub struct DailyAccrual<Tz: TimeZone = Utc> {
348 cutoff: NaiveTime,
349 zone: Tz,
350 days: AccrualDays,
351 day_count: DayCount,
352}
353
354impl<Tz: TimeZone> DailyAccrual<Tz> {
355 pub fn new(cutoff: NaiveTime, zone: Tz, days: AccrualDays, day_count: DayCount) -> Self {
358 Self {
359 cutoff,
360 zone,
361 days,
362 day_count,
363 }
364 }
365
366 pub fn next_boundary(&self, after: DateTime<Utc>) -> DateTime<Utc> {
372 let mut date = after.with_timezone(&self.zone).date_naive();
373
374 for _ in 0..CUTOFF_SEARCH_DAYS {
375 if self.has_cutoff(date) {
376 let boundary = self.cutoff_on(date);
377 if boundary > after {
378 return boundary;
379 }
380 }
381 date = next_date(date);
382 }
383
384 panic!(
385 "DailyAccrual found no cutoff within {CUTOFF_SEARCH_DAYS} days of {after}: its \
386 settlement calendar has no business day"
387 )
388 }
389
390 pub fn accrue(&self, annual: Decimal, boundary: DateTime<Utc>) -> Decimal {
393 self.day_count.accrue(annual, self.days(boundary))
394 }
395
396 pub fn days(&self, boundary: DateTime<Utc>) -> u32 {
398 match &self.days {
399 AccrualDays::Calendar => 1,
400 AccrualDays::Settlement { calendar, lag } => {
401 let date = boundary.with_timezone(&self.zone).date_naive();
402 let settles = add_business_days(calendar.as_ref(), date, *lag);
403 let next_settles = add_business_days(
404 calendar.as_ref(),
405 next_business_day(calendar.as_ref(), date),
406 *lag,
407 );
408 u32::try_from((next_settles - settles).num_days()).unwrap_or(u32::MAX)
410 }
411 }
412 }
413
414 fn has_cutoff(&self, date: NaiveDate) -> bool {
415 match &self.days {
416 AccrualDays::Calendar => true,
417 AccrualDays::Settlement { calendar, .. } => calendar.is_business_day(date),
418 }
419 }
420
421 fn cutoff_on(&self, date: NaiveDate) -> DateTime<Utc> {
423 let local = date.and_time(self.cutoff);
424
425 (0..=24 * 60)
427 .find_map(|minutes| {
428 self.zone
429 .from_local_datetime(&(local + TimeDelta::minutes(minutes)))
430 .earliest()
431 })
432 .map(|instant| instant.with_timezone(&Utc))
433 .unwrap_or_else(|| panic!("DailyAccrual cannot place a cutoff on {date} in its zone"))
434 }
435}
436
437pub(crate) fn next_whole_interval(
440 interval: TimeDelta,
441 after: DateTime<Utc>,
442) -> Option<DateTime<Utc>> {
443 let since_epoch = after - DateTime::UNIX_EPOCH;
444 let interval_ns = interval.num_nanoseconds()?;
445 let elapsed_ns = since_epoch.num_nanoseconds()?;
446 let next = elapsed_ns.div_euclid(interval_ns).checked_add(1)?;
447 DateTime::UNIX_EPOCH.checked_add_signed(TimeDelta::nanoseconds(next.checked_mul(interval_ns)?))
448}
449
450fn next_date(date: NaiveDate) -> NaiveDate {
451 date.succ_opt()
452 .unwrap_or_else(|| panic!("DailyAccrual ran past the last representable date"))
453}
454
455fn next_business_day(calendar: &dyn SettlementCalendar, date: NaiveDate) -> NaiveDate {
457 let mut next = next_date(date);
458 for _ in 0..CUTOFF_SEARCH_DAYS {
459 if calendar.is_business_day(next) {
460 return next;
461 }
462 next = next_date(next);
463 }
464 panic!("SettlementCalendar has no business day within {CUTOFF_SEARCH_DAYS} days after {date}")
465}
466
467fn add_business_days(calendar: &dyn SettlementCalendar, date: NaiveDate, days: u32) -> NaiveDate {
469 (0..days).fold(date, |date, _| next_business_day(calendar, date))
470}
471
472#[derive(Debug, Clone)]
483pub struct FundingModel {
484 interval: TimeDelta,
485 rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
486}
487
488impl FundingModel {
489 pub fn new(interval: TimeDelta) -> Self {
494 assert!(
495 interval > TimeDelta::zero(),
496 "FundingModel interval must be positive, got {interval}"
497 );
498 Self {
499 interval,
500 rates: FnvHashMap::default(),
501 }
502 }
503
504 #[must_use]
506 pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: RateSeries) -> Self {
507 self.rates.insert(instrument, rates);
508 self
509 }
510}
511
512impl HoldingCostModel for FundingModel {
513 fn next_boundary(
514 &self,
515 instrument: &InstrumentNameExchange,
516 after: DateTime<Utc>,
517 ) -> Option<DateTime<Utc>> {
518 if !self.rates.contains_key(instrument) {
519 return None;
520 }
521 next_whole_interval(self.interval, after)
522 }
523
524 fn charge(
525 &self,
526 position: &HeldPosition<'_>,
527 boundary: DateTime<Utc>,
528 ) -> Option<HoldingCharge> {
529 let rate = self.rates.get(position.instrument)?.at(boundary)?;
530 let amount = -(position.quantity * position.contract_size * position.price * rate);
531
532 Some(HoldingCharge::new(
533 CashFlowKind::Funding {
534 rate: Some(rate),
535 position_quantity: Some(position.quantity),
536 },
537 amount,
538 ))
539 }
540}
541
542#[derive(Debug, Clone)]
555pub struct BorrowFeeModel<Tz: TimeZone = Utc> {
556 accrual: DailyAccrual<Tz>,
557 rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
558}
559
560impl<Tz: TimeZone> BorrowFeeModel<Tz> {
561 pub fn new(accrual: DailyAccrual<Tz>) -> Self {
563 Self {
564 accrual,
565 rates: FnvHashMap::default(),
566 }
567 }
568
569 #[must_use]
571 pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: RateSeries) -> Self {
572 self.rates.insert(instrument, rates);
573 self
574 }
575}
576
577impl<Tz> HoldingCostModel for BorrowFeeModel<Tz>
578where
579 Tz: TimeZone + Debug + Send + Sync,
580{
581 fn next_boundary(
582 &self,
583 instrument: &InstrumentNameExchange,
584 after: DateTime<Utc>,
585 ) -> Option<DateTime<Utc>> {
586 self.rates
587 .contains_key(instrument)
588 .then(|| self.accrual.next_boundary(after))
589 }
590
591 fn charge(
592 &self,
593 position: &HeldPosition<'_>,
594 boundary: DateTime<Utc>,
595 ) -> Option<HoldingCharge> {
596 if !position.is_short() {
597 return None;
598 }
599
600 let rate = self.rates.get(position.instrument)?.at(boundary)?;
601 let amount = -self.accrual.accrue(position.notional() * rate, boundary);
602
603 Some(HoldingCharge::new(
604 CashFlowKind::BorrowFee {
605 rate: Some(rate),
606 quantity: Some(position.quantity.abs() * position.contract_size),
607 },
608 amount,
609 ))
610 }
611}
612
613#[non_exhaustive]
620#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
621pub struct FinancingRates {
622 pub long: RateSeries,
624 pub short: RateSeries,
626}
627
628impl FinancingRates {
629 pub fn new(long: RateSeries, short: RateSeries) -> Self {
631 Self { long, short }
632 }
633}
634
635#[derive(Debug, Clone)]
644pub struct FinancingModel<Tz: TimeZone = Utc> {
645 accrual: DailyAccrual<Tz>,
646 rates: FnvHashMap<InstrumentNameExchange, FinancingRates>,
647}
648
649impl<Tz: TimeZone> FinancingModel<Tz> {
650 pub fn new(accrual: DailyAccrual<Tz>) -> Self {
652 Self {
653 accrual,
654 rates: FnvHashMap::default(),
655 }
656 }
657
658 #[must_use]
660 pub fn with_rates(mut self, instrument: InstrumentNameExchange, rates: FinancingRates) -> Self {
661 self.rates.insert(instrument, rates);
662 self
663 }
664}
665
666impl<Tz> HoldingCostModel for FinancingModel<Tz>
667where
668 Tz: TimeZone + Debug + Send + Sync,
669{
670 fn next_boundary(
671 &self,
672 instrument: &InstrumentNameExchange,
673 after: DateTime<Utc>,
674 ) -> Option<DateTime<Utc>> {
675 self.rates
676 .contains_key(instrument)
677 .then(|| self.accrual.next_boundary(after))
678 }
679
680 fn charge(
681 &self,
682 position: &HeldPosition<'_>,
683 boundary: DateTime<Utc>,
684 ) -> Option<HoldingCharge> {
685 let rates = self.rates.get(position.instrument)?;
686 let series = match position.is_short() {
687 true => &rates.short,
688 false => &rates.long,
689 };
690 let rate = series.at(boundary)?;
691 let notional = position.notional();
692 let amount = -self.accrual.accrue(notional * rate, boundary);
693
694 Some(HoldingCharge::new(
695 CashFlowKind::Financing {
696 rate: Some(rate),
697 notional: Some(notional),
698 },
699 amount,
700 ))
701 }
702}
703
704#[cfg(test)]
705#[allow(clippy::unwrap_used)] mod tests {
707 use super::*;
708 use rust_decimal_macros::dec;
709
710 fn utc(text: &str) -> DateTime<Utc> {
711 DateTime::parse_from_rfc3339(text).unwrap().to_utc()
712 }
713
714 fn date(text: &str) -> NaiveDate {
715 NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
716 }
717
718 fn name() -> InstrumentNameExchange {
719 InstrumentNameExchange::new("AAPL")
720 }
721
722 fn held(instrument: &InstrumentNameExchange, quantity: Decimal) -> HeldPosition<'_> {
723 HeldPosition::new(instrument, quantity, Decimal::ONE, dec!(90), dec!(100))
724 }
725
726 fn cutoff(hour: u32) -> NaiveTime {
727 NaiveTime::from_hms_opt(hour, 0, 0).unwrap()
728 }
729
730 fn settlement(lag: u32, holidays: &[&str]) -> AccrualDays {
731 AccrualDays::Settlement {
732 calendar: Arc::new(HolidayCalendar::new(holidays.iter().map(|d| date(d)))),
733 lag,
734 }
735 }
736
737 #[test]
738 fn a_rate_series_steps_at_each_point_and_has_none_before_the_first() {
739 let series = RateSeries::new(vec![
740 (utc("2026-01-01T00:00:00Z"), dec!(0.01)),
741 (utc("2026-01-03T00:00:00Z"), dec!(0.02)),
742 ])
743 .unwrap();
744
745 assert_eq!(series.at(utc("2025-12-31T23:59:59Z")), None);
746 assert_eq!(series.at(utc("2026-01-01T00:00:00Z")), Some(dec!(0.01)));
747 assert_eq!(series.at(utc("2026-01-02T23:59:59Z")), Some(dec!(0.01)));
748 assert_eq!(series.at(utc("2026-01-03T00:00:00Z")), Some(dec!(0.02)));
749 assert_eq!(series.at(utc("2030-01-01T00:00:00Z")), Some(dec!(0.02)));
750 assert_eq!(
751 RateSeries::constant(dec!(0.05)).at(DateTime::<Utc>::MIN_UTC),
752 Some(dec!(0.05))
753 );
754 }
755
756 #[test]
757 fn a_rate_series_out_of_order_or_repeating_an_instant_is_refused() {
758 let first = utc("2026-01-02T00:00:00Z");
759 let unsorted = RateSeries::new(vec![
760 (first, dec!(0.01)),
761 (utc("2026-01-01T00:00:00Z"), dec!(0.02)),
762 ]);
763 assert_eq!(
764 unsorted,
765 Err(RateSeriesUnsorted {
766 index: 1,
767 previous: first,
768 time: utc("2026-01-01T00:00:00Z"),
769 })
770 );
771
772 let repeated = RateSeries::new(vec![(first, dec!(0.01)), (first, dec!(0.02))]);
773 assert!(repeated.is_err(), "two rates at one instant are ambiguous");
774 }
775
776 #[test]
777 fn a_rate_series_loaded_out_of_order_is_refused_as_one_built_out_of_order_is() {
778 let sorted = RateSeries::new(vec![
779 (utc("2026-01-01T00:00:00Z"), dec!(0.01)),
780 (utc("2026-01-02T00:00:00Z"), dec!(0.02)),
781 ])
782 .unwrap();
783 let json = serde_json::to_string(&sorted).unwrap();
784 assert_eq!(serde_json::from_str::<RateSeries>(&json).unwrap(), sorted);
785
786 let unsorted = r#"[["2026-01-02T00:00:00Z","0.02"],["2026-01-01T00:00:00Z","0.01"]]"#;
787 let error = serde_json::from_str::<RateSeries>(unsorted).unwrap_err();
788 assert!(
789 error.to_string().contains("strictly ascending"),
790 "unexpected error: {error}"
791 );
792 }
793
794 #[test]
795 fn a_span_accrues_its_fraction_of_a_year_to_the_nanosecond() {
796 let annual = Decimal::from(360 * SECONDS_PER_DAY);
798
799 assert_eq!(
800 DayCount::Act360.accrue_span(annual, TimeDelta::milliseconds(1500)),
801 dec!(1.5)
802 );
803 assert_eq!(
804 DayCount::Act365.accrue_span(dec!(365), TimeDelta::hours(36)),
805 dec!(1.5)
806 );
807 }
808
809 #[test]
810 fn a_calendar_cutoff_falls_every_day_and_charges_one_day() {
811 let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
812
813 let friday = accrual.next_boundary(utc("2026-10-09T12:00:00Z"));
815 assert_eq!(friday, utc("2026-10-09T21:00:00Z"));
816 let saturday = accrual.next_boundary(friday);
817 assert_eq!(saturday, utc("2026-10-10T21:00:00Z"));
818 assert_eq!(accrual.next_boundary(saturday), utc("2026-10-11T21:00:00Z"));
819 assert_eq!(accrual.days(friday), 1);
820 assert_eq!(accrual.accrue(dec!(360), friday), Decimal::ONE);
821 }
822
823 #[test]
824 fn a_settlement_cutoff_skips_non_business_days() {
825 let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(1, &[]), DayCount::Act360);
826
827 let friday = utc("2026-10-09T21:00:00Z");
828 assert_eq!(accrual.next_boundary(friday), utc("2026-10-12T21:00:00Z"));
829 }
830
831 #[test]
834 fn a_t_plus_1_short_held_over_thursday_pays_three_days() {
835 let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(1, &[]), DayCount::Act360);
836
837 assert_eq!(accrual.days(utc("2026-10-07T21:00:00Z")), 1, "Wednesday");
838 assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 3, "Thursday");
839 assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 1, "Friday");
840 }
841
842 #[test]
843 fn a_t_plus_1_short_held_over_thursday_before_a_monday_holiday_pays_four_days() {
844 let accrual = DailyAccrual::new(
845 cutoff(21),
846 Utc,
847 settlement(1, &["2026-10-12"]),
848 DayCount::Act360,
849 );
850
851 assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 4, "Thursday");
852 assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 1, "Friday");
853 }
854
855 #[test]
856 fn with_no_settlement_lag_friday_pays_the_weekend() {
857 let accrual = DailyAccrual::new(cutoff(21), Utc, settlement(0, &[]), DayCount::Act360);
858
859 assert_eq!(accrual.days(utc("2026-10-08T21:00:00Z")), 1, "Thursday");
860 assert_eq!(accrual.days(utc("2026-10-09T21:00:00Z")), 3, "Friday");
861 }
862
863 #[test]
865 fn a_cutoff_in_a_zone_with_daylight_saving_follows_local_time() {
866 let accrual = DailyAccrual::new(
867 cutoff(17),
868 chrono_tz::America::New_York,
869 AccrualDays::Calendar,
870 DayCount::Act360,
871 );
872
873 assert_eq!(
874 accrual.next_boundary(utc("2026-10-09T12:00:00Z")),
875 utc("2026-10-09T21:00:00Z")
876 );
877 assert_eq!(
878 accrual.next_boundary(utc("2026-12-09T12:00:00Z")),
879 utc("2026-12-09T22:00:00Z")
880 );
881 }
882
883 #[test]
885 fn a_cutoff_in_a_daylight_saving_gap_falls_after_the_gap() {
886 let accrual = DailyAccrual::new(
887 NaiveTime::from_hms_opt(2, 30, 0).unwrap(),
888 chrono_tz::America::New_York,
889 AccrualDays::Calendar,
890 DayCount::Act360,
891 );
892
893 assert_eq!(
895 accrual.next_boundary(utc("2026-03-08T00:00:00Z")),
896 utc("2026-03-08T07:00:00Z")
897 );
898 }
899
900 #[test]
902 fn a_cutoff_that_falls_twice_is_taken_the_first_time() {
903 let accrual = DailyAccrual::new(
904 NaiveTime::from_hms_opt(1, 30, 0).unwrap(),
905 chrono_tz::America::New_York,
906 AccrualDays::Calendar,
907 DayCount::Act360,
908 );
909
910 assert_eq!(
912 accrual.next_boundary(utc("2026-11-01T00:00:00Z")),
913 utc("2026-11-01T05:30:00Z")
914 );
915 }
916
917 #[test]
918 #[should_panic(expected = "no business day")]
919 fn a_calendar_with_no_business_day_panics_rather_than_searching_forever() {
920 #[derive(Debug)]
921 struct Never;
922 impl SettlementCalendar for Never {
923 fn is_business_day(&self, _: NaiveDate) -> bool {
924 false
925 }
926 }
927
928 let days = AccrualDays::Settlement {
929 calendar: Arc::new(Never),
930 lag: 0,
931 };
932 let accrual = DailyAccrual::new(cutoff(21), Utc, days, DayCount::Act360);
933 let _ = accrual.next_boundary(utc("2026-10-09T12:00:00Z"));
934 }
935
936 #[test]
937 fn funding_falls_on_whole_intervals_since_the_epoch() {
938 let instrument = name();
939 let model = FundingModel::new(TimeDelta::hours(8))
940 .with_rates(instrument.clone(), RateSeries::constant(dec!(0.0001)));
941
942 assert_eq!(
943 model.next_boundary(&instrument, utc("2026-10-09T05:00:00Z")),
944 Some(utc("2026-10-09T08:00:00Z"))
945 );
946 assert_eq!(
947 model.next_boundary(&instrument, utc("2026-10-09T08:00:00Z")),
948 Some(utc("2026-10-09T16:00:00Z")),
949 "a boundary steps strictly forward"
950 );
951 assert_eq!(
952 model.next_boundary(
953 &InstrumentNameExchange::new("MSFT"),
954 utc("2026-10-09T05:00:00Z")
955 ),
956 None,
957 "an instrument with no rates is never charged"
958 );
959 }
960
961 #[test]
962 fn funding_is_paid_by_a_long_and_received_by_a_short_at_a_positive_rate() {
963 let instrument = name();
964 let model = FundingModel::new(TimeDelta::hours(1))
965 .with_rates(instrument.clone(), RateSeries::constant(dec!(0.0001)));
966 let boundary = utc("2026-10-09T08:00:00Z");
967
968 let long = model
969 .charge(&held(&instrument, dec!(10)), boundary)
970 .unwrap();
971 assert_eq!(long.amount, dec!(-0.1), "10 × 100 × 0.0001 paid");
972 assert_eq!(
973 long.kind,
974 CashFlowKind::Funding {
975 rate: Some(dec!(0.0001)),
976 position_quantity: Some(dec!(10)),
977 }
978 );
979
980 let short = model
981 .charge(&held(&instrument, dec!(-10)), boundary)
982 .unwrap();
983 assert_eq!(short.amount, dec!(0.1));
984 }
985
986 #[test]
987 fn a_borrow_fee_charges_a_short_only() {
988 let instrument = name();
989 let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
990 let model = BorrowFeeModel::new(accrual)
991 .with_rates(instrument.clone(), RateSeries::constant(dec!(0.36)));
992 let boundary = utc("2026-10-09T21:00:00Z");
993
994 assert_eq!(model.charge(&held(&instrument, dec!(10)), boundary), None);
995
996 let short = model
997 .charge(&held(&instrument, dec!(-10)), boundary)
998 .unwrap();
999 assert_eq!(short.amount, dec!(-1));
1001 assert_eq!(
1002 short.kind,
1003 CashFlowKind::BorrowFee {
1004 rate: Some(dec!(0.36)),
1005 quantity: Some(dec!(10)),
1006 }
1007 );
1008 }
1009
1010 #[test]
1011 fn a_borrow_fee_before_its_rates_begin_is_not_charged() {
1012 let instrument = name();
1013 let accrual = DailyAccrual::new(cutoff(21), Utc, AccrualDays::Calendar, DayCount::Act360);
1014 let rates = RateSeries::new(vec![(utc("2026-10-10T00:00:00Z"), dec!(0.36))]).unwrap();
1015 let model = BorrowFeeModel::new(accrual).with_rates(instrument.clone(), rates);
1016
1017 let before = utc("2026-10-09T21:00:00Z");
1018 assert_eq!(model.charge(&held(&instrument, dec!(-10)), before), None);
1019 }
1020
1021 #[test]
1022 fn financing_charges_each_side_at_its_own_rate_and_pays_a_negative_one() {
1023 let instrument = name();
1024 let accrual = DailyAccrual::new(cutoff(22), Utc, AccrualDays::Calendar, DayCount::Act365);
1025 let rates = FinancingRates::new(
1026 RateSeries::constant(dec!(0.073)),
1027 RateSeries::constant(dec!(-0.0365)),
1028 );
1029 let model = FinancingModel::new(accrual).with_rates(instrument.clone(), rates);
1030 let boundary = utc("2026-10-09T22:00:00Z");
1031
1032 let long = model
1033 .charge(&held(&instrument, dec!(10)), boundary)
1034 .unwrap();
1035 assert_eq!(long.amount, dec!(-0.2));
1037 assert_eq!(
1038 long.kind,
1039 CashFlowKind::Financing {
1040 rate: Some(dec!(0.073)),
1041 notional: Some(dec!(1000)),
1042 }
1043 );
1044
1045 let short = model
1046 .charge(&held(&instrument, dec!(-10)), boundary)
1047 .unwrap();
1048 assert_eq!(short.amount, dec!(0.1), "a negative short rate is received");
1049 }
1050}