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