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::{config::check_unique_values, 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        if let Some(values) = &self.event_type_ids {
298            check_unique_values("event_type_ids", values)?;
299        }
300
301        if let Some(values) = &self.event_type_names {
302            check_unique_values("event_type_names", values)?;
303        }
304
305        if let Some(values) = &self.event_ids {
306            check_unique_values("event_ids", values)?;
307        }
308
309        if let Some(values) = &self.country_codes {
310            check_unique_values("country_codes", values)?;
311        }
312
313        if let Some(values) = &self.market_types {
314            check_unique_values("market_types", values)?;
315        }
316
317        if let Some(values) = &self.market_ids {
318            check_unique_values("market_ids", values)?;
319        }
320
321        let _ = self.currency()?;
322        validate_market_start_time("min_market_start_time", &self.min_market_start_time)?;
323        validate_market_start_time("max_market_start_time", &self.max_market_start_time)?;
324
325        if self.request_rate_per_second == 0 {
326            anyhow::bail!("request_rate_per_second must be greater than zero");
327        }
328
329        self.stream_config().validate()?;
330
331        Ok(())
332    }
333}
334
335/// Configuration for the Betfair live execution client.
336#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
337#[serde(default, deny_unknown_fields)]
338#[cfg_attr(
339    feature = "python",
340    pyo3::pyclass(module = "nautilus_trader.adapters.betfair", from_py_object)
341)]
342#[cfg_attr(
343    feature = "python",
344    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
345)]
346pub struct BetfairExecutionClientConfig {
347    /// Account ID for the client core.
348    #[builder(default = AccountId::from("BETFAIR-001"))]
349    pub account_id: AccountId,
350    /// Account currency code.
351    #[builder(default = "GBP".to_string())]
352    pub account_currency: String,
353    /// Optional Betfair username.
354    pub username: Option<SecretString>,
355    /// Optional Betfair password.
356    pub password: Option<SecretString>,
357    /// Optional Betfair application key.
358    pub app_key: Option<SecretString>,
359    /// Optional proxy URL for HTTP requests.
360    pub proxy_url: Option<SecretString>,
361    /// General HTTP request rate limit per second.
362    #[builder(default = 5)]
363    pub request_rate_per_second: u32,
364    /// Order HTTP request rate limit per second.
365    #[builder(default = 20)]
366    pub order_request_rate_per_second: u32,
367    /// Optional override for stream host.
368    pub stream_host: Option<String>,
369    /// Optional override for stream port.
370    pub stream_port: Option<u16>,
371    /// Optional interval between outbound stream heartbeat messages in seconds.
372    pub stream_heartbeat_secs: Option<u64>,
373    /// Optional dead-peer timeout override in seconds.
374    pub stream_heartbeat_timeout_secs: Option<u64>,
375    /// Initial reconnection backoff in milliseconds.
376    #[builder(default = 2_000)]
377    pub stream_reconnect_delay_initial_ms: u64,
378    /// Maximum reconnection backoff in milliseconds.
379    #[builder(default = 30_000)]
380    pub stream_reconnect_delay_max_ms: u64,
381    /// Whether to use TLS for the stream connection.
382    #[builder(default = true)]
383    pub stream_use_tls: bool,
384    /// Market IDs to filter on the order stream. When set, OCM updates for
385    /// markets not in this list are skipped. `None` processes all markets.
386    pub stream_market_ids_filter: Option<Vec<String>>,
387    /// When true, silently ignore orders from OCM that are not tracked in the local cache.
388    #[builder(default)]
389    pub ignore_external_orders: bool,
390    /// Whether to poll account state periodically.
391    #[builder(default = true)]
392    pub calculate_account_state: bool,
393    /// Interval in seconds between account state polls.
394    #[builder(default = 300)]
395    pub request_account_state_secs: u64,
396    /// When true, reconciliation only requests orders matching `reconcile_market_ids`.
397    #[builder(default)]
398    pub reconcile_market_ids_only: bool,
399    /// Market IDs to restrict reconciliation to.
400    pub reconcile_market_ids: Option<Vec<String>>,
401    /// When true, attach the latest market version to placeOrders and replaceOrders requests.
402    #[builder(default)]
403    pub use_market_version: bool,
404    /// Lookback window in minutes for the post-reconnect mass-status reconciliation
405    /// that recovers fills which terminated during the disconnect gap. Should
406    /// comfortably exceed the longest expected reconnect duration.
407    #[builder(default = 10)]
408    pub stream_gap_recovery_lookback_mins: u64,
409}
410
411#[cfg(feature = "python")]
412nautilus_core::impl_pyo3_config_getters!(BetfairExecutionClientConfig {
413    account_id: AccountId,
414    account_currency: String,
415    request_rate_per_second: u32,
416    order_request_rate_per_second: u32,
417    stream_host: Option<String>,
418    stream_port: Option<u16>,
419    stream_heartbeat_secs: Option<u64>,
420    stream_heartbeat_timeout_secs: Option<u64>,
421    stream_reconnect_delay_initial_ms: u64,
422    stream_reconnect_delay_max_ms: u64,
423    stream_use_tls: bool,
424    stream_market_ids_filter: Option<Vec<String>>,
425    ignore_external_orders: bool,
426    calculate_account_state: bool,
427    request_account_state_secs: u64,
428    reconcile_market_ids_only: bool,
429    reconcile_market_ids: Option<Vec<String>>,
430    use_market_version: bool,
431    stream_gap_recovery_lookback_mins: u64,
432});
433
434impl Default for BetfairExecutionClientConfig {
435    fn default() -> Self {
436        Self::builder().build()
437    }
438}
439
440impl ClientConfig for BetfairExecutionClientConfig {
441    fn as_any(&self) -> &dyn Any {
442        self
443    }
444}
445
446impl BetfairExecutionClientConfig {
447    /// Returns the configured credentials or resolves them from the environment.
448    ///
449    /// # Errors
450    ///
451    /// Returns an error if credentials are incomplete or unavailable.
452    pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
453        resolve_credential(
454            self.username.clone(),
455            self.password.clone(),
456            self.app_key.clone(),
457        )
458    }
459
460    /// Returns the configured account currency.
461    ///
462    /// # Errors
463    ///
464    /// Returns an error if the currency code is invalid.
465    pub fn currency(&self) -> anyhow::Result<Currency> {
466        parse_currency(&self.account_currency)
467    }
468
469    /// Returns the stream configuration.
470    #[must_use]
471    pub fn stream_config(&self) -> BetfairStreamConfig {
472        build_stream_config(
473            &self.stream_host,
474            &self.stream_port,
475            self.stream_heartbeat_secs,
476            self.stream_heartbeat_timeout_secs,
477            self.stream_reconnect_delay_initial_ms,
478            self.stream_reconnect_delay_max_ms,
479            self.stream_use_tls,
480        )
481    }
482
483    /// Validates the configuration.
484    ///
485    /// # Errors
486    ///
487    /// Returns an error if any configured value is invalid.
488    pub fn validate(&self) -> anyhow::Result<()> {
489        if let Some(values) = &self.stream_market_ids_filter {
490            check_unique_values("stream_market_ids_filter", values)?;
491        }
492
493        if let Some(values) = &self.reconcile_market_ids {
494            check_unique_values("reconcile_market_ids", values)?;
495        }
496
497        let _ = self.currency()?;
498
499        if self.request_rate_per_second == 0 {
500            anyhow::bail!("request_rate_per_second must be greater than zero");
501        }
502
503        if self.order_request_rate_per_second == 0 {
504            anyhow::bail!("order_request_rate_per_second must be greater than zero");
505        }
506
507        self.stream_config().validate()?;
508
509        Ok(())
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use nautilus_common::config::ConfigError;
516    use rstest::rstest;
517
518    use super::*;
519
520    #[rstest]
521    #[case("stream_market_ids_filter")]
522    #[case("reconcile_market_ids")]
523    fn test_execution_config_rejects_duplicate_selections(#[case] field: &str) {
524        let mut input = serde_json::to_value(BetfairExecutionClientConfig::default()).unwrap();
525        input[field] = serde_json::json!(["first", "second", "first"]);
526        let config: BetfairExecutionClientConfig = serde_json::from_value(input).unwrap();
527        let original = serde_json::to_value(&config).unwrap();
528        let result = config.validate();
529
530        assert_eq!(
531            result.unwrap_err().downcast_ref::<ConfigError>(),
532            Some(&ConfigError::duplicate(field, None))
533        );
534        assert_eq!(serde_json::to_value(&config).unwrap(), original);
535    }
536
537    #[rstest]
538    #[case("event_type_ids")]
539    #[case("event_type_names")]
540    #[case("event_ids")]
541    #[case("country_codes")]
542    #[case("market_types")]
543    #[case("market_ids")]
544    fn test_data_config_rejects_duplicate_selections(#[case] field: &str) {
545        let mut input = serde_json::to_value(BetfairDataClientConfig::default()).unwrap();
546        input[field] = serde_json::json!(["first", "second", "first"]);
547        let config: BetfairDataClientConfig = serde_json::from_value(input).unwrap();
548        let original = serde_json::to_value(&config).unwrap();
549        let result = config.validate();
550
551        assert_eq!(
552            result.unwrap_err().downcast_ref::<ConfigError>(),
553            Some(&ConfigError::duplicate(field, None))
554        );
555        assert_eq!(serde_json::to_value(&config).unwrap(), original);
556    }
557
558    #[rstest]
559    fn test_config_debug_redacts_credentials() {
560        let data = BetfairDataClientConfig {
561            username: Some("data-username".into()),
562            password: Some("data-password".into()),
563            app_key: Some("data-app-key".into()),
564            proxy_url: Some("http://user:data-proxy@localhost".into()),
565            ..Default::default()
566        };
567        let execution = BetfairExecutionClientConfig {
568            username: Some("exec-username".into()),
569            password: Some("exec-password".into()),
570            app_key: Some("exec-app-key".into()),
571            proxy_url: Some("http://user:exec-proxy@localhost".into()),
572            ..Default::default()
573        };
574
575        let formatted = format!("{data:?} {execution:?}");
576
577        assert_eq!(formatted.matches(REDACTED).count(), 8);
578
579        for secret in [
580            "data-username",
581            "data-password",
582            "data-app-key",
583            "data-proxy",
584            "exec-username",
585            "exec-password",
586            "exec-app-key",
587            "exec-proxy",
588        ] {
589            assert!(!formatted.contains(secret));
590        }
591    }
592
593    #[rstest]
594    fn test_data_config_default() {
595        let config = BetfairDataClientConfig::default();
596
597        assert_eq!(config.account_currency, "GBP");
598        assert_eq!(config.request_rate_per_second, 5);
599        assert!(config.market_ids.is_none());
600        assert_eq!(config.stream_heartbeat_secs, None);
601        assert_eq!(config.stream_heartbeat_timeout_secs, None);
602        assert!(config.stream_conflate_ms.is_none());
603        assert_eq!(config.subscription_delay_secs, 3);
604        assert!(!config.subscribe_race_data);
605        assert!(!config.subscribe_cricket_data);
606        assert_eq!(config.book_snapshot_timeout_secs, 10);
607    }
608
609    #[rstest]
610    fn test_data_config_navigation_filter() {
611        let config = BetfairDataClientConfig {
612            event_type_names: Some(vec!["Horse Racing".to_string()]),
613            market_ids: Some(vec!["1.234567".to_string()]),
614            ..Default::default()
615        };
616
617        let filter = config.navigation_filter();
618
619        assert_eq!(
620            filter.event_type_names,
621            Some(vec!["Horse Racing".to_string()])
622        );
623        assert_eq!(filter.market_ids, Some(vec!["1.234567".to_string()]));
624    }
625
626    #[rstest]
627    fn test_data_config_stream_config() {
628        let config = BetfairDataClientConfig {
629            stream_host: Some("localhost".to_string()),
630            stream_port: Some(9443),
631            stream_heartbeat_secs: Some(3),
632            stream_heartbeat_timeout_secs: Some(30),
633            stream_reconnect_delay_initial_ms: 500,
634            stream_reconnect_delay_max_ms: 5_000,
635            stream_use_tls: false,
636            ..Default::default()
637        };
638
639        let stream_config = config.stream_config();
640
641        assert_eq!(stream_config.host, "localhost");
642        assert_eq!(stream_config.port, 9443);
643        assert_eq!(stream_config.heartbeat_secs, Some(3));
644        assert_eq!(stream_config.heartbeat_timeout_secs, Some(30));
645        assert_eq!(stream_config.reconnect_delay_initial_ms, 500);
646        assert_eq!(stream_config.reconnect_delay_max_ms, 5_000);
647        assert!(!stream_config.use_tls);
648    }
649
650    #[rstest]
651    fn test_data_config_stream_config_uses_defaults() {
652        let config = BetfairDataClientConfig::default();
653
654        let stream_config = config.stream_config();
655
656        assert_eq!(stream_config.host, BetfairStreamConfig::default().host);
657        assert_eq!(stream_config.port, BetfairStreamConfig::default().port);
658    }
659
660    #[rstest]
661    fn test_data_config_credential_rejects_partial_credentials() {
662        let config = BetfairDataClientConfig {
663            username: Some("testuser".into()),
664            ..Default::default()
665        };
666
667        let result = config.credential();
668
669        assert!(result.is_err());
670        assert!(
671            result
672                .err()
673                .unwrap()
674                .to_string()
675                .contains("password is missing")
676        );
677    }
678
679    #[rstest]
680    fn test_exec_config_default() {
681        let config = BetfairExecutionClientConfig::default();
682        assert_eq!(config.account_id, AccountId::from("BETFAIR-001"));
683        assert_eq!(config.account_currency, "GBP");
684        assert_eq!(config.request_rate_per_second, 5);
685        assert_eq!(config.order_request_rate_per_second, 20);
686        assert_eq!(config.stream_heartbeat_secs, None);
687        assert_eq!(config.stream_heartbeat_timeout_secs, None);
688        assert!(config.stream_market_ids_filter.is_none());
689        assert!(!config.ignore_external_orders);
690        assert!(config.calculate_account_state);
691        assert_eq!(config.request_account_state_secs, 300);
692        assert!(!config.reconcile_market_ids_only);
693        assert!(config.reconcile_market_ids.is_none());
694        assert!(!config.use_market_version);
695    }
696
697    #[rstest]
698    fn test_exec_config_with_market_filter() {
699        let config = BetfairExecutionClientConfig {
700            stream_market_ids_filter: Some(vec!["1.234567".to_string(), "1.890123".to_string()]),
701            ..Default::default()
702        };
703
704        let filter = config.stream_market_ids_filter.as_ref().unwrap();
705        assert_eq!(filter.len(), 2);
706        assert!(filter.contains(&"1.234567".to_string()));
707    }
708
709    #[rstest]
710    fn test_exec_config_external_orders_ignored() {
711        let config = BetfairExecutionClientConfig {
712            ignore_external_orders: true,
713            ..Default::default()
714        };
715
716        assert!(config.ignore_external_orders);
717    }
718
719    #[rstest]
720    fn test_exec_config_account_state_disabled() {
721        let config = BetfairExecutionClientConfig {
722            calculate_account_state: false,
723            ..Default::default()
724        };
725
726        assert!(!config.calculate_account_state);
727    }
728
729    #[rstest]
730    fn test_exec_config_reconcile_market_ids() {
731        let config = BetfairExecutionClientConfig {
732            reconcile_market_ids_only: true,
733            reconcile_market_ids: Some(vec!["1.234567".to_string()]),
734            ..Default::default()
735        };
736
737        assert!(config.reconcile_market_ids_only);
738        assert_eq!(config.reconcile_market_ids.as_ref().unwrap().len(), 1);
739    }
740
741    #[rstest]
742    fn test_exec_config_use_market_version() {
743        let config = BetfairExecutionClientConfig {
744            use_market_version: true,
745            ..Default::default()
746        };
747
748        assert!(config.use_market_version);
749    }
750
751    #[rstest]
752    fn test_exec_config_validate_rejects_zero_order_rate_limit() {
753        let config = BetfairExecutionClientConfig {
754            order_request_rate_per_second: 0,
755            ..Default::default()
756        };
757
758        let result = config.validate();
759        assert!(result.is_err());
760        assert!(
761            result
762                .err()
763                .unwrap()
764                .to_string()
765                .contains("order_request_rate_per_second")
766        );
767    }
768
769    #[rstest]
770    fn test_exec_config_validate_rejects_invalid_currency() {
771        let config = BetfairExecutionClientConfig {
772            account_currency: "INVALID".to_string(),
773            ..Default::default()
774        };
775
776        let result = config.validate();
777
778        assert!(result.is_err());
779        assert!(
780            result
781                .err()
782                .unwrap()
783                .to_string()
784                .contains("Invalid account currency")
785        );
786    }
787
788    #[rstest]
789    fn test_data_config_validate_rejects_bad_market_start_time() {
790        let config = BetfairDataClientConfig {
791            min_market_start_time: Some("not-a-timestamp".to_string()),
792            ..Default::default()
793        };
794
795        let result = config.validate();
796        assert!(result.is_err());
797        assert!(
798            result
799                .err()
800                .unwrap()
801                .to_string()
802                .contains("min_market_start_time")
803        );
804    }
805
806    #[rstest]
807    fn test_data_config_min_notional() {
808        let config = BetfairDataClientConfig {
809            default_min_notional: Some(Decimal::new(2, 0)),
810            ..Default::default()
811        };
812
813        let min_notional = config.min_notional().unwrap();
814        assert_eq!(
815            min_notional,
816            Some(Money::from_decimal(Decimal::new(2, 0), Currency::GBP()).unwrap())
817        );
818    }
819
820    #[rstest]
821    fn test_data_config_toml_minimal() {
822        let config: BetfairDataClientConfig = toml::from_str(
823            r#"
824account_currency = "USD"
825request_rate_per_second = 10
826stream_heartbeat_secs = 3
827stream_heartbeat_timeout_secs = 30
828stream_reconnect_delay_initial_ms = 500
829stream_reconnect_delay_max_ms = 5000
830stream_use_tls = false
831subscription_delay_secs = 1
832subscribe_race_data = true
833subscribe_cricket_data = true
834"#,
835        )
836        .unwrap();
837
838        assert_eq!(config.account_currency, "USD");
839        assert_eq!(config.request_rate_per_second, 10);
840        assert_eq!(config.stream_heartbeat_secs, Some(3));
841        assert!(!config.stream_use_tls);
842        assert!(config.subscribe_race_data);
843        assert!(config.subscribe_cricket_data);
844    }
845
846    #[rstest]
847    fn test_exec_config_toml_empty_uses_defaults() {
848        let config: BetfairExecutionClientConfig = toml::from_str("").unwrap();
849        let expected = BetfairExecutionClientConfig::default();
850        assert_eq!(config.account_id, expected.account_id);
851        assert_eq!(config.account_currency, expected.account_currency);
852        assert_eq!(
853            config.request_rate_per_second,
854            expected.request_rate_per_second,
855        );
856        assert_eq!(
857            config.order_request_rate_per_second,
858            expected.order_request_rate_per_second,
859        );
860        assert_eq!(config.stream_heartbeat_secs, expected.stream_heartbeat_secs);
861        assert_eq!(config.stream_use_tls, expected.stream_use_tls);
862        assert_eq!(
863            config.calculate_account_state,
864            expected.calculate_account_state,
865        );
866    }
867}