1use std::{
19 sync::{
20 Arc, OnceLock, Weak,
21 atomic::{AtomicBool, Ordering},
22 },
23 time::Duration,
24};
25
26use ahash::{AHashMap, AHashSet};
27use async_trait::async_trait;
28use nautilus_common::{
29 clients::DataClient,
30 live::{dst::time::Instant, runner::get_data_event_sender, sender::EventSender},
31 messages::{
32 DataEvent,
33 data::{
34 SubscribeBookDeltas, SubscribeInstrumentClose, SubscribeInstrumentStatus,
35 SubscribeTrades, UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeCustomData,
36 UnsubscribeInstrument, UnsubscribeInstrumentClose, UnsubscribeInstrumentStatus,
37 UnsubscribeInstruments, UnsubscribeQuotes, UnsubscribeTrades,
38 },
39 },
40 providers::InstrumentProvider,
41};
42use nautilus_core::{
43 AtomicMap, Params, UnixNanos,
44 string::secret::SecretString,
45 time::{AtomicTime, get_atomic_clock_realtime},
46};
47use nautilus_live::{
48 SocketControl,
49 book::snapshot::SnapshotGate,
50 task::{TaskGroup, TaskGroupGuard, TaskSpawner},
51};
52use nautilus_model::{
53 data::{
54 CustomData, CustomDataTrait, Data, DataType, OrderBookDelta, OrderBookDeltas, TradeTick,
55 },
56 enums::RecordFlag,
57 identifiers::{ClientId, InstrumentId, TradeId, Venue},
58 instruments::{Instrument, InstrumentAny},
59 types::{Currency, Money},
60};
61use parking_lot::Mutex;
62use rust_decimal::Decimal;
63
64use crate::{
65 book::{
66 BookSequenceOutcome,
67 recovery::{spawn_subscription_task, start_recovery},
68 sync::BookSyncTracker,
69 },
70 common::{
71 consts::{BETFAIR_RACE_STREAM_HOST, BETFAIR_VENUE},
72 credential::BetfairCredential,
73 enums::{ChangeType, MarketDataFilterField, MarketStatus, SegmentType},
74 parse::{
75 extract_market_id, make_instrument_id, parse_betfair_price, parse_betfair_quantity,
76 parse_market_definition, parse_millis_timestamp,
77 },
78 },
79 config::BetfairDataClientConfig,
80 data_types::{BetfairSequenceCompleted, register_betfair_custom_data},
81 http::client::BetfairHttpClient,
82 provider::{BetfairInstrumentProvider, NavigationFilter},
83 stream::{
84 CRICKET_STREAMS_ENDPOINT, DATA_STREAMS_ENDPOINT, RACE_STREAMS_ENDPOINT,
85 client::{
86 BetfairRaceStreamClient, BetfairStreamClient, HeartbeatTimeoutSource,
87 StreamMessageHandler,
88 },
89 config::BetfairStreamConfig,
90 error::BetfairStreamError,
91 messages::{MarketChange, MarketDataFilter, StreamMarketFilter, StreamMessage},
92 parse::{
93 make_trade_tick, parse_betfair_starting_prices, parse_betfair_ticker,
94 parse_bsp_book_deltas, parse_cricket_match, parse_instrument_closes,
95 parse_instrument_statuses, parse_race_progress, parse_race_runner_data,
96 parse_runner_book_deltas,
97 },
98 },
99};
100
101const KEEP_ALIVE_INTERVAL_SECS: u64 = 36_000;
103
104#[derive(Debug)]
106pub struct BetfairDataClient {
107 clock: &'static AtomicTime,
108 client_id: ClientId,
109 http_client: Arc<BetfairHttpClient>,
110 provider: BetfairInstrumentProvider,
111 stream_client: Option<Arc<BetfairStreamClient>>,
112 socket_control: Option<SocketControl>,
113 race_socket_control: Option<Arc<SocketControl>>,
114 race_stream_client: Option<Arc<BetfairRaceStreamClient>>,
115 cricket_socket_control: Option<Arc<SocketControl>>,
116 cricket_stream_client: Option<Arc<BetfairRaceStreamClient>>,
117 credential: BetfairCredential,
118 stream_config: BetfairStreamConfig,
119 config: BetfairDataClientConfig,
120 currency: Currency,
121 is_connected: AtomicBool,
122 data_sender: EventSender<DataEvent>,
123 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
124 subscribed_market_ids: AHashSet<String>,
125 book_sync: BookSyncTracker,
126 session_tasks: TaskGroup,
127 command_tasks: TaskGroup,
128 stream_shutdowns: Arc<Mutex<Vec<BetfairStreamShutdown>>>,
129 shutdown_errors: Vec<String>,
130}
131
132pub(crate) fn custom_data_with_instrument(
135 value: Arc<dyn CustomDataTrait>,
136 instrument_id: InstrumentId,
137) -> CustomData {
138 let mut metadata = Params::new();
139 metadata.insert(
140 "instrument_id".to_string(),
141 serde_json::Value::String(instrument_id.to_string()),
142 );
143 let data_type = DataType::new(
144 value.type_name(),
145 Some(metadata),
146 Some(instrument_id.to_string()),
147 );
148 CustomData::new(value, data_type)
149}
150
151impl BetfairDataClient {
152 #[must_use]
154 #[expect(clippy::too_many_arguments)]
155 pub fn new(
156 client_id: ClientId,
157 http_client: BetfairHttpClient,
158 credential: BetfairCredential,
159 stream_config: BetfairStreamConfig,
160 config: BetfairDataClientConfig,
161 nav_filter: NavigationFilter,
162 currency: Currency,
163 min_notional: Option<Money>,
164 ) -> Self {
165 let data_sender = get_data_event_sender();
166 let http_client = Arc::new(http_client);
167 let socket_control = Some(SocketControl::new(
168 client_id,
169 Some(*BETFAIR_VENUE),
170 DATA_STREAMS_ENDPOINT,
171 ));
172 let race_socket_control = config.subscribe_race_data.then(|| {
173 Arc::new(SocketControl::new(
174 client_id,
175 Some(*BETFAIR_VENUE),
176 RACE_STREAMS_ENDPOINT,
177 ))
178 });
179 let cricket_socket_control = config.subscribe_cricket_data.then(|| {
180 Arc::new(SocketControl::new(
181 client_id,
182 Some(*BETFAIR_VENUE),
183 CRICKET_STREAMS_ENDPOINT,
184 ))
185 });
186 let provider = BetfairInstrumentProvider::new(
187 Arc::clone(&http_client),
188 nav_filter,
189 currency,
190 min_notional,
191 );
192
193 let session_tasks = TaskGroup::new();
194 let command_tasks = TaskGroup::new();
195
196 Self {
197 clock: get_atomic_clock_realtime(),
198 client_id,
199 http_client,
200 provider,
201 stream_client: None,
202 socket_control,
203 race_socket_control,
204 race_stream_client: None,
205 cricket_socket_control,
206 cricket_stream_client: None,
207 credential,
208 stream_config,
209 config,
210 currency,
211 is_connected: AtomicBool::new(false),
212 data_sender,
213 instruments: Arc::new(AtomicMap::new()),
214 subscribed_market_ids: AHashSet::new(),
215 book_sync: BookSyncTracker::default(),
216 session_tasks,
217 command_tasks,
218 stream_shutdowns: Arc::new(Mutex::new(Vec::new())),
219 shutdown_errors: Vec::new(),
220 }
221 }
222
223 async fn finish_tasks(&self) -> anyhow::Result<()> {
224 let (session_result, command_result) = tokio::join!(
225 self.session_tasks
226 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
227 self.command_tasks
228 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
229 );
230 session_result
231 .map_err(|e| anyhow::anyhow!("Failed to finish Betfair data session tasks: {e}"))?;
232 command_result
233 .map_err(|e| anyhow::anyhow!("Failed to finish Betfair data command tasks: {e}"))?;
234 Ok(())
235 }
236
237 async fn prepare_task_groups(&mut self) -> anyhow::Result<()> {
238 if !self.session_tasks.is_open() || !self.command_tasks.is_open() {
239 self.teardown_partial_connect().await?;
240 self.session_tasks
241 .start_generation()
242 .map_err(|e| anyhow::anyhow!("Failed to start Betfair data session tasks: {e}"))?;
243 self.command_tasks
244 .start_generation()
245 .map_err(|e| anyhow::anyhow!("Failed to start Betfair data command tasks: {e}"))?;
246 }
247 Ok(())
248 }
249
250 fn begin_stream_shutdown(&self) {
251 for stream in self.stream_shutdowns.lock().iter() {
252 stream.begin_shutdown();
253 }
254 }
255
256 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
257 self.session_tasks.begin_shutdown();
258 self.command_tasks.begin_shutdown();
259 self.begin_stream_shutdown();
260 self.is_connected.store(false, Ordering::Relaxed);
261
262 if let Some(client) = self.cricket_stream_client.as_ref() {
263 client.close().await;
264 self.cricket_stream_client = None;
265 }
266
267 if let Some(client) = self.race_stream_client.as_ref() {
268 client.close().await;
269 self.race_stream_client = None;
270 }
271
272 if let Some(client) = self.stream_client.as_ref() {
273 match client.close().await {
274 Ok(()) => self.stream_client = None,
275 Err(e) => self
276 .shutdown_errors
277 .push(format!("stream shutdown failed: {e}")),
278 }
279 }
280
281 self.http_client.disconnect().await;
282
283 if let Err(e) = self.finish_tasks().await {
284 self.shutdown_errors.push(e.to_string());
285 }
286 self.is_connected.store(false, Ordering::Release);
287 self.deregister_socket_controls();
288
289 if self.stream_client.is_none()
290 && self.race_stream_client.is_none()
291 && self.cricket_stream_client.is_none()
292 {
293 self.stream_shutdowns.lock().clear();
294 }
295
296 if self.shutdown_errors.is_empty() {
297 Ok(())
298 } else {
299 let errors = std::mem::take(&mut self.shutdown_errors);
300 anyhow::bail!("Betfair data shutdown failed: {}", errors.join("; "))
301 }
302 }
303
304 #[expect(clippy::too_many_arguments)]
305 fn create_stream_handler(
306 data_sender: EventSender<DataEvent>,
307 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
308 currency: Currency,
309 min_notional: Option<Money>,
310 reconnect_tx: tokio::sync::mpsc::UnboundedSender<()>,
311 book_sync: BookSyncTracker,
312 stream_client: Arc<OnceLock<Weak<BetfairStreamClient>>>,
313 snapshot_timeout: Duration,
314 tasks: TaskSpawner,
315 clock: &'static AtomicTime,
316 ) -> StreamMessageHandler {
317 let traded_volumes: Arc<Mutex<AHashMap<(InstrumentId, Decimal), Decimal>>> =
320 Arc::new(Mutex::new(AHashMap::new()));
321 let has_initial_connection = Arc::new(AtomicBool::new(false));
322
323 Arc::new(move |msg: StreamMessage| {
324 let ts_init = clock.get_time_ns();
325
326 match msg {
327 StreamMessage::MarketChange(mcm) => {
328 if mcm.is_heartbeat() {
329 return;
330 }
331
332 if mcm.ct == Some(ChangeType::SubImage)
335 && matches!(mcm.segment_type, None | Some(SegmentType::SegStart))
336 {
337 book_sync.begin_image();
338 }
339
340 let sequence_complete = mcm
341 .segment_type
342 .is_none_or(|segment| segment == SegmentType::SegEnd);
343
344 let Some(market_changes) = &mcm.mc else {
345 return;
346 };
347
348 let ts_event = parse_millis_timestamp(mcm.pt);
349
350 for mc in market_changes {
351 let is_snapshot = mc.img;
352 let mut market_closed = false;
353
354 if let Some(def) = &mc.market_definition {
355 match parse_market_definition(
359 &mc.id,
360 def,
361 currency,
362 ts_event,
363 ts_init,
364 min_notional,
365 ) {
366 Ok(new_instruments) => {
367 instruments.rcu(|m| {
368 for inst in &new_instruments {
369 m.insert(inst.id(), inst.clone());
370 }
371 });
372
373 for inst in new_instruments {
374 if let Err(e) =
375 data_sender.send(DataEvent::Instrument(inst))
376 {
377 log::warn!("Failed to send instrument: {e}");
378 }
379 }
380 }
381 Err(e) => {
382 log::warn!(
383 "Failed to parse market definition for {}: {e}",
384 mc.id
385 );
386 }
387 }
388
389 if let Some(status) = &def.status {
390 market_closed = *status == MarketStatus::Closed;
391
392 for event in
393 parse_instrument_statuses(&mc.id, def, ts_event, ts_init)
394 {
395 if let Err(e) =
396 data_sender.send(DataEvent::InstrumentStatus(event))
397 {
398 log::warn!("Failed to send instrument status: {e}");
399 }
400 }
401 }
402
403 for sp in parse_betfair_starting_prices(&mc.id, def, ts_event, ts_init)
404 {
405 let instrument_id = sp.instrument_id;
406 let custom =
407 custom_data_with_instrument(Arc::new(sp), instrument_id);
408
409 if let Err(e) =
410 data_sender.send(DataEvent::Data(Data::Custom(custom)))
411 {
412 log::warn!("Failed to send starting price: {e}");
413 }
414 }
415
416 for close in parse_instrument_closes(&mc.id, def, ts_event, ts_init) {
417 if let Err(e) =
418 data_sender.send(DataEvent::Data(Data::InstrumentClose(close)))
419 {
420 log::warn!("Failed to send instrument close: {e}");
421 }
422 }
423 }
424
425 let accepted_books = accept_market_change(
426 mc,
427 is_snapshot,
428 mcm.pt,
429 ts_event,
430 ts_init,
431 &book_sync,
432 &stream_client,
433 snapshot_timeout,
434 &tasks,
435 );
436 let accepted = accepted_books.is_some();
437 let mut runner_books = accepted_books.unwrap_or_default().into_iter();
438
439 let mut buffered_deltas: Vec<OrderBookDeltas> = Vec::new();
443 let mut buffered_bsp_customs: Vec<CustomData> = Vec::new();
444
445 if let Some(runner_changes) = &mc.rc {
446 for rc in runner_changes {
447 let handicap = rc.hc.unwrap_or(Decimal::ZERO);
448 let instrument_id = make_instrument_id(&mc.id, rc.id, handicap);
449
450 if let Some(deltas) = runner_books.next().flatten() {
451 route_runner_book(
452 deltas,
453 is_snapshot,
454 &data_sender,
455 &mut buffered_deltas,
456 );
457 }
458
459 if let Some(trades) = &rc.trd {
460 let mut volumes = traded_volumes.lock();
461
462 for pv in trades {
463 if pv.volume == Decimal::ZERO {
464 continue;
465 }
466
467 let key = (instrument_id, pv.price);
468 let prev_volume =
469 volumes.get(&key).copied().unwrap_or(Decimal::ZERO);
470
471 if pv.volume <= prev_volume {
472 volumes.insert(key, pv.volume);
473 continue;
474 }
475
476 let trade_volume = pv.volume - prev_volume;
477 volumes.insert(key, pv.volume);
478
479 let price = match parse_betfair_price(pv.price) {
480 Ok(p) => p,
481 Err(e) => {
482 log::warn!("Invalid trade price: {e}");
483 continue;
484 }
485 };
486 let size = match parse_betfair_quantity(trade_volume) {
487 Ok(q) => q,
488 Err(e) => {
489 log::warn!("Invalid trade size: {e}");
490 continue;
491 }
492 };
493 let trade_id = TradeId::new(format!(
494 "{}-{}-{}",
495 mcm.pt, rc.id, pv.price
496 ));
497 let tick: TradeTick = make_trade_tick(
498 instrument_id,
499 price,
500 size,
501 trade_id,
502 ts_event,
503 ts_init,
504 );
505
506 if let Err(e) =
507 data_sender.send(DataEvent::Data(Data::Trade(tick)))
508 {
509 log::warn!("Failed to send trade tick: {e}");
510 }
511 }
512 }
513
514 if let Some(ticker) =
515 parse_betfair_ticker(instrument_id, rc, ts_event, ts_init)
516 {
517 let custom = custom_data_with_instrument(
518 Arc::new(ticker),
519 instrument_id,
520 );
521
522 if let Err(e) =
523 data_sender.send(DataEvent::Data(Data::Custom(custom)))
524 {
525 log::warn!("Failed to send ticker: {e}");
526 }
527 }
528
529 for bsp_delta in
530 parse_bsp_book_deltas(instrument_id, rc, ts_event, ts_init)
531 {
532 buffered_bsp_customs.push(custom_data_with_instrument(
533 Arc::new(bsp_delta),
534 instrument_id,
535 ));
536 }
537 }
538 }
539
540 if accepted && is_snapshot {
541 send_omitted_runner_snapshots(
542 mc,
543 mcm.pt,
544 ts_event,
545 ts_init,
546 &data_sender,
547 );
548 }
549
550 for deltas in buffered_deltas {
551 send_book_deltas(&data_sender, deltas);
552 }
553
554 for custom in buffered_bsp_customs {
555 if let Err(e) = data_sender.send(DataEvent::Data(Data::Custom(custom)))
556 {
557 log::warn!("Failed to send BSP book delta: {e}");
558 }
559 }
560
561 if market_closed {
562 let prefix = format!("{}-", mc.id);
563
564 traded_volumes
565 .lock()
566 .retain(|k, _| !k.0.symbol.as_str().starts_with(&prefix));
567
568 book_sync.remove(&mc.id);
570 }
571 }
572
573 if sequence_complete {
574 let completed = BetfairSequenceCompleted::new(ts_event, ts_init);
575 let custom = CustomData::from_arc(Arc::new(completed));
576 if let Err(e) = data_sender.send(DataEvent::Data(Data::Custom(custom))) {
577 log::warn!("Failed to send sequence completed: {e}");
578 }
579 }
580 }
581 StreamMessage::Connection(_) => {
582 if has_initial_connection.swap(true, Ordering::SeqCst) {
583 log::info!("Betfair data stream reconnected");
584 book_sync.reset_on_reconnect();
585 let _ = reconnect_tx.send(());
586 } else {
587 log::debug!("Betfair data stream connected");
588 }
589 }
590 StreamMessage::Status(status) => {
591 if status.connection_closed {
592 log::warn!(
593 "Betfair stream closed: {:?} - {:?}",
594 status.error_code,
595 status.error_message,
596 );
597 }
598 }
599 StreamMessage::RaceChange(rcm) => {
600 if let Some(race_changes) = &rcm.rc {
601 let ts_event_fallback = parse_millis_timestamp(rcm.pt);
602
603 for rc in race_changes {
604 let race_id = rc.id.as_deref().unwrap_or("");
605 let market_id = rc.mid.as_deref().unwrap_or("");
606
607 if let Some(runners) = &rc.rrc {
608 for rrc in runners {
609 let ts_event =
610 rrc.ft.map_or(ts_event_fallback, parse_millis_timestamp);
611
612 if let Some(runner) = parse_race_runner_data(
613 race_id, market_id, rrc, ts_event, ts_init,
614 ) {
615 let selection_id = rrc.id.unwrap_or(0);
616 let mut metadata = Params::new();
617 metadata.insert(
618 "selection_id".to_string(),
619 serde_json::Value::Number(selection_id.into()),
620 );
621 let value: Arc<dyn CustomDataTrait> = Arc::new(runner);
622 let data_type =
623 DataType::new(value.type_name(), Some(metadata), None);
624 let custom = CustomData::new(value, data_type);
625
626 if let Err(e) =
627 data_sender.send(DataEvent::Data(Data::Custom(custom)))
628 {
629 log::warn!("Failed to send race runner data: {e}");
630 }
631 }
632 }
633 }
634
635 if let Some(rpc) = &rc.rpc {
636 let ts_event =
637 rpc.ft.map_or(ts_event_fallback, parse_millis_timestamp);
638
639 let progress =
640 parse_race_progress(race_id, market_id, rpc, ts_event, ts_init);
641 let mut metadata = Params::new();
642 metadata.insert(
643 "race_id".to_string(),
644 serde_json::Value::String(race_id.to_string()),
645 );
646 let value: Arc<dyn CustomDataTrait> = Arc::new(progress);
647 let data_type =
648 DataType::new(value.type_name(), Some(metadata), None);
649 let custom = CustomData::new(value, data_type);
650
651 if let Err(e) =
652 data_sender.send(DataEvent::Data(Data::Custom(custom)))
653 {
654 log::warn!("Failed to send race progress: {e}");
655 }
656 }
657 }
658 }
659 }
660 StreamMessage::CricketChange(ccm) => {
661 if let Some(cricket_changes) = &ccm.cc {
662 let ts_event = parse_millis_timestamp(ccm.pt);
663
664 for cricket_change in cricket_changes {
665 if let Some(cricket) =
666 parse_cricket_match(cricket_change, ts_event, ts_init)
667 {
668 let mut metadata = Params::new();
669 metadata.insert(
670 "event_id".to_string(),
671 serde_json::Value::String(cricket.event_id.clone()),
672 );
673 let value: Arc<dyn CustomDataTrait> = Arc::new(cricket);
674 let data_type =
675 DataType::new(value.type_name(), Some(metadata), None);
676 let custom = CustomData::new(value, data_type);
677
678 if let Err(e) =
679 data_sender.send(DataEvent::Data(Data::Custom(custom)))
680 {
681 log::warn!("Failed to send cricket match: {e}");
682 }
683 }
684 }
685 }
686 }
687 StreamMessage::OrderChange(_) => {}
688 }
689 })
690 }
691}
692
693#[async_trait(?Send)]
694impl DataClient for BetfairDataClient {
695 fn client_id(&self) -> ClientId {
696 self.client_id
697 }
698
699 fn venue(&self) -> Option<Venue> {
700 Some(*BETFAIR_VENUE)
701 }
702
703 fn start(&mut self) -> anyhow::Result<()> {
704 log::info!("Starting Betfair data client: {}", self.client_id);
705 Ok(())
706 }
707
708 fn stop(&mut self) -> anyhow::Result<()> {
709 log::info!("Stopping Betfair data client: {}", self.client_id);
710
711 self.session_tasks.begin_shutdown();
712 self.command_tasks.begin_shutdown();
713 self.begin_stream_shutdown();
714 self.is_connected.store(false, Ordering::Relaxed);
715
716 Ok(())
717 }
718
719 fn reset(&mut self) -> anyhow::Result<()> {
720 log::info!("Resetting Betfair data client: {}", self.client_id);
721
722 self.session_tasks.begin_shutdown();
723 self.command_tasks.begin_shutdown();
724 self.begin_stream_shutdown();
725
726 self.provider.store_mut().clear();
727 self.subscribed_market_ids.clear();
728 self.book_sync.clear();
729
730 self.instruments.store(AHashMap::new());
731 Ok(())
732 }
733
734 fn dispose(&mut self) -> anyhow::Result<()> {
735 log::debug!("Disposing Betfair data client: {}", self.client_id);
736 self.stop()
737 }
738
739 fn is_connected(&self) -> bool {
740 self.is_connected.load(Ordering::SeqCst)
741 && self.stream_client.as_ref().is_some_and(|client| {
742 client.is_authenticated()
743 && (self.subscribed_market_ids.is_empty() || client.is_market_ready())
744 })
745 }
746
747 fn is_disconnected(&self) -> bool {
748 !self.is_connected()
749 }
750
751 async fn connect(&mut self) -> anyhow::Result<()> {
752 if self.is_connected.load(Ordering::Acquire)
753 && self.session_tasks.is_open()
754 && self.command_tasks.is_open()
755 {
756 return Ok(());
757 }
758
759 self.prepare_task_groups().await?;
760 let stream_shutdowns = Arc::clone(&self.stream_shutdowns);
761 let setup_guard =
762 TaskGroupGuard::new(&[&self.session_tasks, &self.command_tasks], move || {
763 for stream in stream_shutdowns.lock().iter() {
764 stream.begin_shutdown();
765 }
766 });
767
768 register_betfair_custom_data();
769
770 self.http_client
771 .connect()
772 .await
773 .map_err(|e| anyhow::anyhow!("{e}"))?;
774
775 self.provider.load_all(None).await?;
776
777 let loaded: Vec<InstrumentAny> = self
778 .provider
779 .store()
780 .list_all()
781 .into_iter()
782 .cloned()
783 .collect();
784
785 self.instruments.rcu(|m| {
786 for inst in &loaded {
787 m.insert(inst.id(), inst.clone());
788 }
789 });
790
791 for inst in &loaded {
792 if let Err(e) = self.data_sender.send(DataEvent::Instrument(inst.clone())) {
793 log::warn!("Failed to send instrument: {e}");
794 }
795 }
796
797 log::debug!("Cached {} instruments for {}", loaded.len(), self.client_id,);
798
799 let session_token = self
800 .http_client
801 .session_token()
802 .await
803 .ok_or_else(|| anyhow::anyhow!("No session token after login"))?;
804
805 let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
806 let book_tasks = self
807 .command_tasks
808 .spawner()
809 .map_err(|e| anyhow::anyhow!("Betfair data command task admission is closed: {e}"))?;
810 let book_stream_client = Arc::new(OnceLock::new());
811
812 let handler = Self::create_stream_handler(
813 self.data_sender.clone(),
814 Arc::clone(&self.instruments),
815 self.currency,
816 self.provider.min_notional(),
817 reconnect_tx.clone(),
818 self.book_sync.clone(),
819 Arc::clone(&book_stream_client),
820 Duration::from_secs(self.config.book_snapshot_timeout_secs),
821 book_tasks.clone(),
822 self.clock,
823 );
824
825 let state_sink = self.socket_control.as_ref().map(SocketControl::sink);
826 let stream_client = BetfairStreamClient::connect_with_state_sink(
827 &self.credential,
828 session_token,
829 handler,
830 self.stream_config.clone(),
831 HeartbeatTimeoutSource::Server,
832 state_sink,
833 )
834 .await
835 .map_err(|e| anyhow::anyhow!("{e}"))?;
836
837 let stream_client = Arc::new(stream_client);
838 let _ = book_stream_client.set(Arc::downgrade(&stream_client));
839
840 if let Some(control) = &self.socket_control {
841 let reconnect_stream = Arc::clone(&stream_client);
842 control.register(move || reconnect_stream.request_reconnect_outcome());
843 }
844 self.stream_client = Some(stream_client);
845 self.stream_shutdowns
846 .lock()
847 .push(BetfairStreamShutdown::Exchange(Arc::clone(
848 self.stream_client.as_ref().expect("stream client assigned"),
849 )));
850
851 let session_result = async {
852 if self.config.subscribe_race_data {
853 let race_config = BetfairStreamConfig {
854 host: BETFAIR_RACE_STREAM_HOST.to_string(),
855 ..self.stream_config.clone()
856 };
857
858 let race_session = self
859 .http_client
860 .session_token()
861 .await
862 .ok_or_else(|| anyhow::anyhow!("No session token for race stream"))?;
863
864 let race_handler = Self::create_stream_handler(
866 self.data_sender.clone(),
867 Arc::clone(&self.instruments),
868 self.currency,
869 self.provider.min_notional(),
870 reconnect_tx.clone(),
871 BookSyncTracker::default(),
872 Arc::new(OnceLock::new()),
873 Duration::ZERO,
874 book_tasks.clone(),
875 self.clock,
876 );
877
878 let (race_fatal_tx, mut race_fatal_rx) = tokio::sync::mpsc::unbounded_channel();
879
880 let state_sink = self
881 .race_socket_control
882 .as_ref()
883 .map(|control| control.sink());
884
885 match BetfairRaceStreamClient::connect_decoded(
886 &self.credential,
887 race_session,
888 race_handler,
889 race_config,
890 race_fatal_tx,
891 state_sink,
892 )
893 .await
894 {
895 Ok(client) => {
896 let race_client = Arc::new(client);
897 if let Some(control) = &self.race_socket_control {
898 let reconnect_client = Arc::clone(&race_client);
899 control.register(move || reconnect_client.request_reconnect_outcome());
900 }
901 self.race_stream_client = Some(Arc::clone(&race_client));
902 self.stream_shutdowns
903 .lock()
904 .push(BetfairStreamShutdown::Auxiliary(Arc::clone(&race_client)));
905
906 let race_socket_control = self.race_socket_control.as_ref().map(Arc::clone);
907
908 self.session_tasks
909 .spawn(async move {
910 if race_fatal_rx.recv().await.is_some() {
911 log::error!(
912 "Betfair race stream permanently disabled due to fatal error"
913 );
914 race_client.close().await;
915
916 if let Some(control) = race_socket_control {
917 control.deregister();
918 }
919 }
920 })
921 .map_err(|e| {
922 anyhow::anyhow!("Failed to register Betfair race fatal task: {e}")
923 })?;
924
925 log::debug!("Betfair race stream connected");
926 }
927 Err(e) => {
928 log::warn!("Betfair race stream connect failed: {e}");
929
930 if let Some(control) = &self.race_socket_control {
931 control.deregister();
932 }
933 self.race_stream_client = None;
934 }
935 }
936 }
937
938 if self.config.subscribe_cricket_data {
939 let cricket_config = BetfairStreamConfig {
940 host: BETFAIR_RACE_STREAM_HOST.to_string(),
941 ..self.stream_config.clone()
942 };
943
944 let cricket_session = self
945 .http_client
946 .session_token()
947 .await
948 .ok_or_else(|| anyhow::anyhow!("No session token for cricket stream"))?;
949
950 let cricket_handler = Self::create_stream_handler(
952 self.data_sender.clone(),
953 Arc::clone(&self.instruments),
954 self.currency,
955 self.provider.min_notional(),
956 reconnect_tx.clone(),
957 BookSyncTracker::default(),
958 Arc::new(OnceLock::new()),
959 Duration::ZERO,
960 book_tasks.clone(),
961 self.clock,
962 );
963
964 let (cricket_fatal_tx, mut cricket_fatal_rx) =
965 tokio::sync::mpsc::unbounded_channel();
966
967 let state_sink = self
968 .cricket_socket_control
969 .as_ref()
970 .map(|control| control.sink());
971
972 match BetfairRaceStreamClient::connect_cricket_decoded(
973 &self.credential,
974 cricket_session,
975 cricket_handler,
976 cricket_config,
977 cricket_fatal_tx,
978 state_sink,
979 )
980 .await
981 {
982 Ok(client) => {
983 let cricket_client = Arc::new(client);
984 if let Some(control) = &self.cricket_socket_control {
985 let reconnect_client = Arc::clone(&cricket_client);
986 control.register(move || reconnect_client.request_reconnect_outcome());
987 }
988 self.cricket_stream_client = Some(Arc::clone(&cricket_client));
989 self.stream_shutdowns
990 .lock()
991 .push(BetfairStreamShutdown::Auxiliary(Arc::clone(
992 &cricket_client,
993 )));
994
995 let cricket_socket_control =
996 self.cricket_socket_control.as_ref().map(Arc::clone);
997
998 self.session_tasks
999 .spawn(async move {
1000 if cricket_fatal_rx.recv().await.is_some() {
1001 log::error!(
1002 "Betfair cricket stream permanently disabled due to fatal error"
1003 );
1004 cricket_client.close().await;
1005
1006 if let Some(control) = cricket_socket_control {
1007 control.deregister();
1008 }
1009 }
1010 })
1011 .map_err(|e| {
1012 anyhow::anyhow!("Failed to register Betfair cricket fatal task: {e}")
1013 })?;
1014
1015 log::debug!("Betfair cricket stream connected");
1016 }
1017 Err(e) => {
1018 log::warn!("Betfair cricket stream connect failed: {e}");
1019
1020 if let Some(control) = &self.cricket_socket_control {
1021 control.deregister();
1022 }
1023 self.cricket_stream_client = None;
1024 }
1025 }
1026 }
1027
1028 let keep_alive_client = Arc::clone(&self.http_client);
1029 let keep_alive_stream = Arc::clone(self.stream_client.as_ref().unwrap());
1030 let keep_alive_race_stream = self.race_stream_client.as_ref().map(Arc::clone);
1031 let keep_alive_cricket_stream = self.cricket_stream_client.as_ref().map(Arc::clone);
1032 let keep_alive_app_key = self.credential.app_key().to_string();
1033
1034 self.session_tasks
1035 .spawn(async move {
1036 let interval = tokio::time::Duration::from_secs(KEEP_ALIVE_INTERVAL_SECS);
1037 loop {
1038 tokio::time::sleep(interval).await;
1039
1040 let session_replaced = match keep_alive_client.keep_alive_with_token().await
1041 {
1042 Ok(_) => false,
1043 Err(ref e) if e.is_login_failed() => {
1044 log::warn!("Betfair session expired, attempting re-login: {e}");
1045
1046 match keep_alive_client.reconnect_with_token().await {
1047 Ok(_) => true,
1048 Err(e) => {
1049 log::warn!("Betfair re-login failed: {e}");
1050 continue;
1051 }
1052 }
1053 }
1054 Err(e) => {
1055 log::warn!("Betfair keep-alive failed (transient): {e}");
1056 continue;
1057 }
1058 };
1059
1060 let _ = keep_alive_client
1061 .with_session_token(|token| {
1062 refresh_stream_sessions(
1063 keep_alive_stream.as_ref(),
1064 keep_alive_race_stream.as_deref(),
1065 keep_alive_cricket_stream.as_deref(),
1066 &keep_alive_app_key,
1067 token,
1068 session_replaced,
1069 );
1070 })
1071 .await;
1072 log::debug!("Betfair session keep-alive sent");
1073 }
1074 })
1075 .map_err(|e| anyhow::anyhow!("Failed to register Betfair keep-alive task: {e}"))?;
1076
1077 let reconnect_http = Arc::clone(&self.http_client);
1078 let reconnect_stream = Arc::clone(self.stream_client.as_ref().unwrap());
1079 let reconnect_race_stream = self.race_stream_client.as_ref().map(Arc::clone);
1080 let reconnect_cricket_stream = self.cricket_stream_client.as_ref().map(Arc::clone);
1081 let reconnect_app_key = self.credential.app_key().to_string();
1082
1083 self.session_tasks
1084 .spawn(async move {
1085 while reconnect_rx.recv().await.is_some() {
1086 log::info!("Handling data stream reconnection");
1087
1088 let session_replaced = match reconnect_http.keep_alive_with_token().await {
1089 Ok(_) => false,
1090 Err(ref e) if e.is_login_failed() => {
1091 log::warn!(
1092 "Session expired on reconnect, attempting re-login: {e}"
1093 );
1094
1095 match reconnect_http.reconnect_with_token().await {
1096 Ok(_) => true,
1097 Err(e) => {
1098 log::warn!("Re-login failed on reconnect: {e}");
1099 continue;
1100 }
1101 }
1102 }
1103 Err(e) => {
1104 log::warn!("Keep-alive failed on reconnect (transient): {e}");
1105 continue;
1106 }
1107 };
1108
1109 let _ = reconnect_http
1110 .with_session_token(|token| {
1111 refresh_stream_sessions(
1112 reconnect_stream.as_ref(),
1113 reconnect_race_stream.as_deref(),
1114 reconnect_cricket_stream.as_deref(),
1115 &reconnect_app_key,
1116 token,
1117 session_replaced,
1118 );
1119 })
1120 .await;
1121 }
1122 })
1123 .map_err(|e| anyhow::anyhow!("Failed to register Betfair reconnect task: {e}"))?;
1124
1125 Ok::<(), anyhow::Error>(())
1126 }
1127 .await;
1128
1129 if let Err(e) = session_result {
1130 if let Err(teardown_error) = self.teardown_partial_connect().await {
1131 return Err(e.context(format!(
1132 "Betfair data startup teardown failed: {teardown_error}"
1133 )));
1134 }
1135 return Err(e);
1136 }
1137
1138 self.is_connected.store(true, Ordering::Release);
1139 setup_guard.disarm();
1140
1141 log::info!("Betfair data client connected: {}", self.client_id);
1142 Ok(())
1143 }
1144
1145 async fn disconnect(&mut self) -> anyhow::Result<()> {
1146 self.teardown_partial_connect().await?;
1147 self.subscribed_market_ids.clear();
1148 self.book_sync.clear();
1149
1150 log::info!("Betfair data client disconnected: {}", self.client_id);
1151 Ok(())
1152 }
1153
1154 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
1155 let instrument_id = cmd.instrument_id;
1156 let market_id = extract_market_id(&instrument_id)?;
1157
1158 if self.subscribed_market_ids.contains(&market_id) {
1159 log::debug!("Book deltas already subscribed for market {market_id}");
1160 return Ok(());
1161 }
1162
1163 let stream_client = Arc::clone(
1164 self.stream_client
1165 .as_ref()
1166 .ok_or_else(|| anyhow::anyhow!("Stream client not connected"))?,
1167 );
1168
1169 let tasks = match self.command_tasks.spawner() {
1170 Ok(tasks) => tasks,
1171 Err(e) => {
1172 log::warn!("Skipping Betfair data command after shutdown began: {e}");
1173 return Ok(());
1174 }
1175 };
1176
1177 self.subscribed_market_ids.insert(market_id.clone());
1178
1179 let market_filter = market_subscription_filter(&self.subscribed_market_ids);
1182 let conflate_ms = self.config.stream_conflate_ms;
1183
1184 let subscribe = |gate: &SnapshotGate| {
1185 stream_client.write_market_subscription(
1186 market_filter,
1187 market_data_filter(),
1188 None,
1189 conflate_ms,
1190 gate,
1191 )
1192 };
1193
1194 spawn_subscription_task(
1195 market_id,
1196 subscribe,
1197 self.book_sync.clone(),
1198 Arc::clone(&stream_client),
1199 Duration::from_secs(self.config.book_snapshot_timeout_secs),
1200 &tasks,
1201 );
1202
1203 Ok(())
1204 }
1205
1206 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1207 log::debug!(
1208 "Skipping unsubscribe book deltas for Betfair: {}",
1209 cmd.instrument_id
1210 );
1211 Ok(())
1212 }
1213
1214 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
1215 log::debug!(
1217 "Trade data included in book subscription for {}",
1218 cmd.instrument_id
1219 );
1220 Ok(())
1221 }
1222
1223 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1224 log::debug!(
1225 "Skipping unsubscribe trades for Betfair: {}",
1226 cmd.instrument_id
1227 );
1228 Ok(())
1229 }
1230
1231 fn subscribe_instrument_status(
1232 &mut self,
1233 cmd: SubscribeInstrumentStatus,
1234 ) -> anyhow::Result<()> {
1235 log::debug!(
1237 "Instrument status included in book subscription for {}",
1238 cmd.instrument_id
1239 );
1240 Ok(())
1241 }
1242
1243 fn unsubscribe_instrument_status(
1244 &mut self,
1245 cmd: &UnsubscribeInstrumentStatus,
1246 ) -> anyhow::Result<()> {
1247 log::debug!(
1248 "Skipping unsubscribe instrument status for Betfair: {}",
1249 cmd.instrument_id
1250 );
1251 Ok(())
1252 }
1253
1254 fn subscribe_instrument_close(&mut self, cmd: SubscribeInstrumentClose) -> anyhow::Result<()> {
1255 log::debug!(
1258 "Instrument close included in book subscription for {}",
1259 cmd.instrument_id
1260 );
1261 Ok(())
1262 }
1263
1264 fn unsubscribe_instrument_close(
1265 &mut self,
1266 cmd: &UnsubscribeInstrumentClose,
1267 ) -> anyhow::Result<()> {
1268 log::debug!(
1269 "Skipping unsubscribe instrument close for Betfair: {}",
1270 cmd.instrument_id
1271 );
1272 Ok(())
1273 }
1274
1275 fn unsubscribe(&mut self, _cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
1276 log::debug!("Skipping unsubscribe custom data for Betfair");
1277 Ok(())
1278 }
1279
1280 fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
1281 log::debug!(
1282 "Skipping unsubscribe instrument for Betfair: {}",
1283 cmd.instrument_id
1284 );
1285 Ok(())
1286 }
1287
1288 fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
1289 log::debug!("Skipping unsubscribe instruments for Betfair");
1290 Ok(())
1291 }
1292
1293 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1294 log::debug!(
1295 "Skipping unsubscribe quotes for Betfair: {}",
1296 cmd.instrument_id
1297 );
1298 Ok(())
1299 }
1300
1301 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1302 log::debug!("Skipping unsubscribe bars for Betfair: {}", cmd.bar_type);
1303 Ok(())
1304 }
1305}
1306
1307impl BetfairDataClient {
1308 fn deregister_socket_controls(&self) {
1309 let controls = [
1310 self.socket_control.as_ref(),
1311 self.race_socket_control.as_deref(),
1312 self.cricket_socket_control.as_deref(),
1313 ];
1314
1315 for control in controls.into_iter().flatten() {
1316 control.deregister();
1317 }
1318 }
1319}
1320
1321#[derive(Clone, Debug)]
1322enum BetfairStreamShutdown {
1323 Exchange(Arc<BetfairStreamClient>),
1324 Auxiliary(Arc<BetfairRaceStreamClient>),
1325}
1326
1327impl BetfairStreamShutdown {
1328 fn begin_shutdown(&self) {
1329 match self {
1330 Self::Exchange(client) => client.begin_shutdown(),
1331 Self::Auxiliary(client) => client.begin_shutdown(),
1332 }
1333 }
1334}
1335
1336fn market_subscription_filter(market_ids: &AHashSet<String>) -> StreamMarketFilter {
1337 let market_ids = market_ids.iter().cloned().collect();
1338
1339 StreamMarketFilter {
1340 market_ids: Some(market_ids),
1341 ..Default::default()
1342 }
1343}
1344
1345fn market_data_filter() -> MarketDataFilter {
1346 MarketDataFilter {
1347 fields: Some(vec![
1348 MarketDataFilterField::ExAllOffers,
1349 MarketDataFilterField::ExTraded,
1350 MarketDataFilterField::ExTradedVol,
1351 MarketDataFilterField::ExLtp,
1352 MarketDataFilterField::ExMarketDef,
1353 MarketDataFilterField::SpTraded,
1354 MarketDataFilterField::SpProjected,
1355 ]),
1356 ladder_levels: None,
1357 }
1358}
1359
1360#[expect(clippy::too_many_arguments)]
1364fn accept_market_change(
1365 mc: &MarketChange,
1366 is_image: bool,
1367 publish_time: u64,
1368 ts_event: UnixNanos,
1369 ts_init: UnixNanos,
1370 book_sync: &BookSyncTracker,
1371 stream_client: &OnceLock<Weak<BetfairStreamClient>>,
1372 snapshot_timeout: Duration,
1373 tasks: &TaskSpawner,
1374) -> Option<Vec<Option<OrderBookDeltas>>> {
1375 let parsed = mc
1376 .rc
1377 .iter()
1378 .flatten()
1379 .map(|rc| {
1380 let instrument_id = make_instrument_id(&mc.id, rc.id, rc.hc.unwrap_or(Decimal::ZERO));
1381 parse_runner_book_deltas(instrument_id, rc, is_image, publish_time, ts_event, ts_init)
1382 })
1383 .collect::<anyhow::Result<Vec<_>>>();
1384
1385 let outcome = match &parsed {
1386 Ok(_) => book_sync.validate(&mc.id, is_image, Instant::now()),
1387 Err(e) => {
1388 log::warn!(
1389 "Failed to parse book deltas for market {}: {e}; requesting a fresh image",
1390 mc.id
1391 );
1392 let error = BetfairStreamError::ProtocolError(e.to_string());
1393 book_sync.reject_change(&mc.id, is_image, error)
1394 }
1395 };
1396
1397 match outcome {
1398 BookSequenceOutcome::Accept => parsed.ok(),
1399 BookSequenceOutcome::Suppress => None,
1400 BookSequenceOutcome::Recover => {
1401 recover_market_book(&mc.id, book_sync, stream_client, snapshot_timeout, tasks);
1402 None
1403 }
1404 }
1405}
1406
1407fn recover_market_book(
1409 market_id: &str,
1410 book_sync: &BookSyncTracker,
1411 stream_client: &OnceLock<Weak<BetfairStreamClient>>,
1412 snapshot_timeout: Duration,
1413 tasks: &TaskSpawner,
1414) {
1415 let Some(stream_client) = stream_client.get().and_then(Weak::upgrade) else {
1416 return;
1417 };
1418
1419 start_recovery(market_id, book_sync, stream_client, snapshot_timeout, tasks);
1420}
1421
1422fn route_runner_book(
1424 deltas: OrderBookDeltas,
1425 is_image: bool,
1426 data_sender: &EventSender<DataEvent>,
1427 buffered_deltas: &mut Vec<OrderBookDeltas>,
1428) {
1429 if is_image {
1430 send_book_deltas(data_sender, deltas);
1431 } else {
1432 buffered_deltas.push(deltas);
1433 }
1434}
1435
1436fn send_omitted_runner_snapshots(
1439 mc: &MarketChange,
1440 publish_time: u64,
1441 ts_event: UnixNanos,
1442 ts_init: UnixNanos,
1443 data_sender: &EventSender<DataEvent>,
1444) {
1445 let Some(runners) = mc
1446 .market_definition
1447 .as_ref()
1448 .and_then(|def| def.runners.as_ref())
1449 else {
1450 return;
1451 };
1452
1453 for runner in runners {
1454 let handicap = runner.hc.unwrap_or(Decimal::ZERO);
1455 let imaged = mc
1456 .rc
1457 .iter()
1458 .flatten()
1459 .any(|rc| rc.id == runner.id && rc.hc.unwrap_or(Decimal::ZERO) == handicap);
1460
1461 if imaged {
1462 continue;
1463 }
1464
1465 let instrument_id = make_instrument_id(&mc.id, runner.id, handicap);
1466 let mut clear = OrderBookDelta::clear(instrument_id, publish_time, ts_event, ts_init);
1467 clear.flags |= RecordFlag::F_LAST as u8;
1468 send_book_deltas(
1469 data_sender,
1470 OrderBookDeltas::new(instrument_id, vec![clear]),
1471 );
1472 }
1473}
1474
1475fn send_book_deltas(data_sender: &EventSender<DataEvent>, deltas: OrderBookDeltas) {
1476 if let Err(e) = data_sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas)))) {
1477 log::warn!("Failed to send book deltas: {e}");
1478 }
1479}
1480
1481fn refresh_stream_sessions(
1482 stream: &BetfairStreamClient,
1483 race_stream: Option<&BetfairRaceStreamClient>,
1484 cricket_stream: Option<&BetfairRaceStreamClient>,
1485 app_key: &str,
1486 token: &SecretString,
1487 session_replaced: bool,
1488) {
1489 stream.update_auth(app_key, token.clone());
1490
1491 if let Some(race_stream) = race_stream {
1492 race_stream.update_auth(app_key, token.clone());
1493 }
1494
1495 if let Some(cricket_stream) = cricket_stream {
1496 cricket_stream.update_auth(app_key, token.clone());
1497 }
1498
1499 if !session_replaced {
1500 return;
1501 }
1502
1503 let _ = stream.request_reconnect();
1504
1505 if let Some(race_stream) = race_stream {
1506 let _ = race_stream.request_reconnect();
1507 }
1508
1509 if let Some(cricket_stream) = cricket_stream {
1510 let _ = cricket_stream.request_reconnect();
1511 }
1512}
1513
1514#[cfg(test)]
1515mod tests {
1516 use nautilus_core::UnixNanos;
1517 use nautilus_model::enums::BookAction;
1518 use rstest::rstest;
1519
1520 use super::*;
1521 use crate::{
1522 common::testing::load_test_json,
1523 data_types::{BetfairCricketMatch, BetfairRaceRunnerData, BetfairSequenceCompleted},
1524 stream::messages::stream_decode,
1525 };
1526
1527 fn stream_handler_at(
1528 ts_init: UnixNanos,
1529 ) -> (
1530 StreamMessageHandler,
1531 tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
1532 ) {
1533 stream_handler_with_books(ts_init, BookSyncTracker::default())
1534 }
1535
1536 fn stream_handler_with_books(
1537 ts_init: UnixNanos,
1538 book_sync: BookSyncTracker,
1539 ) -> (
1540 StreamMessageHandler,
1541 tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
1542 ) {
1543 let (data_tx, data_rx) = tokio::sync::mpsc::unbounded_channel();
1544 let (reconnect_tx, _reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
1545 let clock = Box::leak(Box::new(AtomicTime::new(false, ts_init)));
1546 let tasks = TaskGroup::new().spawner().unwrap();
1547 let handler = BetfairDataClient::create_stream_handler(
1548 data_tx.into(),
1549 Arc::new(AtomicMap::new()),
1550 Currency::GBP(),
1551 None,
1552 reconnect_tx,
1553 book_sync,
1554 Arc::new(OnceLock::new()),
1555 Duration::ZERO,
1556 tasks,
1557 clock,
1558 );
1559
1560 (handler, data_rx)
1561 }
1562
1563 fn count_books_and_trades(
1564 data_rx: &mut tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
1565 ) -> (usize, usize) {
1566 let mut books = 0;
1567 let mut trades = 0;
1568
1569 while let Ok(event) = data_rx.try_recv() {
1570 match event {
1571 DataEvent::Data(Data::BookDeltas(_)) => books += 1,
1572 DataEvent::Data(Data::Trade(_)) => trades += 1,
1573 _ => {}
1574 }
1575 }
1576
1577 (books, trades)
1578 }
1579
1580 fn receive_custom<T: 'static>(
1581 data_rx: &mut tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
1582 ) -> Arc<dyn CustomDataTrait> {
1583 while let Ok(event) = data_rx.try_recv() {
1584 if let DataEvent::Data(Data::Custom(custom)) = event
1585 && custom.data.as_any().is::<T>()
1586 {
1587 return custom.data;
1588 }
1589 }
1590
1591 panic!("expected {} custom data", std::any::type_name::<T>());
1592 }
1593
1594 #[rstest]
1595 fn test_stream_handler_sets_mcm_init_from_clock() {
1596 let ts_init = UnixNanos::from(1_800_000_000_000_000_001);
1597
1598 let (handler, mut data_rx) = stream_handler_at(ts_init);
1599 let data = load_test_json("stream/mcm_UPDATE.json");
1600
1601 handler(stream_decode(data.as_bytes()).unwrap());
1602
1603 let custom = receive_custom::<BetfairSequenceCompleted>(&mut data_rx);
1604 let completed = custom
1605 .as_any()
1606 .downcast_ref::<BetfairSequenceCompleted>()
1607 .unwrap();
1608
1609 assert_eq!(
1610 completed.ts_event,
1611 UnixNanos::from(1_471_370_160_471_000_000)
1612 );
1613 assert_eq!(completed.ts_init, ts_init);
1614 }
1615
1616 #[rstest]
1617 fn test_stream_handler_completes_segmented_mcm_on_final_segment() {
1618 let ts_init = UnixNanos::from(1_800_000_000_000_000_005);
1619 let (handler, mut data_rx) = stream_handler_at(ts_init);
1620 let data = load_test_json("stream/mcm_SEGMENTS.jsonl");
1621 let mut segments = data.lines();
1622
1623 handler(stream_decode(segments.next().unwrap().as_bytes()).unwrap());
1624 handler(stream_decode(segments.next().unwrap().as_bytes()).unwrap());
1625
1626 assert!(data_rx.try_recv().is_err());
1627
1628 handler(stream_decode(segments.next().unwrap().as_bytes()).unwrap());
1629
1630 let custom = receive_custom::<BetfairSequenceCompleted>(&mut data_rx);
1631 let completed = custom
1632 .as_any()
1633 .downcast_ref::<BetfairSequenceCompleted>()
1634 .unwrap();
1635 assert_eq!(
1636 completed.ts_event,
1637 UnixNanos::from(1_700_000_000_000_000_000)
1638 );
1639 assert_eq!(completed.ts_init, ts_init);
1640 assert!(segments.next().is_none());
1641 assert!(data_rx.try_recv().is_err());
1642 }
1643
1644 #[rstest]
1645 fn test_stream_handler_stress_completes_each_segmented_mcm_once() {
1646 const SEQUENCE_COUNT: usize = 1_024;
1647 const MAX_MIDDLE_SEGMENTS: usize = 15;
1648
1649 let ts_init = UnixNanos::from(1_800_000_000_000_000_006);
1650 let (handler, mut data_rx) = stream_handler_at(ts_init);
1651 let data = load_test_json("stream/mcm_SEGMENTS.jsonl");
1652 let segments = data.lines().collect::<Vec<_>>();
1653
1654 for sequence in 0..SEQUENCE_COUNT {
1655 handler(stream_decode(segments[0].as_bytes()).unwrap());
1656 for _ in 0..sequence % (MAX_MIDDLE_SEGMENTS + 1) {
1657 handler(stream_decode(segments[1].as_bytes()).unwrap());
1658 }
1659
1660 assert!(data_rx.try_recv().is_err());
1661
1662 handler(stream_decode(segments[2].as_bytes()).unwrap());
1663
1664 let custom = receive_custom::<BetfairSequenceCompleted>(&mut data_rx);
1665 let completed = custom
1666 .as_any()
1667 .downcast_ref::<BetfairSequenceCompleted>()
1668 .unwrap();
1669 assert_eq!(
1670 completed.ts_event,
1671 UnixNanos::from(1_700_000_000_000_000_000)
1672 );
1673 assert_eq!(completed.ts_init, ts_init);
1674 assert!(data_rx.try_recv().is_err());
1675 }
1676 }
1677
1678 #[rstest]
1679 fn test_stream_handler_sets_rcm_init_from_clock() {
1680 let ts_init = UnixNanos::from(1_800_000_000_000_000_002);
1681
1682 let (handler, mut data_rx) = stream_handler_at(ts_init);
1683 let data = load_test_json("stream/rcm_single.json");
1684
1685 handler(stream_decode(data.as_bytes()).unwrap());
1686
1687 let custom = receive_custom::<BetfairRaceRunnerData>(&mut data_rx);
1688 let runner = custom
1689 .as_any()
1690 .downcast_ref::<BetfairRaceRunnerData>()
1691 .unwrap();
1692
1693 assert_eq!(runner.ts_event, UnixNanos::from(1_518_626_674_000_000_000));
1694 assert_eq!(runner.ts_init, ts_init);
1695 }
1696
1697 #[rstest]
1698 fn test_stream_handler_uses_rcm_publish_time_without_feed_time() {
1699 let ts_init = UnixNanos::from(1_800_000_000_000_000_003);
1700
1701 let (handler, mut data_rx) = stream_handler_at(ts_init);
1702 let data = load_test_json("stream/rcm_single.json");
1703 let mut message: serde_json::Value = serde_json::from_str(&data).unwrap();
1704 message
1705 .pointer_mut("/rc/0/rrc/0")
1706 .unwrap()
1707 .as_object_mut()
1708 .unwrap()
1709 .remove("ft");
1710 let data = message.to_string();
1711
1712 handler(stream_decode(data.as_bytes()).unwrap());
1713
1714 let custom = receive_custom::<BetfairRaceRunnerData>(&mut data_rx);
1715 let runner = custom
1716 .as_any()
1717 .downcast_ref::<BetfairRaceRunnerData>()
1718 .unwrap();
1719
1720 assert_eq!(runner.ts_event, UnixNanos::from(1_518_626_764_000_000_000));
1721 assert_eq!(runner.ts_init, ts_init);
1722 }
1723
1724 #[rstest]
1725 fn test_stream_handler_emits_cricket_match_custom_data() {
1726 let ts_init = UnixNanos::from(1_800_000_000_000_000_004);
1727
1728 let (handler, mut data_rx) = stream_handler_at(ts_init);
1729 let data = load_test_json("stream/ccm_single.json");
1730
1731 handler(stream_decode(data.as_bytes()).unwrap());
1732
1733 let event = data_rx.try_recv().expect("expected cricket custom data");
1734 let DataEvent::Data(Data::Custom(custom)) = event else {
1735 panic!("expected cricket custom data event, was {event:?}");
1736 };
1737 let cricket = custom
1738 .data
1739 .as_any()
1740 .downcast_ref::<BetfairCricketMatch>()
1741 .expect("custom data must be BetfairCricketMatch");
1742 let metadata = custom.data_type.metadata().expect("event metadata");
1743
1744 assert_eq!(cricket.event_id, "35741575");
1745 assert_eq!(cricket.market_id, "1.259334639");
1746 assert_eq!(cricket.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
1747 assert_eq!(cricket.ts_init, ts_init);
1748 assert_eq!(
1749 metadata.get("event_id"),
1750 Some(&serde_json::Value::String("35741575".to_string())),
1751 );
1752 assert!(
1753 data_rx.try_recv().is_err(),
1754 "CCM fixture must emit exactly one event"
1755 );
1756 }
1757
1758 #[rstest]
1759 fn test_stream_handler_suppresses_books_of_untracked_market() {
1760 let (handler, mut data_rx) =
1761 stream_handler_with_books(UnixNanos::default(), BookSyncTracker::default());
1762 let data = load_test_json("stream/mcm_live_IMAGE.json");
1763
1764 handler(stream_decode(data.as_bytes()).unwrap());
1765
1766 assert_eq!(count_books_and_trades(&mut data_rx), (0, 10));
1767 }
1768
1769 #[rstest]
1770 fn test_stream_handler_closed_market_stops_tracking_book() {
1771 let market_id = "1.185781465";
1772 let book_sync = BookSyncTracker::default();
1773 book_sync.record_subscription(market_id, Instant::now(), SnapshotGate::default());
1774 let (handler, _data_rx) =
1775 stream_handler_with_books(UnixNanos::default(), book_sync.clone());
1776 let frames: serde_json::Value =
1777 serde_json::from_str(&load_test_json("stream/mcm_BSP.json")).unwrap();
1778 let image = frames[0].to_string();
1779 let settled = load_test_json("stream/mcm_BSP_settled.json");
1780
1781 handler(stream_decode(image.as_bytes()).unwrap());
1782 let synced = book_sync.validate(market_id, false, Instant::now());
1783 handler(stream_decode(settled.as_bytes()).unwrap());
1784 let closed = book_sync.validate(market_id, false, Instant::now());
1785
1786 assert_eq!(synced, BookSequenceOutcome::Accept);
1787 assert_eq!(closed, BookSequenceOutcome::Suppress);
1788 }
1789
1790 #[rstest]
1791 fn test_stream_handler_image_start_clears_image_request() {
1792 let book_sync = BookSyncTracker::default();
1793 let now = Instant::now();
1794 book_sync
1795 .request_image(Duration::ZERO, now, &SnapshotGate::default(), || Ok(()))
1796 .unwrap();
1797 let (handler, _data_rx) =
1798 stream_handler_with_books(UnixNanos::default(), book_sync.clone());
1799 let update = load_test_json("stream/mcm_live_UPDATE.json");
1800 let image = load_test_json("stream/mcm_live_IMAGE.json");
1801 let mut writes = Vec::new();
1802
1803 handler(stream_decode(update.as_bytes()).unwrap());
1804 book_sync
1805 .request_image(
1806 Duration::from_secs(10),
1807 now,
1808 &SnapshotGate::default(),
1809 || {
1810 writes.push("after update");
1811 Ok(())
1812 },
1813 )
1814 .unwrap();
1815
1816 handler(stream_decode(image.as_bytes()).unwrap());
1817 book_sync
1818 .request_image(
1819 Duration::from_secs(10),
1820 now,
1821 &SnapshotGate::default(),
1822 || {
1823 writes.push("after image");
1824 Ok(())
1825 },
1826 )
1827 .unwrap();
1828
1829 assert_eq!(writes, ["after image"]);
1830 }
1831
1832 #[rstest]
1835 #[case::selection(false, Decimal::ZERO, 7)]
1836 #[case::handicap(true, Decimal::new(5, 1), 8)]
1837 fn test_stream_handler_image_clears_runner_it_omits(
1838 #[case] omit_by_handicap: bool,
1839 #[case] omitted_handicap: Decimal,
1840 #[case] expected_books: usize,
1841 ) {
1842 let market_id = "1.180737206";
1843 let omitted = make_instrument_id(market_id, 10_147_870, omitted_handicap);
1844 let book_sync = BookSyncTracker::default();
1845 book_sync.record_subscription(market_id, Instant::now(), SnapshotGate::default());
1846 let (handler, mut data_rx) = stream_handler_with_books(UnixNanos::default(), book_sync);
1847 let mut image: serde_json::Value =
1848 serde_json::from_str(&load_test_json("stream/mcm_SUB_IMAGE.json")).unwrap();
1849
1850 if omit_by_handicap {
1851 let runners = image["mc"][0]["marketDefinition"]["runners"]
1852 .as_array_mut()
1853 .unwrap();
1854 let mut handicapped = runners
1855 .iter()
1856 .find(|runner| runner["id"] == "10147870")
1857 .unwrap()
1858 .clone();
1859 handicapped["hc"] = serde_json::json!(0.5);
1860 runners.push(handicapped);
1861 } else {
1862 image["mc"][0]["rc"]
1863 .as_array_mut()
1864 .unwrap()
1865 .retain(|rc| rc["id"] != "10147870");
1866 }
1867
1868 handler(stream_decode(image.to_string().as_bytes()).unwrap());
1869
1870 let mut books = Vec::new();
1871
1872 while let Ok(event) = data_rx.try_recv() {
1873 if let DataEvent::Data(Data::BookDeltas(deltas)) = event {
1874 books.push(*deltas);
1875 }
1876 }
1877
1878 let cleared = books
1879 .iter()
1880 .find(|deltas| deltas.instrument_id == omitted)
1881 .unwrap();
1882 assert_eq!(books.len(), expected_books);
1883 assert_eq!(cleared.deltas.len(), 1);
1884 assert_eq!(cleared.deltas[0].action, BookAction::Clear);
1885 assert_eq!(
1886 cleared.deltas[0].flags,
1887 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1888 );
1889 }
1890
1891 #[rstest]
1892 fn test_stream_handler_image_without_market_suspends_its_book() {
1893 let imaged_market = "1.176621195";
1894 let omitted_market = "1.180727728";
1895 let book_sync = BookSyncTracker::default();
1896
1897 for market_id in [imaged_market, omitted_market] {
1898 book_sync.record_subscription(market_id, Instant::now(), SnapshotGate::default());
1899 assert_eq!(
1900 book_sync.validate(market_id, true, Instant::now()),
1901 BookSequenceOutcome::Accept
1902 );
1903 }
1904
1905 let (handler, mut data_rx) = stream_handler_with_books(UnixNanos::default(), book_sync);
1906 let image = load_test_json("stream/mcm_live_IMAGE.json");
1907 let update = load_test_json("stream/mcm_UPDATE.json");
1908
1909 handler(stream_decode(image.as_bytes()).unwrap());
1910 let (imaged_books, _) = count_books_and_trades(&mut data_rx);
1911 handler(stream_decode(update.as_bytes()).unwrap());
1912 let (omitted_books, _) = count_books_and_trades(&mut data_rx);
1913
1914 assert_eq!(imaged_books, 2);
1915 assert_eq!(omitted_books, 0);
1916 }
1917}