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///
29/// # Construction and the limits of its guarantee
30///
31/// The struct is `#[non_exhaustive]`, so code outside this crate cannot build
32/// one from a field literal and must go through [`new`](Self::new), which
33/// validates, or [`new_unchecked`](Self::new_unchecked), which is an explicit
34/// opt-out for values already known to be sound.
35///
36/// The fields stay public because reading them is by far the common operation
37/// and an accessor on each would buy nothing. That does mean a validated value
38/// can still be *written* into an invalid state afterwards, and nothing detects
39/// it: the indicators that consume this type rely on the constructor's
40/// guarantee rather than re-checking every bar. Treat a mutation the way you
41/// would treat `new_unchecked` — you are asserting the invariants still hold.
42#[derive(Debug, Clone, Copy, PartialEq)]
43#[non_exhaustive]
44pub struct DerivativesTick {
45    /// Current funding rate for the interval (finite; may be negative).
46    pub funding_rate: f64,
47    /// Perpetual mark price (finite, strictly positive).
48    pub mark_price: f64,
49    /// Spot / index price the perpetual tracks (finite, strictly positive).
50    pub index_price: f64,
51    /// Dated (e.g. quarterly) futures mark price (finite, strictly positive).
52    pub futures_price: f64,
53    /// Open interest — outstanding contracts / notional (finite, non-negative).
54    pub open_interest: f64,
55    /// Aggregate long size / long account count (finite, non-negative).
56    pub long_size: f64,
57    /// Aggregate short size / short account count (finite, non-negative).
58    pub short_size: f64,
59    /// Taker buy (ask-lifting) volume (finite, non-negative).
60    pub taker_buy_volume: f64,
61    /// Taker sell (bid-hitting) volume (finite, non-negative).
62    pub taker_sell_volume: f64,
63    /// Long-side liquidation notional (finite, non-negative).
64    pub long_liquidation: f64,
65    /// Short-side liquidation notional (finite, non-negative).
66    pub short_liquidation: f64,
67    /// Tick timestamp (caller-defined epoch / resolution).
68    pub timestamp: i64,
69}
70
71impl DerivativesTick {
72    /// Construct a derivatives tick, validating every field invariant.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`Error::InvalidDerivatives`] if `funding_rate` is not finite;
77    /// any of `mark_price`, `index_price`, `futures_price` is not a finite
78    /// positive number; or any of the six size / volume / liquidation fields is
79    /// not a finite non-negative number.
80    #[allow(clippy::too_many_arguments)]
81    pub fn new(
82        funding_rate: f64,
83        mark_price: f64,
84        index_price: f64,
85        futures_price: f64,
86        open_interest: f64,
87        long_size: f64,
88        short_size: f64,
89        taker_buy_volume: f64,
90        taker_sell_volume: f64,
91        long_liquidation: f64,
92        short_liquidation: f64,
93        timestamp: i64,
94    ) -> Result<Self> {
95        if !funding_rate.is_finite() {
96            return Err(Error::InvalidDerivatives {
97                message: "funding_rate must be finite",
98            });
99        }
100        for price in [mark_price, index_price, futures_price] {
101            if !price.is_finite() || price <= 0.0 {
102                return Err(Error::InvalidDerivatives {
103                    message:
104                        "mark_price, index_price and futures_price must be finite and positive",
105                });
106            }
107        }
108        for amount in [
109            open_interest,
110            long_size,
111            short_size,
112            taker_buy_volume,
113            taker_sell_volume,
114            long_liquidation,
115            short_liquidation,
116        ] {
117            if !amount.is_finite() || amount < 0.0 {
118                return Err(Error::InvalidDerivatives {
119                    message: "open interest, sizes, volumes and liquidations must be finite and non-negative",
120                });
121            }
122        }
123        Ok(Self {
124            funding_rate,
125            mark_price,
126            index_price,
127            futures_price,
128            open_interest,
129            long_size,
130            short_size,
131            taker_buy_volume,
132            taker_sell_volume,
133            long_liquidation,
134            short_liquidation,
135            timestamp,
136        })
137    }
138
139    /// Construct a derivatives tick without validation. The caller asserts that
140    /// every field invariant documented on [`DerivativesTick`] holds.
141    #[allow(clippy::too_many_arguments)]
142    #[must_use]
143    pub const fn new_unchecked(
144        funding_rate: f64,
145        mark_price: f64,
146        index_price: f64,
147        futures_price: f64,
148        open_interest: f64,
149        long_size: f64,
150        short_size: f64,
151        taker_buy_volume: f64,
152        taker_sell_volume: f64,
153        long_liquidation: f64,
154        short_liquidation: f64,
155        timestamp: i64,
156    ) -> Self {
157        Self {
158            funding_rate,
159            mark_price,
160            index_price,
161            futures_price,
162            open_interest,
163            long_size,
164            short_size,
165            taker_buy_volume,
166            taker_sell_volume,
167            long_liquidation,
168            short_liquidation,
169            timestamp,
170        }
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// A fully valid tick used as a baseline; individual tests override one
179    /// field to exercise a single reject branch.
180    fn valid() -> DerivativesTick {
181        DerivativesTick::new(
182            0.0001, 100.0, 99.5, 100.5, 1_000.0, 600.0, 400.0, 50.0, 40.0, 5.0, 3.0, 42,
183        )
184        .unwrap()
185    }
186
187    #[test]
188    fn new_accepts_valid() {
189        let tick = valid();
190        assert_eq!(tick.funding_rate, 0.0001);
191        assert_eq!(tick.mark_price, 100.0);
192        assert_eq!(tick.index_price, 99.5);
193        assert_eq!(tick.futures_price, 100.5);
194        assert_eq!(tick.open_interest, 1_000.0);
195        assert_eq!(tick.long_size, 600.0);
196        assert_eq!(tick.short_size, 400.0);
197        assert_eq!(tick.taker_buy_volume, 50.0);
198        assert_eq!(tick.taker_sell_volume, 40.0);
199        assert_eq!(tick.long_liquidation, 5.0);
200        assert_eq!(tick.short_liquidation, 3.0);
201        assert_eq!(tick.timestamp, 42);
202    }
203
204    #[test]
205    fn new_accepts_negative_funding_and_zero_amounts() {
206        let tick = DerivativesTick::new(
207            -0.0005, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
208        )
209        .unwrap();
210        assert_eq!(tick.funding_rate, -0.0005);
211        assert_eq!(tick.open_interest, 0.0);
212    }
213
214    #[test]
215    fn new_rejects_non_finite_funding() {
216        assert!(matches!(
217            DerivativesTick::new(
218                f64::NAN,
219                100.0,
220                100.0,
221                100.0,
222                0.0,
223                0.0,
224                0.0,
225                0.0,
226                0.0,
227                0.0,
228                0.0,
229                0
230            ),
231            Err(Error::InvalidDerivatives { .. })
232        ));
233        assert!(matches!(
234            DerivativesTick::new(
235                f64::INFINITY,
236                100.0,
237                100.0,
238                100.0,
239                0.0,
240                0.0,
241                0.0,
242                0.0,
243                0.0,
244                0.0,
245                0.0,
246                0
247            ),
248            Err(Error::InvalidDerivatives { .. })
249        ));
250    }
251
252    #[test]
253    fn new_rejects_non_positive_mark() {
254        assert!(matches!(
255            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),
256            Err(Error::InvalidDerivatives { .. })
257        ));
258    }
259
260    #[test]
261    fn new_rejects_non_positive_index() {
262        assert!(matches!(
263            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),
264            Err(Error::InvalidDerivatives { .. })
265        ));
266    }
267
268    #[test]
269    fn new_rejects_non_finite_futures() {
270        assert!(matches!(
271            DerivativesTick::new(
272                0.0,
273                100.0,
274                100.0,
275                f64::NAN,
276                0.0,
277                0.0,
278                0.0,
279                0.0,
280                0.0,
281                0.0,
282                0.0,
283                0
284            ),
285            Err(Error::InvalidDerivatives { .. })
286        ));
287    }
288
289    #[test]
290    fn new_rejects_negative_open_interest() {
291        assert!(matches!(
292            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),
293            Err(Error::InvalidDerivatives { .. })
294        ));
295    }
296
297    #[test]
298    fn new_rejects_non_finite_size() {
299        assert!(matches!(
300            DerivativesTick::new(
301                0.0,
302                100.0,
303                100.0,
304                100.0,
305                0.0,
306                f64::INFINITY,
307                0.0,
308                0.0,
309                0.0,
310                0.0,
311                0.0,
312                0
313            ),
314            Err(Error::InvalidDerivatives { .. })
315        ));
316    }
317
318    #[test]
319    fn new_rejects_negative_liquidation() {
320        assert!(matches!(
321            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),
322            Err(Error::InvalidDerivatives { .. })
323        ));
324    }
325
326    #[test]
327    fn new_unchecked_preserves_fields() {
328        let tick = DerivativesTick::new_unchecked(
329            -1.0, -2.0, -3.0, -4.0, -5.0, -6.0, -7.0, -8.0, -9.0, -10.0, -11.0, 7,
330        );
331        assert_eq!(tick.funding_rate, -1.0);
332        assert_eq!(tick.mark_price, -2.0);
333        assert_eq!(tick.short_liquidation, -11.0);
334        assert_eq!(tick.timestamp, 7);
335    }
336}