Skip to main content

nautilus_binance/
data_types.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
16//! Binance-specific custom data types.
17//!
18//! These types carry Binance domain data through the Nautilus data engine as
19//! [`CustomData`](nautilus_model::data::CustomData).
20
21use std::sync::Arc;
22
23use nautilus_core::UnixNanos;
24use nautilus_model::{
25    custom_data,
26    data::{HasTsInit, custom::CustomDataTrait},
27    enums::OrderSide,
28    identifiers::InstrumentId,
29    types::{Price, Quantity},
30};
31#[cfg(feature = "arrow")]
32use nautilus_serialization::arrow_custom_data;
33use rust_decimal::Decimal;
34use serde::{Deserialize, Serialize};
35
36use crate::common::bar::BinanceBar;
37
38/// Binance Futures current open interest snapshot.
39#[cfg_attr(
40    feature = "arrow",
41    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.binance")
42)]
43#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.binance")]
44pub struct BinanceFuturesOpenInterest {
45    /// The instrument for this snapshot.
46    pub instrument_id: InstrumentId,
47    /// The total open interest value.
48    pub open_interest: Decimal,
49    /// UNIX timestamp (nanoseconds) when the snapshot event occurred.
50    pub ts_event: UnixNanos,
51    /// UNIX timestamp (nanoseconds) when the instance was initialized.
52    pub ts_init: UnixNanos,
53}
54
55/// Binance Futures historical open interest point.
56#[cfg_attr(
57    feature = "python",
58    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
59)]
60#[cfg_attr(
61    feature = "python",
62    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
63)]
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct BinanceFuturesOpenInterestHistPoint {
66    /// The total open interest value.
67    pub sum_open_interest: Decimal,
68    /// The total open interest notional value.
69    pub sum_open_interest_value: Decimal,
70    /// UNIX timestamp (nanoseconds) for the bucket represented by this point.
71    pub ts_event: UnixNanos,
72}
73
74impl BinanceFuturesOpenInterestHistPoint {
75    /// Creates a new [`BinanceFuturesOpenInterestHistPoint`] instance.
76    #[must_use]
77    pub fn new(
78        sum_open_interest: Decimal,
79        sum_open_interest_value: Decimal,
80        ts_event: UnixNanos,
81    ) -> Self {
82        Self {
83            sum_open_interest,
84            sum_open_interest_value,
85            ts_event,
86        }
87    }
88}
89
90/// Binance Futures historical open interest batch.
91///
92/// COIN-M requests are keyed by pair and contract type rather than by symbol.
93/// Perpetuals derive both from the `_PERP` symbol suffix, while delivery
94/// contracts resolve them from the cached instrument definition.
95#[cfg_attr(
96    feature = "python",
97    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
98)]
99#[cfg_attr(
100    feature = "python",
101    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
102)]
103#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104pub struct BinanceFuturesOpenInterestHist {
105    /// The instrument for this batch.
106    pub instrument_id: InstrumentId,
107    /// The Binance period string used for the request (e.g. "5m").
108    pub period: String,
109    /// Ordered open interest history points returned by Binance.
110    pub points: Vec<BinanceFuturesOpenInterestHistPoint>,
111    /// UNIX timestamp (nanoseconds) for the batch, represented by the final point.
112    pub ts_event: UnixNanos,
113    /// UNIX timestamp (nanoseconds) when the instance was initialized.
114    pub ts_init: UnixNanos,
115}
116
117impl BinanceFuturesOpenInterestHist {
118    /// Creates a new [`BinanceFuturesOpenInterestHist`] instance.
119    #[must_use]
120    pub fn new(
121        instrument_id: InstrumentId,
122        period: String,
123        points: Vec<BinanceFuturesOpenInterestHistPoint>,
124        ts_event: UnixNanos,
125        ts_init: UnixNanos,
126    ) -> Self {
127        Self {
128            instrument_id,
129            period,
130            points,
131            ts_event,
132            ts_init,
133        }
134    }
135}
136
137impl HasTsInit for BinanceFuturesOpenInterestHist {
138    fn ts_init(&self) -> UnixNanos {
139        self.ts_init
140    }
141}
142
143impl CustomDataTrait for BinanceFuturesOpenInterestHist {
144    fn type_name(&self) -> &'static str {
145        "BinanceFuturesOpenInterestHist"
146    }
147
148    fn as_any(&self) -> &dyn std::any::Any {
149        self
150    }
151
152    fn ts_event(&self) -> UnixNanos {
153        self.ts_event
154    }
155
156    fn to_json(&self) -> anyhow::Result<String> {
157        Ok(serde_json::to_string(self)?)
158    }
159
160    fn clone_arc(&self) -> Arc<dyn CustomDataTrait> {
161        Arc::new(self.clone())
162    }
163
164    fn eq_arc(&self, other: &dyn CustomDataTrait) -> bool {
165        if let Some(o) = other.as_any().downcast_ref::<Self>() {
166            self == o
167        } else {
168            false
169        }
170    }
171
172    #[cfg(feature = "python")]
173    fn to_pyobject(&self, py: pyo3::Python<'_>) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
174        nautilus_model::data::custom::clone_pyclass_to_pyobject(self, py)
175    }
176
177    fn type_name_static() -> &'static str {
178        "BinanceFuturesOpenInterestHist"
179    }
180
181    fn from_json(value: serde_json::Value) -> anyhow::Result<Arc<dyn CustomDataTrait>> {
182        let json_str = serde_json::to_string(&value)?;
183        let parsed: Self = serde_json::from_str(&json_str)?;
184        Ok(Arc::new(parsed))
185    }
186}
187
188/// Binance Futures liquidation update from the `forceOrder` stream.
189#[cfg_attr(
190    feature = "arrow",
191    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.binance")
192)]
193#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.binance")]
194pub struct BinanceFuturesLiquidation {
195    /// The instrument for this liquidation event.
196    pub instrument_id: InstrumentId,
197    /// The liquidation order side.
198    #[custom_data_field(native_enum)]
199    pub side: OrderSide,
200    /// The order price.
201    pub price: Price,
202    /// The average fill price.
203    pub average_price: Price,
204    /// The last filled quantity.
205    pub last_filled_qty: Quantity,
206    /// The cumulative filled quantity.
207    pub accumulated_qty: Quantity,
208    /// UNIX timestamp (nanoseconds) when the data event occurred.
209    pub ts_event: UnixNanos,
210    /// UNIX timestamp (nanoseconds) when the instance was initialized.
211    pub ts_init: UnixNanos,
212}
213
214/// Binance Spot 24-hour ticker statistics from the `ticker` stream.
215#[cfg_attr(
216    feature = "python",
217    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
218)]
219#[cfg_attr(
220    feature = "python",
221    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
222)]
223#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
224pub struct BinanceSpotTicker {
225    /// The instrument for these 24-hour statistics.
226    pub instrument_id: InstrumentId,
227    /// Price change over the rolling 24-hour window.
228    pub price_change: Decimal,
229    /// Price change percentage over the rolling 24-hour window.
230    pub price_change_percent: Decimal,
231    /// Weighted average price over the rolling 24-hour window.
232    pub weighted_avg_price: Decimal,
233    /// Previous close price before the rolling window.
234    pub prev_close_price: Decimal,
235    /// Last traded price.
236    pub last_price: Decimal,
237    /// Last traded quantity.
238    pub last_qty: Decimal,
239    /// Best bid price.
240    pub bid_price: Decimal,
241    /// Best bid quantity.
242    pub bid_qty: Decimal,
243    /// Best ask price.
244    pub ask_price: Decimal,
245    /// Best ask quantity.
246    pub ask_qty: Decimal,
247    /// Open price for the rolling 24-hour window.
248    pub open_price: Decimal,
249    /// High price for the rolling 24-hour window.
250    pub high_price: Decimal,
251    /// Low price for the rolling 24-hour window.
252    pub low_price: Decimal,
253    /// Total traded base asset volume.
254    pub volume: Decimal,
255    /// Total traded quote asset volume.
256    pub quote_volume: Decimal,
257    /// Statistics open time.
258    pub open_time: UnixNanos,
259    /// Statistics close time.
260    pub close_time: UnixNanos,
261    /// First trade ID included in the statistics window.
262    pub first_trade_id: i64,
263    /// Last trade ID included in the statistics window.
264    pub last_trade_id: i64,
265    /// Total number of trades in the statistics window.
266    pub num_trades: i64,
267    /// UNIX timestamp (nanoseconds) when the ticker event occurred.
268    pub ts_event: UnixNanos,
269    /// UNIX timestamp (nanoseconds) when the instance was initialized.
270    pub ts_init: UnixNanos,
271}
272
273impl HasTsInit for BinanceSpotTicker {
274    fn ts_init(&self) -> UnixNanos {
275        self.ts_init
276    }
277}
278
279impl CustomDataTrait for BinanceSpotTicker {
280    fn type_name(&self) -> &'static str {
281        "BinanceSpotTicker"
282    }
283
284    fn as_any(&self) -> &dyn std::any::Any {
285        self
286    }
287
288    fn ts_event(&self) -> UnixNanos {
289        self.ts_event
290    }
291
292    fn to_json(&self) -> anyhow::Result<String> {
293        Ok(serde_json::to_string(self)?)
294    }
295
296    fn clone_arc(&self) -> Arc<dyn CustomDataTrait> {
297        Arc::new(self.clone())
298    }
299
300    fn eq_arc(&self, other: &dyn CustomDataTrait) -> bool {
301        other.as_any().downcast_ref::<Self>() == Some(self)
302    }
303
304    #[cfg(feature = "python")]
305    fn to_pyobject(&self, py: pyo3::Python<'_>) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
306        nautilus_model::data::custom::clone_pyclass_to_pyobject(self, py)
307    }
308
309    fn type_name_static() -> &'static str {
310        "BinanceSpotTicker"
311    }
312
313    fn from_json(value: serde_json::Value) -> anyhow::Result<Arc<dyn CustomDataTrait>> {
314        let json_str = serde_json::to_string(&value)?;
315        Ok(Arc::new(serde_json::from_str::<Self>(&json_str)?))
316    }
317}
318
319/// Binance Futures mark-price stream update with venue-specific fields.
320#[cfg_attr(
321    feature = "python",
322    pyo3::pyclass(module = "nautilus_trader.adapters.binance", from_py_object)
323)]
324#[cfg_attr(
325    feature = "python",
326    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.binance")
327)]
328#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
329pub struct BinanceFuturesMarkPriceUpdate {
330    /// The instrument for this update.
331    pub instrument_id: InstrumentId,
332    /// Mark price.
333    pub mark_price: Price,
334    /// Index price.
335    pub index_price: Price,
336    /// Estimated settlement price.
337    pub estimated_settle_price: Price,
338    /// Funding rate.
339    pub funding_rate: Decimal,
340    /// Next funding time.
341    pub next_funding_time: Option<UnixNanos>,
342    /// UNIX timestamp (nanoseconds) when the update occurred.
343    pub ts_event: UnixNanos,
344    /// UNIX timestamp (nanoseconds) when the instance was initialized.
345    pub ts_init: UnixNanos,
346}
347
348impl HasTsInit for BinanceFuturesMarkPriceUpdate {
349    fn ts_init(&self) -> UnixNanos {
350        self.ts_init
351    }
352}
353
354impl CustomDataTrait for BinanceFuturesMarkPriceUpdate {
355    fn type_name(&self) -> &'static str {
356        "BinanceFuturesMarkPriceUpdate"
357    }
358
359    fn as_any(&self) -> &dyn std::any::Any {
360        self
361    }
362
363    fn ts_event(&self) -> UnixNanos {
364        self.ts_event
365    }
366
367    fn to_json(&self) -> anyhow::Result<String> {
368        Ok(serde_json::to_string(self)?)
369    }
370
371    fn clone_arc(&self) -> Arc<dyn CustomDataTrait> {
372        Arc::new(self.clone())
373    }
374
375    fn eq_arc(&self, other: &dyn CustomDataTrait) -> bool {
376        other.as_any().downcast_ref::<Self>() == Some(self)
377    }
378
379    #[cfg(feature = "python")]
380    fn to_pyobject(&self, py: pyo3::Python<'_>) -> pyo3::PyResult<pyo3::Py<pyo3::PyAny>> {
381        nautilus_model::data::custom::clone_pyclass_to_pyobject(self, py)
382    }
383
384    fn type_name_static() -> &'static str {
385        "BinanceFuturesMarkPriceUpdate"
386    }
387
388    fn from_json(value: serde_json::Value) -> anyhow::Result<Arc<dyn CustomDataTrait>> {
389        let json_str = serde_json::to_string(&value)?;
390        Ok(Arc::new(serde_json::from_str::<Self>(&json_str)?))
391    }
392}
393
394/// Binance Futures 24-hour ticker statistics from the `ticker` stream.
395#[cfg_attr(
396    feature = "arrow",
397    arrow_custom_data(pyo3, stub_module = "nautilus_trader.adapters.binance")
398)]
399#[custom_data(pyo3, stub_module = "nautilus_trader.adapters.binance")]
400pub struct BinanceFuturesTicker {
401    /// The instrument for these 24-hour statistics.
402    pub instrument_id: InstrumentId,
403    /// Price change over the rolling 24-hour window.
404    pub price_change: Decimal,
405    /// Price change percentage over the rolling 24-hour window.
406    pub price_change_percent: Decimal,
407    /// Weighted average price over the rolling 24-hour window.
408    pub weighted_avg_price: Decimal,
409    /// Last traded price.
410    pub last_price: Decimal,
411    /// Last traded quantity.
412    pub last_qty: Decimal,
413    /// Open price for the rolling 24-hour window.
414    pub open_price: Decimal,
415    /// High price for the rolling 24-hour window.
416    pub high_price: Decimal,
417    /// Low price for the rolling 24-hour window.
418    pub low_price: Decimal,
419    /// Total traded base asset volume.
420    pub volume: Decimal,
421    /// Total traded quote asset volume.
422    pub quote_volume: Decimal,
423    /// Statistics open time.
424    pub open_time: UnixNanos,
425    /// Statistics close time.
426    pub close_time: UnixNanos,
427    /// First trade ID included in the statistics window.
428    pub first_trade_id: i64,
429    /// Last trade ID included in the statistics window.
430    pub last_trade_id: i64,
431    /// Total number of trades in the statistics window.
432    pub num_trades: i64,
433    /// UNIX timestamp (nanoseconds) when the ticker event occurred.
434    pub ts_event: UnixNanos,
435    /// UNIX timestamp (nanoseconds) when the instance was initialized.
436    pub ts_init: UnixNanos,
437}
438
439/// Registers Binance custom data types.
440///
441/// Safe to call multiple times (idempotent via internal `Once` guards).
442pub fn register_binance_custom_data() {
443    #[cfg(feature = "arrow")]
444    {
445        nautilus_serialization::ensure_custom_data_registered::<BinanceBar>();
446        nautilus_serialization::ensure_custom_data_registered::<BinanceFuturesOpenInterest>();
447        nautilus_serialization::ensure_custom_data_registered::<BinanceFuturesLiquidation>();
448        nautilus_serialization::ensure_custom_data_registered::<BinanceFuturesTicker>();
449    }
450
451    #[cfg(not(feature = "arrow"))]
452    {
453        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BinanceBar>();
454        let _ = nautilus_model::data::ensure_custom_data_json_registered::<
455            BinanceFuturesOpenInterest,
456        >();
457        let _ =
458            nautilus_model::data::ensure_custom_data_json_registered::<BinanceFuturesLiquidation>();
459        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BinanceFuturesTicker>();
460    }
461
462    let _ = nautilus_model::data::ensure_custom_data_json_registered::<
463        BinanceFuturesOpenInterestHist,
464    >();
465    let _ = nautilus_model::data::ensure_custom_data_json_registered::<BinanceSpotTicker>();
466    let _ =
467        nautilus_model::data::ensure_custom_data_json_registered::<BinanceFuturesMarkPriceUpdate>();
468}
469
470#[cfg(test)]
471mod tests {
472    use std::str::FromStr;
473    #[cfg(any(feature = "arrow", feature = "python"))]
474    use std::sync::Arc;
475
476    #[cfg(feature = "python")]
477    use nautilus_core::Params;
478    #[cfg(feature = "arrow")]
479    use nautilus_model::data::Data;
480    use nautilus_model::data::register_custom_data_json;
481    #[cfg(feature = "python")]
482    use nautilus_model::data::{CustomData, DataType};
483    #[cfg(feature = "arrow")]
484    use nautilus_serialization::arrow::{
485        ArrowSchemaProvider, DecodeDataFromRecordBatch, EncodeToRecordBatch,
486    };
487    #[cfg(feature = "python")]
488    use pyo3::{prelude::*, types::PyList};
489    use rstest::rstest;
490    use rust_decimal::Decimal;
491
492    use super::*;
493
494    #[rstest]
495    fn test_register_binance_custom_data_is_idempotent() {
496        register_binance_custom_data();
497        register_binance_custom_data();
498    }
499
500    #[rstest]
501    fn test_register_binance_custom_data_registers_json_deserializers() {
502        register_binance_custom_data();
503
504        assert_json_registered::<BinanceBar>();
505        assert_json_registered::<BinanceFuturesOpenInterest>();
506        assert_json_registered::<BinanceFuturesLiquidation>();
507        assert_json_registered::<BinanceFuturesTicker>();
508    }
509
510    fn assert_json_registered<T: CustomDataTrait>() {
511        let type_name = T::type_name_static();
512        let err = register_custom_data_json::<T>().unwrap_err();
513        assert_eq!(
514            err.to_string(),
515            format!("Custom data type \"{type_name}\" is already registered for JSON"),
516        );
517    }
518
519    #[cfg(feature = "arrow")]
520    #[rstest]
521    fn test_binance_futures_open_interest_arrow_round_trip() {
522        let original = BinanceFuturesOpenInterest::new(
523            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
524            Decimal::from_str("123456.789012345678").unwrap(),
525            UnixNanos::from(1),
526            UnixNanos::from(2),
527        );
528        let metadata = EncodeToRecordBatch::metadata(&original);
529        let batch =
530            BinanceFuturesOpenInterest::encode_batch(&metadata, std::slice::from_ref(&original))
531                .unwrap();
532        let decoded = BinanceFuturesOpenInterest::decode_data_batch(&metadata, batch).unwrap();
533
534        assert_eq!(decoded.len(), 1);
535
536        match &decoded[0] {
537            Data::Custom(custom) => {
538                let round_trip = custom
539                    .data
540                    .as_any()
541                    .downcast_ref::<BinanceFuturesOpenInterest>()
542                    .expect("expected BinanceFuturesOpenInterest");
543                assert_eq!(round_trip, &original);
544            }
545            other => panic!("Expected Data::Custom, was {other:?}"),
546        }
547    }
548
549    #[cfg(feature = "arrow")]
550    #[rstest]
551    fn test_binance_futures_liquidation_arrow_schema_uses_native_types() {
552        use arrow::datatypes::DataType;
553
554        let schema = BinanceFuturesLiquidation::get_schema(None);
555
556        assert_eq!(schema.fields().len(), 8);
557        assert_eq!(schema.field(0).name(), "instrument_id");
558        assert!(matches!(
559            schema.field(0).data_type(),
560            DataType::Utf8 | DataType::Utf8View
561        ));
562        assert_eq!(
563            schema.field_with_name("side").unwrap().data_type(),
564            &nautilus_serialization::arrow::enum_dictionary_data_type(),
565        );
566
567        for field_name in [
568            "price",
569            "average_price",
570            "last_filled_qty",
571            "accumulated_qty",
572        ] {
573            assert_eq!(
574                schema.field_with_name(field_name).unwrap().data_type(),
575                &DataType::Decimal128(38, 16),
576            );
577        }
578    }
579
580    #[cfg(feature = "arrow")]
581    #[rstest]
582    fn test_binance_futures_liquidation_arrow_round_trip() {
583        let original = BinanceFuturesLiquidation::new(
584            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
585            OrderSide::Sell,
586            Price::from("65432.10"),
587            Price::from("65431.50"),
588            Quantity::from("0.250"),
589            Quantity::from("1.500"),
590            UnixNanos::from(3),
591            UnixNanos::from(4),
592        );
593        let metadata = EncodeToRecordBatch::metadata(&original);
594        let batch =
595            BinanceFuturesLiquidation::encode_batch(&metadata, std::slice::from_ref(&original))
596                .unwrap();
597        let decoded = BinanceFuturesLiquidation::decode_data_batch(&metadata, batch).unwrap();
598
599        assert_eq!(decoded.len(), 1);
600
601        match &decoded[0] {
602            Data::Custom(custom) => {
603                let round_trip = custom
604                    .data
605                    .as_any()
606                    .downcast_ref::<BinanceFuturesLiquidation>()
607                    .expect("expected BinanceFuturesLiquidation");
608                assert_eq!(round_trip, &original);
609            }
610            other => panic!("Expected Data::Custom, was {other:?}"),
611        }
612    }
613
614    #[cfg(feature = "arrow")]
615    #[rstest]
616    fn test_binance_futures_ticker_arrow_round_trip() {
617        let original = BinanceFuturesTicker::new(
618            InstrumentId::from("BTCUSDT-PERP.BINANCE"),
619            Decimal::from_str("12.34").unwrap(),
620            Decimal::from_str("5.67").unwrap(),
621            Decimal::from_str("62345.123456").unwrap(),
622            Decimal::from_str("62350.000001").unwrap(),
623            Decimal::from_str("0.010000").unwrap(),
624            Decimal::from_str("62000.000000").unwrap(),
625            Decimal::from_str("63000.000000").unwrap(),
626            Decimal::from_str("61000.000000").unwrap(),
627            Decimal::from_str("1234.567890").unwrap(),
628            Decimal::from_str("76543210.123456").unwrap(),
629            UnixNanos::from(10),
630            UnixNanos::from(11),
631            100,
632            200,
633            300,
634            UnixNanos::from(12),
635            UnixNanos::from(13),
636        );
637        let metadata = EncodeToRecordBatch::metadata(&original);
638        let batch =
639            BinanceFuturesTicker::encode_batch(&metadata, std::slice::from_ref(&original)).unwrap();
640        let decoded = BinanceFuturesTicker::decode_data_batch(&metadata, batch).unwrap();
641
642        assert_eq!(decoded.len(), 1);
643
644        match &decoded[0] {
645            Data::Custom(custom) => {
646                let round_trip = custom
647                    .data
648                    .as_any()
649                    .downcast_ref::<BinanceFuturesTicker>()
650                    .expect("expected BinanceFuturesTicker");
651                assert_eq!(round_trip, &original);
652            }
653            other => panic!("Expected Data::Custom, was {other:?}"),
654        }
655    }
656
657    #[rstest]
658    fn test_binance_futures_custom_data_json_round_trip() {
659        let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
660
661        let open_interest = BinanceFuturesOpenInterest::new(
662            instrument_id,
663            Decimal::from_str("123456.789012345678").unwrap(),
664            UnixNanos::from(1),
665            UnixNanos::from(2),
666        );
667        let value: serde_json::Value =
668            serde_json::from_str(&open_interest.to_json().unwrap()).unwrap();
669        let restored = <BinanceFuturesOpenInterest as CustomDataTrait>::from_json(value).unwrap();
670        assert_eq!(
671            restored
672                .as_any()
673                .downcast_ref::<BinanceFuturesOpenInterest>()
674                .unwrap(),
675            &open_interest,
676        );
677
678        let liquidation = BinanceFuturesLiquidation::new(
679            instrument_id,
680            OrderSide::Sell,
681            Price::from("65432.10"),
682            Price::from("65431.50"),
683            Quantity::from("0.250"),
684            Quantity::from("1.500"),
685            UnixNanos::from(3),
686            UnixNanos::from(4),
687        );
688        let value: serde_json::Value =
689            serde_json::from_str(&liquidation.to_json().unwrap()).unwrap();
690        let restored = <BinanceFuturesLiquidation as CustomDataTrait>::from_json(value).unwrap();
691        assert_eq!(
692            restored
693                .as_any()
694                .downcast_ref::<BinanceFuturesLiquidation>()
695                .unwrap(),
696            &liquidation,
697        );
698
699        let ticker = BinanceFuturesTicker::new(
700            instrument_id,
701            Decimal::from_str("12.34").unwrap(),
702            Decimal::from_str("5.67").unwrap(),
703            Decimal::from_str("62345.123456").unwrap(),
704            Decimal::from_str("62350.000001").unwrap(),
705            Decimal::from_str("0.010000").unwrap(),
706            Decimal::from_str("62000.000000").unwrap(),
707            Decimal::from_str("63000.000000").unwrap(),
708            Decimal::from_str("61000.000000").unwrap(),
709            Decimal::from_str("1234.567890").unwrap(),
710            Decimal::from_str("76543210.123456").unwrap(),
711            UnixNanos::from(10),
712            UnixNanos::from(11),
713            100,
714            200,
715            300,
716            UnixNanos::from(12),
717            UnixNanos::from(13),
718        );
719        let value: serde_json::Value = serde_json::from_str(&ticker.to_json().unwrap()).unwrap();
720        let restored = <BinanceFuturesTicker as CustomDataTrait>::from_json(value).unwrap();
721        assert_eq!(
722            restored
723                .as_any()
724                .downcast_ref::<BinanceFuturesTicker>()
725                .unwrap(),
726            &ticker,
727        );
728    }
729
730    #[cfg(feature = "arrow")]
731    #[rstest]
732    fn test_binance_futures_custom_data_catalog_round_trip() {
733        use nautilus_model::data::{CustomData as CatalogCustomData, DataType as CatalogDataType};
734        use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
735        use tempfile::TempDir;
736
737        register_binance_custom_data();
738        let temp_dir = TempDir::new().unwrap();
739        let catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
740        let mut catalog = catalog;
741        let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
742        let ids = vec![instrument_id.to_string()];
743
744        let liquidation_type = CatalogDataType::new(
745            "BinanceFuturesLiquidation",
746            None,
747            Some(instrument_id.to_string()),
748        );
749
750        let liquidation = BinanceFuturesLiquidation::new(
751            instrument_id,
752            OrderSide::Sell,
753            Price::from("65432.10"),
754            Price::from("65431.50"),
755            Quantity::from("0.250"),
756            Quantity::from("1.500"),
757            UnixNanos::from(100),
758            UnixNanos::from(101),
759        );
760        let liquidation_path = catalog
761            .write_custom_data_batch(
762                vec![CatalogCustomData::new(
763                    Arc::new(liquidation.clone()),
764                    liquidation_type,
765                )],
766                None,
767                None,
768                Some(false),
769            )
770            .unwrap();
771        assert!(
772            liquidation_path
773                .to_string_lossy()
774                .contains("data/custom/BinanceFuturesLiquidation/BTCUSDT-PERP.BINANCE")
775        );
776
777        let liquidation_rows = catalog
778            .query_custom_data_dynamic(
779                "BinanceFuturesLiquidation",
780                Some(&ids),
781                None,
782                None,
783                None,
784                None,
785                true,
786            )
787            .unwrap();
788        assert_eq!(liquidation_rows.len(), 1);
789
790        match &liquidation_rows[0] {
791            Data::Custom(custom) => {
792                let row = custom
793                    .data
794                    .as_any()
795                    .downcast_ref::<BinanceFuturesLiquidation>()
796                    .expect("expected BinanceFuturesLiquidation");
797                assert_eq!(row, &liquidation);
798            }
799            other => panic!("Expected Data::Custom, was {other:?}"),
800        }
801
802        let ticker_type = CatalogDataType::new(
803            "BinanceFuturesTicker",
804            None,
805            Some(instrument_id.to_string()),
806        );
807
808        let ticker = BinanceFuturesTicker::new(
809            instrument_id,
810            Decimal::from_str("12.34").unwrap(),
811            Decimal::from_str("5.67").unwrap(),
812            Decimal::from_str("62345.123456").unwrap(),
813            Decimal::from_str("62350.000001").unwrap(),
814            Decimal::from_str("0.010000").unwrap(),
815            Decimal::from_str("62000.000000").unwrap(),
816            Decimal::from_str("63000.000000").unwrap(),
817            Decimal::from_str("61000.000000").unwrap(),
818            Decimal::from_str("1234.567890").unwrap(),
819            Decimal::from_str("76543210.123456").unwrap(),
820            UnixNanos::from(110),
821            UnixNanos::from(111),
822            100,
823            200,
824            300,
825            UnixNanos::from(112),
826            UnixNanos::from(113),
827        );
828        let ticker_path = catalog
829            .write_custom_data_batch(
830                vec![CatalogCustomData::new(
831                    Arc::new(ticker.clone()),
832                    ticker_type,
833                )],
834                None,
835                None,
836                Some(false),
837            )
838            .unwrap();
839        assert!(
840            ticker_path
841                .to_string_lossy()
842                .contains("data/custom/BinanceFuturesTicker/BTCUSDT-PERP.BINANCE")
843        );
844
845        let ticker_rows = catalog
846            .query_custom_data_dynamic(
847                "BinanceFuturesTicker",
848                Some(&ids),
849                None,
850                None,
851                None,
852                None,
853                true,
854            )
855            .unwrap();
856        assert_eq!(ticker_rows.len(), 1);
857
858        match &ticker_rows[0] {
859            Data::Custom(custom) => {
860                let row = custom
861                    .data
862                    .as_any()
863                    .downcast_ref::<BinanceFuturesTicker>()
864                    .expect("expected BinanceFuturesTicker");
865                assert_eq!(row, &ticker);
866            }
867            other => panic!("Expected Data::Custom, was {other:?}"),
868        }
869
870        let open_interest_type = CatalogDataType::new(
871            "BinanceFuturesOpenInterest",
872            None,
873            Some(instrument_id.to_string()),
874        );
875
876        let open_interest = BinanceFuturesOpenInterest::new(
877            instrument_id,
878            Decimal::from_str("123456.789012345678").unwrap(),
879            UnixNanos::from(120),
880            UnixNanos::from(121),
881        );
882        let open_interest_path = catalog
883            .write_custom_data_batch(
884                vec![CatalogCustomData::new(
885                    Arc::new(open_interest.clone()),
886                    open_interest_type,
887                )],
888                None,
889                None,
890                Some(false),
891            )
892            .unwrap();
893        assert!(
894            open_interest_path
895                .to_string_lossy()
896                .contains("data/custom/BinanceFuturesOpenInterest/BTCUSDT-PERP.BINANCE")
897        );
898
899        let open_interest_rows = catalog
900            .query_custom_data_dynamic(
901                "BinanceFuturesOpenInterest",
902                Some(&ids),
903                None,
904                None,
905                None,
906                None,
907                true,
908            )
909            .unwrap();
910        assert_eq!(open_interest_rows.len(), 1);
911
912        match &open_interest_rows[0] {
913            Data::Custom(custom) => {
914                let row = custom
915                    .data
916                    .as_any()
917                    .downcast_ref::<BinanceFuturesOpenInterest>()
918                    .expect("expected BinanceFuturesOpenInterest");
919                assert_eq!(row, &open_interest);
920            }
921            other => panic!("Expected Data::Custom, was {other:?}"),
922        }
923    }
924
925    #[cfg(feature = "python")]
926    #[rstest]
927    fn test_open_interest_hist_points_roundtrip_as_typed_python_list() {
928        pyo3::Python::initialize();
929        register_binance_custom_data();
930
931        Python::attach(|py| {
932            let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
933            let points = vec![
934                BinanceFuturesOpenInterestHistPoint::new(
935                    Decimal::from_str_exact("100.0").unwrap(),
936                    Decimal::from_str_exact("1000.0").unwrap(),
937                    UnixNanos::from_millis(1_700_000_000_000),
938                ),
939                BinanceFuturesOpenInterestHistPoint::new(
940                    Decimal::from_str_exact("101.0").unwrap(),
941                    Decimal::from_str_exact("1005.0").unwrap(),
942                    UnixNanos::from_millis(1_700_000_300_000),
943                ),
944            ];
945            let payload = BinanceFuturesOpenInterestHist::new(
946                instrument_id,
947                "5m".to_string(),
948                points,
949                UnixNanos::from_millis(1_700_000_300_000),
950                UnixNanos::from(42_u64),
951            );
952
953            let mut metadata = Params::new();
954            metadata.insert(
955                "instrument_id".to_string(),
956                serde_json::Value::String("BTCUSDT-PERP.BINANCE".to_string()),
957            );
958            metadata.insert(
959                "period".to_string(),
960                serde_json::Value::String("5m".to_string()),
961            );
962
963            let custom = CustomData::new(
964                Arc::new(payload),
965                DataType::new(
966                    "BinanceFuturesOpenInterestHist",
967                    Some(metadata),
968                    Some("BTCUSDT-PERP.BINANCE".to_string()),
969                ),
970            );
971
972            let py_custom = Py::new(py, custom).unwrap();
973            let py_payload = py_custom.bind(py).getattr("data").unwrap();
974            let py_points = py_payload
975                .getattr("points")
976                .unwrap()
977                .cast_into::<PyList>()
978                .unwrap();
979
980            assert_eq!(py_points.len(), 2);
981            assert!(
982                py_points
983                    .get_item(0)
984                    .unwrap()
985                    .is_instance_of::<BinanceFuturesOpenInterestHistPoint>()
986            );
987
988            let point0 = py_points
989                .get_item(0)
990                .unwrap()
991                .extract::<BinanceFuturesOpenInterestHistPoint>()
992                .unwrap();
993            let point1 = py_points
994                .get_item(1)
995                .unwrap()
996                .extract::<BinanceFuturesOpenInterestHistPoint>()
997                .unwrap();
998
999            assert_eq!(
1000                point0.sum_open_interest,
1001                Decimal::from_str_exact("100.0").unwrap()
1002            );
1003            assert_eq!(
1004                point1.sum_open_interest_value,
1005                Decimal::from_str_exact("1005.0").unwrap()
1006            );
1007        });
1008    }
1009}