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