Skip to main content

nautilus_polymarket/
config.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Configuration structures for the Polymarket adapter.
17
18use 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/// Rust-backed event slug builder for Polymarket Up/Down markets.
56///
57/// Up/Down event slugs follow the pattern
58/// `{asset}-updown-{interval_mins}m-{unix_timestamp}`, where the timestamp is
59/// aligned to the start of the interval. The builder emits slugs for each
60/// configured asset and period.
61#[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    /// Asset codes used in the slug prefix.
73    #[builder(default = default_updown_assets())]
74    pub assets: Vec<String>,
75    /// Up/Down interval in minutes.
76    #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
77    pub interval_mins: u64,
78    /// Number of periods to generate.
79    #[builder(default = DEFAULT_UPDOWN_PERIODS)]
80    pub periods: u64,
81    /// Offset from the current aligned period.
82    #[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    /// Builds event slugs using the current system time.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the interval or period count is zero, all assets are
106    /// blank, or the configured offset resolves before the Unix epoch.
107    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/// Configuration for the Polymarket instrument provider.
172///
173/// This mirrors the Python adapter's `instrument_config` layering so scoped
174/// market bootstrap can migrate naturally to the Rust/pyO3 live path.
175#[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    /// Whether all venue instruments should be loaded on startup.
187    #[builder(default)]
188    pub load_all: bool,
189    /// Optional instrument IDs to load on startup instead of a full bootstrap.
190    pub load_ids: Option<Vec<InstrumentId>>,
191    /// Optional Gamma-style query filters encoded as string key/value pairs.
192    pub filters: Option<HashMap<String, String>>,
193    /// Optional static event slugs to resolve to markets during bootstrap.
194    pub event_slugs: Option<Vec<String>>,
195    /// Optional static market slugs to load directly during bootstrap.
196    pub market_slugs: Option<Vec<String>>,
197    /// Optional Rust-backed Up/Down event slug builder.
198    pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
199    /// Optional Gamma series IDs whose active events resolve to markets during bootstrap.
200    pub series_ids: Option<Vec<u64>>,
201    /// Whether provider warnings should be logged.
202    #[builder(default = true)]
203    pub log_warnings: bool,
204    /// Compatibility field matching the Python adapter. The Rust provider
205    /// already uses the Gamma API for bootstrap, so this currently has no
206    /// behavioral effect beyond configuration parity.
207    #[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    /// Returns whether any configured scope drives a bootstrap load.
237    #[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    /// Returns whether any explicit bootstrap scope (slug, builder, or series) is configured.
243    #[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/// Configuration for the Polymarket data client.
268///
269/// `filters` and `new_market_filter` hold `Arc<dyn InstrumentFilter>` trait objects
270/// and are skipped during serialization; they default to empty/`None` and must be
271/// installed programmatically after deserialization.
272#[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    /// Instrument filters applied to all instruments during loading and discovery.
285    #[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    /// Optional HTTP or HTTPS proxy URL for all HTTP and WebSocket transports.
294    pub proxy_url: Option<SecretString>,
295    /// HTTP timeout in seconds.
296    #[builder(default = 60)]
297    pub http_timeout_secs: u64,
298    /// WebSocket timeout in seconds.
299    #[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    /// Instrument reload interval in minutes.
304    pub update_instruments_interval_mins: Option<u64>,
305    /// Whether to subscribe to new-market discovery, resolution, and best-bid/ask events.
306    #[builder(default)]
307    pub subscribe_new_markets: bool,
308    /// Optional filter applied to newly discovered markets before instrument emission.
309    #[serde(skip)]
310    pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
311    /// Maximum concurrent instrument fetches spawned from `new_market` events.
312    ///
313    /// This bounds adapter-side fan-out during event bursts and prevents
314    /// request storms against Gamma.
315    #[builder(default = 8)]
316    pub new_market_fetch_max_concurrency: usize,
317    /// Whether to drop quote ticks when bid or ask prices are missing.
318    #[builder(default = true)]
319    pub drop_quotes_missing_side: bool,
320    /// Whether to maintain local book state and emit only the net changes from
321    /// book snapshots, at an additional CPU and memory cost.
322    #[builder(default)]
323    pub compute_effective_deltas: bool,
324    /// Whether subscribe and request commands referencing an unknown instrument should
325    /// trigger an ad-hoc load via the instrument provider. Concurrent misses within
326    /// `auto_load_debounce_ms` are coalesced into a single batched request.
327    #[builder(default = true)]
328    pub auto_load_missing_instruments: bool,
329    /// The window (milliseconds) over which concurrent auto-load requests are batched.
330    #[builder(default = 100)]
331    pub auto_load_debounce_ms: u64,
332    /// Maximum retry attempts on transient auto-load failures (markets in the CLOB
333    /// hydration window that return empty `clob_token_ids` from Gamma, or that are
334    /// absent from the bulk response). Set to `0` to disable retry.
335    #[builder(default = 12)]
336    pub auto_load_max_retries: u32,
337    /// Initial delay (seconds) between transient auto-load retries; backed off
338    /// exponentially with positive jitter up to `auto_load_retry_delay_max_secs`.
339    #[builder(default = 5.0)]
340    pub auto_load_retry_delay_initial_secs: f64,
341    /// Maximum delay (seconds) between transient auto-load retries.
342    #[builder(default = 15.0)]
343    pub auto_load_retry_delay_max_secs: f64,
344    /// Whether automatic resolve polling is enabled.
345    #[builder(default = true)]
346    pub resolve_poll_enabled: bool,
347    /// Fixed interval between resolve poll cycles in seconds.
348    #[builder(default = 30)]
349    pub resolve_poll_interval_secs: u64,
350    /// Grace period after expiration before a market becomes resolve poll eligible.
351    #[builder(default = 10)]
352    pub resolve_poll_grace_secs: u64,
353    /// Maximum number of seconds to keep auto-polling after expiration before pausing.
354    #[builder(default = 1800)]
355    pub resolve_poll_max_wait_secs: u64,
356    /// WebSocket transport backend (defaults to `Sockudo`).
357    #[builder(default)]
358    pub transport_backend: TransportBackend,
359    /// Maximum time to wait for a post-reconnect or recovery order book snapshot
360    /// in seconds.
361    ///
362    /// Set to 0 to wait indefinitely: reconnected books stay gated until the
363    /// venue replays a snapshot, with no deadline monitor.
364    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
365    pub book_snapshot_timeout_secs: u64,
366    /// Interval for checking order book feed staleness in seconds.
367    #[builder(default = 5)]
368    pub book_stale_check_interval_secs: u64,
369    /// Maximum time without order book updates before emitting a stale signal in seconds.
370    ///
371    /// Set to 0 to disable. Prediction markets go quiet for long stretches.
372    #[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    /// Returns the validated proxy URL, if configured.
423    ///
424    /// # Errors
425    ///
426    /// Returns an error when the URL is malformed, has no host, or does not use HTTP or HTTPS.
427    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/// Configuration for the Polymarket execution client.
473#[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    /// Falls back to `POLYMARKET_PK` env var.
487    pub private_key: Option<SecretString>,
488    /// Falls back to `POLYMARKET_API_KEY` env var.
489    pub api_key: Option<SecretString>,
490    /// Falls back to `POLYMARKET_API_SECRET` env var.
491    pub api_secret: Option<SecretString>,
492    /// Falls back to `POLYMARKET_PASSPHRASE` env var.
493    pub passphrase: Option<SecretString>,
494    /// Falls back to `POLYMARKET_FUNDER` env var.
495    pub funder: Option<String>,
496    #[builder(default = PolymarketSignatureType::Eoa)]
497    pub signature_type: PolymarketSignatureType,
498    /// Selects owner or delegated session signing.
499    #[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    /// Optional HTTP or HTTPS proxy URL for all HTTP and WebSocket transports.
505    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    /// Enables authenticated order-safety heartbeats.
515    #[builder(default)]
516    pub heartbeat_enabled: bool,
517    /// WebSocket transport backend (defaults to `Sockudo`).
518    #[builder(default)]
519    pub transport_backend: TransportBackend,
520    /// Same instrument provider configuration used by the data client.
521    ///
522    /// Reconciliation classifies unmapped records from `load_ids` on this
523    /// config. When that set is non-empty, venue records for other instruments
524    /// are out of scope. When this field is unset, or `load_ids` is unset or
525    /// empty, every record is in scope.
526    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    /// Returns the validated proxy URL, if configured.
590    ///
591    /// # Errors
592    ///
593    /// Returns an error when the URL is malformed, has no host, or does not use HTTP or HTTPS.
594    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    /// Returns provider `load_ids` used to classify unmapped reconciliation records.
617    #[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}