Skip to main content

wickra_backtest_core/
data.rs

1//! Market-data input types fed to the engine.
2//!
3//! These are serde-friendly, owned value types (loadable from JSONL/CSV/Parquet)
4//! that convert into the `wickra-core` input types when fed to indicators.
5//! Besides OHLCV [`Candle`]s, the microstructure feed types — [`TradePrint`],
6//! [`OrderBook`] and [`DerivativesTick`] — back the trade / order-book /
7//! derivatives indicators.
8
9use serde::{Deserialize, Serialize};
10use wickra_core::{
11    Candle as CoreCandle, CrossSection as CoreCrossSection, DerivativesTick as CoreDerivativesTick,
12    Level as CoreLevel, Member as CoreMember, OrderBook as CoreOrderBook, Side as CoreSide,
13    Trade as CoreTrade,
14};
15
16use crate::error::{BacktestError, Result};
17
18/// One OHLCV bar. `time` is the bar's open time (engine-defined epoch unit; it is
19/// passed straight through to indicators that need a timestamp).
20#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
21pub struct Candle {
22    /// Bar open time (epoch, engine-defined unit — seconds by convention).
23    pub time: i64,
24    /// Open price.
25    pub open: f64,
26    /// High price.
27    pub high: f64,
28    /// Low price.
29    pub low: f64,
30    /// Close price.
31    pub close: f64,
32    /// Bar volume (defaults to `0.0` when absent).
33    #[serde(default)]
34    pub volume: f64,
35}
36
37impl Candle {
38    /// Convert into a `wickra-core` candle for feeding indicators. Fails if the
39    /// OHLC values are not finite or violate `high >= low` etc.
40    pub fn to_core(self) -> Result<CoreCandle> {
41        CoreCandle::new(
42            self.open,
43            self.high,
44            self.low,
45            self.close,
46            self.volume,
47            self.time,
48        )
49        .map_err(|e| BacktestError::InvalidData(e.to_string()))
50    }
51
52    /// Typical price `(high + low + close) / 3`.
53    #[must_use]
54    pub fn hlc3(self) -> f64 {
55        (self.high + self.low + self.close) / 3.0
56    }
57
58    /// Average price `(open + high + low + close) / 4`.
59    #[must_use]
60    pub fn ohlc4(self) -> f64 {
61        (self.open + self.high + self.low + self.close) / 4.0
62    }
63}
64
65/// Aggressor side of a [`TradePrint`].
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum TradeSide {
69    /// A buyer-initiated (aggressive buy) trade.
70    Buy,
71    /// A seller-initiated (aggressive sell) trade.
72    Sell,
73}
74
75impl TradeSide {
76    fn to_core(self) -> CoreSide {
77        match self {
78            TradeSide::Buy => CoreSide::Buy,
79            TradeSide::Sell => CoreSide::Sell,
80        }
81    }
82}
83
84/// A single trade print, fed to trade-flow indicators.
85#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
86pub struct TradePrint {
87    /// Execution price (strictly positive).
88    pub price: f64,
89    /// Executed size / quantity (non-negative).
90    pub size: f64,
91    /// Aggressor side.
92    pub side: TradeSide,
93    /// Trade timestamp (engine-defined epoch unit).
94    #[serde(default)]
95    pub timestamp: i64,
96}
97
98impl TradePrint {
99    /// Convert into a `wickra-core` trade, validating price/size.
100    pub fn to_core(self) -> Result<CoreTrade> {
101        CoreTrade::new(self.price, self.size, self.side.to_core(), self.timestamp)
102            .map_err(|e| BacktestError::InvalidData(e.to_string()))
103    }
104}
105
106/// One order-book price level.
107#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
108pub struct Level {
109    /// Price of the level (strictly positive).
110    pub price: f64,
111    /// Resting size / quantity at this price (non-negative).
112    pub size: f64,
113}
114
115impl Level {
116    fn to_core(self) -> Result<CoreLevel> {
117        CoreLevel::new(self.price, self.size).map_err(|e| BacktestError::InvalidData(e.to_string()))
118    }
119}
120
121/// An order-book snapshot (best level first on each side), fed to order-book
122/// indicators.
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124pub struct OrderBook {
125    /// Bid levels, best (highest price) first.
126    pub bids: Vec<Level>,
127    /// Ask levels, best (lowest price) first.
128    pub asks: Vec<Level>,
129}
130
131impl OrderBook {
132    /// Convert into a `wickra-core` order book, validating the level and
133    /// ordering invariants (non-empty, sorted, uncrossed).
134    pub fn to_core(&self) -> Result<CoreOrderBook> {
135        let bids = self
136            .bids
137            .iter()
138            .map(|l| l.to_core())
139            .collect::<Result<Vec<_>>>()?;
140        let asks = self
141            .asks
142            .iter()
143            .map(|l| l.to_core())
144            .collect::<Result<Vec<_>>>()?;
145        CoreOrderBook::new(bids, asks).map_err(|e| BacktestError::InvalidData(e.to_string()))
146    }
147}
148
149/// A derivatives (perpetual / futures) tick, fed to derivatives indicators.
150#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
151pub struct DerivativesTick {
152    /// Funding rate for the interval (finite; may be negative).
153    pub funding_rate: f64,
154    /// Perpetual mark price (strictly positive).
155    pub mark_price: f64,
156    /// Spot / index price the perpetual tracks (strictly positive).
157    pub index_price: f64,
158    /// Dated futures mark price (strictly positive).
159    pub futures_price: f64,
160    /// Open interest (non-negative).
161    pub open_interest: f64,
162    /// Aggregate long size (non-negative).
163    pub long_size: f64,
164    /// Aggregate short size (non-negative).
165    pub short_size: f64,
166    /// Taker buy volume (non-negative).
167    pub taker_buy_volume: f64,
168    /// Taker sell volume (non-negative).
169    pub taker_sell_volume: f64,
170    /// Long-liquidation volume (non-negative).
171    pub long_liquidation: f64,
172    /// Short-liquidation volume (non-negative).
173    pub short_liquidation: f64,
174    /// Tick timestamp (engine-defined epoch unit).
175    #[serde(default)]
176    pub timestamp: i64,
177}
178
179impl DerivativesTick {
180    /// Convert into a `wickra-core` derivatives tick, validating the fields.
181    pub fn to_core(self) -> Result<CoreDerivativesTick> {
182        CoreDerivativesTick::new(
183            self.funding_rate,
184            self.mark_price,
185            self.index_price,
186            self.futures_price,
187            self.open_interest,
188            self.long_size,
189            self.short_size,
190            self.taker_buy_volume,
191            self.taker_sell_volume,
192            self.long_liquidation,
193            self.short_liquidation,
194            self.timestamp,
195        )
196        .map_err(|e| BacktestError::InvalidData(e.to_string()))
197    }
198}
199
200/// One symbol's breadth signals within a [`CrossSection`].
201#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
202pub struct CrossSectionMember {
203    /// Price change versus the previous close (sign classifies advance/decline).
204    pub change: f64,
205    /// Period volume for the symbol (non-negative).
206    pub volume: f64,
207    /// Whether the symbol printed a new period high.
208    #[serde(default)]
209    pub new_high: bool,
210    /// Whether the symbol printed a new period low.
211    #[serde(default)]
212    pub new_low: bool,
213}
214
215impl CrossSectionMember {
216    fn to_core(self) -> CoreMember {
217        // `above_ma` / `on_buy_signal` are not settable through wickra-core's
218        // non-exhaustive `Member`, so they default to false.
219        CoreMember::new(self.change, self.volume, self.new_high, self.new_low)
220    }
221}
222
223/// A market-wide cross-section (a panel of [`CrossSectionMember`]s at one tick),
224/// fed to the market-breadth indicators (advance/decline, `McClellan`, TRIN, …).
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct CrossSection {
227    /// Per-symbol members of the universe for this tick.
228    pub members: Vec<CrossSectionMember>,
229    /// Tick timestamp (engine-defined epoch unit).
230    #[serde(default)]
231    pub timestamp: i64,
232}
233
234impl CrossSection {
235    /// Convert into a `wickra-core` cross-section, validating the member
236    /// invariants (finite change, non-negative volume, non-empty).
237    pub fn to_core(&self) -> Result<CoreCrossSection> {
238        let members: Vec<CoreMember> = self.members.iter().map(|m| m.to_core()).collect();
239        CoreCrossSection::new(members, self.timestamp)
240            .map_err(|e| BacktestError::InvalidData(e.to_string()))
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn converts_to_core() {
250        let c = Candle {
251            time: 1,
252            open: 10.0,
253            high: 12.0,
254            low: 9.0,
255            close: 11.0,
256            volume: 100.0,
257        };
258        assert!(c.to_core().is_ok());
259    }
260
261    #[test]
262    fn rejects_non_finite() {
263        let c = Candle {
264            time: 1,
265            open: f64::NAN,
266            high: 1.0,
267            low: 1.0,
268            close: 1.0,
269            volume: 0.0,
270        };
271        assert!(c.to_core().is_err());
272    }
273
274    #[test]
275    fn derived_prices() {
276        let c = Candle {
277            time: 0,
278            open: 4.0,
279            high: 6.0,
280            low: 2.0,
281            close: 4.0,
282            volume: 0.0,
283        };
284        assert!((c.hlc3() - 4.0).abs() < 1e-12);
285        assert!((c.ohlc4() - 4.0).abs() < 1e-12);
286    }
287
288    #[test]
289    fn volume_defaults_to_zero() {
290        let c: Candle =
291            serde_json::from_str(r#"{"time":0,"open":1,"high":1,"low":1,"close":1}"#).unwrap();
292        assert!(c.volume.abs() < f64::EPSILON);
293    }
294
295    #[test]
296    fn trade_converts_and_validates() {
297        let t = TradePrint {
298            price: 100.0,
299            size: 1.5,
300            side: TradeSide::Buy,
301            timestamp: 7,
302        };
303        assert!(t.to_core().is_ok());
304        let bad = TradePrint { price: -1.0, ..t };
305        assert!(bad.to_core().is_err());
306    }
307
308    #[test]
309    fn trade_deserializes_side() {
310        let t: TradePrint =
311            serde_json::from_str(r#"{"price":100,"size":1,"side":"sell"}"#).unwrap();
312        assert_eq!(t.side, TradeSide::Sell);
313        assert_eq!(t.timestamp, 0); // defaulted
314    }
315
316    #[test]
317    fn order_book_converts_and_rejects_crossed() {
318        let ob = OrderBook {
319            bids: vec![Level {
320                price: 100.0,
321                size: 2.0,
322            }],
323            asks: vec![Level {
324                price: 101.0,
325                size: 3.0,
326            }],
327        };
328        assert!(ob.to_core().is_ok());
329        let crossed = OrderBook {
330            bids: vec![Level {
331                price: 102.0,
332                size: 1.0,
333            }],
334            asks: vec![Level {
335                price: 101.0,
336                size: 1.0,
337            }],
338        };
339        assert!(crossed.to_core().is_err());
340    }
341
342    #[test]
343    fn derivatives_tick_converts() {
344        let d = DerivativesTick {
345            funding_rate: 0.0001,
346            mark_price: 100.0,
347            index_price: 99.9,
348            futures_price: 100.5,
349            open_interest: 1000.0,
350            long_size: 600.0,
351            short_size: 400.0,
352            taker_buy_volume: 50.0,
353            taker_sell_volume: 40.0,
354            long_liquidation: 1.0,
355            short_liquidation: 2.0,
356            timestamp: 1,
357        };
358        assert!(d.to_core().is_ok());
359    }
360}