Skip to main content

nautilus_betfair/
config.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//! Configuration structures for the Betfair adapter.
17
18use std::any::Any;
19
20use nautilus_common::factories::ClientConfig;
21use nautilus_model::{
22    identifiers::{AccountId, TraderId},
23    types::{Currency, Money},
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27
28use crate::{
29    common::{
30        credential::{BetfairCredential, CredentialError},
31        parse::parse_betfair_timestamp,
32    },
33    provider::NavigationFilter,
34    stream::config::BetfairStreamConfig,
35};
36
37fn parse_currency(code: &str) -> anyhow::Result<Currency> {
38    code.parse::<Currency>()
39        .map_err(|_| anyhow::anyhow!("Invalid account currency code: {code}"))
40}
41
42fn make_min_notional(value: Option<Decimal>, currency: Currency) -> anyhow::Result<Option<Money>> {
43    value
44        .map(|amount| Money::from_decimal(amount, currency))
45        .transpose()
46        .map_err(Into::into)
47}
48
49fn validate_market_start_time(label: &str, value: &Option<String>) -> anyhow::Result<()> {
50    if let Some(value) = value {
51        parse_betfair_timestamp(value)
52            .map(|_| ())
53            .map_err(|e| anyhow::anyhow!("Invalid {label} '{value}': {e}"))?;
54    }
55
56    Ok(())
57}
58
59fn resolve_credential(
60    username: Option<String>,
61    password: Option<String>,
62    app_key: Option<String>,
63) -> anyhow::Result<BetfairCredential> {
64    match BetfairCredential::resolve(username, password, app_key) {
65        Ok(Some(credential)) => Ok(credential),
66        Ok(None) => anyhow::bail!("Missing Betfair credentials in config and environment"),
67        Err(e) => Err(match e {
68            CredentialError::MissingPassword => anyhow::anyhow!(
69                "Invalid Betfair credentials: username provided but password is missing",
70            ),
71            CredentialError::MissingUsername => anyhow::anyhow!(
72                "Invalid Betfair credentials: password or app key provided but username is missing",
73            ),
74            CredentialError::MissingAppKey => {
75                anyhow::anyhow!("Invalid Betfair credentials: app key is missing")
76            }
77        }),
78    }
79}
80
81fn build_stream_config(
82    stream_host: &Option<String>,
83    stream_port: &Option<u16>,
84    stream_heartbeat_ms: u64,
85    stream_idle_timeout_ms: u64,
86    stream_reconnect_delay_initial_ms: u64,
87    stream_reconnect_delay_max_ms: u64,
88    stream_use_tls: bool,
89) -> BetfairStreamConfig {
90    let defaults = BetfairStreamConfig::default();
91
92    BetfairStreamConfig {
93        host: stream_host.clone().unwrap_or(defaults.host),
94        port: stream_port.unwrap_or(defaults.port),
95        heartbeat_ms: stream_heartbeat_ms,
96        idle_timeout_ms: stream_idle_timeout_ms,
97        reconnect_delay_initial_ms: stream_reconnect_delay_initial_ms,
98        reconnect_delay_max_ms: stream_reconnect_delay_max_ms,
99        use_tls: stream_use_tls,
100    }
101}
102
103/// Configuration for the Betfair live data client.
104#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
105#[serde(default, deny_unknown_fields)]
106#[cfg_attr(
107    feature = "python",
108    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.betfair", from_py_object)
109)]
110#[cfg_attr(
111    feature = "python",
112    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
113)]
114pub struct BetfairDataConfig {
115    /// Account currency code.
116    #[builder(default = "GBP".to_string())]
117    pub account_currency: String,
118    /// Optional Betfair username.
119    pub username: Option<String>,
120    /// Optional Betfair password.
121    pub password: Option<String>,
122    /// Optional Betfair application key.
123    pub app_key: Option<String>,
124    /// Optional proxy URL for HTTP requests.
125    pub proxy_url: Option<String>,
126    /// General HTTP request rate limit per second.
127    #[builder(default = 5)]
128    pub request_rate_per_second: u32,
129    /// Optional default minimum notional in `account_currency`.
130    pub default_min_notional: Option<Decimal>,
131    /// Optional event type ID filter.
132    pub event_type_ids: Option<Vec<String>>,
133    /// Optional event type name filter.
134    pub event_type_names: Option<Vec<String>>,
135    /// Optional event ID filter.
136    pub event_ids: Option<Vec<String>>,
137    /// Optional country code filter.
138    pub country_codes: Option<Vec<String>>,
139    /// Optional market type filter.
140    pub market_types: Option<Vec<String>>,
141    /// Optional market ID filter.
142    pub market_ids: Option<Vec<String>>,
143    /// Optional lower bound for market start time.
144    pub min_market_start_time: Option<String>,
145    /// Optional upper bound for market start time.
146    pub max_market_start_time: Option<String>,
147    /// Optional override for stream host.
148    pub stream_host: Option<String>,
149    /// Optional override for stream port.
150    pub stream_port: Option<u16>,
151    /// Interval between stream heartbeat messages in milliseconds.
152    #[builder(default = 5_000)]
153    pub stream_heartbeat_ms: u64,
154    /// Stream idle timeout in milliseconds.
155    #[builder(default = 60_000)]
156    pub stream_idle_timeout_ms: u64,
157    /// Initial reconnection backoff in milliseconds.
158    #[builder(default = 2_000)]
159    pub stream_reconnect_delay_initial_ms: u64,
160    /// Maximum reconnection backoff in milliseconds.
161    #[builder(default = 30_000)]
162    pub stream_reconnect_delay_max_ms: u64,
163    /// Whether to use TLS for the stream connection.
164    #[builder(default = true)]
165    pub stream_use_tls: bool,
166    /// Stream conflation setting in milliseconds. When set, Betfair batches
167    /// stream updates for this interval. `None` uses Betfair defaults.
168    pub stream_conflate_ms: Option<u64>,
169    /// Delay in seconds before sending the initial subscription message after connecting.
170    #[builder(default = 3)]
171    pub subscription_delay_secs: u64,
172    /// Subscribe to the race stream for Total Performance Data (TPD).
173    #[builder(default)]
174    pub subscribe_race_data: bool,
175    /// Subscribe to the sports data stream for cricket match updates.
176    #[builder(default)]
177    pub subscribe_cricket_data: bool,
178}
179
180impl Default for BetfairDataConfig {
181    fn default() -> Self {
182        Self::builder().build()
183    }
184}
185
186impl ClientConfig for BetfairDataConfig {
187    fn as_any(&self) -> &dyn Any {
188        self
189    }
190}
191
192impl BetfairDataConfig {
193    /// Returns the configured credentials or resolves them from the environment.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error if credentials are incomplete or unavailable.
198    pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
199        resolve_credential(
200            self.username.clone(),
201            self.password.clone(),
202            self.app_key.clone(),
203        )
204    }
205
206    /// Returns the configured account currency.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the currency code is invalid.
211    pub fn currency(&self) -> anyhow::Result<Currency> {
212        parse_currency(&self.account_currency)
213    }
214
215    /// Returns the default instrument minimum notional.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the account currency code is invalid.
220    pub fn min_notional(&self) -> anyhow::Result<Option<Money>> {
221        let currency = self.currency()?;
222        make_min_notional(self.default_min_notional, currency)
223    }
224
225    /// Returns the navigation filter for instrument loading.
226    #[must_use]
227    pub fn navigation_filter(&self) -> NavigationFilter {
228        NavigationFilter {
229            event_type_ids: self.event_type_ids.clone(),
230            event_type_names: self.event_type_names.clone(),
231            event_ids: self.event_ids.clone(),
232            country_codes: self.country_codes.clone(),
233            market_types: self.market_types.clone(),
234            market_ids: self.market_ids.clone(),
235            min_market_start_time: self.min_market_start_time.clone(),
236            max_market_start_time: self.max_market_start_time.clone(),
237        }
238    }
239
240    /// Returns the stream configuration.
241    #[must_use]
242    pub fn stream_config(&self) -> BetfairStreamConfig {
243        build_stream_config(
244            &self.stream_host,
245            &self.stream_port,
246            self.stream_heartbeat_ms,
247            self.stream_idle_timeout_ms,
248            self.stream_reconnect_delay_initial_ms,
249            self.stream_reconnect_delay_max_ms,
250            self.stream_use_tls,
251        )
252    }
253
254    /// Validates the configuration.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if any configured value is invalid.
259    pub fn validate(&self) -> anyhow::Result<()> {
260        let _ = self.currency()?;
261        validate_market_start_time("min_market_start_time", &self.min_market_start_time)?;
262        validate_market_start_time("max_market_start_time", &self.max_market_start_time)?;
263
264        if self.request_rate_per_second == 0 {
265            anyhow::bail!("request_rate_per_second must be greater than zero");
266        }
267
268        Ok(())
269    }
270}
271
272/// Configuration for the Betfair live execution client.
273#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
274#[serde(default, deny_unknown_fields)]
275#[cfg_attr(
276    feature = "python",
277    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.betfair", from_py_object)
278)]
279#[cfg_attr(
280    feature = "python",
281    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
282)]
283pub struct BetfairExecConfig {
284    /// Trader ID for the client core.
285    #[builder(default = TraderId::from("TRADER-001"))]
286    pub trader_id: TraderId,
287    /// Account ID for the client core.
288    #[builder(default = AccountId::from("BETFAIR-001"))]
289    pub account_id: AccountId,
290    /// Account currency code.
291    #[builder(default = "GBP".to_string())]
292    pub account_currency: String,
293    /// Optional Betfair username.
294    pub username: Option<String>,
295    /// Optional Betfair password.
296    pub password: Option<String>,
297    /// Optional Betfair application key.
298    pub app_key: Option<String>,
299    /// Optional proxy URL for HTTP requests.
300    pub proxy_url: Option<String>,
301    /// General HTTP request rate limit per second.
302    #[builder(default = 5)]
303    pub request_rate_per_second: u32,
304    /// Order HTTP request rate limit per second.
305    #[builder(default = 20)]
306    pub order_request_rate_per_second: u32,
307    /// Optional override for stream host.
308    pub stream_host: Option<String>,
309    /// Optional override for stream port.
310    pub stream_port: Option<u16>,
311    /// Interval between stream heartbeat messages in milliseconds.
312    #[builder(default = 5_000)]
313    pub stream_heartbeat_ms: u64,
314    /// Stream idle timeout in milliseconds.
315    #[builder(default = 60_000)]
316    pub stream_idle_timeout_ms: u64,
317    /// Initial reconnection backoff in milliseconds.
318    #[builder(default = 2_000)]
319    pub stream_reconnect_delay_initial_ms: u64,
320    /// Maximum reconnection backoff in milliseconds.
321    #[builder(default = 30_000)]
322    pub stream_reconnect_delay_max_ms: u64,
323    /// Whether to use TLS for the stream connection.
324    #[builder(default = true)]
325    pub stream_use_tls: bool,
326    /// Market IDs to filter on the order stream. When set, OCM updates for
327    /// markets not in this list are skipped. `None` processes all markets.
328    pub stream_market_ids_filter: Option<Vec<String>>,
329    /// When true, silently ignore orders from OCM that are not tracked in the local cache.
330    #[builder(default)]
331    pub ignore_external_orders: bool,
332    /// Whether to poll account state periodically.
333    #[builder(default = true)]
334    pub calculate_account_state: bool,
335    /// Interval in seconds between account state polls.
336    #[builder(default = 300)]
337    pub request_account_state_secs: u64,
338    /// When true, reconciliation only requests orders matching `reconcile_market_ids`.
339    #[builder(default)]
340    pub reconcile_market_ids_only: bool,
341    /// Market IDs to restrict reconciliation to.
342    pub reconcile_market_ids: Option<Vec<String>>,
343    /// When true, attach the latest market version to placeOrders and replaceOrders requests.
344    #[builder(default)]
345    pub use_market_version: bool,
346    /// Lookback window in minutes for the post-reconnect mass-status reconciliation
347    /// that recovers fills which terminated during the disconnect gap. Should
348    /// comfortably exceed the longest expected reconnect duration.
349    #[builder(default = 10)]
350    pub stream_gap_recovery_lookback_mins: u64,
351}
352
353impl Default for BetfairExecConfig {
354    fn default() -> Self {
355        Self::builder().build()
356    }
357}
358
359impl ClientConfig for BetfairExecConfig {
360    fn as_any(&self) -> &dyn Any {
361        self
362    }
363}
364
365impl BetfairExecConfig {
366    /// Returns the configured credentials or resolves them from the environment.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if credentials are incomplete or unavailable.
371    pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
372        resolve_credential(
373            self.username.clone(),
374            self.password.clone(),
375            self.app_key.clone(),
376        )
377    }
378
379    /// Returns the configured account currency.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if the currency code is invalid.
384    pub fn currency(&self) -> anyhow::Result<Currency> {
385        parse_currency(&self.account_currency)
386    }
387
388    /// Returns the stream configuration.
389    #[must_use]
390    pub fn stream_config(&self) -> BetfairStreamConfig {
391        build_stream_config(
392            &self.stream_host,
393            &self.stream_port,
394            self.stream_heartbeat_ms,
395            self.stream_idle_timeout_ms,
396            self.stream_reconnect_delay_initial_ms,
397            self.stream_reconnect_delay_max_ms,
398            self.stream_use_tls,
399        )
400    }
401
402    /// Validates the configuration.
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if any configured value is invalid.
407    pub fn validate(&self) -> anyhow::Result<()> {
408        let _ = self.currency()?;
409
410        if self.request_rate_per_second == 0 {
411            anyhow::bail!("request_rate_per_second must be greater than zero");
412        }
413
414        if self.order_request_rate_per_second == 0 {
415            anyhow::bail!("order_request_rate_per_second must be greater than zero");
416        }
417
418        Ok(())
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use rstest::rstest;
425
426    use super::*;
427
428    #[rstest]
429    fn test_data_config_default() {
430        let config = BetfairDataConfig::default();
431
432        assert_eq!(config.account_currency, "GBP");
433        assert_eq!(config.request_rate_per_second, 5);
434        assert!(config.market_ids.is_none());
435        assert_eq!(config.stream_heartbeat_ms, 5_000);
436        assert!(config.stream_conflate_ms.is_none());
437        assert_eq!(config.subscription_delay_secs, 3);
438        assert!(!config.subscribe_race_data);
439        assert!(!config.subscribe_cricket_data);
440    }
441
442    #[rstest]
443    fn test_data_config_navigation_filter() {
444        let config = BetfairDataConfig {
445            event_type_names: Some(vec!["Horse Racing".to_string()]),
446            market_ids: Some(vec!["1.234567".to_string()]),
447            ..Default::default()
448        };
449
450        let filter = config.navigation_filter();
451
452        assert_eq!(
453            filter.event_type_names,
454            Some(vec!["Horse Racing".to_string()])
455        );
456        assert_eq!(filter.market_ids, Some(vec!["1.234567".to_string()]));
457    }
458
459    #[rstest]
460    fn test_data_config_stream_config() {
461        let config = BetfairDataConfig {
462            stream_host: Some("localhost".to_string()),
463            stream_port: Some(9443),
464            stream_heartbeat_ms: 2_500,
465            stream_idle_timeout_ms: 30_000,
466            stream_reconnect_delay_initial_ms: 500,
467            stream_reconnect_delay_max_ms: 5_000,
468            stream_use_tls: false,
469            ..Default::default()
470        };
471
472        let stream_config = config.stream_config();
473
474        assert_eq!(stream_config.host, "localhost");
475        assert_eq!(stream_config.port, 9443);
476        assert_eq!(stream_config.heartbeat_ms, 2_500);
477        assert_eq!(stream_config.idle_timeout_ms, 30_000);
478        assert_eq!(stream_config.reconnect_delay_initial_ms, 500);
479        assert_eq!(stream_config.reconnect_delay_max_ms, 5_000);
480        assert!(!stream_config.use_tls);
481    }
482
483    #[rstest]
484    fn test_data_config_stream_config_uses_defaults() {
485        let config = BetfairDataConfig::default();
486
487        let stream_config = config.stream_config();
488
489        assert_eq!(stream_config.host, BetfairStreamConfig::default().host);
490        assert_eq!(stream_config.port, BetfairStreamConfig::default().port);
491    }
492
493    #[rstest]
494    fn test_data_config_credential_rejects_partial_credentials() {
495        let config = BetfairDataConfig {
496            username: Some("testuser".to_string()),
497            ..Default::default()
498        };
499
500        let result = config.credential();
501
502        assert!(result.is_err());
503        assert!(
504            result
505                .err()
506                .unwrap()
507                .to_string()
508                .contains("password is missing")
509        );
510    }
511
512    #[rstest]
513    fn test_exec_config_default() {
514        let config = BetfairExecConfig::default();
515
516        assert_eq!(config.trader_id, TraderId::from("TRADER-001"));
517        assert_eq!(config.account_id, AccountId::from("BETFAIR-001"));
518        assert_eq!(config.account_currency, "GBP");
519        assert_eq!(config.request_rate_per_second, 5);
520        assert_eq!(config.order_request_rate_per_second, 20);
521        assert!(config.stream_market_ids_filter.is_none());
522        assert!(!config.ignore_external_orders);
523        assert!(config.calculate_account_state);
524        assert_eq!(config.request_account_state_secs, 300);
525        assert!(!config.reconcile_market_ids_only);
526        assert!(config.reconcile_market_ids.is_none());
527        assert!(!config.use_market_version);
528    }
529
530    #[rstest]
531    fn test_exec_config_with_market_filter() {
532        let config = BetfairExecConfig {
533            stream_market_ids_filter: Some(vec!["1.234567".to_string(), "1.890123".to_string()]),
534            ..Default::default()
535        };
536
537        let filter = config.stream_market_ids_filter.as_ref().unwrap();
538        assert_eq!(filter.len(), 2);
539        assert!(filter.contains(&"1.234567".to_string()));
540    }
541
542    #[rstest]
543    fn test_exec_config_external_orders_ignored() {
544        let config = BetfairExecConfig {
545            ignore_external_orders: true,
546            ..Default::default()
547        };
548
549        assert!(config.ignore_external_orders);
550    }
551
552    #[rstest]
553    fn test_exec_config_account_state_disabled() {
554        let config = BetfairExecConfig {
555            calculate_account_state: false,
556            ..Default::default()
557        };
558
559        assert!(!config.calculate_account_state);
560    }
561
562    #[rstest]
563    fn test_exec_config_reconcile_market_ids() {
564        let config = BetfairExecConfig {
565            reconcile_market_ids_only: true,
566            reconcile_market_ids: Some(vec!["1.234567".to_string()]),
567            ..Default::default()
568        };
569
570        assert!(config.reconcile_market_ids_only);
571        assert_eq!(config.reconcile_market_ids.as_ref().unwrap().len(), 1);
572    }
573
574    #[rstest]
575    fn test_exec_config_use_market_version() {
576        let config = BetfairExecConfig {
577            use_market_version: true,
578            ..Default::default()
579        };
580
581        assert!(config.use_market_version);
582    }
583
584    #[rstest]
585    fn test_exec_config_validate_rejects_zero_order_rate_limit() {
586        let config = BetfairExecConfig {
587            order_request_rate_per_second: 0,
588            ..Default::default()
589        };
590
591        let result = config.validate();
592        assert!(result.is_err());
593        assert!(
594            result
595                .err()
596                .unwrap()
597                .to_string()
598                .contains("order_request_rate_per_second")
599        );
600    }
601
602    #[rstest]
603    fn test_exec_config_validate_rejects_invalid_currency() {
604        let config = BetfairExecConfig {
605            account_currency: "INVALID".to_string(),
606            ..Default::default()
607        };
608
609        let result = config.validate();
610
611        assert!(result.is_err());
612        assert!(
613            result
614                .err()
615                .unwrap()
616                .to_string()
617                .contains("Invalid account currency")
618        );
619    }
620
621    #[rstest]
622    fn test_data_config_validate_rejects_bad_market_start_time() {
623        let config = BetfairDataConfig {
624            min_market_start_time: Some("not-a-timestamp".to_string()),
625            ..Default::default()
626        };
627
628        let result = config.validate();
629        assert!(result.is_err());
630        assert!(
631            result
632                .err()
633                .unwrap()
634                .to_string()
635                .contains("min_market_start_time")
636        );
637    }
638
639    #[rstest]
640    fn test_data_config_min_notional() {
641        let config = BetfairDataConfig {
642            default_min_notional: Some(Decimal::new(2, 0)),
643            ..Default::default()
644        };
645
646        let min_notional = config.min_notional().unwrap();
647        assert_eq!(
648            min_notional,
649            Some(Money::from_decimal(Decimal::new(2, 0), Currency::GBP()).unwrap())
650        );
651    }
652
653    #[rstest]
654    fn test_data_config_toml_minimal() {
655        let config: BetfairDataConfig = toml::from_str(
656            r#"
657account_currency = "USD"
658request_rate_per_second = 10
659stream_heartbeat_ms = 2500
660stream_idle_timeout_ms = 30000
661stream_reconnect_delay_initial_ms = 500
662stream_reconnect_delay_max_ms = 5000
663stream_use_tls = false
664subscription_delay_secs = 1
665subscribe_race_data = true
666subscribe_cricket_data = true
667"#,
668        )
669        .unwrap();
670
671        assert_eq!(config.account_currency, "USD");
672        assert_eq!(config.request_rate_per_second, 10);
673        assert_eq!(config.stream_heartbeat_ms, 2_500);
674        assert!(!config.stream_use_tls);
675        assert!(config.subscribe_race_data);
676        assert!(config.subscribe_cricket_data);
677    }
678
679    #[rstest]
680    fn test_exec_config_toml_empty_uses_defaults() {
681        let config: BetfairExecConfig = toml::from_str("").unwrap();
682        let expected = BetfairExecConfig::default();
683
684        assert_eq!(config.trader_id, expected.trader_id);
685        assert_eq!(config.account_id, expected.account_id);
686        assert_eq!(config.account_currency, expected.account_currency);
687        assert_eq!(
688            config.request_rate_per_second,
689            expected.request_rate_per_second,
690        );
691        assert_eq!(
692            config.order_request_rate_per_second,
693            expected.order_request_rate_per_second,
694        );
695        assert_eq!(config.stream_heartbeat_ms, expected.stream_heartbeat_ms);
696        assert_eq!(config.stream_use_tls, expected.stream_use_tls);
697        assert_eq!(
698            config.calculate_account_state,
699            expected.calculate_account_state,
700        );
701    }
702}