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::REDACTED;
26use nautilus_model::identifiers::{AccountId, InstrumentId};
27use nautilus_network::{
28    transport::TransportError,
29    websocket::{TransportBackend, proxy::ProxyUrl},
30};
31use serde::{Deserialize, Serialize};
32
33use crate::{
34    common::{enums::SignatureType, urls},
35    filters::InstrumentFilter,
36};
37
38const DEFAULT_UPDOWN_INTERVAL_MINS: u64 = 5;
39const DEFAULT_UPDOWN_PERIODS: u64 = 3;
40
41fn validated_proxy_url(value: Option<&String>) -> Result<Option<ProxyUrl>, TransportError> {
42    value.cloned().map(ProxyUrl::parse).transpose()
43}
44
45fn default_updown_assets() -> Vec<String> {
46    vec!["btc".to_string()]
47}
48
49/// Rust-backed event slug builder for Polymarket Up/Down markets.
50///
51/// Up/Down event slugs follow the pattern
52/// `{asset}-updown-{interval_mins}m-{unix_timestamp}`, where the timestamp is
53/// aligned to the start of the interval. The builder emits slugs for each
54/// configured asset and period.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
56#[serde(default, deny_unknown_fields)]
57#[cfg_attr(
58    feature = "python",
59    pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
60)]
61#[cfg_attr(
62    feature = "python",
63    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
64)]
65pub struct PolymarketUpDownEventSlugConfig {
66    /// Asset codes used in the slug prefix.
67    #[builder(default = default_updown_assets())]
68    pub assets: Vec<String>,
69    /// Up/Down interval in minutes.
70    #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
71    pub interval_mins: u64,
72    /// Number of periods to generate.
73    #[builder(default = DEFAULT_UPDOWN_PERIODS)]
74    pub periods: u64,
75    /// Offset from the current aligned period.
76    #[builder(default)]
77    pub start_offset_periods: i64,
78}
79
80#[cfg(feature = "python")]
81nautilus_core::impl_pyo3_config_getters!(PolymarketUpDownEventSlugConfig {
82    assets: Vec<String>,
83    interval_mins: u64,
84    periods: u64,
85    start_offset_periods: i64,
86});
87
88impl Default for PolymarketUpDownEventSlugConfig {
89    fn default() -> Self {
90        Self::builder().build()
91    }
92}
93
94impl PolymarketUpDownEventSlugConfig {
95    /// Builds event slugs using the current system time.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the interval or period count is zero, all assets are
100    /// blank, or the configured offset resolves before the Unix epoch.
101    pub fn build_event_slugs(&self) -> anyhow::Result<Vec<String>> {
102        let now = SystemTime::now()
103            .duration_since(UNIX_EPOCH)
104            .map_err(|e| anyhow::anyhow!("system clock before Unix epoch: {e}"))?
105            .as_secs();
106        self.build_event_slugs_at_unix_secs(now)
107    }
108
109    fn build_event_slugs_at_unix_secs(&self, unix_secs: u64) -> anyhow::Result<Vec<String>> {
110        if self.interval_mins == 0 {
111            anyhow::bail!("event_slug_builder.interval_mins must be positive");
112        }
113
114        if self.periods == 0 {
115            anyhow::bail!("event_slug_builder.periods must be positive");
116        }
117
118        let assets = self.normalized_assets();
119        if assets.is_empty() {
120            anyhow::bail!("event_slug_builder.assets must include at least one non-empty asset");
121        }
122
123        let period_secs = self
124            .interval_mins
125            .checked_mul(60)
126            .ok_or_else(|| anyhow::anyhow!("event_slug_builder.interval_mins is too large"))?;
127        let period_start = (unix_secs / period_secs) * period_secs;
128        let period_secs = i128::from(period_secs);
129        let period_start = i128::from(period_start);
130        let mut slugs = Vec::new();
131
132        for period in 0..self.periods {
133            let period_offset = i128::from(self.start_offset_periods) + i128::from(period);
134            let timestamp = period_start + period_offset * period_secs;
135            if timestamp < 0 {
136                anyhow::bail!("event_slug_builder offset resolves before the Unix epoch");
137            }
138
139            for asset in &assets {
140                slugs.push(format!(
141                    "{asset}-updown-{}m-{timestamp}",
142                    self.interval_mins
143                ));
144            }
145        }
146
147        Ok(slugs)
148    }
149
150    fn normalized_assets(&self) -> Vec<String> {
151        let mut assets = Vec::new();
152
153        for asset in &self.assets {
154            let asset = asset.trim().to_ascii_lowercase();
155            if asset.is_empty() || assets.contains(&asset) {
156                continue;
157            }
158            assets.push(asset);
159        }
160
161        assets
162    }
163}
164
165/// Configuration for the Polymarket instrument provider.
166///
167/// This mirrors the Python adapter's `instrument_config` layering so scoped
168/// market bootstrap can migrate naturally to the Rust/pyO3 live path.
169#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
170#[serde(default, deny_unknown_fields)]
171#[cfg_attr(
172    feature = "python",
173    pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
174)]
175#[cfg_attr(
176    feature = "python",
177    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
178)]
179pub struct PolymarketInstrumentProviderConfig {
180    /// Whether all venue instruments should be loaded on startup.
181    #[builder(default)]
182    pub load_all: bool,
183    /// Optional instrument IDs to load on startup instead of a full bootstrap.
184    pub load_ids: Option<Vec<InstrumentId>>,
185    /// Optional Gamma-style query filters encoded as string key/value pairs.
186    pub filters: Option<HashMap<String, String>>,
187    /// Optional static event slugs to resolve to markets during bootstrap.
188    pub event_slugs: Option<Vec<String>>,
189    /// Optional static market slugs to load directly during bootstrap.
190    pub market_slugs: Option<Vec<String>>,
191    /// Optional Rust-backed Up/Down event slug builder.
192    pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
193    /// Optional Gamma series IDs whose active events resolve to markets during bootstrap.
194    pub series_ids: Option<Vec<u64>>,
195    /// Whether provider warnings should be logged.
196    #[builder(default = true)]
197    pub log_warnings: bool,
198    /// Compatibility field matching the Python adapter. The Rust provider
199    /// already uses the Gamma API for bootstrap, so this currently has no
200    /// behavioral effect beyond configuration parity.
201    #[builder(default)]
202    pub use_gamma_markets: bool,
203}
204
205#[cfg(feature = "python")]
206nautilus_core::impl_pyo3_config_getters!(PolymarketInstrumentProviderConfig {
207    load_all: bool,
208    load_ids: Option<Vec<InstrumentId>>,
209    filters: Option<HashMap<String, String>>,
210    event_slugs: Option<Vec<String>>,
211    market_slugs: Option<Vec<String>>,
212    event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
213    series_ids: Option<Vec<u64>>,
214    log_warnings: bool,
215    use_gamma_markets: bool,
216});
217
218impl Default for PolymarketInstrumentProviderConfig {
219    fn default() -> Self {
220        Self::builder().build()
221    }
222}
223
224impl PolymarketInstrumentProviderConfig {
225    #[must_use]
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Returns whether any configured scope drives a bootstrap load.
231    #[must_use]
232    pub fn should_load_all(&self) -> bool {
233        self.load_all || self.has_explicit_scope() || self.has_nonempty_filters()
234    }
235
236    /// Returns whether any explicit bootstrap scope (slug, builder, or series) is configured.
237    #[must_use]
238    pub fn has_explicit_scope(&self) -> bool {
239        self.event_slug_builder.is_some()
240            || self.event_slugs.as_ref().is_some_and(|s| !s.is_empty())
241            || self.market_slugs.as_ref().is_some_and(|s| !s.is_empty())
242            || self.has_series_ids()
243    }
244
245    #[must_use]
246    pub fn has_series_ids(&self) -> bool {
247        self.series_ids.as_ref().is_some_and(|ids| !ids.is_empty())
248    }
249
250    #[must_use]
251    pub fn has_nonempty_filters(&self) -> bool {
252        self.filters.as_ref().is_some_and(|map| !map.is_empty())
253    }
254
255    #[must_use]
256    pub fn has_load_ids(&self) -> bool {
257        self.load_ids.as_ref().is_some_and(|ids| !ids.is_empty())
258    }
259}
260
261/// Configuration for the Polymarket data client.
262///
263/// `filters` and `new_market_filter` hold `Arc<dyn InstrumentFilter>` trait objects
264/// and are skipped during serialization; they default to empty/`None` and must be
265/// installed programmatically after deserialization.
266#[derive(Clone, Serialize, Deserialize, bon::Builder)]
267#[serde(default, deny_unknown_fields)]
268#[cfg_attr(
269    feature = "python",
270    pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
271)]
272#[cfg_attr(
273    feature = "python",
274    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
275)]
276pub struct PolymarketDataClientConfig {
277    pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
278    /// Instrument filters applied to all instruments during loading and discovery.
279    #[builder(default)]
280    #[serde(skip)]
281    pub filters: Vec<Arc<dyn InstrumentFilter>>,
282    pub base_url_http: Option<String>,
283    pub base_url_ws: Option<String>,
284    pub base_url_rtds: Option<String>,
285    pub base_url_gamma: Option<String>,
286    pub base_url_data_api: Option<String>,
287    /// Optional HTTP or HTTPS proxy URL for all HTTP and WebSocket transports.
288    pub proxy_url: Option<String>,
289    /// HTTP timeout in seconds.
290    #[builder(default = 60)]
291    pub http_timeout_secs: u64,
292    /// WebSocket timeout in seconds.
293    #[builder(default = 30)]
294    pub ws_timeout_secs: u64,
295    #[builder(default = crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS)]
296    pub ws_max_subscriptions: usize,
297    /// Instrument reload interval in minutes.
298    pub update_instruments_interval_mins: Option<u64>,
299    /// Whether to subscribe to new-market discovery, resolution, and best-bid/ask events.
300    #[builder(default)]
301    pub subscribe_new_markets: bool,
302    /// Optional filter applied to newly discovered markets before instrument emission.
303    #[serde(skip)]
304    pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
305    /// Maximum concurrent instrument fetches spawned from `new_market` events.
306    ///
307    /// This bounds adapter-side fan-out during event bursts and prevents
308    /// request storms against Gamma.
309    #[builder(default = 8)]
310    pub new_market_fetch_max_concurrency: usize,
311    /// Whether to drop quote ticks when bid or ask prices are missing.
312    #[builder(default = true)]
313    pub drop_quotes_missing_side: bool,
314    /// Whether to maintain local book state and emit only the net changes from
315    /// book snapshots, at an additional CPU and memory cost.
316    #[builder(default)]
317    pub compute_effective_deltas: bool,
318    /// Whether subscribe and request commands referencing an unknown instrument should
319    /// trigger an ad-hoc load via the instrument provider. Concurrent misses within
320    /// `auto_load_debounce_ms` are coalesced into a single batched request.
321    #[builder(default = true)]
322    pub auto_load_missing_instruments: bool,
323    /// The window (milliseconds) over which concurrent auto-load requests are batched.
324    #[builder(default = 100)]
325    pub auto_load_debounce_ms: u64,
326    /// Maximum retry attempts on transient auto-load failures (markets in the CLOB
327    /// hydration window that return empty `clob_token_ids` from Gamma, or that are
328    /// absent from the bulk response). Set to `0` to disable retry.
329    #[builder(default = 12)]
330    pub auto_load_max_retries: u32,
331    /// Initial delay (seconds) between transient auto-load retries; backed off
332    /// exponentially with positive jitter up to `auto_load_retry_delay_max_secs`.
333    #[builder(default = 5.0)]
334    pub auto_load_retry_delay_initial_secs: f64,
335    /// Maximum delay (seconds) between transient auto-load retries.
336    #[builder(default = 15.0)]
337    pub auto_load_retry_delay_max_secs: f64,
338    /// Whether automatic resolve polling is enabled.
339    #[builder(default = true)]
340    pub resolve_poll_enabled: bool,
341    /// Fixed interval between resolve poll cycles in seconds.
342    #[builder(default = 30)]
343    pub resolve_poll_interval_secs: u64,
344    /// Grace period after expiration before a market becomes resolve poll eligible.
345    #[builder(default = 10)]
346    pub resolve_poll_grace_secs: u64,
347    /// Maximum number of seconds to keep auto-polling after expiration before pausing.
348    #[builder(default = 1800)]
349    pub resolve_poll_max_wait_secs: u64,
350    /// WebSocket transport backend (defaults to `Sockudo`).
351    #[builder(default)]
352    pub transport_backend: TransportBackend,
353}
354
355#[cfg(feature = "python")]
356nautilus_core::impl_pyo3_config_getters!(PolymarketDataClientConfig {
357    instrument_config: Option<PolymarketInstrumentProviderConfig>,
358    base_url_http: Option<String>,
359    base_url_ws: Option<String>,
360    base_url_gamma: Option<String>,
361    base_url_data_api: Option<String>,
362    http_timeout_secs: u64,
363    ws_timeout_secs: u64,
364    ws_max_subscriptions: usize,
365    update_instruments_interval_mins: Option<u64>,
366    subscribe_new_markets: bool,
367    auto_load_missing_instruments: bool,
368    auto_load_debounce_ms: u64,
369    auto_load_max_retries: u32,
370    auto_load_retry_delay_initial_secs: f64,
371    auto_load_retry_delay_max_secs: f64,
372    new_market_fetch_max_concurrency: usize,
373    resolve_poll_enabled: bool,
374    resolve_poll_interval_secs: u64,
375    resolve_poll_grace_secs: u64,
376    resolve_poll_max_wait_secs: u64,
377    base_url_rtds: Option<String>,
378    transport_backend: TransportBackend,
379    drop_quotes_missing_side: bool,
380    compute_effective_deltas: bool,
381});
382
383impl Default for PolymarketDataClientConfig {
384    fn default() -> Self {
385        Self {
386            update_instruments_interval_mins: Some(60),
387            ..Self::builder().build()
388        }
389    }
390}
391
392impl Debug for PolymarketDataClientConfig {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        f.debug_struct(stringify!(PolymarketDataClientConfig))
395            .field("instrument_config", &self.instrument_config)
396            .field("filters", &self.filters)
397            .field("base_url_http", &self.base_url_http)
398            .field("base_url_ws", &self.base_url_ws)
399            .field("base_url_rtds", &self.base_url_rtds)
400            .field("base_url_gamma", &self.base_url_gamma)
401            .field("base_url_data_api", &self.base_url_data_api)
402            .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
403            .field("http_timeout_secs", &self.http_timeout_secs)
404            .field("ws_timeout_secs", &self.ws_timeout_secs)
405            .field("ws_max_subscriptions", &self.ws_max_subscriptions)
406            .field(
407                "update_instruments_interval_mins",
408                &self.update_instruments_interval_mins,
409            )
410            .field("subscribe_new_markets", &self.subscribe_new_markets)
411            .field("new_market_filter", &self.new_market_filter)
412            .field(
413                "new_market_fetch_max_concurrency",
414                &self.new_market_fetch_max_concurrency,
415            )
416            .field("drop_quotes_missing_side", &self.drop_quotes_missing_side)
417            .field("compute_effective_deltas", &self.compute_effective_deltas)
418            .field(
419                "auto_load_missing_instruments",
420                &self.auto_load_missing_instruments,
421            )
422            .field("auto_load_debounce_ms", &self.auto_load_debounce_ms)
423            .field("auto_load_max_retries", &self.auto_load_max_retries)
424            .field(
425                "auto_load_retry_delay_initial_secs",
426                &self.auto_load_retry_delay_initial_secs,
427            )
428            .field(
429                "auto_load_retry_delay_max_secs",
430                &self.auto_load_retry_delay_max_secs,
431            )
432            .field("resolve_poll_enabled", &self.resolve_poll_enabled)
433            .field(
434                "resolve_poll_interval_secs",
435                &self.resolve_poll_interval_secs,
436            )
437            .field("resolve_poll_grace_secs", &self.resolve_poll_grace_secs)
438            .field(
439                "resolve_poll_max_wait_secs",
440                &self.resolve_poll_max_wait_secs,
441            )
442            .field("transport_backend", &self.transport_backend)
443            .finish()
444    }
445}
446
447impl PolymarketDataClientConfig {
448    #[must_use]
449    pub fn new() -> Self {
450        Self::default()
451    }
452
453    /// Returns the validated proxy URL, if configured.
454    ///
455    /// # Errors
456    ///
457    /// Returns an error when the URL is malformed, has no host, or does not use HTTP or HTTPS.
458    pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
459        validated_proxy_url(self.proxy_url.as_ref())
460    }
461
462    #[must_use]
463    pub const fn has_proxy_url(&self) -> bool {
464        self.proxy_url.is_some()
465    }
466
467    #[must_use]
468    pub fn http_url(&self) -> String {
469        self.base_url_http
470            .clone()
471            .unwrap_or_else(|| urls::clob_http_url().to_string())
472    }
473
474    #[must_use]
475    pub fn ws_url(&self) -> String {
476        self.base_url_ws
477            .clone()
478            .unwrap_or_else(|| urls::clob_ws_url().to_string())
479    }
480
481    #[must_use]
482    pub fn rtds_url(&self) -> String {
483        self.base_url_rtds
484            .clone()
485            .unwrap_or_else(|| urls::rtds_ws_url().to_string())
486    }
487
488    #[must_use]
489    pub fn gamma_url(&self) -> String {
490        self.base_url_gamma
491            .clone()
492            .unwrap_or_else(|| urls::gamma_api_url().to_string())
493    }
494
495    #[must_use]
496    pub fn data_api_url(&self) -> String {
497        self.base_url_data_api
498            .clone()
499            .unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
500    }
501}
502
503/// Configuration for the Polymarket execution client.
504///
505/// `Debug` is implemented manually to redact secrets, so it is not part of the
506/// derive list.
507#[derive(Clone, Serialize, Deserialize, bon::Builder)]
508#[serde(default, deny_unknown_fields)]
509#[cfg_attr(
510    feature = "python",
511    pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
512)]
513#[cfg_attr(
514    feature = "python",
515    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
516)]
517pub struct PolymarketExecutionClientConfig {
518    #[builder(default = AccountId::from("POLYMARKET-001"))]
519    pub account_id: AccountId,
520    /// Falls back to `POLYMARKET_PK` env var.
521    pub private_key: Option<String>,
522    /// Falls back to `POLYMARKET_API_KEY` env var.
523    pub api_key: Option<String>,
524    /// Falls back to `POLYMARKET_API_SECRET` env var.
525    pub api_secret: Option<String>,
526    /// Falls back to `POLYMARKET_PASSPHRASE` env var.
527    pub passphrase: Option<String>,
528    /// Falls back to `POLYMARKET_FUNDER` env var.
529    pub funder: Option<String>,
530    #[builder(default = SignatureType::Eoa)]
531    pub signature_type: SignatureType,
532    pub base_url_http: Option<String>,
533    pub base_url_ws: Option<String>,
534    pub base_url_data_api: Option<String>,
535    /// Optional HTTP or HTTPS proxy URL for all HTTP and WebSocket transports.
536    pub proxy_url: Option<String>,
537    #[builder(default = 60)]
538    pub http_timeout_secs: u64,
539    #[builder(default = 3)]
540    pub max_retries: u32,
541    #[builder(default = 1000)]
542    pub retry_delay_initial_ms: u64,
543    #[builder(default = 10000)]
544    pub retry_delay_max_ms: u64,
545    /// Enables authenticated order-safety heartbeats.
546    #[builder(default)]
547    pub heartbeat_enabled: bool,
548    /// WebSocket transport backend (defaults to `Sockudo`).
549    #[builder(default)]
550    pub transport_backend: TransportBackend,
551    /// Same instrument provider configuration used by the data client.
552    ///
553    /// Reconciliation classifies unmapped records from `load_ids` on this
554    /// config. When that set is non-empty, venue records for other instruments
555    /// are out of scope. When this field is unset, or `load_ids` is unset or
556    /// empty, every record is in scope.
557    pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
558}
559
560#[cfg(feature = "python")]
561nautilus_core::impl_pyo3_config_getters!(PolymarketExecutionClientConfig {
562    account_id: AccountId,
563    funder: Option<String>,
564    signature_type: SignatureType,
565    base_url_http: Option<String>,
566    base_url_ws: Option<String>,
567    base_url_data_api: Option<String>,
568    http_timeout_secs: u64,
569    max_retries: u32,
570    retry_delay_initial_ms: u64,
571    retry_delay_max_ms: u64,
572    heartbeat_enabled: bool,
573    transport_backend: TransportBackend,
574    instrument_config: Option<PolymarketInstrumentProviderConfig>,
575});
576
577impl Debug for PolymarketExecutionClientConfig {
578    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579        f.debug_struct(stringify!(PolymarketExecutionClientConfig))
580            .field("account_id", &self.account_id)
581            .field("private_key", &"***")
582            .field("api_key", &"***")
583            .field("api_secret", &"***")
584            .field("passphrase", &"***")
585            .field("funder", &self.funder)
586            .field("signature_type", &self.signature_type)
587            .field("base_url_http", &self.base_url_http)
588            .field("base_url_ws", &self.base_url_ws)
589            .field("base_url_data_api", &self.base_url_data_api)
590            .field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
591            .field("http_timeout_secs", &self.http_timeout_secs)
592            .field("max_retries", &self.max_retries)
593            .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
594            .field("retry_delay_max_ms", &self.retry_delay_max_ms)
595            .field("heartbeat_enabled", &self.heartbeat_enabled)
596            .field("instrument_config", &self.instrument_config)
597            .finish()
598    }
599}
600
601impl Default for PolymarketExecutionClientConfig {
602    fn default() -> Self {
603        Self::builder().build()
604    }
605}
606
607impl PolymarketExecutionClientConfig {
608    #[must_use]
609    pub fn new() -> Self {
610        Self::default()
611    }
612
613    /// Returns the validated proxy URL, if configured.
614    ///
615    /// # Errors
616    ///
617    /// Returns an error when the URL is malformed, has no host, or does not use HTTP or HTTPS.
618    pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
619        validated_proxy_url(self.proxy_url.as_ref())
620    }
621
622    #[must_use]
623    pub const fn has_proxy_url(&self) -> bool {
624        self.proxy_url.is_some()
625    }
626
627    #[must_use]
628    pub fn has_credentials(&self) -> bool {
629        self.private_key
630            .as_deref()
631            .is_some_and(|s| !s.trim().is_empty())
632            || self
633                .api_key
634                .as_deref()
635                .is_some_and(|s| !s.trim().is_empty())
636    }
637
638    /// Returns provider `load_ids` used to classify unmapped reconciliation records.
639    #[must_use]
640    pub fn reconciliation_load_ids(&self) -> Option<&[InstrumentId]> {
641        self.instrument_config
642            .as_ref()
643            .and_then(|config| config.load_ids.as_deref())
644    }
645
646    #[must_use]
647    pub fn http_url(&self) -> String {
648        self.base_url_http
649            .clone()
650            .unwrap_or_else(|| urls::clob_http_url().to_string())
651    }
652
653    #[must_use]
654    pub fn ws_url(&self) -> String {
655        self.base_url_ws
656            .clone()
657            .unwrap_or_else(|| urls::clob_ws_url().to_string())
658    }
659
660    #[must_use]
661    pub fn data_api_url(&self) -> String {
662        self.base_url_data_api
663            .clone()
664            .unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use rstest::rstest;
671
672    use super::*;
673
674    #[rstest]
675    fn updown_event_slug_config_builds_aligned_slugs() {
676        let config = PolymarketUpDownEventSlugConfig {
677            assets: vec![
678                "BTC".to_string(),
679                " eth ".to_string(),
680                String::new(),
681                "btc".to_string(),
682            ],
683            interval_mins: 5,
684            periods: 2,
685            start_offset_periods: -1,
686        };
687
688        let slugs = config
689            .build_event_slugs_at_unix_secs(1_700_000_123)
690            .expect("event slugs should build");
691
692        assert_eq!(
693            slugs,
694            [
695                "btc-updown-5m-1699999800",
696                "eth-updown-5m-1699999800",
697                "btc-updown-5m-1700000100",
698                "eth-updown-5m-1700000100",
699            ]
700        );
701    }
702
703    #[rstest]
704    fn updown_event_slug_config_rejects_zero_interval() {
705        let config = PolymarketUpDownEventSlugConfig {
706            interval_mins: 0,
707            ..PolymarketUpDownEventSlugConfig::default()
708        };
709
710        let err = config
711            .build_event_slugs_at_unix_secs(1_700_000_123)
712            .expect_err("zero interval should fail");
713
714        assert!(
715            err.to_string()
716                .contains("event_slug_builder.interval_mins must be positive")
717        );
718    }
719
720    #[rstest]
721    fn provider_config_series_ids_trigger_load_all() {
722        let config = PolymarketInstrumentProviderConfig {
723            series_ids: Some(vec![10684]),
724            ..PolymarketInstrumentProviderConfig::default()
725        };
726
727        assert!(config.has_series_ids());
728        assert!(config.should_load_all());
729    }
730
731    #[rstest]
732    fn provider_config_empty_series_ids_do_not_trigger_load_all() {
733        let config = PolymarketInstrumentProviderConfig {
734            series_ids: Some(Vec::new()),
735            ..PolymarketInstrumentProviderConfig::default()
736        };
737
738        assert!(!config.has_series_ids());
739        assert!(!config.should_load_all());
740    }
741
742    #[rstest]
743    fn provider_config_filters_trigger_load_all() {
744        let config = PolymarketInstrumentProviderConfig {
745            filters: Some(HashMap::from([("tag_id".to_string(), "84".to_string())])),
746            ..PolymarketInstrumentProviderConfig::default()
747        };
748
749        assert!(config.has_nonempty_filters());
750        assert!(!config.has_explicit_scope());
751        assert!(config.should_load_all());
752    }
753
754    #[rstest]
755    fn provider_config_empty_filters_do_not_trigger_load_all() {
756        let config = PolymarketInstrumentProviderConfig {
757            filters: Some(HashMap::new()),
758            ..PolymarketInstrumentProviderConfig::default()
759        };
760
761        assert!(!config.has_nonempty_filters());
762        assert!(!config.should_load_all());
763    }
764
765    #[rstest]
766    fn test_data_config_toml_minimal() {
767        let config: PolymarketDataClientConfig = toml::from_str(
768            "
769http_timeout_secs = 30
770ws_max_subscriptions = 50
771update_instruments_interval_mins = 5
772subscribe_new_markets = true
773new_market_fetch_max_concurrency = 16
774auto_load_debounce_ms = 250
775resolve_poll_enabled = true
776resolve_poll_interval_secs = 30
777resolve_poll_grace_secs = 10
778resolve_poll_max_wait_secs = 1800
779",
780        )
781        .unwrap();
782
783        assert!(config.instrument_config.is_none());
784        assert!(config.filters.is_empty());
785        assert_eq!(config.http_timeout_secs, 30);
786        assert_eq!(config.ws_max_subscriptions, 50);
787        assert_eq!(config.update_instruments_interval_mins, Some(5));
788        assert!(config.subscribe_new_markets);
789        assert!(config.new_market_filter.is_none());
790        assert_eq!(config.new_market_fetch_max_concurrency, 16);
791        assert_eq!(config.auto_load_debounce_ms, 250);
792        assert!(config.resolve_poll_enabled);
793        assert_eq!(config.resolve_poll_interval_secs, 30);
794        assert_eq!(config.resolve_poll_grace_secs, 10);
795        assert_eq!(config.resolve_poll_max_wait_secs, 1800);
796        assert!(config.drop_quotes_missing_side);
797        assert!(!config.compute_effective_deltas);
798    }
799
800    #[rstest]
801    fn test_data_config_toml_sets_compute_effective_deltas() {
802        let config: PolymarketDataClientConfig =
803            toml::from_str("compute_effective_deltas = true").unwrap();
804
805        assert!(config.compute_effective_deltas);
806    }
807
808    #[rstest]
809    fn test_data_config_toml_sets_drop_quotes_missing_side_false() {
810        let config: PolymarketDataClientConfig =
811            toml::from_str("drop_quotes_missing_side = false").unwrap();
812
813        assert!(!config.drop_quotes_missing_side);
814    }
815
816    #[rstest]
817    fn test_data_config_toml_with_instrument_config() {
818        let config: PolymarketDataClientConfig = toml::from_str(
819            r#"
820[instrument_config]
821load_all = true
822event_slugs = ["btc-updown-5m-123", "eth-updown-15m-456"]
823log_warnings = false
824"#,
825        )
826        .unwrap();
827
828        let instrument_config = config.instrument_config.expect("instrument_config");
829        assert!(instrument_config.load_all);
830        assert_eq!(
831            instrument_config.event_slugs,
832            Some(vec![
833                "btc-updown-5m-123".to_string(),
834                "eth-updown-15m-456".to_string(),
835            ]),
836        );
837        assert!(!instrument_config.log_warnings);
838    }
839
840    #[rstest]
841    fn test_exec_config_toml_empty_uses_defaults() {
842        let config: PolymarketExecutionClientConfig = toml::from_str("").unwrap();
843        let expected = PolymarketExecutionClientConfig::default();
844        assert_eq!(config.account_id, expected.account_id);
845        assert_eq!(config.signature_type, expected.signature_type);
846        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
847        assert_eq!(config.max_retries, expected.max_retries);
848        assert!(!config.heartbeat_enabled);
849        assert_eq!(config.transport_backend, expected.transport_backend);
850        assert!(config.instrument_config.is_none());
851        assert!(config.reconciliation_load_ids().is_none());
852    }
853
854    #[rstest]
855    fn test_exec_config_reconciliation_load_ids_come_from_instrument_config() {
856        let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
857        let config: PolymarketExecutionClientConfig = toml::from_str(
858            r#"
859[instrument_config]
860load_ids = ["0xabc-123.POLYMARKET"]
861"#,
862        )
863        .unwrap();
864
865        assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
866    }
867
868    #[rstest]
869    fn test_data_config_proxy_url_validates_and_redacts_debug() {
870        const SECRET: &str = "data-proxy-secret";
871        let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18081");
872        let config: PolymarketDataClientConfig =
873            toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
874                .expect("deserialize data config");
875        let validated = config
876            .validated_proxy_url()
877            .expect("validate data proxy")
878            .expect("data proxy configured");
879        let debug = format!("{config:?}");
880
881        assert_eq!(validated.expose(), proxy_url);
882        assert!(config.has_proxy_url());
883        assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
884        assert!(!debug.contains(SECRET));
885    }
886
887    #[rstest]
888    fn test_exec_config_proxy_url_validates_and_redacts_debug() {
889        const SECRET: &str = "exec-proxy-secret";
890        let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18082");
891        let config: PolymarketExecutionClientConfig =
892            toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
893                .expect("deserialize execution config");
894        let validated = config
895            .validated_proxy_url()
896            .expect("validate execution proxy")
897            .expect("execution proxy configured");
898        let debug = format!("{config:?}");
899
900        assert_eq!(validated.expose(), proxy_url);
901        assert!(config.has_proxy_url());
902        assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
903        assert!(!debug.contains(SECRET));
904    }
905
906    #[rstest]
907    fn test_proxy_url_unset_preserves_direct_configuration() {
908        let data_config = PolymarketDataClientConfig::default();
909        let exec_config = PolymarketExecutionClientConfig::default();
910
911        assert_eq!(data_config.proxy_url, None);
912        assert_eq!(exec_config.proxy_url, None);
913        assert_eq!(data_config.validated_proxy_url().unwrap(), None);
914        assert_eq!(exec_config.validated_proxy_url().unwrap(), None);
915        assert!(!data_config.has_proxy_url());
916        assert!(!exec_config.has_proxy_url());
917    }
918
919    #[rstest]
920    fn test_invalid_proxy_url_error_redacts_credentials() {
921        const SECRET: &str = "invalid-proxy-secret";
922        let config = PolymarketDataClientConfig {
923            proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1")),
924            ..PolymarketDataClientConfig::default()
925        };
926        let error = config
927            .validated_proxy_url()
928            .expect_err("malformed proxy URL should fail");
929
930        assert!(!error.to_string().contains(SECRET));
931    }
932
933    #[rstest]
934    fn test_socks_proxy_url_is_rejected_for_consistent_routing() {
935        let config = PolymarketExecutionClientConfig {
936            proxy_url: Some("socks5://127.0.0.1:1080".to_string()),
937            ..PolymarketExecutionClientConfig::default()
938        };
939        let error = config
940            .validated_proxy_url()
941            .expect_err("SOCKS proxy should fail validation");
942
943        assert_eq!(
944            error.to_string(),
945            "invalid URL: SOCKS proxy scheme 'socks5' is not yet supported for WebSocket connections; use an http:// or https:// proxy"
946        );
947    }
948}