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