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::sync::{
19    Arc, Mutex,
20    atomic::{AtomicBool, Ordering},
21};
22
23use ahash::{AHashMap, AHashSet};
24use async_trait::async_trait;
25use nautilus_common::{
26    clients::DataClient,
27    live::{get_runtime, runner::get_data_event_sender},
28    messages::{
29        DataEvent,
30        data::{
31            SubscribeBookDeltas, SubscribeInstrumentClose, SubscribeInstrumentStatus,
32            SubscribeTrades, UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeCustomData,
33            UnsubscribeInstrument, UnsubscribeInstrumentClose, UnsubscribeInstrumentStatus,
34            UnsubscribeInstruments, UnsubscribeQuotes, UnsubscribeTrades,
35        },
36    },
37    providers::InstrumentProvider,
38};
39use nautilus_core::{AtomicMap, Params};
40use nautilus_model::{
41    data::{
42        CustomData, CustomDataTrait, Data, DataType, OrderBookDeltas, OrderBookDeltas_API,
43        TradeTick,
44    },
45    identifiers::{ClientId, InstrumentId, TradeId, Venue},
46    instruments::{Instrument, InstrumentAny},
47    types::{Currency, Money},
48};
49use nautilus_network::socket::TcpMessageHandler;
50use rust_decimal::Decimal;
51use tokio::task::JoinHandle;
52
53use crate::{
54    common::{
55        consts::{BETFAIR_RACE_STREAM_HOST, BETFAIR_VENUE},
56        credential::BetfairCredential,
57        enums::{MarketDataFilterField, MarketStatus},
58        parse::{
59            extract_market_id, make_instrument_id, parse_betfair_price, parse_betfair_quantity,
60            parse_market_definition, parse_millis_timestamp,
61        },
62    },
63    config::BetfairDataConfig,
64    data_types::{BetfairSequenceCompleted, register_betfair_custom_data},
65    http::client::BetfairHttpClient,
66    provider::{BetfairInstrumentProvider, NavigationFilter},
67    stream::{
68        client::{BetfairRaceStreamClient, BetfairStreamClient},
69        config::BetfairStreamConfig,
70        messages::{MarketDataFilter, StreamMarketFilter, StreamMessage, stream_decode},
71        parse::{
72            make_trade_tick, parse_betfair_starting_prices, parse_betfair_ticker,
73            parse_bsp_book_deltas, parse_cricket_match, parse_instrument_closes,
74            parse_instrument_statuses, parse_race_progress, parse_race_runner_data,
75            parse_runner_book_deltas,
76        },
77    },
78};
79
80/// Keep-alive interval in seconds (10 hours, matching Python default).
81const KEEP_ALIVE_INTERVAL_SECS: u64 = 36_000;
82
83/// Wraps a custom data value with its instrument_id in both metadata (for
84/// topic routing) and identifier (for catalog partitioning).
85pub(crate) fn custom_data_with_instrument(
86    value: Arc<dyn CustomDataTrait>,
87    instrument_id: InstrumentId,
88) -> CustomData {
89    let mut metadata = Params::new();
90    metadata.insert(
91        "instrument_id".to_string(),
92        serde_json::Value::String(instrument_id.to_string()),
93    );
94    let data_type = DataType::new(
95        value.type_name(),
96        Some(metadata),
97        Some(instrument_id.to_string()),
98    );
99    CustomData::new(value, data_type)
100}
101
102/// Betfair live data client.
103#[derive(Debug)]
104pub struct BetfairDataClient {
105    client_id: ClientId,
106    http_client: Arc<BetfairHttpClient>,
107    provider: BetfairInstrumentProvider,
108    stream_client: Option<Arc<BetfairStreamClient>>,
109    race_stream_client: Option<Arc<BetfairRaceStreamClient>>,
110    cricket_stream_client: Option<Arc<BetfairRaceStreamClient>>,
111    credential: BetfairCredential,
112    stream_config: BetfairStreamConfig,
113    config: BetfairDataConfig,
114    currency: Currency,
115    is_connected: AtomicBool,
116    data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
117    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
118    subscribed_market_ids: AHashSet<String>,
119    keep_alive_handle: Option<JoinHandle<()>>,
120    reconnect_handle: Option<JoinHandle<()>>,
121    race_fatal_handle: Option<JoinHandle<()>>,
122    cricket_fatal_handle: Option<JoinHandle<()>>,
123}
124
125impl BetfairDataClient {
126    /// Creates a new [`BetfairDataClient`] instance.
127    #[must_use]
128    #[expect(clippy::too_many_arguments)]
129    pub fn new(
130        client_id: ClientId,
131        http_client: BetfairHttpClient,
132        credential: BetfairCredential,
133        stream_config: BetfairStreamConfig,
134        config: BetfairDataConfig,
135        nav_filter: NavigationFilter,
136        currency: Currency,
137        min_notional: Option<Money>,
138    ) -> Self {
139        let data_sender = get_data_event_sender();
140        let http_client = Arc::new(http_client);
141        let provider = BetfairInstrumentProvider::new(
142            Arc::clone(&http_client),
143            nav_filter,
144            currency,
145            min_notional,
146        );
147
148        Self {
149            client_id,
150            http_client,
151            provider,
152            stream_client: None,
153            race_stream_client: None,
154            cricket_stream_client: None,
155            credential,
156            stream_config,
157            config,
158            currency,
159            is_connected: AtomicBool::new(false),
160            data_sender,
161            instruments: Arc::new(AtomicMap::new()),
162            subscribed_market_ids: AHashSet::new(),
163            keep_alive_handle: None,
164            reconnect_handle: None,
165            race_fatal_handle: None,
166            cricket_fatal_handle: None,
167        }
168    }
169
170    fn create_stream_handler(
171        data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
172        instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
173        currency: Currency,
174        min_notional: Option<Money>,
175        reconnect_tx: tokio::sync::mpsc::UnboundedSender<()>,
176    ) -> TcpMessageHandler {
177        // Track cumulative traded volumes per (instrument_id, price) to compute
178        // incremental trade sizes. Betfair `trd` fields report totals, not deltas.
179        let traded_volumes: Arc<Mutex<AHashMap<(InstrumentId, Decimal), Decimal>>> =
180            Arc::new(Mutex::new(AHashMap::new()));
181        let has_initial_connection = Arc::new(AtomicBool::new(false));
182
183        Arc::new(move |data: &[u8]| {
184            let msg = match stream_decode(data) {
185                Ok(msg) => msg,
186                Err(e) => {
187                    log::warn!("Failed to decode stream message: {e}");
188                    return;
189                }
190            };
191
192            match msg {
193                StreamMessage::MarketChange(mcm) => {
194                    if mcm.is_heartbeat() {
195                        return;
196                    }
197
198                    let Some(market_changes) = &mcm.mc else {
199                        return;
200                    };
201
202                    let ts_event = parse_millis_timestamp(mcm.pt);
203                    let ts_init = ts_event;
204
205                    for mc in market_changes {
206                        let is_snapshot = mc.img;
207                        let mut market_closed = false;
208
209                        if let Some(def) = &mc.market_definition {
210                            // Emit instruments first so downstream consumers (DataEngine,
211                            // BacktestExchange) have the instrument cached before any status
212                            // or close event references it.
213                            match parse_market_definition(
214                                &mc.id,
215                                def,
216                                currency,
217                                ts_init,
218                                min_notional,
219                            ) {
220                                Ok(new_instruments) => {
221                                    instruments.rcu(|m| {
222                                        for inst in &new_instruments {
223                                            m.insert(inst.id(), inst.clone());
224                                        }
225                                    });
226
227                                    for inst in new_instruments {
228                                        if let Err(e) =
229                                            data_sender.send(DataEvent::Instrument(inst))
230                                        {
231                                            log::warn!("Failed to send instrument: {e}");
232                                        }
233                                    }
234                                }
235                                Err(e) => {
236                                    log::warn!(
237                                        "Failed to parse market definition for {}: {e}",
238                                        mc.id
239                                    );
240                                }
241                            }
242
243                            if let Some(status) = &def.status {
244                                market_closed = *status == MarketStatus::Closed;
245
246                                for event in
247                                    parse_instrument_statuses(&mc.id, def, ts_event, ts_init)
248                                {
249                                    if let Err(e) =
250                                        data_sender.send(DataEvent::InstrumentStatus(event))
251                                    {
252                                        log::warn!("Failed to send instrument status: {e}");
253                                    }
254                                }
255                            }
256
257                            for sp in parse_betfair_starting_prices(&mc.id, def, ts_event, ts_init)
258                            {
259                                let instrument_id = sp.instrument_id;
260                                let custom =
261                                    custom_data_with_instrument(Arc::new(sp), instrument_id);
262
263                                if let Err(e) =
264                                    data_sender.send(DataEvent::Data(Data::Custom(custom)))
265                                {
266                                    log::warn!("Failed to send starting price: {e}");
267                                }
268                            }
269
270                            for close in parse_instrument_closes(&mc.id, def, ts_event, ts_init) {
271                                if let Err(e) =
272                                    data_sender.send(DataEvent::Data(Data::InstrumentClose(close)))
273                                {
274                                    log::warn!("Failed to send instrument close: {e}");
275                                }
276                            }
277                        }
278
279                        // Non-snapshot deltas and BSP deltas are buffered and flushed after
280                        // trades/tickers to mirror the Python `market_change_to_updates`
281                        // ordering (book deltas first, then BSP). Snapshots go inline.
282                        let mut buffered_deltas: Vec<OrderBookDeltas> = Vec::new();
283                        let mut buffered_bsp_customs: Vec<CustomData> = Vec::new();
284
285                        if let Some(runner_changes) = &mc.rc {
286                            for rc in runner_changes {
287                                let handicap = rc.hc.unwrap_or(Decimal::ZERO);
288                                let instrument_id = make_instrument_id(&mc.id, rc.id, handicap);
289
290                                match parse_runner_book_deltas(
291                                    instrument_id,
292                                    rc,
293                                    is_snapshot,
294                                    mcm.pt,
295                                    ts_event,
296                                    ts_init,
297                                ) {
298                                    Ok(Some(deltas)) => {
299                                        if is_snapshot {
300                                            if let Err(e) = data_sender.send(DataEvent::Data(
301                                                Data::Deltas(OrderBookDeltas_API::new(deltas)),
302                                            )) {
303                                                log::warn!("Failed to send book deltas: {e}");
304                                            }
305                                        } else {
306                                            buffered_deltas.push(deltas);
307                                        }
308                                    }
309                                    Ok(None) => {}
310                                    Err(e) => {
311                                        log::warn!(
312                                            "Failed to parse book deltas for {instrument_id}: {e}"
313                                        );
314                                    }
315                                }
316
317                                if let Some(trades) = &rc.trd {
318                                    let mut volumes = traded_volumes.lock().unwrap();
319
320                                    for pv in trades {
321                                        if pv.volume == Decimal::ZERO {
322                                            continue;
323                                        }
324
325                                        let key = (instrument_id, pv.price);
326                                        let prev_volume =
327                                            volumes.get(&key).copied().unwrap_or(Decimal::ZERO);
328
329                                        if pv.volume <= prev_volume {
330                                            volumes.insert(key, pv.volume);
331                                            continue;
332                                        }
333
334                                        let trade_volume = pv.volume - prev_volume;
335                                        volumes.insert(key, pv.volume);
336
337                                        let price = match parse_betfair_price(pv.price) {
338                                            Ok(p) => p,
339                                            Err(e) => {
340                                                log::warn!("Invalid trade price: {e}");
341                                                continue;
342                                            }
343                                        };
344                                        let size = match parse_betfair_quantity(trade_volume) {
345                                            Ok(q) => q,
346                                            Err(e) => {
347                                                log::warn!("Invalid trade size: {e}");
348                                                continue;
349                                            }
350                                        };
351                                        let trade_id = TradeId::new(format!(
352                                            "{}-{}-{}",
353                                            mcm.pt, rc.id, pv.price
354                                        ));
355                                        let tick: TradeTick = make_trade_tick(
356                                            instrument_id,
357                                            price,
358                                            size,
359                                            trade_id,
360                                            ts_event,
361                                            ts_init,
362                                        );
363
364                                        if let Err(e) =
365                                            data_sender.send(DataEvent::Data(Data::Trade(tick)))
366                                        {
367                                            log::warn!("Failed to send trade tick: {e}");
368                                        }
369                                    }
370                                }
371
372                                if let Some(ticker) =
373                                    parse_betfair_ticker(instrument_id, rc, ts_event, ts_init)
374                                {
375                                    let custom = custom_data_with_instrument(
376                                        Arc::new(ticker),
377                                        instrument_id,
378                                    );
379
380                                    if let Err(e) =
381                                        data_sender.send(DataEvent::Data(Data::Custom(custom)))
382                                    {
383                                        log::warn!("Failed to send ticker: {e}");
384                                    }
385                                }
386
387                                for bsp_delta in
388                                    parse_bsp_book_deltas(instrument_id, rc, ts_event, ts_init)
389                                {
390                                    buffered_bsp_customs.push(custom_data_with_instrument(
391                                        Arc::new(bsp_delta),
392                                        instrument_id,
393                                    ));
394                                }
395                            }
396                        }
397
398                        for deltas in buffered_deltas {
399                            if let Err(e) = data_sender.send(DataEvent::Data(Data::Deltas(
400                                OrderBookDeltas_API::new(deltas),
401                            ))) {
402                                log::warn!("Failed to send book deltas: {e}");
403                            }
404                        }
405
406                        for custom in buffered_bsp_customs {
407                            if let Err(e) = data_sender.send(DataEvent::Data(Data::Custom(custom)))
408                            {
409                                log::warn!("Failed to send BSP book delta: {e}");
410                            }
411                        }
412
413                        if market_closed {
414                            let prefix = format!("{}-", mc.id);
415
416                            if let Ok(mut volumes) = traded_volumes.lock() {
417                                volumes.retain(|k, _| !k.0.symbol.as_str().starts_with(&prefix));
418                            }
419                        }
420                    }
421
422                    let completed = BetfairSequenceCompleted::new(ts_event, ts_init);
423                    let custom = CustomData::from_arc(Arc::new(completed));
424                    if let Err(e) = data_sender.send(DataEvent::Data(Data::Custom(custom))) {
425                        log::warn!("Failed to send sequence completed: {e}");
426                    }
427                }
428                StreamMessage::Connection(_) => {
429                    if has_initial_connection.swap(true, Ordering::SeqCst) {
430                        log::info!("Betfair data stream reconnected");
431                        let _ = reconnect_tx.send(());
432                    } else {
433                        log::debug!("Betfair data stream connected");
434                    }
435                }
436                StreamMessage::Status(status) => {
437                    if status.connection_closed {
438                        log::warn!(
439                            "Betfair stream closed: {:?} - {:?}",
440                            status.error_code,
441                            status.error_message,
442                        );
443                    }
444                }
445                StreamMessage::RaceChange(rcm) => {
446                    if let Some(race_changes) = &rcm.rc {
447                        let ts_init = parse_millis_timestamp(rcm.pt);
448
449                        for rc in race_changes {
450                            let race_id = rc.id.as_deref().unwrap_or("");
451                            let market_id = rc.mid.as_deref().unwrap_or("");
452
453                            if let Some(runners) = &rc.rrc {
454                                for rrc in runners {
455                                    let ts_event = rrc.ft.map_or(ts_init, parse_millis_timestamp);
456
457                                    if let Some(runner) = parse_race_runner_data(
458                                        race_id, market_id, rrc, ts_event, ts_init,
459                                    ) {
460                                        let selection_id = rrc.id.unwrap_or(0);
461                                        let mut metadata = Params::new();
462                                        metadata.insert(
463                                            "selection_id".to_string(),
464                                            serde_json::Value::Number(selection_id.into()),
465                                        );
466                                        let value: Arc<dyn CustomDataTrait> = Arc::new(runner);
467                                        let data_type =
468                                            DataType::new(value.type_name(), Some(metadata), None);
469                                        let custom = CustomData::new(value, data_type);
470
471                                        if let Err(e) =
472                                            data_sender.send(DataEvent::Data(Data::Custom(custom)))
473                                        {
474                                            log::warn!("Failed to send race runner data: {e}");
475                                        }
476                                    }
477                                }
478                            }
479
480                            if let Some(rpc) = &rc.rpc {
481                                let ts_event = rpc.ft.map_or(ts_init, parse_millis_timestamp);
482                                let progress =
483                                    parse_race_progress(race_id, market_id, rpc, ts_event, ts_init);
484                                let mut metadata = Params::new();
485                                metadata.insert(
486                                    "race_id".to_string(),
487                                    serde_json::Value::String(race_id.to_string()),
488                                );
489                                let value: Arc<dyn CustomDataTrait> = Arc::new(progress);
490                                let data_type =
491                                    DataType::new(value.type_name(), Some(metadata), None);
492                                let custom = CustomData::new(value, data_type);
493
494                                if let Err(e) =
495                                    data_sender.send(DataEvent::Data(Data::Custom(custom)))
496                                {
497                                    log::warn!("Failed to send race progress: {e}");
498                                }
499                            }
500                        }
501                    }
502                }
503                StreamMessage::CricketChange(ccm) => {
504                    if let Some(cricket_changes) = &ccm.cc {
505                        let ts_init = parse_millis_timestamp(ccm.pt);
506
507                        for cricket_change in cricket_changes {
508                            if let Some(cricket) =
509                                parse_cricket_match(cricket_change, ts_init, ts_init)
510                            {
511                                let mut metadata = Params::new();
512                                metadata.insert(
513                                    "event_id".to_string(),
514                                    serde_json::Value::String(cricket.event_id.clone()),
515                                );
516                                let value: Arc<dyn CustomDataTrait> = Arc::new(cricket);
517                                let data_type =
518                                    DataType::new(value.type_name(), Some(metadata), None);
519                                let custom = CustomData::new(value, data_type);
520
521                                if let Err(e) =
522                                    data_sender.send(DataEvent::Data(Data::Custom(custom)))
523                                {
524                                    log::warn!("Failed to send cricket match: {e}");
525                                }
526                            }
527                        }
528                    }
529                }
530                StreamMessage::OrderChange(_) => {}
531            }
532        })
533    }
534}
535
536#[async_trait(?Send)]
537impl DataClient for BetfairDataClient {
538    fn client_id(&self) -> ClientId {
539        self.client_id
540    }
541
542    fn venue(&self) -> Option<Venue> {
543        Some(*BETFAIR_VENUE)
544    }
545
546    fn start(&mut self) -> anyhow::Result<()> {
547        log::info!("Starting Betfair data client: {}", self.client_id);
548        Ok(())
549    }
550
551    fn stop(&mut self) -> anyhow::Result<()> {
552        log::info!("Stopping Betfair data client: {}", self.client_id);
553
554        if let Some(handle) = self.keep_alive_handle.take() {
555            handle.abort();
556        }
557
558        if let Some(handle) = self.reconnect_handle.take() {
559            handle.abort();
560        }
561
562        if let Some(handle) = self.race_fatal_handle.take() {
563            handle.abort();
564        }
565
566        if let Some(handle) = self.cricket_fatal_handle.take() {
567            handle.abort();
568        }
569        self.is_connected.store(false, Ordering::Relaxed);
570        Ok(())
571    }
572
573    fn reset(&mut self) -> anyhow::Result<()> {
574        log::info!("Resetting Betfair data client: {}", self.client_id);
575
576        if let Some(handle) = self.keep_alive_handle.take() {
577            handle.abort();
578        }
579
580        if let Some(handle) = self.reconnect_handle.take() {
581            handle.abort();
582        }
583
584        if let Some(handle) = self.race_fatal_handle.take() {
585            handle.abort();
586        }
587
588        if let Some(handle) = self.cricket_fatal_handle.take() {
589            handle.abort();
590        }
591        self.is_connected.store(false, Ordering::Relaxed);
592        self.stream_client = None;
593        self.race_stream_client = None;
594        self.cricket_stream_client = None;
595        self.provider.store_mut().clear();
596        self.subscribed_market_ids.clear();
597
598        self.instruments.store(AHashMap::new());
599        Ok(())
600    }
601
602    fn dispose(&mut self) -> anyhow::Result<()> {
603        log::debug!("Disposing Betfair data client: {}", self.client_id);
604        self.stop()
605    }
606
607    fn is_connected(&self) -> bool {
608        self.is_connected.load(Ordering::SeqCst)
609    }
610
611    fn is_disconnected(&self) -> bool {
612        !self.is_connected()
613    }
614
615    async fn connect(&mut self) -> anyhow::Result<()> {
616        if self.is_connected() {
617            return Ok(());
618        }
619
620        register_betfair_custom_data();
621
622        self.http_client
623            .connect()
624            .await
625            .map_err(|e| anyhow::anyhow!("{e}"))?;
626
627        self.provider.load_all(None).await?;
628
629        let loaded: Vec<InstrumentAny> = self
630            .provider
631            .store()
632            .list_all()
633            .into_iter()
634            .cloned()
635            .collect();
636
637        self.instruments.rcu(|m| {
638            for inst in &loaded {
639                m.insert(inst.id(), inst.clone());
640            }
641        });
642
643        for inst in &loaded {
644            if let Err(e) = self.data_sender.send(DataEvent::Instrument(inst.clone())) {
645                log::warn!("Failed to send instrument: {e}");
646            }
647        }
648
649        log::debug!("Cached {} instruments for {}", loaded.len(), self.client_id,);
650
651        let session_token = self
652            .http_client
653            .session_token()
654            .await
655            .ok_or_else(|| anyhow::anyhow!("No session token after login"))?;
656
657        let (reconnect_tx, mut reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
658
659        let handler = Self::create_stream_handler(
660            self.data_sender.clone(),
661            Arc::clone(&self.instruments),
662            self.currency,
663            self.provider.min_notional(),
664            reconnect_tx.clone(),
665        );
666
667        let stream_client = BetfairStreamClient::connect(
668            &self.credential,
669            session_token,
670            handler,
671            self.stream_config.clone(),
672        )
673        .await
674        .map_err(|e| anyhow::anyhow!("{e}"))?;
675
676        self.stream_client = Some(Arc::new(stream_client));
677
678        if self.config.subscribe_race_data {
679            let race_config = BetfairStreamConfig {
680                host: BETFAIR_RACE_STREAM_HOST.to_string(),
681                ..self.stream_config.clone()
682            };
683
684            let race_session = self
685                .http_client
686                .session_token()
687                .await
688                .ok_or_else(|| anyhow::anyhow!("No session token for race stream"))?;
689
690            let race_handler = Self::create_stream_handler(
691                self.data_sender.clone(),
692                Arc::clone(&self.instruments),
693                self.currency,
694                self.provider.min_notional(),
695                reconnect_tx.clone(),
696            );
697
698            let (race_fatal_tx, mut race_fatal_rx) = tokio::sync::mpsc::unbounded_channel();
699
700            match BetfairRaceStreamClient::connect(
701                &self.credential,
702                race_session,
703                race_handler,
704                race_config,
705                race_fatal_tx,
706            )
707            .await
708            {
709                Ok(client) => {
710                    let race_client = Arc::new(client);
711                    self.race_stream_client = Some(Arc::clone(&race_client));
712
713                    if let Some(handle) = self.race_fatal_handle.take() {
714                        handle.abort();
715                    }
716
717                    self.race_fatal_handle = Some(get_runtime().spawn(async move {
718                        if race_fatal_rx.recv().await.is_some() {
719                            log::error!(
720                                "Betfair race stream permanently disabled due to fatal error"
721                            );
722                            race_client.close().await;
723                        }
724                    }));
725
726                    log::debug!("Betfair race stream connected");
727                }
728                Err(e) => {
729                    log::warn!("Betfair race stream connect failed: {e}");
730                    self.race_stream_client = None;
731                }
732            }
733        }
734
735        if self.config.subscribe_cricket_data {
736            let cricket_config = BetfairStreamConfig {
737                host: BETFAIR_RACE_STREAM_HOST.to_string(),
738                ..self.stream_config.clone()
739            };
740
741            let cricket_session = self
742                .http_client
743                .session_token()
744                .await
745                .ok_or_else(|| anyhow::anyhow!("No session token for cricket stream"))?;
746
747            let cricket_handler = Self::create_stream_handler(
748                self.data_sender.clone(),
749                Arc::clone(&self.instruments),
750                self.currency,
751                self.provider.min_notional(),
752                reconnect_tx.clone(),
753            );
754
755            let (cricket_fatal_tx, mut cricket_fatal_rx) = tokio::sync::mpsc::unbounded_channel();
756
757            match BetfairRaceStreamClient::connect_cricket(
758                &self.credential,
759                cricket_session,
760                cricket_handler,
761                cricket_config,
762                cricket_fatal_tx,
763            )
764            .await
765            {
766                Ok(client) => {
767                    let cricket_client = Arc::new(client);
768                    self.cricket_stream_client = Some(Arc::clone(&cricket_client));
769
770                    if let Some(handle) = self.cricket_fatal_handle.take() {
771                        handle.abort();
772                    }
773
774                    self.cricket_fatal_handle = Some(get_runtime().spawn(async move {
775                        if cricket_fatal_rx.recv().await.is_some() {
776                            log::error!(
777                                "Betfair cricket stream permanently disabled due to fatal error"
778                            );
779                            cricket_client.close().await;
780                        }
781                    }));
782
783                    log::debug!("Betfair cricket stream connected");
784                }
785                Err(e) => {
786                    log::warn!("Betfair cricket stream connect failed: {e}");
787                    self.cricket_stream_client = None;
788                }
789            }
790        }
791
792        // Abort any existing keep-alive task before spawning a new one
793        if let Some(handle) = self.keep_alive_handle.take() {
794            handle.abort();
795        }
796
797        // Spawn periodic keep-alive to prevent session expiry
798        let keep_alive_client = Arc::clone(&self.http_client);
799        let keep_alive_stream = Arc::clone(self.stream_client.as_ref().unwrap());
800        let keep_alive_race_stream = self.race_stream_client.as_ref().map(Arc::clone);
801        let keep_alive_cricket_stream = self.cricket_stream_client.as_ref().map(Arc::clone);
802        let keep_alive_app_key = self.credential.app_key().to_string();
803
804        self.keep_alive_handle = Some(get_runtime().spawn(async move {
805            let interval = tokio::time::Duration::from_secs(KEEP_ALIVE_INTERVAL_SECS);
806            loop {
807                tokio::time::sleep(interval).await;
808
809                match keep_alive_client.keep_alive().await {
810                    Ok(()) => {}
811                    Err(ref e) if e.is_login_failed() => {
812                        log::warn!("Betfair session expired, attempting re-login: {e}");
813                        if let Err(e) = keep_alive_client.reconnect().await {
814                            log::warn!("Betfair re-login failed: {e}");
815                            continue;
816                        }
817                    }
818                    Err(e) => {
819                        log::warn!("Betfair keep-alive failed (transient): {e}");
820                        continue;
821                    }
822                }
823
824                if let Some(token) = keep_alive_client.session_token().await {
825                    keep_alive_stream.update_auth(&keep_alive_app_key, token.clone());
826
827                    if let Some(ref race_stream) = keep_alive_race_stream {
828                        race_stream.update_auth(&keep_alive_app_key, token.clone());
829                    }
830
831                    if let Some(ref cricket_stream) = keep_alive_cricket_stream {
832                        cricket_stream.update_auth(&keep_alive_app_key, token);
833                    }
834                }
835                log::debug!("Betfair session keep-alive sent");
836            }
837        }));
838
839        // Spawn reconnect handler to refresh session on stream reconnection
840        let reconnect_http = Arc::clone(&self.http_client);
841        let reconnect_stream = Arc::clone(self.stream_client.as_ref().unwrap());
842        let reconnect_race_stream = self.race_stream_client.as_ref().map(Arc::clone);
843        let reconnect_cricket_stream = self.cricket_stream_client.as_ref().map(Arc::clone);
844        let reconnect_app_key = self.credential.app_key().to_string();
845
846        self.reconnect_handle = Some(get_runtime().spawn(async move {
847            while reconnect_rx.recv().await.is_some() {
848                log::info!("Handling data stream reconnection");
849
850                match reconnect_http.keep_alive().await {
851                    Ok(()) => {}
852                    Err(ref e) if e.is_login_failed() => {
853                        log::warn!("Session expired on reconnect, attempting re-login: {e}");
854                        if let Err(e) = reconnect_http.reconnect().await {
855                            log::warn!("Re-login failed on reconnect: {e}");
856                            continue;
857                        }
858                    }
859                    Err(e) => {
860                        log::warn!("Keep-alive failed on reconnect (transient): {e}");
861                        continue;
862                    }
863                }
864
865                if let Some(token) = reconnect_http.session_token().await {
866                    reconnect_stream.update_auth(&reconnect_app_key, token.clone());
867
868                    if let Some(ref race_stream) = reconnect_race_stream {
869                        race_stream.update_auth(&reconnect_app_key, token.clone());
870                    }
871
872                    if let Some(ref cricket_stream) = reconnect_cricket_stream {
873                        cricket_stream.update_auth(&reconnect_app_key, token);
874                    }
875                }
876            }
877        }));
878
879        self.is_connected.store(true, Ordering::Release);
880
881        log::info!("Betfair data client connected: {}", self.client_id);
882        Ok(())
883    }
884
885    async fn disconnect(&mut self) -> anyhow::Result<()> {
886        if self.is_disconnected() {
887            return Ok(());
888        }
889
890        if let Some(handle) = self.keep_alive_handle.take() {
891            handle.abort();
892        }
893
894        if let Some(handle) = self.reconnect_handle.take() {
895            handle.abort();
896        }
897
898        if let Some(handle) = self.race_fatal_handle.take() {
899            handle.abort();
900        }
901
902        if let Some(handle) = self.cricket_fatal_handle.take() {
903            handle.abort();
904        }
905
906        if let Some(client) = &self.cricket_stream_client {
907            client.close().await;
908        }
909        self.cricket_stream_client = None;
910
911        if let Some(client) = &self.race_stream_client {
912            client.close().await;
913        }
914        self.race_stream_client = None;
915
916        if let Some(client) = &self.stream_client {
917            client.close().await;
918        }
919
920        self.http_client.disconnect().await;
921        self.is_connected.store(false, Ordering::Relaxed);
922        self.subscribed_market_ids.clear();
923
924        log::info!("Betfair data client disconnected: {}", self.client_id);
925        Ok(())
926    }
927
928    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
929        let instrument_id = cmd.instrument_id;
930        let market_id = extract_market_id(&instrument_id)?;
931
932        if !self.subscribed_market_ids.insert(market_id.clone()) {
933            log::debug!("Book deltas already subscribed for market {market_id}");
934            return Ok(());
935        }
936
937        let stream_client = Arc::clone(
938            self.stream_client
939                .as_ref()
940                .ok_or_else(|| anyhow::anyhow!("Stream client not connected"))?,
941        );
942
943        let all_ids: Vec<String> = self.subscribed_market_ids.iter().cloned().collect();
944
945        let market_filter = StreamMarketFilter {
946            market_ids: Some(all_ids),
947            ..Default::default()
948        };
949
950        let data_filter = MarketDataFilter {
951            fields: Some(vec![
952                MarketDataFilterField::ExAllOffers,
953                MarketDataFilterField::ExTraded,
954                MarketDataFilterField::ExTradedVol,
955                MarketDataFilterField::ExLtp,
956                MarketDataFilterField::ExMarketDef,
957                MarketDataFilterField::SpTraded,
958                MarketDataFilterField::SpProjected,
959            ]),
960            ladder_levels: None,
961        };
962
963        let conflate_ms = self.config.stream_conflate_ms;
964
965        nautilus_common::live::get_runtime().spawn(async move {
966            if let Err(e) = stream_client
967                .subscribe_markets(market_filter, data_filter, None, conflate_ms)
968                .await
969            {
970                log::warn!("Failed to subscribe to market data: {e}");
971            }
972        });
973
974        Ok(())
975    }
976
977    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
978        log::debug!(
979            "Skipping unsubscribe book deltas for Betfair: {}",
980            cmd.instrument_id
981        );
982        Ok(())
983    }
984
985    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
986        // Trades are included in market subscription via EX_TRADED
987        log::debug!(
988            "Trade data included in book subscription for {}",
989            cmd.instrument_id
990        );
991        Ok(())
992    }
993
994    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
995        log::debug!(
996            "Skipping unsubscribe trades for Betfair: {}",
997            cmd.instrument_id
998        );
999        Ok(())
1000    }
1001
1002    fn subscribe_instrument_status(
1003        &mut self,
1004        cmd: SubscribeInstrumentStatus,
1005    ) -> anyhow::Result<()> {
1006        // Instrument status is included in market subscription via EX_MARKET_DEF
1007        log::debug!(
1008            "Instrument status included in book subscription for {}",
1009            cmd.instrument_id
1010        );
1011        Ok(())
1012    }
1013
1014    fn unsubscribe_instrument_status(
1015        &mut self,
1016        cmd: &UnsubscribeInstrumentStatus,
1017    ) -> anyhow::Result<()> {
1018        log::debug!(
1019            "Skipping unsubscribe instrument status for Betfair: {}",
1020            cmd.instrument_id
1021        );
1022        Ok(())
1023    }
1024
1025    fn subscribe_instrument_close(&mut self, cmd: SubscribeInstrumentClose) -> anyhow::Result<()> {
1026        // Close transitions arrive via marketDefinition.status="CLOSED" on the
1027        // existing market subscription; no separate venue subscription exists.
1028        log::debug!(
1029            "Instrument close included in book subscription for {}",
1030            cmd.instrument_id
1031        );
1032        Ok(())
1033    }
1034
1035    fn unsubscribe_instrument_close(
1036        &mut self,
1037        cmd: &UnsubscribeInstrumentClose,
1038    ) -> anyhow::Result<()> {
1039        log::debug!(
1040            "Skipping unsubscribe instrument close for Betfair: {}",
1041            cmd.instrument_id
1042        );
1043        Ok(())
1044    }
1045
1046    fn unsubscribe(&mut self, _cmd: &UnsubscribeCustomData) -> anyhow::Result<()> {
1047        log::debug!("Skipping unsubscribe custom data for Betfair");
1048        Ok(())
1049    }
1050
1051    fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
1052        log::debug!(
1053            "Skipping unsubscribe instrument for Betfair: {}",
1054            cmd.instrument_id
1055        );
1056        Ok(())
1057    }
1058
1059    fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
1060        log::debug!("Skipping unsubscribe instruments for Betfair");
1061        Ok(())
1062    }
1063
1064    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1065        log::debug!(
1066            "Skipping unsubscribe quotes for Betfair: {}",
1067            cmd.instrument_id
1068        );
1069        Ok(())
1070    }
1071
1072    fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
1073        log::debug!("Skipping unsubscribe bars for Betfair: {}", cmd.bar_type);
1074        Ok(())
1075    }
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080    use rstest::rstest;
1081
1082    use super::*;
1083    use crate::{common::testing::load_test_json, data_types::BetfairCricketMatch};
1084
1085    #[rstest]
1086    fn test_stream_handler_emits_cricket_match_custom_data() {
1087        let (data_tx, mut data_rx) = tokio::sync::mpsc::unbounded_channel();
1088        let (reconnect_tx, _reconnect_rx) = tokio::sync::mpsc::unbounded_channel();
1089        let handler = BetfairDataClient::create_stream_handler(
1090            data_tx,
1091            Arc::new(AtomicMap::new()),
1092            Currency::GBP(),
1093            None,
1094            reconnect_tx,
1095        );
1096        let data = load_test_json("stream/ccm_single.json");
1097
1098        handler(data.as_bytes());
1099
1100        let event = data_rx.try_recv().expect("expected cricket custom data");
1101        let DataEvent::Data(Data::Custom(custom)) = event else {
1102            panic!("expected cricket custom data event, was {event:?}");
1103        };
1104        let cricket = custom
1105            .data
1106            .as_any()
1107            .downcast_ref::<BetfairCricketMatch>()
1108            .expect("custom data must be BetfairCricketMatch");
1109        let metadata = custom.data_type.metadata().expect("event metadata");
1110
1111        assert_eq!(cricket.event_id, "35741575");
1112        assert_eq!(cricket.market_id, "1.259334639");
1113        assert_eq!(
1114            metadata.get("event_id"),
1115            Some(&serde_json::Value::String("35741575".to_string())),
1116        );
1117        assert!(
1118            data_rx.try_recv().is_err(),
1119            "CCM fixture must emit exactly one event"
1120        );
1121    }
1122}