Skip to main content

nautilus_betfair/
loader.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//! File-based loader for historical Betfair Exchange Streaming data.
17//!
18//! Reads compressed (gzip or bzip2) or plain JSON files containing
19//! newline-delimited Betfair ESA messages and produces Nautilus domain
20//! objects. The parsing logic mirrors the live data client handler in
21//! [`crate::data`].
22
23use 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/// A parsed data item from a Betfair historical file.
65#[derive(Debug)]
66pub enum BetfairDataItem {
67    /// Instrument definition from a market definition.
68    Instrument(Box<InstrumentAny>),
69    /// Market status change for an instrument.
70    Status(InstrumentStatus),
71    /// Order book snapshot or delta update.
72    Deltas(OrderBookDeltas),
73    /// Incremental trade tick derived from cumulative traded volumes.
74    Trade(TradeTick),
75    /// Betfair-specific ticker data (last traded price, traded volume, BSP near/far).
76    Ticker(BetfairTicker),
77    /// Betfair Starting Price for a runner.
78    StartingPrice(BetfairStartingPrice),
79    /// BSP book delta (separate from exchange book).
80    BspBookDelta(BetfairBspBookDelta),
81    /// Instrument close event at market settlement.
82    InstrumentClose(InstrumentClose),
83    /// Marker emitted after each MCM batch is fully processed.
84    SequenceCompleted(BetfairSequenceCompleted),
85    /// GPS tracking data for a race runner (from RCM).
86    RaceRunnerData(BetfairRaceRunnerData),
87    /// Race-level progress data (from RCM).
88    RaceProgress(BetfairRaceProgress),
89    /// Cricket match data (from CCM).
90    CricketMatch(BetfairCricketMatch),
91}
92
93/// Reads Betfair historical data files and converts them into Nautilus domain objects.
94///
95/// Each file contains newline-delimited JSON from the Betfair Exchange Streaming API.
96/// The loader handles gzip decompression, stateful traded volume tracking, and
97/// instrument creation from market definitions.
98#[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    /// Creates a new [`BetfairDataLoader`].
108    #[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    /// Returns the instruments cached from the most recent load.
119    #[must_use]
120    pub fn instruments(&self) -> &AHashMap<InstrumentId, InstrumentAny> {
121        &self.instruments
122    }
123
124    /// Clears all cached state (instruments and traded volumes).
125    pub fn reset(&mut self) {
126        self.traded_volumes.clear();
127        self.instruments.clear();
128    }
129
130    /// Loads a Betfair historical data file and returns all parsed data items.
131    ///
132    /// Supports gzip-compressed (`.gz`), bzip2-compressed (`.bz2`), and plain JSON files.
133    /// Each line is deserialized as a Betfair stream message. MCM and RCM
134    /// messages are parsed into Nautilus domain objects; other message types
135    /// are skipped.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the file cannot be opened or a line fails to parse.
140    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    /// Loads only instrument definitions from a Betfair historical data file.
179    ///
180    /// Scans the file for market definitions and creates instruments, but
181    /// skips all other data processing. Faster than `load()` when only
182    /// instruments are needed.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error if the file cannot be opened or parsed.
187    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                // Emit instruments first so sequential consumers (e.g. the backtest
247                // exchange) have the instrument in cache before any status or close
248                // event references it.
249                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            // Non-snapshot deltas and BSP deltas are buffered and flushed after
288            // trades/tickers to mirror the Python `market_change_to_updates`
289            // ordering (book deltas first, then BSP). Snapshots go inline per
290            // runner, also matching Python.
291            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] // Requires user-fetched data in tests/test_data/local/betfair/
713    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        // 3 runners (home/draw/away), emitted on each market definition
746        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        // Winner should be runner 2426
752        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] // Requires user-fetched data in tests/test_data/local/betfair/
774    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        // 6 runners (though 2 removed during the race)
807        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        // Winner should be runner 75925986
813        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    /// Split the loader output into per-MCM slices using `SequenceCompleted`
856    /// as the delimiter. Each MCM ends with one `SequenceCompleted` item.
857    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        // The loader must emit `Instrument` events before any `InstrumentStatus`
874        // or `InstrumentClose` in the same MCM so downstream consumers (e.g. the
875        // backtest exchange) have the instrument cached before lifecycle events
876        // are processed.
877        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        // Synthetic fixture: market CLOSED with terminal runner statuses so that
898        // both Instrument and InstrumentClose are emitted within the same MCM.
899        // Instrument must appear first.
900        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        // Non-snapshot runner updates must emit book deltas AFTER any trades or
922        // tickers parsed from the same message, matching the Python
923        // `market_change_to_updates` ordering and keeping live/backtest in step.
924        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        // Fixture is a single non-snapshot MCM (img=None) with trd + atl on one rc
931        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        // Snapshot messages (mc.img=true) emit Clear+Add deltas inline per runner
946        // so consumers can apply the book state before any trades in the same
947        // MCM. This matches Python's inline-snapshot behaviour.
948        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        // Within each MCM, BSP deltas must emit after all regular book deltas.
972        // Python flushes `book_updates` before `bsp_book_updates`; the Rust
973        // loader must do the same to preserve consumer ordering.
974        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        // Removed runners must fire InstrumentClose as soon as the market
1018        // definition reports the Removed status, regardless of whether the
1019        // market as a whole is still Open. This matches the Python parser.
1020        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}