1use 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#[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 pub bar_type: BarType,
47 pub open: Price,
49 pub high: Price,
51 pub low: Price,
53 pub close: Price,
55 pub volume: Quantity,
57 pub quote_volume: Decimal,
59 pub count: u64,
61 pub taker_buy_base_volume: Decimal,
63 pub taker_buy_quote_volume: Decimal,
65 pub ts_event: UnixNanos,
67 pub ts_init: UnixNanos,
69}
70
71impl BinanceBar {
72 #[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 #[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 #[must_use]
119 pub fn taker_sell_base_volume(&self) -> Decimal {
120 self.volume.as_decimal() - self.taker_buy_base_volume
121 }
122
123 #[must_use]
125 pub fn taker_sell_quote_volume(&self) -> Decimal {
126 self.quote_volume - self.taker_buy_quote_volume
127 }
128
129 #[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 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}