1use nautilus_core::UnixNanos;
30use nautilus_model::identifiers::InstrumentId;
31use nautilus_persistence_macros::custom_data;
32
33mod nan_as_null {
37 pub 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 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 #[serde(
61 serialize_with = "nan_as_null::serialize",
62 deserialize_with = "nan_as_null::deserialize"
63 )]
64 pub last_traded_price: f64,
65 #[serde(
67 serialize_with = "nan_as_null::serialize",
68 deserialize_with = "nan_as_null::deserialize"
69 )]
70 pub traded_volume: f64,
71 #[serde(
73 serialize_with = "nan_as_null::serialize",
74 deserialize_with = "nan_as_null::deserialize"
75 )]
76 pub starting_price_near: f64,
77 #[serde(
79 serialize_with = "nan_as_null::serialize",
80 deserialize_with = "nan_as_null::deserialize"
81 )]
82 pub starting_price_far: f64,
83 pub ts_event: UnixNanos,
85 pub ts_init: UnixNanos,
87}
88
89#[custom_data(pyo3)]
93pub struct BetfairStartingPrice {
94 pub instrument_id: InstrumentId,
96 pub bsp: f64,
98 pub ts_event: UnixNanos,
100 pub ts_init: UnixNanos,
102}
103
104#[custom_data(pyo3)]
110pub struct BetfairBspBookDelta {
111 pub instrument_id: InstrumentId,
113 pub action: u32,
115 pub side: u32,
117 pub price: f64,
119 pub size: f64,
121 pub ts_event: UnixNanos,
123 pub ts_init: UnixNanos,
125}
126
127#[custom_data(pyo3)]
132pub struct BetfairSequenceCompleted {
133 pub ts_event: UnixNanos,
135 pub ts_init: UnixNanos,
137}
138
139#[custom_data(pyo3)]
144pub struct BetfairOrderVoided {
145 pub instrument_id: InstrumentId,
147 pub client_order_id: String,
149 pub venue_order_id: String,
151 pub size_voided: f64,
153 pub price: f64,
155 pub size: f64,
157 pub side: String,
159 #[serde(
161 serialize_with = "nan_as_null::serialize",
162 deserialize_with = "nan_as_null::deserialize"
163 )]
164 pub avg_price_matched: f64,
165 #[serde(
167 serialize_with = "nan_as_null::serialize",
168 deserialize_with = "nan_as_null::deserialize"
169 )]
170 pub size_matched: f64,
171 pub reason: String,
173 pub ts_event: UnixNanos,
175 pub ts_init: UnixNanos,
177}
178
179#[custom_data(pyo3)]
184pub struct BetfairRaceRunnerData {
185 pub race_id: String,
187 pub market_id: String,
189 pub selection_id: i64,
191 #[serde(
193 serialize_with = "nan_as_null::serialize",
194 deserialize_with = "nan_as_null::deserialize"
195 )]
196 pub latitude: f64,
197 #[serde(
199 serialize_with = "nan_as_null::serialize",
200 deserialize_with = "nan_as_null::deserialize"
201 )]
202 pub longitude: f64,
203 #[serde(
205 serialize_with = "nan_as_null::serialize",
206 deserialize_with = "nan_as_null::deserialize"
207 )]
208 pub speed: f64,
209 #[serde(
211 serialize_with = "nan_as_null::serialize",
212 deserialize_with = "nan_as_null::deserialize"
213 )]
214 pub progress: f64,
215 #[serde(
217 serialize_with = "nan_as_null::serialize",
218 deserialize_with = "nan_as_null::deserialize"
219 )]
220 pub stride_frequency: f64,
221 pub ts_event: UnixNanos,
223 pub ts_init: UnixNanos,
225}
226
227#[custom_data(pyo3)]
232pub struct BetfairRaceProgress {
233 pub race_id: String,
235 pub market_id: String,
237 pub gate_name: String,
239 #[serde(
241 serialize_with = "nan_as_null::serialize",
242 deserialize_with = "nan_as_null::deserialize"
243 )]
244 pub sectional_time: f64,
245 #[serde(
247 serialize_with = "nan_as_null::serialize",
248 deserialize_with = "nan_as_null::deserialize"
249 )]
250 pub running_time: f64,
251 #[serde(
253 serialize_with = "nan_as_null::serialize",
254 deserialize_with = "nan_as_null::deserialize"
255 )]
256 pub speed: f64,
257 #[serde(
259 serialize_with = "nan_as_null::serialize",
260 deserialize_with = "nan_as_null::deserialize"
261 )]
262 pub progress: f64,
263 pub order: String,
265 pub jumps: String,
267 pub ts_event: UnixNanos,
269 pub ts_init: UnixNanos,
271}
272
273pub fn register_betfair_custom_data() {
278 nautilus_serialization::ensure_custom_data_registered::<BetfairTicker>();
279 nautilus_serialization::ensure_custom_data_registered::<BetfairStartingPrice>();
280 nautilus_serialization::ensure_custom_data_registered::<BetfairBspBookDelta>();
281 nautilus_serialization::ensure_custom_data_registered::<BetfairSequenceCompleted>();
282 nautilus_serialization::ensure_custom_data_registered::<BetfairOrderVoided>();
283 nautilus_serialization::ensure_custom_data_registered::<BetfairRaceRunnerData>();
284 nautilus_serialization::ensure_custom_data_registered::<BetfairRaceProgress>();
285}
286
287#[cfg(test)]
288mod tests {
289 use nautilus_serialization::arrow::ArrowSchemaProvider;
290 use rstest::rstest;
291
292 use super::*;
293
294 #[rstest]
295 fn test_betfair_ticker_schema() {
296 let schema = BetfairTicker::get_schema(None);
297 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
298 assert!(field_names.contains(&"instrument_id".to_string()));
299 assert!(field_names.contains(&"last_traded_price".to_string()));
300 assert!(field_names.contains(&"traded_volume".to_string()));
301 assert!(field_names.contains(&"starting_price_near".to_string()));
302 assert!(field_names.contains(&"starting_price_far".to_string()));
303 assert!(field_names.contains(&"ts_event".to_string()));
304 assert!(field_names.contains(&"ts_init".to_string()));
305 }
306
307 #[rstest]
308 fn test_betfair_starting_price_schema() {
309 let schema = BetfairStartingPrice::get_schema(None);
310 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
311 assert!(field_names.contains(&"instrument_id".to_string()));
312 assert!(field_names.contains(&"bsp".to_string()));
313 assert!(field_names.contains(&"ts_event".to_string()));
314 assert!(field_names.contains(&"ts_init".to_string()));
315 }
316
317 #[rstest]
318 fn test_betfair_bsp_book_delta_schema() {
319 let schema = BetfairBspBookDelta::get_schema(None);
320 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
321 assert!(field_names.contains(&"instrument_id".to_string()));
322 assert!(field_names.contains(&"action".to_string()));
323 assert!(field_names.contains(&"side".to_string()));
324 assert!(field_names.contains(&"price".to_string()));
325 assert!(field_names.contains(&"size".to_string()));
326 assert!(field_names.contains(&"ts_event".to_string()));
327 assert!(field_names.contains(&"ts_init".to_string()));
328 }
329
330 #[rstest]
331 fn test_betfair_sequence_completed_schema() {
332 let schema = BetfairSequenceCompleted::get_schema(None);
333 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
334 assert!(field_names.contains(&"ts_event".to_string()));
335 assert!(field_names.contains(&"ts_init".to_string()));
336 }
337
338 #[rstest]
339 fn test_betfair_order_voided_schema() {
340 let schema = BetfairOrderVoided::get_schema(None);
341 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
342 assert!(field_names.contains(&"instrument_id".to_string()));
343 assert!(field_names.contains(&"client_order_id".to_string()));
344 assert!(field_names.contains(&"venue_order_id".to_string()));
345 assert!(field_names.contains(&"size_voided".to_string()));
346 assert!(field_names.contains(&"reason".to_string()));
347 }
348
349 #[rstest]
350 fn test_register_betfair_custom_data_is_idempotent() {
351 register_betfair_custom_data();
352 register_betfair_custom_data();
353 }
354
355 #[rstest]
356 fn test_betfair_race_runner_data_schema() {
357 let schema = BetfairRaceRunnerData::get_schema(None);
358 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
359 assert!(field_names.contains(&"race_id".to_string()));
360 assert!(field_names.contains(&"market_id".to_string()));
361 assert!(field_names.contains(&"selection_id".to_string()));
362 assert!(field_names.contains(&"latitude".to_string()));
363 assert!(field_names.contains(&"longitude".to_string()));
364 assert!(field_names.contains(&"speed".to_string()));
365 assert!(field_names.contains(&"progress".to_string()));
366 assert!(field_names.contains(&"stride_frequency".to_string()));
367 }
368
369 #[rstest]
370 fn test_betfair_race_progress_schema() {
371 let schema = BetfairRaceProgress::get_schema(None);
372 let field_names: Vec<_> = schema.fields().iter().map(|f| f.name().clone()).collect();
373 assert!(field_names.contains(&"race_id".to_string()));
374 assert!(field_names.contains(&"market_id".to_string()));
375 assert!(field_names.contains(&"gate_name".to_string()));
376 assert!(field_names.contains(&"sectional_time".to_string()));
377 assert!(field_names.contains(&"running_time".to_string()));
378 assert!(field_names.contains(&"speed".to_string()));
379 assert!(field_names.contains(&"progress".to_string()));
380 assert!(field_names.contains(&"order".to_string()));
381 assert!(field_names.contains(&"jumps".to_string()));
382 }
383
384 #[rstest]
385 fn test_race_runner_data_nan_json_roundtrip() {
386 let data = BetfairRaceRunnerData::new(
387 "28587288.1650".to_string(),
388 "1.1234567".to_string(),
389 7390417,
390 51.4189543,
391 -0.4058491,
392 17.8,
393 f64::NAN,
394 f64::NAN,
395 UnixNanos::from(1_000_000_000u64),
396 UnixNanos::from(1_000_000_000u64),
397 );
398
399 let json = serde_json::to_string(&data).unwrap();
400 assert!(json.contains("\"progress\":null"));
401 assert!(json.contains("\"stride_frequency\":null"));
402 assert!(json.contains("\"latitude\":51.4189543"));
403
404 let parsed: BetfairRaceRunnerData = serde_json::from_str(&json).unwrap();
405 assert!(parsed.progress.is_nan());
406 assert!(parsed.stride_frequency.is_nan());
407 assert_eq!(parsed.latitude, 51.4189543);
408 assert_eq!(parsed.selection_id, 7390417);
409 }
410
411 #[rstest]
412 fn test_betfair_ticker_nan_json_roundtrip() {
413 let ticker = BetfairTicker::new(
414 InstrumentId::from("1.234-56789-0.0.BETFAIR"),
415 1.5,
416 100.0,
417 f64::NAN,
418 f64::NAN,
419 UnixNanos::from(1_000_000_000u64),
420 UnixNanos::from(1_000_000_000u64),
421 );
422
423 let json = serde_json::to_string(&ticker).unwrap();
424 assert!(json.contains("\"starting_price_near\":null"));
425 assert!(json.contains("\"starting_price_far\":null"));
426 assert!(json.contains("\"last_traded_price\":1.5"));
427
428 let parsed: BetfairTicker = serde_json::from_str(&json).unwrap();
429 assert!(parsed.starting_price_near.is_nan());
430 assert!(parsed.starting_price_far.is_nan());
431 assert_eq!(parsed.last_traded_price, 1.5);
432 }
433}