1use 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
39mod 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#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
62#[custom_data(pyo3)]
63pub struct BetfairTicker {
64 pub instrument_id: InstrumentId,
66 pub last_traded_price: Option<Decimal>,
68 pub traded_volume: Option<Decimal>,
70 pub starting_price_near: Option<Decimal>,
72 pub starting_price_far: Option<Decimal>,
74 pub ts_event: UnixNanos,
76 pub ts_init: UnixNanos,
78}
79
80#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
84#[custom_data(pyo3)]
85pub struct BetfairStartingPrice {
86 pub instrument_id: InstrumentId,
88 pub bsp: Decimal,
90 pub ts_event: UnixNanos,
92 pub ts_init: UnixNanos,
94}
95
96#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
102#[custom_data(pyo3)]
103pub struct BetfairBspBookDelta {
104 pub instrument_id: InstrumentId,
106 #[custom_data_field(native_enum)]
108 pub action: BookAction,
109 #[custom_data_field(native_enum)]
111 pub side: OrderSide,
112 pub price: Decimal,
114 pub size: Decimal,
116 pub ts_event: UnixNanos,
118 pub ts_init: UnixNanos,
120}
121
122#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
127#[custom_data(pyo3)]
128pub struct BetfairSequenceCompleted {
129 pub ts_event: UnixNanos,
131 pub ts_init: UnixNanos,
133}
134
135#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
140#[custom_data(pyo3)]
141pub struct BetfairOrderVoided {
142 pub instrument_id: InstrumentId,
144 pub client_order_id: String,
146 pub venue_order_id: String,
148 pub size_voided: Decimal,
150 pub price: Decimal,
152 pub size: Decimal,
154 pub side: String,
156 pub avg_price_matched: Option<Decimal>,
158 pub size_matched: Option<Decimal>,
160 pub reason: String,
162 pub ts_event: UnixNanos,
164 pub ts_init: UnixNanos,
166}
167
168#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
173#[custom_data(pyo3)]
174pub struct BetfairRaceRunnerData {
175 pub race_id: String,
177 pub market_id: String,
179 pub selection_id: i64,
181 #[serde(
183 serialize_with = "nan_as_null::serialize",
184 deserialize_with = "nan_as_null::deserialize"
185 )]
186 pub latitude: f64,
187 #[serde(
189 serialize_with = "nan_as_null::serialize",
190 deserialize_with = "nan_as_null::deserialize"
191 )]
192 pub longitude: f64,
193 #[serde(
195 serialize_with = "nan_as_null::serialize",
196 deserialize_with = "nan_as_null::deserialize"
197 )]
198 pub speed: f64,
199 #[serde(
201 serialize_with = "nan_as_null::serialize",
202 deserialize_with = "nan_as_null::deserialize"
203 )]
204 pub progress: f64,
205 #[serde(
207 serialize_with = "nan_as_null::serialize",
208 deserialize_with = "nan_as_null::deserialize"
209 )]
210 pub stride_frequency: f64,
211 pub ts_event: UnixNanos,
213 pub ts_init: UnixNanos,
215}
216
217#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
222#[custom_data(pyo3)]
223pub struct BetfairRaceProgress {
224 pub race_id: String,
226 pub market_id: String,
228 pub gate_name: String,
230 #[serde(
232 serialize_with = "nan_as_null::serialize",
233 deserialize_with = "nan_as_null::deserialize"
234 )]
235 pub sectional_time: f64,
236 #[serde(
238 serialize_with = "nan_as_null::serialize",
239 deserialize_with = "nan_as_null::deserialize"
240 )]
241 pub running_time: f64,
242 #[serde(
244 serialize_with = "nan_as_null::serialize",
245 deserialize_with = "nan_as_null::deserialize"
246 )]
247 pub speed: f64,
248 #[serde(
250 serialize_with = "nan_as_null::serialize",
251 deserialize_with = "nan_as_null::deserialize"
252 )]
253 pub progress: f64,
254 pub order: String,
256 pub jumps: String,
258 pub ts_event: UnixNanos,
260 pub ts_init: UnixNanos,
262}
263
264#[cfg_attr(feature = "arrow", arrow_custom_data(pyo3))]
265#[custom_data(pyo3)]
266pub struct BetfairCricketMatch {
267 pub event_id: String,
269 pub market_id: String,
271 pub fixture_info: String,
273 pub home_team: String,
275 pub away_team: String,
277 pub match_stats: String,
279 pub incident_list_wrapper: String,
281 pub ts_event: UnixNanos,
283 pub ts_init: UnixNanos,
285}
286
287pub 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}