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 credential::{Credential, credential_env_vars},
37 enums::{PolymarketSignatureType, PolymarketSignerType},
38 urls,
39 },
40 filters::InstrumentFilter,
41};
42
43const DEFAULT_UPDOWN_INTERVAL_MINS: u64 = 5;
44const DEFAULT_UPDOWN_PERIODS: u64 = 3;
45
46fn validated_proxy_url(value: Option<&SecretString>) -> Result<Option<ProxyUrl>, TransportError> {
47 value
48 .map(|value| ProxyUrl::parse(value.expose_secret().to_owned()))
49 .transpose()
50}
51
52fn default_updown_assets() -> Vec<String> {
53 vec!["btc".to_string()]
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
63#[serde(default, deny_unknown_fields)]
64#[cfg_attr(
65 feature = "python",
66 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
67)]
68#[cfg_attr(
69 feature = "python",
70 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
71)]
72pub struct PolymarketUpDownEventSlugConfig {
73 #[builder(default = default_updown_assets())]
75 pub assets: Vec<String>,
76 #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
78 pub interval_mins: u64,
79 #[builder(default = DEFAULT_UPDOWN_PERIODS)]
81 pub periods: u64,
82 #[builder(default)]
84 pub start_offset_periods: i64,
85}
86
87#[cfg(feature = "python")]
88nautilus_core::impl_pyo3_config_getters!(PolymarketUpDownEventSlugConfig {
89 assets: Vec<String>,
90 interval_mins: u64,
91 periods: u64,
92 start_offset_periods: i64,
93});
94
95impl Default for PolymarketUpDownEventSlugConfig {
96 fn default() -> Self {
97 Self::builder().build()
98 }
99}
100
101impl PolymarketUpDownEventSlugConfig {
102 pub fn build_event_slugs(&self) -> anyhow::Result<Vec<String>> {
109 let now = SystemTime::now()
110 .duration_since(UNIX_EPOCH)
111 .map_err(|e| anyhow::anyhow!("system clock before Unix epoch: {e}"))?
112 .as_secs();
113 self.build_event_slugs_at_unix_secs(now)
114 }
115
116 fn build_event_slugs_at_unix_secs(&self, unix_secs: u64) -> anyhow::Result<Vec<String>> {
117 if self.interval_mins == 0 {
118 anyhow::bail!("event_slug_builder.interval_mins must be positive");
119 }
120
121 if self.periods == 0 {
122 anyhow::bail!("event_slug_builder.periods must be positive");
123 }
124
125 let assets = self.normalized_assets();
126 if assets.is_empty() {
127 anyhow::bail!("event_slug_builder.assets must include at least one non-empty asset");
128 }
129
130 let period_secs = self
131 .interval_mins
132 .checked_mul(60)
133 .ok_or_else(|| anyhow::anyhow!("event_slug_builder.interval_mins is too large"))?;
134 let period_start = (unix_secs / period_secs) * period_secs;
135 let period_secs = i128::from(period_secs);
136 let period_start = i128::from(period_start);
137 let mut slugs = Vec::new();
138
139 for period in 0..self.periods {
140 let period_offset = i128::from(self.start_offset_periods) + i128::from(period);
141 let timestamp = period_start + period_offset * period_secs;
142 if timestamp < 0 {
143 anyhow::bail!("event_slug_builder offset resolves before the Unix epoch");
144 }
145
146 for asset in &assets {
147 slugs.push(format!(
148 "{asset}-updown-{}m-{timestamp}",
149 self.interval_mins
150 ));
151 }
152 }
153
154 Ok(slugs)
155 }
156
157 fn normalized_assets(&self) -> Vec<String> {
158 let mut assets = Vec::new();
159
160 for asset in &self.assets {
161 let asset = asset.trim().to_ascii_lowercase();
162 if asset.is_empty() || assets.contains(&asset) {
163 continue;
164 }
165 assets.push(asset);
166 }
167
168 assets
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
177#[serde(default, deny_unknown_fields)]
178#[cfg_attr(
179 feature = "python",
180 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
181)]
182#[cfg_attr(
183 feature = "python",
184 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
185)]
186pub struct PolymarketInstrumentProviderConfig {
187 #[builder(default)]
189 pub load_all: bool,
190 pub load_ids: Option<Vec<InstrumentId>>,
192 pub filters: Option<HashMap<String, String>>,
194 pub event_slugs: Option<Vec<String>>,
196 pub market_slugs: Option<Vec<String>>,
198 pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
200 pub series_ids: Option<Vec<u64>>,
202 #[builder(default = true)]
204 pub log_warnings: bool,
205 #[builder(default)]
209 pub use_gamma_markets: bool,
210}
211
212#[cfg(feature = "python")]
213nautilus_core::impl_pyo3_config_getters!(PolymarketInstrumentProviderConfig {
214 load_all: bool,
215 load_ids: Option<Vec<InstrumentId>>,
216 filters: Option<HashMap<String, String>>,
217 event_slugs: Option<Vec<String>>,
218 market_slugs: Option<Vec<String>>,
219 event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
220 series_ids: Option<Vec<u64>>,
221 log_warnings: bool,
222 use_gamma_markets: bool,
223});
224
225impl Default for PolymarketInstrumentProviderConfig {
226 fn default() -> Self {
227 Self::builder().build()
228 }
229}
230
231impl PolymarketInstrumentProviderConfig {
232 #[must_use]
233 pub fn new() -> Self {
234 Self::default()
235 }
236
237 #[must_use]
239 pub fn should_load_all(&self) -> bool {
240 self.load_all || self.has_explicit_scope() || self.has_nonempty_filters()
241 }
242
243 #[must_use]
245 pub fn has_explicit_scope(&self) -> bool {
246 self.event_slug_builder.is_some()
247 || self.event_slugs.as_ref().is_some_and(|s| !s.is_empty())
248 || self.market_slugs.as_ref().is_some_and(|s| !s.is_empty())
249 || self.has_series_ids()
250 }
251
252 #[must_use]
253 pub fn has_series_ids(&self) -> bool {
254 self.series_ids.as_ref().is_some_and(|ids| !ids.is_empty())
255 }
256
257 #[must_use]
258 pub fn has_nonempty_filters(&self) -> bool {
259 self.filters.as_ref().is_some_and(|map| !map.is_empty())
260 }
261
262 #[must_use]
263 pub fn has_load_ids(&self) -> bool {
264 self.load_ids.as_ref().is_some_and(|ids| !ids.is_empty())
265 }
266}
267
268#[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.adapters.polymarket", from_py_object)
278)]
279#[cfg_attr(
280 feature = "python",
281 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
282)]
283pub struct PolymarketDataClientConfig {
284 pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
285 #[builder(default)]
287 #[serde(skip)]
288 pub filters: Vec<Arc<dyn InstrumentFilter>>,
289 pub base_url_http: Option<String>,
290 pub base_url_ws: Option<String>,
291 pub base_url_reference_prices: Option<String>,
292 pub base_url_gamma: Option<String>,
293 pub api_key: Option<SecretString>,
295 pub api_secret: Option<SecretString>,
298 pub passphrase: Option<SecretString>,
301 pub base_url_data_api: Option<String>,
302 pub proxy_url: Option<SecretString>,
304 #[builder(default = 60)]
306 pub http_timeout_secs: u64,
307 #[builder(default = 30)]
309 pub ws_timeout_secs: u64,
310 #[builder(default = crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS)]
311 pub ws_max_subscriptions: usize,
312 pub update_instruments_interval_mins: Option<u64>,
314 #[builder(default)]
316 pub subscribe_new_markets: bool,
317 #[serde(skip)]
319 pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
320 #[builder(default = 8)]
325 pub new_market_fetch_max_concurrency: usize,
326 #[builder(default = true)]
328 pub drop_quotes_missing_side: bool,
329 #[builder(default)]
332 pub compute_effective_deltas: bool,
333 #[builder(default = true)]
337 pub auto_load_missing_instruments: bool,
338 #[builder(default = 100)]
340 pub auto_load_debounce_ms: u64,
341 #[builder(default = 12)]
345 pub auto_load_max_retries: u32,
346 #[builder(default = 5.0)]
349 pub auto_load_retry_delay_initial_secs: f64,
350 #[builder(default = 15.0)]
352 pub auto_load_retry_delay_max_secs: f64,
353 #[builder(default = true)]
355 pub resolve_poll_enabled: bool,
356 #[builder(default = 30)]
358 pub resolve_poll_interval_secs: u64,
359 #[builder(default = 10)]
361 pub resolve_poll_grace_secs: u64,
362 #[builder(default = 1800)]
364 pub resolve_poll_max_wait_secs: u64,
365 #[builder(default)]
367 pub transport_backend: TransportBackend,
368 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
374 pub book_snapshot_timeout_secs: u64,
375 #[builder(default = 5)]
377 pub book_stale_check_interval_secs: u64,
378 #[builder(default)]
382 pub book_stale_threshold_secs: u64,
383}
384
385#[cfg(feature = "python")]
386nautilus_core::impl_pyo3_config_getters!(PolymarketDataClientConfig {
387 instrument_config: Option<PolymarketInstrumentProviderConfig>,
388 base_url_http: Option<String>,
389 base_url_ws: Option<String>,
390 base_url_gamma: Option<String>,
391 base_url_data_api: Option<String>,
392 http_timeout_secs: u64,
393 ws_timeout_secs: u64,
394 ws_max_subscriptions: usize,
395 update_instruments_interval_mins: Option<u64>,
396 subscribe_new_markets: bool,
397 auto_load_missing_instruments: bool,
398 auto_load_debounce_ms: u64,
399 auto_load_max_retries: u32,
400 auto_load_retry_delay_initial_secs: f64,
401 auto_load_retry_delay_max_secs: f64,
402 new_market_fetch_max_concurrency: usize,
403 resolve_poll_enabled: bool,
404 resolve_poll_interval_secs: u64,
405 resolve_poll_grace_secs: u64,
406 resolve_poll_max_wait_secs: u64,
407 base_url_reference_prices: Option<String>,
408 transport_backend: TransportBackend,
409 drop_quotes_missing_side: bool,
410 compute_effective_deltas: bool,
411 book_snapshot_timeout_secs: u64,
412 book_stale_check_interval_secs: u64,
413 book_stale_threshold_secs: u64,
414});
415
416impl Default for PolymarketDataClientConfig {
417 fn default() -> Self {
418 Self {
419 update_instruments_interval_mins: Some(60),
420 ..Self::builder().build()
421 }
422 }
423}
424
425impl PolymarketDataClientConfig {
426 #[must_use]
427 pub fn new() -> Self {
428 Self::default()
429 }
430
431 pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
437 validated_proxy_url(self.proxy_url.as_ref())
438 }
439
440 #[must_use]
441 pub const fn has_proxy_url(&self) -> bool {
442 self.proxy_url.is_some()
443 }
444
445 #[must_use]
446 pub fn http_url(&self) -> String {
447 self.base_url_http
448 .clone()
449 .unwrap_or_else(|| urls::clob_http_url().to_string())
450 }
451
452 #[must_use]
453 pub fn ws_url(&self) -> String {
454 self.base_url_ws
455 .clone()
456 .unwrap_or_else(|| urls::clob_ws_url().to_string())
457 }
458
459 #[must_use]
460 pub fn reference_prices_url(&self) -> String {
461 self.base_url_reference_prices
462 .clone()
463 .unwrap_or_else(|| urls::polybolt_ws_url().to_string())
464 }
465
466 pub fn polybolt_credential(&self) -> anyhow::Result<Option<Credential>> {
478 let explicit =
479 self.api_key.is_some() || self.api_secret.is_some() || self.passphrase.is_some();
480
481 if !explicit {
482 let (api_key_var, api_secret_var, passphrase_var, _, _) = credential_env_vars();
483 let present: Vec<&str> = [api_key_var, api_secret_var, passphrase_var]
484 .into_iter()
485 .filter(|var| std::env::var(var).is_ok_and(|value| !value.trim().is_empty()))
486 .collect();
487
488 if present.is_empty() {
489 return Ok(None);
490 }
491
492 if present.len() < 3 {
493 log::warn!(
494 "Ignoring partial Polymarket CLOB credentials in the environment \
495 (set: {present:?}); PolyBolt price subscriptions will be rejected"
496 );
497 return Ok(None);
498 }
499 }
500
501 Credential::resolve(
502 self.api_key.clone(),
503 self.api_secret.clone(),
504 self.passphrase.clone(),
505 )
506 .map(Some)
507 .map_err(|e| anyhow::anyhow!("Invalid Polymarket CLOB credentials for PolyBolt: {e}"))
508 }
509
510 #[must_use]
511 pub fn gamma_url(&self) -> String {
512 self.base_url_gamma
513 .clone()
514 .unwrap_or_else(|| urls::gamma_api_url().to_string())
515 }
516
517 #[must_use]
518 pub fn data_api_url(&self) -> String {
519 self.base_url_data_api
520 .clone()
521 .unwrap_or_else(|| urls::data_api_url().to_string())
522 }
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
527#[serde(default, deny_unknown_fields)]
528#[cfg_attr(
529 feature = "python",
530 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
531)]
532#[cfg_attr(
533 feature = "python",
534 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
535)]
536pub struct PolymarketExecutionClientConfig {
537 #[builder(default = AccountId::from("POLYMARKET-001"))]
538 pub account_id: AccountId,
539 pub private_key: Option<SecretString>,
541 pub api_key: Option<SecretString>,
543 pub api_secret: Option<SecretString>,
545 pub passphrase: Option<SecretString>,
547 pub funder: Option<String>,
549 #[builder(default = PolymarketSignatureType::Eoa)]
550 pub signature_type: PolymarketSignatureType,
551 #[builder(default)]
553 pub signer_type: PolymarketSignerType,
554 pub base_url_http: Option<String>,
555 pub base_url_ws: Option<String>,
556 pub base_url_data_api: Option<String>,
557 pub proxy_url: Option<SecretString>,
559 #[builder(default = 60)]
560 pub http_timeout_secs: u64,
561 #[builder(default = 3)]
562 pub max_retries: u32,
563 #[builder(default = 1000)]
564 pub retry_delay_initial_ms: u64,
565 #[builder(default = 10000)]
566 pub retry_delay_max_ms: u64,
567 #[builder(default)]
569 pub heartbeat_enabled: bool,
570 #[builder(default)]
572 pub transport_backend: TransportBackend,
573 pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
580}
581
582#[cfg(feature = "python")]
583nautilus_core::impl_pyo3_config_getters!(PolymarketExecutionClientConfig {
584 account_id: AccountId,
585 funder: Option<String>,
586 signature_type: PolymarketSignatureType,
587 signer_type: PolymarketSignerType,
588 base_url_http: Option<String>,
589 base_url_ws: Option<String>,
590 base_url_data_api: Option<String>,
591 http_timeout_secs: u64,
592 max_retries: u32,
593 retry_delay_initial_ms: u64,
594 retry_delay_max_ms: u64,
595 heartbeat_enabled: bool,
596 transport_backend: TransportBackend,
597 instrument_config: Option<PolymarketInstrumentProviderConfig>,
598});
599
600impl Default for PolymarketExecutionClientConfig {
601 fn default() -> Self {
602 Self::builder().build()
603 }
604}
605
606impl PolymarketExecutionClientConfig {
607 #[must_use]
608 pub fn new() -> Self {
609 Self::default()
610 }
611
612 pub(crate) fn validate_signer(&self) -> anyhow::Result<()> {
613 if self.signer_type == PolymarketSignerType::Session {
614 anyhow::ensure!(
615 self.signature_type == PolymarketSignatureType::Poly1271,
616 "Session signers require POLY_1271"
617 );
618
619 for (name, value) in [
620 ("private_key", self.private_key.as_ref()),
621 ("api_key", self.api_key.as_ref()),
622 ("api_secret", self.api_secret.as_ref()),
623 ("passphrase", self.passphrase.as_ref()),
624 ] {
625 anyhow::ensure!(
626 value.is_some_and(|value| !value.expose_secret().trim().is_empty()),
627 "Session signers require explicit {name}; environment fallback is disabled"
628 );
629 }
630
631 anyhow::ensure!(
632 self.funder
633 .as_ref()
634 .is_some_and(|value| !value.trim().is_empty()),
635 "Session signers require an explicit Deposit Wallet funder"
636 );
637 }
638
639 Ok(())
640 }
641
642 pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
648 validated_proxy_url(self.proxy_url.as_ref())
649 }
650
651 #[must_use]
652 pub const fn has_proxy_url(&self) -> bool {
653 self.proxy_url.is_some()
654 }
655
656 #[must_use]
657 pub fn has_credentials(&self) -> bool {
658 self.private_key
659 .as_ref()
660 .map(SecretString::expose_secret)
661 .is_some_and(|s| !s.trim().is_empty())
662 || self
663 .api_key
664 .as_ref()
665 .map(SecretString::expose_secret)
666 .is_some_and(|s| !s.trim().is_empty())
667 }
668
669 #[must_use]
671 pub fn reconciliation_load_ids(&self) -> Option<&[InstrumentId]> {
672 self.instrument_config
673 .as_ref()
674 .and_then(|config| config.load_ids.as_deref())
675 }
676
677 #[must_use]
678 pub fn http_url(&self) -> String {
679 self.base_url_http
680 .clone()
681 .unwrap_or_else(|| urls::clob_http_url().to_string())
682 }
683
684 #[must_use]
685 pub fn ws_url(&self) -> String {
686 self.base_url_ws
687 .clone()
688 .unwrap_or_else(|| urls::clob_ws_url().to_string())
689 }
690
691 #[must_use]
692 pub fn data_api_url(&self) -> String {
693 self.base_url_data_api
694 .clone()
695 .unwrap_or_else(|| urls::data_api_url().to_string())
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use rstest::rstest;
702
703 use super::*;
704
705 #[rstest]
706 fn updown_event_slug_config_builds_aligned_slugs() {
707 let config = PolymarketUpDownEventSlugConfig {
708 assets: vec![
709 "BTC".to_string(),
710 " eth ".to_string(),
711 String::new(),
712 "btc".to_string(),
713 ],
714 interval_mins: 5,
715 periods: 2,
716 start_offset_periods: -1,
717 };
718
719 let slugs = config
720 .build_event_slugs_at_unix_secs(1_700_000_123)
721 .expect("event slugs should build");
722
723 assert_eq!(
724 slugs,
725 [
726 "btc-updown-5m-1699999800",
727 "eth-updown-5m-1699999800",
728 "btc-updown-5m-1700000100",
729 "eth-updown-5m-1700000100",
730 ]
731 );
732 }
733
734 #[rstest]
735 fn updown_event_slug_config_rejects_zero_interval() {
736 let config = PolymarketUpDownEventSlugConfig {
737 interval_mins: 0,
738 ..PolymarketUpDownEventSlugConfig::default()
739 };
740
741 let err = config
742 .build_event_slugs_at_unix_secs(1_700_000_123)
743 .expect_err("zero interval should fail");
744
745 assert!(
746 err.to_string()
747 .contains("event_slug_builder.interval_mins must be positive")
748 );
749 }
750
751 #[rstest]
752 fn provider_config_series_ids_trigger_load_all() {
753 let config = PolymarketInstrumentProviderConfig {
754 series_ids: Some(vec![10684]),
755 ..PolymarketInstrumentProviderConfig::default()
756 };
757
758 assert!(config.has_series_ids());
759 assert!(config.should_load_all());
760 }
761
762 #[rstest]
763 fn provider_config_empty_series_ids_do_not_trigger_load_all() {
764 let config = PolymarketInstrumentProviderConfig {
765 series_ids: Some(Vec::new()),
766 ..PolymarketInstrumentProviderConfig::default()
767 };
768
769 assert!(!config.has_series_ids());
770 assert!(!config.should_load_all());
771 }
772
773 #[rstest]
774 fn provider_config_filters_trigger_load_all() {
775 let config = PolymarketInstrumentProviderConfig {
776 filters: Some(HashMap::from([("tag_id".to_string(), "84".to_string())])),
777 ..PolymarketInstrumentProviderConfig::default()
778 };
779
780 assert!(config.has_nonempty_filters());
781 assert!(!config.has_explicit_scope());
782 assert!(config.should_load_all());
783 }
784
785 #[rstest]
786 fn data_config_book_sync_defaults_match_documented_values() {
787 let config = PolymarketDataClientConfig::default();
788
789 assert_eq!(config.book_snapshot_timeout_secs, 10);
790 assert_eq!(config.book_stale_check_interval_secs, 5);
791 assert_eq!(
792 config.book_stale_threshold_secs, 0,
793 "stale monitor must stay disabled by default"
794 );
795 }
796
797 #[rstest]
798 fn provider_config_empty_filters_do_not_trigger_load_all() {
799 let config = PolymarketInstrumentProviderConfig {
800 filters: Some(HashMap::new()),
801 ..PolymarketInstrumentProviderConfig::default()
802 };
803
804 assert!(!config.has_nonempty_filters());
805 assert!(!config.should_load_all());
806 }
807
808 #[rstest]
809 fn test_data_config_toml_minimal() {
810 let config: PolymarketDataClientConfig = toml::from_str(
811 "
812http_timeout_secs = 30
813ws_max_subscriptions = 50
814update_instruments_interval_mins = 5
815subscribe_new_markets = true
816new_market_fetch_max_concurrency = 16
817auto_load_debounce_ms = 250
818resolve_poll_enabled = true
819resolve_poll_interval_secs = 30
820resolve_poll_grace_secs = 10
821resolve_poll_max_wait_secs = 1800
822",
823 )
824 .unwrap();
825
826 assert!(config.instrument_config.is_none());
827 assert!(config.filters.is_empty());
828 assert_eq!(config.http_timeout_secs, 30);
829 assert_eq!(config.ws_max_subscriptions, 50);
830 assert_eq!(config.update_instruments_interval_mins, Some(5));
831 assert!(config.subscribe_new_markets);
832 assert!(config.new_market_filter.is_none());
833 assert_eq!(config.new_market_fetch_max_concurrency, 16);
834 assert_eq!(config.auto_load_debounce_ms, 250);
835 assert!(config.resolve_poll_enabled);
836 assert_eq!(config.resolve_poll_interval_secs, 30);
837 assert_eq!(config.resolve_poll_grace_secs, 10);
838 assert_eq!(config.resolve_poll_max_wait_secs, 1800);
839 assert!(config.drop_quotes_missing_side);
840 assert!(!config.compute_effective_deltas);
841 }
842
843 #[rstest]
844 fn test_data_config_toml_sets_compute_effective_deltas() {
845 let config: PolymarketDataClientConfig =
846 toml::from_str("compute_effective_deltas = true").unwrap();
847
848 assert!(config.compute_effective_deltas);
849 }
850
851 #[rstest]
852 fn test_data_config_toml_sets_drop_quotes_missing_side_false() {
853 let config: PolymarketDataClientConfig =
854 toml::from_str("drop_quotes_missing_side = false").unwrap();
855
856 assert!(!config.drop_quotes_missing_side);
857 }
858
859 #[rstest]
860 fn test_data_config_toml_with_instrument_config() {
861 let config: PolymarketDataClientConfig = toml::from_str(
862 r#"
863[instrument_config]
864load_all = true
865event_slugs = ["btc-updown-5m-123", "eth-updown-15m-456"]
866log_warnings = false
867"#,
868 )
869 .unwrap();
870
871 let instrument_config = config.instrument_config.expect("instrument_config");
872 assert!(instrument_config.load_all);
873 assert_eq!(
874 instrument_config.event_slugs,
875 Some(vec![
876 "btc-updown-5m-123".to_string(),
877 "eth-updown-15m-456".to_string(),
878 ]),
879 );
880 assert!(!instrument_config.log_warnings);
881 }
882
883 #[rstest]
884 fn test_exec_config_toml_empty_uses_defaults() {
885 let config: PolymarketExecutionClientConfig = toml::from_str("").unwrap();
886 let expected = PolymarketExecutionClientConfig::default();
887 assert_eq!(config.account_id, expected.account_id);
888 assert_eq!(config.signature_type, expected.signature_type);
889 assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
890 assert_eq!(config.max_retries, expected.max_retries);
891 assert!(!config.heartbeat_enabled);
892 assert_eq!(config.transport_backend, expected.transport_backend);
893 assert!(config.instrument_config.is_none());
894 assert!(config.reconciliation_load_ids().is_none());
895 }
896
897 #[rstest]
898 fn test_exec_config_reconciliation_load_ids_come_from_instrument_config() {
899 let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
900 let config: PolymarketExecutionClientConfig = toml::from_str(
901 r#"
902[instrument_config]
903load_ids = ["0xabc-123.POLYMARKET"]
904"#,
905 )
906 .unwrap();
907
908 assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
909 }
910
911 #[rstest]
912 fn test_data_config_proxy_url_validates_and_redacts_debug() {
913 const SECRET: &str = "data-proxy-secret";
914 let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18081");
915 let config: PolymarketDataClientConfig =
916 toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
917 .expect("deserialize data config");
918 let validated = config
919 .validated_proxy_url()
920 .expect("validate data proxy")
921 .expect("data proxy configured");
922 let debug = format!("{config:?}");
923
924 assert_eq!(validated.expose(), proxy_url);
925 assert!(config.has_proxy_url());
926 assert!(debug.contains("proxy_url: Some(<redacted>)"));
927 assert!(!debug.contains(SECRET));
928 }
929
930 #[rstest]
931 fn test_exec_config_proxy_url_validates_and_redacts_debug() {
932 const SECRET: &str = "exec-proxy-secret";
933 let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18082");
934 let config: PolymarketExecutionClientConfig =
935 toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
936 .expect("deserialize execution config");
937 let validated = config
938 .validated_proxy_url()
939 .expect("validate execution proxy")
940 .expect("execution proxy configured");
941 let debug = format!("{config:?}");
942
943 assert_eq!(validated.expose(), proxy_url);
944 assert!(config.has_proxy_url());
945 assert!(debug.contains("proxy_url: Some(<redacted>)"));
946 assert!(!debug.contains(SECRET));
947 }
948
949 #[rstest]
950 fn test_proxy_url_unset_preserves_direct_configuration() {
951 let data_config = PolymarketDataClientConfig::default();
952 let exec_config = PolymarketExecutionClientConfig::default();
953
954 assert_eq!(data_config.proxy_url, None);
955 assert_eq!(exec_config.proxy_url, None);
956 assert_eq!(data_config.validated_proxy_url().unwrap(), None);
957 assert_eq!(exec_config.validated_proxy_url().unwrap(), None);
958 assert!(!data_config.has_proxy_url());
959 assert!(!exec_config.has_proxy_url());
960 }
961
962 #[rstest]
963 fn test_invalid_proxy_url_error_redacts_credentials() {
964 const SECRET: &str = "invalid-proxy-secret";
965 let config = PolymarketDataClientConfig {
966 proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1").into()),
967 ..PolymarketDataClientConfig::default()
968 };
969 let error = config
970 .validated_proxy_url()
971 .expect_err("malformed proxy URL should fail");
972
973 assert!(!error.to_string().contains(SECRET));
974 }
975
976 #[rstest]
977 fn test_socks_proxy_url_is_rejected_for_consistent_routing() {
978 let config = PolymarketExecutionClientConfig {
979 proxy_url: Some("socks5://127.0.0.1:1080".into()),
980 ..PolymarketExecutionClientConfig::default()
981 };
982 let error = config
983 .validated_proxy_url()
984 .expect_err("SOCKS proxy should fail validation");
985
986 assert_eq!(
987 error.to_string(),
988 "invalid URL: SOCKS proxy scheme 'socks5' is not yet supported for WebSocket connections; use an http:// or https:// proxy"
989 );
990 }
991
992 #[rstest]
993 #[case("private_key")]
994 #[case("api_key")]
995 #[case("api_secret")]
996 #[case("passphrase")]
997 #[case("funder")]
998 fn test_session_requires_explicit_credentials(#[case] missing: &str) {
999 let mut config = PolymarketExecutionClientConfig {
1000 signer_type: PolymarketSignerType::Session,
1001 signature_type: PolymarketSignatureType::Poly1271,
1002 private_key: Some("key".into()),
1003 api_key: Some("api".into()),
1004 api_secret: Some("secret".into()),
1005 passphrase: Some("pass".into()),
1006 funder: Some("wallet".into()),
1007 ..Default::default()
1008 };
1009
1010 assert!(config.validate_signer().is_ok());
1011
1012 match missing {
1013 "private_key" => config.private_key = None,
1014 "api_key" => config.api_key = None,
1015 "api_secret" => config.api_secret = None,
1016 "passphrase" => config.passphrase = Some(" ".into()),
1017 "funder" => config.funder = Some(" ".into()),
1018 _ => unreachable!(),
1019 }
1020
1021 assert!(config.validate_signer().is_err());
1022 }
1023
1024 #[rstest]
1025 #[case(PolymarketSignatureType::Eoa)]
1026 #[case(PolymarketSignatureType::PolyProxy)]
1027 #[case(PolymarketSignatureType::PolyGnosisSafe)]
1028 fn test_session_rejects_other_signature_types(#[case] signature_type: PolymarketSignatureType) {
1029 let config = PolymarketExecutionClientConfig {
1030 signer_type: PolymarketSignerType::Session,
1031 signature_type,
1032 ..Default::default()
1033 };
1034
1035 assert_eq!(
1036 config.validate_signer().unwrap_err().to_string(),
1037 "Session signers require POLY_1271"
1038 );
1039 }
1040
1041 #[rstest]
1042 fn polybolt_credential_resolves_explicit_fields() {
1043 let config = PolymarketDataClientConfig {
1044 api_key: Some(SecretString::from("fixture-key")),
1045 api_secret: Some(SecretString::from("Zml4dHVyZS1zZWNyZXQ=")),
1046 passphrase: Some(SecretString::from("fixture-passphrase")),
1047 ..PolymarketDataClientConfig::default()
1048 };
1049
1050 let credential = config
1051 .polybolt_credential()
1052 .expect("explicit credentials resolve")
1053 .expect("credential present");
1054
1055 assert_eq!(credential.api_key_str(), "fixture-key");
1056 }
1057
1058 #[rstest]
1059 fn polybolt_credential_rejects_malformed_explicit_secret() {
1060 let config = PolymarketDataClientConfig {
1061 api_key: Some(SecretString::from("fixture-key")),
1062 api_secret: Some(SecretString::from("not base64!")),
1063 passphrase: Some(SecretString::from("fixture-passphrase")),
1064 ..PolymarketDataClientConfig::default()
1065 };
1066
1067 let error = config.polybolt_credential().unwrap_err().to_string();
1068
1069 assert!(
1070 error.starts_with("Invalid Polymarket CLOB credentials for PolyBolt"),
1071 "{error}"
1072 );
1073 }
1074}