1use std::{
19 collections::HashMap,
20 fmt::Debug,
21 sync::Arc,
22 time::{SystemTime, UNIX_EPOCH},
23};
24
25use nautilus_core::string::secret::REDACTED;
26use nautilus_model::identifiers::{AccountId, InstrumentId};
27use nautilus_network::{
28 transport::TransportError,
29 websocket::{TransportBackend, proxy::ProxyUrl},
30};
31use serde::{Deserialize, Serialize};
32
33use crate::{
34 common::{enums::SignatureType, urls},
35 filters::InstrumentFilter,
36};
37
38const DEFAULT_UPDOWN_INTERVAL_MINS: u64 = 5;
39const DEFAULT_UPDOWN_PERIODS: u64 = 3;
40
41fn validated_proxy_url(value: Option<&String>) -> Result<Option<ProxyUrl>, TransportError> {
42 value.cloned().map(ProxyUrl::parse).transpose()
43}
44
45fn default_updown_assets() -> Vec<String> {
46 vec!["btc".to_string()]
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
56#[serde(default, deny_unknown_fields)]
57#[cfg_attr(
58 feature = "python",
59 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
60)]
61#[cfg_attr(
62 feature = "python",
63 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
64)]
65pub struct PolymarketUpDownEventSlugConfig {
66 #[builder(default = default_updown_assets())]
68 pub assets: Vec<String>,
69 #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
71 pub interval_mins: u64,
72 #[builder(default = DEFAULT_UPDOWN_PERIODS)]
74 pub periods: u64,
75 #[builder(default)]
77 pub start_offset_periods: i64,
78}
79
80#[cfg(feature = "python")]
81nautilus_core::impl_pyo3_config_getters!(PolymarketUpDownEventSlugConfig {
82 assets: Vec<String>,
83 interval_mins: u64,
84 periods: u64,
85 start_offset_periods: i64,
86});
87
88impl Default for PolymarketUpDownEventSlugConfig {
89 fn default() -> Self {
90 Self::builder().build()
91 }
92}
93
94impl PolymarketUpDownEventSlugConfig {
95 pub fn build_event_slugs(&self) -> anyhow::Result<Vec<String>> {
102 let now = SystemTime::now()
103 .duration_since(UNIX_EPOCH)
104 .map_err(|e| anyhow::anyhow!("system clock before Unix epoch: {e}"))?
105 .as_secs();
106 self.build_event_slugs_at_unix_secs(now)
107 }
108
109 fn build_event_slugs_at_unix_secs(&self, unix_secs: u64) -> anyhow::Result<Vec<String>> {
110 if self.interval_mins == 0 {
111 anyhow::bail!("event_slug_builder.interval_mins must be positive");
112 }
113
114 if self.periods == 0 {
115 anyhow::bail!("event_slug_builder.periods must be positive");
116 }
117
118 let assets = self.normalized_assets();
119 if assets.is_empty() {
120 anyhow::bail!("event_slug_builder.assets must include at least one non-empty asset");
121 }
122
123 let period_secs = self
124 .interval_mins
125 .checked_mul(60)
126 .ok_or_else(|| anyhow::anyhow!("event_slug_builder.interval_mins is too large"))?;
127 let period_start = (unix_secs / period_secs) * period_secs;
128 let period_secs = i128::from(period_secs);
129 let period_start = i128::from(period_start);
130 let mut slugs = Vec::new();
131
132 for period in 0..self.periods {
133 let period_offset = i128::from(self.start_offset_periods) + i128::from(period);
134 let timestamp = period_start + period_offset * period_secs;
135 if timestamp < 0 {
136 anyhow::bail!("event_slug_builder offset resolves before the Unix epoch");
137 }
138
139 for asset in &assets {
140 slugs.push(format!(
141 "{asset}-updown-{}m-{timestamp}",
142 self.interval_mins
143 ));
144 }
145 }
146
147 Ok(slugs)
148 }
149
150 fn normalized_assets(&self) -> Vec<String> {
151 let mut assets = Vec::new();
152
153 for asset in &self.assets {
154 let asset = asset.trim().to_ascii_lowercase();
155 if asset.is_empty() || assets.contains(&asset) {
156 continue;
157 }
158 assets.push(asset);
159 }
160
161 assets
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
170#[serde(default, deny_unknown_fields)]
171#[cfg_attr(
172 feature = "python",
173 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
174)]
175#[cfg_attr(
176 feature = "python",
177 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
178)]
179pub struct PolymarketInstrumentProviderConfig {
180 #[builder(default)]
182 pub load_all: bool,
183 pub load_ids: Option<Vec<InstrumentId>>,
185 pub filters: Option<HashMap<String, String>>,
187 pub event_slugs: Option<Vec<String>>,
189 pub market_slugs: Option<Vec<String>>,
191 pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
193 pub series_ids: Option<Vec<u64>>,
195 #[builder(default = true)]
197 pub log_warnings: bool,
198 #[builder(default)]
202 pub use_gamma_markets: bool,
203}
204
205#[cfg(feature = "python")]
206nautilus_core::impl_pyo3_config_getters!(PolymarketInstrumentProviderConfig {
207 load_all: bool,
208 load_ids: Option<Vec<InstrumentId>>,
209 filters: Option<HashMap<String, String>>,
210 event_slugs: Option<Vec<String>>,
211 market_slugs: Option<Vec<String>>,
212 event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
213 series_ids: Option<Vec<u64>>,
214 log_warnings: bool,
215 use_gamma_markets: bool,
216});
217
218impl Default for PolymarketInstrumentProviderConfig {
219 fn default() -> Self {
220 Self::builder().build()
221 }
222}
223
224impl PolymarketInstrumentProviderConfig {
225 #[must_use]
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 #[must_use]
232 pub fn should_load_all(&self) -> bool {
233 self.load_all || self.has_explicit_scope() || self.has_nonempty_filters()
234 }
235
236 #[must_use]
238 pub fn has_explicit_scope(&self) -> bool {
239 self.event_slug_builder.is_some()
240 || self.event_slugs.as_ref().is_some_and(|s| !s.is_empty())
241 || self.market_slugs.as_ref().is_some_and(|s| !s.is_empty())
242 || self.has_series_ids()
243 }
244
245 #[must_use]
246 pub fn has_series_ids(&self) -> bool {
247 self.series_ids.as_ref().is_some_and(|ids| !ids.is_empty())
248 }
249
250 #[must_use]
251 pub fn has_nonempty_filters(&self) -> bool {
252 self.filters.as_ref().is_some_and(|map| !map.is_empty())
253 }
254
255 #[must_use]
256 pub fn has_load_ids(&self) -> bool {
257 self.load_ids.as_ref().is_some_and(|ids| !ids.is_empty())
258 }
259}
260
261#[derive(Clone, Serialize, Deserialize, bon::Builder)]
267#[serde(default, deny_unknown_fields)]
268#[cfg_attr(
269 feature = "python",
270 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
271)]
272#[cfg_attr(
273 feature = "python",
274 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
275)]
276pub struct PolymarketDataClientConfig {
277 pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
278 #[builder(default)]
280 #[serde(skip)]
281 pub filters: Vec<Arc<dyn InstrumentFilter>>,
282 pub base_url_http: Option<String>,
283 pub base_url_ws: Option<String>,
284 pub base_url_rtds: Option<String>,
285 pub base_url_gamma: Option<String>,
286 pub base_url_data_api: Option<String>,
287 pub proxy_url: Option<String>,
289 #[builder(default = 60)]
291 pub http_timeout_secs: u64,
292 #[builder(default = 30)]
294 pub ws_timeout_secs: u64,
295 #[builder(default = crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS)]
296 pub ws_max_subscriptions: usize,
297 pub update_instruments_interval_mins: Option<u64>,
299 #[builder(default)]
301 pub subscribe_new_markets: bool,
302 #[serde(skip)]
304 pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
305 #[builder(default = 8)]
310 pub new_market_fetch_max_concurrency: usize,
311 #[builder(default = true)]
313 pub drop_quotes_missing_side: bool,
314 #[builder(default)]
317 pub compute_effective_deltas: bool,
318 #[builder(default = true)]
322 pub auto_load_missing_instruments: bool,
323 #[builder(default = 100)]
325 pub auto_load_debounce_ms: u64,
326 #[builder(default = 12)]
330 pub auto_load_max_retries: u32,
331 #[builder(default = 5.0)]
334 pub auto_load_retry_delay_initial_secs: f64,
335 #[builder(default = 15.0)]
337 pub auto_load_retry_delay_max_secs: f64,
338 #[builder(default = true)]
340 pub resolve_poll_enabled: bool,
341 #[builder(default = 30)]
343 pub resolve_poll_interval_secs: u64,
344 #[builder(default = 10)]
346 pub resolve_poll_grace_secs: u64,
347 #[builder(default = 1800)]
349 pub resolve_poll_max_wait_secs: u64,
350 #[builder(default)]
352 pub transport_backend: TransportBackend,
353}
354
355#[cfg(feature = "python")]
356nautilus_core::impl_pyo3_config_getters!(PolymarketDataClientConfig {
357 instrument_config: Option<PolymarketInstrumentProviderConfig>,
358 base_url_http: Option<String>,
359 base_url_ws: Option<String>,
360 base_url_gamma: Option<String>,
361 base_url_data_api: Option<String>,
362 http_timeout_secs: u64,
363 ws_timeout_secs: u64,
364 ws_max_subscriptions: usize,
365 update_instruments_interval_mins: Option<u64>,
366 subscribe_new_markets: bool,
367 auto_load_missing_instruments: bool,
368 auto_load_debounce_ms: u64,
369 auto_load_max_retries: u32,
370 auto_load_retry_delay_initial_secs: f64,
371 auto_load_retry_delay_max_secs: f64,
372 new_market_fetch_max_concurrency: usize,
373 resolve_poll_enabled: bool,
374 resolve_poll_interval_secs: u64,
375 resolve_poll_grace_secs: u64,
376 resolve_poll_max_wait_secs: u64,
377 base_url_rtds: Option<String>,
378 transport_backend: TransportBackend,
379 drop_quotes_missing_side: bool,
380 compute_effective_deltas: bool,
381});
382
383impl Default for PolymarketDataClientConfig {
384 fn default() -> Self {
385 Self {
386 update_instruments_interval_mins: Some(60),
387 ..Self::builder().build()
388 }
389 }
390}
391
392impl Debug for PolymarketDataClientConfig {
393 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394 f.debug_struct(stringify!(PolymarketDataClientConfig))
395 .field("instrument_config", &self.instrument_config)
396 .field("filters", &self.filters)
397 .field("base_url_http", &self.base_url_http)
398 .field("base_url_ws", &self.base_url_ws)
399 .field("base_url_rtds", &self.base_url_rtds)
400 .field("base_url_gamma", &self.base_url_gamma)
401 .field("base_url_data_api", &self.base_url_data_api)
402 .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
403 .field("http_timeout_secs", &self.http_timeout_secs)
404 .field("ws_timeout_secs", &self.ws_timeout_secs)
405 .field("ws_max_subscriptions", &self.ws_max_subscriptions)
406 .field(
407 "update_instruments_interval_mins",
408 &self.update_instruments_interval_mins,
409 )
410 .field("subscribe_new_markets", &self.subscribe_new_markets)
411 .field("new_market_filter", &self.new_market_filter)
412 .field(
413 "new_market_fetch_max_concurrency",
414 &self.new_market_fetch_max_concurrency,
415 )
416 .field("drop_quotes_missing_side", &self.drop_quotes_missing_side)
417 .field("compute_effective_deltas", &self.compute_effective_deltas)
418 .field(
419 "auto_load_missing_instruments",
420 &self.auto_load_missing_instruments,
421 )
422 .field("auto_load_debounce_ms", &self.auto_load_debounce_ms)
423 .field("auto_load_max_retries", &self.auto_load_max_retries)
424 .field(
425 "auto_load_retry_delay_initial_secs",
426 &self.auto_load_retry_delay_initial_secs,
427 )
428 .field(
429 "auto_load_retry_delay_max_secs",
430 &self.auto_load_retry_delay_max_secs,
431 )
432 .field("resolve_poll_enabled", &self.resolve_poll_enabled)
433 .field(
434 "resolve_poll_interval_secs",
435 &self.resolve_poll_interval_secs,
436 )
437 .field("resolve_poll_grace_secs", &self.resolve_poll_grace_secs)
438 .field(
439 "resolve_poll_max_wait_secs",
440 &self.resolve_poll_max_wait_secs,
441 )
442 .field("transport_backend", &self.transport_backend)
443 .finish()
444 }
445}
446
447impl PolymarketDataClientConfig {
448 #[must_use]
449 pub fn new() -> Self {
450 Self::default()
451 }
452
453 pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
459 validated_proxy_url(self.proxy_url.as_ref())
460 }
461
462 #[must_use]
463 pub const fn has_proxy_url(&self) -> bool {
464 self.proxy_url.is_some()
465 }
466
467 #[must_use]
468 pub fn http_url(&self) -> String {
469 self.base_url_http
470 .clone()
471 .unwrap_or_else(|| urls::clob_http_url().to_string())
472 }
473
474 #[must_use]
475 pub fn ws_url(&self) -> String {
476 self.base_url_ws
477 .clone()
478 .unwrap_or_else(|| urls::clob_ws_url().to_string())
479 }
480
481 #[must_use]
482 pub fn rtds_url(&self) -> String {
483 self.base_url_rtds
484 .clone()
485 .unwrap_or_else(|| urls::rtds_ws_url().to_string())
486 }
487
488 #[must_use]
489 pub fn gamma_url(&self) -> String {
490 self.base_url_gamma
491 .clone()
492 .unwrap_or_else(|| urls::gamma_api_url().to_string())
493 }
494
495 #[must_use]
496 pub fn data_api_url(&self) -> String {
497 self.base_url_data_api
498 .clone()
499 .unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
500 }
501}
502
503#[derive(Clone, Serialize, Deserialize, bon::Builder)]
508#[serde(default, deny_unknown_fields)]
509#[cfg_attr(
510 feature = "python",
511 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
512)]
513#[cfg_attr(
514 feature = "python",
515 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
516)]
517pub struct PolymarketExecutionClientConfig {
518 #[builder(default = AccountId::from("POLYMARKET-001"))]
519 pub account_id: AccountId,
520 pub private_key: Option<String>,
522 pub api_key: Option<String>,
524 pub api_secret: Option<String>,
526 pub passphrase: Option<String>,
528 pub funder: Option<String>,
530 #[builder(default = SignatureType::Eoa)]
531 pub signature_type: SignatureType,
532 pub base_url_http: Option<String>,
533 pub base_url_ws: Option<String>,
534 pub base_url_data_api: Option<String>,
535 pub proxy_url: Option<String>,
537 #[builder(default = 60)]
538 pub http_timeout_secs: u64,
539 #[builder(default = 3)]
540 pub max_retries: u32,
541 #[builder(default = 1000)]
542 pub retry_delay_initial_ms: u64,
543 #[builder(default = 10000)]
544 pub retry_delay_max_ms: u64,
545 #[builder(default)]
547 pub heartbeat_enabled: bool,
548 #[builder(default)]
550 pub transport_backend: TransportBackend,
551 pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
558}
559
560#[cfg(feature = "python")]
561nautilus_core::impl_pyo3_config_getters!(PolymarketExecutionClientConfig {
562 account_id: AccountId,
563 funder: Option<String>,
564 signature_type: SignatureType,
565 base_url_http: Option<String>,
566 base_url_ws: Option<String>,
567 base_url_data_api: Option<String>,
568 http_timeout_secs: u64,
569 max_retries: u32,
570 retry_delay_initial_ms: u64,
571 retry_delay_max_ms: u64,
572 heartbeat_enabled: bool,
573 transport_backend: TransportBackend,
574 instrument_config: Option<PolymarketInstrumentProviderConfig>,
575});
576
577impl Debug for PolymarketExecutionClientConfig {
578 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579 f.debug_struct(stringify!(PolymarketExecutionClientConfig))
580 .field("account_id", &self.account_id)
581 .field("private_key", &"***")
582 .field("api_key", &"***")
583 .field("api_secret", &"***")
584 .field("passphrase", &"***")
585 .field("funder", &self.funder)
586 .field("signature_type", &self.signature_type)
587 .field("base_url_http", &self.base_url_http)
588 .field("base_url_ws", &self.base_url_ws)
589 .field("base_url_data_api", &self.base_url_data_api)
590 .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
591 .field("http_timeout_secs", &self.http_timeout_secs)
592 .field("max_retries", &self.max_retries)
593 .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
594 .field("retry_delay_max_ms", &self.retry_delay_max_ms)
595 .field("heartbeat_enabled", &self.heartbeat_enabled)
596 .field("instrument_config", &self.instrument_config)
597 .finish()
598 }
599}
600
601impl Default for PolymarketExecutionClientConfig {
602 fn default() -> Self {
603 Self::builder().build()
604 }
605}
606
607impl PolymarketExecutionClientConfig {
608 #[must_use]
609 pub fn new() -> Self {
610 Self::default()
611 }
612
613 pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
619 validated_proxy_url(self.proxy_url.as_ref())
620 }
621
622 #[must_use]
623 pub const fn has_proxy_url(&self) -> bool {
624 self.proxy_url.is_some()
625 }
626
627 #[must_use]
628 pub fn has_credentials(&self) -> bool {
629 self.private_key
630 .as_deref()
631 .is_some_and(|s| !s.trim().is_empty())
632 || self
633 .api_key
634 .as_deref()
635 .is_some_and(|s| !s.trim().is_empty())
636 }
637
638 #[must_use]
640 pub fn reconciliation_load_ids(&self) -> Option<&[InstrumentId]> {
641 self.instrument_config
642 .as_ref()
643 .and_then(|config| config.load_ids.as_deref())
644 }
645
646 #[must_use]
647 pub fn http_url(&self) -> String {
648 self.base_url_http
649 .clone()
650 .unwrap_or_else(|| urls::clob_http_url().to_string())
651 }
652
653 #[must_use]
654 pub fn ws_url(&self) -> String {
655 self.base_url_ws
656 .clone()
657 .unwrap_or_else(|| urls::clob_ws_url().to_string())
658 }
659
660 #[must_use]
661 pub fn data_api_url(&self) -> String {
662 self.base_url_data_api
663 .clone()
664 .unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use rstest::rstest;
671
672 use super::*;
673
674 #[rstest]
675 fn updown_event_slug_config_builds_aligned_slugs() {
676 let config = PolymarketUpDownEventSlugConfig {
677 assets: vec![
678 "BTC".to_string(),
679 " eth ".to_string(),
680 String::new(),
681 "btc".to_string(),
682 ],
683 interval_mins: 5,
684 periods: 2,
685 start_offset_periods: -1,
686 };
687
688 let slugs = config
689 .build_event_slugs_at_unix_secs(1_700_000_123)
690 .expect("event slugs should build");
691
692 assert_eq!(
693 slugs,
694 [
695 "btc-updown-5m-1699999800",
696 "eth-updown-5m-1699999800",
697 "btc-updown-5m-1700000100",
698 "eth-updown-5m-1700000100",
699 ]
700 );
701 }
702
703 #[rstest]
704 fn updown_event_slug_config_rejects_zero_interval() {
705 let config = PolymarketUpDownEventSlugConfig {
706 interval_mins: 0,
707 ..PolymarketUpDownEventSlugConfig::default()
708 };
709
710 let err = config
711 .build_event_slugs_at_unix_secs(1_700_000_123)
712 .expect_err("zero interval should fail");
713
714 assert!(
715 err.to_string()
716 .contains("event_slug_builder.interval_mins must be positive")
717 );
718 }
719
720 #[rstest]
721 fn provider_config_series_ids_trigger_load_all() {
722 let config = PolymarketInstrumentProviderConfig {
723 series_ids: Some(vec![10684]),
724 ..PolymarketInstrumentProviderConfig::default()
725 };
726
727 assert!(config.has_series_ids());
728 assert!(config.should_load_all());
729 }
730
731 #[rstest]
732 fn provider_config_empty_series_ids_do_not_trigger_load_all() {
733 let config = PolymarketInstrumentProviderConfig {
734 series_ids: Some(Vec::new()),
735 ..PolymarketInstrumentProviderConfig::default()
736 };
737
738 assert!(!config.has_series_ids());
739 assert!(!config.should_load_all());
740 }
741
742 #[rstest]
743 fn provider_config_filters_trigger_load_all() {
744 let config = PolymarketInstrumentProviderConfig {
745 filters: Some(HashMap::from([("tag_id".to_string(), "84".to_string())])),
746 ..PolymarketInstrumentProviderConfig::default()
747 };
748
749 assert!(config.has_nonempty_filters());
750 assert!(!config.has_explicit_scope());
751 assert!(config.should_load_all());
752 }
753
754 #[rstest]
755 fn provider_config_empty_filters_do_not_trigger_load_all() {
756 let config = PolymarketInstrumentProviderConfig {
757 filters: Some(HashMap::new()),
758 ..PolymarketInstrumentProviderConfig::default()
759 };
760
761 assert!(!config.has_nonempty_filters());
762 assert!(!config.should_load_all());
763 }
764
765 #[rstest]
766 fn test_data_config_toml_minimal() {
767 let config: PolymarketDataClientConfig = toml::from_str(
768 "
769http_timeout_secs = 30
770ws_max_subscriptions = 50
771update_instruments_interval_mins = 5
772subscribe_new_markets = true
773new_market_fetch_max_concurrency = 16
774auto_load_debounce_ms = 250
775resolve_poll_enabled = true
776resolve_poll_interval_secs = 30
777resolve_poll_grace_secs = 10
778resolve_poll_max_wait_secs = 1800
779",
780 )
781 .unwrap();
782
783 assert!(config.instrument_config.is_none());
784 assert!(config.filters.is_empty());
785 assert_eq!(config.http_timeout_secs, 30);
786 assert_eq!(config.ws_max_subscriptions, 50);
787 assert_eq!(config.update_instruments_interval_mins, Some(5));
788 assert!(config.subscribe_new_markets);
789 assert!(config.new_market_filter.is_none());
790 assert_eq!(config.new_market_fetch_max_concurrency, 16);
791 assert_eq!(config.auto_load_debounce_ms, 250);
792 assert!(config.resolve_poll_enabled);
793 assert_eq!(config.resolve_poll_interval_secs, 30);
794 assert_eq!(config.resolve_poll_grace_secs, 10);
795 assert_eq!(config.resolve_poll_max_wait_secs, 1800);
796 assert!(config.drop_quotes_missing_side);
797 assert!(!config.compute_effective_deltas);
798 }
799
800 #[rstest]
801 fn test_data_config_toml_sets_compute_effective_deltas() {
802 let config: PolymarketDataClientConfig =
803 toml::from_str("compute_effective_deltas = true").unwrap();
804
805 assert!(config.compute_effective_deltas);
806 }
807
808 #[rstest]
809 fn test_data_config_toml_sets_drop_quotes_missing_side_false() {
810 let config: PolymarketDataClientConfig =
811 toml::from_str("drop_quotes_missing_side = false").unwrap();
812
813 assert!(!config.drop_quotes_missing_side);
814 }
815
816 #[rstest]
817 fn test_data_config_toml_with_instrument_config() {
818 let config: PolymarketDataClientConfig = toml::from_str(
819 r#"
820[instrument_config]
821load_all = true
822event_slugs = ["btc-updown-5m-123", "eth-updown-15m-456"]
823log_warnings = false
824"#,
825 )
826 .unwrap();
827
828 let instrument_config = config.instrument_config.expect("instrument_config");
829 assert!(instrument_config.load_all);
830 assert_eq!(
831 instrument_config.event_slugs,
832 Some(vec![
833 "btc-updown-5m-123".to_string(),
834 "eth-updown-15m-456".to_string(),
835 ]),
836 );
837 assert!(!instrument_config.log_warnings);
838 }
839
840 #[rstest]
841 fn test_exec_config_toml_empty_uses_defaults() {
842 let config: PolymarketExecutionClientConfig = toml::from_str("").unwrap();
843 let expected = PolymarketExecutionClientConfig::default();
844 assert_eq!(config.account_id, expected.account_id);
845 assert_eq!(config.signature_type, expected.signature_type);
846 assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
847 assert_eq!(config.max_retries, expected.max_retries);
848 assert!(!config.heartbeat_enabled);
849 assert_eq!(config.transport_backend, expected.transport_backend);
850 assert!(config.instrument_config.is_none());
851 assert!(config.reconciliation_load_ids().is_none());
852 }
853
854 #[rstest]
855 fn test_exec_config_reconciliation_load_ids_come_from_instrument_config() {
856 let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
857 let config: PolymarketExecutionClientConfig = toml::from_str(
858 r#"
859[instrument_config]
860load_ids = ["0xabc-123.POLYMARKET"]
861"#,
862 )
863 .unwrap();
864
865 assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
866 }
867
868 #[rstest]
869 fn test_data_config_proxy_url_validates_and_redacts_debug() {
870 const SECRET: &str = "data-proxy-secret";
871 let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18081");
872 let config: PolymarketDataClientConfig =
873 toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
874 .expect("deserialize data config");
875 let validated = config
876 .validated_proxy_url()
877 .expect("validate data proxy")
878 .expect("data proxy configured");
879 let debug = format!("{config:?}");
880
881 assert_eq!(validated.expose(), proxy_url);
882 assert!(config.has_proxy_url());
883 assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
884 assert!(!debug.contains(SECRET));
885 }
886
887 #[rstest]
888 fn test_exec_config_proxy_url_validates_and_redacts_debug() {
889 const SECRET: &str = "exec-proxy-secret";
890 let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18082");
891 let config: PolymarketExecutionClientConfig =
892 toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
893 .expect("deserialize execution config");
894 let validated = config
895 .validated_proxy_url()
896 .expect("validate execution proxy")
897 .expect("execution proxy configured");
898 let debug = format!("{config:?}");
899
900 assert_eq!(validated.expose(), proxy_url);
901 assert!(config.has_proxy_url());
902 assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
903 assert!(!debug.contains(SECRET));
904 }
905
906 #[rstest]
907 fn test_proxy_url_unset_preserves_direct_configuration() {
908 let data_config = PolymarketDataClientConfig::default();
909 let exec_config = PolymarketExecutionClientConfig::default();
910
911 assert_eq!(data_config.proxy_url, None);
912 assert_eq!(exec_config.proxy_url, None);
913 assert_eq!(data_config.validated_proxy_url().unwrap(), None);
914 assert_eq!(exec_config.validated_proxy_url().unwrap(), None);
915 assert!(!data_config.has_proxy_url());
916 assert!(!exec_config.has_proxy_url());
917 }
918
919 #[rstest]
920 fn test_invalid_proxy_url_error_redacts_credentials() {
921 const SECRET: &str = "invalid-proxy-secret";
922 let config = PolymarketDataClientConfig {
923 proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1")),
924 ..PolymarketDataClientConfig::default()
925 };
926 let error = config
927 .validated_proxy_url()
928 .expect_err("malformed proxy URL should fail");
929
930 assert!(!error.to_string().contains(SECRET));
931 }
932
933 #[rstest]
934 fn test_socks_proxy_url_is_rejected_for_consistent_routing() {
935 let config = PolymarketExecutionClientConfig {
936 proxy_url: Some("socks5://127.0.0.1:1080".to_string()),
937 ..PolymarketExecutionClientConfig::default()
938 };
939 let error = config
940 .validated_proxy_url()
941 .expect_err("SOCKS proxy should fail validation");
942
943 assert_eq!(
944 error.to_string(),
945 "invalid URL: SOCKS proxy scheme 'socks5' is not yet supported for WebSocket connections; use an http:// or https:// proxy"
946 );
947 }
948}