Skip to main content

wickra_core/
derivatives.rs

1//! Derivatives value type: the perpetual / futures tick.
2//!
3//! [`DerivativesTick`] is the non-OHLCV input consumed by the derivatives /
4//! perpetual-futures indicator family. A single tick bundles the funding,
5//! price, open-interest, positioning, taker-flow and liquidation fields a
6//! perp/futures venue publishes per update; each indicator reads only the
7//! subset it needs (the same one-rich-type-per-family pattern as [`Trade`] /
8//! [`OrderBook`] in [`crate::microstructure`]).
9//!
10//! [`Trade`]: crate::microstructure::Trade
11//! [`OrderBook`]: crate::microstructure::OrderBook
12
13use crate::error::{Error, Result};
14
15/// A single derivatives / perpetual-futures market tick.
16///
17/// Field invariants enforced by [`new`](DerivativesTick::new):
18///
19/// - `funding_rate` is finite and **may be negative** (a negative funding rate
20///   means shorts pay longs).
21/// - `mark_price`, `index_price` and `futures_price` are finite and strictly
22///   positive.
23/// - `open_interest`, `long_size`, `short_size`, `taker_buy_volume`,
24///   `taker_sell_volume`, `long_liquidation` and `short_liquidation` are finite
25///   and non-negative.
26///
27/// `timestamp` is a caller-defined epoch / resolution and is not validated.
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct DerivativesTick {
30    /// Current funding rate for the interval (finite; may be negative).
31    pub funding_rate: f64,
32    /// Perpetual mark price (finite, strictly positive).
33    pub mark_price: f64,
34    /// Spot / index price the perpetual tracks (finite, strictly positive).
35    pub index_price: f64,
36    /// Dated (e.g. quarterly) futures mark price (finite, strictly positive).
37    pub futures_price: f64,
38    /// Open interest — outstanding contracts / notional (finite, non-negative).
39    pub open_interest: f64,
40    /// Aggregate long size / long account count (finite, non-negative).
41    pub long_size: f64,
42    /// Aggregate short size / short account count (finite, non-negative).
43    pub short_size: f64,
44    /// Taker buy (ask-lifting) volume (finite, non-negative).
45    pub taker_buy_volume: f64,
46    /// Taker sell (bid-hitting) volume (finite, non-negative).
47    pub taker_sell_volume: f64,
48    /// Long-side liquidation notional (finite, non-negative).
49    pub long_liquidation: f64,
50    /// Short-side liquidation notional (finite, non-negative).
51    pub short_liquidation: f64,
52    /// Tick timestamp (caller-defined epoch / resolution).
53    pub timestamp: i64,
54}
55
56impl DerivativesTick {
57    /// Construct a derivatives tick, validating every field invariant.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`Error::InvalidDerivatives`] if `funding_rate` is not finite;
62    /// any of `mark_price`, `index_price`, `futures_price` is not a finite
63    /// positive number; or any of the six size / volume / liquidation fields is
64    /// not a finite non-negative number.
65    #[allow(clippy::too_many_arguments)]
66    pub fn new(
67        funding_rate: f64,
68        mark_price: f64,
69        index_price: f64,
70        futures_price: f64,
71        open_interest: f64,
72        long_size: f64,
73        short_size: f64,
74        taker_buy_volume: f64,
75        taker_sell_volume: f64,
76        long_liquidation: f64,
77        short_liquidation: f64,
78        timestamp: i64,
79    ) -> Result<Self> {
80        if !funding_rate.is_finite() {
81            return Err(Error::InvalidDerivatives {
82                message: "funding_rate must be finite",
83            });
84        }
85        for price in [mark_price, index_price, futures_price] {
86            if !price.is_finite() || price <= 0.0 {
87                return Err(Error::InvalidDerivatives {
88                    message:
89                        "mark_price, index_price and futures_price must be finite and positive",
90                });
91            }
92        }
93        for amount in [
94            open_interest,
95            long_size,
96            short_size,
97            taker_buy_volume,
98            taker_sell_volume,
99            long_liquidation,
100            short_liquidation,
101        ] {
102            if !amount.is_finite() || amount < 0.0 {
103                return Err(Error::InvalidDerivatives {
104                    message: "open interest, sizes, volumes and liquidations must be finite and non-negative",
105                });
106            }
107        }
108        Ok(Self {
109            funding_rate,
110            mark_price,
111            index_price,
112            futures_price,
113            open_interest,
114            long_size,
115            short_size,
116            taker_buy_volume,
117            taker_sell_volume,
118            long_liquidation,
119            short_liquidation,
120            timestamp,
121        })
122    }
123
124    /// Construct a derivatives tick without validation. The caller asserts that
125    /// every field invariant documented on [`DerivativesTick`] holds.
126    #[allow(clippy::too_many_arguments)]
127    #[must_use]
128    pub const fn new_unchecked(
129        funding_rate: f64,
130        mark_price: f64,
131        index_price: f64,
132        futures_price: f64,
133        open_interest: f64,
134        long_size: f64,
135        short_size: f64,
136        taker_buy_volume: f64,
137        taker_sell_volume: f64,
138        long_liquidation: f64,
139        short_liquidation: f64,
140        timestamp: i64,
141    ) -> Self {
142        Self {
143            funding_rate,
144            mark_price,
145            index_price,
146            futures_price,
147            open_interest,
148            long_size,
149            short_size,
150            taker_buy_volume,
151            taker_sell_volume,
152            long_liquidation,
153            short_liquidation,
154            timestamp,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// A fully valid tick used as a baseline; individual tests override one
164    /// field to exercise a single reject branch.
165    fn valid() -> DerivativesTick {
166        DerivativesTick::new(
167            0.0001, 100.0, 99.5, 100.5, 1_000.0, 600.0, 400.0, 50.0, 40.0, 5.0, 3.0, 42,
168        )
169        .unwrap()
170    }
171
172    #[test]
173    fn new_accepts_valid() {
174        let tick = valid();
175        assert_eq!(tick.funding_rate, 0.0001);
176        assert_eq!(tick.mark_price, 100.0);
177        assert_eq!(tick.index_price, 99.5);
178        assert_eq!(tick.futures_price, 100.5);
179        assert_eq!(tick.open_interest, 1_000.0);
180        assert_eq!(tick.long_size, 600.0);
181        assert_eq!(tick.short_size, 400.0);
182        assert_eq!(tick.taker_buy_volume, 50.0);
183        assert_eq!(tick.taker_sell_volume, 40.0);
184        assert_eq!(tick.long_liquidation, 5.0);
185        assert_eq!(tick.short_liquidation, 3.0);
186        assert_eq!(tick.timestamp, 42);
187    }
188
189    #[test]
190    fn new_accepts_negative_funding_and_zero_amounts() {
191        let tick = DerivativesTick::new(
192            -0.0005, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
193        )
194        .unwrap();
195        assert_eq!(tick.funding_rate, -0.0005);
196        assert_eq!(tick.open_interest, 0.0);
197    }
198
199    #[test]
200    fn new_rejects_non_finite_funding() {
201        assert!(matches!(
202            DerivativesTick::new(
203                f64::NAN,
204                100.0,
205                100.0,
206                100.0,
207                0.0,
208                0.0,
209                0.0,
210                0.0,
211                0.0,
212                0.0,
213                0.0,
214                0
215            ),
216            Err(Error::InvalidDerivatives { .. })
217        ));
218        assert!(matches!(
219            DerivativesTick::new(
220                f64::INFINITY,
221                100.0,
222                100.0,
223                100.0,
224                0.0,
225                0.0,
226                0.0,
227                0.0,
228                0.0,
229                0.0,
230                0.0,
231                0
232            ),
233            Err(Error::InvalidDerivatives { .. })
234        ));
235    }
236
237    #[test]
238    fn new_rejects_non_positive_mark() {
239        assert!(matches!(
240            DerivativesTick::new(0.0, 0.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
241            Err(Error::InvalidDerivatives { .. })
242        ));
243    }
244
245    #[test]
246    fn new_rejects_non_positive_index() {
247        assert!(matches!(
248            DerivativesTick::new(0.0, 100.0, -1.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
249            Err(Error::InvalidDerivatives { .. })
250        ));
251    }
252
253    #[test]
254    fn new_rejects_non_finite_futures() {
255        assert!(matches!(
256            DerivativesTick::new(
257                0.0,
258                100.0,
259                100.0,
260                f64::NAN,
261                0.0,
262                0.0,
263                0.0,
264                0.0,
265                0.0,
266                0.0,
267                0.0,
268                0
269            ),
270            Err(Error::InvalidDerivatives { .. })
271        ));
272    }
273
274    #[test]
275    fn new_rejects_negative_open_interest() {
276        assert!(matches!(
277            DerivativesTick::new(0.0, 100.0, 100.0, 100.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0),
278            Err(Error::InvalidDerivatives { .. })
279        ));
280    }
281
282    #[test]
283    fn new_rejects_non_finite_size() {
284        assert!(matches!(
285            DerivativesTick::new(
286                0.0,
287                100.0,
288                100.0,
289                100.0,
290                0.0,
291                f64::INFINITY,
292                0.0,
293                0.0,
294                0.0,
295                0.0,
296                0.0,
297                0
298            ),
299            Err(Error::InvalidDerivatives { .. })
300        ));
301    }
302
303    #[test]
304    fn new_rejects_negative_liquidation() {
305        assert!(matches!(
306            DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, -2.0, 0),
307            Err(Error::InvalidDerivatives { .. })
308        ));
309    }
310
311    #[test]
312    fn new_unchecked_preserves_fields() {
313        let tick = DerivativesTick::new_unchecked(
314            -1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, 7,
315        );
316        assert_eq!(tick.funding_rate, -1.0);
317        assert_eq!(tick.mark_price, -2.0);
318        assert_eq!(tick.short_liquidation, -11.0);
319        assert_eq!(tick.timestamp, 7);
320    }
321}