Skip to main content

nautilus_betfair/
data.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Live market data client for the Betfair adapter.
17
18use 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
101/// Keep-alive interval in seconds (10 hours, matching Python default).
102const KEEP_ALIVE_INTERVAL_SECS: u64 = 36_000;
103
104/// Betfair live data client.
105#[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
132/// Wraps a custom data value with its instrument_id in both metadata (for
133/// topic routing) and identifier (for catalog partitioning).
134pub(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    /// Creates a new [`BetfairDataClient`] instance.
153    #[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        // Track cumulative traded volumes per (instrument_id, price) to compute
318        // incremental trade sizes. Betfair `trd` fields report totals, not deltas.
319        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                    // A new image replaces every subscribed market, so each book awaits its own
333                    // image and no recovery waits on an earlier image request
334                    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                            // Emit instruments first so downstream consumers (DataEngine,
356                            // BacktestExchange) have the instrument cached before any status
357                            // or close event references it.
358                            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                        // Non-snapshot deltas and BSP deltas are buffered and flushed after
440                        // trades/tickers to mirror the Python `market_change_to_updates`
441                        // ordering (book deltas first, then BSP). Snapshots go inline.
442                        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                            // A closed market streams no further book changes or images
569                            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                // Only the exchange stream carries market changes, so its books stay separate
865                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                // Only the exchange stream carries market changes, so its books stay separate
951                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        // Betfair keeps one market subscription per connection, so each write carries every
1180        // subscribed market, and writing at once keeps the latest write last
1181        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        // Trades are included in market subscription via EX_TRADED
1216        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        // Instrument status is included in market subscription via EX_MARKET_DEF
1236        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        // Close transitions arrive via marketDefinition.status="CLOSED" on the
1256        // existing market subscription; no separate venue subscription exists.
1257        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// Returns each runner's book deltas in runner order when the market accepts the change, starting
1361// recovery when the market needs a fresh image. Parsing comes first, so an unparsable image fails
1362// the recovery waiting on it rather than completing it
1363#[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
1407// Recovery needs the stream client, which connects after its handler is built
1408fn 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
1422// Sends an image's runner deltas at once and buffers update deltas behind the runner's trades
1423fn 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
1436// An image replaces its whole market, so a runner its definition lists but its changes omit has
1437// no prices
1438fn 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    // An Asian handicap market lists one selection at several handicaps, so a handicap the image
1833    // omits has no prices even when the selection appears at another
1834    #[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}