rustrade_data/exchange/binance/kline.rs
1//! Live Binance kline (candle) WebSocket payloads and their normalisation to
2//! [`Candle`](crate::subscription::candle::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`](crate::event::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`](crate::subscription::candle::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 volume: self.volume,
135 trade_count: self.trade_count,
136 },
137 })]),
138 None => MarketIter(vec![Err(DataError::Socket(format!(
139 "Binance candle close_time overflow: open_time {} + interval {} exceeds the representable DateTime<Utc> range",
140 self.open_time, self.interval
141 )))]),
142 }
143 }
144}
145
146/// [`BinanceSpot`](super::spot::BinanceSpot) real-time kline (candle) message.
147///
148/// ### Raw Payload Example
149/// See docs: <https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-streams>
150/// ```json
151/// {
152/// "e": "kline",
153/// "E": 1638747660000,
154/// "s": "BTCUSDT",
155/// "k": {
156/// "t": 1638747660000, "T": 1638747719999, "s": "BTCUSDT", "i": "1m",
157/// "f": 100, "L": 200, "o": "0.0010", "c": "0.0020", "h": "0.0025",
158/// "l": "0.0015", "v": "1000", "n": 100, "x": false, "q": "1.0000",
159/// "V": "500", "Q": "0.500", "B": "123456"
160/// }
161/// }
162/// ```
163// `Serialize` is intentionally not derived: the hand-written `Deserialize` reads Binance's wire
164// frame (top-level `s`, nested `k`), a different shape than this struct's own fields, so a derived
165// `Serialize` would not round-trip — and nothing serializes these wire types.
166#[derive(Clone, PartialEq, PartialOrd, Debug)]
167pub struct BinanceKline {
168 /// Instrument-map routing key `{channel}|{MARKET}` (e.g. `@kline_1m|BTCUSDT`), baked at
169 /// deserialize from the top-level symbol (`s`) and the inner interval (`k.i`) by reusing
170 /// [`ExchangeSub::id`](crate::exchange::ExchangeSub) — the single source of truth shared with
171 /// subscribe-time key construction. Baking here (rather than re-deriving per frame in `id()`)
172 /// means the subscribe-time and frame-time keys cannot drift and silently misroute.
173 pub subscription_id: SubscriptionId,
174 pub kline: BinanceKlineData,
175}
176
177impl<'de> Deserialize<'de> for BinanceKline {
178 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179 where
180 D: serde::de::Deserializer<'de>,
181 {
182 /// Private wire mirror of the spot `@kline_` frame: the symbol lives top-level (`s`) and
183 /// the interval is nested in `k.i`, so the routing key needs both fields — a single-field
184 /// `deserialize_with` cannot see across them.
185 #[derive(Deserialize)]
186 struct Wire {
187 #[serde(rename = "s")]
188 symbol: SmolStr,
189 #[serde(rename = "k")]
190 kline: BinanceKlineData,
191 }
192
193 let Wire { symbol, kline } = Wire::deserialize(deserializer)?;
194 let subscription_id =
195 ExchangeSub::from((BinanceChannel::spot_candle(kline.interval), symbol.as_str())).id();
196 Ok(Self {
197 subscription_id,
198 kline,
199 })
200 }
201}
202
203impl Identifier<Option<SubscriptionId>> for BinanceKline {
204 fn id(&self) -> Option<SubscriptionId> {
205 Some(self.subscription_id.clone())
206 }
207}
208
209impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceKline)>
210 for MarketIter<InstrumentKey, Candle>
211{
212 fn from((exchange_id, instrument, kline): (ExchangeId, InstrumentKey, BinanceKline)) -> Self {
213 kline.kline.into_market_iter(exchange_id, instrument)
214 }
215}
216
217/// [`BinanceFuturesUsdMarket`](super::futures::BinanceFuturesUsdMarket) real-time continuous-contract
218/// (perpetual) kline message.
219///
220/// Differs from the spot [`BinanceKline`] shape: the symbol is the top-level `ps` (pair) and
221/// `ct` (contract type), and the inner `k` object has **no** `s` field.
222///
223/// ### Raw Payload Example
224/// See docs: <https://binance-docs.github.io/apidocs/futures/en/#continuous-contract-kline-candlestick-streams>
225/// ```json
226/// {
227/// "e": "continuous_kline",
228/// "E": 1607443058651,
229/// "ps": "BTCUSDT",
230/// "ct": "PERPETUAL",
231/// "k": {
232/// "t": 1607443020000, "T": 1607443079999, "i": "1m", "f": 116467658886,
233/// "L": 116468012423, "o": "18787.00", "c": "18804.04", "h": "18804.04",
234/// "l": "18786.54", "v": "197.664", "n": 543, "x": false, "q": "3715253.19494",
235/// "V": "184.769", "Q": "3472925.84746", "B": "0"
236/// }
237/// }
238/// ```
239// `Serialize` is intentionally not derived: the hand-written `Deserialize` reads Binance's wire
240// frame (top-level `ps`, nested `k`), a different shape than this struct's own fields, so a derived
241// `Serialize` would not round-trip — and nothing serializes these wire types.
242#[derive(Clone, PartialEq, PartialOrd, Debug)]
243pub struct BinanceContinuousKline {
244 /// Instrument-map routing key `{channel}|{MARKET}` (e.g.
245 /// `_perpetual@continuousKline_1m|BTCUSDT`), baked at deserialize from the top-level pair
246 /// (`ps`) and the inner interval (`k.i`) by reusing
247 /// [`ExchangeSub::id`](crate::exchange::ExchangeSub) — the single source of truth shared with
248 /// subscribe-time key construction. The continuous payload has no `k.s`, so the pair is the
249 /// only symbol source. The channel prefix is hardcoded perpetual (`_perpetual@`): the wire
250 /// `ct` (contract type) is intentionally not deserialized, since the
251 /// `_perpetual@continuousKline_` subscription this model decodes only ever receives PERPETUAL
252 /// frames. A non-perpetual frame (impossible given the subscription) would build a
253 /// non-matching key and be dropped at the instrument-map lookup rather than mis-attributed.
254 pub subscription_id: SubscriptionId,
255 pub kline: BinanceKlineData,
256}
257
258impl<'de> Deserialize<'de> for BinanceContinuousKline {
259 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
260 where
261 D: serde::de::Deserializer<'de>,
262 {
263 /// Private wire mirror of the futures `@continuousKline_` frame: the pair lives top-level
264 /// (`ps`) and the interval is nested in `k.i`, so the routing key needs both fields — a
265 /// single-field `deserialize_with` cannot see across them.
266 #[derive(Deserialize)]
267 struct Wire {
268 #[serde(rename = "ps")]
269 pair: SmolStr,
270 #[serde(rename = "k")]
271 kline: BinanceKlineData,
272 }
273
274 let Wire { pair, kline } = Wire::deserialize(deserializer)?;
275 let subscription_id = ExchangeSub::from((
276 BinanceChannel::futures_candle(kline.interval),
277 pair.as_str(),
278 ))
279 .id();
280 Ok(Self {
281 subscription_id,
282 kline,
283 })
284 }
285}
286
287impl Identifier<Option<SubscriptionId>> for BinanceContinuousKline {
288 fn id(&self) -> Option<SubscriptionId> {
289 Some(self.subscription_id.clone())
290 }
291}
292
293impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceContinuousKline)>
294 for MarketIter<InstrumentKey, Candle>
295{
296 fn from(
297 (exchange_id, instrument, kline): (ExchangeId, InstrumentKey, BinanceContinuousKline),
298 ) -> Self {
299 kline.kline.into_market_iter(exchange_id, instrument)
300 }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
305mod tests {
306 use super::*;
307 use chrono::TimeZone;
308 use rust_decimal_macros::dec;
309
310 /// Spot `@kline_1m` frame for a closed candle.
311 const SPOT_CLOSED: &str = r#"
312 {
313 "e": "kline", "E": 1638747660000, "s": "BTCUSDT",
314 "k": {
315 "t": 1638747660000, "T": 1638747719999, "s": "BTCUSDT", "i": "1m",
316 "f": 100, "L": 200, "o": "0.0010", "c": "0.0020", "h": "0.0025",
317 "l": "0.0015", "v": "1000", "n": 100, "x": true, "q": "1.0000",
318 "V": "500", "Q": "0.500", "B": "123456"
319 }
320 }"#;
321
322 /// Futures `continuousKline_1s` frame for an in-progress candle (note: no `k.s`).
323 const FUTURES_OPEN: &str = r#"
324 {
325 "e": "continuous_kline", "E": 1607443058651, "ps": "BTCUSDT", "ct": "PERPETUAL",
326 "k": {
327 "t": 1607443020000, "T": 1607443079999, "i": "1s",
328 "f": 116467658886, "L": 116468012423, "o": "18787.00", "c": "18804.04",
329 "h": "18804.04", "l": "18786.54", "v": "197.664", "n": 543, "x": false,
330 "q": "3715253.19494", "V": "184.769", "Q": "3472925.84746", "B": "0"
331 }
332 }"#;
333
334 /// Futures `continuousKline_1m` frame for a **closed** candle (note: no `k.s`; the symbol
335 /// source is the top-level `ps` pair).
336 const FUTURES_CLOSED: &str = r#"
337 {
338 "e": "continuous_kline", "E": 1607443079999, "ps": "BTCUSDT", "ct": "PERPETUAL",
339 "k": {
340 "t": 1607443020000, "T": 1607443079999, "i": "1m",
341 "f": 116467658886, "L": 116468012423, "o": "18787.00", "c": "18804.04",
342 "h": "18810.00", "l": "18786.54", "v": "197.664", "n": 543, "x": true,
343 "q": "3715253.19494", "V": "184.769", "Q": "3472925.84746", "B": "0"
344 }
345 }"#;
346
347 #[test]
348 fn spot_kline_deserialises_and_builds_map_key() {
349 let kline = serde_json::from_str::<BinanceKline>(SPOT_CLOSED).unwrap();
350 assert_eq!(
351 kline.subscription_id,
352 SubscriptionId::from("@kline_1m|BTCUSDT")
353 );
354 assert_eq!(kline.kline.interval, CandleInterval::Min1);
355 assert!(kline.kline.closed);
356 assert_eq!(kline.kline.open, dec!(0.0010));
357 assert_eq!(
358 kline.id(),
359 Some(SubscriptionId::from("@kline_1m|BTCUSDT")),
360 "map key must be {{channel}}|{{MARKET}}, not the lowercase stream name"
361 );
362 }
363
364 #[test]
365 fn futures_continuous_kline_deserialises_without_k_s() {
366 let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_OPEN).unwrap();
367 assert_eq!(
368 kline.subscription_id,
369 SubscriptionId::from("_perpetual@continuousKline_1s|BTCUSDT")
370 );
371 assert_eq!(kline.kline.interval, CandleInterval::Sec1);
372 assert!(!kline.kline.closed);
373 assert_eq!(
374 kline.id(),
375 Some(SubscriptionId::from(
376 "_perpetual@continuousKline_1s|BTCUSDT"
377 ))
378 );
379 }
380
381 #[test]
382 fn high_precision_ohlcv_round_trips_via_decimal_not_f64() {
383 // A value an f64 intermediate would truncate; str→Decimal must preserve it exactly.
384 let input = r#"
385 {
386 "e": "kline", "E": 1, "s": "ETHUSDT",
387 "k": { "t": 0, "T": 1, "s": "ETHUSDT", "i": "1m", "o": "0.000000010000000",
388 "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }
389 }"#;
390 let kline = serde_json::from_str::<BinanceKline>(input).unwrap();
391 assert_eq!(kline.kline.open, dec!(0.000000010000000));
392 }
393
394 #[test]
395 fn closed_candle_maps_to_candle_with_boundary_close_time() {
396 let kline = serde_json::from_str::<BinanceKline>(SPOT_CLOSED).unwrap();
397 let MarketIter(events) =
398 MarketIter::<u64, Candle>::from((ExchangeId::BinanceSpot, 1u64, kline));
399 assert_eq!(events.len(), 1);
400 let event = events.into_iter().next().unwrap().unwrap();
401 // close_time = open (1638747660000ms) + 1m = 1638747720000ms — NOT the wire `T`
402 // (1638747719999ms, the `period-end − 1ms` convention).
403 let expected = Utc.timestamp_millis_opt(1638747720000).unwrap();
404 assert_eq!(event.kind.close_time, expected);
405 assert_eq!(event.time_exchange, expected);
406 assert_eq!(event.kind.trade_count, 100);
407 }
408
409 #[test]
410 fn in_progress_candle_emits_nothing() {
411 let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_OPEN).unwrap();
412 let MarketIter(events) =
413 MarketIter::<u64, Candle>::from((ExchangeId::BinanceFuturesUsd, 1u64, kline));
414 assert!(events.is_empty(), "in-progress klines must not emit events");
415 }
416
417 #[test]
418 fn closed_continuous_kline_maps_with_boundary_close_time_via_ps_pair() {
419 // Symmetric to `closed_candle_maps_to_candle_with_boundary_close_time`, but for the
420 // futures continuous frame: the symbol comes from `ps` (no `k.s`) and the OHLCV must
421 // map through unchanged.
422 let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_CLOSED).unwrap();
423 let MarketIter(events) =
424 MarketIter::<u64, Candle>::from((ExchangeId::BinanceFuturesUsd, 7u64, kline));
425 assert_eq!(
426 events.len(),
427 1,
428 "a closed continuous kline must emit exactly one candle"
429 );
430 let event = events.into_iter().next().unwrap().unwrap();
431 // close_time = open (1607443020000ms) + 1m = 1607443080000ms — the exclusive boundary,
432 // NOT the wire `T` (1607443079999ms).
433 let expected = Utc.timestamp_millis_opt(1607443080000).unwrap();
434 assert_eq!(event.kind.close_time, expected);
435 assert_eq!(event.time_exchange, expected);
436 assert_eq!(event.kind.open, dec!(18787.00));
437 assert_eq!(event.kind.high, dec!(18810.00));
438 assert_eq!(event.kind.low, dec!(18786.54));
439 assert_eq!(event.kind.close, dec!(18804.04));
440 assert_eq!(event.kind.volume, dec!(197.664));
441 assert_eq!(event.kind.trade_count, 543);
442 }
443
444 /// Drift guard: for **every** [`CandleInterval`] the routing key baked at deserialize must
445 /// equal the canonical instrument-map key `{channel}|{MARKET}` — the same key built at
446 /// subscribe time by [`ExchangeSub::id`] and stored in the instrument
447 /// [`Map`](crate::subscription::Map). Asserting against the literal wire format here (rather
448 /// than a second [`ExchangeSub::id`] call, which would tautologically re-derive the baked key)
449 /// also pins the channel prefix and the `{}|{}` separator, so a regression in either is caught,
450 /// not just a wrong field being read. If the baked and subscribe-time keys ever drift, frames
451 /// silently misroute/drop at `Map::find`; iterating here catches that at construction (in CI),
452 /// not in production. Covers both spot (`@kline_`) and futures (`_perpetual@continuousKline_`).
453 #[test]
454 fn baked_subscription_id_matches_exchange_sub_for_every_interval() {
455 const MARKET: &str = "BTCUSDT";
456
457 for interval in CandleInterval::ALL {
458 let wire = interval.as_str();
459
460 // Spot `@kline_<interval>`.
461 let spot_frame = format!(
462 r#"{{ "e": "kline", "E": 1, "s": "{MARKET}",
463 "k": {{ "t": 0, "T": 1, "s": "{MARKET}", "i": "{wire}",
464 "o": "0", "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }} }}"#
465 );
466 let spot = serde_json::from_str::<BinanceKline>(&spot_frame).unwrap();
467 assert_eq!(
468 spot.id(),
469 Some(SubscriptionId::from(format!("@kline_{wire}|{MARKET}"))),
470 "spot kline routing key drifted from the canonical `@kline_<i>|<MARKET>` form for {interval:?}"
471 );
472
473 // Futures `_perpetual@continuousKline_<interval>` (no `k.s`; symbol from `ps`).
474 let futures_frame = format!(
475 r#"{{ "e": "continuous_kline", "E": 1, "ps": "{MARKET}", "ct": "PERPETUAL",
476 "k": {{ "t": 0, "T": 1, "i": "{wire}",
477 "o": "0", "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }} }}"#
478 );
479 let futures = serde_json::from_str::<BinanceContinuousKline>(&futures_frame).unwrap();
480 assert_eq!(
481 futures.id(),
482 Some(SubscriptionId::from(format!(
483 "_perpetual@continuousKline_{wire}|{MARKET}"
484 ))),
485 "futures kline routing key drifted from the canonical `_perpetual@continuousKline_<i>|<MARKET>` form for {interval:?}"
486 );
487 }
488 }
489
490 #[test]
491 fn zero_volume_candle_is_delivered_not_filtered() {
492 // Binance REST gap-fills zero-trade periods (V=0, OHLC=prev_close); the library must
493 // not drop them. (WS omits them, but if one arrives it is still delivered.)
494 let input = r#"
495 {
496 "e": "kline", "E": 1, "s": "BTCUSDT",
497 "k": { "t": 0, "T": 59999, "s": "BTCUSDT", "i": "1m", "o": "100", "c": "100",
498 "h": "100", "l": "100", "v": "0", "n": 0, "x": true }
499 }"#;
500 let kline = serde_json::from_str::<BinanceKline>(input).unwrap();
501 let MarketIter(events) =
502 MarketIter::<u64, Candle>::from((ExchangeId::BinanceSpot, 1u64, kline));
503 assert_eq!(events.len(), 1);
504 let event = events.into_iter().next().unwrap().unwrap();
505 assert_eq!(event.kind.volume, dec!(0));
506 assert_eq!(event.kind.trade_count, 0);
507 }
508}