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