1use std::any::Any;
19
20use nautilus_common::factories::ClientConfig;
21use nautilus_model::{
22 identifiers::{AccountId, TraderId},
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_ms: u64,
85 stream_idle_timeout_ms: 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_ms: stream_heartbeat_ms,
96 idle_timeout_ms: stream_idle_timeout_ms,
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.core.nautilus_pyo3.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 BetfairDataConfig {
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 #[builder(default = 5_000)]
153 pub stream_heartbeat_ms: u64,
154 #[builder(default = 60_000)]
156 pub stream_idle_timeout_ms: u64,
157 #[builder(default = 2_000)]
159 pub stream_reconnect_delay_initial_ms: u64,
160 #[builder(default = 30_000)]
162 pub stream_reconnect_delay_max_ms: u64,
163 #[builder(default = true)]
165 pub stream_use_tls: bool,
166 pub stream_conflate_ms: Option<u64>,
169 #[builder(default = 3)]
171 pub subscription_delay_secs: u64,
172 #[builder(default)]
174 pub subscribe_race_data: bool,
175 #[builder(default)]
177 pub subscribe_cricket_data: bool,
178}
179
180impl Default for BetfairDataConfig {
181 fn default() -> Self {
182 Self::builder().build()
183 }
184}
185
186impl ClientConfig for BetfairDataConfig {
187 fn as_any(&self) -> &dyn Any {
188 self
189 }
190}
191
192impl BetfairDataConfig {
193 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
199 resolve_credential(
200 self.username.clone(),
201 self.password.clone(),
202 self.app_key.clone(),
203 )
204 }
205
206 pub fn currency(&self) -> anyhow::Result<Currency> {
212 parse_currency(&self.account_currency)
213 }
214
215 pub fn min_notional(&self) -> anyhow::Result<Option<Money>> {
221 let currency = self.currency()?;
222 make_min_notional(self.default_min_notional, currency)
223 }
224
225 #[must_use]
227 pub fn navigation_filter(&self) -> NavigationFilter {
228 NavigationFilter {
229 event_type_ids: self.event_type_ids.clone(),
230 event_type_names: self.event_type_names.clone(),
231 event_ids: self.event_ids.clone(),
232 country_codes: self.country_codes.clone(),
233 market_types: self.market_types.clone(),
234 market_ids: self.market_ids.clone(),
235 min_market_start_time: self.min_market_start_time.clone(),
236 max_market_start_time: self.max_market_start_time.clone(),
237 }
238 }
239
240 #[must_use]
242 pub fn stream_config(&self) -> BetfairStreamConfig {
243 build_stream_config(
244 &self.stream_host,
245 &self.stream_port,
246 self.stream_heartbeat_ms,
247 self.stream_idle_timeout_ms,
248 self.stream_reconnect_delay_initial_ms,
249 self.stream_reconnect_delay_max_ms,
250 self.stream_use_tls,
251 )
252 }
253
254 pub fn validate(&self) -> anyhow::Result<()> {
260 let _ = self.currency()?;
261 validate_market_start_time("min_market_start_time", &self.min_market_start_time)?;
262 validate_market_start_time("max_market_start_time", &self.max_market_start_time)?;
263
264 if self.request_rate_per_second == 0 {
265 anyhow::bail!("request_rate_per_second must be greater than zero");
266 }
267
268 Ok(())
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
274#[serde(default, deny_unknown_fields)]
275#[cfg_attr(
276 feature = "python",
277 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.betfair", from_py_object)
278)]
279#[cfg_attr(
280 feature = "python",
281 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.betfair")
282)]
283pub struct BetfairExecConfig {
284 #[builder(default = TraderId::from("TRADER-001"))]
286 pub trader_id: TraderId,
287 #[builder(default = AccountId::from("BETFAIR-001"))]
289 pub account_id: AccountId,
290 #[builder(default = "GBP".to_string())]
292 pub account_currency: String,
293 pub username: Option<String>,
295 pub password: Option<String>,
297 pub app_key: Option<String>,
299 pub proxy_url: Option<String>,
301 #[builder(default = 5)]
303 pub request_rate_per_second: u32,
304 #[builder(default = 20)]
306 pub order_request_rate_per_second: u32,
307 pub stream_host: Option<String>,
309 pub stream_port: Option<u16>,
311 #[builder(default = 5_000)]
313 pub stream_heartbeat_ms: u64,
314 #[builder(default = 60_000)]
316 pub stream_idle_timeout_ms: u64,
317 #[builder(default = 2_000)]
319 pub stream_reconnect_delay_initial_ms: u64,
320 #[builder(default = 30_000)]
322 pub stream_reconnect_delay_max_ms: u64,
323 #[builder(default = true)]
325 pub stream_use_tls: bool,
326 pub stream_market_ids_filter: Option<Vec<String>>,
329 #[builder(default)]
331 pub ignore_external_orders: bool,
332 #[builder(default = true)]
334 pub calculate_account_state: bool,
335 #[builder(default = 300)]
337 pub request_account_state_secs: u64,
338 #[builder(default)]
340 pub reconcile_market_ids_only: bool,
341 pub reconcile_market_ids: Option<Vec<String>>,
343 #[builder(default)]
345 pub use_market_version: bool,
346 #[builder(default = 10)]
350 pub stream_gap_recovery_lookback_mins: u64,
351}
352
353impl Default for BetfairExecConfig {
354 fn default() -> Self {
355 Self::builder().build()
356 }
357}
358
359impl ClientConfig for BetfairExecConfig {
360 fn as_any(&self) -> &dyn Any {
361 self
362 }
363}
364
365impl BetfairExecConfig {
366 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
372 resolve_credential(
373 self.username.clone(),
374 self.password.clone(),
375 self.app_key.clone(),
376 )
377 }
378
379 pub fn currency(&self) -> anyhow::Result<Currency> {
385 parse_currency(&self.account_currency)
386 }
387
388 #[must_use]
390 pub fn stream_config(&self) -> BetfairStreamConfig {
391 build_stream_config(
392 &self.stream_host,
393 &self.stream_port,
394 self.stream_heartbeat_ms,
395 self.stream_idle_timeout_ms,
396 self.stream_reconnect_delay_initial_ms,
397 self.stream_reconnect_delay_max_ms,
398 self.stream_use_tls,
399 )
400 }
401
402 pub fn validate(&self) -> anyhow::Result<()> {
408 let _ = self.currency()?;
409
410 if self.request_rate_per_second == 0 {
411 anyhow::bail!("request_rate_per_second must be greater than zero");
412 }
413
414 if self.order_request_rate_per_second == 0 {
415 anyhow::bail!("order_request_rate_per_second must be greater than zero");
416 }
417
418 Ok(())
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use rstest::rstest;
425
426 use super::*;
427
428 #[rstest]
429 fn test_data_config_default() {
430 let config = BetfairDataConfig::default();
431
432 assert_eq!(config.account_currency, "GBP");
433 assert_eq!(config.request_rate_per_second, 5);
434 assert!(config.market_ids.is_none());
435 assert_eq!(config.stream_heartbeat_ms, 5_000);
436 assert!(config.stream_conflate_ms.is_none());
437 assert_eq!(config.subscription_delay_secs, 3);
438 assert!(!config.subscribe_race_data);
439 assert!(!config.subscribe_cricket_data);
440 }
441
442 #[rstest]
443 fn test_data_config_navigation_filter() {
444 let config = BetfairDataConfig {
445 event_type_names: Some(vec!["Horse Racing".to_string()]),
446 market_ids: Some(vec!["1.234567".to_string()]),
447 ..Default::default()
448 };
449
450 let filter = config.navigation_filter();
451
452 assert_eq!(
453 filter.event_type_names,
454 Some(vec!["Horse Racing".to_string()])
455 );
456 assert_eq!(filter.market_ids, Some(vec!["1.234567".to_string()]));
457 }
458
459 #[rstest]
460 fn test_data_config_stream_config() {
461 let config = BetfairDataConfig {
462 stream_host: Some("localhost".to_string()),
463 stream_port: Some(9443),
464 stream_heartbeat_ms: 2_500,
465 stream_idle_timeout_ms: 30_000,
466 stream_reconnect_delay_initial_ms: 500,
467 stream_reconnect_delay_max_ms: 5_000,
468 stream_use_tls: false,
469 ..Default::default()
470 };
471
472 let stream_config = config.stream_config();
473
474 assert_eq!(stream_config.host, "localhost");
475 assert_eq!(stream_config.port, 9443);
476 assert_eq!(stream_config.heartbeat_ms, 2_500);
477 assert_eq!(stream_config.idle_timeout_ms, 30_000);
478 assert_eq!(stream_config.reconnect_delay_initial_ms, 500);
479 assert_eq!(stream_config.reconnect_delay_max_ms, 5_000);
480 assert!(!stream_config.use_tls);
481 }
482
483 #[rstest]
484 fn test_data_config_stream_config_uses_defaults() {
485 let config = BetfairDataConfig::default();
486
487 let stream_config = config.stream_config();
488
489 assert_eq!(stream_config.host, BetfairStreamConfig::default().host);
490 assert_eq!(stream_config.port, BetfairStreamConfig::default().port);
491 }
492
493 #[rstest]
494 fn test_data_config_credential_rejects_partial_credentials() {
495 let config = BetfairDataConfig {
496 username: Some("testuser".to_string()),
497 ..Default::default()
498 };
499
500 let result = config.credential();
501
502 assert!(result.is_err());
503 assert!(
504 result
505 .err()
506 .unwrap()
507 .to_string()
508 .contains("password is missing")
509 );
510 }
511
512 #[rstest]
513 fn test_exec_config_default() {
514 let config = BetfairExecConfig::default();
515
516 assert_eq!(config.trader_id, TraderId::from("TRADER-001"));
517 assert_eq!(config.account_id, AccountId::from("BETFAIR-001"));
518 assert_eq!(config.account_currency, "GBP");
519 assert_eq!(config.request_rate_per_second, 5);
520 assert_eq!(config.order_request_rate_per_second, 20);
521 assert!(config.stream_market_ids_filter.is_none());
522 assert!(!config.ignore_external_orders);
523 assert!(config.calculate_account_state);
524 assert_eq!(config.request_account_state_secs, 300);
525 assert!(!config.reconcile_market_ids_only);
526 assert!(config.reconcile_market_ids.is_none());
527 assert!(!config.use_market_version);
528 }
529
530 #[rstest]
531 fn test_exec_config_with_market_filter() {
532 let config = BetfairExecConfig {
533 stream_market_ids_filter: Some(vec!["1.234567".to_string(), "1.890123".to_string()]),
534 ..Default::default()
535 };
536
537 let filter = config.stream_market_ids_filter.as_ref().unwrap();
538 assert_eq!(filter.len(), 2);
539 assert!(filter.contains(&"1.234567".to_string()));
540 }
541
542 #[rstest]
543 fn test_exec_config_external_orders_ignored() {
544 let config = BetfairExecConfig {
545 ignore_external_orders: true,
546 ..Default::default()
547 };
548
549 assert!(config.ignore_external_orders);
550 }
551
552 #[rstest]
553 fn test_exec_config_account_state_disabled() {
554 let config = BetfairExecConfig {
555 calculate_account_state: false,
556 ..Default::default()
557 };
558
559 assert!(!config.calculate_account_state);
560 }
561
562 #[rstest]
563 fn test_exec_config_reconcile_market_ids() {
564 let config = BetfairExecConfig {
565 reconcile_market_ids_only: true,
566 reconcile_market_ids: Some(vec!["1.234567".to_string()]),
567 ..Default::default()
568 };
569
570 assert!(config.reconcile_market_ids_only);
571 assert_eq!(config.reconcile_market_ids.as_ref().unwrap().len(), 1);
572 }
573
574 #[rstest]
575 fn test_exec_config_use_market_version() {
576 let config = BetfairExecConfig {
577 use_market_version: true,
578 ..Default::default()
579 };
580
581 assert!(config.use_market_version);
582 }
583
584 #[rstest]
585 fn test_exec_config_validate_rejects_zero_order_rate_limit() {
586 let config = BetfairExecConfig {
587 order_request_rate_per_second: 0,
588 ..Default::default()
589 };
590
591 let result = config.validate();
592 assert!(result.is_err());
593 assert!(
594 result
595 .err()
596 .unwrap()
597 .to_string()
598 .contains("order_request_rate_per_second")
599 );
600 }
601
602 #[rstest]
603 fn test_exec_config_validate_rejects_invalid_currency() {
604 let config = BetfairExecConfig {
605 account_currency: "INVALID".to_string(),
606 ..Default::default()
607 };
608
609 let result = config.validate();
610
611 assert!(result.is_err());
612 assert!(
613 result
614 .err()
615 .unwrap()
616 .to_string()
617 .contains("Invalid account currency")
618 );
619 }
620
621 #[rstest]
622 fn test_data_config_validate_rejects_bad_market_start_time() {
623 let config = BetfairDataConfig {
624 min_market_start_time: Some("not-a-timestamp".to_string()),
625 ..Default::default()
626 };
627
628 let result = config.validate();
629 assert!(result.is_err());
630 assert!(
631 result
632 .err()
633 .unwrap()
634 .to_string()
635 .contains("min_market_start_time")
636 );
637 }
638
639 #[rstest]
640 fn test_data_config_min_notional() {
641 let config = BetfairDataConfig {
642 default_min_notional: Some(Decimal::new(2, 0)),
643 ..Default::default()
644 };
645
646 let min_notional = config.min_notional().unwrap();
647 assert_eq!(
648 min_notional,
649 Some(Money::from_decimal(Decimal::new(2, 0), Currency::GBP()).unwrap())
650 );
651 }
652
653 #[rstest]
654 fn test_data_config_toml_minimal() {
655 let config: BetfairDataConfig = toml::from_str(
656 r#"
657account_currency = "USD"
658request_rate_per_second = 10
659stream_heartbeat_ms = 2500
660stream_idle_timeout_ms = 30000
661stream_reconnect_delay_initial_ms = 500
662stream_reconnect_delay_max_ms = 5000
663stream_use_tls = false
664subscription_delay_secs = 1
665subscribe_race_data = true
666subscribe_cricket_data = true
667"#,
668 )
669 .unwrap();
670
671 assert_eq!(config.account_currency, "USD");
672 assert_eq!(config.request_rate_per_second, 10);
673 assert_eq!(config.stream_heartbeat_ms, 2_500);
674 assert!(!config.stream_use_tls);
675 assert!(config.subscribe_race_data);
676 assert!(config.subscribe_cricket_data);
677 }
678
679 #[rstest]
680 fn test_exec_config_toml_empty_uses_defaults() {
681 let config: BetfairExecConfig = toml::from_str("").unwrap();
682 let expected = BetfairExecConfig::default();
683
684 assert_eq!(config.trader_id, expected.trader_id);
685 assert_eq!(config.account_id, expected.account_id);
686 assert_eq!(config.account_currency, expected.account_currency);
687 assert_eq!(
688 config.request_rate_per_second,
689 expected.request_rate_per_second,
690 );
691 assert_eq!(
692 config.order_request_rate_per_second,
693 expected.order_request_rate_per_second,
694 );
695 assert_eq!(config.stream_heartbeat_ms, expected.stream_heartbeat_ms);
696 assert_eq!(config.stream_use_tls, expected.stream_use_tls);
697 assert_eq!(
698 config.calculate_account_state,
699 expected.calculate_account_state,
700 );
701 }
702}