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_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#[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 #[builder(default = "GBP".to_string())]
125 pub account_currency: String,
126 pub username: Option<SecretString>,
128 pub password: Option<SecretString>,
130 pub app_key: Option<SecretString>,
132 pub proxy_url: Option<SecretString>,
134 #[builder(default = 5)]
136 pub request_rate_per_second: u32,
137 pub default_min_notional: Option<Decimal>,
139 pub event_type_ids: Option<Vec<String>>,
141 pub event_type_names: Option<Vec<String>>,
143 pub event_ids: Option<Vec<String>>,
145 pub country_codes: Option<Vec<String>>,
147 pub market_types: Option<Vec<String>>,
149 pub market_ids: Option<Vec<String>>,
151 pub min_market_start_time: Option<String>,
153 pub max_market_start_time: Option<String>,
155 pub stream_host: Option<String>,
157 pub stream_port: Option<u16>,
159 pub stream_heartbeat_secs: Option<u64>,
161 pub stream_heartbeat_timeout_secs: Option<u64>,
163 #[builder(default = 2_000)]
165 pub stream_reconnect_delay_initial_ms: u64,
166 #[builder(default = 30_000)]
168 pub stream_reconnect_delay_max_ms: u64,
169 #[builder(default = true)]
171 pub stream_use_tls: bool,
172 pub stream_conflate_ms: Option<u64>,
175 #[builder(default = 3)]
177 pub subscription_delay_secs: u64,
178 #[builder(default)]
180 pub subscribe_race_data: bool,
181 #[builder(default)]
183 pub subscribe_cricket_data: bool,
184 #[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 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 pub fn currency(&self) -> anyhow::Result<Currency> {
249 parse_currency(&self.account_currency)
250 }
251
252 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 #[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 #[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 pub fn validate(&self) -> anyhow::Result<()> {
297 let _ = self.currency()?;
298 validate_market_start_time("min_market_start_time", &self.min_market_start_time)?;
299 validate_market_start_time("max_market_start_time", &self.max_market_start_time)?;
300
301 if self.request_rate_per_second == 0 {
302 anyhow::bail!("request_rate_per_second must be greater than zero");
303 }
304
305 self.stream_config().validate()?;
306
307 Ok(())
308 }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
313#[serde(default, deny_unknown_fields)]
314#[cfg_attr(
315 feature = "python",
316 pyo3::pyclass(module = "nautilus_trader.adapters.betfair", from_py_object)
317)]
318#[cfg_attr(
319 feature = "python",
320 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
321)]
322pub struct BetfairExecutionClientConfig {
323 #[builder(default = AccountId::from("BETFAIR-001"))]
325 pub account_id: AccountId,
326 #[builder(default = "GBP".to_string())]
328 pub account_currency: String,
329 pub username: Option<SecretString>,
331 pub password: Option<SecretString>,
333 pub app_key: Option<SecretString>,
335 pub proxy_url: Option<SecretString>,
337 #[builder(default = 5)]
339 pub request_rate_per_second: u32,
340 #[builder(default = 20)]
342 pub order_request_rate_per_second: u32,
343 pub stream_host: Option<String>,
345 pub stream_port: Option<u16>,
347 pub stream_heartbeat_secs: Option<u64>,
349 pub stream_heartbeat_timeout_secs: Option<u64>,
351 #[builder(default = 2_000)]
353 pub stream_reconnect_delay_initial_ms: u64,
354 #[builder(default = 30_000)]
356 pub stream_reconnect_delay_max_ms: u64,
357 #[builder(default = true)]
359 pub stream_use_tls: bool,
360 pub stream_market_ids_filter: Option<Vec<String>>,
363 #[builder(default)]
365 pub ignore_external_orders: bool,
366 #[builder(default = true)]
368 pub calculate_account_state: bool,
369 #[builder(default = 300)]
371 pub request_account_state_secs: u64,
372 #[builder(default)]
374 pub reconcile_market_ids_only: bool,
375 pub reconcile_market_ids: Option<Vec<String>>,
377 #[builder(default)]
379 pub use_market_version: bool,
380 #[builder(default = 10)]
384 pub stream_gap_recovery_lookback_mins: u64,
385}
386
387#[cfg(feature = "python")]
388nautilus_core::impl_pyo3_config_getters!(BetfairExecutionClientConfig {
389 account_id: AccountId,
390 account_currency: String,
391 request_rate_per_second: u32,
392 order_request_rate_per_second: u32,
393 stream_host: Option<String>,
394 stream_port: Option<u16>,
395 stream_heartbeat_secs: Option<u64>,
396 stream_heartbeat_timeout_secs: Option<u64>,
397 stream_reconnect_delay_initial_ms: u64,
398 stream_reconnect_delay_max_ms: u64,
399 stream_use_tls: bool,
400 stream_market_ids_filter: Option<Vec<String>>,
401 ignore_external_orders: bool,
402 calculate_account_state: bool,
403 request_account_state_secs: u64,
404 reconcile_market_ids_only: bool,
405 reconcile_market_ids: Option<Vec<String>>,
406 use_market_version: bool,
407 stream_gap_recovery_lookback_mins: u64,
408});
409
410impl Default for BetfairExecutionClientConfig {
411 fn default() -> Self {
412 Self::builder().build()
413 }
414}
415
416impl ClientConfig for BetfairExecutionClientConfig {
417 fn as_any(&self) -> &dyn Any {
418 self
419 }
420}
421
422impl BetfairExecutionClientConfig {
423 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
429 resolve_credential(
430 self.username.clone(),
431 self.password.clone(),
432 self.app_key.clone(),
433 )
434 }
435
436 pub fn currency(&self) -> anyhow::Result<Currency> {
442 parse_currency(&self.account_currency)
443 }
444
445 #[must_use]
447 pub fn stream_config(&self) -> BetfairStreamConfig {
448 build_stream_config(
449 &self.stream_host,
450 &self.stream_port,
451 self.stream_heartbeat_secs,
452 self.stream_heartbeat_timeout_secs,
453 self.stream_reconnect_delay_initial_ms,
454 self.stream_reconnect_delay_max_ms,
455 self.stream_use_tls,
456 )
457 }
458
459 pub fn validate(&self) -> anyhow::Result<()> {
465 let _ = self.currency()?;
466
467 if self.request_rate_per_second == 0 {
468 anyhow::bail!("request_rate_per_second must be greater than zero");
469 }
470
471 if self.order_request_rate_per_second == 0 {
472 anyhow::bail!("order_request_rate_per_second must be greater than zero");
473 }
474
475 self.stream_config().validate()?;
476
477 Ok(())
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use rstest::rstest;
484
485 use super::*;
486
487 #[rstest]
488 fn test_config_debug_redacts_credentials() {
489 let data = BetfairDataClientConfig {
490 username: Some("data-username".into()),
491 password: Some("data-password".into()),
492 app_key: Some("data-app-key".into()),
493 proxy_url: Some("http://user:data-proxy@localhost".into()),
494 ..Default::default()
495 };
496 let execution = BetfairExecutionClientConfig {
497 username: Some("exec-username".into()),
498 password: Some("exec-password".into()),
499 app_key: Some("exec-app-key".into()),
500 proxy_url: Some("http://user:exec-proxy@localhost".into()),
501 ..Default::default()
502 };
503
504 let formatted = format!("{data:?} {execution:?}");
505
506 assert_eq!(formatted.matches(REDACTED).count(), 8);
507
508 for secret in [
509 "data-username",
510 "data-password",
511 "data-app-key",
512 "data-proxy",
513 "exec-username",
514 "exec-password",
515 "exec-app-key",
516 "exec-proxy",
517 ] {
518 assert!(!formatted.contains(secret));
519 }
520 }
521
522 #[rstest]
523 fn test_data_config_default() {
524 let config = BetfairDataClientConfig::default();
525
526 assert_eq!(config.account_currency, "GBP");
527 assert_eq!(config.request_rate_per_second, 5);
528 assert!(config.market_ids.is_none());
529 assert_eq!(config.stream_heartbeat_secs, None);
530 assert_eq!(config.stream_heartbeat_timeout_secs, None);
531 assert!(config.stream_conflate_ms.is_none());
532 assert_eq!(config.subscription_delay_secs, 3);
533 assert!(!config.subscribe_race_data);
534 assert!(!config.subscribe_cricket_data);
535 assert_eq!(config.book_snapshot_timeout_secs, 10);
536 }
537
538 #[rstest]
539 fn test_data_config_navigation_filter() {
540 let config = BetfairDataClientConfig {
541 event_type_names: Some(vec!["Horse Racing".to_string()]),
542 market_ids: Some(vec!["1.234567".to_string()]),
543 ..Default::default()
544 };
545
546 let filter = config.navigation_filter();
547
548 assert_eq!(
549 filter.event_type_names,
550 Some(vec!["Horse Racing".to_string()])
551 );
552 assert_eq!(filter.market_ids, Some(vec!["1.234567".to_string()]));
553 }
554
555 #[rstest]
556 fn test_data_config_stream_config() {
557 let config = BetfairDataClientConfig {
558 stream_host: Some("localhost".to_string()),
559 stream_port: Some(9443),
560 stream_heartbeat_secs: Some(3),
561 stream_heartbeat_timeout_secs: Some(30),
562 stream_reconnect_delay_initial_ms: 500,
563 stream_reconnect_delay_max_ms: 5_000,
564 stream_use_tls: false,
565 ..Default::default()
566 };
567
568 let stream_config = config.stream_config();
569
570 assert_eq!(stream_config.host, "localhost");
571 assert_eq!(stream_config.port, 9443);
572 assert_eq!(stream_config.heartbeat_secs, Some(3));
573 assert_eq!(stream_config.heartbeat_timeout_secs, Some(30));
574 assert_eq!(stream_config.reconnect_delay_initial_ms, 500);
575 assert_eq!(stream_config.reconnect_delay_max_ms, 5_000);
576 assert!(!stream_config.use_tls);
577 }
578
579 #[rstest]
580 fn test_data_config_stream_config_uses_defaults() {
581 let config = BetfairDataClientConfig::default();
582
583 let stream_config = config.stream_config();
584
585 assert_eq!(stream_config.host, BetfairStreamConfig::default().host);
586 assert_eq!(stream_config.port, BetfairStreamConfig::default().port);
587 }
588
589 #[rstest]
590 fn test_data_config_credential_rejects_partial_credentials() {
591 let config = BetfairDataClientConfig {
592 username: Some("testuser".into()),
593 ..Default::default()
594 };
595
596 let result = config.credential();
597
598 assert!(result.is_err());
599 assert!(
600 result
601 .err()
602 .unwrap()
603 .to_string()
604 .contains("password is missing")
605 );
606 }
607
608 #[rstest]
609 fn test_exec_config_default() {
610 let config = BetfairExecutionClientConfig::default();
611 assert_eq!(config.account_id, AccountId::from("BETFAIR-001"));
612 assert_eq!(config.account_currency, "GBP");
613 assert_eq!(config.request_rate_per_second, 5);
614 assert_eq!(config.order_request_rate_per_second, 20);
615 assert_eq!(config.stream_heartbeat_secs, None);
616 assert_eq!(config.stream_heartbeat_timeout_secs, None);
617 assert!(config.stream_market_ids_filter.is_none());
618 assert!(!config.ignore_external_orders);
619 assert!(config.calculate_account_state);
620 assert_eq!(config.request_account_state_secs, 300);
621 assert!(!config.reconcile_market_ids_only);
622 assert!(config.reconcile_market_ids.is_none());
623 assert!(!config.use_market_version);
624 }
625
626 #[rstest]
627 fn test_exec_config_with_market_filter() {
628 let config = BetfairExecutionClientConfig {
629 stream_market_ids_filter: Some(vec!["1.234567".to_string(), "1.890123".to_string()]),
630 ..Default::default()
631 };
632
633 let filter = config.stream_market_ids_filter.as_ref().unwrap();
634 assert_eq!(filter.len(), 2);
635 assert!(filter.contains(&"1.234567".to_string()));
636 }
637
638 #[rstest]
639 fn test_exec_config_external_orders_ignored() {
640 let config = BetfairExecutionClientConfig {
641 ignore_external_orders: true,
642 ..Default::default()
643 };
644
645 assert!(config.ignore_external_orders);
646 }
647
648 #[rstest]
649 fn test_exec_config_account_state_disabled() {
650 let config = BetfairExecutionClientConfig {
651 calculate_account_state: false,
652 ..Default::default()
653 };
654
655 assert!(!config.calculate_account_state);
656 }
657
658 #[rstest]
659 fn test_exec_config_reconcile_market_ids() {
660 let config = BetfairExecutionClientConfig {
661 reconcile_market_ids_only: true,
662 reconcile_market_ids: Some(vec!["1.234567".to_string()]),
663 ..Default::default()
664 };
665
666 assert!(config.reconcile_market_ids_only);
667 assert_eq!(config.reconcile_market_ids.as_ref().unwrap().len(), 1);
668 }
669
670 #[rstest]
671 fn test_exec_config_use_market_version() {
672 let config = BetfairExecutionClientConfig {
673 use_market_version: true,
674 ..Default::default()
675 };
676
677 assert!(config.use_market_version);
678 }
679
680 #[rstest]
681 fn test_exec_config_validate_rejects_zero_order_rate_limit() {
682 let config = BetfairExecutionClientConfig {
683 order_request_rate_per_second: 0,
684 ..Default::default()
685 };
686
687 let result = config.validate();
688 assert!(result.is_err());
689 assert!(
690 result
691 .err()
692 .unwrap()
693 .to_string()
694 .contains("order_request_rate_per_second")
695 );
696 }
697
698 #[rstest]
699 fn test_exec_config_validate_rejects_invalid_currency() {
700 let config = BetfairExecutionClientConfig {
701 account_currency: "INVALID".to_string(),
702 ..Default::default()
703 };
704
705 let result = config.validate();
706
707 assert!(result.is_err());
708 assert!(
709 result
710 .err()
711 .unwrap()
712 .to_string()
713 .contains("Invalid account currency")
714 );
715 }
716
717 #[rstest]
718 fn test_data_config_validate_rejects_bad_market_start_time() {
719 let config = BetfairDataClientConfig {
720 min_market_start_time: Some("not-a-timestamp".to_string()),
721 ..Default::default()
722 };
723
724 let result = config.validate();
725 assert!(result.is_err());
726 assert!(
727 result
728 .err()
729 .unwrap()
730 .to_string()
731 .contains("min_market_start_time")
732 );
733 }
734
735 #[rstest]
736 fn test_data_config_min_notional() {
737 let config = BetfairDataClientConfig {
738 default_min_notional: Some(Decimal::new(2, 0)),
739 ..Default::default()
740 };
741
742 let min_notional = config.min_notional().unwrap();
743 assert_eq!(
744 min_notional,
745 Some(Money::from_decimal(Decimal::new(2, 0), Currency::GBP()).unwrap())
746 );
747 }
748
749 #[rstest]
750 fn test_data_config_toml_minimal() {
751 let config: BetfairDataClientConfig = toml::from_str(
752 r#"
753account_currency = "USD"
754request_rate_per_second = 10
755stream_heartbeat_secs = 3
756stream_heartbeat_timeout_secs = 30
757stream_reconnect_delay_initial_ms = 500
758stream_reconnect_delay_max_ms = 5000
759stream_use_tls = false
760subscription_delay_secs = 1
761subscribe_race_data = true
762subscribe_cricket_data = true
763"#,
764 )
765 .unwrap();
766
767 assert_eq!(config.account_currency, "USD");
768 assert_eq!(config.request_rate_per_second, 10);
769 assert_eq!(config.stream_heartbeat_secs, Some(3));
770 assert!(!config.stream_use_tls);
771 assert!(config.subscribe_race_data);
772 assert!(config.subscribe_cricket_data);
773 }
774
775 #[rstest]
776 fn test_exec_config_toml_empty_uses_defaults() {
777 let config: BetfairExecutionClientConfig = toml::from_str("").unwrap();
778 let expected = BetfairExecutionClientConfig::default();
779 assert_eq!(config.account_id, expected.account_id);
780 assert_eq!(config.account_currency, expected.account_currency);
781 assert_eq!(
782 config.request_rate_per_second,
783 expected.request_rate_per_second,
784 );
785 assert_eq!(
786 config.order_request_rate_per_second,
787 expected.order_request_rate_per_second,
788 );
789 assert_eq!(config.stream_heartbeat_secs, expected.stream_heartbeat_secs);
790 assert_eq!(config.stream_use_tls, expected.stream_use_tls);
791 assert_eq!(
792 config.calculate_account_state,
793 expected.calculate_account_state,
794 );
795 }
796}