1use 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#[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 pub exchange_id: Ustr,
49 #[serde(default)]
51 pub query: SymbolsQuery,
52 pub instrument_ids: Vec<InstrumentId>,
54}
55
56#[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 pub api_key: Option<SecretString>,
70 pub base_url_http: Option<String>,
72 pub base_url_ws: Option<String>,
74 pub proxy_url: Option<SecretString>,
76 #[builder(default)]
78 pub instruments: Vec<CoinApiInstrumentSelection>,
79 #[builder(default = CoinApiHttpConfig::default().credit_budget)]
81 pub credit_budget: u64,
82 #[builder(default = CoinApiHttpConfig::default().retry.max_retries)]
84 pub max_retries: u32,
85 #[builder(default = CoinApiHttpConfig::default().retry.operation_timeout_ms.expect("default REST timeout is set") / 1_000)]
87 pub http_timeout_secs: u64,
88 #[builder(default = CoinApiHttpConfig::default().max_concurrent_requests)]
90 pub max_concurrent_requests: usize,
91 #[builder(default = CoinApiHttpConfig::default().retry.initial_delay_ms)]
93 pub retry_delay_initial_ms: u64,
94 #[builder(default = CoinApiHttpConfig::default().retry.max_delay_ms)]
96 pub retry_delay_max_ms: u64,
97 #[builder(default = CoinApiHttpConfig::default().retry.max_elapsed_ms.expect("default REST elapsed budget is set"))]
99 pub retry_max_elapsed_ms: u64,
100 #[builder(default)]
102 pub transport_backend: TransportBackend,
103 #[builder(default = DEFAULT_BOOK_SNAPSHOT_TIMEOUT_SECS)]
106 pub book_snapshot_timeout_secs: u64,
107 #[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 #[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 #[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 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}