Skip to main content

nautilus_polymarket/
factories.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//! Factory functions for creating Polymarket clients and components.
17
18use 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/// Factory for creating Polymarket data clients.
54#[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        // Fail fast on malformed credentials; the client resolves them again for its feed.
102        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/// Factory for creating Polymarket execution clients.
165#[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, // base_currency
207            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}