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_common::python::config_error_to_pyvalue_err;
17use nautilus_core::{python::to_pyvalue_err, string::secret::SecretString};
18use nautilus_model::identifiers::{AccountId, InstrumentId};
19use nautilus_network::websocket::TransportBackend;
20use pyo3::{PyResult, pymethods};
21
22use crate::{
23    common::enums::{PolymarketSignatureType, PolymarketSignerType},
24    config::{
25        PolymarketDataClientConfig, PolymarketExecutionClientConfig,
26        PolymarketInstrumentProviderConfig, PolymarketUpDownEventSlugConfig,
27    },
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    ) -> PyResult<Self> {
56        let default = Self::default();
57
58        let config = Self {
59            assets: assets.unwrap_or(default.assets),
60            interval_mins: interval_mins.unwrap_or(default.interval_mins),
61            periods: periods.unwrap_or(default.periods),
62            start_offset_periods: start_offset_periods.unwrap_or(default.start_offset_periods),
63        };
64
65        config.validate().map_err(config_error_to_pyvalue_err)?;
66        Ok(config)
67    }
68
69    fn __repr__(&self) -> String {
70        format!("{self:?}")
71    }
72
73    fn __str__(&self) -> String {
74        format!("{self:?}")
75    }
76}
77
78#[pymethods]
79#[pyo3_stub_gen::derive::gen_stub_pymethods]
80impl PolymarketInstrumentProviderConfig {
81    /// Configuration for the Polymarket instrument provider.
82    ///
83    /// This mirrors the Python adapter's `instrument_config` layering so scoped
84    /// market bootstrap can migrate naturally to the Rust/pyO3 live path.
85    #[new]
86    #[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))]
87    #[expect(clippy::too_many_arguments)]
88    fn py_new(
89        load_all: Option<bool>,
90        load_ids: Option<Vec<InstrumentId>>,
91        filters: Option<std::collections::HashMap<String, String>>,
92        event_slugs: Option<Vec<String>>,
93        market_slugs: Option<Vec<String>>,
94        event_slug_builder: Option<PolymarketUpDownEventSlugConfig>,
95        log_warnings: Option<bool>,
96        use_gamma_markets: Option<bool>,
97        series_ids: Option<Vec<u64>>,
98    ) -> PyResult<Self> {
99        let default = Self::default();
100        let config = Self {
101            load_all: load_all.unwrap_or(default.load_all),
102            load_ids,
103            filters,
104            event_slugs,
105            market_slugs,
106            event_slug_builder,
107            series_ids,
108            log_warnings: log_warnings.unwrap_or(default.log_warnings),
109            use_gamma_markets: use_gamma_markets.unwrap_or(default.use_gamma_markets),
110        };
111
112        config.validate().map_err(to_pyvalue_err)?;
113        Ok(config)
114    }
115
116    fn __repr__(&self) -> String {
117        format!("{self:?}")
118    }
119
120    fn __str__(&self) -> String {
121        format!("{self:?}")
122    }
123}
124
125#[pymethods]
126#[pyo3_stub_gen::derive::gen_stub_pymethods]
127impl PolymarketDataClientConfig {
128    /// Configuration for the Polymarket data client.
129    ///
130    /// `filters` and `new_market_filter` hold `Arc<dyn InstrumentFilter>` trait objects
131    /// and are skipped during serialization; they default to empty/`None` and must be
132    /// installed programmatically after deserialization.
133    #[new]
134    #[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_reference_prices=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, api_key=None, api_secret=None, passphrase=None))]
135    #[expect(clippy::too_many_arguments)]
136    fn py_new(
137        instrument_config: Option<PolymarketInstrumentProviderConfig>,
138        base_url_http: Option<String>,
139        base_url_ws: Option<String>,
140        base_url_gamma: Option<String>,
141        base_url_data_api: Option<String>,
142        http_timeout_secs: Option<u64>,
143        ws_timeout_secs: Option<u64>,
144        ws_max_subscriptions: Option<usize>,
145        update_instruments_interval_mins: Option<u64>,
146        subscribe_new_markets: Option<bool>,
147        auto_load_missing_instruments: Option<bool>,
148        auto_load_debounce_ms: Option<u64>,
149        auto_load_max_retries: Option<u32>,
150        auto_load_retry_delay_initial_secs: Option<f64>,
151        auto_load_retry_delay_max_secs: Option<f64>,
152        new_market_fetch_max_concurrency: Option<usize>,
153        resolve_poll_enabled: Option<bool>,
154        resolve_poll_interval_secs: Option<u64>,
155        resolve_poll_grace_secs: Option<u64>,
156        resolve_poll_max_wait_secs: Option<u64>,
157        base_url_reference_prices: Option<String>,
158        transport_backend: Option<TransportBackend>,
159        drop_quotes_missing_side: Option<bool>,
160        proxy_url: Option<String>,
161        compute_effective_deltas: Option<bool>,
162        book_snapshot_timeout_secs: Option<u64>,
163        book_stale_check_interval_secs: Option<u64>,
164        book_stale_threshold_secs: Option<u64>,
165        api_key: Option<String>,
166        api_secret: Option<String>,
167        passphrase: Option<String>,
168    ) -> PyResult<Self> {
169        let default = Self::default();
170
171        let config = Self {
172            instrument_config,
173            filters: Vec::new(),
174            base_url_http,
175            base_url_ws,
176            base_url_reference_prices,
177            base_url_gamma,
178            base_url_data_api,
179            api_key: api_key.map(SecretString::from),
180            api_secret: api_secret.map(SecretString::from),
181            passphrase: passphrase.map(SecretString::from),
182            proxy_url: proxy_url.map(SecretString::from),
183            http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
184            ws_timeout_secs: ws_timeout_secs.unwrap_or(default.ws_timeout_secs),
185            ws_max_subscriptions: ws_max_subscriptions.unwrap_or(default.ws_max_subscriptions),
186            update_instruments_interval_mins: resolve_optional_u64_arg(
187                update_instruments_interval_mins,
188                default.update_instruments_interval_mins,
189            ),
190            subscribe_new_markets: subscribe_new_markets.unwrap_or(default.subscribe_new_markets),
191            new_market_filter: None,
192            new_market_fetch_max_concurrency: new_market_fetch_max_concurrency
193                .unwrap_or(default.new_market_fetch_max_concurrency),
194            drop_quotes_missing_side: drop_quotes_missing_side
195                .unwrap_or(default.drop_quotes_missing_side),
196            auto_load_missing_instruments: auto_load_missing_instruments
197                .unwrap_or(default.auto_load_missing_instruments),
198            auto_load_debounce_ms: auto_load_debounce_ms.unwrap_or(default.auto_load_debounce_ms),
199            auto_load_max_retries: auto_load_max_retries.unwrap_or(default.auto_load_max_retries),
200            auto_load_retry_delay_initial_secs: auto_load_retry_delay_initial_secs
201                .unwrap_or(default.auto_load_retry_delay_initial_secs),
202            auto_load_retry_delay_max_secs: auto_load_retry_delay_max_secs
203                .unwrap_or(default.auto_load_retry_delay_max_secs),
204            resolve_poll_enabled: resolve_poll_enabled.unwrap_or(default.resolve_poll_enabled),
205            resolve_poll_interval_secs: resolve_poll_interval_secs
206                .unwrap_or(default.resolve_poll_interval_secs),
207            resolve_poll_grace_secs: resolve_poll_grace_secs
208                .unwrap_or(default.resolve_poll_grace_secs),
209            resolve_poll_max_wait_secs: resolve_poll_max_wait_secs
210                .unwrap_or(default.resolve_poll_max_wait_secs),
211            transport_backend: transport_backend.unwrap_or(default.transport_backend),
212            compute_effective_deltas: compute_effective_deltas
213                .unwrap_or(default.compute_effective_deltas),
214            book_snapshot_timeout_secs: book_snapshot_timeout_secs
215                .unwrap_or(default.book_snapshot_timeout_secs),
216            book_stale_check_interval_secs: book_stale_check_interval_secs
217                .unwrap_or(default.book_stale_check_interval_secs),
218            book_stale_threshold_secs: book_stale_threshold_secs
219                .unwrap_or(default.book_stale_threshold_secs),
220        };
221        config
222            .validated_proxy_url()
223            .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
224        config.validate().map_err(to_pyvalue_err)?;
225        Ok(config)
226    }
227
228    #[getter]
229    #[pyo3(name = "has_proxy_url")]
230    const fn py_has_proxy_url(&self) -> bool {
231        self.has_proxy_url()
232    }
233
234    fn __repr__(&self) -> String {
235        format!("{self:?}")
236    }
237
238    fn __str__(&self) -> String {
239        format!("{self:?}")
240    }
241}
242
243#[pymethods]
244#[pyo3_stub_gen::derive::gen_stub_pymethods]
245impl PolymarketExecutionClientConfig {
246    /// Configuration for the Polymarket execution client.
247    #[new]
248    #[expect(clippy::too_many_arguments)]
249    #[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))]
250    fn py_new(
251        account_id: Option<String>,
252        private_key: Option<String>,
253        api_key: Option<String>,
254        api_secret: Option<String>,
255        passphrase: Option<String>,
256        funder: Option<String>,
257        signature_type: Option<PolymarketSignatureType>,
258        base_url_http: Option<String>,
259        base_url_ws: Option<String>,
260        base_url_data_api: Option<String>,
261        http_timeout_secs: Option<u64>,
262        max_retries: Option<u32>,
263        retry_delay_initial_ms: Option<u64>,
264        retry_delay_max_ms: Option<u64>,
265        heartbeat_enabled: Option<bool>,
266        transport_backend: Option<TransportBackend>,
267        proxy_url: Option<String>,
268        instrument_config: Option<PolymarketInstrumentProviderConfig>,
269        signer_type: Option<PolymarketSignerType>,
270    ) -> PyResult<Self> {
271        let default = Self::default();
272        let config = Self {
273            account_id: account_id.map_or(default.account_id, |s| AccountId::from(s.as_str())),
274            private_key: private_key.map(SecretString::from),
275            api_key: api_key.map(SecretString::from),
276            api_secret: api_secret.map(SecretString::from),
277            passphrase: passphrase.map(SecretString::from),
278            funder,
279            signature_type: signature_type.unwrap_or(default.signature_type),
280            signer_type: signer_type.unwrap_or_default(),
281            base_url_http,
282            base_url_ws,
283            base_url_data_api,
284            proxy_url: proxy_url.map(SecretString::from),
285            http_timeout_secs: http_timeout_secs.unwrap_or(default.http_timeout_secs),
286            max_retries: max_retries.unwrap_or(default.max_retries),
287            retry_delay_initial_ms: retry_delay_initial_ms
288                .unwrap_or(default.retry_delay_initial_ms),
289            retry_delay_max_ms: retry_delay_max_ms.unwrap_or(default.retry_delay_max_ms),
290            heartbeat_enabled: heartbeat_enabled.unwrap_or(default.heartbeat_enabled),
291            transport_backend: transport_backend.unwrap_or(default.transport_backend),
292            instrument_config,
293        };
294
295        config.validate().map_err(to_pyvalue_err)?;
296        config.validate_signer().map_err(to_pyvalue_err)?;
297        config
298            .validated_proxy_url()
299            .map_err(|e| to_pyvalue_err(format!("Invalid Polymarket proxy URL: {e}")))?;
300        Ok(config)
301    }
302
303    #[getter]
304    #[pyo3(name = "has_proxy_url")]
305    const fn py_has_proxy_url(&self) -> bool {
306        self.has_proxy_url()
307    }
308
309    fn __repr__(&self) -> String {
310        format!("{self:?}")
311    }
312
313    fn __str__(&self) -> String {
314        format!("{self:?}")
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use pyo3::{
321        Bound, IntoPyObject, Python,
322        types::{PyAnyMethods, PyDict, PyDictMethods, PyStringMethods, PyTuple},
323    };
324    use rstest::rstest;
325
326    use super::*;
327
328    fn construct_data_client_config(
329        py: Python<'_>,
330        args: Option<&Bound<'_, PyTuple>>,
331        kwargs: Option<&Bound<'_, PyDict>>,
332    ) -> PolymarketDataClientConfig {
333        let cls = py.get_type::<PolymarketDataClientConfig>();
334
335        let config = match args {
336            Some(args) => cls.call(args, kwargs),
337            None => cls.call((), kwargs),
338        }
339        .expect("construct PolymarketDataClientConfig");
340
341        config
342            .extract::<PolymarketDataClientConfig>()
343            .expect("extract PolymarketDataClientConfig")
344    }
345
346    #[rstest]
347    fn direct_pyo3_constructor_preserves_none_update_interval() {
348        Python::initialize();
349        Python::attach(|py| {
350            let kwargs = PyDict::new(py);
351            kwargs
352                .set_item("update_instruments_interval_mins", py.None())
353                .unwrap();
354
355            let config = construct_data_client_config(py, None, Some(&kwargs));
356
357            assert_eq!(config.update_instruments_interval_mins, None);
358        });
359    }
360
361    #[rstest]
362    fn direct_pyo3_constructor_uses_default_update_interval_when_omitted() {
363        Python::initialize();
364        Python::attach(|py| {
365            let config = construct_data_client_config(py, None, None);
366
367            assert_eq!(
368                config.update_instruments_interval_mins,
369                PolymarketDataClientConfig::default().update_instruments_interval_mins,
370            );
371            assert!(config.drop_quotes_missing_side);
372        });
373    }
374
375    #[rstest]
376    fn direct_pyo3_constructor_preserves_none_update_interval_for_positional_args() {
377        Python::initialize();
378        Python::attach(|py| {
379            let args = PyTuple::new(
380                py,
381                [
382                    py.None(),
383                    py.None(),
384                    py.None(),
385                    py.None(),
386                    py.None(),
387                    py.None(),
388                    py.None(),
389                    py.None(),
390                    py.None(),
391                    py.None(),
392                ],
393            )
394            .expect("args");
395
396            let config = construct_data_client_config(py, Some(&args), None);
397
398            assert_eq!(config.update_instruments_interval_mins, None);
399        });
400    }
401
402    #[rstest]
403    fn direct_pyo3_constructor_keeps_positional_slots_ahead_of_credentials() {
404        Python::initialize();
405        Python::attach(|py| {
406            // `drop_quotes_missing_side` is the 23rd positional parameter, directly after
407            // `base_url_reference_prices` and `transport_backend`; the credential parameters
408            // were appended so this slot did not move.
409            let mut args: Vec<pyo3::Bound<'_, pyo3::PyAny>> =
410                (0..22).map(|_| py.None().into_bound(py)).collect();
411            args.push(pyo3::types::PyBool::new(py, false).to_owned().into_any());
412            let args = PyTuple::new(py, args).expect("args");
413
414            let config = construct_data_client_config(py, Some(&args), None);
415
416            assert!(!config.drop_quotes_missing_side);
417            assert!(config.api_key.is_none());
418            assert!(config.api_secret.is_none());
419            assert!(config.passphrase.is_none());
420        });
421    }
422
423    #[rstest]
424    fn direct_pyo3_constructor_sets_new_market_fetch_max_concurrency() {
425        Python::initialize();
426        Python::attach(|py| {
427            let kwargs = PyDict::new(py);
428            kwargs
429                .set_item("new_market_fetch_max_concurrency", 23)
430                .unwrap();
431
432            let config = construct_data_client_config(py, None, Some(&kwargs));
433
434            assert_eq!(config.new_market_fetch_max_concurrency, 23);
435        });
436    }
437
438    #[rstest]
439    fn direct_pyo3_constructor_sets_drop_quotes_missing_side() {
440        Python::initialize();
441        Python::attach(|py| {
442            let kwargs = PyDict::new(py);
443            kwargs.set_item("drop_quotes_missing_side", false).unwrap();
444
445            let config = construct_data_client_config(py, None, Some(&kwargs));
446
447            assert!(!config.drop_quotes_missing_side);
448        });
449    }
450
451    #[rstest]
452    fn direct_pyo3_constructor_sets_compute_effective_deltas() {
453        Python::initialize();
454        Python::attach(|py| {
455            let kwargs = PyDict::new(py);
456            kwargs.set_item("compute_effective_deltas", true).unwrap();
457
458            let config = construct_data_client_config(py, None, Some(&kwargs));
459
460            assert!(config.compute_effective_deltas);
461        });
462    }
463
464    #[rstest]
465    fn direct_pyo3_constructor_sets_base_url_reference_prices() {
466        Python::initialize();
467        Python::attach(|py| {
468            let kwargs = PyDict::new(py);
469            kwargs
470                .set_item("base_url_reference_prices", "wss://ws-live-data.example")
471                .unwrap();
472
473            let config = construct_data_client_config(py, None, Some(&kwargs));
474
475            assert_eq!(
476                config.base_url_reference_prices.as_deref(),
477                Some("wss://ws-live-data.example")
478            );
479        });
480    }
481
482    #[rstest]
483    fn direct_pyo3_constructor_preserves_existing_positional_order() {
484        Python::initialize();
485        Python::attach(|py| {
486            let args = PyTuple::new(
487                py,
488                [
489                    py.None(),
490                    "https://http.example"
491                        .into_pyobject(py)
492                        .unwrap()
493                        .into_any()
494                        .unbind(),
495                    "wss://ws.example"
496                        .into_pyobject(py)
497                        .unwrap()
498                        .into_any()
499                        .unbind(),
500                    "https://gamma.example"
501                        .into_pyobject(py)
502                        .unwrap()
503                        .into_any()
504                        .unbind(),
505                    "https://data.example"
506                        .into_pyobject(py)
507                        .unwrap()
508                        .into_any()
509                        .unbind(),
510                    41_u64.into_pyobject(py).unwrap().into_any().unbind(),
511                    42_u64.into_pyobject(py).unwrap().into_any().unbind(),
512                    512_usize.into_pyobject(py).unwrap().into_any().unbind(),
513                ],
514            )
515            .expect("args");
516
517            let config = construct_data_client_config(py, Some(&args), None);
518
519            assert_eq!(
520                config.base_url_http.as_deref(),
521                Some("https://http.example")
522            );
523            assert_eq!(config.base_url_ws.as_deref(), Some("wss://ws.example"));
524            assert_eq!(
525                config.base_url_gamma.as_deref(),
526                Some("https://gamma.example")
527            );
528            assert_eq!(
529                config.base_url_data_api.as_deref(),
530                Some("https://data.example")
531            );
532            assert_eq!(config.base_url_reference_prices, None);
533            assert_eq!(config.http_timeout_secs, 41);
534            assert_eq!(config.ws_timeout_secs, 42);
535            assert_eq!(config.ws_max_subscriptions, 512);
536        });
537    }
538
539    #[rstest]
540    fn direct_pyo3_constructor_preserves_base_url_reference_prices_positional_slot() {
541        Python::initialize();
542        Python::attach(|py| {
543            let args = PyTuple::new(
544                py,
545                [
546                    py.None(),
547                    py.None(),
548                    py.None(),
549                    py.None(),
550                    py.None(),
551                    py.None(),
552                    py.None(),
553                    py.None(),
554                    py.None(),
555                    py.None(),
556                    py.None(),
557                    py.None(),
558                    py.None(),
559                    py.None(),
560                    py.None(),
561                    py.None(),
562                    py.None(),
563                    py.None(),
564                    py.None(),
565                    py.None(),
566                    "wss://ws-live-data.example"
567                        .into_pyobject(py)
568                        .unwrap()
569                        .into_any()
570                        .unbind(),
571                ],
572            )
573            .expect("args");
574
575            let config = construct_data_client_config(py, Some(&args), None);
576
577            assert_eq!(
578                config.base_url_reference_prices.as_deref(),
579                Some("wss://ws-live-data.example")
580            );
581        });
582    }
583
584    #[rstest]
585    fn direct_pyo3_data_config_propagates_proxy_without_raw_getter() {
586        const SECRET: &str = "data-python-proxy-secret";
587        Python::initialize();
588        Python::attach(|py| {
589            let proxy_url = format!("http://data-user:{SECRET}@127.0.0.1:18083");
590            let kwargs = PyDict::new(py);
591            kwargs.set_item("proxy_url", &proxy_url).unwrap();
592            let cls = py.get_type::<PolymarketDataClientConfig>();
593            let obj = cls.call((), Some(&kwargs)).expect("construct data config");
594            let has_proxy_url = obj
595                .getattr("has_proxy_url")
596                .expect("has_proxy_url getter")
597                .extract::<bool>()
598                .expect("bool getter");
599            let repr = obj.repr().expect("data config repr").to_string();
600            let config = obj
601                .extract::<PolymarketDataClientConfig>()
602                .expect("extract data config");
603
604            assert_eq!(
605                config.proxy_url.as_ref().map(SecretString::expose_secret),
606                Some(proxy_url.as_str()),
607            );
608            assert!(has_proxy_url);
609            assert!(!obj.hasattr("proxy_url").unwrap());
610            assert!(!repr.contains(SECRET));
611        });
612    }
613
614    #[rstest]
615    fn direct_pyo3_exec_config_propagates_proxy_without_raw_getter() {
616        const SECRET: &str = "exec-python-proxy-secret";
617        Python::initialize();
618        Python::attach(|py| {
619            let proxy_url = format!("https://exec-user:{SECRET}@127.0.0.1:18084");
620            let kwargs = PyDict::new(py);
621            kwargs.set_item("proxy_url", &proxy_url).unwrap();
622            kwargs.set_item("heartbeat_enabled", true).unwrap();
623            let cls = py.get_type::<PolymarketExecutionClientConfig>();
624            let obj = cls
625                .call((), Some(&kwargs))
626                .expect("construct execution config");
627            let has_proxy_url = obj
628                .getattr("has_proxy_url")
629                .expect("has_proxy_url getter")
630                .extract::<bool>()
631                .expect("bool getter");
632            let repr = obj.repr().expect("execution config repr").to_string();
633            let heartbeat_enabled = obj
634                .getattr("heartbeat_enabled")
635                .expect("heartbeat_enabled getter")
636                .extract::<bool>()
637                .expect("bool getter");
638            let config = obj
639                .extract::<PolymarketExecutionClientConfig>()
640                .expect("extract execution config");
641
642            assert_eq!(
643                config.proxy_url.as_ref().map(SecretString::expose_secret),
644                Some(proxy_url.as_str()),
645            );
646            assert!(config.heartbeat_enabled);
647            assert!(has_proxy_url);
648            assert!(heartbeat_enabled);
649            assert!(!obj.hasattr("proxy_url").unwrap());
650            assert!(!repr.contains(SECRET));
651        });
652    }
653
654    #[rstest]
655    fn direct_pyo3_exec_config_wires_instrument_config_load_ids() {
656        Python::initialize();
657        Python::attach(|py| {
658            let scoped = InstrumentId::from("0xabc-123.POLYMARKET");
659            let provider_kwargs = PyDict::new(py);
660            provider_kwargs.set_item("load_ids", vec![scoped]).unwrap();
661            let provider = py
662                .get_type::<PolymarketInstrumentProviderConfig>()
663                .call((), Some(&provider_kwargs))
664                .expect("construct provider config");
665            let kwargs = PyDict::new(py);
666            kwargs.set_item("instrument_config", &provider).unwrap();
667            let obj = py
668                .get_type::<PolymarketExecutionClientConfig>()
669                .call((), Some(&kwargs))
670                .expect("construct execution config");
671            let exposed = obj
672                .getattr("instrument_config")
673                .expect("instrument_config getter")
674                .extract::<PolymarketInstrumentProviderConfig>()
675                .expect("extract provider config");
676            let config = obj
677                .extract::<PolymarketExecutionClientConfig>()
678                .expect("extract execution config");
679
680            assert_eq!(exposed.load_ids.as_deref(), Some([scoped].as_slice()));
681            assert_eq!(config.reconciliation_load_ids(), Some([scoped].as_slice()));
682        });
683    }
684
685    #[rstest]
686    fn direct_pyo3_proxy_validation_error_redacts_credentials() {
687        const SECRET: &str = "invalid-python-proxy-secret";
688        Python::initialize();
689        Python::attach(|py| {
690            let kwargs = PyDict::new(py);
691            kwargs
692                .set_item("proxy_url", format!("http://proxy-user:{SECRET}@[::1"))
693                .unwrap();
694            let error = py
695                .get_type::<PolymarketDataClientConfig>()
696                .call((), Some(&kwargs))
697                .expect_err("malformed proxy URL should fail");
698            let message = error.to_string();
699
700            assert!(message.contains("Invalid Polymarket proxy URL"));
701            assert!(!message.contains(SECRET));
702        });
703    }
704
705    #[rstest]
706    #[case::provider("provider")]
707    #[case::data("data")]
708    #[case::execution("execution")]
709    fn test_python_config_rejects_nested_or_filter_duplicates(#[case] kind: &str) {
710        Python::initialize();
711        Python::attach(|py| {
712            let kwargs = PyDict::new(py);
713
714            let cls = match kind {
715                "provider" => {
716                    kwargs
717                        .set_item(
718                            "filters",
719                            std::collections::HashMap::from([("slug", "one,two,one")]),
720                        )
721                        .unwrap();
722                    py.get_type::<PolymarketInstrumentProviderConfig>()
723                }
724                _ => {
725                    let provider = PolymarketInstrumentProviderConfig {
726                        series_ids: Some(vec![1, 2, 1]),
727                        ..Default::default()
728                    };
729
730                    kwargs
731                        .set_item("instrument_config", pyo3::Py::new(py, provider).unwrap())
732                        .unwrap();
733
734                    if kind == "data" {
735                        py.get_type::<PolymarketDataClientConfig>()
736                    } else {
737                        py.get_type::<PolymarketExecutionClientConfig>()
738                    }
739                }
740            };
741
742            let error = cls.call((), Some(&kwargs)).unwrap_err();
743
744            let field = if kind == "provider" {
745                "filters.slug"
746            } else {
747                "series_ids"
748            };
749
750            assert!(error.is_instance_of::<pyo3::exceptions::PyValueError>(py));
751            assert_eq!(
752                error.value(py).str().unwrap().to_str().unwrap(),
753                format!("duplicate {field}")
754            );
755        });
756    }
757}