Skip to main content

nautilus_binance/common/
bar.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use std::{collections::HashMap, sync::Arc};
17
18use anyhow::Context;
19use nautilus_core::{Params, UnixNanos};
20use nautilus_model::{
21    data::{
22        DataType, HasTsInit,
23        bar::{Bar, BarType},
24        custom::{CustomData, CustomDataTrait},
25    },
26    types::{Price, Quantity},
27};
28use rust_decimal::Decimal;
29use serde::{Deserialize, Serialize};
30
31/// Represents a Binance bar (kline/candlestick) with additional Binance-specific fields.
32///
33/// Extends the core `Bar` fields with `quote_volume`, `count`,
34/// `taker_buy_base_volume`, and `taker_buy_quote_volume`.
35#[cfg_attr(
36    feature = "python",
37    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
38)]
39#[cfg_attr(
40    feature = "python",
41    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
42)]
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
44pub struct BinanceBar {
45    /// The bar type for this bar.
46    pub bar_type: BarType,
47    /// The bars open price.
48    pub open: Price,
49    /// The bars high price.
50    pub high: Price,
51    /// The bars low price.
52    pub low: Price,
53    /// The bars close price.
54    pub close: Price,
55    /// The bars volume.
56    pub volume: Quantity,
57    /// The quote asset volume.
58    pub quote_volume: Decimal,
59    /// The number of trades.
60    pub count: u64,
61    /// Taker buy base asset volume.
62    pub taker_buy_base_volume: Decimal,
63    /// Taker buy quote asset volume.
64    pub taker_buy_quote_volume: Decimal,
65    /// UNIX timestamp (nanoseconds) when the data event occurred.
66    pub ts_event: UnixNanos,
67    /// UNIX timestamp (nanoseconds) when the data object was initialized.
68    pub ts_init: UnixNanos,
69}
70
71impl BinanceBar {
72    /// Creates a new [`BinanceBar`] instance.
73    #[expect(clippy::too_many_arguments)]
74    #[must_use]
75    pub fn new(
76        bar_type: BarType,
77        open: Price,
78        high: Price,
79        low: Price,
80        close: Price,
81        volume: Quantity,
82        quote_volume: Decimal,
83        count: u64,
84        taker_buy_base_volume: Decimal,
85        taker_buy_quote_volume: Decimal,
86        ts_event: UnixNanos,
87        ts_init: UnixNanos,
88    ) -> Self {
89        Self {
90            bar_type,
91            open,
92            high,
93            low,
94            close,
95            volume,
96            quote_volume,
97            count,
98            taker_buy_base_volume,
99            taker_buy_quote_volume,
100            ts_event,
101            ts_init,
102        }
103    }
104
105    /// Returns the metadata for the type, for use with serialization formats.
106    #[must_use]
107    pub fn get_metadata(bar_type: &BarType) -> HashMap<String, String> {
108        let mut metadata = HashMap::new();
109        metadata.insert("bar_type".to_string(), bar_type.to_string());
110        metadata.insert(
111            "instrument_id".to_string(),
112            bar_type.instrument_id().to_string(),
113        );
114        metadata
115    }
116
117    /// Returns the taker sell base asset volume.
118    #[must_use]
119    pub fn taker_sell_base_volume(&self) -> Decimal {
120        self.volume.as_decimal() - self.taker_buy_base_volume
121    }
122
123    /// Returns the taker sell quote asset volume.
124    #[must_use]
125    pub fn taker_sell_quote_volume(&self) -> Decimal {
126        self.quote_volume - self.taker_buy_quote_volume
127    }
128
129    /// Returns the core bar representation.
130    #[must_use]
131    pub fn bar(&self) -> Bar {
132        Bar::new(
133            self.bar_type,
134            self.open,
135            self.high,
136            self.low,
137            self.close,
138            self.volume,
139            self.ts_event,
140            self.ts_init,
141        )
142    }
143}
144
145impl HasTsInit for BinanceBar {
146    fn ts_init(&self) -> UnixNanos {
147        self.ts_init
148    }
149}
150
151pub(crate) fn binance_bar_data_type(bar_type: BarType) -> DataType {
152    let metadata = Params::new(
153        [
154            (
155                "bar_type".to_string(),
156                serde_json::Value::String(bar_type.to_string()),
157            ),
158            (
159                "instrument_id".to_string(),
160                serde_json::Value::String(bar_type.instrument_id().to_string()),
161            ),
162        ]
163        .into(),
164    );
165    DataType::new("BinanceBar", Some(metadata), Some(bar_type.to_string()))
166}
167
168pub(crate) fn binance_bars_to_custom_data(
169    bar_type: BarType,
170    bars: Vec<BinanceBar>,
171) -> Vec<CustomData> {
172    let data_type = binance_bar_data_type(bar_type);
173    bars.into_iter()
174        .map(|bar| CustomData::new(Arc::new(bar), data_type.clone()))
175        .collect()
176}
177
178pub(crate) fn parse_binance_bar_type(data_type: &DataType) -> anyhow::Result<BarType> {
179    let raw = data_type
180        .metadata()
181        .as_ref()
182        .and_then(|metadata| metadata.get("bar_type"))
183        .and_then(|value| value.as_str())
184        .map(str::trim)
185        .filter(|value| !value.is_empty())
186        .context("BinanceBar custom data requires `bar_type` metadata")?;
187    raw.parse()
188        .with_context(|| format!("invalid bar_type metadata `{raw}`"))
189}
190
191impl CustomDataTrait for BinanceBar {
192    fn type_name(&self) -> &'static str {
193        "BinanceBar"
194    }
195
196    fn as_any(&self) -> &dyn std::any::Any {
197        self
198    }
199
200    fn ts_event(&self) -> UnixNanos {
201        self.ts_event
202    }
203
204    fn to_json(&self) -> anyhow::Result<String> {
205        Ok(serde_json::to_string(self)?)
206    }
207
208    fn clone_arc(&self) -> Arc<dyn CustomDataTrait> {
209        Arc::new(self.clone())
210    }
211
212    fn eq_arc(&self, other: &dyn CustomDataTrait) -> bool {
213        if let Some(o) = other.as_any().downcast_ref::<Self>() {
214            self == o
215        } else {
216            false
217        }
218    }
219
220    #[cfg(feature = "python")]
221    fn to_pyobject(&self, py: pyo3::Python<'_>) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
222        nautilus_model::data::custom::clone_pyclass_to_pyobject(self, py)
223    }
224
225    fn type_name_static() -> &'static str {
226        "BinanceBar"
227    }
228
229    fn from_json(value: serde_json::Value) -> anyhow::Result<Arc<dyn CustomDataTrait>> {
230        // Price/Quantity deserialize from borrowed &str, so we must go through
231        // a string representation rather than serde_json::from_value which
232        // produces owned strings.
233        let json_str = serde_json::to_string(&value)?;
234        let parsed: Self = serde_json::from_str(&json_str)?;
235        Ok(Arc::new(parsed))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use rstest::rstest;
242    use rust_decimal_macros::dec;
243
244    use super::*;
245
246    fn stub_binance_bar() -> BinanceBar {
247        binance_bar_with_volumes(Quantity::from("148976.11427815"), dec!(1756.87402397))
248    }
249
250    fn binance_bar_with_volumes(volume: Quantity, taker_buy_base_volume: Decimal) -> BinanceBar {
251        BinanceBar::new(
252            BarType::from("BTCUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL"),
253            Price::from("0.01634790"),
254            Price::from("0.01640000"),
255            Price::from("0.01575800"),
256            Price::from("0.01577100"),
257            volume,
258            dec!(2434.19055334),
259            100,
260            taker_buy_base_volume,
261            dec!(28.46694368),
262            UnixNanos::from(1_650_000_000_000_000_000u64),
263            UnixNanos::from(1_650_000_000_000_000_000u64),
264        )
265    }
266
267    #[rstest]
268    fn test_type_name() {
269        let bar = stub_binance_bar();
270        assert_eq!(bar.type_name(), "BinanceBar");
271        assert_eq!(BinanceBar::type_name_static(), "BinanceBar");
272    }
273
274    #[rstest]
275    fn test_taker_sell_base_volume() {
276        let bar = binance_bar_with_volumes(Quantity::from("10.00"), dec!(3));
277        assert_eq!(bar.taker_sell_base_volume(), dec!(7));
278    }
279
280    #[rstest]
281    fn test_taker_sell_base_volume_fractional() {
282        let bar = stub_binance_bar();
283        assert_eq!(bar.taker_sell_base_volume(), dec!(147219.24025418));
284    }
285
286    #[rstest]
287    #[case("10")]
288    #[case("10.0")]
289    #[case("10.00")]
290    #[case("10.00000")]
291    fn test_taker_sell_base_volume_matches_across_display_precisions(#[case] volume: &str) {
292        let bar = binance_bar_with_volumes(Quantity::from(volume), dec!(3));
293        assert_eq!(bar.taker_sell_base_volume(), dec!(7));
294    }
295
296    #[rstest]
297    fn test_taker_sell_base_volume_zero_when_total_equals_taker_buy() {
298        let bar = binance_bar_with_volumes(Quantity::from("10.00"), dec!(10));
299        assert_eq!(bar.taker_sell_base_volume(), dec!(0));
300    }
301
302    #[rstest]
303    fn test_taker_sell_quote_volume() {
304        let bar = stub_binance_bar();
305        assert_eq!(bar.taker_sell_quote_volume(), dec!(2405.72360966));
306    }
307
308    #[rstest]
309    fn test_json_round_trip() {
310        let bar = stub_binance_bar();
311        let json = bar.to_json().unwrap();
312        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
313        let restored = BinanceBar::from_json(value).unwrap();
314        let restored_bar = restored.as_any().downcast_ref::<BinanceBar>().unwrap();
315        assert_eq!(restored_bar, &bar);
316    }
317
318    #[cfg(feature = "arrow")]
319    #[rstest]
320    fn test_binance_bar_catalog_round_trip() {
321        use std::sync::Arc;
322
323        use nautilus_model::data::{CustomData as CatalogCustomData, Data, DataType};
324        use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
325        use nautilus_serialization::ensure_custom_data_registered;
326        use tempfile::TempDir;
327
328        ensure_custom_data_registered::<BinanceBar>();
329        let temp_dir = TempDir::new().unwrap();
330        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
331        let mut catalog = catalog;
332        let bar = stub_binance_bar();
333        let bar_type_str = bar.bar_type.to_string();
334        let data_type = DataType::new("BinanceBar", None, Some(bar_type_str.clone()));
335
336        let path = catalog
337            .write_custom_data_batch(
338                vec![CatalogCustomData::new(Arc::new(bar.clone()), data_type)],
339                None,
340                None,
341                Some(false),
342            )
343            .unwrap();
344        assert!(
345            path.to_string_lossy()
346                .contains("data/custom/BinanceBar/BTCUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL")
347        );
348
349        let rows = catalog
350            .query_custom_data_dynamic(
351                "BinanceBar",
352                Some(&[bar_type_str]),
353                None,
354                None,
355                None,
356                None,
357                true,
358            )
359            .unwrap();
360        assert_eq!(rows.len(), 1);
361
362        match &rows[0] {
363            Data::Custom(custom) => {
364                let row = custom
365                    .data
366                    .as_any()
367                    .downcast_ref::<BinanceBar>()
368                    .expect("expected BinanceBar");
369                assert_eq!(row, &bar);
370            }
371            other => panic!("Expected Data::Custom, was {other:?}"),
372        }
373    }
374}