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 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 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}