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