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    subscription::candle::{Candle, CandleInterval, close_time_from_open},
35};
36use chrono::{DateTime, Utc};
37use rust_decimal::Decimal;
38use rustrade_instrument::exchange::ExchangeId;
39use rustrade_integration::subscription::SubscriptionId;
40use serde::{Deserialize, Serialize};
41use smol_str::{SmolStr, format_smolstr};
42
43/// The inner `k` payload shared by the [`BinanceSpot`](super::spot::BinanceSpot) `@kline_`
44/// and [`BinanceFuturesUsdMarket`](super::futures::BinanceFuturesUsdMarket) `@continuousKline_` streams.
45///
46/// Both surfaces carry an identical `k` object for the fields rustrade consumes; they differ
47/// only in where the market symbol lives (spot top-level `s` vs futures top-level `ps`) and
48/// in the continuous payload omitting `k.s`. This struct deliberately omits the symbol so it
49/// can be reused by both — the symbol is captured by the outer model and used to build the
50/// [`SubscriptionId`].
51///
52/// ### Correctness notes
53/// - OHLCV are JSON **strings** on the wire (e.g. `"0.01634790"`) → parsed via `de_str` to
54///   [`Decimal`], never through an `f64` (which silently truncates precision).
55/// - `open_time` (`t`) is the candle's open instant; the exclusive `close_time` boundary is
56///   recomputed library-side via [`close_time_from_open`] (`open + interval`), **not** taken
57///   from the wire `T` (Binance's `period-end − 1ms` convention).
58#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
59pub struct BinanceKlineData {
60    #[serde(
61        alias = "t",
62        deserialize_with = "rustrade_integration::serde::de::de_u64_epoch_ms_as_datetime_utc"
63    )]
64    pub open_time: DateTime<Utc>,
65    #[serde(alias = "i")]
66    pub interval: CandleInterval,
67    #[serde(
68        alias = "o",
69        deserialize_with = "rustrade_integration::serde::de::de_str"
70    )]
71    pub open: Decimal,
72    #[serde(
73        alias = "h",
74        deserialize_with = "rustrade_integration::serde::de::de_str"
75    )]
76    pub high: Decimal,
77    #[serde(
78        alias = "l",
79        deserialize_with = "rustrade_integration::serde::de::de_str"
80    )]
81    pub low: Decimal,
82    #[serde(
83        alias = "c",
84        deserialize_with = "rustrade_integration::serde::de::de_str"
85    )]
86    pub close: Decimal,
87    #[serde(
88        alias = "v",
89        deserialize_with = "rustrade_integration::serde::de::de_str"
90    )]
91    pub volume: Decimal,
92    #[serde(alias = "n")]
93    pub trade_count: u64,
94    /// `true` once the kline interval has closed. rustrade emits **closed candles only**
95    /// (no repaint/lookahead) — an in-progress kline yields an empty [`MarketIter`].
96    #[serde(alias = "x")]
97    pub closed: bool,
98}
99
100impl BinanceKlineData {
101    /// Map a kline payload to a normalised [`Candle`] [`MarketEvent`], honouring the
102    /// closed-only policy and the `close_time = open + interval` boundary contract.
103    ///
104    /// - In-progress klines (`x == false`) yield an empty [`MarketIter`] (no event).
105    /// - A closed kline whose computed `close_time` overflows the representable
106    ///   [`DateTime<Utc>`] range yields an observable [`DataError`] rather than a silent
107    ///   drop or a plausible-but-wrong timestamp (unreachable for real intervals, but the
108    ///   boundary contract forbids a silent fallback).
109    fn into_market_iter<InstrumentKey>(
110        self,
111        exchange_id: ExchangeId,
112        instrument: InstrumentKey,
113    ) -> MarketIter<InstrumentKey, Candle> {
114        if !self.closed {
115            return MarketIter(vec![]);
116        }
117
118        match close_time_from_open(self.open_time, self.interval.to_step()) {
119            Some(close_time) => MarketIter(vec![Ok(MarketEvent {
120                time_exchange: close_time,
121                time_received: Utc::now(),
122                exchange: exchange_id,
123                instrument,
124                kind: Candle {
125                    close_time,
126                    open: self.open,
127                    high: self.high,
128                    low: self.low,
129                    close: self.close,
130                    volume: self.volume,
131                    trade_count: self.trade_count,
132                },
133            })]),
134            None => MarketIter(vec![Err(DataError::Socket(format!(
135                "Binance candle close_time overflow: open_time {} + interval {} exceeds the representable DateTime<Utc> range",
136                self.open_time, self.interval
137            )))]),
138        }
139    }
140}
141
142/// [`BinanceSpot`](super::spot::BinanceSpot) real-time kline (candle) message.
143///
144/// ### Raw Payload Example
145/// See docs: <https://binance-docs.github.io/apidocs/spot/en/#kline-candlestick-streams>
146/// ```json
147/// {
148///     "e": "kline",
149///     "E": 1638747660000,
150///     "s": "BTCUSDT",
151///     "k": {
152///         "t": 1638747660000, "T": 1638747719999, "s": "BTCUSDT", "i": "1m",
153///         "f": 100, "L": 200, "o": "0.0010", "c": "0.0020", "h": "0.0025",
154///         "l": "0.0015", "v": "1000", "n": 100, "x": false, "q": "1.0000",
155///         "V": "500", "Q": "0.500", "B": "123456"
156///     }
157/// }
158/// ```
159#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
160pub struct BinanceKline {
161    /// Top-level market symbol, UPPERCASE (e.g. `BTCUSDT`).
162    #[serde(alias = "s")]
163    pub symbol: SmolStr,
164    #[serde(alias = "k")]
165    pub kline: BinanceKlineData,
166}
167
168impl Identifier<Option<SubscriptionId>> for BinanceKline {
169    /// Builds the instrument-map key `{channel}|{MARKET}` (e.g. `@kline_1m|BTCUSDT`) from the
170    /// payload's interval (`k.i`) and UPPERCASE symbol (`s`) — matching
171    /// [`ExchangeSub::id`](crate::exchange::ExchangeSub), **not** the lowercase stream name.
172    fn id(&self) -> Option<SubscriptionId> {
173        Some(SubscriptionId(format_smolstr!(
174            "{}|{}",
175            BinanceChannel::spot_candle(self.kline.interval).0,
176            self.symbol
177        )))
178    }
179}
180
181impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceKline)>
182    for MarketIter<InstrumentKey, Candle>
183{
184    fn from((exchange_id, instrument, kline): (ExchangeId, InstrumentKey, BinanceKline)) -> Self {
185        kline.kline.into_market_iter(exchange_id, instrument)
186    }
187}
188
189/// [`BinanceFuturesUsdMarket`](super::futures::BinanceFuturesUsdMarket) real-time continuous-contract
190/// (perpetual) kline message.
191///
192/// Differs from the spot [`BinanceKline`] shape: the symbol is the top-level `ps` (pair) and
193/// `ct` (contract type), and the inner `k` object has **no** `s` field.
194///
195/// ### Raw Payload Example
196/// See docs: <https://binance-docs.github.io/apidocs/futures/en/#continuous-contract-kline-candlestick-streams>
197/// ```json
198/// {
199///     "e": "continuous_kline",
200///     "E": 1607443058651,
201///     "ps": "BTCUSDT",
202///     "ct": "PERPETUAL",
203///     "k": {
204///         "t": 1607443020000, "T": 1607443079999, "i": "1m", "f": 116467658886,
205///         "L": 116468012423, "o": "18787.00", "c": "18804.04", "h": "18804.04",
206///         "l": "18786.54", "v": "197.664", "n": 543, "x": false, "q": "3715253.19494",
207///         "V": "184.769", "Q": "3472925.84746", "B": "0"
208///     }
209/// }
210/// ```
211#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
212pub struct BinanceContinuousKline {
213    /// Top-level pair symbol, UPPERCASE (e.g. `BTCUSDT`). For a perpetual-only venue the pair
214    /// equals the symbol.
215    #[serde(alias = "ps")]
216    pub pair: SmolStr,
217    // The wire `ct` (contract type, e.g. `"PERPETUAL"`) is intentionally not deserialized: the
218    // `_perpetual@continuousKline_` subscription this model decodes only ever receives PERPETUAL
219    // frames, and `id()` hardcodes the perpetual channel prefix to match. A non-perpetual frame
220    // (impossible given the subscription) would build a non-matching `SubscriptionId` and be
221    // dropped at the instrument-map lookup rather than mis-attributed.
222    #[serde(alias = "k")]
223    pub kline: BinanceKlineData,
224}
225
226impl Identifier<Option<SubscriptionId>> for BinanceContinuousKline {
227    /// Builds the instrument-map key `{channel}|{MARKET}` (e.g.
228    /// `_perpetual@continuousKline_1m|BTCUSDT`) from the payload's interval (`k.i`) and
229    /// UPPERCASE pair (`ps`) — matching [`ExchangeSub::id`](crate::exchange::ExchangeSub). The
230    /// continuous payload has no `k.s`, so the pair is the only symbol source.
231    fn id(&self) -> Option<SubscriptionId> {
232        Some(SubscriptionId(format_smolstr!(
233            "{}|{}",
234            BinanceChannel::futures_candle(self.kline.interval).0,
235            self.pair
236        )))
237    }
238}
239
240impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceContinuousKline)>
241    for MarketIter<InstrumentKey, Candle>
242{
243    fn from(
244        (exchange_id, instrument, kline): (ExchangeId, InstrumentKey, BinanceContinuousKline),
245    ) -> Self {
246        kline.kline.into_market_iter(exchange_id, instrument)
247    }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
252mod tests {
253    use super::*;
254    use chrono::TimeZone;
255    use rust_decimal_macros::dec;
256
257    /// Spot `@kline_1m` frame for a closed candle.
258    const SPOT_CLOSED: &str = r#"
259    {
260        "e": "kline", "E": 1638747660000, "s": "BTCUSDT",
261        "k": {
262            "t": 1638747660000, "T": 1638747719999, "s": "BTCUSDT", "i": "1m",
263            "f": 100, "L": 200, "o": "0.0010", "c": "0.0020", "h": "0.0025",
264            "l": "0.0015", "v": "1000", "n": 100, "x": true, "q": "1.0000",
265            "V": "500", "Q": "0.500", "B": "123456"
266        }
267    }"#;
268
269    /// Futures `continuousKline_1s` frame for an in-progress candle (note: no `k.s`).
270    const FUTURES_OPEN: &str = r#"
271    {
272        "e": "continuous_kline", "E": 1607443058651, "ps": "BTCUSDT", "ct": "PERPETUAL",
273        "k": {
274            "t": 1607443020000, "T": 1607443079999, "i": "1s",
275            "f": 116467658886, "L": 116468012423, "o": "18787.00", "c": "18804.04",
276            "h": "18804.04", "l": "18786.54", "v": "197.664", "n": 543, "x": false,
277            "q": "3715253.19494", "V": "184.769", "Q": "3472925.84746", "B": "0"
278        }
279    }"#;
280
281    /// Futures `continuousKline_1m` frame for a **closed** candle (note: no `k.s`; the symbol
282    /// source is the top-level `ps` pair).
283    const FUTURES_CLOSED: &str = r#"
284    {
285        "e": "continuous_kline", "E": 1607443079999, "ps": "BTCUSDT", "ct": "PERPETUAL",
286        "k": {
287            "t": 1607443020000, "T": 1607443079999, "i": "1m",
288            "f": 116467658886, "L": 116468012423, "o": "18787.00", "c": "18804.04",
289            "h": "18810.00", "l": "18786.54", "v": "197.664", "n": 543, "x": true,
290            "q": "3715253.19494", "V": "184.769", "Q": "3472925.84746", "B": "0"
291        }
292    }"#;
293
294    #[test]
295    fn spot_kline_deserialises_and_builds_map_key() {
296        let kline = serde_json::from_str::<BinanceKline>(SPOT_CLOSED).unwrap();
297        assert_eq!(kline.symbol, "BTCUSDT");
298        assert_eq!(kline.kline.interval, CandleInterval::Min1);
299        assert!(kline.kline.closed);
300        assert_eq!(kline.kline.open, dec!(0.0010));
301        assert_eq!(
302            kline.id(),
303            Some(SubscriptionId::from("@kline_1m|BTCUSDT")),
304            "map key must be {{channel}}|{{MARKET}}, not the lowercase stream name"
305        );
306    }
307
308    #[test]
309    fn futures_continuous_kline_deserialises_without_k_s() {
310        let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_OPEN).unwrap();
311        assert_eq!(kline.pair, "BTCUSDT");
312        assert_eq!(kline.kline.interval, CandleInterval::Sec1);
313        assert!(!kline.kline.closed);
314        assert_eq!(
315            kline.id(),
316            Some(SubscriptionId::from(
317                "_perpetual@continuousKline_1s|BTCUSDT"
318            ))
319        );
320    }
321
322    #[test]
323    fn high_precision_ohlcv_round_trips_via_decimal_not_f64() {
324        // A value an f64 intermediate would truncate; str→Decimal must preserve it exactly.
325        let input = r#"
326        {
327            "e": "kline", "E": 1, "s": "ETHUSDT",
328            "k": { "t": 0, "T": 1, "s": "ETHUSDT", "i": "1m", "o": "0.000000010000000",
329                   "c": "0", "h": "0", "l": "0", "v": "0", "n": 0, "x": true }
330        }"#;
331        let kline = serde_json::from_str::<BinanceKline>(input).unwrap();
332        assert_eq!(kline.kline.open, dec!(0.000000010000000));
333    }
334
335    #[test]
336    fn closed_candle_maps_to_candle_with_boundary_close_time() {
337        let kline = serde_json::from_str::<BinanceKline>(SPOT_CLOSED).unwrap();
338        let MarketIter(events) =
339            MarketIter::<u64, Candle>::from((ExchangeId::BinanceSpot, 1u64, kline));
340        assert_eq!(events.len(), 1);
341        let event = events.into_iter().next().unwrap().unwrap();
342        // close_time = open (1638747660000ms) + 1m = 1638747720000ms — NOT the wire `T`
343        // (1638747719999ms, the `period-end − 1ms` convention).
344        let expected = Utc.timestamp_millis_opt(1638747720000).unwrap();
345        assert_eq!(event.kind.close_time, expected);
346        assert_eq!(event.time_exchange, expected);
347        assert_eq!(event.kind.trade_count, 100);
348    }
349
350    #[test]
351    fn in_progress_candle_emits_nothing() {
352        let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_OPEN).unwrap();
353        let MarketIter(events) =
354            MarketIter::<u64, Candle>::from((ExchangeId::BinanceFuturesUsd, 1u64, kline));
355        assert!(events.is_empty(), "in-progress klines must not emit events");
356    }
357
358    #[test]
359    fn closed_continuous_kline_maps_with_boundary_close_time_via_ps_pair() {
360        // Symmetric to `closed_candle_maps_to_candle_with_boundary_close_time`, but for the
361        // futures continuous frame: the symbol comes from `ps` (no `k.s`) and the OHLCV must
362        // map through unchanged.
363        let kline = serde_json::from_str::<BinanceContinuousKline>(FUTURES_CLOSED).unwrap();
364        let MarketIter(events) =
365            MarketIter::<u64, Candle>::from((ExchangeId::BinanceFuturesUsd, 7u64, kline));
366        assert_eq!(
367            events.len(),
368            1,
369            "a closed continuous kline must emit exactly one candle"
370        );
371        let event = events.into_iter().next().unwrap().unwrap();
372        // close_time = open (1607443020000ms) + 1m = 1607443080000ms — the exclusive boundary,
373        // NOT the wire `T` (1607443079999ms).
374        let expected = Utc.timestamp_millis_opt(1607443080000).unwrap();
375        assert_eq!(event.kind.close_time, expected);
376        assert_eq!(event.time_exchange, expected);
377        assert_eq!(event.kind.open, dec!(18787.00));
378        assert_eq!(event.kind.high, dec!(18810.00));
379        assert_eq!(event.kind.low, dec!(18786.54));
380        assert_eq!(event.kind.close, dec!(18804.04));
381        assert_eq!(event.kind.volume, dec!(197.664));
382        assert_eq!(event.kind.trade_count, 543);
383    }
384
385    #[test]
386    fn zero_volume_candle_is_delivered_not_filtered() {
387        // Binance REST gap-fills zero-trade periods (V=0, OHLC=prev_close); the library must
388        // not drop them. (WS omits them, but if one arrives it is still delivered.)
389        let input = r#"
390        {
391            "e": "kline", "E": 1, "s": "BTCUSDT",
392            "k": { "t": 0, "T": 59999, "s": "BTCUSDT", "i": "1m", "o": "100", "c": "100",
393                   "h": "100", "l": "100", "v": "0", "n": 0, "x": true }
394        }"#;
395        let kline = serde_json::from_str::<BinanceKline>(input).unwrap();
396        let MarketIter(events) =
397            MarketIter::<u64, Candle>::from((ExchangeId::BinanceSpot, 1u64, kline));
398        assert_eq!(events.len(), 1);
399        let event = events.into_iter().next().unwrap().unwrap();
400        assert_eq!(event.kind.volume, dec!(0));
401        assert_eq!(event.kind.trade_count, 0);
402    }
403}