1use 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#[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 #[builder(default = default_updown_assets())]
63 pub assets: Vec<String>,
64 #[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
66 pub interval_mins: u64,
67 #[builder(default = DEFAULT_UPDOWN_PERIODS)]
69 pub periods: u64,
70 #[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 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#[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 #[builder(default)]
172 pub load_all: bool,
173 pub load_ids: Option<Vec<InstrumentId>>,
175 pub filters: Option<HashMap<String, String>>,
177 pub event_slugs: Option<Vec<String>>,
179 pub market_slugs: Option<Vec<String>>,
181 pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
183 #[builder(default = true)]
185 pub log_warnings: bool,
186 #[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#[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 #[builder(default = 60)]
246 pub http_timeout_secs: u64,
247 #[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 pub update_instruments_interval_mins: Option<u64>,
254 #[builder(default)]
256 pub subscribe_new_markets: bool,
257 #[builder(default = 8)]
262 pub new_market_fetch_max_concurrency: usize,
263 #[builder(default = true)]
267 pub auto_load_missing_instruments: bool,
268 #[builder(default = 100)]
270 pub auto_load_debounce_ms: u64,
271 #[builder(default = 12)]
275 pub auto_load_max_retries: u32,
276 #[builder(default = 5.0)]
279 pub auto_load_retry_delay_initial_secs: f64,
280 #[builder(default = 15.0)]
282 pub auto_load_retry_delay_max_secs: f64,
283 #[builder(default = true)]
285 pub resolve_poll_enabled: bool,
286 #[builder(default = 30)]
288 pub resolve_poll_interval_secs: u64,
289 #[builder(default = 10)]
291 pub resolve_poll_grace_secs: u64,
292 #[builder(default = 1800)]
294 pub resolve_poll_max_wait_secs: u64,
295 #[builder(default)]
297 #[serde(skip)]
298 pub filters: Vec<Arc<dyn InstrumentFilter>>,
299 #[serde(skip)]
301 pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
302 #[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#[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 pub private_key: Option<String>,
382 pub api_key: Option<String>,
384 pub api_secret: Option<String>,
386 pub passphrase: Option<String>,
388 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 #[builder(default = 5)]
405 pub ack_timeout_secs: u64,
406 #[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}