Skip to main content

nautilus_coinapi/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
16//! Python bindings for CoinAPI configuration.
17
18use std::collections::HashMap;
19
20use nautilus_common::{config::check_unique_values, python::config_error_to_pyvalue_err};
21use nautilus_core::{python::to_pyvalue_err, string::secret::SecretString};
22use nautilus_model::identifiers::InstrumentId;
23use nautilus_network::websocket::TransportBackend;
24use pyo3::{PyResult, pymethods};
25use ustr::Ustr;
26
27use crate::{
28    config::{CoinApiDataClientConfig, CoinApiInstrumentSelection},
29    http::query::SymbolsQuery,
30};
31
32#[pymethods]
33#[pyo3_stub_gen::derive::gen_stub_pymethods]
34impl CoinApiInstrumentSelection {
35    /// Native instruments to load from the active symbols of one CoinAPI exchange.
36    ///
37    /// The optional filters bound the symbol metadata response and its credit cost.
38    #[new]
39    #[pyo3(signature = (exchange_id, instrument_ids, filter_symbol_id = None, filter_asset_id = None))]
40    fn py_new(
41        exchange_id: &str,
42        instrument_ids: Vec<InstrumentId>,
43        filter_symbol_id: Option<String>,
44        filter_asset_id: Option<String>,
45    ) -> PyResult<Self> {
46        check_unique_values("instrument_ids", &instrument_ids)
47            .map_err(config_error_to_pyvalue_err)?;
48        Ok(Self {
49            exchange_id: Ustr::from(exchange_id),
50            query: SymbolsQuery {
51                filter_symbol_id,
52                filter_asset_id,
53            },
54            instrument_ids,
55        })
56    }
57
58    #[getter]
59    fn exchange_id(&self) -> &str {
60        self.exchange_id.as_str()
61    }
62
63    #[getter]
64    fn instrument_ids(&self) -> Vec<InstrumentId> {
65        self.instrument_ids.clone()
66    }
67
68    #[getter]
69    fn filter_symbol_id(&self) -> Option<String> {
70        self.query.filter_symbol_id.clone()
71    }
72
73    #[getter]
74    fn filter_asset_id(&self) -> Option<String> {
75        self.query.filter_asset_id.clone()
76    }
77}
78
79#[pymethods]
80#[pyo3_stub_gen::derive::gen_stub_pymethods]
81impl CoinApiDataClientConfig {
82    /// Configuration for the CoinAPI live data client.
83    ///
84    /// When `api_key` is `None`, the client reads `COINAPI_API_KEY` at construction.
85    #[new]
86    #[pyo3(signature = (
87        api_key = None,
88        base_url_http = None,
89        base_url_ws = None,
90        instruments = None,
91        credit_budget = None,
92        max_retries = None,
93        transport_backend = None,
94        book_snapshot_timeout_secs = None,
95        price_precisions = None,
96        proxy_url = None,
97        http_timeout_secs = None,
98        max_concurrent_requests = None,
99        retry_delay_initial_ms = None,
100        retry_delay_max_ms = None,
101        retry_max_elapsed_ms = None,
102    ))]
103    #[expect(clippy::too_many_arguments)]
104    fn py_new(
105        api_key: Option<String>,
106        base_url_http: Option<String>,
107        base_url_ws: Option<String>,
108        instruments: Option<Vec<CoinApiInstrumentSelection>>,
109        credit_budget: Option<u64>,
110        max_retries: Option<u32>,
111        transport_backend: Option<TransportBackend>,
112        book_snapshot_timeout_secs: Option<u64>,
113        price_precisions: Option<HashMap<InstrumentId, u8>>,
114        proxy_url: Option<String>,
115        http_timeout_secs: Option<u64>,
116        max_concurrent_requests: Option<usize>,
117        retry_delay_initial_ms: Option<u64>,
118        retry_delay_max_ms: Option<u64>,
119        retry_max_elapsed_ms: Option<u64>,
120    ) -> PyResult<Self> {
121        let defaults = Self::default();
122
123        let config = Self {
124            api_key: api_key.map(SecretString::from),
125            base_url_http,
126            base_url_ws,
127            instruments: instruments.unwrap_or(defaults.instruments),
128            credit_budget: credit_budget.unwrap_or(defaults.credit_budget),
129            max_retries: max_retries.unwrap_or(defaults.max_retries),
130            transport_backend: transport_backend.unwrap_or(defaults.transport_backend),
131            book_snapshot_timeout_secs: book_snapshot_timeout_secs
132                .unwrap_or(defaults.book_snapshot_timeout_secs),
133            price_precisions: price_precisions.unwrap_or(defaults.price_precisions),
134            proxy_url: proxy_url.map(SecretString::from),
135            http_timeout_secs: http_timeout_secs.unwrap_or(defaults.http_timeout_secs),
136            max_concurrent_requests: max_concurrent_requests
137                .unwrap_or(defaults.max_concurrent_requests),
138            retry_delay_initial_ms: retry_delay_initial_ms
139                .unwrap_or(defaults.retry_delay_initial_ms),
140            retry_delay_max_ms: retry_delay_max_ms.unwrap_or(defaults.retry_delay_max_ms),
141            retry_max_elapsed_ms: retry_max_elapsed_ms.unwrap_or(defaults.retry_max_elapsed_ms),
142        };
143
144        config.validate().map_err(to_pyvalue_err)?;
145        Ok(config)
146    }
147
148    #[getter]
149    const fn has_proxy_url(&self) -> bool {
150        self.proxy_url.is_some()
151    }
152
153    fn __repr__(&self) -> String {
154        stringify!(CoinApiDataClientConfig).to_string()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use rstest::rstest;
161
162    use super::*;
163
164    #[rstest]
165    #[case(TransportBackend::Tungstenite)]
166    #[case(TransportBackend::Sockudo)]
167    fn constructors_preserve_selection_and_configuration_fields(#[case] backend: TransportBackend) {
168        let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
169        let selection = CoinApiInstrumentSelection::py_new(
170            "COINBASE",
171            vec![instrument_id],
172            Some("COINBASE_SPOT_BTC_USD".into()),
173            Some("BTC".into()),
174        )
175        .unwrap();
176        assert_eq!(selection.exchange_id(), "COINBASE");
177        assert_eq!(selection.instrument_ids(), [instrument_id]);
178        assert_eq!(
179            selection.filter_symbol_id().as_deref(),
180            Some("COINBASE_SPOT_BTC_USD")
181        );
182        assert_eq!(selection.filter_asset_id().as_deref(), Some("BTC"));
183        let config = CoinApiDataClientConfig::py_new(
184            Some("test-key".into()),
185            Some("http://localhost:8123".into()),
186            Some("ws://localhost:8124".into()),
187            Some(vec![selection.clone()]),
188            Some(17),
189            Some(2),
190            Some(backend),
191            Some(7),
192            Some(HashMap::from([(instrument_id, 3)])),
193            Some("http://user:proxy-secret@localhost:8125".into()),
194            Some(13),
195            Some(4),
196            Some(29),
197            Some(53),
198            Some(71_000),
199        )
200        .unwrap();
201
202        assert_eq!(config.api_key.as_ref().unwrap().expose_secret(), "test-key");
203        assert_eq!(
204            config.base_url_http.as_deref(),
205            Some("http://localhost:8123")
206        );
207        assert_eq!(config.base_url_ws.as_deref(), Some("ws://localhost:8124"));
208        assert_eq!(config.instruments.len(), 1);
209        assert_eq!(config.instruments[0].exchange_id, selection.exchange_id);
210        assert_eq!(
211            config.instruments[0].instrument_ids,
212            selection.instrument_ids
213        );
214        assert_eq!(
215            config.instruments[0].query.filter_symbol_id,
216            selection.query.filter_symbol_id
217        );
218        assert_eq!(
219            config.instruments[0].query.filter_asset_id,
220            selection.query.filter_asset_id
221        );
222        assert_eq!(
223            config.proxy_url.as_ref().unwrap().expose_secret(),
224            "http://user:proxy-secret@localhost:8125"
225        );
226        assert_eq!(config.http_timeout_secs, 13);
227        assert_eq!(config.max_concurrent_requests, 4);
228        assert_eq!(config.retry_delay_initial_ms, 29);
229        assert_eq!(config.retry_delay_max_ms, 53);
230        assert_eq!(config.retry_max_elapsed_ms, 71_000);
231        assert_eq!(config.credit_budget, 17);
232        assert_eq!(config.max_retries, 2);
233        assert_eq!(config.transport_backend, backend);
234        assert_eq!(config.book_snapshot_timeout_secs, 7);
235        assert_eq!(config.price_precisions, HashMap::from([(instrument_id, 3)]));
236    }
237}