1use std::{any::Any, cell::RefCell, rc::Rc, sync::Arc};
19
20use nautilus_common::{
21 cache::CacheView,
22 clients::{DataClient, ExecutionClient},
23 clock::Clock,
24 factories::{ClientConfig, DataClientFactory, ExecutionClientFactory},
25};
26use nautilus_live::ExecutionClientCore;
27use nautilus_model::{
28 enums::{AccountType, OmsType},
29 identifiers::{ClientId, TraderId},
30};
31use nautilus_network::retry::RetryConfig;
32#[cfg(test)]
33use nautilus_network::websocket::proxy::ProxyUrl;
34
35use crate::{
36 common::consts::{POLYMARKET, POLYMARKET_VENUE},
37 config::{PolymarketDataClientConfig, PolymarketExecutionClientConfig},
38 data::PolymarketDataClient,
39 execution::PolymarketExecutionClient,
40 http::{
41 clob::PolymarketClobPublicClient, data_api::PolymarketDataApiHttpClient,
42 gamma::PolymarketGammaHttpClient,
43 },
44 websocket::pool::PolymarketMarketConnectionPool,
45};
46
47impl ClientConfig for PolymarketDataClientConfig {
48 fn as_any(&self) -> &dyn Any {
49 self
50 }
51}
52
53#[cfg_attr(
55 feature = "python",
56 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
57)]
58#[cfg_attr(
59 feature = "python",
60 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
61)]
62#[derive(Debug, Clone)]
63pub struct PolymarketDataClientFactory;
64
65impl DataClientFactory for PolymarketDataClientFactory {
66 fn create(
67 &self,
68 name: &str,
69 config: &dyn ClientConfig,
70 _cache: CacheView,
71 _clock: Rc<RefCell<dyn Clock>>,
72 ) -> anyhow::Result<Box<dyn DataClient>> {
73 let polymarket_config = config
74 .as_any()
75 .downcast_ref::<PolymarketDataClientConfig>()
76 .ok_or_else(|| {
77 anyhow::anyhow!(
78 "Invalid config type for PolymarketDataClientFactory. Expected PolymarketDataClientConfig, was {config:?}",
79 )
80 })?;
81
82 Ok(Box::new(Self::create_client(name, polymarket_config)?))
83 }
84
85 fn name(&self) -> &'static str {
86 POLYMARKET
87 }
88
89 fn config_type(&self) -> &'static str {
90 "PolymarketDataClientConfig"
91 }
92}
93
94impl PolymarketDataClientFactory {
95 fn create_client(
96 name: &str,
97 polymarket_config: &PolymarketDataClientConfig,
98 ) -> anyhow::Result<PolymarketDataClient> {
99 let client_id = ClientId::from(name);
100 let proxy_url = polymarket_config.validated_proxy_url()?;
101 polymarket_config.polybolt_credential()?;
103
104 let gamma_client = PolymarketGammaHttpClient::new_with_proxy(
105 Some(polymarket_config.gamma_url()),
106 polymarket_config.http_timeout_secs,
107 RetryConfig {
108 max_retries: 10,
109 initial_delay_ms: 5_000,
110 max_delay_ms: 30_000,
111 backoff_factor: 1.5,
112 jitter_ms: 2_000,
113 operation_timeout_ms: Some(30_000),
114 immediate_first: true,
115 max_elapsed_ms: Some(300_000),
116 },
117 proxy_url.clone(),
118 )?;
119
120 let clob_public_client = PolymarketClobPublicClient::new_with_proxy(
121 polymarket_config.base_url_http.clone(),
122 polymarket_config.http_timeout_secs,
123 proxy_url.clone(),
124 )?;
125
126 let data_api_client = PolymarketDataApiHttpClient::new_with_proxy(
127 Some(polymarket_config.data_api_url()),
128 polymarket_config.http_timeout_secs,
129 proxy_url.clone(),
130 )?;
131
132 let ws_client = PolymarketMarketConnectionPool::new_with_proxy(
133 polymarket_config.base_url_ws.clone(),
134 polymarket_config.subscribe_new_markets,
135 polymarket_config.transport_backend,
136 polymarket_config.ws_max_subscriptions,
137 proxy_url.clone(),
138 );
139
140 let mut client = PolymarketDataClient::new_with_proxy(
141 client_id,
142 polymarket_config.clone(),
143 gamma_client,
144 clob_public_client,
145 data_api_client,
146 ws_client,
147 proxy_url,
148 );
149
150 for filter in &polymarket_config.filters {
151 client.add_instrument_filter(Arc::clone(filter));
152 }
153
154 Ok(client)
155 }
156}
157
158impl ClientConfig for PolymarketExecutionClientConfig {
159 fn as_any(&self) -> &dyn Any {
160 self
161 }
162}
163
164#[cfg_attr(
166 feature = "python",
167 pyo3::pyclass(module = "nautilus_trader.adapters.polymarket", from_py_object)
168)]
169#[cfg_attr(
170 feature = "python",
171 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.polymarket")
172)]
173#[derive(Debug, Clone)]
174pub struct PolymarketExecutionClientFactory;
175
176impl ExecutionClientFactory for PolymarketExecutionClientFactory {
177 fn create(
178 &self,
179 trader_id: TraderId,
180 name: &str,
181 config: &dyn ClientConfig,
182 cache: CacheView,
183 _clock: Rc<RefCell<dyn Clock>>,
184 ) -> anyhow::Result<Box<dyn ExecutionClient>> {
185 let polymarket_config = config
186 .as_any()
187 .downcast_ref::<PolymarketExecutionClientConfig>()
188 .ok_or_else(|| {
189 anyhow::anyhow!(
190 "Invalid config type for PolymarketExecutionClientFactory. Expected PolymarketExecutionClientConfig, was {config:?}",
191 )
192 })?
193 .clone();
194
195 let oms_type = OmsType::Netting;
196 let account_type = AccountType::Cash;
197
198 let client_id = ClientId::from(name);
199 let core = ExecutionClientCore::new(
200 trader_id,
201 client_id,
202 *POLYMARKET_VENUE,
203 oms_type,
204 polymarket_config.account_id,
205 account_type,
206 None, cache,
208 );
209
210 let client = PolymarketExecutionClient::new(core, polymarket_config)?;
211
212 Ok(Box::new(client))
213 }
214
215 fn name(&self) -> &'static str {
216 POLYMARKET
217 }
218
219 fn config_type(&self) -> &'static str {
220 "PolymarketExecutionClientConfig"
221 }
222}
223
224#[cfg(test)]
225pub(crate) async fn spawn_rejecting_proxy(
226 connection_count: usize,
227) -> (
228 std::net::SocketAddr,
229 std::sync::Arc<tokio::sync::Mutex<Vec<String>>>,
230) {
231 use tokio::io::{AsyncReadExt, AsyncWriteExt};
232
233 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
234 let addr = listener.local_addr().unwrap();
235 let requests = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new()));
236 let captured = std::sync::Arc::clone(&requests);
237
238 tokio::spawn(async move {
239 for _ in 0..connection_count {
240 let (mut stream, _) = listener.accept().await.unwrap();
241 let mut request = Vec::new();
242 let mut chunk = [0u8; 1024];
243 loop {
244 let read = stream.read(&mut chunk).await.unwrap();
245 if read == 0 {
246 break;
247 }
248 request.extend_from_slice(&chunk[..read]);
249 if request.windows(4).any(|window| window == b"\r\n\r\n") {
250 break;
251 }
252 }
253 captured
254 .lock()
255 .await
256 .push(String::from_utf8(request).unwrap());
257 stream
258 .write_all(
259 b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n",
260 )
261 .await
262 .unwrap();
263 }
264 });
265
266 (addr, requests)
267}
268
269#[cfg(test)]
270mod tests {
271 use std::{cell::RefCell, rc::Rc};
272
273 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
274 use nautilus_common::{
275 cache::Cache,
276 clock::VirtualClock,
277 factories::{ClientConfig, DataClientFactory, ExecutionClientFactory},
278 live::runner::replace_data_event_sender,
279 messages::DataEvent,
280 };
281 use rstest::rstest;
282
283 use super::*;
284 use crate::{
285 common::credential::Credential,
286 config::{PolymarketDataClientConfig, PolymarketExecutionClientConfig},
287 http::clob::PolymarketClobHttpClient,
288 };
289
290 #[derive(Debug)]
291 struct WrongConfig;
292
293 impl ClientConfig for WrongConfig {
294 fn as_any(&self) -> &dyn std::any::Any {
295 self
296 }
297 }
298
299 #[rstest]
300 fn test_polymarket_data_client_factory_creation() {
301 let factory = PolymarketDataClientFactory;
302 assert_eq!(factory.name(), POLYMARKET);
303 assert_eq!(factory.config_type(), "PolymarketDataClientConfig");
304 }
305
306 #[rstest]
307 fn test_polymarket_data_client_config_implements_client_config() {
308 let config = PolymarketDataClientConfig::default();
309 let boxed_config: Box<dyn ClientConfig> = Box::new(config);
310 let downcasted = boxed_config
311 .as_any()
312 .downcast_ref::<PolymarketDataClientConfig>();
313 assert!(downcasted.is_some());
314 }
315
316 #[rstest]
317 fn test_polymarket_data_client_factory_rejects_wrong_config_type() {
318 let factory = PolymarketDataClientFactory;
319 let wrong_config = WrongConfig;
320 let cache = Rc::new(RefCell::new(Cache::default()));
321 let clock = Rc::new(RefCell::new(VirtualClock::new()));
322
323 let result = factory.create(POLYMARKET, &wrong_config, cache.into(), clock);
324 assert!(result.is_err());
325 assert!(
326 result
327 .err()
328 .unwrap()
329 .to_string()
330 .contains("Invalid config type")
331 );
332 }
333
334 #[rstest]
335 fn test_polymarket_execution_client_factory_creation() {
336 let factory = PolymarketExecutionClientFactory;
337 assert_eq!(factory.name(), POLYMARKET);
338 assert_eq!(factory.config_type(), "PolymarketExecutionClientConfig");
339 }
340
341 #[rstest]
342 fn test_polymarket_exec_client_config_implements_client_config() {
343 let config = PolymarketExecutionClientConfig::default();
344 let boxed_config: Box<dyn ClientConfig> = Box::new(config);
345 let downcasted = boxed_config
346 .as_any()
347 .downcast_ref::<PolymarketExecutionClientConfig>();
348 assert!(downcasted.is_some());
349 }
350
351 #[rstest]
352 fn test_polymarket_execution_client_factory_rejects_wrong_config_type() {
353 let factory = PolymarketExecutionClientFactory;
354 let wrong_config = WrongConfig;
355 let cache = Rc::new(RefCell::new(Cache::default()));
356
357 let result = factory.create(
358 TraderId::from("TRADER-001"),
359 POLYMARKET,
360 &wrong_config,
361 cache.into(),
362 Rc::new(RefCell::new(VirtualClock::new())),
363 );
364 assert!(result.is_err());
365 assert!(
366 result
367 .err()
368 .unwrap()
369 .to_string()
370 .contains("Invalid config type")
371 );
372 }
373
374 #[rstest]
375 fn data_factory_invalid_proxy_error_redacts_credentials() {
376 const SECRET: &str = "data-factory-proxy-secret";
377 let factory = PolymarketDataClientFactory;
378 let config = PolymarketDataClientConfig {
379 proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1").into()),
380 ..PolymarketDataClientConfig::default()
381 };
382 let cache = Rc::new(RefCell::new(Cache::default()));
383 let clock = Rc::new(RefCell::new(VirtualClock::new()));
384 let Err(e) = factory.create(POLYMARKET, &config, cache.into(), clock) else {
385 panic!("malformed proxy URL should fail");
386 };
387
388 assert!(!e.to_string().contains(SECRET));
389 }
390
391 #[rstest]
392 fn execution_factory_invalid_proxy_error_redacts_credentials() {
393 const SECRET: &str = "execution-factory-proxy-secret";
394 let factory = PolymarketExecutionClientFactory;
395 let config = PolymarketExecutionClientConfig {
396 proxy_url: Some(format!("http://proxy-user:{SECRET}@[::1").into()),
397 ..PolymarketExecutionClientConfig::default()
398 };
399 let cache = Rc::new(RefCell::new(Cache::default()));
400 let Err(e) = factory.create(
401 TraderId::from("TRADER-001"),
402 POLYMARKET,
403 &config,
404 cache.into(),
405 Rc::new(RefCell::new(VirtualClock::new())),
406 ) else {
407 panic!("malformed proxy URL should fail");
408 };
409
410 assert!(!e.to_string().contains(SECRET));
411 }
412
413 #[rstest]
414 #[tokio::test]
415 async fn data_factory_propagates_configured_proxy() {
416 const USERNAME: &str = "proxytest";
417 const SECRET: &str = "http-client-proxy-secret";
418 let (proxy_addr, requests) = spawn_rejecting_proxy(3).await;
419 let proxy_url = format!("http://{USERNAME}:{SECRET}@{proxy_addr}");
420 let (data_tx, _data_rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
421 replace_data_event_sender(data_tx);
422 let config = PolymarketDataClientConfig {
423 base_url_http: Some("https://clob-public.fixture".to_string()),
424 base_url_ws: Some("wss://market.fixture/ws".to_string()),
425 base_url_gamma: Some("https://gamma.fixture".to_string()),
426 base_url_data_api: Some("https://data.fixture".to_string()),
427 base_url_reference_prices: Some("wss://polybolt.fixture/ws".to_string()),
428 proxy_url: Some(proxy_url.clone().into()),
429 http_timeout_secs: 2,
430 ..PolymarketDataClientConfig::default()
431 };
432 let client = PolymarketDataClientFactory::create_client(POLYMARKET, &config).unwrap();
433
434 let errors = [
435 client
436 .provider()
437 .http_client()
438 .request_tags()
439 .await
440 .unwrap_err()
441 .to_string(),
442 client
443 .clob_public_client()
444 .get_book("public-token")
445 .await
446 .unwrap_err()
447 .to_string(),
448 client
449 .data_api_client()
450 .get_positions("0x0000000000000000000000000000000000000002")
451 .await
452 .unwrap_err()
453 .to_string(),
454 ];
455 let configured_proxy = client.config().validated_proxy_url().unwrap().unwrap();
456
457 assert_eq!(client.ws_client().proxy_url().unwrap().expose(), proxy_url);
458 assert_eq!(
459 client.polybolt_feed().proxy_url().unwrap().expose(),
460 proxy_url
461 );
462 assert_eq!(configured_proxy.expose(), proxy_url);
463
464 let requests = requests.lock().await;
465 let request_lines = requests
466 .iter()
467 .map(|request| request.lines().next().unwrap().to_string())
468 .collect::<Vec<_>>();
469 let expected_auth = format!("Basic {}", BASE64.encode(format!("{USERNAME}:{SECRET}")));
470
471 assert_eq!(
472 request_lines,
473 [
474 "CONNECT gamma.fixture:443 HTTP/1.1",
475 "CONNECT clob-public.fixture:443 HTTP/1.1",
476 "CONNECT data.fixture:443 HTTP/1.1",
477 ]
478 );
479
480 for request in requests.iter() {
481 let auth = request
482 .lines()
483 .find_map(|line| {
484 let (name, value) = line.split_once(':')?;
485 name.eq_ignore_ascii_case("proxy-authorization")
486 .then_some(value.trim())
487 })
488 .expect("Proxy-Authorization header");
489 assert_eq!(auth, expected_auth);
490 }
491
492 for error in errors {
493 assert!(!error.contains(SECRET));
494 assert!(!error.contains(&BASE64.encode(SECRET)));
495 assert!(!error.contains(&expected_auth));
496 }
497 }
498
499 #[rstest]
500 #[tokio::test]
501 async fn authenticated_clob_http_client_uses_configured_proxy() {
502 const USERNAME: &str = "proxytest";
503 const SECRET: &str = "authenticated-clob-proxy-secret";
504 let (proxy_addr, requests) = spawn_rejecting_proxy(1).await;
505 let proxy_url =
506 ProxyUrl::parse(format!("http://{USERNAME}:{SECRET}@{proxy_addr}")).unwrap();
507 let credential = Credential::new(
508 "fixture-key".into(),
509 "Zml4dHVyZQ==".into(),
510 "fixture-passphrase".into(),
511 )
512 .unwrap();
513 let clob_auth = PolymarketClobHttpClient::new_with_proxy(
514 credential,
515 "0x0000000000000000000000000000000000000001".to_string(),
516 Some("https://clob-auth.fixture".to_string()),
517 2,
518 Some(proxy_url),
519 )
520 .unwrap();
521
522 let error = clob_auth
523 .get_book("auth-token")
524 .await
525 .unwrap_err()
526 .to_string();
527 let requests = requests.lock().await;
528 let request = requests.first().expect("captured CONNECT request");
529 let request_line = request.lines().next().unwrap();
530 let expected_auth = format!("Basic {}", BASE64.encode(format!("{USERNAME}:{SECRET}")));
531 let auth = request
532 .lines()
533 .find_map(|line| {
534 let (name, value) = line.split_once(':')?;
535 name.eq_ignore_ascii_case("proxy-authorization")
536 .then_some(value.trim())
537 })
538 .expect("Proxy-Authorization header");
539
540 assert_eq!(request_line, "CONNECT clob-auth.fixture:443 HTTP/1.1");
541 assert_eq!(auth, expected_auth);
542 assert!(!error.contains(SECRET));
543 assert!(!error.contains(&BASE64.encode(SECRET)));
544 assert!(!error.contains(&expected_auth));
545 }
546}