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