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