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}