1use 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#[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 pub exchange_id: Ustr,
53 #[serde(default)]
55 pub query: SymbolsQuery,
56 pub instrument_ids: Vec<InstrumentId>,
58}
59
60#[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 pub api_key: Option<SecretString>,
74 pub base_url_http: Option<String>,
76 pub base_url_ws: Option<String>,
78 pub proxy_url: Option<SecretString>,
80 #[builder(default)]
82 pub instruments: Vec<CoinApiInstrumentSelection>,
83 #[builder(default = CoinApiHttpConfig::default().credit_budget)]
85 pub credit_budget: u64,
86 #[builder(default = CoinApiHttpConfig::default().retry.max_retries)]
88 pub max_retries: u32,
89 #[builder(default = CoinApiHttpConfig::default().retry.operation_timeout_ms.expect("default REST timeout is set") / 1_000)]
91 pub http_timeout_secs: u64,
92 #[builder(default = CoinApiHttpConfig::default().max_concurrent_requests)]
94 pub max_concurrent_requests: usize,
95 #[builder(default = CoinApiHttpConfig::default().retry.initial_delay_ms)]
97 pub retry_delay_initial_ms: u64,
98 #[builder(default = CoinApiHttpConfig::default().retry.max_delay_ms)]
100 pub retry_delay_max_ms: u64,
101 #[builder(default = CoinApiHttpConfig::default().retry.max_elapsed_ms.expect("default REST elapsed budget is set"))]
103 pub retry_max_elapsed_ms: u64,
104 #[builder(default)]
106 pub transport_backend: TransportBackend,
107 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
110 pub book_snapshot_timeout_secs: u64,
111 #[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 pub fn validate(&self) -> anyhow::Result<()> {
148 validate_selections(&self.instruments)
149 }
150
151 #[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 #[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 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}