1use nautilus_core::UnixNanos;
29use nautilus_model::identifiers::InstrumentId;
30use nautilus_persistence_macros::custom_data;
31use rust_decimal::Decimal;
32
33mod nan_as_null {
37 pub(super) fn serialize<S: serde::Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
38 if v.is_nan() {
39 s.serialize_none()
40 } else {
41 s.serialize_f64(*v)
42 }
43 }
44
45 pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
46 use serde::Deserialize;
47 Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
48 }
49}
50
51#[custom_data(pyo3)]
56pub struct BetfairTicker {
57 pub instrument_id: InstrumentId,
59 #[custom_data_field(serde)]
61 pub last_traded_price: Option<Decimal>,
62 #[custom_data_field(serde)]
64 pub traded_volume: Option<Decimal>,
65 #[custom_data_field(serde)]
67 pub starting_price_near: Option<Decimal>,
68 #[custom_data_field(serde)]
70 pub starting_price_far: Option<Decimal>,
71 pub ts_event: UnixNanos,
73 pub ts_init: UnixNanos,
75}
76
77#[custom_data(pyo3)]
81pub struct BetfairStartingPrice {
82 pub instrument_id: InstrumentId,
84 #[custom_data_field(serde)]
86 pub bsp: Decimal,
87 pub ts_event: UnixNanos,
89 pub ts_init: UnixNanos,
91}
92
93#[custom_data(pyo3)]
99pub struct BetfairBspBookDelta {
100 pub instrument_id: InstrumentId,
102 pub action: u32,
104 pub side: u32,
106 #[custom_data_field(serde)]
108 pub price: Decimal,
109 #[custom_data_field(serde)]
111 pub size: Decimal,
112 pub ts_event: UnixNanos,
114 pub ts_init: UnixNanos,
116}
117
118#[custom_data(pyo3)]
123pub struct BetfairSequenceCompleted {
124 pub ts_event: UnixNanos,
126 pub ts_init: UnixNanos,
128}
129
130#[custom_data(pyo3)]
135pub struct BetfairOrderVoided {
136 pub instrument_id: InstrumentId,
138 pub client_order_id: String,
140 pub venue_order_id: String,
142 #[custom_data_field(serde)]
144 pub size_voided: Decimal,
145 #[custom_data_field(serde)]
147 pub price: Decimal,
148 #[custom_data_field(serde)]
150 pub size: Decimal,
151 pub side: String,
153 #[custom_data_field(serde)]
155 pub avg_price_matched: Option<Decimal>,
156 #[custom_data_field(serde)]
158 pub size_matched: Option<Decimal>,
159 pub reason: String,
161 pub ts_event: UnixNanos,
163 pub ts_init: UnixNanos,
165}
166
167#[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#[custom_data(pyo3)]
220pub struct BetfairRaceProgress {
221 pub race_id: String,
223 pub market_id: String,
225 pub gate_name: String,
227 #[serde(
229 serialize_with = "nan_as_null::serialize",
230 deserialize_with = "nan_as_null::deserialize"
231 )]
232 pub sectional_time: f64,
233 #[serde(
235 serialize_with = "nan_as_null::serialize",
236 deserialize_with = "nan_as_null::deserialize"
237 )]
238 pub running_time: f64,
239 #[serde(
241 serialize_with = "nan_as_null::serialize",
242 deserialize_with = "nan_as_null::deserialize"
243 )]
244 pub speed: f64,
245 #[serde(
247 serialize_with = "nan_as_null::serialize",
248 deserialize_with = "nan_as_null::deserialize"
249 )]
250 pub progress: f64,
251 pub order: String,
253 pub jumps: String,
255 pub ts_event: UnixNanos,
257 pub ts_init: UnixNanos,
259}
260
261#[custom_data(pyo3)]
262pub struct BetfairCricketMatch {
263 pub event_id: String,
265 pub market_id: String,
267 pub fixture_info: String,
269 pub home_team: String,
271 pub away_team: String,
273 pub match_stats: String,
275 pub incident_list_wrapper: String,
277 pub ts_event: UnixNanos,
279 pub ts_init: UnixNanos,
281}
282
283pub fn register_betfair_custom_data() {
288 nautilus_serialization::ensure_custom_data_registered::<BetfairTicker>();
289 nautilus_serialization::ensure_custom_data_registered::<BetfairStartingPrice>();
290 nautilus_serialization::ensure_custom_data_registered::<BetfairBspBookDelta>();
291 nautilus_serialization::ensure_custom_data_registered::<BetfairSequenceCompleted>();
292 nautilus_serialization::ensure_custom_data_registered::<BetfairOrderVoided>();
293 nautilus_serialization::ensure_custom_data_registered::<BetfairRaceRunnerData>();
294 nautilus_serialization::ensure_custom_data_registered::<BetfairRaceProgress>();
295 nautilus_serialization::ensure_custom_data_registered::<BetfairCricketMatch>();
296}
297
298#[cfg(test)]
299mod tests {
300 use nautilus_serialization::arrow::ArrowSchemaProvider;
301 use rstest::rstest;
302 use rust_decimal::Decimal;
303
304 use super::*;
305
306 #[rstest]
307 fn test_betfair_ticker_schema() {
308 let schema = BetfairTicker::get_schema(None);
309 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
310 assert!(field_names.contains(&"instrument_id".to_string()));
311 assert!(field_names.contains(&"last_traded_price".to_string()));
312 assert!(field_names.contains(&"traded_volume".to_string()));
313 assert!(field_names.contains(&"starting_price_near".to_string()));
314 assert!(field_names.contains(&"starting_price_far".to_string()));
315 assert!(field_names.contains(&"ts_event".to_string()));
316 assert!(field_names.contains(&"ts_init".to_string()));
317 }
318
319 #[rstest]
320 fn test_betfair_starting_price_schema() {
321 let schema = BetfairStartingPrice::get_schema(None);
322 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
323 assert!(field_names.contains(&"instrument_id".to_string()));
324 assert!(field_names.contains(&"bsp".to_string()));
325 assert!(field_names.contains(&"ts_event".to_string()));
326 assert!(field_names.contains(&"ts_init".to_string()));
327 }
328
329 #[rstest]
330 fn test_betfair_bsp_book_delta_schema() {
331 let schema = BetfairBspBookDelta::get_schema(None);
332 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
333 assert!(field_names.contains(&"instrument_id".to_string()));
334 assert!(field_names.contains(&"action".to_string()));
335 assert!(field_names.contains(&"side".to_string()));
336 assert!(field_names.contains(&"price".to_string()));
337 assert!(field_names.contains(&"size".to_string()));
338 assert!(field_names.contains(&"ts_event".to_string()));
339 assert!(field_names.contains(&"ts_init".to_string()));
340 }
341
342 #[rstest]
343 fn test_betfair_sequence_completed_schema() {
344 let schema = BetfairSequenceCompleted::get_schema(None);
345 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
346 assert!(field_names.contains(&"ts_event".to_string()));
347 assert!(field_names.contains(&"ts_init".to_string()));
348 }
349
350 #[rstest]
351 fn test_betfair_order_voided_schema() {
352 let schema = BetfairOrderVoided::get_schema(None);
353 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
354 assert!(field_names.contains(&"instrument_id".to_string()));
355 assert!(field_names.contains(&"client_order_id".to_string()));
356 assert!(field_names.contains(&"venue_order_id".to_string()));
357 assert!(field_names.contains(&"size_voided".to_string()));
358 assert!(field_names.contains(&"reason".to_string()));
359 }
360
361 #[rstest]
362 fn test_register_betfair_custom_data_is_idempotent() {
363 register_betfair_custom_data();
364 register_betfair_custom_data();
365 }
366
367 #[rstest]
368 fn test_betfair_race_runner_data_schema() {
369 let schema = BetfairRaceRunnerData::get_schema(None);
370 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
371 assert!(field_names.contains(&"race_id".to_string()));
372 assert!(field_names.contains(&"market_id".to_string()));
373 assert!(field_names.contains(&"selection_id".to_string()));
374 assert!(field_names.contains(&"latitude".to_string()));
375 assert!(field_names.contains(&"longitude".to_string()));
376 assert!(field_names.contains(&"speed".to_string()));
377 assert!(field_names.contains(&"progress".to_string()));
378 assert!(field_names.contains(&"stride_frequency".to_string()));
379 }
380
381 #[rstest]
382 fn test_betfair_race_progress_schema() {
383 let schema = BetfairRaceProgress::get_schema(None);
384 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
385 assert!(field_names.contains(&"race_id".to_string()));
386 assert!(field_names.contains(&"market_id".to_string()));
387 assert!(field_names.contains(&"gate_name".to_string()));
388 assert!(field_names.contains(&"sectional_time".to_string()));
389 assert!(field_names.contains(&"running_time".to_string()));
390 assert!(field_names.contains(&"speed".to_string()));
391 assert!(field_names.contains(&"progress".to_string()));
392 assert!(field_names.contains(&"order".to_string()));
393 assert!(field_names.contains(&"jumps".to_string()));
394 }
395
396 #[rstest]
397 fn test_betfair_cricket_match_schema() {
398 let schema = BetfairCricketMatch::get_schema(None);
399 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
400 assert!(field_names.contains(&"event_id".to_string()));
401 assert!(field_names.contains(&"market_id".to_string()));
402 assert!(field_names.contains(&"match_stats".to_string()));
403 assert!(field_names.contains(&"incident_list_wrapper".to_string()));
404 }
405
406 #[rstest]
407 fn test_race_runner_data_nan_json_roundtrip() {
408 let data = BetfairRaceRunnerData::new(
409 "28587288.1650".to_string(),
410 "1.1234567".to_string(),
411 7390417,
412 51.4189543,
413 -0.4058491,
414 17.8,
415 f64::NAN,
416 f64::NAN,
417 UnixNanos::from(1_000_000_000u64),
418 UnixNanos::from(1_000_000_000u64),
419 );
420
421 let json = serde_json::to_string(&data).unwrap();
422 assert!(json.contains("\"progress\":null"));
423 assert!(json.contains("\"stride_frequency\":null"));
424 assert!(json.contains("\"latitude\":51.4189543"));
425
426 let parsed: BetfairRaceRunnerData = serde_json::from_str(&json).unwrap();
427 assert!(parsed.progress.is_nan());
428 assert!(parsed.stride_frequency.is_nan());
429 assert_eq!(parsed.latitude, 51.4189543);
430 assert_eq!(parsed.selection_id, 7390417);
431 }
432
433 #[rstest]
434 fn test_betfair_ticker_optional_decimal_json_roundtrip() {
435 let ticker = BetfairTicker::new(
436 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
437 Some(Decimal::new(15, 1)),
438 Some(Decimal::new(100, 0)),
439 None,
440 None,
441 UnixNanos::from(1_000_000_000u64),
442 UnixNanos::from(1_000_000_000u64),
443 );
444
445 let json = serde_json::to_string(&ticker).unwrap();
446 assert!(json.contains("\"starting_price_near\":null"));
447 assert!(json.contains("\"starting_price_far\":null"));
448
449 let parsed: BetfairTicker = serde_json::from_str(&json).unwrap();
450 assert!(parsed.starting_price_near.is_none());
451 assert!(parsed.starting_price_far.is_none());
452 assert_eq!(parsed.last_traded_price, Some(Decimal::new(15, 1)));
453 assert_eq!(parsed.traded_volume, Some(Decimal::new(100, 0)));
454 }
455
456 #[rstest]
457 fn test_betfair_starting_price_decimal_json_roundtrip() {
458 let starting_price = BetfairStartingPrice::new(
459 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
460 Decimal::new(573, 2),
461 UnixNanos::from(1_000_000_000u64),
462 UnixNanos::from(1_000_000_000u64),
463 );
464
465 let json = serde_json::to_string(&starting_price).unwrap();
466 let parsed: BetfairStartingPrice = serde_json::from_str(&json).unwrap();
467
468 assert_eq!(parsed.instrument_id, starting_price.instrument_id);
469 assert_eq!(parsed.bsp, Decimal::new(573, 2));
470 assert_eq!(parsed.ts_event, starting_price.ts_event);
471 assert_eq!(parsed.ts_init, starting_price.ts_init);
472 }
473
474 #[rstest]
475 fn test_betfair_bsp_book_delta_decimal_json_roundtrip() {
476 let delta = BetfairBspBookDelta::new(
477 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
478 2,
479 1,
480 Decimal::new(1000, 0),
481 Decimal::new(3338, 2),
482 UnixNanos::from(1_000_000_000u64),
483 UnixNanos::from(1_000_000_000u64),
484 );
485
486 let json = serde_json::to_string(&delta).unwrap();
487 let parsed: BetfairBspBookDelta = serde_json::from_str(&json).unwrap();
488
489 assert_eq!(parsed.instrument_id, delta.instrument_id);
490 assert_eq!(parsed.action, delta.action);
491 assert_eq!(parsed.side, delta.side);
492 assert_eq!(parsed.price, Decimal::new(1000, 0));
493 assert_eq!(parsed.size, Decimal::new(3338, 2));
494 assert_eq!(parsed.ts_event, delta.ts_event);
495 assert_eq!(parsed.ts_init, delta.ts_init);
496 }
497
498 #[rstest]
499 fn test_betfair_order_voided_decimal_json_roundtrip() {
500 let voided = BetfairOrderVoided::new(
501 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
502 "client-001".to_string(),
503 "430069890490".to_string(),
504 Decimal::new(40, 0),
505 Decimal::new(25, 1),
506 Decimal::new(100, 0),
507 "BACK".to_string(),
508 Some(Decimal::new(25, 1)),
509 Some(Decimal::new(60, 0)),
510 "VAR".to_string(),
511 UnixNanos::from(1_000_000_000u64),
512 UnixNanos::from(1_000_000_000u64),
513 );
514
515 let json = serde_json::to_string(&voided).unwrap();
516 let parsed: BetfairOrderVoided = serde_json::from_str(&json).unwrap();
517
518 assert_eq!(parsed.instrument_id, voided.instrument_id);
519 assert_eq!(parsed.client_order_id, voided.client_order_id);
520 assert_eq!(parsed.venue_order_id, voided.venue_order_id);
521 assert_eq!(parsed.size_voided, Decimal::new(40, 0));
522 assert_eq!(parsed.price, Decimal::new(25, 1));
523 assert_eq!(parsed.size, Decimal::new(100, 0));
524 assert_eq!(parsed.side, "BACK");
525 assert_eq!(parsed.avg_price_matched, Some(Decimal::new(25, 1)));
526 assert_eq!(parsed.size_matched, Some(Decimal::new(60, 0)));
527 assert_eq!(parsed.reason, "VAR");
528 assert_eq!(parsed.ts_event, voided.ts_event);
529 assert_eq!(parsed.ts_init, voided.ts_init);
530 }
531}