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