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