Skip to main content

rustrade_backtest/
funding.rs

1//! Perpetual-futures funding cashflows for the replay engine.
2//!
3//! Perp exchanges settle funding at fixed timestamps (KuCoin and Binance
4//! USDT perps every 8 hours, on the 00:00 / 08:00 / 16:00 UTC grid). At
5//! each settlement an open position pays or receives `rate × notional`:
6//! a **positive rate means longs pay shorts** (a long's cashflow is
7//! negative, a short collects), a negative rate mirrors. A strategy that
8//! holds across settlements — the platform's funding-capture edge most of
9//! all — has this cashflow as a first-order PnL term, so a backtest that
10//! omits it overstates (or understates) the edge.
11//!
12//! [`FundingModel`] plugs the settlement schedule into
13//! [`BacktestConfig`](crate::BacktestConfig) the same way
14//! [`FeeModel`](crate::FeeModel) / [`SlippageModel`](crate::SlippageModel)
15//! do. It defaults to [`FundingModel::None`] — existing backtests are
16//! bit-for-bit unchanged unless a model is configured.
17//!
18//! # Settlement semantics (parity with the live paper ledger)
19//!
20//! The live paper bot accrues funding over a hold as the settlements in
21//! `(entry, exit]` — strictly after entry, up to and including exit — each
22//! paying the position `−direction × rate × notional`. The replay engine
23//! applies the same window per candle: settlements in
24//! `(previous_candle_time, candle_time]` are booked against the position
25//! *as it stood entering the candle*, at the last-known mark. A position
26//! opened exactly on a settlement timestamp does not pay that settlement;
27//! a position closed exactly on one does.
28
29use serde::{Deserialize, Serialize};
30
31/// Eight hours in milliseconds — the funding interval of KuCoin and
32/// Binance USDT perps (settling on the 00:00 / 08:00 / 16:00 UTC grid,
33/// which is exactly the epoch-aligned 8 h grid).
34pub const EIGHT_HOURS_MS: i64 = 8 * 3_600_000;
35
36/// Pluggable perp funding-rate schedule. Defaults to [`Self::None`].
37///
38/// Rates are fractions of notional per settlement (`0.0001` = 1 bp), the
39/// same convention exchanges publish. Timestamps are epoch milliseconds —
40/// the same unit as [`Candle::time`](rustrade_core::Candle).
41///
42/// # Example
43///
44/// ```
45/// use rustrade_backtest::{FundingModel, EIGHT_HOURS_MS};
46///
47/// // Historical series: (settlement timestamp ms, rate).
48/// let series = FundingModel::Series(vec![
49///     (EIGHT_HOURS_MS, 0.0001),
50///     (2 * EIGHT_HOURS_MS, -0.0002),
51/// ]);
52/// assert_eq!(series.settlements_between(0, 2 * EIGHT_HOURS_MS).len(), 2);
53///
54/// // Constant fallback when no series exists: 1 bp every 8 h.
55/// let constant = FundingModel::Constant { rate: 0.0001, interval_ms: EIGHT_HOURS_MS };
56/// let s = constant.settlements_between(0, 24 * 3_600_000);
57/// assert_eq!(s, vec![
58///     (EIGHT_HOURS_MS, 0.0001),
59///     (2 * EIGHT_HOURS_MS, 0.0001),
60///     (3 * EIGHT_HOURS_MS, 0.0001),
61/// ]);
62/// ```
63#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
64pub enum FundingModel {
65    /// No funding cashflows — spot backtests and every pre-existing
66    /// consumer. The default.
67    #[default]
68    None,
69    /// Constant rate applied at every multiple of `interval_ms` on the
70    /// epoch-aligned grid — the fallback when no historical series
71    /// exists. `interval_ms = EIGHT_HOURS_MS` reproduces the KuCoin /
72    /// Binance 8 h UTC settlement grid.
73    Constant {
74        /// Rate per settlement as a fraction of notional (`0.0001` = 1 bp).
75        /// Positive = longs pay, shorts receive.
76        rate: f64,
77        /// Milliseconds between settlements. Must be `> 0`.
78        interval_ms: i64,
79    },
80    /// Historical settlement series: `(timestamp_ms, rate)` pairs.
81    /// [`BacktestConfigBuilder::build`](crate::BacktestConfigBuilder::build)
82    /// sorts the series by timestamp and rejects non-finite rates and
83    /// duplicate timestamps (a duplicate would double-book a settlement).
84    Series(Vec<(i64, f64)>),
85}
86
87impl FundingModel {
88    /// The settlements in the half-open window `(after, until]`, ascending
89    /// by timestamp. This is the live paper ledger's accrual window: a
90    /// settlement exactly at `after` is excluded, one exactly at `until`
91    /// is included.
92    ///
93    /// For [`Self::Series`] the series is assumed ascending by timestamp
94    /// (the config builder sorts it, and
95    /// [`Backtest::run`](crate::Backtest::run) re-validates the schedule
96    /// so a series assigned directly to `BacktestConfig.funding` cannot
97    /// reach the engine unsorted); results on an unsorted series are
98    /// unspecified.
99    pub fn settlements_between(&self, after: i64, until: i64) -> Vec<(i64, f64)> {
100        if until <= after {
101            return Vec::new();
102        }
103        match self {
104            Self::None => Vec::new(),
105            Self::Constant { rate, interval_ms } => {
106                let interval = *interval_ms;
107                if interval <= 0 {
108                    return Vec::new();
109                }
110                // First grid point strictly greater than `after`.
111                // `div_euclid` keeps the grid epoch-aligned for negative
112                // timestamps too.
113                let mut t = (after.div_euclid(interval) + 1) * interval;
114                let mut out = Vec::new();
115                while t <= until {
116                    out.push((t, *rate));
117                    t += interval;
118                }
119                out
120            }
121            Self::Series(points) => {
122                // The binary search below is only correct on an ascending
123                // series. `Backtest::run` re-validates (and sorts) the
124                // schedule so this holds even for a series assigned
125                // directly to the pub `BacktestConfig.funding`; the
126                // debug-assert catches any future caller that bypasses it.
127                debug_assert!(
128                    points.windows(2).all(|w| w[0].0 <= w[1].0),
129                    "settlements_between requires an ascending series; call FundingModel::validated first"
130                );
131                let start = points.partition_point(|(t, _)| *t <= after);
132                let end = points.partition_point(|(t, _)| *t <= until);
133                points[start..end].to_vec()
134            }
135        }
136    }
137
138    /// Validate (and normalise) the model for use in a config.
139    ///
140    /// Rejects non-finite rates and non-positive intervals; sorts a
141    /// [`Self::Series`] ascending by timestamp and rejects duplicate
142    /// timestamps. Returns the normalised model.
143    pub(crate) fn validated(mut self) -> std::result::Result<Self, String> {
144        match &mut self {
145            Self::None => {}
146            Self::Constant { rate, interval_ms } => {
147                if !rate.is_finite() {
148                    return Err(format!("funding rate must be finite, got {rate}"));
149                }
150                if *interval_ms <= 0 {
151                    return Err(format!(
152                        "funding interval_ms must be > 0, got {interval_ms}"
153                    ));
154                }
155            }
156            Self::Series(points) => {
157                for (t, rate) in points.iter() {
158                    if !rate.is_finite() {
159                        return Err(format!("funding rate at t={t} must be finite, got {rate}"));
160                    }
161                }
162                points.sort_by_key(|(t, _)| *t);
163                if points.windows(2).any(|w| w[0].0 == w[1].0) {
164                    return Err(
165                        "funding series has duplicate settlement timestamps (would double-book)"
166                            .into(),
167                    );
168                }
169            }
170        }
171        Ok(self)
172    }
173
174    /// `true` unless the model is [`Self::None`].
175    pub(crate) fn is_enabled(&self) -> bool {
176        !matches!(self, Self::None)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn none_yields_no_settlements() {
186        assert!(
187            FundingModel::None
188                .settlements_between(0, i64::MAX)
189                .is_empty()
190        );
191    }
192
193    #[test]
194    fn constant_grid_is_entry_exclusive_exit_inclusive() {
195        let m = FundingModel::Constant {
196            rate: 0.0001,
197            interval_ms: 100,
198        };
199        // A settlement exactly at `after` is excluded…
200        assert_eq!(m.settlements_between(100, 250), vec![(200, 0.0001)]);
201        // …and one exactly at `until` is included.
202        assert_eq!(
203            m.settlements_between(50, 200),
204            vec![(100, 0.0001), (200, 0.0001)]
205        );
206        // Empty / inverted windows yield nothing.
207        assert!(m.settlements_between(100, 100).is_empty());
208        assert!(m.settlements_between(200, 100).is_empty());
209    }
210
211    #[test]
212    fn constant_grid_stays_epoch_aligned_across_zero() {
213        let m = FundingModel::Constant {
214            rate: 0.5,
215            interval_ms: 100,
216        };
217        assert_eq!(
218            m.settlements_between(-150, 100),
219            vec![(-100, 0.5), (0, 0.5), (100, 0.5)]
220        );
221    }
222
223    #[test]
224    fn series_window_matches_constant_window_semantics() {
225        let m = FundingModel::Series(vec![(100, 0.1), (200, 0.2), (300, 0.3)]);
226        assert_eq!(
227            m.settlements_between(100, 300),
228            vec![(200, 0.2), (300, 0.3)]
229        );
230        assert!(m.settlements_between(300, 1_000).is_empty());
231    }
232
233    #[test]
234    fn validated_sorts_series_and_rejects_duplicates() {
235        let m = FundingModel::Series(vec![(300, 0.3), (100, 0.1)])
236            .validated()
237            .unwrap();
238        assert_eq!(m, FundingModel::Series(vec![(100, 0.1), (300, 0.3)]));
239
240        assert!(
241            FundingModel::Series(vec![(100, 0.1), (100, 0.2)])
242                .validated()
243                .is_err()
244        );
245    }
246
247    #[test]
248    fn validated_rejects_bad_constant() {
249        assert!(
250            FundingModel::Constant {
251                rate: f64::NAN,
252                interval_ms: 100
253            }
254            .validated()
255            .is_err()
256        );
257        assert!(
258            FundingModel::Constant {
259                rate: 0.0001,
260                interval_ms: 0
261            }
262            .validated()
263            .is_err()
264        );
265    }
266
267    #[test]
268    fn validated_rejects_non_finite_series_rate() {
269        assert!(
270            FundingModel::Series(vec![(100, f64::INFINITY)])
271                .validated()
272                .is_err()
273        );
274    }
275}