Skip to main content

rustrade_execution/
holding_cost.rs

1//! What a simulated position costs to hold over time: perpetual funding, borrow fees and
2//! overnight financing.
3//!
4//! A [`FeeModel`](crate::fee::FeeModel) prices a fill, at the instant it happens. A
5//! [`HoldingCostModel`] prices holding the position afterwards, at boundaries in time: a funding
6//! time, a daily cutoff. The simulated venue asks each configured model for its next boundary,
7//! charges the position it holds across that boundary, and reports each charge as the same
8//! [`CashFlow`](crate::cash_flow::CashFlow) a live venue sends, so a backtest and a live run reach
9//! the engine's `Position::carry` by one path.
10//!
11//! The built-in models cover the three costs a backtest of a short or leveraged strategy most
12//! often leaves out:
13//! - [`FundingModel`]: a perpetual's funding, every fixed interval, from a replayed rate.
14//! - [`BorrowFeeModel`]: the fee for borrowing what a short sold, daily, from an annualised rate.
15//! - [`FinancingModel`]: a CFD's overnight financing, daily, at separate long and short rates.
16//!
17//! Every rate is a [`RateSeries`], a step function of time, so a constant rate and a replayed
18//! history are one type. A daily charge is timed and sized by a [`DailyAccrual`]: the cutoff, its
19//! time zone, which days have one ([`AccrualDays`]) and the year basis ([`DayCount`]).
20//!
21//! # Where the models run
22//! Configured on a venue through `SimVenueOptions` in `rustrade`, which drives the venue to each
23//! boundary as it falls. A [`SimulatedVenue`](crate::exchange::mock::SimulatedVenue) built
24//! directly takes them through
25//! [`with_holding_costs`](crate::exchange::mock::SimulatedVenue::with_holding_costs), and charges
26//! a boundary when its driver next moves its clock past it. `MockExchange` charges none.
27
28use 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
36/// Charges a simulated position for holding it across boundaries in time.
37///
38/// The venue calls [`next_boundary`](Self::next_boundary) to learn when the model next charges an
39/// instrument, and [`charge`](Self::charge) once for each boundary its clock reaches while the
40/// account holds a position there. Each charge becomes one
41/// [`CashFlow`](crate::cash_flow::CashFlow), debited from or credited to the instrument's quote
42/// asset.
43///
44/// # Which position is charged
45/// The position held **across** the boundary. A position closed at the boundary instant is still
46/// charged for it, and one opened at that instant is not: at any instant the venue charges what
47/// is due before it fills anything. A position opened and closed between two boundaries is never
48/// charged, as a venue charges only what is held at its cutoff.
49///
50/// # Contract
51/// - `next_boundary` must return an instant strictly after `after`, or `None` when the model never
52///   charges the instrument. A boundary at or before `after` would charge one instant forever, so
53///   the venue **panics** on one.
54/// - Both methods must be pure functions of their arguments: the venue may ask for the same
55///   boundary more than once, and charges it once.
56///
57/// `Send + Sync` because a venue holds its models behind an `Arc`, so that one model, and the
58/// rate history it carries, can be shared by every run of a backtest sweep without being copied.
59pub trait HoldingCostModel: Debug + Send + Sync {
60    /// The first boundary strictly after `after` at which this model charges `instrument`, or
61    /// `None` if it never does.
62    fn next_boundary(
63        &self,
64        instrument: &InstrumentNameExchange,
65        after: DateTime<Utc>,
66    ) -> Option<DateTime<Utc>>;
67
68    /// What `position`, held across `boundary`, is charged there, or `None` for nothing.
69    fn charge(&self, position: &HeldPosition<'_>, boundary: DateTime<Utc>)
70    -> Option<HoldingCharge>;
71}
72
73/// A position as a [`HoldingCostModel`] sees it at one boundary.
74#[non_exhaustive]
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct HeldPosition<'a> {
77    /// The instrument held.
78    pub instrument: &'a InstrumentNameExchange,
79    /// Signed quantity in contracts: positive long, negative short. Never zero.
80    pub quantity: Decimal,
81    /// The multiplier from contracts to units of the underlying.
82    pub contract_size: Decimal,
83    /// The average price the position was entered at.
84    pub entry_price: Decimal,
85    /// The price the venue values the position at: the mid of its latest market, else the last
86    /// price, else, on a venue with no market for the instrument, the entry price.
87    pub price: Decimal,
88}
89
90impl<'a> HeldPosition<'a> {
91    /// A position of signed `quantity` contracts in `instrument`, each of `contract_size` units of
92    /// the underlying, entered at `entry_price` and valued at `price`.
93    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    /// The position's value at [`price`](Self::price), unsigned:
110    /// `|quantity| × contract_size × price`.
111    pub fn notional(&self) -> Decimal {
112        self.quantity.abs() * self.contract_size * self.price
113    }
114
115    /// Whether the position is short.
116    pub fn is_short(&self) -> bool {
117        self.quantity.is_sign_negative() && !self.quantity.is_zero()
118    }
119}
120
121/// One charge a [`HoldingCostModel`] makes at one boundary.
122#[non_exhaustive]
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct HoldingCharge {
125    /// What the charge is for, with the model's figures.
126    pub kind: CashFlowKind,
127    /// The amount in the instrument's quote asset, signed as a
128    /// [`CashFlow`](crate::cash_flow::CashFlow) is: positive when the account receives it, negative
129    /// when it pays. A zero amount is not charged.
130    pub amount: Decimal,
131}
132
133impl HoldingCharge {
134    /// A charge for `kind` of `amount`, signed positive when the account receives it.
135    pub fn new(kind: CashFlowKind, amount: Decimal) -> Self {
136        Self { kind, amount }
137    }
138}
139
140/// A rate as a step function of time: each point's rate applies from its instant until the next
141/// point's.
142///
143/// One type for a constant rate ([`constant`](Self::constant)) and a replayed history
144/// ([`new`](Self::new)). Before its first point a series has no rate, and a model charges nothing
145/// there rather than inventing one.
146///
147/// Serialised as its list of points, and deserialised through [`new`](Self::new), so a history
148/// loaded from a file is refused out of order exactly as one built in code is.
149#[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    /// A series from `points`, each a rate and the instant it takes effect.
174    ///
175    /// # Errors
176    /// [`RateSeriesUnsorted`] if the instants are not strictly ascending. A replayed history out
177    /// of order would apply each rate over the wrong interval, so it is refused rather than
178    /// sorted: sorting would hide a fault in the data that produced it.
179    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    /// A series with one rate at every instant.
192    pub fn constant(rate: Decimal) -> Self {
193        Self {
194            points: vec![(DateTime::<Utc>::MIN_UTC, rate)],
195        }
196    }
197
198    /// The rate in effect at `time`: that of the latest point at or before it, or `None` before
199    /// the first point.
200    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/// A [`RateSeries`] was given instants that are not strictly ascending.
207#[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    /// The index of the first point that does not follow its predecessor.
213    pub index: usize,
214    /// Its predecessor's instant.
215    pub previous: DateTime<Utc>,
216    /// Its own instant.
217    pub time: DateTime<Utc>,
218}
219
220/// The year basis an annualised rate is divided by: the number of days a full year's rate is
221/// charged over.
222#[derive(
223    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
224)]
225pub enum DayCount {
226    /// Actual days over 360: US equity borrow and most money-market rates.
227    #[default]
228    Act360,
229    /// Actual days over 365: sterling and many CFD financing rates.
230    Act365,
231}
232
233impl DayCount {
234    /// What `days` days of `annual`, a whole year's amount, come to on this basis:
235    /// `annual × days / basis`.
236    ///
237    /// Divided last, so an amount the basis divides exactly comes out exact rather than carrying
238    /// the rounding of a non-terminating year fraction such as `1 / 360`.
239    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
248/// Which dates a market settles on.
249///
250/// `Send + Sync` because an [`AccrualDays`] shares one across the models it is cloned into.
251pub trait SettlementCalendar: Debug + Send + Sync {
252    /// Whether the market settles on `date`.
253    fn is_business_day(&self, date: NaiveDate) -> bool;
254}
255
256/// A calendar whose business days are Monday to Friday, with no holidays.
257#[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/// A calendar whose business days are Monday to Friday, less the holidays it is given.
269///
270/// The holidays are the caller's: this library ships no market's calendar, since a bundled one
271/// goes stale with every year a venue publishes.
272#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
273pub struct HolidayCalendar {
274    holidays: BTreeSet<NaiveDate>,
275}
276
277impl HolidayCalendar {
278    /// A calendar closed on weekends and on each of `holidays`.
279    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/// Which dates a daily charge falls on, and how many days each one covers.
293#[derive(Debug, Clone)]
294pub enum AccrualDays {
295    /// A cutoff on every calendar date, each charging one day. A weekend is charged on its own
296    /// days.
297    Calendar,
298    /// A cutoff on each business day only, each charging the calendar days until the next one
299    /// settles.
300    ///
301    /// A position held across the cutoff on date `d` is charged
302    /// `settle(next(d)) − settle(d)` days, where `next(d)` is the next business day and
303    /// `settle(x)` is the date `lag` business days after `x`. With `lag: 1` (US equities, T+1) a
304    /// short held over Thursday's cutoff settles Friday, and the next day's trade settles
305    /// Monday, so it pays 3 days; 4 across a Monday holiday. With `lag: 0` the 3 days fall on
306    /// Friday instead.
307    Settlement {
308        /// The market's business days.
309        calendar: Arc<dyn SettlementCalendar>,
310        /// The settlement lag in business days.
311        lag: u32,
312    },
313}
314
315/// How far ahead a [`DailyAccrual`] looks for a cutoff date before deciding its calendar has none.
316const CUTOFF_SEARCH_DAYS: u32 = 3660;
317
318/// When a daily charge falls and what fraction of a year it covers.
319///
320/// A cutoff is a local time of day in `zone` (`Utc`, a `chrono::FixedOffset`, or a
321/// `chrono_tz::Tz` such as `America/New_York`, which moves with daylight saving). A cutoff
322/// that falls in a daylight-saving gap is taken at the first local time after the gap, and one
323/// that falls twice is taken the first time.
324#[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    /// Cutoffs at local time `cutoff` in `zone`, on the dates `days` names, each charging its days
334    /// on the `day_count` basis.
335    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    /// The first cutoff strictly after `after`.
345    ///
346    /// # Panics
347    /// If an [`AccrualDays::Settlement`] calendar has no business day within ten years of
348    /// `after`: a calendar that never settles is a mis-specified fixture.
349    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    /// What the charge at `boundary` comes to when a whole year's is `annual`: its
369    /// [`days`](Self::days) on the [`DayCount`] basis.
370    pub fn accrue(&self, annual: Decimal, boundary: DateTime<Utc>) -> Decimal {
371        self.day_count.accrue(annual, self.days(boundary))
372    }
373
374    /// How many days the charge at `boundary`, one of this accrual's cutoffs, covers.
375    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                // Ascending by construction, and bounded by the search limit, so this fits.
387                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    /// The instant of the cutoff on local `date`.
400    fn cutoff_on(&self, date: NaiveDate) -> DateTime<Utc> {
401        let local = date.and_time(self.cutoff);
402
403        // A daylight-saving gap is at most a few hours wide; step past it a minute at a time.
404        (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
420/// The first business day strictly after `date`.
421fn 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
432/// The date `days` business days after `date`, or `date` itself for zero.
433fn 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/// A perpetual's funding: at every fixed interval, the position pays
438/// `quantity × contract_size × price × rate`, signed so that a long pays a positive rate and a
439/// short receives it.
440///
441/// Boundaries fall at whole multiples of `interval` since the Unix epoch, which puts an hourly
442/// interval on the hour and an 8-hour one at 00:00, 08:00 and 16:00 UTC, as Hyperliquid and
443/// Binance settle. The rate is per interval, as venues publish it, read from the instrument's
444/// [`RateSeries`] at the boundary; an instrument with no series is not charged.
445///
446/// Charged as [`CashFlowKind::Funding`], with the rate and the signed position in contracts.
447#[derive(Debug, Clone)]
448pub struct FundingModel {
449    interval: TimeDelta,
450    rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
451}
452
453impl FundingModel {
454    /// A model charging funding every `interval`, with no instrument's rates yet.
455    ///
456    /// # Panics
457    /// If `interval` is not positive.
458    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    /// Charges `instrument` at the funding rates in `rates`.
470    #[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        // Whole intervals since the epoch, so `after` on a boundary steps to the next one.
488        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/// The fee for borrowing what a short sold: at each daily cutoff, a short pays
515/// `notional × rate × days / basis`. A long is not charged.
516///
517/// The rate is annualised, as brokers quote borrow, read from the instrument's [`RateSeries`] at
518/// the cutoff; an instrument with no series is not charged. The cutoff, its days and the basis
519/// come from the [`DailyAccrual`].
520///
521/// Charged as [`CashFlowKind::BorrowFee`], with the rate and the quantity borrowed in units of the
522/// underlying (`|quantity| × contract_size`).
523///
524/// Its boundaries do not depend on the position's side, so a long in an instrument it has rates
525/// for is still woken for at each cutoff, and charged nothing there.
526#[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    /// A model charging at `accrual`'s cutoffs, with no instrument's rates yet.
534    pub fn new(accrual: DailyAccrual<Tz>) -> Self {
535        Self {
536            accrual,
537            rates: FnvHashMap::default(),
538        }
539    }
540
541    /// Charges a short in `instrument` at the annualised borrow rates in `rates`.
542    #[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/// The annualised rates a [`FinancingModel`] charges one instrument: one for a long, one for a
586/// short.
587///
588/// Each is what the position **pays**; a negative rate is received. A CFD provider typically
589/// charges a long a benchmark rate plus its markup, and a short the benchmark less its markup,
590/// which a short receives while it is positive.
591#[non_exhaustive]
592#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
593pub struct FinancingRates {
594    /// The annualised rate a long pays.
595    pub long: RateSeries,
596    /// The annualised rate a short pays; negative when it receives.
597    pub short: RateSeries,
598}
599
600impl FinancingRates {
601    /// The rates a long and a short pay.
602    pub fn new(long: RateSeries, short: RateSeries) -> Self {
603        Self { long, short }
604    }
605}
606
607/// A CFD's overnight financing: at each daily cutoff, the position pays
608/// `notional × rate × days / basis` at the rate for its side, and receives it when that rate is
609/// negative.
610///
611/// Rates come from the instrument's [`FinancingRates`] at the cutoff; an instrument with none is
612/// not charged. The cutoff, its days and the basis come from the [`DailyAccrual`].
613///
614/// Charged as [`CashFlowKind::Financing`], with the rate for the position's side and its notional.
615#[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    /// A model charging at `accrual`'s cutoffs, with no instrument's rates yet.
623    pub fn new(accrual: DailyAccrual<Tz>) -> Self {
624        Self {
625            accrual,
626            rates: FnvHashMap::default(),
627        }
628    }
629
630    /// Charges a position in `instrument` at `rates`.
631    #[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)] // Test code: panics on bad input are acceptable
678mod 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        // Friday 2026-10-09, then Saturday and Sunday each get their own cutoff.
771        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    /// The T+1 case: a short held over Thursday's cutoff settles Friday, and the next trade date
789    /// settles Monday, so Thursday's charge covers three days and Friday's one.
790    #[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    /// 17:00 in New York is 21:00 UTC in summer and 22:00 UTC in winter; the cutoff follows it.
821    #[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    /// 02:30 does not exist in New York on 2026-03-08; the cutoff falls when the clock resumes.
841    #[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        // 03:00 EDT, the first local time after the gap.
851        assert_eq!(
852            accrual.next_boundary(utc("2026-03-08T00:00:00Z")),
853            utc("2026-03-08T07:00:00Z")
854        );
855    }
856
857    /// 01:30 happens twice in New York on 2026-11-01; the cutoff is the first, in daylight time.
858    #[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        // 01:30 EDT, not 01:30 EST an hour later.
868        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        // 1000 notional × 36% / 360 for one day.
957        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        // 1000 × 7.3% / 365.
993        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}