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 mut metadata = Params::new();
153    metadata.insert(
154        "bar_type".to_string(),
155        serde_json::Value::String(bar_type.to_string()),
156    );
157    metadata.insert(
158        "instrument_id".to_string(),
159        serde_json::Value::String(bar_type.instrument_id().to_string()),
160    );
161    DataType::new("BinanceBar", Some(metadata), Some(bar_type.to_string()))
162}
163
164pub(crate) fn binance_bars_to_custom_data(
165    bar_type: BarType,
166    bars: Vec<BinanceBar>,
167) -> Vec<CustomData> {
168    let data_type = binance_bar_data_type(bar_type);
169    bars.into_iter()
170        .map(|bar| CustomData::new(Arc::new(bar), data_type.clone()))
171        .collect()
172}
173
174pub(crate) fn parse_binance_bar_type(data_type: &DataType) -> anyhow::Result<BarType> {
175    let raw = data_type
176        .metadata()
177        .as_ref()
178        .and_then(|metadata| metadata.get("bar_type"))
179        .and_then(|value| value.as_str())
180        .map(str::trim)
181        .filter(|value| !value.is_empty())
182        .context("BinanceBar custom data requires `bar_type` metadata")?;
183    raw.parse()
184        .with_context(|| format!("invalid bar_type metadata `{raw}`"))
185}
186
187impl CustomDataTrait for BinanceBar {
188    fn type_name(&self) -> &'static str {
189        "BinanceBar"
190    }
191
192    fn as_any(&self) -> &dyn std::any::Any {
193        self
194    }
195
196    fn ts_event(&self) -> UnixNanos {
197        self.ts_event
198    }
199
200    fn to_json(&self) -> anyhow::Result<String> {
201        Ok(serde_json::to_string(self)?)
202    }
203
204    fn clone_arc(&self) -> Arc<dyn CustomDataTrait> {
205        Arc::new(self.clone())
206    }
207
208    fn eq_arc(&self, other: &dyn CustomDataTrait) -> bool {
209        if let Some(o) = other.as_any().downcast_ref::<Self>() {
210            self == o
211        } else {
212            false
213        }
214    }
215
216    #[cfg(feature = "python")]
217    fn to_pyobject(&self, py: pyo3::Python<'_>) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
218        nautilus_model::data::custom::clone_pyclass_to_pyobject(self, py)
219    }
220
221    fn type_name_static() -> &'static str {
222        "BinanceBar"
223    }
224
225    fn from_json(value: serde_json::Value) -> anyhow::Result<Arc<dyn CustomDataTrait>> {
226        // Price/Quantity deserialize from borrowed &str, so we must go through
227        // a string representation rather than serde_json::from_value which
228        // produces owned strings.
229        let json_str = serde_json::to_string(&value)?;
230        let parsed: Self = serde_json::from_str(&json_str)?;
231        Ok(Arc::new(parsed))
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use rstest::rstest;
238    use rust_decimal_macros::dec;
239
240    use super::*;
241
242    fn stub_binance_bar() -> BinanceBar {
243        binance_bar_with_volumes(Quantity::from("148976.11427815"), dec!(1756.87402397))
244    }
245
246    fn binance_bar_with_volumes(volume: Quantity, taker_buy_base_volume: Decimal) -> BinanceBar {
247        BinanceBar::new(
248            BarType::from("BTCUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL"),
249            Price::from("0.01634790"),
250            Price::from("0.01640000"),
251            Price::from("0.01575800"),
252            Price::from("0.01577100"),
253            volume,
254            dec!(2434.19055334),
255            100,
256            taker_buy_base_volume,
257            dec!(28.46694368),
258            UnixNanos::from(1_650_000_000_000_000_000u64),
259            UnixNanos::from(1_650_000_000_000_000_000u64),
260        )
261    }
262
263    #[rstest]
264    fn test_type_name() {
265        let bar = stub_binance_bar();
266        assert_eq!(bar.type_name(), "BinanceBar");
267        assert_eq!(BinanceBar::type_name_static(), "BinanceBar");
268    }
269
270    #[rstest]
271    fn test_taker_sell_base_volume() {
272        let bar = binance_bar_with_volumes(Quantity::from("10.00"), dec!(3));
273        assert_eq!(bar.taker_sell_base_volume(), dec!(7));
274    }
275
276    #[rstest]
277    fn test_taker_sell_base_volume_fractional() {
278        let bar = stub_binance_bar();
279        assert_eq!(bar.taker_sell_base_volume(), dec!(147219.24025418));
280    }
281
282    #[rstest]
283    #[case("10")]
284    #[case("10.0")]
285    #[case("10.00")]
286    #[case("10.00000")]
287    fn test_taker_sell_base_volume_matches_across_display_precisions(#[case] volume: &str) {
288        let bar = binance_bar_with_volumes(Quantity::from(volume), dec!(3));
289        assert_eq!(bar.taker_sell_base_volume(), dec!(7));
290    }
291
292    #[rstest]
293    fn test_taker_sell_base_volume_zero_when_total_equals_taker_buy() {
294        let bar = binance_bar_with_volumes(Quantity::from("10.00"), dec!(10));
295        assert_eq!(bar.taker_sell_base_volume(), dec!(0));
296    }
297
298    #[rstest]
299    fn test_taker_sell_quote_volume() {
300        let bar = stub_binance_bar();
301        assert_eq!(bar.taker_sell_quote_volume(), dec!(2405.72360966));
302    }
303
304    #[rstest]
305    fn test_json_round_trip() {
306        let bar = stub_binance_bar();
307        let json = bar.to_json().unwrap();
308        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
309        let restored = BinanceBar::from_json(value).unwrap();
310        let restored_bar = restored.as_any().downcast_ref::<BinanceBar>().unwrap();
311        assert_eq!(restored_bar, &bar);
312    }
313
314    #[rstest]
315    fn test_binance_bar_catalog_round_trip() {
316        use std::sync::Arc;
317
318        use nautilus_model::data::{CustomData as CatalogCustomData, Data, DataType};
319        use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
320        use nautilus_serialization::ensure_custom_data_registered;
321        use tempfile::TempDir;
322
323        ensure_custom_data_registered::<BinanceBar>();
324        let temp_dir = TempDir::new().unwrap();
325        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
326        let mut catalog = catalog;
327        let bar = stub_binance_bar();
328        let bar_type_str = bar.bar_type.to_string();
329        let data_type = DataType::new("BinanceBar", None, Some(bar_type_str.clone()));
330
331        let path = catalog
332            .write_custom_data_batch(
333                vec![CatalogCustomData::new(Arc::new(bar.clone()), data_type)],
334                None,
335                None,
336                Some(false),
337            )
338            .unwrap();
339        assert!(
340            path.to_string_lossy()
341                .contains("data/custom/BinanceBar/BTCUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL")
342        );
343
344        let rows = catalog
345            .query_custom_data_dynamic(
346                "BinanceBar",
347                Some(&[bar_type_str]),
348                None,
349                None,
350                None,
351                None,
352                true,
353            )
354            .unwrap();
355        assert_eq!(rows.len(), 1);
356
357        match &rows[0] {
358            Data::Custom(custom) => {
359                let row = custom
360                    .data
361                    .as_any()
362                    .downcast_ref::<BinanceBar>()
363                    .expect("expected BinanceBar");
364                assert_eq!(row, &bar);
365            }
366            other => panic!("Expected Data::Custom, was {other:?}"),
367        }
368    }
369}