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