1use std::{
19 collections::HashMap,
20 fmt::Debug,
21 sync::Arc,
22 time::{SystemTime, UNIX_EPOCH},
23};
24
25use nautilus_common::config::{ConfigError, ConfigResult, check_range, check_unique_values};
26use nautilus_core::{datetime::SECONDS_IN_MINUTE, string::secret::SecretString};
27use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
28use nautilus_model::identifiers::{AccountId, InstrumentId};
29use nautilus_network::{
30 transport::TransportError,
31 websocket::{TransportBackend, proxy::ProxyUrl},
32};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36 common::{
37 credential::{Credential, credential_env_vars},
38 enums::{PolymarketSignatureType, PolymarketSignerType},
39 urls,
40 },
41 filters::InstrumentFilter,
42 providers::build_gamma_params_from_hashmap,
43};
44
45const DEFAULT_UPDOWN_INTERVAL_MINS: u64 = 5;
46const DEFAULT_UPDOWN_PERIODS: u64 = 3;
47
48fn validated_proxy_url(value: Option<&SecretString>) -> Result<Option<ProxyUrl>, TransportError> {
49 value
50 .map(|value| ProxyUrl::parse(value.expose_secret().to_owned()))
51 .transpose()
52}
53
54fn default_updown_assets() -> Vec<String> {
55 vec!["btc".to_string()]
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
65#[serde(default, deny_unknown_fields)]
66#[cfg_attr(
67 feature = "python",
68 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
69)]
70#[cfg_attr(
71 feature = "python",
72 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
73)]
74pub struct PolymarketUpDownEventSlugConfig {
75 #[builder(default = default_updown_assets())]
77 pub assets: Vec<String>,
78 #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
80 pub interval_mins: u64,
81 #[builder(default = DEFAULT_UPDOWN_PERIODS)]
83 pub periods: u64,
84 #[builder(default)]
86 pub start_offset_periods: i64,
87}
88
89#[cfg(feature = "python")]
90nautilus_core::impl_pyo3_config_getters!(PolymarketUpDownEventSlugConfig {
91 assets: Vec<String>,
92 interval_mins: u64,
93 periods: u64,
94 start_offset_periods: i64,
95});
96
97impl Default for PolymarketUpDownEventSlugConfig {
98 fn default() -> Self {
99 Self::builder().build()
100 }
101}
102
103impl PolymarketUpDownEventSlugConfig {
104 pub fn validate(&self) -> ConfigResult<()> {
114 self.validated_assets().map(|_| ())
115 }
116
117 pub fn build_event_slugs(&self) -> anyhow::Result<Vec<String>> {
124 let now = SystemTime::now()
125 .duration_since(UNIX_EPOCH)
126 .map_err(|e| anyhow::anyhow!("system clock before Unix epoch: {e}"))?
127 .as_secs();
128 self.build_event_slugs_at_unix_secs(now)
129 }
130
131 fn build_event_slugs_at_unix_secs(&self, unix_secs: u64) -> anyhow::Result<Vec<String>> {
132 let assets = self.validated_assets()?;
133 let period_secs = self.interval_mins * SECONDS_IN_MINUTE;
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 validated_assets(&self) -> ConfigResult<Vec<String>> {
158 check_range(
159 "event_slug_builder.interval_mins",
160 self.interval_mins > 0 && self.interval_mins <= u64::MAX / SECONDS_IN_MINUTE,
161 "must be positive and fit in seconds",
162 )?;
163 check_range(
164 "event_slug_builder.periods",
165 self.periods > 0,
166 "must be positive",
167 )?;
168
169 check_unique_values("event_slug_builder.assets", &self.assets)?;
170 let mut assets = Vec::new();
171
172 for asset in &self.assets {
173 let asset = asset.trim().to_ascii_lowercase();
174 if asset.is_empty() {
175 continue;
176 }
177 assets.push(asset);
178 }
179
180 check_unique_values("event_slug_builder.assets", &assets)?;
181 if assets.is_empty() {
182 return Err(ConfigError::empty_field("event_slug_builder.assets"));
183 }
184
185 Ok(assets)
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
194#[serde(default, deny_unknown_fields)]
195#[cfg_attr(
196 feature = "python",
197 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
198)]
199#[cfg_attr(
200 feature = "python",
201 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
202)]
203pub struct PolymarketInstrumentProviderConfig {
204 #[builder(default)]
206 pub load_all: bool,
207 pub load_ids: Option<Vec<InstrumentId>>,
209 pub filters: Option<HashMap<String, String>>,
211 pub event_slugs: Option<Vec<String>>,
213 pub market_slugs: Option<Vec<String>>,
215 pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
217 pub series_ids: Option<Vec<u64>>,
219 #[builder(default = true)]
221 pub log_warnings: bool,
222 #[builder(default)]
226 pub use_gamma_markets: bool,
227}
228
229#[cfg(feature = "python")]
230nautilus_core::impl_pyo3_config_getters!(PolymarketInstrumentProviderConfig {
231 load_all: bool,
232 load_ids: Option<Vec<InstrumentId>>,
233 filters: Option<HashMap<String, String>>,
234 event_slugs: Option<Vec<String>>,
235 market_slugs: Option<Vec<String>>,
236 event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
237 series_ids: Option<Vec<u64>>,
238 log_warnings: bool,
239 use_gamma_markets: bool,
240});
241
242impl Default for PolymarketInstrumentProviderConfig {
243 fn default() -> Self {
244 Self::builder().build()
245 }
246}
247
248impl PolymarketInstrumentProviderConfig {
249 #[must_use]
250 pub fn new() -> Self {
251 Self::default()
252 }
253
254 pub fn validate(&self) -> anyhow::Result<()> {
260 if let Some(values) = &self.load_ids {
261 check_unique_values("load_ids", values)?;
262 }
263
264 if let Some(values) = &self.event_slugs {
265 check_unique_values("event_slugs", values)?;
266 }
267
268 if let Some(values) = &self.market_slugs {
269 check_unique_values("market_slugs", values)?;
270 }
271
272 if let Some(values) = &self.series_ids {
273 check_unique_values("series_ids", values)?;
274 }
275
276 if let Some(builder) = &self.event_slug_builder {
277 builder.validate()?;
278 }
279
280 if let Some(filters) = &self.filters {
281 let params = build_gamma_params_from_hashmap(filters)?;
282 check_unique_values("filters.id", params.id.as_deref().unwrap_or_default())?;
283 check_unique_values(
284 "filters.tag_id",
285 params.tag_id.as_deref().unwrap_or_default(),
286 )?;
287
288 for (field, values) in [
289 ("filters.slug", params.slug.as_deref()),
290 ("filters.condition_ids", params.condition_ids.as_deref()),
291 ("filters.clob_token_ids", params.clob_token_ids.as_deref()),
292 ("filters.question_ids", params.question_ids.as_deref()),
293 (
294 "filters.market_maker_address",
295 params.market_maker_address.as_deref(),
296 ),
297 (
298 "filters.sports_market_types",
299 params.sports_market_types.as_deref(),
300 ),
301 ] {
302 check_unique_values(field, values.unwrap_or_default())?;
303 }
304 }
305
306 Ok(())
307 }
308
309 #[must_use]
311 pub fn should_load_all(&self) -> bool {
312 self.load_all || self.has_explicit_scope() || self.has_nonempty_filters()
313 }
314
315 #[must_use]
317 pub fn has_explicit_scope(&self) -> bool {
318 self.event_slug_builder.is_some()
319 || self.event_slugs.as_ref().is_some_and(|s| !s.is_empty())
320 || self.market_slugs.as_ref().is_some_and(|s| !s.is_empty())
321 || self.has_series_ids()
322 }
323
324 #[must_use]
325 pub fn has_series_ids(&self) -> bool {
326 self.series_ids.as_ref().is_some_and(|ids| !ids.is_empty())
327 }
328
329 #[must_use]
330 pub fn has_nonempty_filters(&self) -> bool {
331 self.filters.as_ref().is_some_and(|map| !map.is_empty())
332 }
333
334 #[must_use]
335 pub fn has_load_ids(&self) -> bool {
336 self.load_ids.as_ref().is_some_and(|ids| !ids.is_empty())
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
346#[serde(default, deny_unknown_fields)]
347#[cfg_attr(
348 feature = "python",
349 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
350)]
351#[cfg_attr(
352 feature = "python",
353 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
354)]
355pub struct PolymarketDataClientConfig {
356 pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
357 #[builder(default)]
359 #[serde(skip)]
360 pub filters: Vec<Arc<dyn InstrumentFilter>>,
361 pub base_url_http: Option<String>,
362 pub base_url_ws: Option<String>,
363 pub base_url_reference_prices: Option<String>,
364 pub base_url_gamma: Option<String>,
365 pub api_key: Option<SecretString>,
367 pub api_secret: Option<SecretString>,
370 pub passphrase: Option<SecretString>,
373 pub base_url_data_api: Option<String>,
374 pub proxy_url: Option<SecretString>,
376 #[builder(default = 60)]
378 pub http_timeout_secs: u64,
379 #[builder(default = 30)]
383 pub ws_timeout_secs: u64,
384 #[builder(default = crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS)]
385 pub ws_max_subscriptions: usize,
386 pub update_instruments_interval_mins: Option<u64>,
388 #[builder(default)]
390 pub subscribe_new_markets: bool,
391 #[serde(skip)]
393 pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
394 #[builder(default = 8)]
399 pub new_market_fetch_max_concurrency: usize,
400 #[builder(default = true)]
402 pub drop_quotes_missing_side: bool,
403 #[builder(default)]
406 pub compute_effective_deltas: bool,
407 #[builder(default = true)]
411 pub auto_load_missing_instruments: bool,
412 #[builder(default = 100)]
414 pub auto_load_debounce_ms: u64,
415 #[builder(default = 12)]
419 pub auto_load_max_retries: u32,
420 #[builder(default = 5.0)]
423 pub auto_load_retry_delay_initial_secs: f64,
424 #[builder(default = 15.0)]
426 pub auto_load_retry_delay_max_secs: f64,
427 #[builder(default = true)]
429 pub resolve_poll_enabled: bool,
430 #[builder(default = 30)]
432 pub resolve_poll_interval_secs: u64,
433 #[builder(default = 10)]
435 pub resolve_poll_grace_secs: u64,
436 #[builder(default = 1800)]
438 pub resolve_poll_max_wait_secs: u64,
439 #[builder(default)]
441 pub transport_backend: TransportBackend,
442 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
449 pub book_snapshot_timeout_secs: u64,
450 #[builder(default = 5)]
452 pub book_stale_check_interval_secs: u64,
453 #[builder(default)]
457 pub book_stale_threshold_secs: u64,
458}
459
460#[cfg(feature = "python")]
461nautilus_core::impl_pyo3_config_getters!(PolymarketDataClientConfig {
462 instrument_config: Option<PolymarketInstrumentProviderConfig>,
463 base_url_http: Option<String>,
464 base_url_ws: Option<String>,
465 base_url_gamma: Option<String>,
466 base_url_data_api: Option<String>,
467 http_timeout_secs: u64,
468 ws_timeout_secs: u64,
469 ws_max_subscriptions: usize,
470 update_instruments_interval_mins: Option<u64>,
471 subscribe_new_markets: bool,
472 auto_load_missing_instruments: bool,
473 auto_load_debounce_ms: u64,
474 auto_load_max_retries: u32,
475 auto_load_retry_delay_initial_secs: f64,
476 auto_load_retry_delay_max_secs: f64,
477 new_market_fetch_max_concurrency: usize,
478 resolve_poll_enabled: bool,
479 resolve_poll_interval_secs: u64,
480 resolve_poll_grace_secs: u64,
481 resolve_poll_max_wait_secs: u64,
482 base_url_reference_prices: Option<String>,
483 transport_backend: TransportBackend,
484 drop_quotes_missing_side: bool,
485 compute_effective_deltas: bool,
486 book_snapshot_timeout_secs: u64,
487 book_stale_check_interval_secs: u64,
488 book_stale_threshold_secs: u64,
489});
490
491impl Default for PolymarketDataClientConfig {
492 fn default() -> Self {
493 Self {
494 update_instruments_interval_mins: Some(60),
495 ..Self::builder().build()
496 }
497 }
498}
499
500impl PolymarketDataClientConfig {
501 #[must_use]
502 pub fn new() -> Self {
503 Self::default()
504 }
505
506 pub fn validate(&self) -> anyhow::Result<()> {
513 check_range(
514 "ws_timeout_secs",
515 self.ws_timeout_secs > 0,
516 "must be positive",
517 )?;
518
519 if let Some(config) = &self.instrument_config {
520 config.validate()?;
521 }
522
523 Ok(())
524 }
525
526 pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
532 validated_proxy_url(self.proxy_url.as_ref())
533 }
534
535 #[must_use]
536 pub const fn has_proxy_url(&self) -> bool {
537 self.proxy_url.is_some()
538 }
539
540 #[must_use]
541 pub fn http_url(&self) -> String {
542 self.base_url_http
543 .clone()
544 .unwrap_or_else(|| urls::clob_http_url().to_string())
545 }
546
547 #[must_use]
548 pub fn ws_url(&self) -> String {
549 self.base_url_ws
550 .clone()
551 .unwrap_or_else(|| urls::clob_ws_url().to_string())
552 }
553
554 #[must_use]
555 pub fn reference_prices_url(&self) -> String {
556 self.base_url_reference_prices
557 .clone()
558 .unwrap_or_else(|| urls::polybolt_ws_url().to_string())
559 }
560
561 pub fn polybolt_credential(&self) -> anyhow::Result<Option<Credential>> {
573 let explicit =
574 self.api_key.is_some() || self.api_secret.is_some() || self.passphrase.is_some();
575
576 if !explicit {
577 let (api_key_var, api_secret_var, passphrase_var, _, _) = credential_env_vars();
578 let present: Vec<&str> = [api_key_var, api_secret_var, passphrase_var]
579 .into_iter()
580 .filter(|var| std::env::var(var).is_ok_and(|value| !value.trim().is_empty()))
581 .collect();
582
583 if present.is_empty() {
584 return Ok(None);
585 }
586
587 if present.len() < 3 {
588 log::warn!(
589 "Ignoring partial Polymarket CLOB credentials in the environment \
590 (set: {present:?}); PolyBolt price subscriptions will be rejected"
591 );
592 return Ok(None);
593 }
594 }
595
596 Credential::resolve(
597 self.api_key.clone(),
598 self.api_secret.clone(),
599 self.passphrase.clone(),
600 )
601 .map(Some)
602 .map_err(|e| anyhow::anyhow!("Invalid Polymarket CLOB credentials for PolyBolt: {e}"))
603 }
604
605 #[must_use]
606 pub fn gamma_url(&self) -> String {
607 self.base_url_gamma
608 .clone()
609 .unwrap_or_else(|| urls::gamma_api_url().to_string())
610 }
611
612 #[must_use]
613 pub fn data_api_url(&self) -> String {
614 self.base_url_data_api
615 .clone()
616 .unwrap_or_else(|| urls::data_api_url().to_string())
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
622#[serde(default, deny_unknown_fields)]
623#[cfg_attr(
624 feature = "python",
625 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
626)]
627#[cfg_attr(
628 feature = "python",
629 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
630)]
631pub struct PolymarketExecutionClientConfig {
632 #[builder(default = AccountId::from("POLYMARKET-001"))]
633 pub account_id: AccountId,
634 pub private_key: Option<SecretString>,
636 pub api_key: Option<SecretString>,
638 pub api_secret: Option<SecretString>,
640 pub passphrase: Option<SecretString>,
642 pub funder: Option<String>,
644 #[builder(default = PolymarketSignatureType::Eoa)]
645 pub signature_type: PolymarketSignatureType,
646 #[builder(default)]
648 pub signer_type: PolymarketSignerType,
649 pub base_url_http: Option<String>,
650 pub base_url_ws: Option<String>,
651 pub base_url_data_api: Option<String>,
652 pub proxy_url: Option<SecretString>,
654 #[builder(default = 60)]
655 pub http_timeout_secs: u64,
656 #[builder(default = 3)]
657 pub max_retries: u32,
658 #[builder(default = 1000)]
659 pub retry_delay_initial_ms: u64,
660 #[builder(default = 10000)]
661 pub retry_delay_max_ms: u64,
662 #[builder(default)]
664 pub heartbeat_enabled: bool,
665 #[builder(default)]
667 pub transport_backend: TransportBackend,
668 pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
675}
676
677#[cfg(feature = "python")]
678nautilus_core::impl_pyo3_config_getters!(PolymarketExecutionClientConfig {
679 account_id: AccountId,
680 funder: Option<String>,
681 signature_type: PolymarketSignatureType,
682 signer_type: PolymarketSignerType,
683 base_url_http: Option<String>,
684 base_url_ws: Option<String>,
685 base_url_data_api: Option<String>,
686 http_timeout_secs: u64,
687 max_retries: u32,
688 retry_delay_initial_ms: u64,
689 retry_delay_max_ms: u64,
690 heartbeat_enabled: bool,
691 transport_backend: TransportBackend,
692 instrument_config: Option<PolymarketInstrumentProviderConfig>,
693});
694
695impl Default for PolymarketExecutionClientConfig {
696 fn default() -> Self {
697 Self::builder().build()
698 }
699}
700
701impl PolymarketExecutionClientConfig {
702 #[must_use]
703 pub fn new() -> Self {
704 Self::default()
705 }
706
707 pub fn validate(&self) -> anyhow::Result<()> {
714 if let Some(config) = &self.instrument_config {
715 config.validate()?;
716 }
717
718 Ok(())
719 }
720
721 pub(crate) fn validate_signer(&self) -> anyhow::Result<()> {
722 if self.signer_type == PolymarketSignerType::Session {
723 anyhow::ensure!(
724 self.signature_type == PolymarketSignatureType::Poly1271,
725 "Session signers require POLY_1271"
726 );
727
728 for (name, value) in [
729 ("private_key", self.private_key.as_ref()),
730 ("api_key", self.api_key.as_ref()),
731 ("api_secret", self.api_secret.as_ref()),
732 ("passphrase", self.passphrase.as_ref()),
733 ] {
734 anyhow::ensure!(
735 value.is_some_and(|value| !value.expose_secret().trim().is_empty()),
736 "Session signers require explicit {name}; environment fallback is disabled"
737 );
738 }
739
740 anyhow::ensure!(
741 self.funder
742 .as_ref()
743 .is_some_and(|value| !value.trim().is_empty()),
744 "Session signers require an explicit Deposit Wallet funder"
745 );
746 }
747
748 Ok(())
749 }
750
751 pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
757 validated_proxy_url(self.proxy_url.as_ref())
758 }
759
760 #[must_use]
761 pub const fn has_proxy_url(&self) -> bool {
762 self.proxy_url.is_some()
763 }
764
765 #[must_use]
766 pub fn has_credentials(&self) -> bool {
767 self.private_key
768 .as_ref()
769 .map(SecretString::expose_secret)
770 .is_some_and(|s| !s.trim().is_empty())
771 || self
772 .api_key
773 .as_ref()
774 .map(SecretString::expose_secret)
775 .is_some_and(|s| !s.trim().is_empty())
776 }
777
778 #[must_use]
780 pub fn reconciliation_load_ids(&self) -> Option<&[InstrumentId]> {
781 self.instrument_config
782 .as_ref()
783 .and_then(|config| config.load_ids.as_deref())
784 }
785
786 #[must_use]
787 pub fn http_url(&self) -> String {
788 self.base_url_http
789 .clone()
790 .unwrap_or_else(|| urls::clob_http_url().to_string())
791 }
792
793 #[must_use]
794 pub fn ws_url(&self) -> String {
795 self.base_url_ws
796 .clone()
797 .unwrap_or_else(|| urls::clob_ws_url().to_string())
798 }
799
800 #[must_use]
801 pub fn data_api_url(&self) -> String {
802 self.base_url_data_api
803 .clone()
804 .unwrap_or_else(|| urls::data_api_url().to_string())
805 }
806}
807
808#[cfg(test)]
809mod tests {
810 use nautilus_common::config::ConfigError;
811 use rstest::rstest;
812
813 use super::*;
814
815 #[rstest]
816 fn updown_event_slug_config_builds_aligned_slugs() {
817 let config = PolymarketUpDownEventSlugConfig {
818 assets: vec!["BTC".to_string(), " eth ".to_string(), String::new()],
819 interval_mins: 5,
820 periods: 2,
821 start_offset_periods: -1,
822 };
823
824 let slugs = config
825 .build_event_slugs_at_unix_secs(1_700_000_123)
826 .expect("event slugs should build");
827
828 assert_eq!(
829 slugs,
830 [
831 "btc-updown-5m-1699999800",
832 "eth-updown-5m-1699999800",
833 "btc-updown-5m-1700000100",
834 "eth-updown-5m-1700000100",
835 ]
836 );
837 }
838
839 #[rstest]
840 fn updown_event_slug_config_rejects_zero_interval() {
841 let config = PolymarketUpDownEventSlugConfig {
842 interval_mins: 0,
843 ..PolymarketUpDownEventSlugConfig::default()
844 };
845
846 let err = config
847 .build_event_slugs_at_unix_secs(1_700_000_123)
848 .expect_err("zero interval should fail");
849
850 assert_eq!(
851 err.downcast_ref::<ConfigError>(),
852 Some(&ConfigError::range(
853 "event_slug_builder.interval_mins",
854 "must be positive and fit in seconds"
855 )),
856 );
857 }
858
859 #[rstest]
860 fn provider_config_series_ids_trigger_load_all() {
861 let config = PolymarketInstrumentProviderConfig {
862 series_ids: Some(vec![10684]),
863 ..PolymarketInstrumentProviderConfig::default()
864 };
865
866 assert!(config.has_series_ids());
867 assert!(config.should_load_all());
868 }
869
870 #[rstest]
871 fn provider_config_empty_series_ids_do_not_trigger_load_all() {
872 let config = PolymarketInstrumentProviderConfig {
873 series_ids: Some(Vec::new()),
874 ..PolymarketInstrumentProviderConfig::default()
875 };
876
877 assert!(!config.has_series_ids());
878 assert!(!config.should_load_all());
879 }
880
881 #[rstest]
882 fn provider_config_filters_trigger_load_all() {
883 let config = PolymarketInstrumentProviderConfig {
884 filters: Some(HashMap::from([("tag_id".to_string(), "84".to_string())])),
885 ..PolymarketInstrumentProviderConfig::default()
886 };
887
888 assert!(config.has_nonempty_filters());
889 assert!(!config.has_explicit_scope());
890 assert!(config.should_load_all());
891 }
892
893 #[rstest]
894 fn data_config_book_sync_defaults_match_documented_values() {
895 let config = PolymarketDataClientConfig::default();
896
897 assert_eq!(config.book_snapshot_timeout_secs, 10);
898 assert_eq!(config.book_stale_check_interval_secs, 5);
899 assert_eq!(
900 config.book_stale_threshold_secs, 0,
901 "stale monitor must stay disabled by default"
902 );
903 }
904
905 #[rstest]
906 fn provider_config_empty_filters_do_not_trigger_load_all() {
907 let config = PolymarketInstrumentProviderConfig {
908 filters: Some(HashMap::new()),
909 ..PolymarketInstrumentProviderConfig::default()
910 };
911
912 assert!(!config.has_nonempty_filters());
913 assert!(!config.should_load_all());
914 }
915
916 #[rstest]
917 fn test_data_config_toml_minimal() {
918 let config: PolymarketDataClientConfig = toml::from_str(
919 "
920http_timeout_secs = 30
921ws_max_subscriptions = 50
922update_instruments_interval_mins = 5
923subscribe_new_markets = true
924new_market_fetch_max_concurrency = 16
925auto_load_debounce_ms = 250
926resolve_poll_enabled = true
927resolve_poll_interval_secs = 30
928resolve_poll_grace_secs = 10
929resolve_poll_max_wait_secs = 1800
930",
931 )
932 .unwrap();
933
934 assert!(config.instrument_config.is_none());
935 assert!(config.filters.is_empty());
936 assert_eq!(config.http_timeout_secs, 30);
937 assert_eq!(config.ws_max_subscriptions, 50);
938 assert_eq!(config.update_instruments_interval_mins, Some(5));
939 assert!(config.subscribe_new_markets);
940 assert!(config.new_market_filter.is_none());
941 assert_eq!(config.new_market_fetch_max_concurrency, 16);
942 assert_eq!(config.auto_load_debounce_ms, 250);
943 assert!(config.resolve_poll_enabled);
944 assert_eq!(config.resolve_poll_interval_secs, 30);
945 assert_eq!(config.resolve_poll_grace_secs, 10);
946 assert_eq!(config.resolve_poll_max_wait_secs, 1800);
947 assert!(config.drop_quotes_missing_side);
948 assert!(!config.compute_effective_deltas);
949 }
950
951 #[rstest]
952 fn test_data_config_toml_sets_compute_effective_deltas() {
953 let config: PolymarketDataClientConfig =
954 toml::from_str("compute_effective_deltas = true").unwrap();
955
956 assert!(config.compute_effective_deltas);
957 }
958
959 #[rstest]
960 fn test_data_config_toml_sets_drop_quotes_missing_side_false() {
961 let config: PolymarketDataClientConfig =
962 toml::from_str("drop_quotes_missing_side = false").unwrap();
963
964 assert!(!config.drop_quotes_missing_side);
965 }
966
967 #[rstest]
968 fn test_data_config_toml_with_instrument_config() {
969 let config: PolymarketDataClientConfig = toml::from_str(
970 r#"
971[instrument_config]
972load_all = true
973event_slugs = ["btc-updown-5m-123", "eth-updown-15m-456"]
974log_warnings = false
975"#,
976 )
977 .unwrap();
978
979 let instrument_config = config.instrument_config.expect("instrument_config");
980 assert!(instrument_config.load_all);
981 assert_eq!(
982 instrument_config.event_slugs,
983 Some(vec![
984 "btc-updown-5m-123".to_string(),
985 "eth-updown-15m-456".to_string(),
986 ]),
987 );
988 assert!(!instrument_config.log_warnings);
989 }
990
991 #[rstest]
992 fn test_exec_config_toml_empty_uses_defaults() {
993 let config: PolymarketExecutionClientConfig = toml::from_str("").unwrap();
994 let expected = PolymarketExecutionClientConfig::default();
995 assert_eq!(config.account_id, expected.account_id);
996 assert_eq!(config.signature_type, expected.signature_type);
997 assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
998 assert_eq!(config.max_retries, expected.max_retries);
999 assert!(!config.heartbeat_enabled);
1000 assert_eq!(config.transport_backend, expected.transport_backend);
1001 assert!(config.instrument_config.is_none());
1002 assert!(config.reconciliation_load_ids().is_none());
1003 }
1004
1005 #[rstest]
1006 fn test_exec_config_reconciliation_load_ids_come_from_instrument_config() {
1007 let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
1008 let config: PolymarketExecutionClientConfig = toml::from_str(
1009 r#"
1010[instrument_config]
1011load_ids = ["0xabc-123.POLYMARKET"]
1012"#,
1013 )
1014 .unwrap();
1015
1016 assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
1017 }
1018
1019 #[rstest]
1020 fn test_data_config_proxy_url_validates_and_redacts_debug() {
1021 const SECRET: &str = "data-proxy-secret";
1022 let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18081");
1023 let config: PolymarketDataClientConfig =
1024 toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
1025 .expect("deserialize data config");
1026 let validated = config
1027 .validated_proxy_url()
1028 .expect("validate data proxy")
1029 .expect("data proxy configured");
1030 let debug = format!("{config:?}");
1031
1032 assert_eq!(validated.expose(), proxy_url);
1033 assert!(config.has_proxy_url());
1034 assert!(debug.contains("proxy_url: Some(<redacted>)"));
1035 assert!(!debug.contains(SECRET));
1036 }
1037
1038 #[rstest]
1039 fn test_exec_config_proxy_url_validates_and_redacts_debug() {
1040 const SECRET: &str = "exec-proxy-secret";
1041 let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18082");
1042 let config: PolymarketExecutionClientConfig =
1043 toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
1044 .expect("deserialize execution config");
1045 let validated = config
1046 .validated_proxy_url()
1047 .expect("validate execution proxy")
1048 .expect("execution proxy configured");
1049 let debug = format!("{config:?}");
1050
1051 assert_eq!(validated.expose(), proxy_url);
1052 assert!(config.has_proxy_url());
1053 assert!(debug.contains("proxy_url: Some(<redacted>)"));
1054 assert!(!debug.contains(SECRET));
1055 }
1056
1057 #[rstest]
1058 fn test_proxy_url_unset_preserves_direct_configuration() {
1059 let data_config = PolymarketDataClientConfig::default();
1060 let exec_config = PolymarketExecutionClientConfig::default();
1061
1062 assert_eq!(data_config.proxy_url, None);
1063 assert_eq!(exec_config.proxy_url, None);
1064 assert_eq!(data_config.validated_proxy_url().unwrap(), None);
1065 assert_eq!(exec_config.validated_proxy_url().unwrap(), None);
1066 assert!(!data_config.has_proxy_url());
1067 assert!(!exec_config.has_proxy_url());
1068 }
1069
1070 #[rstest]
1071 fn test_invalid_proxy_url_error_redacts_credentials() {
1072 const SECRET: &str = "invalid-proxy-secret";
1073 let config = PolymarketDataClientConfig {
1074 proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1").into()),
1075 ..PolymarketDataClientConfig::default()
1076 };
1077 let error = config
1078 .validated_proxy_url()
1079 .expect_err("malformed proxy URL should fail");
1080
1081 assert!(!error.to_string().contains(SECRET));
1082 }
1083
1084 #[rstest]
1085 fn test_socks_proxy_url_is_rejected_for_consistent_routing() {
1086 let config = PolymarketExecutionClientConfig {
1087 proxy_url: Some("socks5://127.0.0.1:1080".into()),
1088 ..PolymarketExecutionClientConfig::default()
1089 };
1090 let error = config
1091 .validated_proxy_url()
1092 .expect_err("SOCKS proxy should fail validation");
1093
1094 assert_eq!(
1095 error.to_string(),
1096 "invalid URL: SOCKS proxy scheme 'socks5' is not yet supported for WebSocket connections; use an http:// or https:// proxy"
1097 );
1098 }
1099
1100 #[rstest]
1101 #[case("private_key")]
1102 #[case("api_key")]
1103 #[case("api_secret")]
1104 #[case("passphrase")]
1105 #[case("funder")]
1106 fn test_session_requires_explicit_credentials(#[case] missing: &str) {
1107 let mut config = PolymarketExecutionClientConfig {
1108 signer_type: PolymarketSignerType::Session,
1109 signature_type: PolymarketSignatureType::Poly1271,
1110 private_key: Some("key".into()),
1111 api_key: Some("api".into()),
1112 api_secret: Some("secret".into()),
1113 passphrase: Some("pass".into()),
1114 funder: Some("wallet".into()),
1115 ..Default::default()
1116 };
1117
1118 assert!(config.validate_signer().is_ok());
1119
1120 match missing {
1121 "private_key" => config.private_key = None,
1122 "api_key" => config.api_key = None,
1123 "api_secret" => config.api_secret = None,
1124 "passphrase" => config.passphrase = Some(" ".into()),
1125 "funder" => config.funder = Some(" ".into()),
1126 _ => unreachable!(),
1127 }
1128
1129 assert!(config.validate_signer().is_err());
1130 }
1131
1132 #[rstest]
1133 #[case(PolymarketSignatureType::Eoa)]
1134 #[case(PolymarketSignatureType::PolyProxy)]
1135 #[case(PolymarketSignatureType::PolyGnosisSafe)]
1136 fn test_session_rejects_other_signature_types(#[case] signature_type: PolymarketSignatureType) {
1137 let config = PolymarketExecutionClientConfig {
1138 signer_type: PolymarketSignerType::Session,
1139 signature_type,
1140 ..Default::default()
1141 };
1142
1143 assert_eq!(
1144 config.validate_signer().unwrap_err().to_string(),
1145 "Session signers require POLY_1271"
1146 );
1147 }
1148
1149 #[rstest]
1150 fn polybolt_credential_resolves_explicit_fields() {
1151 let config = PolymarketDataClientConfig {
1152 api_key: Some(SecretString::from("fixture-key")),
1153 api_secret: Some(SecretString::from("Zml4dHVyZS1zZWNyZXQ=")),
1154 passphrase: Some(SecretString::from("fixture-passphrase")),
1155 ..PolymarketDataClientConfig::default()
1156 };
1157
1158 let credential = config
1159 .polybolt_credential()
1160 .expect("explicit credentials resolve")
1161 .expect("credential present");
1162
1163 assert_eq!(credential.api_key_str(), "fixture-key");
1164 }
1165
1166 #[rstest]
1167 fn polybolt_credential_rejects_malformed_explicit_secret() {
1168 let config = PolymarketDataClientConfig {
1169 api_key: Some(SecretString::from("fixture-key")),
1170 api_secret: Some(SecretString::from("not base64!")),
1171 passphrase: Some(SecretString::from("fixture-passphrase")),
1172 ..PolymarketDataClientConfig::default()
1173 };
1174
1175 let error = config.polybolt_credential().unwrap_err().to_string();
1176
1177 assert!(
1178 error.starts_with("Invalid Polymarket CLOB credentials for PolyBolt"),
1179 "{error}"
1180 );
1181 }
1182
1183 #[rstest]
1184 #[case("load_ids", serde_json::json!(["token-one.POLYMARKET", "token-two.POLYMARKET"]))]
1185 #[case("event_slugs", serde_json::json!(["event-one", "event-two"]))]
1186 #[case("market_slugs", serde_json::json!(["market-one", "market-two"]))]
1187 #[case("series_ids", serde_json::json!([1, 2]))]
1188 fn test_instrument_provider_selection_uniqueness(
1189 #[case] field: &str,
1190 #[case] mut values: serde_json::Value,
1191 #[values(false, true)] duplicate: bool,
1192 ) {
1193 if duplicate {
1194 let entries = values.as_array_mut().unwrap();
1195 entries.push(entries[0].clone());
1196 }
1197
1198 let mut input =
1199 serde_json::to_value(PolymarketInstrumentProviderConfig::default()).unwrap();
1200 input[field] = values;
1201 let config: PolymarketInstrumentProviderConfig = serde_json::from_value(input).unwrap();
1202 let original = serde_json::to_value(&config).unwrap();
1203
1204 let expected = if duplicate {
1205 Err(ConfigError::duplicate(field, None))
1206 } else {
1207 Ok(())
1208 };
1209
1210 let result = config
1211 .validate()
1212 .map_err(|e| e.downcast::<ConfigError>().unwrap());
1213
1214 assert_eq!(result, expected);
1215 assert_eq!(serde_json::to_value(&config).unwrap(), original);
1216 }
1217
1218 #[rstest]
1219 #[case::exact(vec!["btc".to_string(), "btc".to_string()])]
1220 #[case::normalized(vec!["BTC".to_string(), " btc ".to_string()])]
1221 fn test_slug_assets_reject_duplicates(#[case] assets: Vec<String>) {
1222 let config = PolymarketUpDownEventSlugConfig {
1223 assets,
1224 ..Default::default()
1225 };
1226
1227 let error = config
1228 .build_event_slugs_at_unix_secs(1_700_000_123)
1229 .unwrap_err();
1230
1231 assert_eq!(
1232 error.downcast_ref::<ConfigError>(),
1233 Some(&ConfigError::duplicate("event_slug_builder.assets", None))
1234 );
1235 }
1236
1237 #[rstest]
1238 #[case("slug", "market-a, market-b,market-a")]
1239 #[case("slug", " market-a,market-a ")]
1240 #[case("condition_ids", "condition-a,condition-b,condition-a")]
1241 #[case("clob_token_ids", "token-a,token-b,token-a")]
1242 #[case("question_ids", "question-a,question-b,question-a")]
1243 #[case("market_maker_address", "address-a,address-b,address-a")]
1244 #[case("sports_market_types", "type-a,type-b,type-a")]
1245 #[case("tag_id", "1,01")]
1246 #[case("id", "1,2,1")]
1247 #[case("id", "1,01")]
1248 fn test_gamma_filter_selection_duplicates(#[case] key: &str, #[case] values: &str) {
1249 let config = PolymarketInstrumentProviderConfig {
1250 filters: Some(std::collections::HashMap::from([(
1251 key.to_string(),
1252 values.to_string(),
1253 )])),
1254 ..Default::default()
1255 };
1256
1257 let error = config.validate().unwrap_err();
1258 assert_eq!(
1259 error.downcast_ref::<ConfigError>(),
1260 Some(&ConfigError::duplicate(format!("filters.{key}"), None))
1261 );
1262 }
1263
1264 #[rstest]
1265 fn test_execution_config_rejects_duplicate_provider_selections() {
1266 let config = PolymarketExecutionClientConfig {
1267 instrument_config: Some(PolymarketInstrumentProviderConfig {
1268 load_ids: Some(vec![
1269 InstrumentId::from("one.POLYMARKET"),
1270 InstrumentId::from("one.POLYMARKET"),
1271 ]),
1272 ..Default::default()
1273 }),
1274 ..Default::default()
1275 };
1276
1277 let error = config.validate().unwrap_err();
1278 assert_eq!(
1279 error.downcast_ref::<ConfigError>(),
1280 Some(&ConfigError::duplicate("load_ids", None))
1281 );
1282 }
1283
1284 #[rstest]
1285 #[case::interval_zero(0, 1, vec!["btc".to_string()], Err(ConfigError::range("event_slug_builder.interval_mins", "must be positive and fit in seconds")))]
1286 #[case::interval_overflow(u64::MAX, 1, vec!["btc".to_string()], Err(ConfigError::range("event_slug_builder.interval_mins", "must be positive and fit in seconds")))]
1287 #[case::periods_zero(5, 0, vec!["btc".to_string()], Err(ConfigError::range("event_slug_builder.periods", "must be positive")))]
1288 #[case::assets_empty(5, 1, vec![], Err(ConfigError::empty_field("event_slug_builder.assets")))]
1289 #[case::assets_blank(5, 1, vec![" ".to_string()], Err(ConfigError::empty_field("event_slug_builder.assets")))]
1290 #[case::interval_max(u64::MAX / SECONDS_IN_MINUTE, 1, vec!["btc".to_string()], Ok(()))]
1291 fn test_slug_builder_config_static_validation(
1292 #[case] interval_mins: u64,
1293 #[case] periods: u64,
1294 #[case] assets: Vec<String>,
1295 #[case] expected: ConfigResult<()>,
1296 ) {
1297 let config = PolymarketUpDownEventSlugConfig {
1298 assets,
1299 interval_mins,
1300 periods,
1301 ..Default::default()
1302 };
1303
1304 assert_eq!(config.validate(), expected);
1305 }
1306}