Skip to main content

nautilus_polymarket/python/
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
16use nautilus_core::python::to_pyvalue_err;
17use nautilus_model::identifiers::{AccountId, InstrumentId};
18use nautilus_network::websocket::TransportBackend;
19use pyo3::{PyResult, pymethods};
20
21use crate::{
22    common::enums::SignatureType,
23    config::{
24        PolymarketDataClientConfig, PolymarketExecutionClientConfig,
25        PolymarketInstrumentProviderConfig, PolymarketUpDownEventSlugConfig,
26    },
27    providers::build_gamma_params_from_hashmap,
28};
29
30const PY_OPTION_U64_MISSING_SENTINEL: u64 = u64::MAX;
31
32fn resolve_optional_u64_arg(value: Option<u64>, default: Option<u64>) -> Option<u64> {
33    match value {
34        Some(PY_OPTION_U64_MISSING_SENTINEL) => default,
35        other => other,
36    }
37}
38
39#[pymethods]
40#[pyo3_stub_gen::derive::gen_stub_pymethods]
41impl PolymarketUpDownEventSlugConfig {
42    /// Rust-backed event slug builder for Polymarket Up/Down markets.
43    ///
44    /// Up/Down event slugs follow the pattern
45    /// `{asset}-updown-{interval_mins}m-{unix_timestamp}`, where the timestamp is
46    /// aligned to the start of the interval. The builder emits slugs for each
47    /// configured asset and period.
48    #[new]
49    #[pyo3(signature = (assets=None, interval_mins=None, periods=None, start_offset_periods=None))]
50    fn py_new(
51        assets: Option<Vec<String>>,
52        interval_mins: Option<u64>,
53        periods: Option<u64>,
54        start_offset_periods: Option<i64>,
55    ) -> Self {
56        let default = Self::default();
57        Self {
58            assets: assets.unwrap_or(default.assets),
59            interval_mins: interval_mins.unwrap_or(default.interval_mins),
60            periods: periods.unwrap_or(default.periods),
61            start_offset_periods: start_offset_periods.unwrap_or(default.start_offset_periods),
62        }
63    }
64
65    fn __repr__(&self) -> String {
66        format!("{self:?}")
67    }
68
69    fn __str__(&self) -> String {
70        format!("{self:?}")
71    }
72}
73
74#[pymethods]
75#[pyo3_stub_gen::derive::gen_stub_pymethods]
76impl PolymarketInstrumentProviderConfig {
77    /// Configuration for the Polymarket instrument provider.
78    ///
79    /// This mirrors the Python adapter's `instrument_config` layering so scoped
80    /// market bootstrap can migrate naturally to the Rust/pyO3 live path.
81    #[new]
82    #[pyo3(signature = (load_all=None, load_ids=None, filters=None, event_slugs=None, market_slugs=None, event_slug_builder=None, log_warnings=None, use_gamma_markets=None, series_ids=None))]
83    #[expect(clippy::too_many_arguments)]
84    fn py_new(
85        load_all: Option<bool>,
86        load_ids: Option<Vec<InstrumentId>>,
87        filters: Option<std::collections::HashMap<String, String>>,
88        event_slugs: Option<Vec<String>>,
89        market_slugs: Option<Vec<String>>,
90        event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
91        log_warnings: Option<bool>,
92        use_gamma_markets: Option<bool>,
93        series_ids: Option<Vec<u64>>,
94    ) -> PyResult<Self> {
95        let default = Self::default();
96        let config = Self {
97            load_all: load_all.unwrap_or(default.load_all),
98            load_ids,
99            filters,
100            event_slugs,
101            market_slugs,
102            event_slug_builder,
103            series_ids,
104            log_warnings: log_warnings.unwrap_or(default.log_warnings),
105            use_gamma_markets: use_gamma_markets.unwrap_or(default.use_gamma_markets),
106        };
107
108        if let Some(filters) = config.filters.as_ref() {
109            build_gamma_params_from_hashmap(filters)
110                .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket Gamma filters: {e}")))?;
111        }
112        Ok(config)
113    }
114
115    fn __repr__(&self) -> String {
116        format!("{self:?}")
117    }
118
119    fn __str__(&self) -> String {
120        format!("{self:?}")
121    }
122}
123
124#[pymethods]
125#[pyo3_stub_gen::derive::gen_stub_pymethods]
126impl PolymarketDataClientConfig {
127    /// Configuration for the Polymarket data client.
128    ///
129    /// `filters` and `new_market_filter` hold `Arc<dyn InstrumentFilter>` trait objects
130    /// and are skipped during serialization; they default to empty/`None` and must be
131    /// installed programmatically after deserialization.
132    #[new]
133    #[pyo3(signature = (instrument_config=None, base_url_http=None, base_url_ws=None, base_url_gamma=None, base_url_data_api=None, http_timeout_secs=None, ws_timeout_secs=None, ws_max_subscriptions=None, update_instruments_interval_mins=PY_OPTION_U64_MISSING_SENTINEL, subscribe_new_markets=None, auto_load_missing_instruments=None, auto_load_debounce_ms=None, auto_load_max_retries=None, auto_load_retry_delay_initial_secs=None, auto_load_retry_delay_max_secs=None, new_market_fetch_max_concurrency=None, resolve_poll_enabled=None, resolve_poll_interval_secs=None, resolve_poll_grace_secs=None, resolve_poll_max_wait_secs=None, base_url_rtds=None, transport_backend=None, drop_quotes_missing_side=None, proxy_url=None, compute_effective_deltas=None))]
134    #[expect(clippy::too_many_arguments)]
135    fn py_new(
136        instrument_config: Option<PolymarketInstrumentProviderConfig>,
137        base_url_http: Option<String>,
138        base_url_ws: Option<String>,
139        base_url_gamma: Option<String>,
140        base_url_data_api: Option<String>,
141        http_timeout_secs: Option<u64>,
142        ws_timeout_secs: Option<u64>,
143        ws_max_subscriptions: Option<usize>,
144        update_instruments_interval_mins: Option<u64>,
145        subscribe_new_markets: Option<bool>,
146        auto_load_missing_instruments: Option<bool>,
147        auto_load_debounce_ms: Option<u64>,
148        auto_load_max_retries: Option<u32>,
149        auto_load_retry_delay_initial_secs: Option<f64>,
150        auto_load_retry_delay_max_secs: Option<f64>,
151        new_market_fetch_max_concurrency: Option<usize>,
152        resolve_poll_enabled: Option<bool>,
153        resolve_poll_interval_secs: Option<u64>,
154        resolve_poll_grace_secs: Option<u64>,
155        resolve_poll_max_wait_secs: Option<u64>,
156        base_url_rtds: Option<String>,
157        transport_backend: Option<TransportBackend>,
158        drop_quotes_missing_side: Option<bool>,
159        proxy_url: Option<String>,
160        compute_effective_deltas: Option<bool>,
161    ) -> PyResult<Self> {
162        let default = Self::default();
163
164        let config = Self {
165            instrument_config,
166            filters: Vec::new(),
167            base_url_http,
168            base_url_ws,
169            base_url_rtds,
170            base_url_gamma,
171            base_url_data_api,
172            proxy_url,
173            http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
174            ws_timeout_secs: ws_timeout_secs.unwrap_or(default.ws_timeout_secs),
175            ws_max_subscriptions: ws_max_subscriptions.unwrap_or(default.ws_max_subscriptions),
176            update_instruments_interval_mins: resolve_optional_u64_arg(
177                update_instruments_interval_mins,
178                default.update_instruments_interval_mins,
179            ),
180            subscribe_new_markets: subscribe_new_markets.unwrap_or(default.subscribe_new_markets),
181            new_market_filter: None,
182            new_market_fetch_max_concurrency: new_market_fetch_max_concurrency
183                .unwrap_or(default.new_market_fetch_max_concurrency),
184            drop_quotes_missing_side: drop_quotes_missing_side
185                .unwrap_or(default.drop_quotes_missing_side),
186            auto_load_missing_instruments: auto_load_missing_instruments
187                .unwrap_or(default.auto_load_missing_instruments),
188            auto_load_debounce_ms: auto_load_debounce_ms.unwrap_or(default.auto_load_debounce_ms),
189            auto_load_max_retries: auto_load_max_retries.unwrap_or(default.auto_load_max_retries),
190            auto_load_retry_delay_initial_secs: auto_load_retry_delay_initial_secs
191                .unwrap_or(default.auto_load_retry_delay_initial_secs),
192            auto_load_retry_delay_max_secs: auto_load_retry_delay_max_secs
193                .unwrap_or(default.auto_load_retry_delay_max_secs),
194            resolve_poll_enabled: resolve_poll_enabled.unwrap_or(default.resolve_poll_enabled),
195            resolve_poll_interval_secs: resolve_poll_interval_secs
196                .unwrap_or(default.resolve_poll_interval_secs),
197            resolve_poll_grace_secs: resolve_poll_grace_secs
198                .unwrap_or(default.resolve_poll_grace_secs),
199            resolve_poll_max_wait_secs: resolve_poll_max_wait_secs
200                .unwrap_or(default.resolve_poll_max_wait_secs),
201            transport_backend: transport_backend.unwrap_or(default.transport_backend),
202            compute_effective_deltas: compute_effective_deltas
203                .unwrap_or(default.compute_effective_deltas),
204        };
205        config
206            .validated_proxy_url()
207            .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
208        Ok(config)
209    }
210
211    #[getter]
212    #[pyo3(name = "has_proxy_url")]
213    const fn py_has_proxy_url(&self) -> bool {
214        self.has_proxy_url()
215    }
216
217    fn __repr__(&self) -> String {
218        format!("{self:?}")
219    }
220
221    fn __str__(&self) -> String {
222        format!("{self:?}")
223    }
224}
225
226#[pymethods]
227#[pyo3_stub_gen::derive::gen_stub_pymethods]
228impl PolymarketExecutionClientConfig {
229    /// Configuration for the Polymarket execution client.
230    ///
231    /// `Debug` is implemented manually to redact secrets, so it is not part of the
232    /// derive list.
233    #[new]
234    #[expect(clippy::too_many_arguments)]
235    #[pyo3(signature = (account_id=None, private_key=None, api_key=None, api_secret=None, passphrase=None, funder=None, signature_type=None, base_url_http=None, base_url_ws=None, base_url_data_api=None, http_timeout_secs=None, max_retries=None, retry_delay_initial_ms=None, retry_delay_max_ms=None, heartbeat_enabled=None, transport_backend=None, proxy_url=None, instrument_config=None))]
236    fn py_new(
237        account_id: Option<String>,
238        private_key: Option<String>,
239        api_key: Option<String>,
240        api_secret: Option<String>,
241        passphrase: Option<String>,
242        funder: Option<String>,
243        signature_type: Option<SignatureType>,
244        base_url_http: Option<String>,
245        base_url_ws: Option<String>,
246        base_url_data_api: Option<String>,
247        http_timeout_secs: Option<u64>,
248        max_retries: Option<u32>,
249        retry_delay_initial_ms: Option<u64>,
250        retry_delay_max_ms: Option<u64>,
251        heartbeat_enabled: Option<bool>,
252        transport_backend: Option<TransportBackend>,
253        proxy_url: Option<String>,
254        instrument_config: Option<PolymarketInstrumentProviderConfig>,
255    ) -> PyResult<Self> {
256        let default = Self::default();
257        let config = Self {
258            account_id: account_id.map_or(default.account_id, |s| AccountId::from(s.as_str())),
259            private_key,
260            api_key,
261            api_secret,
262            passphrase,
263            funder,
264            signature_type: signature_type.unwrap_or(default.signature_type),
265            base_url_http,
266            base_url_ws,
267            base_url_data_api,
268            proxy_url,
269            http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
270            max_retries: max_retries.unwrap_or(default.max_retries),
271            retry_delay_initial_ms: retry_delay_initial_ms
272                .unwrap_or(default.retry_delay_initial_ms),
273            retry_delay_max_ms: retry_delay_max_ms.unwrap_or(default.retry_delay_max_ms),
274            heartbeat_enabled: heartbeat_enabled.unwrap_or(default.heartbeat_enabled),
275            transport_backend: transport_backend.unwrap_or(default.transport_backend),
276            instrument_config,
277        };
278        config
279            .validated_proxy_url()
280            .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
281        Ok(config)
282    }
283
284    #[getter]
285    #[pyo3(name = "has_proxy_url")]
286    const fn py_has_proxy_url(&self) -> bool {
287        self.has_proxy_url()
288    }
289
290    fn __repr__(&self) -> String {
291        format!("{self:?}")
292    }
293
294    fn __str__(&self) -> String {
295        format!("{self:?}")
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use pyo3::{
302        Bound, IntoPyObject, Python,
303        types::{PyAnyMethods, PyDict, PyDictMethods, PyTuple},
304    };
305    use rstest::rstest;
306
307    use super::*;
308
309    fn construct_data_client_config(
310        py: Python<'_>,
311        args: Option<&Bound<'_, PyTuple>>,
312        kwargs: Option<&Bound<'_, PyDict>>,
313    ) -> PolymarketDataClientConfig {
314        let cls = py.get_type::<PolymarketDataClientConfig>();
315
316        let config = match args {
317            Some(args) => cls.call(args, kwargs),
318            None => cls.call((), kwargs),
319        }
320        .expect("construct PolymarketDataClientConfig");
321
322        config
323            .extract::<PolymarketDataClientConfig>()
324            .expect("extract PolymarketDataClientConfig")
325    }
326
327    #[rstest]
328    fn direct_pyo3_constructor_preserves_none_update_interval() {
329        Python::initialize();
330        Python::attach(|py| {
331            let kwargs = PyDict::new(py);
332            kwargs
333                .set_item("update_instruments_interval_mins", py.None())
334                .unwrap();
335
336            let config = construct_data_client_config(py, None, Some(&kwargs));
337
338            assert_eq!(config.update_instruments_interval_mins, None);
339        });
340    }
341
342    #[rstest]
343    fn direct_pyo3_constructor_uses_default_update_interval_when_omitted() {
344        Python::initialize();
345        Python::attach(|py| {
346            let config = construct_data_client_config(py, None, None);
347
348            assert_eq!(
349                config.update_instruments_interval_mins,
350                PolymarketDataClientConfig::default().update_instruments_interval_mins,
351            );
352            assert!(config.drop_quotes_missing_side);
353        });
354    }
355
356    #[rstest]
357    fn direct_pyo3_constructor_preserves_none_update_interval_for_positional_args() {
358        Python::initialize();
359        Python::attach(|py| {
360            let args = PyTuple::new(
361                py,
362                [
363                    py.None(),
364                    py.None(),
365                    py.None(),
366                    py.None(),
367                    py.None(),
368                    py.None(),
369                    py.None(),
370                    py.None(),
371                    py.None(),
372                    py.None(),
373                ],
374            )
375            .expect("args");
376
377            let config = construct_data_client_config(py, Some(&args), None);
378
379            assert_eq!(config.update_instruments_interval_mins, None);
380        });
381    }
382
383    #[rstest]
384    fn direct_pyo3_constructor_sets_new_market_fetch_max_concurrency() {
385        Python::initialize();
386        Python::attach(|py| {
387            let kwargs = PyDict::new(py);
388            kwargs
389                .set_item("new_market_fetch_max_concurrency", 23)
390                .unwrap();
391
392            let config = construct_data_client_config(py, None, Some(&kwargs));
393
394            assert_eq!(config.new_market_fetch_max_concurrency, 23);
395        });
396    }
397
398    #[rstest]
399    fn direct_pyo3_constructor_sets_drop_quotes_missing_side() {
400        Python::initialize();
401        Python::attach(|py| {
402            let kwargs = PyDict::new(py);
403            kwargs.set_item("drop_quotes_missing_side", false).unwrap();
404
405            let config = construct_data_client_config(py, None, Some(&kwargs));
406
407            assert!(!config.drop_quotes_missing_side);
408        });
409    }
410
411    #[rstest]
412    fn direct_pyo3_constructor_sets_compute_effective_deltas() {
413        Python::initialize();
414        Python::attach(|py| {
415            let kwargs = PyDict::new(py);
416            kwargs.set_item("compute_effective_deltas", true).unwrap();
417
418            let config = construct_data_client_config(py, None, Some(&kwargs));
419
420            assert!(config.compute_effective_deltas);
421        });
422    }
423
424    #[rstest]
425    fn direct_pyo3_constructor_sets_base_url_rtds() {
426        Python::initialize();
427        Python::attach(|py| {
428            let kwargs = PyDict::new(py);
429            kwargs
430                .set_item("base_url_rtds", "wss://ws-live-data.example")
431                .unwrap();
432
433            let config = construct_data_client_config(py, None, Some(&kwargs));
434
435            assert_eq!(
436                config.base_url_rtds.as_deref(),
437                Some("wss://ws-live-data.example")
438            );
439        });
440    }
441
442    #[rstest]
443    fn direct_pyo3_constructor_preserves_existing_positional_order() {
444        Python::initialize();
445        Python::attach(|py| {
446            let args = PyTuple::new(
447                py,
448                [
449                    py.None(),
450                    "https://http.example"
451                        .into_pyobject(py)
452                        .unwrap()
453                        .into_any()
454                        .unbind(),
455                    "wss://ws.example"
456                        .into_pyobject(py)
457                        .unwrap()
458                        .into_any()
459                        .unbind(),
460                    "https://gamma.example"
461                        .into_pyobject(py)
462                        .unwrap()
463                        .into_any()
464                        .unbind(),
465                    "https://data.example"
466                        .into_pyobject(py)
467                        .unwrap()
468                        .into_any()
469                        .unbind(),
470                    41_u64.into_pyobject(py).unwrap().into_any().unbind(),
471                    42_u64.into_pyobject(py).unwrap().into_any().unbind(),
472                    512_usize.into_pyobject(py).unwrap().into_any().unbind(),
473                ],
474            )
475            .expect("args");
476
477            let config = construct_data_client_config(py, Some(&args), None);
478
479            assert_eq!(
480                config.base_url_http.as_deref(),
481                Some("https://http.example")
482            );
483            assert_eq!(config.base_url_ws.as_deref(), Some("wss://ws.example"));
484            assert_eq!(
485                config.base_url_gamma.as_deref(),
486                Some("https://gamma.example")
487            );
488            assert_eq!(
489                config.base_url_data_api.as_deref(),
490                Some("https://data.example")
491            );
492            assert_eq!(config.base_url_rtds, None);
493            assert_eq!(config.http_timeout_secs, 41);
494            assert_eq!(config.ws_timeout_secs, 42);
495            assert_eq!(config.ws_max_subscriptions, 512);
496        });
497    }
498
499    #[rstest]
500    fn direct_pyo3_constructor_preserves_base_url_rtds_positional_slot() {
501        Python::initialize();
502        Python::attach(|py| {
503            let args = PyTuple::new(
504                py,
505                [
506                    py.None(),
507                    py.None(),
508                    py.None(),
509                    py.None(),
510                    py.None(),
511                    py.None(),
512                    py.None(),
513                    py.None(),
514                    py.None(),
515                    py.None(),
516                    py.None(),
517                    py.None(),
518                    py.None(),
519                    py.None(),
520                    py.None(),
521                    py.None(),
522                    py.None(),
523                    py.None(),
524                    py.None(),
525                    py.None(),
526                    "wss://ws-live-data.example"
527                        .into_pyobject(py)
528                        .unwrap()
529                        .into_any()
530                        .unbind(),
531                ],
532            )
533            .expect("args");
534
535            let config = construct_data_client_config(py, Some(&args), None);
536
537            assert_eq!(
538                config.base_url_rtds.as_deref(),
539                Some("wss://ws-live-data.example")
540            );
541        });
542    }
543
544    #[rstest]
545    fn direct_pyo3_data_config_propagates_proxy_without_raw_getter() {
546        const SECRET: &str = "data-python-proxy-secret";
547        Python::initialize();
548        Python::attach(|py| {
549            let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18083");
550            let kwargs = PyDict::new(py);
551            kwargs.set_item("proxy_url", &proxy_url).unwrap();
552            let cls = py.get_type::<PolymarketDataClientConfig>();
553            let obj = cls.call((), Some(&kwargs)).expect("construct data config");
554            let has_proxy_url = obj
555                .getattr("has_proxy_url")
556                .expect("has_proxy_url getter")
557                .extract::<bool>()
558                .expect("bool getter");
559            let repr = obj.repr().expect("data config repr").to_string();
560            let config = obj
561                .extract::<PolymarketDataClientConfig>()
562                .expect("extract data config");
563
564            assert_eq!(config.proxy_url.as_deref(), Some(proxy_url.as_str()));
565            assert!(has_proxy_url);
566            assert!(!obj.hasattr("proxy_url").unwrap());
567            assert!(!repr.contains(SECRET));
568        });
569    }
570
571    #[rstest]
572    fn direct_pyo3_exec_config_propagates_proxy_without_raw_getter() {
573        const SECRET: &str = "exec-python-proxy-secret";
574        Python::initialize();
575        Python::attach(|py| {
576            let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18084");
577            let kwargs = PyDict::new(py);
578            kwargs.set_item("proxy_url", &proxy_url).unwrap();
579            kwargs.set_item("heartbeat_enabled", true).unwrap();
580            let cls = py.get_type::<PolymarketExecutionClientConfig>();
581            let obj = cls
582                .call((), Some(&kwargs))
583                .expect("construct execution config");
584            let has_proxy_url = obj
585                .getattr("has_proxy_url")
586                .expect("has_proxy_url getter")
587                .extract::<bool>()
588                .expect("bool getter");
589            let repr = obj.repr().expect("execution config repr").to_string();
590            let heartbeat_enabled = obj
591                .getattr("heartbeat_enabled")
592                .expect("heartbeat_enabled getter")
593                .extract::<bool>()
594                .expect("bool getter");
595            let config = obj
596                .extract::<PolymarketExecutionClientConfig>()
597                .expect("extract execution config");
598
599            assert_eq!(config.proxy_url.as_deref(), Some(proxy_url.as_str()));
600            assert!(config.heartbeat_enabled);
601            assert!(has_proxy_url);
602            assert!(heartbeat_enabled);
603            assert!(!obj.hasattr("proxy_url").unwrap());
604            assert!(!repr.contains(SECRET));
605        });
606    }
607
608    #[rstest]
609    fn direct_pyo3_exec_config_wires_instrument_config_load_ids() {
610        Python::initialize();
611        Python::attach(|py| {
612            let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
613            let provider_kwargs = PyDict::new(py);
614            provider_kwargs.set_item("load_ids", vec![scoped]).unwrap();
615            let provider = py
616                .get_type::<PolymarketInstrumentProviderConfig>()
617                .call((), Some(&provider_kwargs))
618                .expect("construct provider config");
619            let kwargs = PyDict::new(py);
620            kwargs.set_item("instrument_config", &provider).unwrap();
621            let obj = py
622                .get_type::<PolymarketExecutionClientConfig>()
623                .call((), Some(&kwargs))
624                .expect("construct execution config");
625            let exposed = obj
626                .getattr("instrument_config")
627                .expect("instrument_config getter")
628                .extract::<PolymarketInstrumentProviderConfig>()
629                .expect("extract provider config");
630            let config = obj
631                .extract::<PolymarketExecutionClientConfig>()
632                .expect("extract execution config");
633
634            assert_eq!(exposed.load_ids.as_deref(), Some([scoped].as_slice()));
635            assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
636        });
637    }
638
639    #[rstest]
640    fn direct_pyo3_proxy_validation_error_redacts_credentials() {
641        const SECRET: &str = "invalid-python-proxy-secret";
642        Python::initialize();
643        Python::attach(|py| {
644            let kwargs = PyDict::new(py);
645            kwargs
646                .set_item("proxy_url", format!("http://proxy-user:{SECRET}@[::1"))
647                .unwrap();
648            let error = py
649                .get_type::<PolymarketDataClientConfig>()
650                .call((), Some(&kwargs))
651                .expect_err("malformed proxy URL should fail");
652            let message = error.to_string();
653
654            assert!(message.contains("Invalid Polymarket proxy URL"));
655            assert!(!message.contains(SECRET));
656        });
657    }
658}