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)]` macro which generates `CustomDataTrait`, Arrow codec, and
21//! serialization implementations.
22//!
23//! Call [`register_betfair_custom_data`] once (e.g. during client `connect()`)
24//! to register all types for JSON and Arrow encoding.
25//!
26//! Absent optional race telemetry values use `f64::NAN` as the sentinel.
27
28use nautilus_core::UnixNanos;
29use nautilus_model::identifiers::InstrumentId;
30use nautilus_persistence_macros::custom_data;
31use rust_decimal::Decimal;
32
33/// Serde helpers for f64 fields that use NaN as a sentinel for absent values.
34/// Serializes NaN as JSON `null` and deserializes `null` back to NaN,
35/// avoiding `serde_json` errors on non-finite floats.
36mod 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/// Betfair ticker data from MCM runner changes.
52///
53/// Carries last traded price, traded volume, and starting price
54/// near/far values per runner.
55#[custom_data(pyo3)]
56pub struct BetfairTicker {
57    /// The instrument ID for this ticker.
58    pub instrument_id: InstrumentId,
59    /// Last traded price.
60    #[custom_data_field(serde)]
61    pub last_traded_price: Option<Decimal>,
62    /// Total traded volume.
63    #[custom_data_field(serde)]
64    pub traded_volume: Option<Decimal>,
65    /// Starting price near (projected BSP from matched portion).
66    #[custom_data_field(serde)]
67    pub starting_price_near: Option<Decimal>,
68    /// Starting price far (projected BSP from unmatched portion).
69    #[custom_data_field(serde)]
70    pub starting_price_far: Option<Decimal>,
71    /// UNIX timestamp (nanoseconds) when the data event occurred.
72    pub ts_event: UnixNanos,
73    /// UNIX timestamp (nanoseconds) when the instance was initialized.
74    pub ts_init: UnixNanos,
75}
76
77/// Realized Betfair Starting Price (BSP) for a runner.
78///
79/// Emitted from the market definition when a runner's BSP is determined.
80#[custom_data(pyo3)]
81pub struct BetfairStartingPrice {
82    /// The instrument ID for this starting price.
83    pub instrument_id: InstrumentId,
84    /// The realized best starting price value.
85    #[custom_data_field(serde)]
86    pub bsp: Decimal,
87    /// UNIX timestamp (nanoseconds) when the data event occurred.
88    pub ts_event: UnixNanos,
89    /// UNIX timestamp (nanoseconds) when the instance was initialized.
90    pub ts_init: UnixNanos,
91}
92
93/// BSP order book delta from starting price back/lay arrays.
94///
95/// Mirrors `OrderBookDelta` fields as a custom data type so strategies
96/// can subscribe specifically to BSP book updates (spb/spl) separately
97/// from the exchange order book (atb/atl).
98#[custom_data(pyo3)]
99pub struct BetfairBspBookDelta {
100    /// The instrument ID for this BSP delta.
101    pub instrument_id: InstrumentId,
102    /// The book action (add/update/delete/clear) as `BookAction` u8.
103    pub action: u32,
104    /// The order side as `OrderSide` u8.
105    pub side: u32,
106    /// The price level.
107    #[custom_data_field(serde)]
108    pub price: Decimal,
109    /// The size at this price level.
110    #[custom_data_field(serde)]
111    pub size: Decimal,
112    /// UNIX timestamp (nanoseconds) when the data event occurred.
113    pub ts_event: UnixNanos,
114    /// UNIX timestamp (nanoseconds) when the instance was initialized.
115    pub ts_init: UnixNanos,
116}
117
118/// Marker emitted after all changes in a single MCM batch are processed.
119///
120/// Strategies can use this to know when a coherent set of market updates
121/// has been fully delivered.
122#[custom_data(pyo3)]
123pub struct BetfairSequenceCompleted {
124    /// UNIX timestamp (nanoseconds) when the data event occurred.
125    pub ts_event: UnixNanos,
126    /// UNIX timestamp (nanoseconds) when the instance was initialized.
127    pub ts_init: UnixNanos,
128}
129
130/// Betfair order void event (e.g. VAR void).
131///
132/// Published when a matched bet is retroactively voided by Betfair, such as
133/// when a goal is disallowed following a VAR review.
134#[custom_data(pyo3)]
135pub struct BetfairOrderVoided {
136    /// The instrument ID for the voided order.
137    pub instrument_id: InstrumentId,
138    /// The client order ID.
139    pub client_order_id: String,
140    /// The venue (Betfair) order ID (bet ID).
141    pub venue_order_id: String,
142    /// The size that was voided.
143    #[custom_data_field(serde)]
144    pub size_voided: Decimal,
145    /// The order price.
146    #[custom_data_field(serde)]
147    pub price: Decimal,
148    /// The original order size.
149    #[custom_data_field(serde)]
150    pub size: Decimal,
151    /// The order side ("BACK" or "LAY").
152    pub side: String,
153    /// The average price matched.
154    #[custom_data_field(serde)]
155    pub avg_price_matched: Option<Decimal>,
156    /// The total size matched.
157    #[custom_data_field(serde)]
158    pub size_matched: Option<Decimal>,
159    /// The void reason. Empty string if absent.
160    pub reason: String,
161    /// UNIX timestamp (nanoseconds) when the data event occurred.
162    pub ts_event: UnixNanos,
163    /// UNIX timestamp (nanoseconds) when the instance was initialized.
164    pub ts_init: UnixNanos,
165}
166
167/// GPS tracking data for a single runner from RCM (Race Change Messages).
168///
169/// Betfair's Total Performance Data (TPD) provides real-time GPS positions,
170/// speed, and stride frequency for each runner in supported races.
171#[custom_data(pyo3)]
172pub struct BetfairRaceRunnerData {
173    /// Race identifier (e.g. "28587288.1650").
174    pub race_id: String,
175    /// Betfair market identifier.
176    pub market_id: String,
177    /// Betfair selection identifier.
178    pub selection_id: i64,
179    /// GPS latitude coordinate. `f64::NAN` if absent.
180    #[serde(
181        serialize_with = "nan_as_null::serialize",
182        deserialize_with = "nan_as_null::deserialize"
183    )]
184    pub latitude: f64,
185    /// GPS longitude coordinate. `f64::NAN` if absent.
186    #[serde(
187        serialize_with = "nan_as_null::serialize",
188        deserialize_with = "nan_as_null::deserialize"
189    )]
190    pub longitude: f64,
191    /// Speed in m/s (Doppler-derived). `f64::NAN` if absent.
192    #[serde(
193        serialize_with = "nan_as_null::serialize",
194        deserialize_with = "nan_as_null::deserialize"
195    )]
196    pub speed: f64,
197    /// Distance to finish in meters. `f64::NAN` if absent.
198    #[serde(
199        serialize_with = "nan_as_null::serialize",
200        deserialize_with = "nan_as_null::deserialize"
201    )]
202    pub progress: f64,
203    /// Stride frequency in Hz. `f64::NAN` if absent.
204    #[serde(
205        serialize_with = "nan_as_null::serialize",
206        deserialize_with = "nan_as_null::deserialize"
207    )]
208    pub stride_frequency: f64,
209    /// UNIX timestamp (nanoseconds) when the data event occurred.
210    pub ts_event: UnixNanos,
211    /// UNIX timestamp (nanoseconds) when the instance was initialized.
212    pub ts_init: UnixNanos,
213}
214
215/// Race-level progress from RCM (Race Change Messages).
216///
217/// Provides sectional timing, race order, and obstacle data for the
218/// overall race rather than individual runners.
219#[custom_data(pyo3)]
220pub struct BetfairRaceProgress {
221    /// Race identifier (e.g. "28587288.1650").
222    pub race_id: String,
223    /// Betfair market identifier.
224    pub market_id: String,
225    /// Gate/sectional name (e.g. "1f", "2f", "Finish"). Empty if absent.
226    pub gate_name: String,
227    /// Sectional time in seconds. `f64::NAN` if absent.
228    #[serde(
229        serialize_with = "nan_as_null::serialize",
230        deserialize_with = "nan_as_null::deserialize"
231    )]
232    pub sectional_time: f64,
233    /// Running time since race start in seconds. `f64::NAN` if absent.
234    #[serde(
235        serialize_with = "nan_as_null::serialize",
236        deserialize_with = "nan_as_null::deserialize"
237    )]
238    pub running_time: f64,
239    /// Speed of lead horse in m/s. `f64::NAN` if absent.
240    #[serde(
241        serialize_with = "nan_as_null::serialize",
242        deserialize_with = "nan_as_null::deserialize"
243    )]
244    pub speed: f64,
245    /// Distance to finish for leading horse in meters. `f64::NAN` if absent.
246    #[serde(
247        serialize_with = "nan_as_null::serialize",
248        deserialize_with = "nan_as_null::deserialize"
249    )]
250    pub progress: f64,
251    /// Runner order by selection ID (JSON-encoded array). Empty if absent.
252    pub order: String,
253    /// Jump obstacles (JSON-encoded array of {"J":int,"L":float}). Empty if absent.
254    pub jumps: String,
255    /// UNIX timestamp (nanoseconds) when the data event occurred.
256    pub ts_event: UnixNanos,
257    /// UNIX timestamp (nanoseconds) when the instance was initialized.
258    pub ts_init: UnixNanos,
259}
260
261#[custom_data(pyo3)]
262pub struct BetfairCricketMatch {
263    /// Betfair event identifier.
264    pub event_id: String,
265    /// Betfair market identifier.
266    pub market_id: String,
267    /// Fixture metadata (JSON-encoded object). Empty if absent.
268    pub fixture_info: String,
269    /// Home team metadata (JSON-encoded object). Empty if absent.
270    pub home_team: String,
271    /// Away team metadata (JSON-encoded object). Empty if absent.
272    pub away_team: String,
273    /// Match statistics (JSON-encoded object). Empty if absent.
274    pub match_stats: String,
275    /// Match incidents (JSON-encoded object). Empty if absent.
276    pub incident_list_wrapper: String,
277    /// UNIX timestamp (nanoseconds) when the data event occurred.
278    pub ts_event: UnixNanos,
279    /// UNIX timestamp (nanoseconds) when the instance was initialized.
280    pub ts_init: UnixNanos,
281}
282
283/// Registers all Betfair custom data types for JSON and Arrow encoding.
284///
285/// This must be called once before emitting or persisting Betfair custom data.
286/// Safe to call multiple times (idempotent via internal `Once` guards).
287pub 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}