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}