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}