1use std::any::Any;
19
20use nautilus_common::factories::ClientConfig;
21use nautilus_model::{
22 identifiers::AccountId,
23 types::{Currency, Money},
24};
25use rust_decimal::Decimal;
26use serde::{Deserialize, Serialize};
27
28use crate::{
29 common::{
30 credential::{BetfairCredential, CredentialError},
31 parse::parse_betfair_timestamp,
32 },
33 provider::NavigationFilter,
34 stream::config::BetfairStreamConfig,
35};
36
37fn parse_currency(code: &str) -> anyhow::Result<Currency> {
38 code.parse::<Currency>()
39 .map_err(|_| anyhow::anyhow!("Invalid account currency code: {code}"))
40}
41
42fn make_min_notional(value: Option<Decimal>, currency: Currency) -> anyhow::Result<Option<Money>> {
43 value
44 .map(|amount| Money::from_decimal(amount, currency))
45 .transpose()
46 .map_err(Into::into)
47}
48
49fn validate_market_start_time(label: &str, value: &Option<String>) -> anyhow::Result<()> {
50 if let Some(value) = value {
51 parse_betfair_timestamp(value)
52 .map(|_| ())
53 .map_err(|e| anyhow::anyhow!("Invalid {label} '{value}': {e}"))?;
54 }
55
56 Ok(())
57}
58
59fn resolve_credential(
60 username: Option<String>,
61 password: Option<String>,
62 app_key: Option<String>,
63) -> anyhow::Result<BetfairCredential> {
64 match BetfairCredential::resolve(username, password, app_key) {
65 Ok(Some(credential)) => Ok(credential),
66 Ok(None) => anyhow::bail!("Missing Betfair credentials in config and environment"),
67 Err(e) => Err(match e {
68 CredentialError::MissingPassword => anyhow::anyhow!(
69 "Invalid Betfair credentials: username provided but password is missing",
70 ),
71 CredentialError::MissingUsername => anyhow::anyhow!(
72 "Invalid Betfair credentials: password or app key provided but username is missing",
73 ),
74 CredentialError::MissingAppKey => {
75 anyhow::anyhow!("Invalid Betfair credentials: app key is missing")
76 }
77 }),
78 }
79}
80
81fn build_stream_config(
82 stream_host: &Option<String>,
83 stream_port: &Option<u16>,
84 stream_heartbeat_secs: Option<u64>,
85 stream_heartbeat_timeout_secs: Option<u64>,
86 stream_reconnect_delay_initial_ms: u64,
87 stream_reconnect_delay_max_ms: u64,
88 stream_use_tls: bool,
89) -> BetfairStreamConfig {
90 let defaults = BetfairStreamConfig::default();
91
92 BetfairStreamConfig {
93 host: stream_host.clone().unwrap_or(defaults.host),
94 port: stream_port.unwrap_or(defaults.port),
95 heartbeat_secs: stream_heartbeat_secs,
96 heartbeat_timeout_secs: stream_heartbeat_timeout_secs,
97 reconnect_delay_initial_ms: stream_reconnect_delay_initial_ms,
98 reconnect_delay_max_ms: stream_reconnect_delay_max_ms,
99 use_tls: stream_use_tls,
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
105#[serde(default, deny_unknown_fields)]
106#[cfg_attr(
107 feature = "python",
108 pyo3::pyclass(module = "nautilus_trader.adapters.betfair", from_py_object)
109)]
110#[cfg_attr(
111 feature = "python",
112 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
113)]
114pub struct BetfairDataClientConfig {
115 #[builder(default = "GBP".to_string())]
117 pub account_currency: String,
118 pub username: Option<String>,
120 pub password: Option<String>,
122 pub app_key: Option<String>,
124 pub proxy_url: Option<String>,
126 #[builder(default = 5)]
128 pub request_rate_per_second: u32,
129 pub default_min_notional: Option<Decimal>,
131 pub event_type_ids: Option<Vec<String>>,
133 pub event_type_names: Option<Vec<String>>,
135 pub event_ids: Option<Vec<String>>,
137 pub country_codes: Option<Vec<String>>,
139 pub market_types: Option<Vec<String>>,
141 pub market_ids: Option<Vec<String>>,
143 pub min_market_start_time: Option<String>,
145 pub max_market_start_time: Option<String>,
147 pub stream_host: Option<String>,
149 pub stream_port: Option<u16>,
151 pub stream_heartbeat_secs: Option<u64>,
153 pub stream_heartbeat_timeout_secs: Option<u64>,
155 #[builder(default = 2_000)]
157 pub stream_reconnect_delay_initial_ms: u64,
158 #[builder(default = 30_000)]
160 pub stream_reconnect_delay_max_ms: u64,
161 #[builder(default = true)]
163 pub stream_use_tls: bool,
164 pub stream_conflate_ms: Option<u64>,
167 #[builder(default = 3)]
169 pub subscription_delay_secs: u64,
170 #[builder(default)]
172 pub subscribe_race_data: bool,
173 #[builder(default)]
175 pub subscribe_cricket_data: bool,
176}
177
178#[cfg(feature = "python")]
179nautilus_core::impl_pyo3_config_getters!(BetfairDataClientConfig {
180 account_currency: String,
181 username: Option<String>,
182 request_rate_per_second: u32,
183 default_min_notional: Option<Decimal>,
184 event_type_ids: Option<Vec<String>>,
185 event_type_names: Option<Vec<String>>,
186 event_ids: Option<Vec<String>>,
187 country_codes: Option<Vec<String>>,
188 market_types: Option<Vec<String>>,
189 market_ids: Option<Vec<String>>,
190 min_market_start_time: Option<String>,
191 max_market_start_time: Option<String>,
192 stream_host: Option<String>,
193 stream_port: Option<u16>,
194 stream_heartbeat_secs: Option<u64>,
195 stream_heartbeat_timeout_secs: Option<u64>,
196 stream_reconnect_delay_initial_ms: u64,
197 stream_reconnect_delay_max_ms: u64,
198 stream_use_tls: bool,
199 stream_conflate_ms: Option<u64>,
200 subscription_delay_secs: u64,
201 subscribe_race_data: bool,
202 subscribe_cricket_data: bool,
203});
204
205impl Default for BetfairDataClientConfig {
206 fn default() -> Self {
207 Self::builder().build()
208 }
209}
210
211impl ClientConfig for BetfairDataClientConfig {
212 fn as_any(&self) -> &dyn Any {
213 self
214 }
215}
216
217impl BetfairDataClientConfig {
218 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
224 resolve_credential(
225 self.username.clone(),
226 self.password.clone(),
227 self.app_key.clone(),
228 )
229 }
230
231 pub fn currency(&self) -> anyhow::Result<Currency> {
237 parse_currency(&self.account_currency)
238 }
239
240 pub fn min_notional(&self) -> anyhow::Result<Option<Money>> {
246 let currency = self.currency()?;
247 make_min_notional(self.default_min_notional, currency)
248 }
249
250 #[must_use]
252 pub fn navigation_filter(&self) -> NavigationFilter {
253 NavigationFilter {
254 event_type_ids: self.event_type_ids.clone(),
255 event_type_names: self.event_type_names.clone(),
256 event_ids: self.event_ids.clone(),
257 country_codes: self.country_codes.clone(),
258 market_types: self.market_types.clone(),
259 market_ids: self.market_ids.clone(),
260 min_market_start_time: self.min_market_start_time.clone(),
261 max_market_start_time: self.max_market_start_time.clone(),
262 }
263 }
264
265 #[must_use]
267 pub fn stream_config(&self) -> BetfairStreamConfig {
268 build_stream_config(
269 &self.stream_host,
270 &self.stream_port,
271 self.stream_heartbeat_secs,
272 self.stream_heartbeat_timeout_secs,
273 self.stream_reconnect_delay_initial_ms,
274 self.stream_reconnect_delay_max_ms,
275 self.stream_use_tls,
276 )
277 }
278
279 pub fn validate(&self) -> anyhow::Result<()> {
285 let _ = self.currency()?;
286 validate_market_start_time("min_market_start_time", &self.min_market_start_time)?;
287 validate_market_start_time("max_market_start_time", &self.max_market_start_time)?;
288
289 if self.request_rate_per_second == 0 {
290 anyhow::bail!("request_rate_per_second must be greater than zero");
291 }
292
293 self.stream_config().validate()?;
294
295 Ok(())
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
301#[serde(default, deny_unknown_fields)]
302#[cfg_attr(
303 feature = "python",
304 pyo3::pyclass(module = "nautilus_trader.adapters.betfair", from_py_object)
305)]
306#[cfg_attr(
307 feature = "python",
308 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
309)]
310pub struct BetfairExecutionClientConfig {
311 #[builder(default = AccountId::from("BETFAIR-001"))]
313 pub account_id: AccountId,
314 #[builder(default = "GBP".to_string())]
316 pub account_currency: String,
317 pub username: Option<String>,
319 pub password: Option<String>,
321 pub app_key: Option<String>,
323 pub proxy_url: Option<String>,
325 #[builder(default = 5)]
327 pub request_rate_per_second: u32,
328 #[builder(default = 20)]
330 pub order_request_rate_per_second: u32,
331 pub stream_host: Option<String>,
333 pub stream_port: Option<u16>,
335 pub stream_heartbeat_secs: Option<u64>,
337 pub stream_heartbeat_timeout_secs: Option<u64>,
339 #[builder(default = 2_000)]
341 pub stream_reconnect_delay_initial_ms: u64,
342 #[builder(default = 30_000)]
344 pub stream_reconnect_delay_max_ms: u64,
345 #[builder(default = true)]
347 pub stream_use_tls: bool,
348 pub stream_market_ids_filter: Option<Vec<String>>,
351 #[builder(default)]
353 pub ignore_external_orders: bool,
354 #[builder(default = true)]
356 pub calculate_account_state: bool,
357 #[builder(default = 300)]
359 pub request_account_state_secs: u64,
360 #[builder(default)]
362 pub reconcile_market_ids_only: bool,
363 pub reconcile_market_ids: Option<Vec<String>>,
365 #[builder(default)]
367 pub use_market_version: bool,
368 #[builder(default = 10)]
372 pub stream_gap_recovery_lookback_mins: u64,
373}
374
375#[cfg(feature = "python")]
376nautilus_core::impl_pyo3_config_getters!(BetfairExecutionClientConfig {
377 account_id: AccountId,
378 account_currency: String,
379 username: Option<String>,
380 request_rate_per_second: u32,
381 order_request_rate_per_second: u32,
382 stream_host: Option<String>,
383 stream_port: Option<u16>,
384 stream_heartbeat_secs: Option<u64>,
385 stream_heartbeat_timeout_secs: Option<u64>,
386 stream_reconnect_delay_initial_ms: u64,
387 stream_reconnect_delay_max_ms: u64,
388 stream_use_tls: bool,
389 stream_market_ids_filter: Option<Vec<String>>,
390 ignore_external_orders: bool,
391 calculate_account_state: bool,
392 request_account_state_secs: u64,
393 reconcile_market_ids_only: bool,
394 reconcile_market_ids: Option<Vec<String>>,
395 use_market_version: bool,
396 stream_gap_recovery_lookback_mins: u64,
397});
398
399impl Default for BetfairExecutionClientConfig {
400 fn default() -> Self {
401 Self::builder().build()
402 }
403}
404
405impl ClientConfig for BetfairExecutionClientConfig {
406 fn as_any(&self) -> &dyn Any {
407 self
408 }
409}
410
411impl BetfairExecutionClientConfig {
412 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
418 resolve_credential(
419 self.username.clone(),
420 self.password.clone(),
421 self.app_key.clone(),
422 )
423 }
424
425 pub fn currency(&self) -> anyhow::Result<Currency> {
431 parse_currency(&self.account_currency)
432 }
433
434 #[must_use]
436 pub fn stream_config(&self) -> BetfairStreamConfig {
437 build_stream_config(
438 &self.stream_host,
439 &self.stream_port,
440 self.stream_heartbeat_secs,
441 self.stream_heartbeat_timeout_secs,
442 self.stream_reconnect_delay_initial_ms,
443 self.stream_reconnect_delay_max_ms,
444 self.stream_use_tls,
445 )
446 }
447
448 pub fn validate(&self) -> anyhow::Result<()> {
454 let _ = self.currency()?;
455
456 if self.request_rate_per_second == 0 {
457 anyhow::bail!("request_rate_per_second must be greater than zero");
458 }
459
460 if self.order_request_rate_per_second == 0 {
461 anyhow::bail!("order_request_rate_per_second must be greater than zero");
462 }
463
464 self.stream_config().validate()?;
465
466 Ok(())
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use rstest::rstest;
473
474 use super::*;
475
476 #[rstest]
477 fn test_data_config_default() {
478 let config = BetfairDataClientConfig::default();
479
480 assert_eq!(config.account_currency, "GBP");
481 assert_eq!(config.request_rate_per_second, 5);
482 assert!(config.market_ids.is_none());
483 assert_eq!(config.stream_heartbeat_secs, None);
484 assert_eq!(config.stream_heartbeat_timeout_secs, None);
485 assert!(config.stream_conflate_ms.is_none());
486 assert_eq!(config.subscription_delay_secs, 3);
487 assert!(!config.subscribe_race_data);
488 assert!(!config.subscribe_cricket_data);
489 }
490
491 #[rstest]
492 fn test_data_config_navigation_filter() {
493 let config = BetfairDataClientConfig {
494 event_type_names: Some(vec!["Horse Racing".to_string()]),
495 market_ids: Some(vec!["1.234567".to_string()]),
496 ..Default::default()
497 };
498
499 let filter = config.navigation_filter();
500
501 assert_eq!(
502 filter.event_type_names,
503 Some(vec!["Horse Racing".to_string()])
504 );
505 assert_eq!(filter.market_ids, Some(vec!["1.234567".to_string()]));
506 }
507
508 #[rstest]
509 fn test_data_config_stream_config() {
510 let config = BetfairDataClientConfig {
511 stream_host: Some("localhost".to_string()),
512 stream_port: Some(9443),
513 stream_heartbeat_secs: Some(3),
514 stream_heartbeat_timeout_secs: Some(30),
515 stream_reconnect_delay_initial_ms: 500,
516 stream_reconnect_delay_max_ms: 5_000,
517 stream_use_tls: false,
518 ..Default::default()
519 };
520
521 let stream_config = config.stream_config();
522
523 assert_eq!(stream_config.host, "localhost");
524 assert_eq!(stream_config.port, 9443);
525 assert_eq!(stream_config.heartbeat_secs, Some(3));
526 assert_eq!(stream_config.heartbeat_timeout_secs, Some(30));
527 assert_eq!(stream_config.reconnect_delay_initial_ms, 500);
528 assert_eq!(stream_config.reconnect_delay_max_ms, 5_000);
529 assert!(!stream_config.use_tls);
530 }
531
532 #[rstest]
533 fn test_data_config_stream_config_uses_defaults() {
534 let config = BetfairDataClientConfig::default();
535
536 let stream_config = config.stream_config();
537
538 assert_eq!(stream_config.host, BetfairStreamConfig::default().host);
539 assert_eq!(stream_config.port, BetfairStreamConfig::default().port);
540 }
541
542 #[rstest]
543 fn test_data_config_credential_rejects_partial_credentials() {
544 let config = BetfairDataClientConfig {
545 username: Some("testuser".to_string()),
546 ..Default::default()
547 };
548
549 let result = config.credential();
550
551 assert!(result.is_err());
552 assert!(
553 result
554 .err()
555 .unwrap()
556 .to_string()
557 .contains("password is missing")
558 );
559 }
560
561 #[rstest]
562 fn test_exec_config_default() {
563 let config = BetfairExecutionClientConfig::default();
564 assert_eq!(config.account_id, AccountId::from("BETFAIR-001"));
565 assert_eq!(config.account_currency, "GBP");
566 assert_eq!(config.request_rate_per_second, 5);
567 assert_eq!(config.order_request_rate_per_second, 20);
568 assert_eq!(config.stream_heartbeat_secs, None);
569 assert_eq!(config.stream_heartbeat_timeout_secs, None);
570 assert!(config.stream_market_ids_filter.is_none());
571 assert!(!config.ignore_external_orders);
572 assert!(config.calculate_account_state);
573 assert_eq!(config.request_account_state_secs, 300);
574 assert!(!config.reconcile_market_ids_only);
575 assert!(config.reconcile_market_ids.is_none());
576 assert!(!config.use_market_version);
577 }
578
579 #[rstest]
580 fn test_exec_config_with_market_filter() {
581 let config = BetfairExecutionClientConfig {
582 stream_market_ids_filter: Some(vec!["1.234567".to_string(), "1.890123".to_string()]),
583 ..Default::default()
584 };
585
586 let filter = config.stream_market_ids_filter.as_ref().unwrap();
587 assert_eq!(filter.len(), 2);
588 assert!(filter.contains(&"1.234567".to_string()));
589 }
590
591 #[rstest]
592 fn test_exec_config_external_orders_ignored() {
593 let config = BetfairExecutionClientConfig {
594 ignore_external_orders: true,
595 ..Default::default()
596 };
597
598 assert!(config.ignore_external_orders);
599 }
600
601 #[rstest]
602 fn test_exec_config_account_state_disabled() {
603 let config = BetfairExecutionClientConfig {
604 calculate_account_state: false,
605 ..Default::default()
606 };
607
608 assert!(!config.calculate_account_state);
609 }
610
611 #[rstest]
612 fn test_exec_config_reconcile_market_ids() {
613 let config = BetfairExecutionClientConfig {
614 reconcile_market_ids_only: true,
615 reconcile_market_ids: Some(vec!["1.234567".to_string()]),
616 ..Default::default()
617 };
618
619 assert!(config.reconcile_market_ids_only);
620 assert_eq!(config.reconcile_market_ids.as_ref().unwrap().len(), 1);
621 }
622
623 #[rstest]
624 fn test_exec_config_use_market_version() {
625 let config = BetfairExecutionClientConfig {
626 use_market_version: true,
627 ..Default::default()
628 };
629
630 assert!(config.use_market_version);
631 }
632
633 #[rstest]
634 fn test_exec_config_validate_rejects_zero_order_rate_limit() {
635 let config = BetfairExecutionClientConfig {
636 order_request_rate_per_second: 0,
637 ..Default::default()
638 };
639
640 let result = config.validate();
641 assert!(result.is_err());
642 assert!(
643 result
644 .err()
645 .unwrap()
646 .to_string()
647 .contains("order_request_rate_per_second")
648 );
649 }
650
651 #[rstest]
652 fn test_exec_config_validate_rejects_invalid_currency() {
653 let config = BetfairExecutionClientConfig {
654 account_currency: "INVALID".to_string(),
655 ..Default::default()
656 };
657
658 let result = config.validate();
659
660 assert!(result.is_err());
661 assert!(
662 result
663 .err()
664 .unwrap()
665 .to_string()
666 .contains("Invalid account currency")
667 );
668 }
669
670 #[rstest]
671 fn test_data_config_validate_rejects_bad_market_start_time() {
672 let config = BetfairDataClientConfig {
673 min_market_start_time: Some("not-a-timestamp".to_string()),
674 ..Default::default()
675 };
676
677 let result = config.validate();
678 assert!(result.is_err());
679 assert!(
680 result
681 .err()
682 .unwrap()
683 .to_string()
684 .contains("min_market_start_time")
685 );
686 }
687
688 #[rstest]
689 fn test_data_config_min_notional() {
690 let config = BetfairDataClientConfig {
691 default_min_notional: Some(Decimal::new(2, 0)),
692 ..Default::default()
693 };
694
695 let min_notional = config.min_notional().unwrap();
696 assert_eq!(
697 min_notional,
698 Some(Money::from_decimal(Decimal::new(2, 0), Currency::GBP()).unwrap())
699 );
700 }
701
702 #[rstest]
703 fn test_data_config_toml_minimal() {
704 let config: BetfairDataClientConfig = toml::from_str(
705 r#"
706account_currency = "USD"
707request_rate_per_second = 10
708stream_heartbeat_secs = 3
709stream_heartbeat_timeout_secs = 30
710stream_reconnect_delay_initial_ms = 500
711stream_reconnect_delay_max_ms = 5000
712stream_use_tls = false
713subscription_delay_secs = 1
714subscribe_race_data = true
715subscribe_cricket_data = true
716"#,
717 )
718 .unwrap();
719
720 assert_eq!(config.account_currency, "USD");
721 assert_eq!(config.request_rate_per_second, 10);
722 assert_eq!(config.stream_heartbeat_secs, Some(3));
723 assert!(!config.stream_use_tls);
724 assert!(config.subscribe_race_data);
725 assert!(config.subscribe_cricket_data);
726 }
727
728 #[rstest]
729 fn test_exec_config_toml_empty_uses_defaults() {
730 let config: BetfairExecutionClientConfig = toml::from_str("").unwrap();
731 let expected = BetfairExecutionClientConfig::default();
732 assert_eq!(config.account_id, expected.account_id);
733 assert_eq!(config.account_currency, expected.account_currency);
734 assert_eq!(
735 config.request_rate_per_second,
736 expected.request_rate_per_second,
737 );
738 assert_eq!(
739 config.order_request_rate_per_second,
740 expected.order_request_rate_per_second,
741 );
742 assert_eq!(config.stream_heartbeat_secs, expected.stream_heartbeat_secs);
743 assert_eq!(config.stream_use_tls, expected.stream_use_tls);
744 assert_eq!(
745 config.calculate_account_state,
746 expected.calculate_account_state,
747 );
748 }
749}