1use nautilus_core::UnixNanos;
29use nautilus_model::{
30 custom_data,
31 enums::{BookAction, OrderSide},
32 identifiers::InstrumentId,
33};
34use nautilus_serialization::arrow_custom_data;
35use rust_decimal::Decimal;
36
37mod nan_as_null {
41 pub(super) fn serialize<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
42 if v.is_nan() {
43 s.serialize_none()
44 } else {
45 s.serialize_f64(*v)
46 }
47 }
48
49 pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
50 use serde::Deserialize;
51 Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
52 }
53}
54
55#[arrow_custom_data(pyo3)]
60#[custom_data(pyo3)]
61pub struct BetfairTicker {
62 pub instrument_id: InstrumentId,
64 pub last_traded_price: Option<Decimal>,
66 pub traded_volume: Option<Decimal>,
68 pub starting_price_near: Option<Decimal>,
70 pub starting_price_far: Option<Decimal>,
72 pub ts_event: UnixNanos,
74 pub ts_init: UnixNanos,
76}
77
78#[arrow_custom_data(pyo3)]
82#[custom_data(pyo3)]
83pub struct BetfairStartingPrice {
84 pub instrument_id: InstrumentId,
86 pub bsp: Decimal,
88 pub ts_event: UnixNanos,
90 pub ts_init: UnixNanos,
92}
93
94#[arrow_custom_data(pyo3)]
100#[custom_data(pyo3)]
101pub struct BetfairBspBookDelta {
102 pub instrument_id: InstrumentId,
104 #[custom_data_field(native_enum)]
106 pub action: BookAction,
107 #[custom_data_field(native_enum)]
109 pub side: OrderSide,
110 pub price: Decimal,
112 pub size: Decimal,
114 pub ts_event: UnixNanos,
116 pub ts_init: UnixNanos,
118}
119
120#[arrow_custom_data(pyo3)]
125#[custom_data(pyo3)]
126pub struct BetfairSequenceCompleted {
127 pub ts_event: UnixNanos,
129 pub ts_init: UnixNanos,
131}
132
133#[arrow_custom_data(pyo3)]
138#[custom_data(pyo3)]
139pub struct BetfairOrderVoided {
140 pub instrument_id: InstrumentId,
142 pub client_order_id: String,
144 pub venue_order_id: String,
146 pub size_voided: Decimal,
148 pub price: Decimal,
150 pub size: Decimal,
152 pub side: String,
154 pub avg_price_matched: Option<Decimal>,
156 pub size_matched: Option<Decimal>,
158 pub reason: String,
160 pub ts_event: UnixNanos,
162 pub ts_init: UnixNanos,
164}
165
166#[arrow_custom_data(pyo3)]
171#[custom_data(pyo3)]
172pub struct BetfairRaceRunnerData {
173 pub race_id: String,
175 pub market_id: String,
177 pub selection_id: i64,
179 #[serde(
181 serialize_with = "nan_as_null::serialize",
182 deserialize_with = "nan_as_null::deserialize"
183 )]
184 pub latitude: f64,
185 #[serde(
187 serialize_with = "nan_as_null::serialize",
188 deserialize_with = "nan_as_null::deserialize"
189 )]
190 pub longitude: f64,
191 #[serde(
193 serialize_with = "nan_as_null::serialize",
194 deserialize_with = "nan_as_null::deserialize"
195 )]
196 pub speed: f64,
197 #[serde(
199 serialize_with = "nan_as_null::serialize",
200 deserialize_with = "nan_as_null::deserialize"
201 )]
202 pub progress: f64,
203 #[serde(
205 serialize_with = "nan_as_null::serialize",
206 deserialize_with = "nan_as_null::deserialize"
207 )]
208 pub stride_frequency: f64,
209 pub ts_event: UnixNanos,
211 pub ts_init: UnixNanos,
213}
214
215#[arrow_custom_data(pyo3)]
220#[custom_data(pyo3)]
221pub struct BetfairRaceProgress {
222 pub race_id: String,
224 pub market_id: String,
226 pub gate_name: String,
228 #[serde(
230 serialize_with = "nan_as_null::serialize",
231 deserialize_with = "nan_as_null::deserialize"
232 )]
233 pub sectional_time: f64,
234 #[serde(
236 serialize_with = "nan_as_null::serialize",
237 deserialize_with = "nan_as_null::deserialize"
238 )]
239 pub running_time: f64,
240 #[serde(
242 serialize_with = "nan_as_null::serialize",
243 deserialize_with = "nan_as_null::deserialize"
244 )]
245 pub speed: f64,
246 #[serde(
248 serialize_with = "nan_as_null::serialize",
249 deserialize_with = "nan_as_null::deserialize"
250 )]
251 pub progress: f64,
252 pub order: String,
254 pub jumps: String,
256 pub ts_event: UnixNanos,
258 pub ts_init: UnixNanos,
260}
261
262#[arrow_custom_data(pyo3)]
263#[custom_data(pyo3)]
264pub struct BetfairCricketMatch {
265 pub event_id: String,
267 pub market_id: String,
269 pub fixture_info: String,
271 pub home_team: String,
273 pub away_team: String,
275 pub match_stats: String,
277 pub incident_list_wrapper: String,
279 pub ts_event: UnixNanos,
281 pub ts_init: UnixNanos,
283}
284
285pub fn register_betfair_custom_data() {
290 nautilus_serialization::ensure_custom_data_registered::<BetfairTicker>();
291 nautilus_serialization::ensure_custom_data_registered::<BetfairStartingPrice>();
292 nautilus_serialization::ensure_custom_data_registered::<BetfairBspBookDelta>();
293 nautilus_serialization::ensure_custom_data_registered::<BetfairSequenceCompleted>();
294 nautilus_serialization::ensure_custom_data_registered::<BetfairOrderVoided>();
295 nautilus_serialization::ensure_custom_data_registered::<BetfairRaceRunnerData>();
296 nautilus_serialization::ensure_custom_data_registered::<BetfairRaceProgress>();
297 nautilus_serialization::ensure_custom_data_registered::<BetfairCricketMatch>();
298}
299
300#[cfg(test)]
301mod tests {
302 use nautilus_serialization::arrow::ArrowSchemaProvider;
303 use rstest::rstest;
304 use rust_decimal::Decimal;
305
306 use super::*;
307
308 #[rstest]
309 fn test_betfair_ticker_schema() {
310 let schema = BetfairTicker::get_schema(None);
311 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
312 assert!(field_names.contains(&"instrument_id".to_string()));
313 assert!(field_names.contains(&"last_traded_price".to_string()));
314 assert!(field_names.contains(&"traded_volume".to_string()));
315 assert!(field_names.contains(&"starting_price_near".to_string()));
316 assert!(field_names.contains(&"starting_price_far".to_string()));
317 assert!(field_names.contains(&"ts_event".to_string()));
318 assert!(field_names.contains(&"ts_init".to_string()));
319 }
320
321 #[rstest]
322 fn test_betfair_starting_price_schema() {
323 let schema = BetfairStartingPrice::get_schema(None);
324 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
325 assert!(field_names.contains(&"instrument_id".to_string()));
326 assert!(field_names.contains(&"bsp".to_string()));
327 assert!(field_names.contains(&"ts_event".to_string()));
328 assert!(field_names.contains(&"ts_init".to_string()));
329 }
330
331 #[rstest]
332 fn test_betfair_bsp_book_delta_schema() {
333 let schema = BetfairBspBookDelta::get_schema(None);
334 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
335 assert!(field_names.contains(&"instrument_id".to_string()));
336 assert!(field_names.contains(&"action".to_string()));
337 assert!(field_names.contains(&"side".to_string()));
338 assert!(field_names.contains(&"price".to_string()));
339 assert!(field_names.contains(&"size".to_string()));
340 assert!(field_names.contains(&"ts_event".to_string()));
341 assert!(field_names.contains(&"ts_init".to_string()));
342 assert_eq!(
343 schema.field_with_name("action").unwrap().data_type(),
344 &nautilus_serialization::arrow::enum_dictionary_data_type(),
345 );
346 assert_eq!(
347 schema.field_with_name("side").unwrap().data_type(),
348 &nautilus_serialization::arrow::enum_dictionary_data_type(),
349 );
350 }
351
352 #[rstest]
353 fn test_betfair_sequence_completed_schema() {
354 let schema = BetfairSequenceCompleted::get_schema(None);
355 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
356 assert!(field_names.contains(&"ts_event".to_string()));
357 assert!(field_names.contains(&"ts_init".to_string()));
358 }
359
360 #[rstest]
361 fn test_betfair_order_voided_schema() {
362 let schema = BetfairOrderVoided::get_schema(None);
363 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
364 assert!(field_names.contains(&"instrument_id".to_string()));
365 assert!(field_names.contains(&"client_order_id".to_string()));
366 assert!(field_names.contains(&"venue_order_id".to_string()));
367 assert!(field_names.contains(&"size_voided".to_string()));
368 assert!(field_names.contains(&"reason".to_string()));
369 }
370
371 #[rstest]
372 fn test_register_betfair_custom_data_is_idempotent() {
373 register_betfair_custom_data();
374 register_betfair_custom_data();
375 }
376
377 #[rstest]
378 fn test_betfair_race_runner_data_schema() {
379 let schema = BetfairRaceRunnerData::get_schema(None);
380 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
381 assert!(field_names.contains(&"race_id".to_string()));
382 assert!(field_names.contains(&"market_id".to_string()));
383 assert!(field_names.contains(&"selection_id".to_string()));
384 assert!(field_names.contains(&"latitude".to_string()));
385 assert!(field_names.contains(&"longitude".to_string()));
386 assert!(field_names.contains(&"speed".to_string()));
387 assert!(field_names.contains(&"progress".to_string()));
388 assert!(field_names.contains(&"stride_frequency".to_string()));
389 }
390
391 #[rstest]
392 fn test_betfair_race_progress_schema() {
393 let schema = BetfairRaceProgress::get_schema(None);
394 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
395 assert!(field_names.contains(&"race_id".to_string()));
396 assert!(field_names.contains(&"market_id".to_string()));
397 assert!(field_names.contains(&"gate_name".to_string()));
398 assert!(field_names.contains(&"sectional_time".to_string()));
399 assert!(field_names.contains(&"running_time".to_string()));
400 assert!(field_names.contains(&"speed".to_string()));
401 assert!(field_names.contains(&"progress".to_string()));
402 assert!(field_names.contains(&"order".to_string()));
403 assert!(field_names.contains(&"jumps".to_string()));
404 }
405
406 #[rstest]
407 fn test_betfair_cricket_match_schema() {
408 let schema = BetfairCricketMatch::get_schema(None);
409 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
410 assert!(field_names.contains(&"event_id".to_string()));
411 assert!(field_names.contains(&"market_id".to_string()));
412 assert!(field_names.contains(&"match_stats".to_string()));
413 assert!(field_names.contains(&"incident_list_wrapper".to_string()));
414 }
415
416 #[rstest]
417 fn test_race_runner_data_nan_json_roundtrip() {
418 let data = BetfairRaceRunnerData::new(
419 "28587288.1650".to_string(),
420 "1.1234567".to_string(),
421 7390417,
422 51.4189543,
423 -0.4058491,
424 17.8,
425 f64::NAN,
426 f64::NAN,
427 UnixNanos::from(1_000_000_000u64),
428 UnixNanos::from(1_000_000_000u64),
429 );
430
431 let json = serde_json::to_string(&data).unwrap();
432 assert!(json.contains("\"progress\":null"));
433 assert!(json.contains("\"stride_frequency\":null"));
434 assert!(json.contains("\"latitude\":51.4189543"));
435
436 let parsed: BetfairRaceRunnerData = serde_json::from_str(&json).unwrap();
437 assert!(parsed.progress.is_nan());
438 assert!(parsed.stride_frequency.is_nan());
439 assert_eq!(parsed.latitude, 51.4189543);
440 assert_eq!(parsed.selection_id, 7390417);
441 }
442
443 #[rstest]
444 fn test_betfair_ticker_optional_decimal_json_roundtrip() {
445 let ticker = BetfairTicker::new(
446 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
447 Some(Decimal::new(15, 1)),
448 Some(Decimal::new(100, 0)),
449 None,
450 None,
451 UnixNanos::from(1_000_000_000u64),
452 UnixNanos::from(1_000_000_000u64),
453 );
454
455 let json = serde_json::to_string(&ticker).unwrap();
456 assert!(json.contains("\"starting_price_near\":null"));
457 assert!(json.contains("\"starting_price_far\":null"));
458
459 let parsed: BetfairTicker = serde_json::from_str(&json).unwrap();
460 assert!(parsed.starting_price_near.is_none());
461 assert!(parsed.starting_price_far.is_none());
462 assert_eq!(parsed.last_traded_price, Some(Decimal::new(15, 1)));
463 assert_eq!(parsed.traded_volume, Some(Decimal::new(100, 0)));
464 }
465
466 #[rstest]
467 fn test_betfair_ticker_optional_decimal_arrow_roundtrip() {
468 use arrow::{array::Array, datatypes::DataType};
469 use nautilus_model::data::Data;
470 use nautilus_serialization::arrow::{DecodeDataFromRecordBatch, EncodeToRecordBatch};
471
472 let original = BetfairTicker::new(
473 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
474 Some(Decimal::new(15, 1)),
475 Some(Decimal::new(123_456_789_012_345_678, 16)),
476 None,
477 Some(Decimal::new(425, 2)),
478 UnixNanos::from(1_000_000_000),
479 UnixNanos::from(1_000_000_001),
480 );
481 let metadata = original.metadata();
482 let batch =
483 BetfairTicker::encode_batch(&metadata, std::slice::from_ref(&original)).unwrap();
484 let schema = batch.schema();
485
486 assert_eq!(
487 schema
488 .field_with_name("last_traded_price")
489 .unwrap()
490 .data_type(),
491 &DataType::Decimal128(38, 16),
492 );
493 assert!(
494 batch
495 .column_by_name("starting_price_near")
496 .unwrap()
497 .is_null(0)
498 );
499
500 let decoded = BetfairTicker::decode_data_batch(schema.metadata(), batch).unwrap();
501 let Data::Custom(custom) = &decoded[0] else {
502 panic!("expected custom data");
503 };
504 let decoded = custom
505 .data
506 .as_any()
507 .downcast_ref::<BetfairTicker>()
508 .unwrap();
509
510 assert_eq!(decoded, &original);
511 }
512
513 #[rstest]
514 fn test_betfair_starting_price_decimal_json_roundtrip() {
515 let starting_price = BetfairStartingPrice::new(
516 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
517 Decimal::new(573, 2),
518 UnixNanos::from(1_000_000_000u64),
519 UnixNanos::from(1_000_000_000u64),
520 );
521
522 let json = serde_json::to_string(&starting_price).unwrap();
523 let parsed: BetfairStartingPrice = serde_json::from_str(&json).unwrap();
524
525 assert_eq!(parsed.instrument_id, starting_price.instrument_id);
526 assert_eq!(parsed.bsp, Decimal::new(573, 2));
527 assert_eq!(parsed.ts_event, starting_price.ts_event);
528 assert_eq!(parsed.ts_init, starting_price.ts_init);
529 }
530
531 #[rstest]
532 fn test_betfair_bsp_book_delta_decimal_json_roundtrip() {
533 let delta = BetfairBspBookDelta::new(
534 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
535 BookAction::Update,
536 OrderSide::Buy,
537 Decimal::new(1000, 0),
538 Decimal::new(3338, 2),
539 UnixNanos::from(1_000_000_000u64),
540 UnixNanos::from(1_000_000_000u64),
541 );
542
543 let json = serde_json::to_string(&delta).unwrap();
544 let parsed: BetfairBspBookDelta = serde_json::from_str(&json).unwrap();
545
546 assert_eq!(parsed.instrument_id, delta.instrument_id);
547 assert_eq!(parsed.action, delta.action);
548 assert_eq!(parsed.side, delta.side);
549 assert_eq!(parsed.price, Decimal::new(1000, 0));
550 assert_eq!(parsed.size, Decimal::new(3338, 2));
551 assert_eq!(parsed.ts_event, delta.ts_event);
552 assert_eq!(parsed.ts_init, delta.ts_init);
553 }
554
555 #[rstest]
556 fn test_betfair_order_voided_decimal_json_roundtrip() {
557 let voided = BetfairOrderVoided::new(
558 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
559 "client-001".to_string(),
560 "430069890490".to_string(),
561 Decimal::new(40, 0),
562 Decimal::new(25, 1),
563 Decimal::new(100, 0),
564 "BACK".to_string(),
565 Some(Decimal::new(25, 1)),
566 Some(Decimal::new(60, 0)),
567 "VAR".to_string(),
568 UnixNanos::from(1_000_000_000u64),
569 UnixNanos::from(1_000_000_000u64),
570 );
571
572 let json = serde_json::to_string(&voided).unwrap();
573 let parsed: BetfairOrderVoided = serde_json::from_str(&json).unwrap();
574
575 assert_eq!(parsed.instrument_id, voided.instrument_id);
576 assert_eq!(parsed.client_order_id, voided.client_order_id);
577 assert_eq!(parsed.venue_order_id, voided.venue_order_id);
578 assert_eq!(parsed.size_voided, Decimal::new(40, 0));
579 assert_eq!(parsed.price, Decimal::new(25, 1));
580 assert_eq!(parsed.size, Decimal::new(100, 0));
581 assert_eq!(parsed.side, "BACK");
582 assert_eq!(parsed.avg_price_matched, Some(Decimal::new(25, 1)));
583 assert_eq!(parsed.size_matched, Some(Decimal::new(60, 0)));
584 assert_eq!(parsed.reason, "VAR");
585 assert_eq!(parsed.ts_event, voided.ts_event);
586 assert_eq!(parsed.ts_init, voided.ts_init);
587 }
588}