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, string::secret::SecretString};
17use nautilus_model::identifiers::{AccountId, InstrumentId};
18use nautilus_network::websocket::TransportBackend;
19use pyo3::{PyResult, pymethods};
20
21use crate::{
22    common::enums::{PolymarketSignatureType, PolymarketSignerType},
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, book_snapshot_timeout_secs=None, book_stale_check_interval_secs=None, book_stale_threshold_secs=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        book_snapshot_timeout_secs: Option<u64>,
162        book_stale_check_interval_secs: Option<u64>,
163        book_stale_threshold_secs: Option<u64>,
164    ) -> PyResult<Self> {
165        let default = Self::default();
166
167        let config = Self {
168            instrument_config,
169            filters: Vec::new(),
170            base_url_http,
171            base_url_ws,
172            base_url_rtds,
173            base_url_gamma,
174            base_url_data_api,
175            proxy_url: proxy_url.map(SecretString::from),
176            http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
177            ws_timeout_secs: ws_timeout_secs.unwrap_or(default.ws_timeout_secs),
178            ws_max_subscriptions: ws_max_subscriptions.unwrap_or(default.ws_max_subscriptions),
179            update_instruments_interval_mins: resolve_optional_u64_arg(
180                update_instruments_interval_mins,
181                default.update_instruments_interval_mins,
182            ),
183            subscribe_new_markets: subscribe_new_markets.unwrap_or(default.subscribe_new_markets),
184            new_market_filter: None,
185            new_market_fetch_max_concurrency: new_market_fetch_max_concurrency
186                .unwrap_or(default.new_market_fetch_max_concurrency),
187            drop_quotes_missing_side: drop_quotes_missing_side
188                .unwrap_or(default.drop_quotes_missing_side),
189            auto_load_missing_instruments: auto_load_missing_instruments
190                .unwrap_or(default.auto_load_missing_instruments),
191            auto_load_debounce_ms: auto_load_debounce_ms.unwrap_or(default.auto_load_debounce_ms),
192            auto_load_max_retries: auto_load_max_retries.unwrap_or(default.auto_load_max_retries),
193            auto_load_retry_delay_initial_secs: auto_load_retry_delay_initial_secs
194                .unwrap_or(default.auto_load_retry_delay_initial_secs),
195            auto_load_retry_delay_max_secs: auto_load_retry_delay_max_secs
196                .unwrap_or(default.auto_load_retry_delay_max_secs),
197            resolve_poll_enabled: resolve_poll_enabled.unwrap_or(default.resolve_poll_enabled),
198            resolve_poll_interval_secs: resolve_poll_interval_secs
199                .unwrap_or(default.resolve_poll_interval_secs),
200            resolve_poll_grace_secs: resolve_poll_grace_secs
201                .unwrap_or(default.resolve_poll_grace_secs),
202            resolve_poll_max_wait_secs: resolve_poll_max_wait_secs
203                .unwrap_or(default.resolve_poll_max_wait_secs),
204            transport_backend: transport_backend.unwrap_or(default.transport_backend),
205            compute_effective_deltas: compute_effective_deltas
206                .unwrap_or(default.compute_effective_deltas),
207            book_snapshot_timeout_secs: book_snapshot_timeout_secs
208                .unwrap_or(default.book_snapshot_timeout_secs),
209            book_stale_check_interval_secs: book_stale_check_interval_secs
210                .unwrap_or(default.book_stale_check_interval_secs),
211            book_stale_threshold_secs: book_stale_threshold_secs
212                .unwrap_or(default.book_stale_threshold_secs),
213        };
214        config
215            .validated_proxy_url()
216            .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
217        Ok(config)
218    }
219
220    #[getter]
221    #[pyo3(name = "has_proxy_url")]
222    const fn py_has_proxy_url(&self) -> bool {
223        self.has_proxy_url()
224    }
225
226    fn __repr__(&self) -> String {
227        format!("{self:?}")
228    }
229
230    fn __str__(&self) -> String {
231        format!("{self:?}")
232    }
233}
234
235#[pymethods]
236#[pyo3_stub_gen::derive::gen_stub_pymethods]
237impl PolymarketExecutionClientConfig {
238    /// Configuration for the Polymarket execution client.
239    #[new]
240    #[expect(clippy::too_many_arguments)]
241    #[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, signer_type=None))]
242    fn py_new(
243        account_id: Option<String>,
244        private_key: Option<String>,
245        api_key: Option<String>,
246        api_secret: Option<String>,
247        passphrase: Option<String>,
248        funder: Option<String>,
249        signature_type: Option<PolymarketSignatureType>,
250        base_url_http: Option<String>,
251        base_url_ws: Option<String>,
252        base_url_data_api: Option<String>,
253        http_timeout_secs: Option<u64>,
254        max_retries: Option<u32>,
255        retry_delay_initial_ms: Option<u64>,
256        retry_delay_max_ms: Option<u64>,
257        heartbeat_enabled: Option<bool>,
258        transport_backend: Option<TransportBackend>,
259        proxy_url: Option<String>,
260        instrument_config: Option<PolymarketInstrumentProviderConfig>,
261        signer_type: Option<PolymarketSignerType>,
262    ) -> PyResult<Self> {
263        let default = Self::default();
264        let config = Self {
265            account_id: account_id.map_or(default.account_id, |s| AccountId::from(s.as_str())),
266            private_key: private_key.map(SecretString::from),
267            api_key: api_key.map(SecretString::from),
268            api_secret: api_secret.map(SecretString::from),
269            passphrase: passphrase.map(SecretString::from),
270            funder,
271            signature_type: signature_type.unwrap_or(default.signature_type),
272            signer_type: signer_type.unwrap_or_default(),
273            base_url_http,
274            base_url_ws,
275            base_url_data_api,
276            proxy_url: proxy_url.map(SecretString::from),
277            http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
278            max_retries: max_retries.unwrap_or(default.max_retries),
279            retry_delay_initial_ms: retry_delay_initial_ms
280                .unwrap_or(default.retry_delay_initial_ms),
281            retry_delay_max_ms: retry_delay_max_ms.unwrap_or(default.retry_delay_max_ms),
282            heartbeat_enabled: heartbeat_enabled.unwrap_or(default.heartbeat_enabled),
283            transport_backend: transport_backend.unwrap_or(default.transport_backend),
284            instrument_config,
285        };
286
287        config.validate_signer().map_err(to_pyvalue_err)?;
288        config
289            .validated_proxy_url()
290            .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
291        Ok(config)
292    }
293
294    #[getter]
295    #[pyo3(name = "has_proxy_url")]
296    const fn py_has_proxy_url(&self) -> bool {
297        self.has_proxy_url()
298    }
299
300    fn __repr__(&self) -> String {
301        format!("{self:?}")
302    }
303
304    fn __str__(&self) -> String {
305        format!("{self:?}")
306    }
307}
308
309#[cfg(test)]
310mod tests {
311    use pyo3::{
312        Bound, IntoPyObject, Python,
313        types::{PyAnyMethods, PyDict, PyDictMethods, PyTuple},
314    };
315    use rstest::rstest;
316
317    use super::*;
318
319    fn construct_data_client_config(
320        py: Python<'_>,
321        args: Option<&Bound<'_, PyTuple>>,
322        kwargs: Option<&Bound<'_, PyDict>>,
323    ) -> PolymarketDataClientConfig {
324        let cls = py.get_type::<PolymarketDataClientConfig>();
325
326        let config = match args {
327            Some(args) => cls.call(args, kwargs),
328            None => cls.call((), kwargs),
329        }
330        .expect("construct PolymarketDataClientConfig");
331
332        config
333            .extract::<PolymarketDataClientConfig>()
334            .expect("extract PolymarketDataClientConfig")
335    }
336
337    #[rstest]
338    fn direct_pyo3_constructor_preserves_none_update_interval() {
339        Python::initialize();
340        Python::attach(|py| {
341            let kwargs = PyDict::new(py);
342            kwargs
343                .set_item("update_instruments_interval_mins", py.None())
344                .unwrap();
345
346            let config = construct_data_client_config(py, None, Some(&kwargs));
347
348            assert_eq!(config.update_instruments_interval_mins, None);
349        });
350    }
351
352    #[rstest]
353    fn direct_pyo3_constructor_uses_default_update_interval_when_omitted() {
354        Python::initialize();
355        Python::attach(|py| {
356            let config = construct_data_client_config(py, None, None);
357
358            assert_eq!(
359                config.update_instruments_interval_mins,
360                PolymarketDataClientConfig::default().update_instruments_interval_mins,
361            );
362            assert!(config.drop_quotes_missing_side);
363        });
364    }
365
366    #[rstest]
367    fn direct_pyo3_constructor_preserves_none_update_interval_for_positional_args() {
368        Python::initialize();
369        Python::attach(|py| {
370            let args = PyTuple::new(
371                py,
372                [
373                    py.None(),
374                    py.None(),
375                    py.None(),
376                    py.None(),
377                    py.None(),
378                    py.None(),
379                    py.None(),
380                    py.None(),
381                    py.None(),
382                    py.None(),
383                ],
384            )
385            .expect("args");
386
387            let config = construct_data_client_config(py, Some(&args), None);
388
389            assert_eq!(config.update_instruments_interval_mins, None);
390        });
391    }
392
393    #[rstest]
394    fn direct_pyo3_constructor_sets_new_market_fetch_max_concurrency() {
395        Python::initialize();
396        Python::attach(|py| {
397            let kwargs = PyDict::new(py);
398            kwargs
399                .set_item("new_market_fetch_max_concurrency", 23)
400                .unwrap();
401
402            let config = construct_data_client_config(py, None, Some(&kwargs));
403
404            assert_eq!(config.new_market_fetch_max_concurrency, 23);
405        });
406    }
407
408    #[rstest]
409    fn direct_pyo3_constructor_sets_drop_quotes_missing_side() {
410        Python::initialize();
411        Python::attach(|py| {
412            let kwargs = PyDict::new(py);
413            kwargs.set_item("drop_quotes_missing_side", false).unwrap();
414
415            let config = construct_data_client_config(py, None, Some(&kwargs));
416
417            assert!(!config.drop_quotes_missing_side);
418        });
419    }
420
421    #[rstest]
422    fn direct_pyo3_constructor_sets_compute_effective_deltas() {
423        Python::initialize();
424        Python::attach(|py| {
425            let kwargs = PyDict::new(py);
426            kwargs.set_item("compute_effective_deltas", true).unwrap();
427
428            let config = construct_data_client_config(py, None, Some(&kwargs));
429
430            assert!(config.compute_effective_deltas);
431        });
432    }
433
434    #[rstest]
435    fn direct_pyo3_constructor_sets_base_url_rtds() {
436        Python::initialize();
437        Python::attach(|py| {
438            let kwargs = PyDict::new(py);
439            kwargs
440                .set_item("base_url_rtds", "wss://ws-live-data.example")
441                .unwrap();
442
443            let config = construct_data_client_config(py, None, Some(&kwargs));
444
445            assert_eq!(
446                config.base_url_rtds.as_deref(),
447                Some("wss://ws-live-data.example")
448            );
449        });
450    }
451
452    #[rstest]
453    fn direct_pyo3_constructor_preserves_existing_positional_order() {
454        Python::initialize();
455        Python::attach(|py| {
456            let args = PyTuple::new(
457                py,
458                [
459                    py.None(),
460                    "https://http.example"
461                        .into_pyobject(py)
462                        .unwrap()
463                        .into_any()
464                        .unbind(),
465                    "wss://ws.example"
466                        .into_pyobject(py)
467                        .unwrap()
468                        .into_any()
469                        .unbind(),
470                    "https://gamma.example"
471                        .into_pyobject(py)
472                        .unwrap()
473                        .into_any()
474                        .unbind(),
475                    "https://data.example"
476                        .into_pyobject(py)
477                        .unwrap()
478                        .into_any()
479                        .unbind(),
480                    41_u64.into_pyobject(py).unwrap().into_any().unbind(),
481                    42_u64.into_pyobject(py).unwrap().into_any().unbind(),
482                    512_usize.into_pyobject(py).unwrap().into_any().unbind(),
483                ],
484            )
485            .expect("args");
486
487            let config = construct_data_client_config(py, Some(&args), None);
488
489            assert_eq!(
490                config.base_url_http.as_deref(),
491                Some("https://http.example")
492            );
493            assert_eq!(config.base_url_ws.as_deref(), Some("wss://ws.example"));
494            assert_eq!(
495                config.base_url_gamma.as_deref(),
496                Some("https://gamma.example")
497            );
498            assert_eq!(
499                config.base_url_data_api.as_deref(),
500                Some("https://data.example")
501            );
502            assert_eq!(config.base_url_rtds, None);
503            assert_eq!(config.http_timeout_secs, 41);
504            assert_eq!(config.ws_timeout_secs, 42);
505            assert_eq!(config.ws_max_subscriptions, 512);
506        });
507    }
508
509    #[rstest]
510    fn direct_pyo3_constructor_preserves_base_url_rtds_positional_slot() {
511        Python::initialize();
512        Python::attach(|py| {
513            let args = PyTuple::new(
514                py,
515                [
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                    py.None(),
527                    py.None(),
528                    py.None(),
529                    py.None(),
530                    py.None(),
531                    py.None(),
532                    py.None(),
533                    py.None(),
534                    py.None(),
535                    py.None(),
536                    "wss://ws-live-data.example"
537                        .into_pyobject(py)
538                        .unwrap()
539                        .into_any()
540                        .unbind(),
541                ],
542            )
543            .expect("args");
544
545            let config = construct_data_client_config(py, Some(&args), None);
546
547            assert_eq!(
548                config.base_url_rtds.as_deref(),
549                Some("wss://ws-live-data.example")
550            );
551        });
552    }
553
554    #[rstest]
555    fn direct_pyo3_data_config_propagates_proxy_without_raw_getter() {
556        const SECRET: &str = "data-python-proxy-secret";
557        Python::initialize();
558        Python::attach(|py| {
559            let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18083");
560            let kwargs = PyDict::new(py);
561            kwargs.set_item("proxy_url", &proxy_url).unwrap();
562            let cls = py.get_type::<PolymarketDataClientConfig>();
563            let obj = cls.call((), Some(&kwargs)).expect("construct data config");
564            let has_proxy_url = obj
565                .getattr("has_proxy_url")
566                .expect("has_proxy_url getter")
567                .extract::<bool>()
568                .expect("bool getter");
569            let repr = obj.repr().expect("data config repr").to_string();
570            let config = obj
571                .extract::<PolymarketDataClientConfig>()
572                .expect("extract data config");
573
574            assert_eq!(
575                config.proxy_url.as_ref().map(SecretString::expose_secret),
576                Some(proxy_url.as_str()),
577            );
578            assert!(has_proxy_url);
579            assert!(!obj.hasattr("proxy_url").unwrap());
580            assert!(!repr.contains(SECRET));
581        });
582    }
583
584    #[rstest]
585    fn direct_pyo3_exec_config_propagates_proxy_without_raw_getter() {
586        const SECRET: &str = "exec-python-proxy-secret";
587        Python::initialize();
588        Python::attach(|py| {
589            let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18084");
590            let kwargs = PyDict::new(py);
591            kwargs.set_item("proxy_url", &proxy_url).unwrap();
592            kwargs.set_item("heartbeat_enabled", true).unwrap();
593            let cls = py.get_type::<PolymarketExecutionClientConfig>();
594            let obj = cls
595                .call((), Some(&kwargs))
596                .expect("construct execution config");
597            let has_proxy_url = obj
598                .getattr("has_proxy_url")
599                .expect("has_proxy_url getter")
600                .extract::<bool>()
601                .expect("bool getter");
602            let repr = obj.repr().expect("execution config repr").to_string();
603            let heartbeat_enabled = obj
604                .getattr("heartbeat_enabled")
605                .expect("heartbeat_enabled getter")
606                .extract::<bool>()
607                .expect("bool getter");
608            let config = obj
609                .extract::<PolymarketExecutionClientConfig>()
610                .expect("extract execution config");
611
612            assert_eq!(
613                config.proxy_url.as_ref().map(SecretString::expose_secret),
614                Some(proxy_url.as_str()),
615            );
616            assert!(config.heartbeat_enabled);
617            assert!(has_proxy_url);
618            assert!(heartbeat_enabled);
619            assert!(!obj.hasattr("proxy_url").unwrap());
620            assert!(!repr.contains(SECRET));
621        });
622    }
623
624    #[rstest]
625    fn direct_pyo3_exec_config_wires_instrument_config_load_ids() {
626        Python::initialize();
627        Python::attach(|py| {
628            let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
629            let provider_kwargs = PyDict::new(py);
630            provider_kwargs.set_item("load_ids", vec![scoped]).unwrap();
631            let provider = py
632                .get_type::<PolymarketInstrumentProviderConfig>()
633                .call((), Some(&provider_kwargs))
634                .expect("construct provider config");
635            let kwargs = PyDict::new(py);
636            kwargs.set_item("instrument_config", &provider).unwrap();
637            let obj = py
638                .get_type::<PolymarketExecutionClientConfig>()
639                .call((), Some(&kwargs))
640                .expect("construct execution config");
641            let exposed = obj
642                .getattr("instrument_config")
643                .expect("instrument_config getter")
644                .extract::<PolymarketInstrumentProviderConfig>()
645                .expect("extract provider config");
646            let config = obj
647                .extract::<PolymarketExecutionClientConfig>()
648                .expect("extract execution config");
649
650            assert_eq!(exposed.load_ids.as_deref(), Some([scoped].as_slice()));
651            assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
652        });
653    }
654
655    #[rstest]
656    fn direct_pyo3_proxy_validation_error_redacts_credentials() {
657        const SECRET: &str = "invalid-python-proxy-secret";
658        Python::initialize();
659        Python::attach(|py| {
660            let kwargs = PyDict::new(py);
661            kwargs
662                .set_item("proxy_url", format!("http://proxy-user:{SECRET}@[::1"))
663                .unwrap();
664            let error = py
665                .get_type::<PolymarketDataClientConfig>()
666                .call((), Some(&kwargs))
667                .expect_err("malformed proxy URL should fail");
668            let message = error.to_string();
669
670            assert!(message.contains("Invalid Polymarket proxy URL"));
671            assert!(!message.contains(SECRET));
672        });
673    }
674}