1use std::any::Any;
19
20use nautilus_common::factories::ClientConfig;
21use nautilus_model::{
22 identifiers::{AccountId, TraderId},
23 types::{Currency, Money},
24};
25
26use crate::{
27 common::{
28 credential::{BetfairCredential, CredentialError},
29 parse::parse_betfair_timestamp,
30 },
31 provider::NavigationFilter,
32 stream::config::BetfairStreamConfig,
33};
34
35fn parse_currency(code: &str) -> anyhow::Result<Currency> {
36 code.parse::<Currency>()
37 .map_err(|_| anyhow::anyhow!("Invalid account currency code: {code}"))
38}
39
40fn make_min_notional(value: Option<f64>, currency: Currency) -> Option<Money> {
41 value.map(|amount| Money::new(amount, currency))
42}
43
44fn validate_market_start_time(label: &str, value: &Option<String>) -> anyhow::Result<()> {
45 if let Some(value) = value {
46 parse_betfair_timestamp(value)
47 .map(|_| ())
48 .map_err(|e| anyhow::anyhow!("Invalid {label} '{value}': {e}"))?;
49 }
50
51 Ok(())
52}
53
54fn resolve_credential(
55 username: Option<String>,
56 password: Option<String>,
57 app_key: Option<String>,
58) -> anyhow::Result<BetfairCredential> {
59 match BetfairCredential::resolve(username, password, app_key) {
60 Ok(Some(credential)) => Ok(credential),
61 Ok(None) => anyhow::bail!("Missing Betfair credentials in config and environment"),
62 Err(e) => Err(match e {
63 CredentialError::MissingPassword => anyhow::anyhow!(
64 "Invalid Betfair credentials: username provided but password is missing",
65 ),
66 CredentialError::MissingUsername => anyhow::anyhow!(
67 "Invalid Betfair credentials: password or app key provided but username is missing",
68 ),
69 CredentialError::MissingAppKey => {
70 anyhow::anyhow!("Invalid Betfair credentials: app key is missing")
71 }
72 }),
73 }
74}
75
76fn build_stream_config(
77 stream_host: &Option<String>,
78 stream_port: &Option<u16>,
79 stream_heartbeat_ms: u64,
80 stream_idle_timeout_ms: u64,
81 stream_reconnect_delay_initial_ms: u64,
82 stream_reconnect_delay_max_ms: u64,
83 stream_use_tls: bool,
84) -> BetfairStreamConfig {
85 let defaults = BetfairStreamConfig::default();
86
87 BetfairStreamConfig {
88 host: stream_host.clone().unwrap_or(defaults.host),
89 port: stream_port.unwrap_or(defaults.port),
90 heartbeat_ms: stream_heartbeat_ms,
91 idle_timeout_ms: stream_idle_timeout_ms,
92 reconnect_delay_initial_ms: stream_reconnect_delay_initial_ms,
93 reconnect_delay_max_ms: stream_reconnect_delay_max_ms,
94 use_tls: stream_use_tls,
95 }
96}
97
98#[derive(Clone, Debug, bon::Builder)]
100#[cfg_attr(
101 feature = "python",
102 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.betfair", from_py_object)
103)]
104#[cfg_attr(
105 feature = "python",
106 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.betfair")
107)]
108pub struct BetfairDataConfig {
109 #[builder(default = "GBP".to_string())]
111 pub account_currency: String,
112 pub username: Option<String>,
114 pub password: Option<String>,
116 pub app_key: Option<String>,
118 pub proxy_url: Option<String>,
120 #[builder(default = 5)]
122 pub request_rate_per_second: u32,
123 pub default_min_notional: Option<f64>,
125 pub event_type_ids: Option<Vec<String>>,
127 pub event_type_names: Option<Vec<String>>,
129 pub event_ids: Option<Vec<String>>,
131 pub country_codes: Option<Vec<String>>,
133 pub market_types: Option<Vec<String>>,
135 pub market_ids: Option<Vec<String>>,
137 pub min_market_start_time: Option<String>,
139 pub max_market_start_time: Option<String>,
141 pub stream_host: Option<String>,
143 pub stream_port: Option<u16>,
145 #[builder(default = 5_000)]
147 pub stream_heartbeat_ms: u64,
148 #[builder(default = 60_000)]
150 pub stream_idle_timeout_ms: u64,
151 #[builder(default = 2_000)]
153 pub stream_reconnect_delay_initial_ms: u64,
154 #[builder(default = 30_000)]
156 pub stream_reconnect_delay_max_ms: u64,
157 #[builder(default = true)]
159 pub stream_use_tls: bool,
160 pub stream_conflate_ms: Option<u64>,
163 #[builder(default = 3)]
165 pub subscription_delay_secs: u64,
166 #[builder(default)]
168 pub subscribe_race_data: bool,
169}
170
171impl Default for BetfairDataConfig {
172 fn default() -> Self {
173 Self::builder().build()
174 }
175}
176
177impl ClientConfig for BetfairDataConfig {
178 fn as_any(&self) -> &dyn Any {
179 self
180 }
181}
182
183impl BetfairDataConfig {
184 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
190 resolve_credential(
191 self.username.clone(),
192 self.password.clone(),
193 self.app_key.clone(),
194 )
195 }
196
197 pub fn currency(&self) -> anyhow::Result<Currency> {
203 parse_currency(&self.account_currency)
204 }
205
206 pub fn min_notional(&self) -> anyhow::Result<Option<Money>> {
212 let currency = self.currency()?;
213 Ok(make_min_notional(self.default_min_notional, currency))
214 }
215
216 #[must_use]
218 pub fn navigation_filter(&self) -> NavigationFilter {
219 NavigationFilter {
220 event_type_ids: self.event_type_ids.clone(),
221 event_type_names: self.event_type_names.clone(),
222 event_ids: self.event_ids.clone(),
223 country_codes: self.country_codes.clone(),
224 market_types: self.market_types.clone(),
225 market_ids: self.market_ids.clone(),
226 min_market_start_time: self.min_market_start_time.clone(),
227 max_market_start_time: self.max_market_start_time.clone(),
228 }
229 }
230
231 #[must_use]
233 pub fn stream_config(&self) -> BetfairStreamConfig {
234 build_stream_config(
235 &self.stream_host,
236 &self.stream_port,
237 self.stream_heartbeat_ms,
238 self.stream_idle_timeout_ms,
239 self.stream_reconnect_delay_initial_ms,
240 self.stream_reconnect_delay_max_ms,
241 self.stream_use_tls,
242 )
243 }
244
245 pub fn validate(&self) -> anyhow::Result<()> {
251 let _ = self.currency()?;
252 validate_market_start_time("min_market_start_time", &self.min_market_start_time)?;
253 validate_market_start_time("max_market_start_time", &self.max_market_start_time)?;
254
255 if self.request_rate_per_second == 0 {
256 anyhow::bail!("request_rate_per_second must be greater than zero");
257 }
258
259 Ok(())
260 }
261}
262
263#[derive(Clone, Debug, bon::Builder)]
265#[cfg_attr(
266 feature = "python",
267 pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.betfair", from_py_object)
268)]
269#[cfg_attr(
270 feature = "python",
271 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.betfair")
272)]
273pub struct BetfairExecConfig {
274 #[builder(default = TraderId::from("TRADER-001"))]
276 pub trader_id: TraderId,
277 #[builder(default = AccountId::from("BETFAIR-001"))]
279 pub account_id: AccountId,
280 #[builder(default = "GBP".to_string())]
282 pub account_currency: String,
283 pub username: Option<String>,
285 pub password: Option<String>,
287 pub app_key: Option<String>,
289 pub proxy_url: Option<String>,
291 #[builder(default = 5)]
293 pub request_rate_per_second: u32,
294 #[builder(default = 20)]
296 pub order_request_rate_per_second: u32,
297 pub stream_host: Option<String>,
299 pub stream_port: Option<u16>,
301 #[builder(default = 5_000)]
303 pub stream_heartbeat_ms: u64,
304 #[builder(default = 60_000)]
306 pub stream_idle_timeout_ms: u64,
307 #[builder(default = 2_000)]
309 pub stream_reconnect_delay_initial_ms: u64,
310 #[builder(default = 30_000)]
312 pub stream_reconnect_delay_max_ms: u64,
313 #[builder(default = true)]
315 pub stream_use_tls: bool,
316 pub stream_market_ids_filter: Option<Vec<String>>,
319 #[builder(default)]
321 pub ignore_external_orders: bool,
322 #[builder(default = true)]
324 pub calculate_account_state: bool,
325 #[builder(default = 300)]
327 pub request_account_state_secs: u64,
328 #[builder(default)]
330 pub reconcile_market_ids_only: bool,
331 pub reconcile_market_ids: Option<Vec<String>>,
333 #[builder(default)]
335 pub use_market_version: bool,
336}
337
338impl Default for BetfairExecConfig {
339 fn default() -> Self {
340 Self::builder().build()
341 }
342}
343
344impl ClientConfig for BetfairExecConfig {
345 fn as_any(&self) -> &dyn Any {
346 self
347 }
348}
349
350impl BetfairExecConfig {
351 pub fn credential(&self) -> anyhow::Result<BetfairCredential> {
357 resolve_credential(
358 self.username.clone(),
359 self.password.clone(),
360 self.app_key.clone(),
361 )
362 }
363
364 pub fn currency(&self) -> anyhow::Result<Currency> {
370 parse_currency(&self.account_currency)
371 }
372
373 #[must_use]
375 pub fn stream_config(&self) -> BetfairStreamConfig {
376 build_stream_config(
377 &self.stream_host,
378 &self.stream_port,
379 self.stream_heartbeat_ms,
380 self.stream_idle_timeout_ms,
381 self.stream_reconnect_delay_initial_ms,
382 self.stream_reconnect_delay_max_ms,
383 self.stream_use_tls,
384 )
385 }
386
387 pub fn validate(&self) -> anyhow::Result<()> {
393 let _ = self.currency()?;
394
395 if self.request_rate_per_second == 0 {
396 anyhow::bail!("request_rate_per_second must be greater than zero");
397 }
398
399 if self.order_request_rate_per_second == 0 {
400 anyhow::bail!("order_request_rate_per_second must be greater than zero");
401 }
402
403 Ok(())
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use rstest::rstest;
410
411 use super::*;
412
413 #[rstest]
414 fn test_data_config_default() {
415 let config = BetfairDataConfig::default();
416
417 assert_eq!(config.account_currency, "GBP");
418 assert_eq!(config.request_rate_per_second, 5);
419 assert!(config.market_ids.is_none());
420 assert_eq!(config.stream_heartbeat_ms, 5_000);
421 assert!(config.stream_conflate_ms.is_none());
422 assert_eq!(config.subscription_delay_secs, 3);
423 assert!(!config.subscribe_race_data);
424 }
425
426 #[rstest]
427 fn test_data_config_navigation_filter() {
428 let config = BetfairDataConfig {
429 event_type_names: Some(vec!["Horse Racing".to_string()]),
430 market_ids: Some(vec!["1.234567".to_string()]),
431 ..Default::default()
432 };
433
434 let filter = config.navigation_filter();
435
436 assert_eq!(
437 filter.event_type_names,
438 Some(vec!["Horse Racing".to_string()])
439 );
440 assert_eq!(filter.market_ids, Some(vec!["1.234567".to_string()]));
441 }
442
443 #[rstest]
444 fn test_data_config_stream_config() {
445 let config = BetfairDataConfig {
446 stream_host: Some("localhost".to_string()),
447 stream_port: Some(9443),
448 stream_heartbeat_ms: 2_500,
449 stream_idle_timeout_ms: 30_000,
450 stream_reconnect_delay_initial_ms: 500,
451 stream_reconnect_delay_max_ms: 5_000,
452 stream_use_tls: false,
453 ..Default::default()
454 };
455
456 let stream_config = config.stream_config();
457
458 assert_eq!(stream_config.host, "localhost");
459 assert_eq!(stream_config.port, 9443);
460 assert_eq!(stream_config.heartbeat_ms, 2_500);
461 assert_eq!(stream_config.idle_timeout_ms, 30_000);
462 assert_eq!(stream_config.reconnect_delay_initial_ms, 500);
463 assert_eq!(stream_config.reconnect_delay_max_ms, 5_000);
464 assert!(!stream_config.use_tls);
465 }
466
467 #[rstest]
468 fn test_data_config_stream_config_uses_defaults() {
469 let config = BetfairDataConfig::default();
470
471 let stream_config = config.stream_config();
472
473 assert_eq!(stream_config.host, BetfairStreamConfig::default().host);
474 assert_eq!(stream_config.port, BetfairStreamConfig::default().port);
475 }
476
477 #[rstest]
478 fn test_data_config_credential_rejects_partial_credentials() {
479 let config = BetfairDataConfig {
480 username: Some("testuser".to_string()),
481 ..Default::default()
482 };
483
484 let result = config.credential();
485
486 assert!(result.is_err());
487 assert!(
488 result
489 .err()
490 .unwrap()
491 .to_string()
492 .contains("password is missing")
493 );
494 }
495
496 #[rstest]
497 fn test_exec_config_default() {
498 let config = BetfairExecConfig::default();
499
500 assert_eq!(config.trader_id, TraderId::from("TRADER-001"));
501 assert_eq!(config.account_id, AccountId::from("BETFAIR-001"));
502 assert_eq!(config.account_currency, "GBP");
503 assert_eq!(config.request_rate_per_second, 5);
504 assert_eq!(config.order_request_rate_per_second, 20);
505 assert!(config.stream_market_ids_filter.is_none());
506 assert!(!config.ignore_external_orders);
507 assert!(config.calculate_account_state);
508 assert_eq!(config.request_account_state_secs, 300);
509 assert!(!config.reconcile_market_ids_only);
510 assert!(config.reconcile_market_ids.is_none());
511 assert!(!config.use_market_version);
512 }
513
514 #[rstest]
515 fn test_exec_config_with_market_filter() {
516 let config = BetfairExecConfig {
517 stream_market_ids_filter: Some(vec!["1.234567".to_string(), "1.890123".to_string()]),
518 ..Default::default()
519 };
520
521 let filter = config.stream_market_ids_filter.as_ref().unwrap();
522 assert_eq!(filter.len(), 2);
523 assert!(filter.contains(&"1.234567".to_string()));
524 }
525
526 #[rstest]
527 fn test_exec_config_external_orders_ignored() {
528 let config = BetfairExecConfig {
529 ignore_external_orders: true,
530 ..Default::default()
531 };
532
533 assert!(config.ignore_external_orders);
534 }
535
536 #[rstest]
537 fn test_exec_config_account_state_disabled() {
538 let config = BetfairExecConfig {
539 calculate_account_state: false,
540 ..Default::default()
541 };
542
543 assert!(!config.calculate_account_state);
544 }
545
546 #[rstest]
547 fn test_exec_config_reconcile_market_ids() {
548 let config = BetfairExecConfig {
549 reconcile_market_ids_only: true,
550 reconcile_market_ids: Some(vec!["1.234567".to_string()]),
551 ..Default::default()
552 };
553
554 assert!(config.reconcile_market_ids_only);
555 assert_eq!(config.reconcile_market_ids.as_ref().unwrap().len(), 1);
556 }
557
558 #[rstest]
559 fn test_exec_config_use_market_version() {
560 let config = BetfairExecConfig {
561 use_market_version: true,
562 ..Default::default()
563 };
564
565 assert!(config.use_market_version);
566 }
567
568 #[rstest]
569 fn test_exec_config_validate_rejects_zero_order_rate_limit() {
570 let config = BetfairExecConfig {
571 order_request_rate_per_second: 0,
572 ..Default::default()
573 };
574
575 let result = config.validate();
576 assert!(result.is_err());
577 assert!(
578 result
579 .err()
580 .unwrap()
581 .to_string()
582 .contains("order_request_rate_per_second")
583 );
584 }
585
586 #[rstest]
587 fn test_exec_config_validate_rejects_invalid_currency() {
588 let config = BetfairExecConfig {
589 account_currency: "INVALID".to_string(),
590 ..Default::default()
591 };
592
593 let result = config.validate();
594
595 assert!(result.is_err());
596 assert!(
597 result
598 .err()
599 .unwrap()
600 .to_string()
601 .contains("Invalid account currency")
602 );
603 }
604
605 #[rstest]
606 fn test_data_config_validate_rejects_bad_market_start_time() {
607 let config = BetfairDataConfig {
608 min_market_start_time: Some("not-a-timestamp".to_string()),
609 ..Default::default()
610 };
611
612 let result = config.validate();
613 assert!(result.is_err());
614 assert!(
615 result
616 .err()
617 .unwrap()
618 .to_string()
619 .contains("min_market_start_time")
620 );
621 }
622
623 #[rstest]
624 fn test_data_config_min_notional() {
625 let config = BetfairDataConfig {
626 default_min_notional: Some(2.0),
627 ..Default::default()
628 };
629
630 let min_notional = config.min_notional().unwrap();
631 assert_eq!(min_notional, Some(Money::new(2.0, Currency::GBP())));
632 }
633}