Skip to main content

rustrade_data/exchange/binance/
kline.rs

1//! Live Binance kline (candle) WebSocket payloads and their normalisation to
2//! [`Candle`].
3//!
4//! Covers both the [`BinanceSpot`](crate::exchange::binance::spot::BinanceSpot) `@kline_<interval>`
5//! stream and the
6//! [`BinanceFuturesUsdMarket`](crate::exchange::binance::futures::BinanceFuturesUsdMarket)
7//! `@continuousKline_<interval>` stream (perpetual-only). Endpoints are public/unauthenticated.
8//!
9//! # Closed candles only (no repaint)
10//!
11//! rustrade emits **closed candles only** — an in-progress kline (`k.x == false`) yields an empty
12//! [`MarketIter`], so consumers never see a repainting/lookahead value.
13//! The exclusive `close_time` boundary is recomputed library-side as `open + interval` (see
14//! [`close_time_from_open`]), **not** taken from
15//! Binance's wire `T` (its `period-end − 1ms` convention) — consumers comparing against the raw `T`
16//! will see a 1ms difference by design.
17//!
18//! # Reconnection: no replay, no dedup (consumer policy)
19//!
20//! Across a reconnect the underlying [`Connector`](crate::exchange::Connector) /
21//! `ReconnectingStream` re-subscribes but does **not** replay or de-duplicate: a closed candle
22//! straddling the disconnect may be **re-delivered or skipped**. De-duplication and gap-back-fill
23//! are **consumer policy, not library policy** (consistent with rustrade's "no consumer-specific
24//! policy in the library" rule). A consumer wanting a gapless series should reconcile the live
25//! candle stream against a
26//! [`fetch_candles`](crate::exchange::binance::historical::BinanceHistoricalClient::fetch_candles)
27//! backfill keyed on `close_time` (the field both paths agree on, since `open ≡ close − interval`).
28
29use super::BinanceChannel;
30use crate::{
31    Identifier,
32    error::DataError,
33    event::{MarketEvent, MarketIter},
34    exchange::ExchangeSub,
35    subscription::candle::{Candle, CandleInterval, close_time_from_open},
36};
37use chrono::{DateTime, Utc};
38use rust_decimal::Decimal;
39use rustrade_instrument::exchange::ExchangeId;
40use rustrade_integration::subscription::SubscriptionId;
41use serde::Deserialize;
42use smol_str::SmolStr;
43
44/// The inner `k` payload shared by the [`BinanceSpot`](super::spot::BinanceSpot) `@kline_`
45/// and [`BinanceFuturesUsdMarket`](super::futures::BinanceFuturesUsdMarket) `@continuousKline_` streams.
46///
47/// Both surfaces carry an identical `k` object for the fields rustrade consumes; they differ
48/// only in where the market symbol lives (spot top-level `s` vs futures top-level `ps`) and
49/// in the continuous payload omitting `k.s`. This struct deliberately omits the symbol so it
50/// can be reused by both — the symbol is captured by the outer model and used to build the
51/// [`SubscriptionId`].
52///
53/// ### Correctness notes
54/// - OHLCV are JSON **strings** on the wire (e.g. `"0.01634790"`) → parsed via `de_str` to
55///   [`Decimal`], never through an `f64` (which silently truncates precision).
56/// - `open_time` (`t`) is the candle's open instant; the exclusive `close_time` boundary is
57///   recomputed library-side via [`close_time_from_open`] (`open + interval`), **not** taken
58///   from the wire `T` (Binance's `period-end − 1ms` convention).
59// `Serialize` is intentionally not derived: the fields are decoded from Binance's wire shape via
60// `deserialize_with` (epoch-ms timestamps, string-encoded decimals), so a derived `Serialize` would
61// emit a different shape that does not round-trip — and nothing serializes this decode-only payload.
62#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize)]
63pub struct BinanceKlineData {
64    #[serde(
65        alias = "t",
66        deserialize_with = "rustrade_integration::serde::de::de_u64_epoch_ms_as_datetime_utc"
67    )]
68    pub open_time: DateTime<Utc>,
69    #[serde(alias = "i")]
70    pub interval: CandleInterval,
71    #[serde(
72        alias = "o",
73        deserialize_with = "rustrade_integration::serde::de::de_str"
74    )]
75    pub open: Decimal,
76    #[serde(
77        alias = "h",
78        deserialize_with = "rustrade_integration::serde::de::de_str"
79    )]
80    pub high: Decimal,
81    #[serde(
82        alias = "l",
83        deserialize_with = "rustrade_integration::serde::de::de_str"
84    )]
85    pub low: Decimal,
86    #[serde(
87        alias = "c",
88        deserialize_with = "rustrade_integration::serde::de::de_str"
89    )]
90    pub close: Decimal,
91    #[serde(
92        alias = "v",
93        deserialize_with = "rustrade_integration::serde::de::de_str"
94    )]
95    pub volume: Decimal,
96    #[serde(alias = "n")]
97    pub trade_count: u64,
98    /// `true` once the kline interval has closed. rustrade emits **closed candles only**
99    /// (no repaint/lookahead) — an in-progress kline yields an empty [`MarketIter`].
100    #[serde(alias = "x")]
101    pub closed: bool,
102}
103
104impl BinanceKlineData {
105    /// Map a kline payload to a normalised [`Candle`] [`MarketEvent`], honouring the
106    /// closed-only policy and the `close_time = open + interval` boundary contract.
107    ///
108    /// - In-progress klines (`x == false`) yield an empty [`MarketIter`] (no event).
109    /// - A closed kline whose computed `close_time` overflows the representable
110    ///   [`DateTime<Utc>`] range yields an observable [`DataError`] rather than a silent
111    ///   drop or a plausible-but-wrong timestamp (unreachable for real intervals, but the
112    ///   boundary contract forbids a silent fallback).
113    fn into_market_iter<InstrumentKey>(
114        self,
115        exchange_id: ExchangeId,
116        instrument: InstrumentKey,
117    ) -> MarketIter<InstrumentKey, Candle> {
118        if !self.closed {
119            return MarketIter(vec![]);
120        }
121
122        match close_time_from_open(self.open_time, self.interval.to_step()) {
123            Some(close_time) => MarketIter(vec![Ok(MarketEvent {
124                time_exchange: close_time,
125                time_received: Utc::now(),
126                exchange: exchange_id,
127                instrument,
128                kind: Candle {
129                    close_time,
130                    open: self.open,
131                    high: self.high,
132                    low: self.low,
133                    close: self.close,
134                    // Binance klines carry real consolidated volume and trade count.
135                    volume: Some(self.volume),
136                    trade_count: Some(self.trade_count),
137                },
138            })]),
139            None => MarketIter(vec![Err(DataError::Socket(format!(
140                "Binance candle close_time overflow: open_time {} + interval {} exceeds the representable DateTime<Utc> range",
141                self.open_time, self.interval
142            )))]),
143        }
144    }
145}
146
147/// [`BinanceSpot`](super::spot::BinanceSpot) real-time kline (candle) message.
148///
149/// ### Raw Payload Example
150/// See docs: <https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-streams>
151/// ```json
152/// {
153///     "e": "kline",
154///     "E": 1638747660000,
155///     "s": "BTCUSDT",
156///     "k": {
157///         "t": 1638747660000, "T": 1638747719999, "s": "BTCUSDT", "i": "1m",
158///         "f": 100, "L": 200, "o": "0.0010", "c": "0.0020", "h": "0.0025",
159///         "l": "0.0015", "v": "1000", "n": 100, "x": false, "q": "1.0000",
160///         "V": "500", "Q": "0.500", "B": "123456"
161///     }
162/// }
163/// ```
164// `Serialize` is intentionally not derived: the hand-written `Deserialize` reads Binance's wire
165// frame (top-level `s`, nested `k`), a different shape than this struct's own fields, so a derived
166// `Serialize` would not round-trip — and nothing serializes these wire types.
167#[derive(Clone, PartialEq, PartialOrd, Debug)]
168pub struct BinanceKline {
169    /// Instrument-map routing key `{channel}|{MARKET}` (e.g. `@kline_1m|BTCUSDT`), baked at
170    /// deserialize from the top-level symbol (`s`) and the inner interval (`k.i`) by reusing
171    /// [`ExchangeSub::id`](crate::exchange::ExchangeSub) — the single source of truth shared with
172    /// subscribe-time key construction. Baking here (rather than re-deriving per frame in `id()`)
173    /// means the subscribe-time and frame-time keys cannot drift and silently misroute.
174    pub subscription_id: SubscriptionId,
175    pub kline: BinanceKlineData,
176}
177
178impl<'de> Deserialize<'de> for BinanceKline {
179    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
180    where
181        D: serde::de::Deserializer<'de>,
182    {
183        /// Private wire mirror of the spot `@kline_` frame: the symbol lives top-level (`s`) and
184        /// the interval is nested in `k.i`, so the routing key needs both fields — a single-field
185        /// `deserialize_with` cannot see across them.
186        #[derive(Deserialize)]
187        struct Wire {
188            #[serde(rename = "s")]
189            symbol: SmolStr,
190            #[serde(rename = "k")]
191            kline: BinanceKlineData,
192        }
193
194        let Wire { symbol, kline } = Wire::deserialize(deserializer)?;
195        let subscription_id =
196            ExchangeSub::from((BinanceChannel::spot_candle(kline.interval), symbol.as_str())).id();
197        Ok(Self {
198            subscription_id,
199            kline,
200        })
201    }
202}
203
204impl Identifier<Option<SubscriptionId>> for BinanceKline {
205    fn id(&self) -> Option<SubscriptionId> {
206        Some(self.subscription_id.clone())
207    }
208}
209
210impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceKline)>
211    for MarketIter<InstrumentKey, Candle>
212{
213    fn from((exchange_id, instrument, kline): (ExchangeId, InstrumentKey, BinanceKline)) -> Self {
214        kline.kline.into_market_iter(exchange_id, instrument)
215    }
216}
217
218/// [`BinanceFuturesUsdMarket`](super::futures::BinanceFuturesUsdMarket) real-time continuous-contract
219/// (perpetual) kline message.
220///
221/// Differs from the spot [`BinanceKline`] shape: the symbol is the top-level `ps` (pair) and
222/// `ct` (contract type), and the inner `k` object has **no** `s` field.
223///
224/// ### Raw Payload Example
225/// See docs: <https://binance-docs.github.io/apidocs/futures/en/#continuous-contract-kline-candlestick-streams>
226/// ```json
227/// {
228///     "e": "continuous_kline",
229///     "E": 1607443058651,
230///     "ps": "BTCUSDT",
231///     "ct": "PERPETUAL",
232///     "k": {
233///         "t": 1607443020000, "T": 1607443079999, "i": "1m", "f": 116467658886,
234///         "L": 116468012423, "o": "18787.00", "c": "18804.04", "h": "18804.04",
235///         "l": "18786.54", "v": "197.664", "n": 543, "x": false, "q": "3715253.19494",
236///         "V": "184.769", "Q": "3472925.84746", "B": "0"
237///     }
238/// }
239/// ```
240// `Serialize` is intentionally not derived: the hand-written `Deserialize` reads Binance's wire
241// frame (top-level `ps`, nested `k`), a different shape than this struct's own fields, so a derived
242// `Serialize` would not round-trip — and nothing serializes these wire types.
243#[derive(Clone, PartialEq, PartialOrd, Debug)]
244pub struct BinanceContinuousKline {
245    /// Instrument-map routing key `{channel}|{MARKET}` (e.g.
246    /// `_perpetual@continuousKline_1m|BTCUSDT`), baked at deserialize from the top-level pair
247    /// (`ps`) and the inner interval (`k.i`) by reusing
248    /// [`ExchangeSub::id`](crate::exchange::ExchangeSub) — the single source of truth shared with
249    /// subscribe-time key construction. The continuous payload has no `k.s`, so the pair is the
250    /// only symbol source. The channel prefix is hardcoded perpetual (`_perpetual@`): the wire
251    /// `ct` (contract type) is intentionally not deserialized, since the
252    /// `_perpetual@continuousKline_` subscription this model decodes only ever receives PERPETUAL
253    /// frames. A non-perpetual frame (impossible given the subscription) would build a
254    /// non-matching key and be dropped at the instrument-map lookup rather than mis-attributed.
255    pub subscription_id: SubscriptionId,
256    pub kline: BinanceKlineData,
257}
258
259impl<'de> Deserialize<'de> for BinanceContinuousKline {
260    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
261    where
262        D: serde::de::Deserializer<'de>,
263    {
264        /// Private wire mirror of the futures `@continuousKline_` frame: the pair lives top-level
265        /// (`ps`) and the interval is nested in `k.i`, so the routing key needs both fields — a
266        /// single-field `deserialize_with` cannot see across them.
267        #[derive(Deserialize)]
268        struct Wire {
269            #[serde(rename = "ps")]
270            pair: SmolStr,
271            #[serde(rename = "k")]
272            kline: BinanceKlineData,
273        }
274
275        let Wire { pair, kline } = Wire::deserialize(deserializer)?;
276        let subscription_id = ExchangeSub::from((
277            BinanceChannel::futures_candle(kline.interval),
278            pair.as_str(),
279        ))
280        .id();
281        Ok(Self {
282            subscription_id,
283            kline,
284        })
285    }
286}
287
288impl Identifier<Option<SubscriptionId>> for BinanceContinuousKline {
289    fn id(&self) -> Option<SubscriptionId> {
290        Some(self.subscription_id.clone())
291    }
292}
293
294impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceContinuousKline)>
295    for MarketIter<InstrumentKey, Candle>
296{
297    fn from(
298        (exchange_id, instrument, kline): (ExchangeId, InstrumentKey, BinanceContinuousKline),
299    ) -> Self {
300        kline.kline.into_market_iter(exchange_id, instrument)
301    }
302}
303
304#[cfg(test)]
305#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
306mod tests {
307    use super::*;
308    use chrono::TimeZone;
309    use rust_decimal_macros::dec;
310
311    /// Spot `@kline_1m` frame for a closed candle.
312    const SPOT_CLOSED: &str = r#"
313    {
314        "e": "kline", "E": 1638747660000, "s": "BTCUSDT",
315        "k": {
316            "t": 1638747660000, "T": 1638747719999, "s": "BTCUSDT", "i": "1m",
317            "f": 100, "L": 200, "o": "0.0010", "c": "0.0020", "h": "0.0025",
318            "l": "0.0015", "v": "1000", "n": 100, "x": true, "q": "1.0000",
319            "V": "500", "Q": "0.500", "B": "123456"
320        }
321    }"#;
322
323    /// Futures `continuousKline_1s` frame for an in-progress candle (note: no `k.s`).
324    const FUTURES_OPEN: &str = r#"
325    {
326        "e": "continuous_kline", "E": 1607443058651, "ps": "BTCUSDT", "ct": "PERPETUAL",
327        "k": {
328            "t": 1607443020000, "T": 1607443079999, "i": "1s",
329            "f": 116467658886, "L": 116468012423, "o": "18787.00", "c": "18804.04",
330            "h": "18804.04", "l": "18786.54", "v": "197.664", "n": 543, "x": false,
331            "q": "3715253.19494", "V": "184.769", "Q": "3472925.84746", "B": "0"
332        }
333    }"#;
334
335    /// Futures `continuousKline_1m` frame for a **closed** candle (note: no `k.s`; the symbol
336    /// source is the top-level `ps` pair).
337    const FUTURES_CLOSED: &str = r#"
338    {
339        "e": "continuous_kline", "E": 1607443079999, "ps": "BTCUSDT", "ct": "PERPETUAL",
340        "k": {
341            "t": 1607443020000, "T": 1607443079999, "i": "1m",
342            "f": 116467658886, "L": 116468012423, "o": "18787.00", "c": "18804.04",
343            "h": "18810.00", "l": "18786.54", "v": "197.664", "n": 543, "x": true,
344            "q": "3715253.19494", "V": "184.769", "Q": "3472925.84746", "B": "0"
345        }
346    }"#;
347
348    #[test]
349    fn spot_kline_deserialises_and_builds_map_key() {
350        let kline = serde_json::from_str::<BinanceKline>(SPOT_CLOSED).unwrap();
351        assert_eq!(
352            kline.subscription_id,
353            SubscriptionId::from("@kline_1m|BTCUSDT")
354        );
355        assert_eq!(kline.kline.interval, CandleInterval::Min1);
356        assert!(kline.kline.closed);
357        assert_eq!(kline.kline.open, dec!(0.0010));
358        assert_eq!(
359            kline.id(),
360            Some(SubscriptionId::from("@kline_1m|BTCUSDT")),
361            "map key must be {{channel}}|{{MARKET}}, not the lowercase stream name"
362        );
363    }
364
365    #[test]
366    fn futures_continuous_kline_deserialises_without_k_s() {
367        let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_OPEN).unwrap();
368        assert_eq!(
369            kline.subscription_id,
370            SubscriptionId::from("_perpetual@continuousKline_1s|BTCUSDT")
371        );
372        assert_eq!(kline.kline.interval, CandleInterval::Sec1);
373        assert!(!kline.kline.closed);
374        assert_eq!(
375            kline.id(),
376            Some(SubscriptionId::from(
377                "_perpetual@continuousKline_1s|BTCUSDT"
378            ))
379        );
380    }
381
382    #[test]
383    fn high_precision_ohlcv_round_trips_via_decimal_not_f64() {
384        // A value an f64 intermediate would truncate; str→Decimal must preserve it exactly.
385        let input = r#"
386        {
387            "e": "kline", "E": 1, "s": "ETHUSDT",
388            "k": { "t": 0, "T": 1, "s": "ETHUSDT", "i": "1m", "o": "0.000000010000000",
389                   "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }
390        }"#;
391        let kline = serde_json::from_str::<BinanceKline>(input).unwrap();
392        assert_eq!(kline.kline.open, dec!(0.000000010000000));
393    }
394
395    #[test]
396    fn closed_candle_maps_to_candle_with_boundary_close_time() {
397        let kline = serde_json::from_str::<BinanceKline>(SPOT_CLOSED).unwrap();
398        let MarketIter(events) =
399            MarketIter::<u64, Candle>::from((ExchangeId::BinanceSpot, 1u64, kline));
400        assert_eq!(events.len(), 1);
401        let event = events.into_iter().next().unwrap().unwrap();
402        // close_time = open (1638747660000ms) + 1m = 1638747720000ms — NOT the wire `T`
403        // (1638747719999ms, the `period-end − 1ms` convention).
404        let expected = Utc.timestamp_millis_opt(1638747720000).unwrap();
405        assert_eq!(event.kind.close_time, expected);
406        assert_eq!(event.time_exchange, expected);
407        assert_eq!(event.kind.trade_count, Some(100));
408    }
409
410    #[test]
411    fn in_progress_candle_emits_nothing() {
412        let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_OPEN).unwrap();
413        let MarketIter(events) =
414            MarketIter::<u64, Candle>::from((ExchangeId::BinanceFuturesUsd, 1u64, kline));
415        assert!(events.is_empty(), "in-progress klines must not emit events");
416    }
417
418    #[test]
419    fn closed_continuous_kline_maps_with_boundary_close_time_via_ps_pair() {
420        // Symmetric to `closed_candle_maps_to_candle_with_boundary_close_time`, but for the
421        // futures continuous frame: the symbol comes from `ps` (no `k.s`) and the OHLCV must
422        // map through unchanged.
423        let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_CLOSED).unwrap();
424        let MarketIter(events) =
425            MarketIter::<u64, Candle>::from((ExchangeId::BinanceFuturesUsd, 7u64, kline));
426        assert_eq!(
427            events.len(),
428            1,
429            "a closed continuous kline must emit exactly one candle"
430        );
431        let event = events.into_iter().next().unwrap().unwrap();
432        // close_time = open (1607443020000ms) + 1m = 1607443080000ms — the exclusive boundary,
433        // NOT the wire `T` (1607443079999ms).
434        let expected = Utc.timestamp_millis_opt(1607443080000).unwrap();
435        assert_eq!(event.kind.close_time, expected);
436        assert_eq!(event.time_exchange, expected);
437        assert_eq!(event.kind.open, dec!(18787.00));
438        assert_eq!(event.kind.high, dec!(18810.00));
439        assert_eq!(event.kind.low, dec!(18786.54));
440        assert_eq!(event.kind.close, dec!(18804.04));
441        assert_eq!(event.kind.volume, Some(dec!(197.664)));
442        assert_eq!(event.kind.trade_count, Some(543));
443    }
444
445    /// Drift guard: for **every** [`CandleInterval`] the routing key baked at deserialize must
446    /// equal the canonical instrument-map key `{channel}|{MARKET}` — the same key built at
447    /// subscribe time by [`ExchangeSub::id`] and stored in the instrument
448    /// [`Map`](crate::subscription::Map). Asserting against the literal wire format here (rather
449    /// than a second [`ExchangeSub::id`] call, which would tautologically re-derive the baked key)
450    /// also pins the channel prefix and the `{}|{}` separator, so a regression in either is caught,
451    /// not just a wrong field being read. If the baked and subscribe-time keys ever drift, frames
452    /// silently misroute/drop at `Map::find`; iterating here catches that at construction (in CI),
453    /// not in production. Covers both spot (`@kline_`) and futures (`_perpetual@continuousKline_`).
454    #[test]
455    fn baked_subscription_id_matches_exchange_sub_for_every_interval() {
456        const MARKET: &str = "BTCUSDT";
457
458        for interval in CandleInterval::ALL {
459            let wire = interval.as_str();
460
461            // Spot `@kline_<interval>`.
462            let spot_frame = format!(
463                r#"{{ "e": "kline", "E": 1, "s": "{MARKET}",
464                      "k": {{ "t": 0, "T": 1, "s": "{MARKET}", "i": "{wire}",
465                              "o": "0", "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }} }}"#
466            );
467            let spot = serde_json::from_str::<BinanceKline>(&spot_frame).unwrap();
468            assert_eq!(
469                spot.id(),
470                Some(SubscriptionId::from(format!("@kline_{wire}|{MARKET}"))),
471                "spot kline routing key drifted from the canonical `@kline_<i>|<MARKET>` form for {interval:?}"
472            );
473
474            // Futures `_perpetual@continuousKline_<interval>` (no `k.s`; symbol from `ps`).
475            let futures_frame = format!(
476                r#"{{ "e": "continuous_kline", "E": 1, "ps": "{MARKET}", "ct": "PERPETUAL",
477                      "k": {{ "t": 0, "T": 1, "i": "{wire}",
478                              "o": "0", "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }} }}"#
479            );
480            let futures = serde_json::from_str::<BinanceContinuousKline>(&futures_frame).unwrap();
481            assert_eq!(
482                futures.id(),
483                Some(SubscriptionId::from(format!(
484                    "_perpetual@continuousKline_{wire}|{MARKET}"
485                ))),
486                "futures kline routing key drifted from the canonical `_perpetual@continuousKline_<i>|<MARKET>` form for {interval:?}"
487            );
488        }
489    }
490
491    #[test]
492    fn zero_volume_candle_is_delivered_not_filtered() {
493        // Binance REST gap-fills zero-trade periods (V=0, OHLC=prev_close); the library must
494        // not drop them. (WS omits them, but if one arrives it is still delivered.)
495        let input = r#"
496        {
497            "e": "kline", "E": 1, "s": "BTCUSDT",
498            "k": { "t": 0, "T": 59999, "s": "BTCUSDT", "i": "1m", "o": "100", "c": "100",
499                   "h": "100", "l": "100", "v": "0", "n": 0, "x": true }
500        }"#;
501        let kline = serde_json::from_str::<BinanceKline>(input).unwrap();
502        let MarketIter(events) =
503            MarketIter::<u64, Candle>::from((ExchangeId::BinanceSpot, 1u64, kline));
504        assert_eq!(events.len(), 1);
505        let event = events.into_iter().next().unwrap().unwrap();
506        // Binance genuinely reports V=0 for a gap-filled bar: a real zero, so
507        // `Some(0)` — distinct from a producer that carries no volume (`None`).
508        assert_eq!(event.kind.volume, Some(dec!(0)));
509        assert_eq!(event.kind.trade_count, Some(0));
510    }
511}