use std::{
collections::HashMap,
fmt::Debug,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use nautilus_core::string::secret::REDACTED;
use nautilus_model::identifiers::{AccountId, InstrumentId, TraderId};
use nautilus_network::{
transport::TransportError,
websocket::{TransportBackend, proxy::ProxyUrl},
};
use serde::{Deserialize, Serialize};
use crate::{
common::{enums::SignatureType, urls},
filters::InstrumentFilter,
};
const DEFAULT_UPDOWN_INTERVAL_MINS: u64 = 5;
const DEFAULT_UPDOWN_PERIODS: u64 = 3;
fn validated_proxy_url(value: Option<&String>) -> Result<Option<ProxyUrl>, TransportError> {
value.cloned().map(ProxyUrl::parse).transpose()
}
fn default_updown_assets() -> Vec<String> {
vec!["btc".to_string()]
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, bon::Builder)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(
module = "nautilus_trader.core.nautilus_pyo3.polymarket",
from_py_object
)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
)]
pub struct PolymarketUpDownEventSlugConfig {
#[builder(default = default_updown_assets())]
pub assets: Vec<String>,
#[builder(default = DEFAULT_UPDOWN_INTERVAL_MINS)]
pub interval_mins: u64,
#[builder(default = DEFAULT_UPDOWN_PERIODS)]
pub periods: u64,
#[builder(default)]
pub start_offset_periods: i64,
}
#[cfg(feature = "python")]
nautilus_core::impl_pyo3_config_getters!(PolymarketUpDownEventSlugConfig {
assets: Vec<String>,
interval_mins: u64,
periods: u64,
start_offset_periods: i64,
});
impl Default for PolymarketUpDownEventSlugConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl PolymarketUpDownEventSlugConfig {
pub fn build_event_slugs(&self) -> anyhow::Result<Vec<String>> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| anyhow::anyhow!("system clock before Unix epoch: {e}"))?
.as_secs();
self.build_event_slugs_at_unix_secs(now)
}
fn build_event_slugs_at_unix_secs(&self, unix_secs: u64) -> anyhow::Result<Vec<String>> {
if self.interval_mins == 0 {
anyhow::bail!("event_slug_builder.interval_mins must be positive");
}
if self.periods == 0 {
anyhow::bail!("event_slug_builder.periods must be positive");
}
let assets = self.normalized_assets();
if assets.is_empty() {
anyhow::bail!("event_slug_builder.assets must include at least one non-empty asset");
}
let period_secs = self
.interval_mins
.checked_mul(60)
.ok_or_else(|| anyhow::anyhow!("event_slug_builder.interval_mins is too large"))?;
let period_start = (unix_secs / period_secs) * period_secs;
let period_secs = i128::from(period_secs);
let period_start = i128::from(period_start);
let mut slugs = Vec::new();
for period in 0..self.periods {
let period_offset = i128::from(self.start_offset_periods) + i128::from(period);
let timestamp = period_start + period_offset * period_secs;
if timestamp < 0 {
anyhow::bail!("event_slug_builder offset resolves before the Unix epoch");
}
for asset in &assets {
slugs.push(format!(
"{asset}-updown-{}m-{timestamp}",
self.interval_mins
));
}
}
Ok(slugs)
}
fn normalized_assets(&self) -> Vec<String> {
let mut assets = Vec::new();
for asset in &self.assets {
let asset = asset.trim().to_ascii_lowercase();
if asset.is_empty() || assets.contains(&asset) {
continue;
}
assets.push(asset);
}
assets
}
}
#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(
module = "nautilus_trader.core.nautilus_pyo3.polymarket",
from_py_object
)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
)]
pub struct PolymarketInstrumentProviderConfig {
#[builder(default)]
pub load_all: bool,
pub load_ids: Option<Vec<InstrumentId>>,
pub filters: Option<HashMap<String, String>>,
pub event_slugs: Option<Vec<String>>,
pub market_slugs: Option<Vec<String>>,
pub event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
#[builder(default = true)]
pub log_warnings: bool,
#[builder(default)]
pub use_gamma_markets: bool,
}
#[cfg(feature = "python")]
nautilus_core::impl_pyo3_config_getters!(PolymarketInstrumentProviderConfig {
load_all: bool,
load_ids: Option<Vec<InstrumentId>>,
filters: Option<HashMap<String, String>>,
event_slugs: Option<Vec<String>>,
market_slugs: Option<Vec<String>>,
event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
log_warnings: bool,
use_gamma_markets: bool,
});
impl Default for PolymarketInstrumentProviderConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl PolymarketInstrumentProviderConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn should_load_all(&self) -> bool {
self.load_all
|| self.event_slug_builder.is_some()
|| self.event_slugs.as_ref().is_some_and(|s| !s.is_empty())
|| self.market_slugs.as_ref().is_some_and(|s| !s.is_empty())
}
#[must_use]
pub fn has_load_ids(&self) -> bool {
self.load_ids.as_ref().is_some_and(|ids| !ids.is_empty())
}
}
#[derive(Clone, Serialize, Deserialize, bon::Builder)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(
module = "nautilus_trader.core.nautilus_pyo3.polymarket",
from_py_object
)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
)]
pub struct PolymarketDataClientConfig {
pub instrument_config: Option<PolymarketInstrumentProviderConfig>,
pub base_url_http: Option<String>,
pub base_url_ws: Option<String>,
pub base_url_rtds: Option<String>,
pub base_url_gamma: Option<String>,
pub base_url_data_api: Option<String>,
pub proxy_url: Option<String>,
#[builder(default = 60)]
pub http_timeout_secs: u64,
#[builder(default = 30)]
pub ws_timeout_secs: u64,
#[builder(default = crate::common::consts::WS_DEFAULT_SUBSCRIPTIONS)]
pub ws_max_subscriptions: usize,
pub update_instruments_interval_mins: Option<u64>,
#[builder(default)]
pub subscribe_new_markets: bool,
#[builder(default = true)]
pub drop_quotes_missing_side: bool,
#[builder(default = 8)]
pub new_market_fetch_max_concurrency: usize,
#[builder(default = true)]
pub auto_load_missing_instruments: bool,
#[builder(default = 100)]
pub auto_load_debounce_ms: u64,
#[builder(default = 12)]
pub auto_load_max_retries: u32,
#[builder(default = 5.0)]
pub auto_load_retry_delay_initial_secs: f64,
#[builder(default = 15.0)]
pub auto_load_retry_delay_max_secs: f64,
#[builder(default = true)]
pub resolve_poll_enabled: bool,
#[builder(default = 30)]
pub resolve_poll_interval_secs: u64,
#[builder(default = 10)]
pub resolve_poll_grace_secs: u64,
#[builder(default = 1800)]
pub resolve_poll_max_wait_secs: u64,
#[builder(default)]
#[serde(skip)]
pub filters: Vec<Arc<dyn InstrumentFilter>>,
#[serde(skip)]
pub new_market_filter: Option<Arc<dyn InstrumentFilter>>,
#[builder(default)]
pub transport_backend: TransportBackend,
}
#[cfg(feature = "python")]
nautilus_core::impl_pyo3_config_getters!(PolymarketDataClientConfig {
instrument_config: Option<PolymarketInstrumentProviderConfig>,
base_url_http: Option<String>,
base_url_ws: Option<String>,
base_url_gamma: Option<String>,
base_url_data_api: Option<String>,
http_timeout_secs: u64,
ws_timeout_secs: u64,
ws_max_subscriptions: usize,
update_instruments_interval_mins: Option<u64>,
subscribe_new_markets: bool,
auto_load_missing_instruments: bool,
auto_load_debounce_ms: u64,
auto_load_max_retries: u32,
auto_load_retry_delay_initial_secs: f64,
auto_load_retry_delay_max_secs: f64,
new_market_fetch_max_concurrency: usize,
resolve_poll_enabled: bool,
resolve_poll_interval_secs: u64,
resolve_poll_grace_secs: u64,
resolve_poll_max_wait_secs: u64,
base_url_rtds: Option<String>,
transport_backend: TransportBackend,
drop_quotes_missing_side: bool,
});
impl Default for PolymarketDataClientConfig {
fn default() -> Self {
Self {
update_instruments_interval_mins: Some(60),
..Self::builder().build()
}
}
}
impl Debug for PolymarketDataClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(stringify!(PolymarketDataClientConfig))
.field("instrument_config", &self.instrument_config)
.field("base_url_http", &self.base_url_http)
.field("base_url_ws", &self.base_url_ws)
.field("base_url_rtds", &self.base_url_rtds)
.field("base_url_gamma", &self.base_url_gamma)
.field("base_url_data_api", &self.base_url_data_api)
.field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
.field("http_timeout_secs", &self.http_timeout_secs)
.field("ws_timeout_secs", &self.ws_timeout_secs)
.field("ws_max_subscriptions", &self.ws_max_subscriptions)
.field(
"update_instruments_interval_mins",
&self.update_instruments_interval_mins,
)
.field("subscribe_new_markets", &self.subscribe_new_markets)
.field("drop_quotes_missing_side", &self.drop_quotes_missing_side)
.field(
"new_market_fetch_max_concurrency",
&self.new_market_fetch_max_concurrency,
)
.field(
"auto_load_missing_instruments",
&self.auto_load_missing_instruments,
)
.field("auto_load_debounce_ms", &self.auto_load_debounce_ms)
.field("auto_load_max_retries", &self.auto_load_max_retries)
.field(
"auto_load_retry_delay_initial_secs",
&self.auto_load_retry_delay_initial_secs,
)
.field(
"auto_load_retry_delay_max_secs",
&self.auto_load_retry_delay_max_secs,
)
.field("resolve_poll_enabled", &self.resolve_poll_enabled)
.field(
"resolve_poll_interval_secs",
&self.resolve_poll_interval_secs,
)
.field("resolve_poll_grace_secs", &self.resolve_poll_grace_secs)
.field(
"resolve_poll_max_wait_secs",
&self.resolve_poll_max_wait_secs,
)
.field("filters", &self.filters)
.field("new_market_filter", &self.new_market_filter)
.field("transport_backend", &self.transport_backend)
.finish()
}
}
impl PolymarketDataClientConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
validated_proxy_url(self.proxy_url.as_ref())
}
#[must_use]
pub const fn has_proxy_url(&self) -> bool {
self.proxy_url.is_some()
}
#[must_use]
pub fn http_url(&self) -> String {
self.base_url_http
.clone()
.unwrap_or_else(|| urls::clob_http_url().to_string())
}
#[must_use]
pub fn ws_url(&self) -> String {
self.base_url_ws
.clone()
.unwrap_or_else(|| urls::clob_ws_url().to_string())
}
#[must_use]
pub fn rtds_url(&self) -> String {
self.base_url_rtds
.clone()
.unwrap_or_else(|| urls::rtds_ws_url().to_string())
}
#[must_use]
pub fn gamma_url(&self) -> String {
self.base_url_gamma
.clone()
.unwrap_or_else(|| urls::gamma_api_url().to_string())
}
#[must_use]
pub fn data_api_url(&self) -> String {
self.base_url_data_api
.clone()
.unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
}
}
#[derive(Clone, Serialize, Deserialize, bon::Builder)]
#[serde(default, deny_unknown_fields)]
#[cfg_attr(
feature = "python",
pyo3::pyclass(
module = "nautilus_trader.core.nautilus_pyo3.polymarket",
from_py_object
)
)]
#[cfg_attr(
feature = "python",
pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
)]
pub struct PolymarketExecClientConfig {
#[builder(default)]
pub trader_id: TraderId,
#[builder(default = AccountId::from("POLYMARKET-001"))]
pub account_id: AccountId,
pub private_key: Option<String>,
pub api_key: Option<String>,
pub api_secret: Option<String>,
pub passphrase: Option<String>,
pub funder: Option<String>,
#[builder(default = SignatureType::Eoa)]
pub signature_type: SignatureType,
pub base_url_http: Option<String>,
pub base_url_ws: Option<String>,
pub base_url_data_api: Option<String>,
pub proxy_url: Option<String>,
#[builder(default = 60)]
pub http_timeout_secs: u64,
#[builder(default = 3)]
pub max_retries: u32,
#[builder(default = 1000)]
pub retry_delay_initial_ms: u64,
#[builder(default = 10000)]
pub retry_delay_max_ms: u64,
#[builder(default)]
pub heartbeat_enabled: bool,
#[builder(default)]
pub transport_backend: TransportBackend,
}
#[cfg(feature = "python")]
nautilus_core::impl_pyo3_config_getters!(PolymarketExecClientConfig {
trader_id: TraderId,
account_id: AccountId,
funder: Option<String>,
signature_type: SignatureType,
base_url_http: Option<String>,
base_url_ws: Option<String>,
base_url_data_api: Option<String>,
http_timeout_secs: u64,
max_retries: u32,
retry_delay_initial_ms: u64,
retry_delay_max_ms: u64,
heartbeat_enabled: bool,
transport_backend: TransportBackend,
});
impl Debug for PolymarketExecClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(stringify!(PolymarketExecClientConfig))
.field("trader_id", &self.trader_id)
.field("account_id", &self.account_id)
.field("private_key", &"***")
.field("api_key", &"***")
.field("api_secret", &"***")
.field("passphrase", &"***")
.field("funder", &self.funder)
.field("signature_type", &self.signature_type)
.field("base_url_http", &self.base_url_http)
.field("base_url_ws", &self.base_url_ws)
.field("base_url_data_api", &self.base_url_data_api)
.field("proxy_url", &self.proxy_url.as_ref().map(|_| REDACTED))
.field("http_timeout_secs", &self.http_timeout_secs)
.field("max_retries", &self.max_retries)
.field("retry_delay_initial_ms", &self.retry_delay_initial_ms)
.field("retry_delay_max_ms", &self.retry_delay_max_ms)
.field("heartbeat_enabled", &self.heartbeat_enabled)
.finish()
}
}
impl Default for PolymarketExecClientConfig {
fn default() -> Self {
Self::builder().build()
}
}
impl PolymarketExecClientConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn validated_proxy_url(&self) -> Result<Option<ProxyUrl>, TransportError> {
validated_proxy_url(self.proxy_url.as_ref())
}
#[must_use]
pub const fn has_proxy_url(&self) -> bool {
self.proxy_url.is_some()
}
#[must_use]
pub fn has_credentials(&self) -> bool {
self.private_key
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
|| self
.api_key
.as_deref()
.is_some_and(|s| !s.trim().is_empty())
}
#[must_use]
pub fn http_url(&self) -> String {
self.base_url_http
.clone()
.unwrap_or_else(|| urls::clob_http_url().to_string())
}
#[must_use]
pub fn ws_url(&self) -> String {
self.base_url_ws
.clone()
.unwrap_or_else(|| urls::clob_ws_url().to_string())
}
#[must_use]
pub fn data_api_url(&self) -> String {
self.base_url_data_api
.clone()
.unwrap_or_else(|| "https://data-api.polymarket.com".to_string())
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
fn updown_event_slug_config_builds_aligned_slugs() {
let config = PolymarketUpDownEventSlugConfig {
assets: vec![
"BTC".to_string(),
" eth ".to_string(),
String::new(),
"btc".to_string(),
],
interval_mins: 5,
periods: 2,
start_offset_periods: -1,
};
let slugs = config
.build_event_slugs_at_unix_secs(1_700_000_123)
.expect("event slugs should build");
assert_eq!(
slugs,
[
"btc-updown-5m-1699999800",
"eth-updown-5m-1699999800",
"btc-updown-5m-1700000100",
"eth-updown-5m-1700000100",
]
);
}
#[rstest]
fn updown_event_slug_config_rejects_zero_interval() {
let config = PolymarketUpDownEventSlugConfig {
interval_mins: 0,
..PolymarketUpDownEventSlugConfig::default()
};
let err = config
.build_event_slugs_at_unix_secs(1_700_000_123)
.expect_err("zero interval should fail");
assert!(
err.to_string()
.contains("event_slug_builder.interval_mins must be positive")
);
}
#[rstest]
fn test_data_config_toml_minimal() {
let config: PolymarketDataClientConfig = toml::from_str(
"
http_timeout_secs = 30
ws_max_subscriptions = 50
update_instruments_interval_mins = 5
subscribe_new_markets = true
new_market_fetch_max_concurrency = 16
auto_load_debounce_ms = 250
resolve_poll_enabled = true
resolve_poll_interval_secs = 30
resolve_poll_grace_secs = 10
resolve_poll_max_wait_secs = 1800
",
)
.unwrap();
assert_eq!(config.http_timeout_secs, 30);
assert_eq!(config.ws_max_subscriptions, 50);
assert_eq!(config.update_instruments_interval_mins, Some(5));
assert!(config.subscribe_new_markets);
assert_eq!(config.new_market_fetch_max_concurrency, 16);
assert_eq!(config.auto_load_debounce_ms, 250);
assert!(config.instrument_config.is_none());
assert!(config.resolve_poll_enabled);
assert_eq!(config.resolve_poll_interval_secs, 30);
assert_eq!(config.resolve_poll_grace_secs, 10);
assert_eq!(config.resolve_poll_max_wait_secs, 1800);
assert!(config.drop_quotes_missing_side);
assert!(config.filters.is_empty());
assert!(config.new_market_filter.is_none());
}
#[rstest]
fn test_data_config_toml_sets_drop_quotes_missing_side_false() {
let config: PolymarketDataClientConfig =
toml::from_str("drop_quotes_missing_side = false").unwrap();
assert!(!config.drop_quotes_missing_side);
}
#[rstest]
fn test_data_config_toml_with_instrument_config() {
let config: PolymarketDataClientConfig = toml::from_str(
r#"
[instrument_config]
load_all = true
event_slugs = ["btc-updown-5m-123", "eth-updown-15m-456"]
log_warnings = false
"#,
)
.unwrap();
let instrument_config = config.instrument_config.expect("instrument_config");
assert!(instrument_config.load_all);
assert_eq!(
instrument_config.event_slugs,
Some(vec![
"btc-updown-5m-123".to_string(),
"eth-updown-15m-456".to_string(),
]),
);
assert!(!instrument_config.log_warnings);
}
#[rstest]
fn test_exec_config_toml_empty_uses_defaults() {
let config: PolymarketExecClientConfig = toml::from_str("").unwrap();
let expected = PolymarketExecClientConfig::default();
assert_eq!(config.trader_id, expected.trader_id);
assert_eq!(config.account_id, expected.account_id);
assert_eq!(config.signature_type, expected.signature_type);
assert_eq!(config.http_timeout_secs, expected.http_timeout_secs);
assert_eq!(config.max_retries, expected.max_retries);
assert!(!config.heartbeat_enabled);
assert_eq!(config.transport_backend, expected.transport_backend);
}
#[rstest]
fn test_data_config_proxy_url_validates_and_redacts_debug() {
const SECRET: &str = "data-proxy-secret";
let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18081");
let config: PolymarketDataClientConfig =
toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
.expect("deserialize data config");
let validated = config
.validated_proxy_url()
.expect("validate data proxy")
.expect("data proxy configured");
let debug = format!("{config:?}");
assert_eq!(validated.expose(), proxy_url);
assert!(config.has_proxy_url());
assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
assert!(!debug.contains(SECRET));
}
#[rstest]
fn test_exec_config_proxy_url_validates_and_redacts_debug() {
const SECRET: &str = "exec-proxy-secret";
let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18082");
let config: PolymarketExecClientConfig =
toml::from_str(&format!("proxy_url = \"{proxy_url}\""))
.expect("deserialize execution config");
let validated = config
.validated_proxy_url()
.expect("validate execution proxy")
.expect("execution proxy configured");
let debug = format!("{config:?}");
assert_eq!(validated.expose(), proxy_url);
assert!(config.has_proxy_url());
assert!(debug.contains("proxy_url: Some(\"<redacted>\")"));
assert!(!debug.contains(SECRET));
}
#[rstest]
fn test_proxy_url_unset_preserves_direct_configuration() {
let data_config = PolymarketDataClientConfig::default();
let exec_config = PolymarketExecClientConfig::default();
assert_eq!(data_config.proxy_url, None);
assert_eq!(exec_config.proxy_url, None);
assert_eq!(data_config.validated_proxy_url().unwrap(), None);
assert_eq!(exec_config.validated_proxy_url().unwrap(), None);
assert!(!data_config.has_proxy_url());
assert!(!exec_config.has_proxy_url());
}
#[rstest]
fn test_invalid_proxy_url_error_redacts_credentials() {
const SECRET: &str = "invalid-proxy-secret";
let config = PolymarketDataClientConfig {
proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1")),
..PolymarketDataClientConfig::default()
};
let error = config
.validated_proxy_url()
.expect_err("malformed proxy URL should fail");
assert!(!error.to_string().contains(SECRET));
}
#[rstest]
fn test_socks_proxy_url_is_rejected_for_consistent_routing() {
let config = PolymarketExecClientConfig {
proxy_url: Some("socks5://127.0.0.1:1080".to_string()),
..PolymarketExecClientConfig::default()
};
let error = config
.validated_proxy_url()
.expect_err("SOCKS proxy should fail validation");
assert_eq!(
error.to_string(),
"invalid URL: SOCKS proxy scheme 'socks5' is not yet supported for WebSocket connections; use an http:// or https:// proxy"
);
}
}