Skip to main content

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}