1use std::{
24 fs::File,
25 io::{BufRead, BufReader},
26 path::Path,
27};
28
29use ahash::AHashMap;
30use anyhow::Context;
31use bzip2::read::BzDecoder;
32use flate2::read::GzDecoder;
33use nautilus_model::{
34 data::{InstrumentClose, InstrumentStatus, OrderBookDeltas, TradeTick},
35 identifiers::{InstrumentId, TradeId},
36 instruments::{Instrument, InstrumentAny},
37 types::{Currency, Money},
38};
39use rust_decimal::Decimal;
40
41use crate::{
42 common::{
43 enums::MarketStatus,
44 parse::{
45 make_instrument_id, parse_betfair_price, parse_betfair_quantity,
46 parse_market_definition, parse_millis_timestamp,
47 },
48 },
49 data_types::{
50 BetfairBspBookDelta, BetfairCricketMatch, BetfairRaceProgress, BetfairRaceRunnerData,
51 BetfairSequenceCompleted, BetfairStartingPrice, BetfairTicker,
52 },
53 stream::{
54 messages::{CCM, MCM, RCM, StreamMessage, stream_decode},
55 parse::{
56 make_trade_tick, parse_betfair_starting_prices, parse_betfair_ticker,
57 parse_bsp_book_deltas, parse_cricket_match, parse_instrument_closes,
58 parse_instrument_statuses, parse_race_progress, parse_race_runner_data,
59 parse_runner_book_deltas,
60 },
61 },
62};
63
64#[derive(Debug)]
66pub enum BetfairDataItem {
67 Instrument(Box<InstrumentAny>),
69 Status(InstrumentStatus),
71 Deltas(OrderBookDeltas),
73 Trade(TradeTick),
75 Ticker(BetfairTicker),
77 StartingPrice(BetfairStartingPrice),
79 BspBookDelta(BetfairBspBookDelta),
81 InstrumentClose(InstrumentClose),
83 SequenceCompleted(BetfairSequenceCompleted),
85 RaceRunnerData(BetfairRaceRunnerData),
87 RaceProgress(BetfairRaceProgress),
89 CricketMatch(BetfairCricketMatch),
91}
92
93#[derive(Debug)]
99pub struct BetfairDataLoader {
100 currency: Currency,
101 min_notional: Option<Money>,
102 traded_volumes: AHashMap<(InstrumentId, Decimal), Decimal>,
103 instruments: AHashMap<InstrumentId, InstrumentAny>,
104}
105
106impl BetfairDataLoader {
107 #[must_use]
109 pub fn new(currency: Currency, min_notional: Option<Money>) -> Self {
110 Self {
111 currency,
112 min_notional,
113 traded_volumes: AHashMap::new(),
114 instruments: AHashMap::new(),
115 }
116 }
117
118 #[must_use]
120 pub fn instruments(&self) -> &AHashMap<InstrumentId, InstrumentAny> {
121 &self.instruments
122 }
123
124 pub fn reset(&mut self) {
126 self.traded_volumes.clear();
127 self.instruments.clear();
128 }
129
130 pub fn load(&mut self, filepath: &Path) -> anyhow::Result<Vec<BetfairDataItem>> {
141 let reader = open_reader(filepath)?;
142 let mut items = Vec::new();
143
144 for (line_num, line_result) in reader.lines().enumerate() {
145 let line = line_result.with_context(|| {
146 format!(
147 "failed to read line {} of '{}'",
148 line_num + 1,
149 filepath.display()
150 )
151 })?;
152
153 if line.is_empty() {
154 continue;
155 }
156
157 let msg = match stream_decode(line.as_bytes()) {
158 Ok(msg) => msg,
159 Err(e) => {
160 log::warn!("Failed to decode line {}: {e}", line_num + 1);
161 continue;
162 }
163 };
164
165 match msg {
166 StreamMessage::MarketChange(mcm) => self.process_mcm(&mcm, &mut items),
167 StreamMessage::RaceChange(rcm) => Self::process_rcm(&rcm, &mut items),
168 StreamMessage::CricketChange(ccm) => Self::process_ccm(&ccm, &mut items),
169 StreamMessage::Connection(_)
170 | StreamMessage::Status(_)
171 | StreamMessage::OrderChange(_) => {}
172 }
173 }
174
175 Ok(items)
176 }
177
178 pub fn load_instruments(&mut self, filepath: &Path) -> anyhow::Result<Vec<InstrumentAny>> {
188 let reader = open_reader(filepath)?;
189
190 for line_result in reader.lines() {
191 let line = line_result?;
192 if line.is_empty() {
193 continue;
194 }
195
196 let msg = match stream_decode(line.as_bytes()) {
197 Ok(msg) => msg,
198 Err(_) => continue,
199 };
200
201 if let StreamMessage::MarketChange(mcm) = msg {
202 let Some(market_changes) = &mcm.mc else {
203 continue;
204 };
205
206 let ts_init = parse_millis_timestamp(mcm.pt);
207
208 for mc in market_changes {
209 if let Some(def) = &mc.market_definition
210 && let Ok(instruments) = parse_market_definition(
211 &mc.id,
212 def,
213 self.currency,
214 ts_init,
215 self.min_notional,
216 )
217 {
218 for inst in instruments {
219 self.instruments.insert(inst.id(), inst);
220 }
221 }
222 }
223 }
224 }
225
226 Ok(self.instruments.values().cloned().collect())
227 }
228
229 fn process_mcm(&mut self, mcm: &MCM, items: &mut Vec<BetfairDataItem>) {
230 if mcm.is_heartbeat() {
231 return;
232 }
233
234 let Some(market_changes) = &mcm.mc else {
235 return;
236 };
237
238 let ts_event = parse_millis_timestamp(mcm.pt);
239 let ts_init = ts_event;
240
241 for mc in market_changes {
242 let is_snapshot = mc.img;
243 let mut market_closed = false;
244
245 if let Some(def) = &mc.market_definition {
246 match parse_market_definition(
250 &mc.id,
251 def,
252 self.currency,
253 ts_init,
254 self.min_notional,
255 ) {
256 Ok(new_instruments) => {
257 for inst in &new_instruments {
258 self.instruments.insert(inst.id(), inst.clone());
259 }
260
261 for inst in new_instruments {
262 items.push(BetfairDataItem::Instrument(Box::new(inst)));
263 }
264 }
265 Err(e) => {
266 log::warn!("Failed to parse market definition for {}: {e}", mc.id);
267 }
268 }
269
270 if let Some(status) = &def.status {
271 market_closed = *status == MarketStatus::Closed;
272
273 for event in parse_instrument_statuses(&mc.id, def, ts_event, ts_init) {
274 items.push(BetfairDataItem::Status(event));
275 }
276 }
277
278 for sp in parse_betfair_starting_prices(&mc.id, def, ts_event, ts_init) {
279 items.push(BetfairDataItem::StartingPrice(sp));
280 }
281
282 for close in parse_instrument_closes(&mc.id, def, ts_event, ts_init) {
283 items.push(BetfairDataItem::InstrumentClose(close));
284 }
285 }
286
287 let mut buffered_deltas: Vec<OrderBookDeltas> = Vec::new();
292 let mut buffered_bsp_deltas: Vec<BetfairBspBookDelta> = Vec::new();
293
294 if let Some(runner_changes) = &mc.rc {
295 for rc in runner_changes {
296 let handicap = rc.hc.unwrap_or(Decimal::ZERO);
297 let instrument_id = make_instrument_id(&mc.id, rc.id, handicap);
298
299 match parse_runner_book_deltas(
300 instrument_id,
301 rc,
302 is_snapshot,
303 mcm.pt,
304 ts_event,
305 ts_init,
306 ) {
307 Ok(Some(deltas)) => {
308 if is_snapshot {
309 items.push(BetfairDataItem::Deltas(deltas));
310 } else {
311 buffered_deltas.push(deltas);
312 }
313 }
314 Ok(None) => {}
315 Err(e) => {
316 log::warn!("Failed to parse book deltas for {instrument_id}: {e}");
317 }
318 }
319
320 if let Some(trades) = &rc.trd {
321 for pv in trades {
322 if pv.volume == Decimal::ZERO {
323 continue;
324 }
325
326 let key = (instrument_id, pv.price);
327 let prev_volume = self
328 .traded_volumes
329 .get(&key)
330 .copied()
331 .unwrap_or(Decimal::ZERO);
332
333 if pv.volume <= prev_volume {
334 self.traded_volumes.insert(key, pv.volume);
335 continue;
336 }
337
338 let trade_volume = pv.volume - prev_volume;
339 self.traded_volumes.insert(key, pv.volume);
340
341 let price = match parse_betfair_price(pv.price) {
342 Ok(p) => p,
343 Err(e) => {
344 log::warn!("Invalid trade price: {e}");
345 continue;
346 }
347 };
348 let size = match parse_betfair_quantity(trade_volume) {
349 Ok(q) => q,
350 Err(e) => {
351 log::warn!("Invalid trade size: {e}");
352 continue;
353 }
354 };
355 let trade_id =
356 TradeId::new(format!("{}-{}-{}", mcm.pt, rc.id, pv.price));
357 let tick = make_trade_tick(
358 instrument_id,
359 price,
360 size,
361 trade_id,
362 ts_event,
363 ts_init,
364 );
365 items.push(BetfairDataItem::Trade(tick));
366 }
367 }
368
369 if let Some(ticker) = parse_betfair_ticker(instrument_id, rc, ts_event, ts_init)
370 {
371 items.push(BetfairDataItem::Ticker(ticker));
372 }
373
374 buffered_bsp_deltas.extend(parse_bsp_book_deltas(
375 instrument_id,
376 rc,
377 ts_event,
378 ts_init,
379 ));
380 }
381 }
382
383 for deltas in buffered_deltas {
384 items.push(BetfairDataItem::Deltas(deltas));
385 }
386
387 for bsp_delta in buffered_bsp_deltas {
388 items.push(BetfairDataItem::BspBookDelta(bsp_delta));
389 }
390
391 if market_closed {
392 let prefix = format!("{}-", mc.id);
393 self.traded_volumes
394 .retain(|k, _| !k.0.symbol.as_str().starts_with(&prefix));
395 }
396 }
397
398 items.push(BetfairDataItem::SequenceCompleted(
399 BetfairSequenceCompleted::new(ts_event, ts_init),
400 ));
401 }
402
403 fn process_rcm(rcm: &RCM, items: &mut Vec<BetfairDataItem>) {
404 let Some(race_changes) = &rcm.rc else {
405 return;
406 };
407
408 let ts_init = parse_millis_timestamp(rcm.pt);
409
410 for rc in race_changes {
411 let race_id = rc.id.as_deref().unwrap_or("");
412 let market_id = rc.mid.as_deref().unwrap_or("");
413
414 if let Some(runners) = &rc.rrc {
415 for rrc in runners {
416 let ts_event = rrc.ft.map_or(ts_init, parse_millis_timestamp);
417
418 if let Some(runner) =
419 parse_race_runner_data(race_id, market_id, rrc, ts_event, ts_init)
420 {
421 items.push(BetfairDataItem::RaceRunnerData(runner));
422 }
423 }
424 }
425
426 if let Some(rpc) = &rc.rpc {
427 let ts_event = rpc.ft.map_or(ts_init, parse_millis_timestamp);
428 let progress = parse_race_progress(race_id, market_id, rpc, ts_event, ts_init);
429 items.push(BetfairDataItem::RaceProgress(progress));
430 }
431 }
432 }
433
434 fn process_ccm(ccm: &CCM, items: &mut Vec<BetfairDataItem>) {
435 let Some(cricket_changes) = &ccm.cc else {
436 return;
437 };
438
439 let ts_init = parse_millis_timestamp(ccm.pt);
440
441 for cricket_change in cricket_changes {
442 if let Some(cricket) = parse_cricket_match(cricket_change, ts_init, ts_init) {
443 items.push(BetfairDataItem::CricketMatch(cricket));
444 }
445 }
446 }
447}
448
449fn open_reader(filepath: &Path) -> anyhow::Result<Box<dyn BufRead>> {
450 let file =
451 File::open(filepath).with_context(|| format!("failed to open '{}'", filepath.display()))?;
452
453 let ext = filepath.extension().and_then(|e| e.to_str()).unwrap_or("");
454
455 if ext.eq_ignore_ascii_case("gz") {
456 Ok(Box::new(BufReader::new(GzDecoder::new(file))))
457 } else if ext.eq_ignore_ascii_case("bz2") {
458 Ok(Box::new(BufReader::new(BzDecoder::new(file))))
459 } else {
460 Ok(Box::new(BufReader::new(file)))
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use std::path::PathBuf;
467
468 use nautilus_model::types::Price;
469 use rstest::rstest;
470
471 use super::*;
472 use crate::common::testing::load_test_json;
473
474 fn compact_json(pretty: &str) -> String {
475 let value: serde_json::Value = serde_json::from_str(pretty).unwrap();
476 serde_json::to_string(&value).unwrap()
477 }
478
479 fn local_data_dir() -> PathBuf {
480 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
481 .ancestors()
482 .nth(3)
483 .unwrap()
484 .join("tests/test_data/local/betfair")
485 }
486
487 fn test_data_dir() -> PathBuf {
488 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
489 }
490
491 #[rstest]
492 fn test_load_bz2_file() {
493 let filepath = test_data_dir().join("stream/sample.bz2");
494 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
495 let items = loader.load(&filepath).unwrap();
496
497 let instrument_count = items
498 .iter()
499 .filter(|i| matches!(i, BetfairDataItem::Instrument(_)))
500 .count();
501 assert!(
502 instrument_count > 0,
503 "should parse instruments from bz2 file"
504 );
505 assert_eq!(loader.instruments().len(), instrument_count);
506
507 let has_sequence = items
508 .iter()
509 .any(|i| matches!(i, BetfairDataItem::SequenceCompleted(_)));
510 assert!(has_sequence, "should emit SequenceCompleted");
511 }
512
513 #[rstest]
514 fn test_load_single_mcm_line() {
515 let data = compact_json(&load_test_json("stream/mcm_SUB_IMAGE.json"));
516 let tmp_dir = std::env::temp_dir().join("betfair_test");
517 std::fs::create_dir_all(&tmp_dir).unwrap();
518 let tmp_file = tmp_dir.join("test_single_mcm.json");
519 std::fs::write(&tmp_file, &data).unwrap();
520
521 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
522 let items = loader.load(&tmp_file).unwrap();
523
524 let instrument_count = items
525 .iter()
526 .filter(|i| matches!(i, BetfairDataItem::Instrument(_)))
527 .count();
528 assert!(
529 instrument_count > 0,
530 "should parse instruments from market definition"
531 );
532 assert_eq!(loader.instruments().len(), instrument_count);
533
534 let has_sequence = items
535 .iter()
536 .any(|i| matches!(i, BetfairDataItem::SequenceCompleted(_)));
537 assert!(has_sequence, "should emit SequenceCompleted");
538
539 std::fs::remove_file(&tmp_file).ok();
540 }
541
542 #[rstest]
543 fn test_load_rcm_uses_publish_time_as_ts_init() {
544 let rcm = r#"{"op":"rcm","clk":"17787241","pt":1583685064634,"rc":[{"id":"29741583.1630","mid":"1.169866199","rrc":[{"ft":1583685064600,"id":24330465,"long":-0.9116606,"lat":53.0712976,"spd":16.91,"prg":750.7,"sfq":2.52}],"rpc":{"ft":1583685064600,"g":"4f","st":11.74,"rt":30.93,"spd":16.8,"prg":749.2,"ord":[8709395,24330465]}}]}"#;
545 let tmp_dir = std::env::temp_dir().join("betfair_test");
546 std::fs::create_dir_all(&tmp_dir).unwrap();
547 let tmp_file = tmp_dir.join("test_rcm_timestamps.json");
548 std::fs::write(&tmp_file, rcm).unwrap();
549
550 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
551 let items = loader.load(&tmp_file).unwrap();
552
553 let expected_event = nautilus_core::UnixNanos::from(1_583_685_064_600_000_000);
554 let expected_init = nautilus_core::UnixNanos::from(1_583_685_064_634_000_000);
555
556 let runner = items
557 .iter()
558 .find_map(|item| match item {
559 BetfairDataItem::RaceRunnerData(runner) => Some(runner),
560 _ => None,
561 })
562 .expect("expected race runner data");
563 assert_eq!(runner.ts_event, expected_event);
564 assert_eq!(runner.ts_init, expected_init);
565
566 let progress = items
567 .iter()
568 .find_map(|item| match item {
569 BetfairDataItem::RaceProgress(progress) => Some(progress),
570 _ => None,
571 })
572 .expect("expected race progress data");
573 assert_eq!(progress.ts_event, expected_event);
574 assert_eq!(progress.ts_init, expected_init);
575
576 std::fs::remove_file(&tmp_file).ok();
577 }
578
579 #[rstest]
580 fn test_load_ccm_cricket_match() {
581 let ccm = compact_json(&load_test_json("stream/ccm_single.json"));
582 let tmp_dir = std::env::temp_dir().join("betfair_test");
583 std::fs::create_dir_all(&tmp_dir).unwrap();
584 let tmp_file = tmp_dir.join("test_ccm.json");
585 std::fs::write(&tmp_file, ccm).unwrap();
586
587 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
588 let items = loader.load(&tmp_file).unwrap();
589
590 let cricket = items
591 .iter()
592 .find_map(|item| match item {
593 BetfairDataItem::CricketMatch(cricket) => Some(cricket),
594 _ => None,
595 })
596 .expect("expected cricket match data");
597 assert_eq!(cricket.event_id, "35741575");
598 assert_eq!(cricket.market_id, "1.259334639");
599
600 std::fs::remove_file(&tmp_file).ok();
601 }
602
603 #[rstest]
604 fn test_load_mcm_with_book_data() {
605 let sub_image = compact_json(&load_test_json("stream/mcm_SUB_IMAGE.json"));
606 let update = compact_json(&load_test_json("stream/mcm_UPDATE.json"));
607
608 let tmp_dir = std::env::temp_dir().join("betfair_test");
609 std::fs::create_dir_all(&tmp_dir).unwrap();
610 let tmp_file = tmp_dir.join("test_book_data.json");
611 std::fs::write(&tmp_file, format!("{sub_image}\n{update}")).unwrap();
612
613 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
614 let items = loader.load(&tmp_file).unwrap();
615
616 let deltas_count = items
617 .iter()
618 .filter(|i| matches!(i, BetfairDataItem::Deltas(_)))
619 .count();
620 assert!(deltas_count > 0, "should parse book deltas");
621
622 std::fs::remove_file(&tmp_file).ok();
623 }
624
625 #[rstest]
626 fn test_load_instruments_only() {
627 let sub_image = compact_json(&load_test_json("stream/mcm_SUB_IMAGE.json"));
628 let update = compact_json(&load_test_json("stream/mcm_UPDATE.json"));
629
630 let tmp_dir = std::env::temp_dir().join("betfair_test");
631 std::fs::create_dir_all(&tmp_dir).unwrap();
632 let tmp_file = tmp_dir.join("test_instruments_only.json");
633 std::fs::write(&tmp_file, format!("{sub_image}\n{update}")).unwrap();
634
635 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
636 let instruments = loader.load_instruments(&tmp_file).unwrap();
637
638 assert!(!instruments.is_empty(), "should find instruments");
639 assert_eq!(loader.instruments().len(), instruments.len());
640
641 std::fs::remove_file(&tmp_file).ok();
642 }
643
644 #[rstest]
645 fn test_reset_clears_state() {
646 let data = compact_json(&load_test_json("stream/mcm_SUB_IMAGE.json"));
647 let tmp_dir = std::env::temp_dir().join("betfair_test");
648 std::fs::create_dir_all(&tmp_dir).unwrap();
649 let tmp_file = tmp_dir.join("test_reset.json");
650 std::fs::write(&tmp_file, &data).unwrap();
651
652 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
653 loader.load(&tmp_file).unwrap();
654 assert!(!loader.instruments().is_empty());
655
656 loader.reset();
657 assert!(loader.instruments().is_empty());
658 assert!(loader.traded_volumes.is_empty());
659
660 std::fs::remove_file(&tmp_file).ok();
661 }
662
663 #[rstest]
664 fn test_load_bsp_data() {
665 let raw = load_test_json("stream/mcm_BSP.json");
666 let messages: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
667 let lines: Vec<String> = messages
668 .iter()
669 .map(|v| serde_json::to_string(v).unwrap())
670 .collect();
671
672 let tmp_dir = std::env::temp_dir().join("betfair_test");
673 std::fs::create_dir_all(&tmp_dir).unwrap();
674 let tmp_file = tmp_dir.join("test_bsp.json");
675 std::fs::write(&tmp_file, lines.join("\n")).unwrap();
676
677 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
678 let items = loader.load(&tmp_file).unwrap();
679
680 let bsp_count = items
681 .iter()
682 .filter(|i| matches!(i, BetfairDataItem::BspBookDelta(_)))
683 .count();
684 assert!(bsp_count > 0, "should parse BSP book deltas");
685
686 std::fs::remove_file(&tmp_file).ok();
687 }
688
689 #[rstest]
690 fn test_load_market_definition_with_traded_volumes() {
691 let sub_image = compact_json(&load_test_json("stream/mcm_SUB_IMAGE.json"));
692 let update_tv = compact_json(&load_test_json("stream/mcm_UPDATE_tv.json"));
693
694 let tmp_dir = std::env::temp_dir().join("betfair_test");
695 std::fs::create_dir_all(&tmp_dir).unwrap();
696 let tmp_file = tmp_dir.join("test_tv.json");
697 std::fs::write(&tmp_file, format!("{sub_image}\n{update_tv}")).unwrap();
698
699 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
700 let items = loader.load(&tmp_file).unwrap();
701
702 let ticker_count = items
703 .iter()
704 .filter(|i| matches!(i, BetfairDataItem::Ticker(_)))
705 .count();
706 assert!(ticker_count > 0, "should parse ticker data from tv updates");
707
708 std::fs::remove_file(&tmp_file).ok();
709 }
710
711 #[rstest]
712 #[ignore] fn test_load_match_odds_file() {
714 let filepath = local_data_dir().join("1.253378068.gz");
715 if !filepath.exists() {
716 eprintln!("Skipping: {filepath:?} not found");
717 return;
718 }
719
720 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
721 let items = loader.load(&filepath).unwrap();
722
723 let instrument_count = items
724 .iter()
725 .filter(|i| matches!(i, BetfairDataItem::Instrument(_)))
726 .count();
727 let deltas_count = items
728 .iter()
729 .filter(|i| matches!(i, BetfairDataItem::Deltas(_)))
730 .count();
731 let trade_count = items
732 .iter()
733 .filter(|i| matches!(i, BetfairDataItem::Trade(_)))
734 .count();
735 let close_count = items
736 .iter()
737 .filter(|i| matches!(i, BetfairDataItem::InstrumentClose(_)))
738 .count();
739
740 println!(
741 "Match odds file: {instrument_count} instruments, {deltas_count} deltas, {trade_count} trades, {close_count} closes"
742 );
743 println!("Total items: {}", items.len());
744
745 assert!(instrument_count >= 3, "expected at least 3 instruments");
747 assert!(deltas_count > 0, "expected book deltas");
748 assert!(trade_count > 0, "expected trade ticks");
749 assert!(close_count > 0, "expected instrument closes at settlement");
750
751 let closes: Vec<_> = items
753 .iter()
754 .filter_map(|i| match i {
755 BetfairDataItem::InstrumentClose(c) => Some(c),
756 _ => None,
757 })
758 .collect();
759 let winner = closes.iter().find(|c| c.close_price == Price::from("1.00"));
760 assert!(winner.is_some(), "expected a winner with close_price 1.00");
761 assert!(
762 winner
763 .unwrap()
764 .instrument_id
765 .symbol
766 .as_str()
767 .contains("2426"),
768 "winner should be runner 2426"
769 );
770 }
771
772 #[rstest]
773 #[ignore] fn test_load_racing_win_file() {
775 let filepath = local_data_dir().join("1.245077076.gz");
776 if !filepath.exists() {
777 eprintln!("Skipping: {filepath:?} not found");
778 return;
779 }
780
781 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
782 let items = loader.load(&filepath).unwrap();
783
784 let instrument_count = items
785 .iter()
786 .filter(|i| matches!(i, BetfairDataItem::Instrument(_)))
787 .count();
788 let deltas_count = items
789 .iter()
790 .filter(|i| matches!(i, BetfairDataItem::Deltas(_)))
791 .count();
792 let trade_count = items
793 .iter()
794 .filter(|i| matches!(i, BetfairDataItem::Trade(_)))
795 .count();
796 let close_count = items
797 .iter()
798 .filter(|i| matches!(i, BetfairDataItem::InstrumentClose(_)))
799 .count();
800
801 println!(
802 "Racing file: {instrument_count} instruments, {deltas_count} deltas, {trade_count} trades, {close_count} closes"
803 );
804 println!("Total items: {}", items.len());
805
806 assert!(instrument_count >= 6, "expected at least 6 instruments");
808 assert!(deltas_count > 0, "expected book deltas");
809 assert!(trade_count > 0, "expected trade ticks");
810 assert!(close_count > 0, "expected instrument closes at settlement");
811
812 let closes: Vec<_> = items
814 .iter()
815 .filter_map(|i| match i {
816 BetfairDataItem::InstrumentClose(c) => Some(c),
817 _ => None,
818 })
819 .collect();
820 let winner = closes.iter().find(|c| c.close_price == Price::from("1.00"));
821 assert!(winner.is_some(), "expected a winner with close_price 1.00");
822 assert!(
823 winner
824 .unwrap()
825 .instrument_id
826 .symbol
827 .as_str()
828 .contains("75925986"),
829 "winner should be runner 75925986"
830 );
831 }
832
833 fn write_tmp(contents: &str, name: &str) -> PathBuf {
834 let tmp_dir = std::env::temp_dir().join("betfair_test");
835 std::fs::create_dir_all(&tmp_dir).unwrap();
836 let tmp_file = tmp_dir.join(name);
837 std::fs::write(&tmp_file, contents).unwrap();
838 tmp_file
839 }
840
841 fn find_first(
842 items: &[BetfairDataItem],
843 pred: impl Fn(&BetfairDataItem) -> bool,
844 ) -> Option<usize> {
845 items.iter().position(pred)
846 }
847
848 fn find_last(
849 items: &[BetfairDataItem],
850 pred: impl Fn(&BetfairDataItem) -> bool,
851 ) -> Option<usize> {
852 items.iter().rposition(pred)
853 }
854
855 fn partition_by_mcm(items: &[BetfairDataItem]) -> Vec<&[BetfairDataItem]> {
858 let mut partitions = Vec::new();
859 let mut start = 0;
860
861 for (i, item) in items.iter().enumerate() {
862 if matches!(item, BetfairDataItem::SequenceCompleted(_)) {
863 partitions.push(&items[start..=i]);
864 start = i + 1;
865 }
866 }
867
868 partitions
869 }
870
871 #[rstest]
872 fn test_load_emits_instrument_before_status_and_close() {
873 let data = compact_json(&load_test_json("stream/mcm_UPDATE_md.json"));
878 let tmp_file = write_tmp(&data, "test_order_instrument_first.json");
879
880 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
881 let items = loader.load(&tmp_file).unwrap();
882
883 let instrument_idx =
884 find_first(&items, |i| matches!(i, BetfairDataItem::Instrument(_))).unwrap();
885 let status_idx = find_first(&items, |i| matches!(i, BetfairDataItem::Status(_))).unwrap();
886
887 assert!(
888 instrument_idx < status_idx,
889 "Instrument (idx {instrument_idx}) must precede Status (idx {status_idx})"
890 );
891
892 std::fs::remove_file(&tmp_file).ok();
893 }
894
895 #[rstest]
896 fn test_load_emits_instrument_before_close() {
897 let mcm = r#"{"op":"mcm","id":1,"pt":1627617202953,"ct":"SUB_IMAGE","mc":[{"id":"1.1","marketDefinition":{"bspMarket":false,"turnInPlayEnabled":false,"persistenceEnabled":false,"marketBaseRate":5,"eventId":"1","eventTypeId":"1","numberOfWinners":1,"bettingType":"ODDS","marketType":"WIN","marketTime":"2021-07-30T03:55:00.000Z","bspReconciled":true,"complete":true,"inPlay":false,"crossMatching":false,"runnersVoidable":false,"numberOfActiveRunners":0,"betDelay":0,"status":"CLOSED","runners":[{"status":"WINNER","sortPriority":1,"id":101},{"status":"LOSER","sortPriority":2,"id":102}],"regulators":["MR_INT"],"discountAllowed":true,"timezone":"UTC","openDate":"2021-07-30T02:45:00.000Z","version":1,"priceLadderDefinition":{"type":"CLASSIC"}}}]}"#;
901 let tmp_file = write_tmp(mcm, "test_order_instrument_before_close.json");
902
903 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
904 let items = loader.load(&tmp_file).unwrap();
905
906 let instrument_idx =
907 find_first(&items, |i| matches!(i, BetfairDataItem::Instrument(_))).unwrap();
908 let close_idx =
909 find_first(&items, |i| matches!(i, BetfairDataItem::InstrumentClose(_))).unwrap();
910
911 assert!(
912 instrument_idx < close_idx,
913 "Instrument (idx {instrument_idx}) must precede InstrumentClose (idx {close_idx})"
914 );
915
916 std::fs::remove_file(&tmp_file).ok();
917 }
918
919 #[rstest]
920 fn test_load_non_snapshot_deltas_tail_after_trades() {
921 let data = compact_json(&load_test_json("stream/mcm_live_UPDATE.json"));
925 let tmp_file = write_tmp(&data, "test_order_deltas_tail.json");
926
927 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
928 let items = loader.load(&tmp_file).unwrap();
929
930 let last_trade_idx = find_last(&items, |i| matches!(i, BetfairDataItem::Trade(_))).unwrap();
932 let first_deltas_idx =
933 find_first(&items, |i| matches!(i, BetfairDataItem::Deltas(_))).unwrap();
934
935 assert!(
936 first_deltas_idx > last_trade_idx,
937 "Deltas (first idx {first_deltas_idx}) must tail after Trade (last idx {last_trade_idx}) on non-snapshot updates"
938 );
939
940 std::fs::remove_file(&tmp_file).ok();
941 }
942
943 #[rstest]
944 fn test_load_snapshot_deltas_emit_inline_before_trades() {
945 let data = compact_json(&load_test_json(
949 "stream/market_definition_runner_removed.json",
950 ));
951 let tmp_file = write_tmp(&data, "test_order_snapshot_inline.json");
952
953 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
954 let items = loader.load(&tmp_file).unwrap();
955
956 let first_deltas_idx =
957 find_first(&items, |i| matches!(i, BetfairDataItem::Deltas(_))).unwrap();
958 let first_trade_idx =
959 find_first(&items, |i| matches!(i, BetfairDataItem::Trade(_))).unwrap();
960
961 assert!(
962 first_deltas_idx < first_trade_idx,
963 "Snapshot Deltas (first idx {first_deltas_idx}) must emit before Trade (first idx {first_trade_idx})"
964 );
965
966 std::fs::remove_file(&tmp_file).ok();
967 }
968
969 #[rstest]
970 fn test_load_bsp_tails_after_book_deltas() {
971 let raw = load_test_json("stream/mcm_BSP.json");
975 let messages: Vec<serde_json::Value> = serde_json::from_str(&raw).unwrap();
976 let lines: Vec<String> = messages
977 .iter()
978 .map(|v| serde_json::to_string(v).unwrap())
979 .collect();
980 let tmp_file = write_tmp(&lines.join("\n"), "test_order_bsp_tail.json");
981
982 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
983 let items = loader.load(&tmp_file).unwrap();
984
985 let partitions = partition_by_mcm(&items);
986 assert!(
987 !partitions.is_empty(),
988 "expected at least one MCM partition"
989 );
990
991 let mut checked_any = false;
992
993 for partition in partitions {
994 let last_deltas_idx = find_last(partition, |i| matches!(i, BetfairDataItem::Deltas(_)));
995 let first_bsp_idx =
996 find_first(partition, |i| matches!(i, BetfairDataItem::BspBookDelta(_)));
997
998 if let (Some(last_deltas), Some(first_bsp)) = (last_deltas_idx, first_bsp_idx) {
999 assert!(
1000 last_deltas < first_bsp,
1001 "BspBookDelta (first idx {first_bsp}) must tail after Deltas (last idx {last_deltas}) within the same MCM"
1002 );
1003 checked_any = true;
1004 }
1005 }
1006
1007 assert!(
1008 checked_any,
1009 "expected at least one MCM to contain both Deltas and BspBookDelta"
1010 );
1011
1012 std::fs::remove_file(&tmp_file).ok();
1013 }
1014
1015 #[rstest]
1016 fn test_load_emits_close_for_removed_runner_while_market_open() {
1017 let mcm = r#"{"op":"mcm","id":1,"pt":1627617202953,"ct":"SUB_IMAGE","mc":[{"id":"1.2","marketDefinition":{"bspMarket":false,"turnInPlayEnabled":false,"persistenceEnabled":false,"marketBaseRate":5,"eventId":"1","eventTypeId":"1","numberOfWinners":1,"bettingType":"ODDS","marketType":"WIN","marketTime":"2021-07-30T03:55:00.000Z","bspReconciled":false,"complete":true,"inPlay":false,"crossMatching":false,"runnersVoidable":false,"numberOfActiveRunners":1,"betDelay":0,"status":"OPEN","runners":[{"status":"ACTIVE","sortPriority":1,"id":201},{"status":"REMOVED","sortPriority":2,"id":202}],"regulators":["MR_INT"],"discountAllowed":true,"timezone":"UTC","openDate":"2021-07-30T02:45:00.000Z","version":1,"priceLadderDefinition":{"type":"CLASSIC"}}}]}"#;
1021 let tmp_file = write_tmp(mcm, "test_order_close_for_removed.json");
1022
1023 let mut loader = BetfairDataLoader::new(Currency::GBP(), None);
1024 let items = loader.load(&tmp_file).unwrap();
1025
1026 let closes: Vec<_> = items
1027 .iter()
1028 .filter_map(|i| match i {
1029 BetfairDataItem::InstrumentClose(c) => Some(c),
1030 _ => None,
1031 })
1032 .collect();
1033
1034 assert_eq!(
1035 closes.len(),
1036 1,
1037 "Removed runner must produce exactly one InstrumentClose while market is Open"
1038 );
1039 assert!(
1040 closes[0].instrument_id.symbol.as_str().contains("202"),
1041 "close must target the removed runner (selection id 202)"
1042 );
1043
1044 std::fs::remove_file(&tmp_file).ok();
1045 }
1046}