1use 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#[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 pub exchange_id: Ustr,
52 #[serde(default)]
54 pub query: SymbolsQuery,
55 pub instrument_ids: Vec<InstrumentId>,
57}
58
59#[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 pub api_key: Option<SecretString>,
73 pub base_url_http: Option<String>,
75 pub base_url_ws: Option<String>,
77 pub proxy_url: Option<SecretString>,
79 #[builder(default)]
81 pub instruments: Vec<CoinApiInstrumentSelection>,
82 #[builder(default = CoinApiHttpConfig::default().credit_budget)]
84 pub credit_budget: u64,
85 #[builder(default = CoinApiHttpConfig::default().retry.max_retries)]
87 pub max_retries: u32,
88 #[builder(default = CoinApiHttpConfig::default().retry.operation_timeout_ms.expect("default REST timeout is set") / 1_000)]
90 pub http_timeout_secs: u64,
91 #[builder(default = CoinApiHttpConfig::default().max_concurrent_requests)]
93 pub max_concurrent_requests: usize,
94 #[builder(default = CoinApiHttpConfig::default().retry.initial_delay_ms)]
96 pub retry_delay_initial_ms: u64,
97 #[builder(default = CoinApiHttpConfig::default().retry.max_delay_ms)]
99 pub retry_delay_max_ms: u64,
100 #[builder(default = CoinApiHttpConfig::default().retry.max_elapsed_ms.expect("default REST elapsed budget is set"))]
102 pub retry_max_elapsed_ms: u64,
103 #[builder(default)]
105 pub transport_backend: TransportBackend,
106 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
109 pub book_snapshot_timeout_secs: u64,
110 #[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 pub fn validate(&self) -> anyhow::Result<()> {
147 validate_selections(&self.instruments)
148 }
149
150 #[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 #[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 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}