Skip to main content

nautilus_coinapi/
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//! Configuration for the CoinAPI data client.
17
18use std::collections::HashMap;
19
20use nautilus_core::string::secret::SecretString;
21use nautilus_live::book::DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS;
22use nautilus_model::identifiers::InstrumentId;
23use nautilus_network::{retry::RetryConfig, websocket::TransportBackend};
24use serde::{Deserialize, Serialize};
25use ustr::Ustr;
26
27use crate::{
28    http::{
29        client::{CoinApiHttpConfig, REST_URL},
30        query::SymbolsQuery,
31    },
32    websocket::client::{CoinApiWebSocketConfig, WS_URL},
33};
34
35/// Native instruments to load from the active symbols of one CoinAPI exchange.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38#[cfg_attr(
39    feature = "python",
40    pyo3::pyclass(module = "nautilus_trader.adapters.coinapi", from_py_object)
41)]
42#[cfg_attr(
43    feature = "python",
44    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinapi")
45)]
46pub struct CoinApiInstrumentSelection {
47    /// CoinAPI exchange whose active symbols are requested, for example `COINBASE`.
48    pub exchange_id: Ustr,
49    /// Metadata filters that bound the response size and its credit cost.
50    #[serde(default)]
51    pub query: SymbolsQuery,
52    /// Native instrument IDs to load; every ID must map to `exchange_id`.
53    pub instrument_ids: Vec<InstrumentId>,
54}
55
56/// Configuration for [`CoinApiDataClient`](crate::data::CoinApiDataClient).
57#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
58#[serde(default, deny_unknown_fields)]
59#[cfg_attr(
60    feature = "python",
61    pyo3::pyclass(module = "nautilus_trader.adapters.coinapi", from_py_object)
62)]
63#[cfg_attr(
64    feature = "python",
65    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinapi")
66)]
67pub struct CoinApiDataClientConfig {
68    /// API key; `None` falls back to the `COINAPI_API_KEY` environment variable.
69    pub api_key: Option<SecretString>,
70    /// Optional override for the REST base URL, including its version prefix.
71    pub base_url_http: Option<String>,
72    /// Optional override for the WebSocket URL.
73    pub base_url_ws: Option<String>,
74    /// Optional explicit proxy URL for HTTP and WebSocket transports.
75    pub proxy_url: Option<SecretString>,
76    /// Instruments loaded on connect and served by instrument requests.
77    #[builder(default)]
78    pub instruments: Vec<CoinApiInstrumentSelection>,
79    /// Conservative lifetime REST credit allowance for this client; see [`CoinApiHttpConfig`].
80    #[builder(default = CoinApiHttpConfig::default().credit_budget)]
81    pub credit_budget: u64,
82    /// Maximum retries for a failed REST request.
83    #[builder(default = CoinApiHttpConfig::default().retry.max_retries)]
84    pub max_retries: u32,
85    /// Positive per-attempt REST timeout in seconds, including concurrency admission.
86    #[builder(default = CoinApiHttpConfig::default().retry.operation_timeout_ms.expect("default REST timeout is set") / 1_000)]
87    pub http_timeout_secs: u64,
88    /// Maximum simultaneous REST requests for this client instance.
89    #[builder(default = CoinApiHttpConfig::default().max_concurrent_requests)]
90    pub max_concurrent_requests: usize,
91    /// Initial REST retry delay in milliseconds.
92    #[builder(default = CoinApiHttpConfig::default().retry.initial_delay_ms)]
93    pub retry_delay_initial_ms: u64,
94    /// Maximum REST retry backoff in milliseconds; provider retry delays can exceed it.
95    #[builder(default = CoinApiHttpConfig::default().retry.max_delay_ms)]
96    pub retry_delay_max_ms: u64,
97    /// Positive total REST elapsed budget in milliseconds, including admission and retries.
98    #[builder(default = CoinApiHttpConfig::default().retry.max_elapsed_ms.expect("default REST elapsed budget is set"))]
99    pub retry_max_elapsed_ms: u64,
100    /// WebSocket transport backend.
101    #[builder(default)]
102    pub transport_backend: TransportBackend,
103    /// Seconds to wait for an order book snapshot after a subscription, reconnect, or recovery
104    /// request; zero disables the deadline.
105    #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
106    pub book_snapshot_timeout_secs: u64,
107    /// Price precision by selected instrument, replacing the CoinAPI price resolution when it is
108    /// coarser than the prices the venue quotes.
109    #[builder(default)]
110    pub price_precisions: HashMap<InstrumentId, u8>,
111}
112
113#[cfg(feature = "python")]
114nautilus_core::impl_pyo3_config_getters!(CoinApiDataClientConfig {
115    base_url_http: Option<String>,
116    base_url_ws: Option<String>,
117    instruments: Vec<CoinApiInstrumentSelection>,
118    credit_budget: u64,
119    max_retries: u32,
120    http_timeout_secs: u64,
121    max_concurrent_requests: usize,
122    retry_delay_initial_ms: u64,
123    retry_delay_max_ms: u64,
124    retry_max_elapsed_ms: u64,
125    transport_backend: TransportBackend,
126    book_snapshot_timeout_secs: u64,
127    price_precisions: HashMap<InstrumentId, u8>,
128});
129
130impl Default for CoinApiDataClientConfig {
131    fn default() -> Self {
132        Self::builder().build()
133    }
134}
135
136impl CoinApiDataClientConfig {
137    /// Returns the REST transport configuration, applying overrides to the defaults.
138    #[must_use]
139    pub fn http_config(&self) -> CoinApiHttpConfig {
140        let default = CoinApiHttpConfig::default();
141
142        CoinApiHttpConfig {
143            base_url: self
144                .base_url_http
145                .clone()
146                .unwrap_or_else(|| REST_URL.into()),
147            credit_budget: self.credit_budget,
148            proxy_url: self.proxy_url.clone(),
149            max_concurrent_requests: self.max_concurrent_requests,
150            retry: RetryConfig {
151                max_retries: self.max_retries,
152                operation_timeout_ms: Some(self.http_timeout_secs.saturating_mul(1_000)),
153                initial_delay_ms: self.retry_delay_initial_ms,
154                max_delay_ms: self.retry_delay_max_ms,
155                max_elapsed_ms: Some(self.retry_max_elapsed_ms),
156                ..default.retry
157            },
158        }
159    }
160
161    /// Returns the WebSocket transport configuration, applying overrides to the defaults.
162    #[must_use]
163    pub fn websocket_config(&self) -> CoinApiWebSocketConfig {
164        CoinApiWebSocketConfig {
165            url: self.base_url_ws.clone().unwrap_or_else(|| WS_URL.into()),
166            backend: self.transport_backend,
167            proxy_url: self.proxy_url.clone(),
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use rstest::rstest;
175
176    use super::*;
177
178    #[rstest]
179    fn default_resolves_transport_defaults() {
180        let config = CoinApiDataClientConfig::default();
181        let http = config.http_config();
182        let websocket = config.websocket_config();
183        let http_default = CoinApiHttpConfig::default();
184
185        assert!(config.api_key.is_none());
186        assert!(config.instruments.is_empty());
187        assert!(config.price_precisions.is_empty());
188        assert_eq!(http.base_url, REST_URL);
189        assert_eq!(
190            http.max_concurrent_requests,
191            http_default.max_concurrent_requests
192        );
193        assert_eq!(http.credit_budget, http_default.credit_budget);
194        // RetryConfig has no PartialEq, so compare its complete Debug form
195        assert_eq!(
196            format!("{:?}", http.retry),
197            format!("{:?}", http_default.retry)
198        );
199        assert_eq!(websocket.url, WS_URL);
200        assert_eq!(websocket.backend, TransportBackend::Sockudo);
201        assert!(config.proxy_url.is_none());
202        assert!(http.proxy_url.is_none());
203        assert!(websocket.proxy_url.is_none());
204        assert_eq!(
205            config.book_snapshot_timeout_secs,
206            DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS
207        );
208    }
209
210    #[rstest]
211    #[case(13, 13_000)]
212    #[case(0, 0)]
213    #[case(u64::MAX, u64::MAX)]
214    fn overrides_reach_transport_configs(#[case] timeout_secs: u64, #[case] timeout_ms: u64) {
215        let config = CoinApiDataClientConfig::builder()
216            .base_url_http("http://127.0.0.1:1/v1/".into())
217            .base_url_ws("ws://127.0.0.1:2/v1/".into())
218            .proxy_url("http://user:proxy-secret@127.0.0.1:3".into())
219            .http_timeout_secs(timeout_secs)
220            .max_concurrent_requests(4)
221            .retry_delay_initial_ms(29)
222            .retry_delay_max_ms(53)
223            .retry_max_elapsed_ms(71_000)
224            .credit_budget(7)
225            .max_retries(0)
226            .transport_backend(TransportBackend::Tungstenite)
227            .build();
228        let http = config.http_config();
229        let websocket = config.websocket_config();
230        let http_default = CoinApiHttpConfig::default();
231
232        assert_eq!(http.base_url, "http://127.0.0.1:1/v1/");
233        assert_eq!(http.credit_budget, 7);
234        assert_eq!(
235            format!("{:?}", http.retry),
236            format!(
237                "{:?}",
238                RetryConfig {
239                    max_retries: 0,
240                    operation_timeout_ms: Some(timeout_ms),
241                    initial_delay_ms: 29,
242                    max_delay_ms: 53,
243                    max_elapsed_ms: Some(71_000),
244                    ..http_default.retry
245                }
246            )
247        );
248        assert_eq!(http.max_concurrent_requests, 4);
249        assert_eq!(
250            http.proxy_url.as_ref().unwrap().expose_secret(),
251            "http://user:proxy-secret@127.0.0.1:3"
252        );
253        assert_eq!(
254            websocket.proxy_url.as_ref().unwrap().expose_secret(),
255            "http://user:proxy-secret@127.0.0.1:3"
256        );
257        assert!(!format!("{config:?} {http:?} {websocket:?}").contains("proxy-secret"));
258        assert_eq!(websocket.url, "ws://127.0.0.1:2/v1/");
259        assert_eq!(websocket.backend, TransportBackend::Tungstenite);
260    }
261
262    #[rstest]
263    #[case(0, 120_000, 30)]
264    #[case(1, 0, 30)]
265    #[case(1, 120_000, 0)]
266    fn transport_settings_reject_unbounded_admission(
267        #[case] concurrency: usize,
268        #[case] elapsed_ms: u64,
269        #[case] timeout_secs: u64,
270    ) {
271        let config = CoinApiDataClientConfig::builder()
272            .max_concurrent_requests(concurrency)
273            .retry_max_elapsed_ms(elapsed_ms)
274            .http_timeout_secs(timeout_secs)
275            .build();
276        let error =
277            crate::http::client::CoinApiHttpClient::new("test-key".into(), config.http_config())
278                .unwrap_err();
279        assert!(
280            matches!(error, crate::http::error::Error::Validation(message)
281            if message == "concurrency, credits, elapsed budget, and configured timeout must be positive and bounded")
282        );
283    }
284
285    #[rstest]
286    #[case(0, 10_000, "delay_initial must be non-zero")]
287    #[case(1_000, 500, "delay_max must be >= delay_initial")]
288    #[case(
289        1_000,
290        u64::MAX,
291        "delay_max exceeds maximum representable duration (≈584 years)"
292    )]
293    fn transport_settings_reject_invalid_backoff(
294        #[case] initial_ms: u64,
295        #[case] max_ms: u64,
296        #[case] expected: &str,
297    ) {
298        let config = CoinApiDataClientConfig::builder()
299            .max_retries(0)
300            .retry_delay_initial_ms(initial_ms)
301            .retry_delay_max_ms(max_ms)
302            .build();
303        let error =
304            crate::http::client::CoinApiHttpClient::new("test-key".into(), config.http_config())
305                .unwrap_err();
306        assert!(
307            matches!(error, crate::http::error::Error::Validation(message) if message == expected)
308        );
309    }
310
311    #[rstest]
312    fn transport_settings_survive_serde_roundtrip() {
313        let config = CoinApiDataClientConfig::builder()
314            .proxy_url("http://user:proxy-secret@localhost:1234".into())
315            .http_timeout_secs(13)
316            .max_concurrent_requests(4)
317            .retry_delay_initial_ms(29)
318            .retry_delay_max_ms(53)
319            .retry_max_elapsed_ms(71_000)
320            .transport_backend(TransportBackend::Tungstenite)
321            .build();
322        let json = serde_json::to_string(&config).unwrap();
323        let decoded: CoinApiDataClientConfig = serde_json::from_str(&json).unwrap();
324
325        assert_eq!(
326            decoded.proxy_url.as_ref().unwrap().expose_secret(),
327            config.proxy_url.as_ref().unwrap().expose_secret()
328        );
329        assert_eq!(decoded.http_timeout_secs, 13);
330        assert_eq!(decoded.max_concurrent_requests, 4);
331        assert_eq!(decoded.retry_delay_initial_ms, 29);
332        assert_eq!(decoded.retry_delay_max_ms, 53);
333        assert_eq!(decoded.retry_max_elapsed_ms, 71_000);
334        assert_eq!(decoded.transport_backend, TransportBackend::Tungstenite);
335    }
336
337    #[rstest]
338    fn deserializes_selection_and_fills_defaults() {
339        let config: CoinApiDataClientConfig = serde_json::from_str(
340            r#"{"instruments":[{"exchange_id":"COINBASE","query":{"filter_symbol_id":"COINBASE_SPOT_BTC_USD"},"instrument_ids":["BTC-USD.COINBASE"]}],"credit_budget":3,"book_snapshot_timeout_secs":0,"price_precisions":{"BTC-USD.COINBASE":3}}"#,
341        )
342        .unwrap();
343
344        assert_eq!(config.credit_budget, 3);
345        assert_eq!(config.book_snapshot_timeout_secs, 0);
346        assert_eq!(
347            config.price_precisions,
348            HashMap::from([(InstrumentId::from("BTC-USD.COINBASE"), 3)])
349        );
350        assert_eq!(
351            config.max_retries,
352            CoinApiHttpConfig::default().retry.max_retries
353        );
354        assert_eq!(config.instruments.len(), 1);
355        assert_eq!(config.instruments[0].exchange_id, "COINBASE");
356        assert_eq!(
357            config.instruments[0].query.filter_symbol_id.as_deref(),
358            Some("COINBASE_SPOT_BTC_USD")
359        );
360        assert_eq!(config.instruments[0].query.filter_asset_id, None);
361        assert_eq!(
362            config.instruments[0].instrument_ids,
363            vec![InstrumentId::from("BTC-USD.COINBASE")]
364        );
365    }
366
367    #[rstest]
368    #[case(r#"{"credit_budgett":3}"#)]
369    #[case(r#"{"instruments":[{"exchange_id":"COINBASE","instrument_ids":[],"extra":1}]}"#)]
370    #[case(r#"{"instruments":[{"exchange_id":"COINBASE","query":{"filter":"X"},"instrument_ids":[]}]}"#)]
371    fn rejects_unknown_fields(#[case] json: &str) {
372        assert!(serde_json::from_str::<CoinApiDataClientConfig>(json).is_err());
373    }
374}