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