1use 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#[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 #[builder(default = "GBP".to_string())]
124 pub account_currency: String,
125 pub username: Option<SecretString>,
127 pub password: Option<SecretString>,
129 pub app_key: Option<SecretString>,
131 pub proxy_url: Option<SecretString>,
133 #[builder(default = 5)]
135 pub request_rate_per_second: u32,
136 pub default_min_notional: Option<Decimal>,
138 pub event_type_ids: Option<Vec<String>>,
140 pub event_type_names: Option<Vec<String>>,
142 pub event_ids: Option<Vec<String>>,
144 pub country_codes: Option<Vec<String>>,
146 pub market_types: Option<Vec<String>>,
148 pub market_ids: Option<Vec<String>>,
150 pub min_market_start_time: Option<String>,
152 pub max_market_start_time: Option<String>,
154 pub stream_host: Option<String>,
156 pub stream_port: Option<u16>,
158 pub stream_heartbeat_secs: Option<u64>,
160 pub stream_heartbeat_timeout_secs: Option<u64>,
162 #[builder(default = 2_000)]
164 pub stream_reconnect_delay_initial_ms: u64,
165 #[builder(default = 30_000)]
167 pub stream_reconnect_delay_max_ms: u64,
168 #[builder(default = true)]
170 pub stream_use_tls: bool,
171 pub stream_conflate_ms: Option<u64>,
174 #[builder(default = 3)]
176 pub subscription_delay_secs: u64,
177 #[builder(default)]
179 pub subscribe_race_data: bool,
180 #[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 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 pub fn currency(&self) -> anyhow::Result<Currency> {
243 parse_currency(&self.account_currency)
244 }
245
246 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 #[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 #[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 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#[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 #[builder(default = AccountId::from("BETFAIR-001"))]
319 pub account_id: AccountId,
320 #[builder(default = "GBP".to_string())]
322 pub account_currency: String,
323 pub username: Option<SecretString>,
325 pub password: Option<SecretString>,
327 pub app_key: Option<SecretString>,
329 pub proxy_url: Option<SecretString>,
331 #[builder(default = 5)]
333 pub request_rate_per_second: u32,
334 #[builder(default = 20)]
336 pub order_request_rate_per_second: u32,
337 pub stream_host: Option<String>,
339 pub stream_port: Option<u16>,
341 pub stream_heartbeat_secs: Option<u64>,
343 pub stream_heartbeat_timeout_secs: Option<u64>,
345 #[builder(default = 2_000)]
347 pub stream_reconnect_delay_initial_ms: u64,
348 #[builder(default = 30_000)]
350 pub stream_reconnect_delay_max_ms: u64,
351 #[builder(default = true)]
353 pub stream_use_tls: bool,
354 pub stream_market_ids_filter: Option<Vec<String>>,
357 #[builder(default)]
359 pub ignore_external_orders: bool,
360 #[builder(default = true)]
362 pub calculate_account_state: bool,
363 #[builder(default = 300)]
365 pub request_account_state_secs: u64,
366 #[builder(default)]
368 pub reconcile_market_ids_only: bool,
369 pub reconcile_market_ids: Option<Vec<String>>,
371 #[builder(default)]
373 pub use_market_version: bool,
374 #[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 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 pub fn currency(&self) -> anyhow::Result<Currency> {
436 parse_currency(&self.account_currency)
437 }
438
439 #[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 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}