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