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    /// Every rate in the series, in time order.
206    pub(crate) fn rates(&self) -> impl Iterator<Item = Decimal> + '_ {
207        self.points.iter().map(|(_, rate)| *rate)
208    }
209}
210
211/// A [`RateSeries`] was given instants that are not strictly ascending.
212#[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    /// The index of the first point that does not follow its predecessor.
218    pub index: usize,
219    /// Its predecessor's instant.
220    pub previous: DateTime<Utc>,
221    /// Its own instant.
222    pub time: DateTime<Utc>,
223}
224
225/// The year basis an annualised rate is divided by: the number of days a full year's rate is
226/// charged over.
227#[derive(
228    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
229)]
230pub enum DayCount {
231    /// Actual days over 360: US equity borrow and most money-market rates.
232    #[default]
233    Act360,
234    /// Actual days over 365: sterling and many CFD financing rates.
235    Act365,
236}
237
238impl DayCount {
239    /// What `days` days of `annual`, a whole year's amount, come to on this basis:
240    /// `annual × days / basis`.
241    ///
242    /// Divided last, so an amount the basis divides exactly comes out exact rather than carrying
243    /// the rounding of a non-terminating year fraction such as `1 / 360`.
244    pub fn accrue(self, annual: Decimal, days: u32) -> Decimal {
245        annual * Decimal::from(days) / self.basis()
246    }
247
248    /// What `span` of `annual`, a whole year's amount, comes to on this basis:
249    /// `annual × span / (basis × 1 day)`, for a charge over a period shorter than a day, such as
250    /// an hour's margin interest.
251    ///
252    /// Divided last, as [`accrue`](Self::accrue) is. A negative `span` accrues a negative amount.
253    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    /// The days a full year's rate is charged over.
260    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
270/// Which dates a market settles on.
271///
272/// `Send + Sync` because an [`AccrualDays`] shares one across the models it is cloned into.
273pub trait SettlementCalendar: Debug + Send + Sync {
274    /// Whether the market settles on `date`.
275    fn is_business_day(&self, date: NaiveDate) -> bool;
276}
277
278/// A calendar whose business days are Monday to Friday, with no holidays.
279#[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/// A calendar whose business days are Monday to Friday, less the holidays it is given.
291///
292/// The holidays are the caller's: this library ships no market's calendar, since a bundled one
293/// goes stale with every year a venue publishes.
294#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
295pub struct HolidayCalendar {
296    holidays: BTreeSet<NaiveDate>,
297}
298
299impl HolidayCalendar {
300    /// A calendar closed on weekends and on each of `holidays`.
301    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/// Which dates a daily charge falls on, and how many days each one covers.
315#[derive(Debug, Clone)]
316pub enum AccrualDays {
317    /// A cutoff on every calendar date, each charging one day. A weekend is charged on its own
318    /// days.
319    Calendar,
320    /// A cutoff on each business day only, each charging the calendar days until the next one
321    /// settles.
322    ///
323    /// A position held across the cutoff on date `d` is charged
324    /// `settle(next(d)) − settle(d)` days, where `next(d)` is the next business day and
325    /// `settle(x)` is the date `lag` business days after `x`. With `lag: 1` (US equities, T+1) a
326    /// short held over Thursday's cutoff settles Friday, and the next day's trade settles
327    /// Monday, so it pays 3 days; 4 across a Monday holiday. With `lag: 0` the 3 days fall on
328    /// Friday instead.
329    Settlement {
330        /// The market's business days.
331        calendar: Arc<dyn SettlementCalendar>,
332        /// The settlement lag in business days.
333        lag: u32,
334    },
335}
336
337/// How far ahead a [`DailyAccrual`] looks for a cutoff date before deciding its calendar has none.
338const CUTOFF_SEARCH_DAYS: u32 = 3660;
339
340/// When a daily charge falls and what fraction of a year it covers.
341///
342/// A cutoff is a local time of day in `zone` (`Utc`, a `chrono::FixedOffset`, or a
343/// `chrono_tz::Tz` such as `America/New_York`, which moves with daylight saving). A cutoff
344/// that falls in a daylight-saving gap is taken at the first local time after the gap, and one
345/// that falls twice is taken the first time.
346#[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    /// Cutoffs at local time `cutoff` in `zone`, on the dates `days` names, each charging its days
356    /// on the `day_count` basis.
357    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    /// The first cutoff strictly after `after`.
367    ///
368    /// # Panics
369    /// If an [`AccrualDays::Settlement`] calendar has no business day within ten years of
370    /// `after`: a calendar that never settles is a mis-specified fixture.
371    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    /// What the charge at `boundary` comes to when a whole year's is `annual`: its
391    /// [`days`](Self::days) on the [`DayCount`] basis.
392    pub fn accrue(&self, annual: Decimal, boundary: DateTime<Utc>) -> Decimal {
393        self.day_count.accrue(annual, self.days(boundary))
394    }
395
396    /// How many days the charge at `boundary`, one of this accrual's cutoffs, covers.
397    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                // Ascending by construction, and bounded by the search limit, so this fits.
409                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    /// The instant of the cutoff on local `date`.
422    fn cutoff_on(&self, date: NaiveDate) -> DateTime<Utc> {
423        let local = date.and_time(self.cutoff);
424
425        // A daylight-saving gap is at most a few hours wide; step past it a minute at a time.
426        (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
437/// The first whole multiple of `interval` since the Unix epoch strictly after `after`, or `None`
438/// if it is out of range. `after` on a boundary steps to the next one.
439pub(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
455/// The first business day strictly after `date`.
456fn 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
467/// The date `days` business days after `date`, or `date` itself for zero.
468fn 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/// A perpetual's funding: at every fixed interval, the position pays
473/// `quantity × contract_size × price × rate`, signed so that a long pays a positive rate and a
474/// short receives it.
475///
476/// Boundaries fall at whole multiples of `interval` since the Unix epoch, which puts an hourly
477/// interval on the hour and an 8-hour one at 00:00, 08:00 and 16:00 UTC, as Hyperliquid and
478/// Binance settle. The rate is per interval, as venues publish it, read from the instrument's
479/// [`RateSeries`] at the boundary; an instrument with no series is not charged.
480///
481/// Charged as [`CashFlowKind::Funding`], with the rate and the signed position in contracts.
482#[derive(Debug, Clone)]
483pub struct FundingModel {
484    interval: TimeDelta,
485    rates: FnvHashMap<InstrumentNameExchange, RateSeries>,
486}
487
488impl FundingModel {
489    /// A model charging funding every `interval`, with no instrument's rates yet.
490    ///
491    /// # Panics
492    /// If `interval` is not positive.
493    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    /// Charges `instrument` at the funding rates in `rates`.
505    #[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/// The fee for borrowing what a short sold: at each daily cutoff, a short pays
543/// `notional × rate × days / basis`. A long is not charged.
544///
545/// The rate is annualised, as brokers quote borrow, read from the instrument's [`RateSeries`] at
546/// the cutoff; an instrument with no series is not charged. The cutoff, its days and the basis
547/// come from the [`DailyAccrual`].
548///
549/// Charged as [`CashFlowKind::BorrowFee`], with the rate and the quantity borrowed in units of the
550/// underlying (`|quantity| × contract_size`).
551///
552/// Its boundaries do not depend on the position's side, so a long in an instrument it has rates
553/// for is still woken for at each cutoff, and charged nothing there.
554#[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    /// A model charging at `accrual`'s cutoffs, with no instrument's rates yet.
562    pub fn new(accrual: DailyAccrual<Tz>) -> Self {
563        Self {
564            accrual,
565            rates: FnvHashMap::default(),
566        }
567    }
568
569    /// Charges a short in `instrument` at the annualised borrow rates in `rates`.
570    #[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/// The annualised rates a [`FinancingModel`] charges one instrument: one for a long, one for a
614/// short.
615///
616/// Each is what the position **pays**; a negative rate is received. A CFD provider typically
617/// charges a long a benchmark rate plus its markup, and a short the benchmark less its markup,
618/// which a short receives while it is positive.
619#[non_exhaustive]
620#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
621pub struct FinancingRates {
622    /// The annualised rate a long pays.
623    pub long: RateSeries,
624    /// The annualised rate a short pays; negative when it receives.
625    pub short: RateSeries,
626}
627
628impl FinancingRates {
629    /// The rates a long and a short pay.
630    pub fn new(long: RateSeries, short: RateSeries) -> Self {
631        Self { long, short }
632    }
633}
634
635/// A CFD's overnight financing: at each daily cutoff, the position pays
636/// `notional × rate × days / basis` at the rate for its side, and receives it when that rate is
637/// negative.
638///
639/// Rates come from the instrument's [`FinancingRates`] at the cutoff; an instrument with none is
640/// not charged. The cutoff, its days and the basis come from the [`DailyAccrual`].
641///
642/// Charged as [`CashFlowKind::Financing`], with the rate for the position's side and its notional.
643#[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    /// A model charging at `accrual`'s cutoffs, with no instrument's rates yet.
651    pub fn new(accrual: DailyAccrual<Tz>) -> Self {
652        Self {
653            accrual,
654            rates: FnvHashMap::default(),
655        }
656    }
657
658    /// Charges a position in `instrument` at `rates`.
659    #[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)] // Test code: panics on bad input are acceptable
706mod 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        // A year's amount of one per second on Act360, so a span accrues its length in seconds.
797        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        // Friday 2026-10-09, then Saturday and Sunday each get their own cutoff.
814        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    /// The T+1 case: a short held over Thursday's cutoff settles Friday, and the next trade date
832    /// settles Monday, so Thursday's charge covers three days and Friday's one.
833    #[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    /// 17:00 in New York is 21:00 UTC in summer and 22:00 UTC in winter; the cutoff follows it.
864    #[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    /// 02:30 does not exist in New York on 2026-03-08; the cutoff falls when the clock resumes.
884    #[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        // 03:00 EDT, the first local time after the gap.
894        assert_eq!(
895            accrual.next_boundary(utc("2026-03-08T00:00:00Z")),
896            utc("2026-03-08T07:00:00Z")
897        );
898    }
899
900    /// 01:30 happens twice in New York on 2026-11-01; the cutoff is the first, in daylight time.
901    #[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        // 01:30 EDT, not 01:30 EST an hour later.
911        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        // 1000 notional × 36% / 360 for one day.
1000        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        // 1000 × 7.3% / 365.
1036        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}