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, 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
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 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    /// Creates a new [`BetfairDataClient`] instance.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error if the client or stream configuration is invalid.
159    #[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        // Track cumulative traded volumes per (instrument_id, price) to compute
326        // incremental trade sizes. Betfair `trd` fields report totals, not deltas.
327        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                    // A new image replaces every subscribed market, so each book awaits its own
341                    // image and no recovery waits on an earlier image request
342                    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                            // Emit instruments first so downstream consumers (DataEngine,
364                            // BacktestExchange) have the instrument cached before any status
365                            // or close event references it.
366                            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                        // Non-snapshot deltas and BSP deltas are buffered and flushed after
448                        // trades/tickers to mirror the Python `market_change_to_updates`
449                        // ordering (book deltas first, then BSP). Snapshots go inline.
450                        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                            // A closed market streams no further book changes or images
577                            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                // Only the exchange stream carries market changes, so its books stay separate
881                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                // Only the exchange stream carries market changes, so its books stay separate
967                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            // Betfair keeps one market subscription per connection, so each write carries every
1196            // subscribed market, and writing at once keeps the latest write last
1197            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        // Trades are included in market subscription via EX_TRADED
1232        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            // Instrument status is included in market subscription via EX_MARKET_DEF
1253            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            // Close transitions arrive via marketDefinition.status="CLOSED" on the
1271            // existing market subscription; no separate venue subscription exists.
1272            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// Returns each runner's book deltas in runner order when the market accepts the change, starting
1374// recovery when the market needs a fresh image. Parsing comes first, so an unparsable image fails
1375// the recovery waiting on it rather than completing it
1376#[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
1420// Recovery needs the stream client, which connects after its handler is built
1421fn 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
1435// Sends an image's runner deltas at once and buffers update deltas behind the runner's trades
1436fn 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
1449// An image replaces its whole market, so a runner its definition lists but its changes omit has
1450// no prices
1451fn 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    // An Asian handicap market lists one selection at several handicaps, so a handicap the image
1846    // omits has no prices even when the selection appears at another
1847    #[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}