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_model::identifiers::{AccountId, InstrumentId, TraderId};
26use nautilus_network::websocket::TransportBackend;
27use serde::{Deserialize, Serialize};
28
29use crate::{
30    common::{enums::SignatureType, urls},
31    filters::InstrumentFilter,
32};
33
34const DEFAULT_UPDOWN_INTERVAL_MINS: u64 = 5;
35const DEFAULT_UPDOWN_PERIODS: u64 = 3;
36
37fn default_updown_assets() -> Vec<String> {
38    vec!["btc".to_string()]
39}
40
41/// Rust-backed event slug builder for Polymarket Up/Down markets.
42///
43/// Up/Down event slugs follow the pattern
44/// `{asset}-updown-{interval_mins}m-{unix_timestamp}`, where the timestamp is
45/// aligned to the start of the interval. The builder emits slugs for each
46/// configured asset and period.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
48#[serde(default, deny_unknown_fields)]
49#[cfg_attr(
50    feature = "python",
51    pyo3::pyclass(
52        module = "nautilus_trader.core.nautilus_pyo3.polymarket",
53        from_py_object
54    )
55)]
56#[cfg_attr(
57    feature = "python",
58    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
59)]
60pub struct PolymarketUpDownEventSlugConfig {
61    /// Asset codes used in the slug prefix.
62    #[builder(default = default_updown_assets())]
63    pub assets: Vec<String>,
64    /// Up/Down interval in minutes.
65    #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
66    pub interval_mins: u64,
67    /// Number of periods to generate.
68    #[builder(default = DEFAULT_UPDOWN_PERIODS)]
69    pub periods: u64,
70    /// Offset from the current aligned period.
71    #[builder(default)]
72    pub start_offset_periods: i64,
73}
74
75impl Default for PolymarketUpDownEventSlugConfig {
76    fn default() -> Self {
77        Self::builder().build()
78    }
79}
80
81impl PolymarketUpDownEventSlugConfig {
82    /// Builds event slugs using the current system time.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the interval or period count is zero, all assets are
87    /// blank, or the configured offset resolves before the Unix epoch.
88    pub fn build_event_slugs(&self) -> anyhow::Result<Vec<String>> {
89        let now = SystemTime::now()
90            .duration_since(UNIX_EPOCH)
91            .map_err(|e| anyhow::anyhow!("system clock before Unix epoch: {e}"))?
92            .as_secs();
93        self.build_event_slugs_at_unix_secs(now)
94    }
95
96    fn build_event_slugs_at_unix_secs(&self, unix_secs: u64) -> anyhow::Result<Vec<String>> {
97        if self.interval_mins == 0 {
98            anyhow::bail!("event_slug_builder.interval_mins must be positive");
99        }
100
101        if self.periods == 0 {
102            anyhow::bail!("event_slug_builder.periods must be positive");
103        }
104
105        let assets = self.normalized_assets();
106        if assets.is_empty() {
107            anyhow::bail!("event_slug_builder.assets must include at least one non-empty asset");
108        }
109
110        let period_secs = self
111            .interval_mins
112            .checked_mul(60)
113            .ok_or_else(|| anyhow::anyhow!("event_slug_builder.interval_mins is too large"))?;
114        let period_start = (unix_secs / period_secs) * period_secs;
115        let period_secs = i128::from(period_secs);
116        let period_start = i128::from(period_start);
117        let mut slugs = Vec::new();
118
119        for period in 0..self.periods {
120            let period_offset = i128::from(self.start_offset_periods) + i128::from(period);
121            let timestamp = period_start + period_offset * period_secs;
122            if timestamp < 0 {
123                anyhow::bail!("event_slug_builder offset resolves before the Unix epoch");
124            }
125
126            for asset in &assets {
127                slugs.push(format!(
128                    "{asset}-updown-{}m-{timestamp}",
129                    self.interval_mins
130                ));
131            }
132        }
133
134        Ok(slugs)
135    }
136
137    fn normalized_assets(&self) -> Vec<String> {
138        let mut assets = Vec::new();
139
140        for asset in &self.assets {
141            let asset = asset.trim().to_ascii_lowercase();
142            if asset.is_empty() || assets.contains(&asset) {
143                continue;
144            }
145            assets.push(asset);
146        }
147
148        assets
149    }
150}
151
152/// Configuration for the Polymarket instrument provider.
153///
154/// This mirrors the Python adapter's `instrument_config` layering so scoped
155/// market bootstrap can migrate naturally to the Rust/pyO3 live path.
156#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
157#[serde(default, deny_unknown_fields)]
158#[cfg_attr(
159    feature = "python",
160    pyo3::pyclass(
161        module = "nautilus_trader.core.nautilus_pyo3.polymarket",
162        from_py_object
163    )
164)]
165#[cfg_attr(
166    feature = "python",
167    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
168)]
169pub struct PolymarketInstrumentProviderConfig {
170    /// Whether all venue instruments should be loaded on startup.
171    #[builder(default)]
172    pub load_all: bool,
173    /// Optional instrument IDs to load on startup instead of a full bootstrap.
174    pub load_ids: Option<Vec<InstrumentId>>,
175    /// Optional Gamma-style query filters encoded as string key/value pairs.
176    pub filters: Option<HashMap<String, String>>,
177    /// Optional static event slugs to resolve to markets during bootstrap.
178    pub event_slugs: Option<Vec<String>>,
179    /// Optional static market slugs to load directly during bootstrap.
180    pub market_slugs: Option<Vec<String>>,
181    /// Optional Rust-backed Up/Down event slug builder.
182    pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
183    /// Whether provider warnings should be logged.
184    #[builder(default = true)]
185    pub log_warnings: bool,
186    /// Compatibility field matching the Python adapter. The Rust provider
187    /// already uses the Gamma API for bootstrap, so this currently has no
188    /// behavioral effect beyond configuration parity.
189    #[builder(default)]
190    pub use_gamma_markets: bool,
191}
192
193impl Default for PolymarketInstrumentProviderConfig {
194    fn default() -> Self {
195        Self::builder().build()
196    }
197}
198
199impl PolymarketInstrumentProviderConfig {
200    #[must_use]
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    #[must_use]
206    pub fn should_load_all(&self) -> bool {
207        self.load_all
208            || self.event_slug_builder.is_some()
209            || self.event_slugs.as_ref().is_some_and(|s| !s.is_empty())
210            || self.market_slugs.as_ref().is_some_and(|s| !s.is_empty())
211    }
212
213    #[must_use]
214    pub fn has_load_ids(&self) -> bool {
215        self.load_ids.as_ref().is_some_and(|ids| !ids.is_empty())
216    }
217}
218
219/// Configuration for the Polymarket data client.
220///
221/// `filters` and `new_market_filter` hold `Arc<dyn InstrumentFilter>` trait objects
222/// and are skipped during serialization; they default to empty/`None` and must be
223/// installed programmatically after deserialization.
224#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
225#[serde(default, deny_unknown_fields)]
226#[cfg_attr(
227    feature = "python",
228    pyo3::pyclass(
229        module = "nautilus_trader.core.nautilus_pyo3.polymarket",
230        from_py_object
231    )
232)]
233#[cfg_attr(
234    feature = "python",
235    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
236)]
237pub struct PolymarketDataClientConfig {
238    pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
239    pub base_url_http: Option<String>,
240    pub base_url_ws: Option<String>,
241    pub base_url_rtds: Option<String>,
242    pub base_url_gamma: Option<String>,
243    pub base_url_data_api: Option<String>,
244    /// HTTP timeout in seconds.
245    #[builder(default = 60)]
246    pub http_timeout_secs: u64,
247    /// WebSocket timeout in seconds.
248    #[builder(default = 30)]
249    pub ws_timeout_secs: u64,
250    #[builder(default = crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS)]
251    pub ws_max_subscriptions: usize,
252    /// Instrument reload interval in minutes.
253    pub update_instruments_interval_mins: Option<u64>,
254    /// Whether to subscribe to new market discovery events via WebSocket.
255    #[builder(default)]
256    pub subscribe_new_markets: bool,
257    /// Maximum concurrent instrument fetches spawned from `new_market` events.
258    ///
259    /// This bounds adapter-side fan-out during event bursts and prevents
260    /// request storms against Gamma.
261    #[builder(default = 8)]
262    pub new_market_fetch_max_concurrency: usize,
263    /// Whether subscribe and request commands referencing an unknown instrument should
264    /// trigger an ad-hoc load via the instrument provider. Concurrent misses within
265    /// `auto_load_debounce_ms` are coalesced into a single batched request.
266    #[builder(default = true)]
267    pub auto_load_missing_instruments: bool,
268    /// The window (milliseconds) over which concurrent auto-load requests are batched.
269    #[builder(default = 100)]
270    pub auto_load_debounce_ms: u64,
271    /// Maximum retry attempts on transient auto-load failures (markets in the CLOB
272    /// hydration window that return empty `clob_token_ids` from Gamma, or that are
273    /// absent from the bulk response). Set to `0` to disable retry.
274    #[builder(default = 12)]
275    pub auto_load_max_retries: u32,
276    /// Initial delay (seconds) between transient auto-load retries; backed off
277    /// exponentially with positive jitter up to `auto_load_retry_delay_max_secs`.
278    #[builder(default = 5.0)]
279    pub auto_load_retry_delay_initial_secs: f64,
280    /// Maximum delay (seconds) between transient auto-load retries.
281    #[builder(default = 15.0)]
282    pub auto_load_retry_delay_max_secs: f64,
283    /// Whether automatic resolve polling is enabled.
284    #[builder(default = true)]
285    pub resolve_poll_enabled: bool,
286    /// Fixed interval between resolve poll cycles in seconds.
287    #[builder(default = 30)]
288    pub resolve_poll_interval_secs: u64,
289    /// Grace period after expiration before a market becomes resolve poll eligible.
290    #[builder(default = 10)]
291    pub resolve_poll_grace_secs: u64,
292    /// Maximum number of seconds to keep auto-polling after expiration before pausing.
293    #[builder(default = 1800)]
294    pub resolve_poll_max_wait_secs: u64,
295    /// Instrument filters applied to all instruments during loading and discovery.
296    #[builder(default)]
297    #[serde(skip)]
298    pub filters: Vec<Arc<dyn InstrumentFilter>>,
299    /// Optional filter applied to newly discovered markets before instrument emission.
300    #[serde(skip)]
301    pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
302    /// WebSocket transport backend (defaults to `Sockudo`).
303    #[builder(default)]
304    pub transport_backend: TransportBackend,
305}
306
307impl Default for PolymarketDataClientConfig {
308    fn default() -> Self {
309        Self {
310            update_instruments_interval_mins: Some(60),
311            ..Self::builder().build()
312        }
313    }
314}
315
316impl PolymarketDataClientConfig {
317    #[must_use]
318    pub fn new() -> Self {
319        Self::default()
320    }
321
322    #[must_use]
323    pub fn http_url(&self) -> String {
324        self.base_url_http
325            .clone()
326            .unwrap_or_else(|| urls::clob_http_url().to_string())
327    }
328
329    #[must_use]
330    pub fn ws_url(&self) -> String {
331        self.base_url_ws
332            .clone()
333            .unwrap_or_else(|| urls::clob_ws_url().to_string())
334    }
335
336    #[must_use]
337    pub fn rtds_url(&self) -> String {
338        self.base_url_rtds
339            .clone()
340            .unwrap_or_else(|| urls::rtds_ws_url().to_string())
341    }
342
343    #[must_use]
344    pub fn gamma_url(&self) -> String {
345        self.base_url_gamma
346            .clone()
347            .unwrap_or_else(|| urls::gamma_api_url().to_string())
348    }
349
350    #[must_use]
351    pub fn data_api_url(&self) -> String {
352        self.base_url_data_api
353            .clone()
354            .unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
355    }
356}
357
358/// Configuration for the Polymarket execution client.
359///
360/// `Debug` is implemented manually to redact secrets, so it is not part of the
361/// derive list.
362#[derive(Clone, Serialize, Deserialize, bon::Builder)]
363#[serde(default, deny_unknown_fields)]
364#[cfg_attr(
365    feature = "python",
366    pyo3::pyclass(
367        module = "nautilus_trader.core.nautilus_pyo3.polymarket",
368        from_py_object
369    )
370)]
371#[cfg_attr(
372    feature = "python",
373    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
374)]
375pub struct PolymarketExecClientConfig {
376    #[builder(default)]
377    pub trader_id: TraderId,
378    #[builder(default = AccountId::from("POLYMARKET-001"))]
379    pub account_id: AccountId,
380    /// Falls back to `POLYMARKET_PK` env var.
381    pub private_key: Option<String>,
382    /// Falls back to `POLYMARKET_API_KEY` env var.
383    pub api_key: Option<String>,
384    /// Falls back to `POLYMARKET_API_SECRET` env var.
385    pub api_secret: Option<String>,
386    /// Falls back to `POLYMARKET_PASSPHRASE` env var.
387    pub passphrase: Option<String>,
388    /// Falls back to `POLYMARKET_FUNDER` env var.
389    pub funder: Option<String>,
390    #[builder(default = SignatureType::Eoa)]
391    pub signature_type: SignatureType,
392    pub base_url_http: Option<String>,
393    pub base_url_ws: Option<String>,
394    pub base_url_data_api: Option<String>,
395    #[builder(default = 60)]
396    pub http_timeout_secs: u64,
397    #[builder(default = 3)]
398    pub max_retries: u32,
399    #[builder(default = 1000)]
400    pub retry_delay_initial_ms: u64,
401    #[builder(default = 10000)]
402    pub retry_delay_max_ms: u64,
403    /// Timeout waiting for WS order acknowledgment (seconds).
404    #[builder(default = 5)]
405    pub ack_timeout_secs: u64,
406    /// WebSocket transport backend (defaults to `Sockudo`).
407    #[builder(default)]
408    pub transport_backend: TransportBackend,
409}
410
411impl Debug for PolymarketExecClientConfig {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        f.debug_struct(stringify!(PolymarketExecClientConfig))
414            .field("trader_id", &self.trader_id)
415            .field("account_id", &self.account_id)
416            .field("private_key", &"***")
417            .field("api_key", &"***")
418            .field("api_secret", &"***")
419            .field("passphrase", &"***")
420            .field("funder", &self.funder)
421            .field("signature_type", &self.signature_type)
422            .field("base_url_http", &self.base_url_http)
423            .field("base_url_ws", &self.base_url_ws)
424            .field("base_url_data_api", &self.base_url_data_api)
425            .field("http_timeout_secs", &self.http_timeout_secs)
426            .field("max_retries", &self.max_retries)
427            .field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
428            .field("retry_delay_max_ms", &self.retry_delay_max_ms)
429            .field("ack_timeout_secs", &self.ack_timeout_secs)
430            .finish()
431    }
432}
433
434impl Default for PolymarketExecClientConfig {
435    fn default() -> Self {
436        Self::builder().build()
437    }
438}
439
440impl PolymarketExecClientConfig {
441    #[must_use]
442    pub fn new() -> Self {
443        Self::default()
444    }
445
446    #[must_use]
447    pub fn has_credentials(&self) -> bool {
448        self.private_key
449            .as_deref()
450            .is_some_and(|s| !s.trim().is_empty())
451            || self
452                .api_key
453                .as_deref()
454                .is_some_and(|s| !s.trim().is_empty())
455    }
456
457    #[must_use]
458    pub fn http_url(&self) -> String {
459        self.base_url_http
460            .clone()
461            .unwrap_or_else(|| urls::clob_http_url().to_string())
462    }
463
464    #[must_use]
465    pub fn ws_url(&self) -> String {
466        self.base_url_ws
467            .clone()
468            .unwrap_or_else(|| urls::clob_ws_url().to_string())
469    }
470
471    #[must_use]
472    pub fn data_api_url(&self) -> String {
473        self.base_url_data_api
474            .clone()
475            .unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use rstest::rstest;
482
483    use super::*;
484
485    #[rstest]
486    fn updown_event_slug_config_builds_aligned_slugs() {
487        let config = PolymarketUpDownEventSlugConfig {
488            assets: vec![
489                "BTC".to_string(),
490                " eth ".to_string(),
491                String::new(),
492                "btc".to_string(),
493            ],
494            interval_mins: 5,
495            periods: 2,
496            start_offset_periods: -1,
497        };
498
499        let slugs = config
500            .build_event_slugs_at_unix_secs(1_700_000_123)
501            .expect("event slugs should build");
502
503        assert_eq!(
504            slugs,
505            [
506                "btc-updown-5m-1699999800",
507                "eth-updown-5m-1699999800",
508                "btc-updown-5m-1700000100",
509                "eth-updown-5m-1700000100",
510            ]
511        );
512    }
513
514    #[rstest]
515    fn updown_event_slug_config_rejects_zero_interval() {
516        let config = PolymarketUpDownEventSlugConfig {
517            interval_mins: 0,
518            ..PolymarketUpDownEventSlugConfig::default()
519        };
520
521        let err = config
522            .build_event_slugs_at_unix_secs(1_700_000_123)
523            .expect_err("zero interval should fail");
524
525        assert!(
526            err.to_string()
527                .contains("event_slug_builder.interval_mins must be positive")
528        );
529    }
530
531    #[rstest]
532    fn test_data_config_toml_minimal() {
533        let config: PolymarketDataClientConfig = toml::from_str(
534            "
535http_timeout_secs = 30
536ws_max_subscriptions = 50
537update_instruments_interval_mins = 5
538subscribe_new_markets = true
539new_market_fetch_max_concurrency = 16
540auto_load_debounce_ms = 250
541resolve_poll_enabled = true
542resolve_poll_interval_secs = 30
543resolve_poll_grace_secs = 10
544resolve_poll_max_wait_secs = 1800
545",
546        )
547        .unwrap();
548
549        assert_eq!(config.http_timeout_secs, 30);
550        assert_eq!(config.ws_max_subscriptions, 50);
551        assert_eq!(config.update_instruments_interval_mins, Some(5));
552        assert!(config.subscribe_new_markets);
553        assert_eq!(config.new_market_fetch_max_concurrency, 16);
554        assert_eq!(config.auto_load_debounce_ms, 250);
555        assert!(config.instrument_config.is_none());
556        assert!(config.resolve_poll_enabled);
557        assert_eq!(config.resolve_poll_interval_secs, 30);
558        assert_eq!(config.resolve_poll_grace_secs, 10);
559        assert_eq!(config.resolve_poll_max_wait_secs, 1800);
560        assert!(config.filters.is_empty());
561        assert!(config.new_market_filter.is_none());
562    }
563
564    #[rstest]
565    fn test_data_config_toml_with_instrument_config() {
566        let config: PolymarketDataClientConfig = toml::from_str(
567            r#"
568[instrument_config]
569load_all = true
570event_slugs = ["btc-updown-5m-123", "eth-updown-15m-456"]
571log_warnings = false
572"#,
573        )
574        .unwrap();
575
576        let instrument_config = config.instrument_config.expect("instrument_config");
577        assert!(instrument_config.load_all);
578        assert_eq!(
579            instrument_config.event_slugs,
580            Some(vec![
581                "btc-updown-5m-123".to_string(),
582                "eth-updown-15m-456".to_string(),
583            ]),
584        );
585        assert!(!instrument_config.log_warnings);
586    }
587
588    #[rstest]
589    fn test_exec_config_toml_empty_uses_defaults() {
590        let config: PolymarketExecClientConfig = toml::from_str("").unwrap();
591        let expected = PolymarketExecClientConfig::default();
592
593        assert_eq!(config.trader_id, expected.trader_id);
594        assert_eq!(config.account_id, expected.account_id);
595        assert_eq!(config.signature_type, expected.signature_type);
596        assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
597        assert_eq!(config.max_retries, expected.max_retries);
598        assert_eq!(config.ack_timeout_secs, expected.ack_timeout_secs);
599        assert_eq!(config.transport_backend, expected.transport_backend);
600    }
601}