Skip to main content

nautilus_betfair/
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//! Betfair-specific custom data types.
17//!
18//! These types carry Betfair domain data through the Nautilus data engine as
19//! [`CustomData`](nautilus_model::data::CustomData). Each type uses the
20//! `#[custom_data(pyo3)]` and `#[arrow_custom_data(pyo3)]` macros, which generate the model and
21//! Arrow persistence implementations respectively. The Arrow implementations require the `arrow`
22//! feature.
23//!
24//! Call [`register_betfair_custom_data`] once (e.g. during client `connect()`)
25//! to register all types for JSON encoding, and Arrow encoding when the `arrow` feature is enabled.
26//!
27//! Absent optional race telemetry values use `f64::NAN` as the sentinel.
28
29use nautilus_core::UnixNanos;
30use nautilus_model::{
31    custom_data,
32    enums::{BookAction, OrderSide},
33    identifiers::InstrumentId,
34};
35#[cfg(feature = "arrow")]
36use nautilus_serialization::arrow_custom_data;
37use rust_decimal::Decimal;
38
39/// Serde adapter for f64 fields that use NaN as a sentinel for absent values.
40/// Serializes NaN as JSON `null` and deserializes `null` back to NaN,
41/// avoiding `serde_json` errors on non-finite floats.
42mod nan_as_null {
43    pub(super) fn serialize<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
44        if v.is_nan() {
45            s.serialize_none()
46        } else {
47            s.serialize_f64(*v)
48        }
49    }
50
51    pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
52        use serde::Deserialize;
53        Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
54    }
55}
56
57/// Betfair ticker data from MCM runner changes.
58///
59/// Carries last traded price, traded volume, and starting price
60/// near/far values per runner.
61#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
62#[custom_data(pyo3)]
63pub struct BetfairTicker {
64    /// The instrument ID for this ticker.
65    pub instrument_id: InstrumentId,
66    /// Last traded price.
67    pub last_traded_price: Option<Decimal>,
68    /// Total traded volume.
69    pub traded_volume: Option<Decimal>,
70    /// Starting price near (projected BSP from matched portion).
71    pub starting_price_near: Option<Decimal>,
72    /// Starting price far (projected BSP from unmatched portion).
73    pub starting_price_far: Option<Decimal>,
74    /// UNIX timestamp (nanoseconds) when the data event occurred.
75    pub ts_event: UnixNanos,
76    /// UNIX timestamp (nanoseconds) when the instance was initialized.
77    pub ts_init: UnixNanos,
78}
79
80/// Realized Betfair Starting Price (BSP) for a runner.
81///
82/// Emitted from the market definition when a runner's BSP is determined.
83#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
84#[custom_data(pyo3)]
85pub struct BetfairStartingPrice {
86    /// The instrument ID for this starting price.
87    pub instrument_id: InstrumentId,
88    /// The realized best starting price value.
89    pub bsp: Decimal,
90    /// UNIX timestamp (nanoseconds) when the data event occurred.
91    pub ts_event: UnixNanos,
92    /// UNIX timestamp (nanoseconds) when the instance was initialized.
93    pub ts_init: UnixNanos,
94}
95
96/// BSP order book delta from starting price back/lay arrays.
97///
98/// Mirrors `OrderBookDelta` fields as a custom data type so strategies
99/// can subscribe specifically to BSP book updates (spb/spl) separately
100/// from the exchange order book (atb/atl).
101#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
102#[custom_data(pyo3)]
103pub struct BetfairBspBookDelta {
104    /// The instrument ID for this BSP delta.
105    pub instrument_id: InstrumentId,
106    /// The book action (add/update/delete/clear).
107    #[custom_data_field(native_enum)]
108    pub action: BookAction,
109    /// The order side.
110    #[custom_data_field(native_enum)]
111    pub side: OrderSide,
112    /// The price level.
113    pub price: Decimal,
114    /// The size at this price level.
115    pub size: Decimal,
116    /// UNIX timestamp (nanoseconds) when the data event occurred.
117    pub ts_event: UnixNanos,
118    /// UNIX timestamp (nanoseconds) when the instance was initialized.
119    pub ts_init: UnixNanos,
120}
121
122/// Marker emitted after all changes in an MCM sequence are processed.
123///
124/// Strategies can use this to know when a coherent set of market updates
125/// has been fully delivered.
126#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
127#[custom_data(pyo3)]
128pub struct BetfairSequenceCompleted {
129    /// UNIX timestamp (nanoseconds) when the data event occurred.
130    pub ts_event: UnixNanos,
131    /// UNIX timestamp (nanoseconds) when the instance was initialized.
132    pub ts_init: UnixNanos,
133}
134
135/// Betfair order void event (e.g. VAR void).
136///
137/// Published when a matched bet is retroactively voided by Betfair, such as
138/// when a goal is disallowed following a VAR review.
139#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
140#[custom_data(pyo3)]
141pub struct BetfairOrderVoided {
142    /// The instrument ID for the voided order.
143    pub instrument_id: InstrumentId,
144    /// The client order ID.
145    pub client_order_id: String,
146    /// The venue (Betfair) order ID (bet ID).
147    pub venue_order_id: String,
148    /// The size that was voided.
149    pub size_voided: Decimal,
150    /// The order price.
151    pub price: Decimal,
152    /// The original order size.
153    pub size: Decimal,
154    /// The order side ("BACK" or "LAY").
155    pub side: String,
156    /// The average price matched.
157    pub avg_price_matched: Option<Decimal>,
158    /// The total size matched.
159    pub size_matched: Option<Decimal>,
160    /// The void reason. Empty string if absent.
161    pub reason: String,
162    /// UNIX timestamp (nanoseconds) when the data event occurred.
163    pub ts_event: UnixNanos,
164    /// UNIX timestamp (nanoseconds) when the instance was initialized.
165    pub ts_init: UnixNanos,
166}
167
168/// GPS tracking data for a single runner from RCM (Race Change Messages).
169///
170/// Betfair's Total Performance Data (TPD) provides real-time GPS positions,
171/// speed, and stride frequency for each runner in supported races.
172#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
173#[custom_data(pyo3)]
174pub struct BetfairRaceRunnerData {
175    /// Race identifier (e.g. "28587288.1650").
176    pub race_id: String,
177    /// Betfair market identifier.
178    pub market_id: String,
179    /// Betfair selection identifier.
180    pub selection_id: i64,
181    /// GPS latitude coordinate. `f64::NAN` if absent.
182    #[serde(
183        serialize_with = "nan_as_null::serialize",
184        deserialize_with = "nan_as_null::deserialize"
185    )]
186    pub latitude: f64,
187    /// GPS longitude coordinate. `f64::NAN` if absent.
188    #[serde(
189        serialize_with = "nan_as_null::serialize",
190        deserialize_with = "nan_as_null::deserialize"
191    )]
192    pub longitude: f64,
193    /// Speed in m/s (Doppler-derived). `f64::NAN` if absent.
194    #[serde(
195        serialize_with = "nan_as_null::serialize",
196        deserialize_with = "nan_as_null::deserialize"
197    )]
198    pub speed: f64,
199    /// Distance to finish in meters. `f64::NAN` if absent.
200    #[serde(
201        serialize_with = "nan_as_null::serialize",
202        deserialize_with = "nan_as_null::deserialize"
203    )]
204    pub progress: f64,
205    /// Stride frequency in Hz. `f64::NAN` if absent.
206    #[serde(
207        serialize_with = "nan_as_null::serialize",
208        deserialize_with = "nan_as_null::deserialize"
209    )]
210    pub stride_frequency: f64,
211    /// UNIX timestamp (nanoseconds) when the data event occurred.
212    pub ts_event: UnixNanos,
213    /// UNIX timestamp (nanoseconds) when the instance was initialized.
214    pub ts_init: UnixNanos,
215}
216
217/// Race-level progress from RCM (Race Change Messages).
218///
219/// Provides sectional timing, race order, and obstacle data for the
220/// overall race rather than individual runners.
221#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
222#[custom_data(pyo3)]
223pub struct BetfairRaceProgress {
224    /// Race identifier (e.g. "28587288.1650").
225    pub race_id: String,
226    /// Betfair market identifier.
227    pub market_id: String,
228    /// Gate/sectional name (e.g. "1f", "2f", "Finish"). Empty if absent.
229    pub gate_name: String,
230    /// Sectional time in seconds. `f64::NAN` if absent.
231    #[serde(
232        serialize_with = "nan_as_null::serialize",
233        deserialize_with = "nan_as_null::deserialize"
234    )]
235    pub sectional_time: f64,
236    /// Running time since race start in seconds. `f64::NAN` if absent.
237    #[serde(
238        serialize_with = "nan_as_null::serialize",
239        deserialize_with = "nan_as_null::deserialize"
240    )]
241    pub running_time: f64,
242    /// Speed of lead horse in m/s. `f64::NAN` if absent.
243    #[serde(
244        serialize_with = "nan_as_null::serialize",
245        deserialize_with = "nan_as_null::deserialize"
246    )]
247    pub speed: f64,
248    /// Distance to finish for leading horse in meters. `f64::NAN` if absent.
249    #[serde(
250        serialize_with = "nan_as_null::serialize",
251        deserialize_with = "nan_as_null::deserialize"
252    )]
253    pub progress: f64,
254    /// Runner order by selection ID (JSON-encoded array). Empty if absent.
255    pub order: String,
256    /// Jump obstacles (JSON-encoded array of {"J":int,"L":float}). Empty if absent.
257    pub jumps: String,
258    /// UNIX timestamp (nanoseconds) when the data event occurred.
259    pub ts_event: UnixNanos,
260    /// UNIX timestamp (nanoseconds) when the instance was initialized.
261    pub ts_init: UnixNanos,
262}
263
264#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
265#[custom_data(pyo3)]
266pub struct BetfairCricketMatch {
267    /// Betfair event identifier.
268    pub event_id: String,
269    /// Betfair market identifier.
270    pub market_id: String,
271    /// Fixture metadata (JSON-encoded object). Empty if absent.
272    pub fixture_info: String,
273    /// Home team metadata (JSON-encoded object). Empty if absent.
274    pub home_team: String,
275    /// Away team metadata (JSON-encoded object). Empty if absent.
276    pub away_team: String,
277    /// Match statistics (JSON-encoded object). Empty if absent.
278    pub match_stats: String,
279    /// Match incidents (JSON-encoded object). Empty if absent.
280    pub incident_list_wrapper: String,
281    /// UNIX timestamp (nanoseconds) when the data event occurred.
282    pub ts_event: UnixNanos,
283    /// UNIX timestamp (nanoseconds) when the instance was initialized.
284    pub ts_init: UnixNanos,
285}
286
287/// Registers all Betfair custom data types for JSON encoding, and Arrow encoding when the
288/// `arrow` feature is enabled.
289///
290/// This must be called once before emitting or persisting Betfair custom data.
291/// Safe to call multiple times (idempotent via internal `Once` guards).
292pub fn register_betfair_custom_data() {
293    #[cfg(feature = "arrow")]
294    {
295        nautilus_serialization::ensure_custom_data_registered::<BetfairTicker>();
296        nautilus_serialization::ensure_custom_data_registered::<BetfairStartingPrice>();
297        nautilus_serialization::ensure_custom_data_registered::<BetfairBspBookDelta>();
298        nautilus_serialization::ensure_custom_data_registered::<BetfairSequenceCompleted>();
299        nautilus_serialization::ensure_custom_data_registered::<BetfairOrderVoided>();
300        nautilus_serialization::ensure_custom_data_registered::<BetfairRaceRunnerData>();
301        nautilus_serialization::ensure_custom_data_registered::<BetfairRaceProgress>();
302        nautilus_serialization::ensure_custom_data_registered::<BetfairCricketMatch>();
303    }
304
305    #[cfg(not(feature = "arrow"))]
306    {
307        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairTicker>();
308        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairStartingPrice>();
309        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairBspBookDelta>();
310        let _ =
311            nautilus_model::data::ensure_custom_data_json_registered::<BetfairSequenceCompleted>();
312        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairOrderVoided>();
313        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairRaceRunnerData>();
314        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairRaceProgress>();
315        let _ = nautilus_model::data::ensure_custom_data_json_registered::<BetfairCricketMatch>();
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use nautilus_model::data::{CustomDataTrait, register_custom_data_json};
322    #[cfg(feature = "arrow")]
323    use nautilus_serialization::arrow::ArrowSchemaProvider;
324    use rstest::rstest;
325    use rust_decimal::Decimal;
326
327    use super::*;
328
329    #[cfg(feature = "arrow")]
330    #[rstest]
331    fn test_betfair_ticker_schema() {
332        let schema = BetfairTicker::get_schema(None);
333        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
334        assert!(field_names.contains(&"instrument_id".to_string()));
335        assert!(field_names.contains(&"last_traded_price".to_string()));
336        assert!(field_names.contains(&"traded_volume".to_string()));
337        assert!(field_names.contains(&"starting_price_near".to_string()));
338        assert!(field_names.contains(&"starting_price_far".to_string()));
339        assert!(field_names.contains(&"ts_event".to_string()));
340        assert!(field_names.contains(&"ts_init".to_string()));
341    }
342
343    #[cfg(feature = "arrow")]
344    #[rstest]
345    fn test_betfair_starting_price_schema() {
346        let schema = BetfairStartingPrice::get_schema(None);
347        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
348        assert!(field_names.contains(&"instrument_id".to_string()));
349        assert!(field_names.contains(&"bsp".to_string()));
350        assert!(field_names.contains(&"ts_event".to_string()));
351        assert!(field_names.contains(&"ts_init".to_string()));
352    }
353
354    #[cfg(feature = "arrow")]
355    #[rstest]
356    fn test_betfair_bsp_book_delta_schema() {
357        let schema = BetfairBspBookDelta::get_schema(None);
358        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
359        assert!(field_names.contains(&"instrument_id".to_string()));
360        assert!(field_names.contains(&"action".to_string()));
361        assert!(field_names.contains(&"side".to_string()));
362        assert!(field_names.contains(&"price".to_string()));
363        assert!(field_names.contains(&"size".to_string()));
364        assert!(field_names.contains(&"ts_event".to_string()));
365        assert!(field_names.contains(&"ts_init".to_string()));
366        assert_eq!(
367            schema.field_with_name("action").unwrap().data_type(),
368            &nautilus_serialization::arrow::enum_dictionary_data_type(),
369        );
370        assert_eq!(
371            schema.field_with_name("side").unwrap().data_type(),
372            &nautilus_serialization::arrow::enum_dictionary_data_type(),
373        );
374    }
375
376    #[cfg(feature = "arrow")]
377    #[rstest]
378    fn test_betfair_sequence_completed_schema() {
379        let schema = BetfairSequenceCompleted::get_schema(None);
380        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
381        assert!(field_names.contains(&"ts_event".to_string()));
382        assert!(field_names.contains(&"ts_init".to_string()));
383    }
384
385    #[cfg(feature = "arrow")]
386    #[rstest]
387    fn test_betfair_order_voided_schema() {
388        let schema = BetfairOrderVoided::get_schema(None);
389        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
390        assert!(field_names.contains(&"instrument_id".to_string()));
391        assert!(field_names.contains(&"client_order_id".to_string()));
392        assert!(field_names.contains(&"venue_order_id".to_string()));
393        assert!(field_names.contains(&"size_voided".to_string()));
394        assert!(field_names.contains(&"reason".to_string()));
395    }
396
397    #[rstest]
398    fn test_register_betfair_custom_data_is_idempotent() {
399        register_betfair_custom_data();
400        register_betfair_custom_data();
401    }
402
403    #[rstest]
404    fn test_register_betfair_custom_data_registers_json_deserializers() {
405        register_betfair_custom_data();
406
407        assert_json_registered::<BetfairTicker>();
408        assert_json_registered::<BetfairStartingPrice>();
409        assert_json_registered::<BetfairBspBookDelta>();
410        assert_json_registered::<BetfairSequenceCompleted>();
411        assert_json_registered::<BetfairOrderVoided>();
412        assert_json_registered::<BetfairRaceRunnerData>();
413        assert_json_registered::<BetfairRaceProgress>();
414        assert_json_registered::<BetfairCricketMatch>();
415    }
416
417    fn assert_json_registered<T: CustomDataTrait>() {
418        let type_name = T::type_name_static();
419        let err = register_custom_data_json::<T>().unwrap_err();
420        assert_eq!(
421            err.to_string(),
422            format!("Custom data type \"{type_name}\" is already registered for JSON"),
423        );
424    }
425
426    #[cfg(feature = "arrow")]
427    #[rstest]
428    fn test_betfair_race_runner_data_schema() {
429        let schema = BetfairRaceRunnerData::get_schema(None);
430        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
431        assert!(field_names.contains(&"race_id".to_string()));
432        assert!(field_names.contains(&"market_id".to_string()));
433        assert!(field_names.contains(&"selection_id".to_string()));
434        assert!(field_names.contains(&"latitude".to_string()));
435        assert!(field_names.contains(&"longitude".to_string()));
436        assert!(field_names.contains(&"speed".to_string()));
437        assert!(field_names.contains(&"progress".to_string()));
438        assert!(field_names.contains(&"stride_frequency".to_string()));
439    }
440
441    #[cfg(feature = "arrow")]
442    #[rstest]
443    fn test_betfair_race_progress_schema() {
444        let schema = BetfairRaceProgress::get_schema(None);
445        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
446        assert!(field_names.contains(&"race_id".to_string()));
447        assert!(field_names.contains(&"market_id".to_string()));
448        assert!(field_names.contains(&"gate_name".to_string()));
449        assert!(field_names.contains(&"sectional_time".to_string()));
450        assert!(field_names.contains(&"running_time".to_string()));
451        assert!(field_names.contains(&"speed".to_string()));
452        assert!(field_names.contains(&"progress".to_string()));
453        assert!(field_names.contains(&"order".to_string()));
454        assert!(field_names.contains(&"jumps".to_string()));
455    }
456
457    #[cfg(feature = "arrow")]
458    #[rstest]
459    fn test_betfair_cricket_match_schema() {
460        let schema = BetfairCricketMatch::get_schema(None);
461        let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
462        assert!(field_names.contains(&"event_id".to_string()));
463        assert!(field_names.contains(&"market_id".to_string()));
464        assert!(field_names.contains(&"match_stats".to_string()));
465        assert!(field_names.contains(&"incident_list_wrapper".to_string()));
466    }
467
468    #[rstest]
469    fn test_race_runner_data_nan_json_roundtrip() {
470        let data = BetfairRaceRunnerData::new(
471            "28587288.1650".to_string(),
472            "1.1234567".to_string(),
473            7390417,
474            51.4189543,
475            -0.4058491,
476            17.8,
477            f64::NAN,
478            f64::NAN,
479            UnixNanos::from(1_000_000_000u64),
480            UnixNanos::from(1_000_000_000u64),
481        );
482
483        let json = serde_json::to_string(&data).unwrap();
484        assert!(json.contains("\"progress\":null"));
485        assert!(json.contains("\"stride_frequency\":null"));
486        assert!(json.contains("\"latitude\":51.4189543"));
487
488        let parsed: BetfairRaceRunnerData = serde_json::from_str(&json).unwrap();
489        assert!(parsed.progress.is_nan());
490        assert!(parsed.stride_frequency.is_nan());
491        assert_eq!(parsed.latitude, 51.4189543);
492        assert_eq!(parsed.selection_id, 7390417);
493    }
494
495    #[rstest]
496    fn test_betfair_ticker_optional_decimal_json_roundtrip() {
497        let ticker = BetfairTicker::new(
498            InstrumentId::from("1.234-56789-0.0.BETFAIR"),
499            Some(Decimal::new(15, 1)),
500            Some(Decimal::new(100, 0)),
501            None,
502            None,
503            UnixNanos::from(1_000_000_000u64),
504            UnixNanos::from(1_000_000_000u64),
505        );
506
507        let json = serde_json::to_string(&ticker).unwrap();
508        assert!(json.contains("\"starting_price_near\":null"));
509        assert!(json.contains("\"starting_price_far\":null"));
510
511        let parsed: BetfairTicker = serde_json::from_str(&json).unwrap();
512        assert!(parsed.starting_price_near.is_none());
513        assert!(parsed.starting_price_far.is_none());
514        assert_eq!(parsed.last_traded_price, Some(Decimal::new(15, 1)));
515        assert_eq!(parsed.traded_volume, Some(Decimal::new(100, 0)));
516    }
517
518    #[cfg(feature = "arrow")]
519    #[rstest]
520    fn test_betfair_ticker_optional_decimal_arrow_roundtrip() {
521        use arrow::{array::Array, datatypes::DataType};
522        use nautilus_model::data::Data;
523        use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
524
525        let original = BetfairTicker::new(
526            InstrumentId::from("1.234-56789-0.0.BETFAIR"),
527            Some(Decimal::new(15, 1)),
528            Some(Decimal::new(123_456_789_012_345_678, 16)),
529            None,
530            Some(Decimal::new(425, 2)),
531            UnixNanos::from(1_000_000_000),
532            UnixNanos::from(1_000_000_001),
533        );
534        let metadata = original.metadata();
535        let batch =
536            BetfairTicker::encode_batch(&metadata, std::slice::from_ref(&original)).unwrap();
537        let schema = batch.schema();
538
539        assert_eq!(
540            schema
541                .field_with_name("last_traded_price")
542                .unwrap()
543                .data_type(),
544            &DataType::Decimal128(38, 16),
545        );
546        assert!(
547            batch
548                .column_by_name("starting_price_near")
549                .unwrap()
550                .is_null(0)
551        );
552
553        let decoded = BetfairTicker::decode_data_batch(schema.metadata(), batch).unwrap();
554        let Data::Custom(custom) = &decoded[0] else {
555            panic!("expected custom data");
556        };
557        let decoded = custom
558            .data
559            .as_any()
560            .downcast_ref::<BetfairTicker>()
561            .unwrap();
562
563        assert_eq!(decoded, &original);
564    }
565
566    #[rstest]
567    fn test_betfair_starting_price_decimal_json_roundtrip() {
568        let starting_price = BetfairStartingPrice::new(
569            InstrumentId::from("1.234-56789-0.0.BETFAIR"),
570            Decimal::new(573, 2),
571            UnixNanos::from(1_000_000_000u64),
572            UnixNanos::from(1_000_000_000u64),
573        );
574
575        let json = serde_json::to_string(&starting_price).unwrap();
576        let parsed: BetfairStartingPrice = serde_json::from_str(&json).unwrap();
577
578        assert_eq!(parsed.instrument_id, starting_price.instrument_id);
579        assert_eq!(parsed.bsp, Decimal::new(573, 2));
580        assert_eq!(parsed.ts_event, starting_price.ts_event);
581        assert_eq!(parsed.ts_init, starting_price.ts_init);
582    }
583
584    #[rstest]
585    fn test_betfair_bsp_book_delta_decimal_json_roundtrip() {
586        let delta = BetfairBspBookDelta::new(
587            InstrumentId::from("1.234-56789-0.0.BETFAIR"),
588            BookAction::Update,
589            OrderSide::Buy,
590            Decimal::new(1000, 0),
591            Decimal::new(3338, 2),
592            UnixNanos::from(1_000_000_000u64),
593            UnixNanos::from(1_000_000_000u64),
594        );
595
596        let json = serde_json::to_string(&delta).unwrap();
597        let parsed: BetfairBspBookDelta = serde_json::from_str(&json).unwrap();
598
599        assert_eq!(parsed.instrument_id, delta.instrument_id);
600        assert_eq!(parsed.action, delta.action);
601        assert_eq!(parsed.side, delta.side);
602        assert_eq!(parsed.price, Decimal::new(1000, 0));
603        assert_eq!(parsed.size, Decimal::new(3338, 2));
604        assert_eq!(parsed.ts_event, delta.ts_event);
605        assert_eq!(parsed.ts_init, delta.ts_init);
606    }
607
608    #[rstest]
609    fn test_betfair_order_voided_decimal_json_roundtrip() {
610        let voided = BetfairOrderVoided::new(
611            InstrumentId::from("1.234-56789-0.0.BETFAIR"),
612            "client-001".to_string(),
613            "430069890490".to_string(),
614            Decimal::new(40, 0),
615            Decimal::new(25, 1),
616            Decimal::new(100, 0),
617            "BACK".to_string(),
618            Some(Decimal::new(25, 1)),
619            Some(Decimal::new(60, 0)),
620            "VAR".to_string(),
621            UnixNanos::from(1_000_000_000u64),
622            UnixNanos::from(1_000_000_000u64),
623        );
624
625        let json = serde_json::to_string(&voided).unwrap();
626        let parsed: BetfairOrderVoided = serde_json::from_str(&json).unwrap();
627
628        assert_eq!(parsed.instrument_id, voided.instrument_id);
629        assert_eq!(parsed.client_order_id, voided.client_order_id);
630        assert_eq!(parsed.venue_order_id, voided.venue_order_id);
631        assert_eq!(parsed.size_voided, Decimal::new(40, 0));
632        assert_eq!(parsed.price, Decimal::new(25, 1));
633        assert_eq!(parsed.size, Decimal::new(100, 0));
634        assert_eq!(parsed.side, "BACK");
635        assert_eq!(parsed.avg_price_matched, Some(Decimal::new(25, 1)));
636        assert_eq!(parsed.size_matched, Some(Decimal::new(60, 0)));
637        assert_eq!(parsed.reason, "VAR");
638        assert_eq!(parsed.ts_event, voided.ts_event);
639        assert_eq!(parsed.ts_init, voided.ts_init);
640    }
641}