1use std::{
19 collections::HashMap,
20 future::Future,
21 sync::atomic::{AtomicU64, Ordering},
22 time::Duration,
23};
24
25use jiff::Timestamp;
26use nautilus_core::{UnixNanos, string::secret::SecretString};
27use nautilus_model::{
28 identifiers::InstrumentId, instruments::Instrument, types::fixed::check_fixed_precision,
29};
30use nautilus_network::{
31 backoff::ExponentialBackoff,
32 http::{HttpClient, HttpRedirectPolicy, HttpResponse, Method},
33 retry::{RetryConfig, RetryManager},
34};
35use rust_decimal::Decimal;
36use serde::{Deserialize, Serialize, de::DeserializeOwned};
37use tokio_util::sync::CancellationToken;
38use url::Url;
39
40use super::{
41 error::Error,
42 models::{Book, Exchange, Ohlcv, Quote, Symbol, Trade},
43 parse::{CoinApiInstrument, parse_instrument, parse_instruments},
44 query::{
45 BookQuery, ExchangesQuery, HISTORY_LIMIT_MAX, HistoryQuery, HistoryRange, OhlcvQuery,
46 SymbolsQuery, TradesQuery, provider_time,
47 },
48};
49use crate::common::{consts::COINAPI_NAUTILUS_REFERRER, credential::is_valid_api_key};
50
51pub const REST_URL: &str = "https://rest.coinapi.io/v1/";
53
54#[derive(Debug, Clone)]
69pub struct CoinApiHttpConfig {
70 pub base_url: String,
72 pub proxy_url: Option<SecretString>,
74 pub max_concurrent_requests: usize,
76 pub credit_budget: u64,
78 pub retry: RetryConfig,
80}
81
82impl Default for CoinApiHttpConfig {
83 fn default() -> Self {
84 Self {
85 base_url: REST_URL.into(),
86 proxy_url: None,
87 max_concurrent_requests: 1,
88 credit_budget: 100,
89 retry: RetryConfig {
90 jitter_ms: 0,
91 max_elapsed_ms: Some(120_000),
92 ..RetryConfig::default()
93 },
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct HistoryPages<T> {
104 pub records: Vec<T>,
105 pub is_truncated: bool,
106}
107
108#[derive(Debug)]
114pub struct CoinApiHttpClient {
115 client: HttpClient,
116 base_url: Url,
117 api_key: SecretString,
118 concurrency: tokio::sync::Semaphore,
119 ranges: tokio::sync::Mutex<()>,
121 credits_remaining: AtomicU64,
122 retry: RetryManager<Error>,
123 cancellation: CancellationToken,
124}
125
126impl CoinApiHttpClient {
127 pub fn new(api_key: SecretString, config: CoinApiHttpConfig) -> Result<Self, Error> {
134 if !is_valid_api_key(api_key.expose_secret()) {
135 return Err(Error::Validation(
136 "API key must contain visible ASCII characters".into(),
137 ));
138 }
139
140 let mut base_url = Url::parse(&config.base_url)
141 .map_err(|_| Error::Validation("invalid base URL".into()))?;
142
143 if !matches!(base_url.scheme(), "http" | "https")
144 || base_url.host_str().is_none()
145 || !base_url.username().is_empty()
146 || base_url.password().is_some()
147 || base_url.query().is_some()
148 || base_url.fragment().is_some()
149 {
150 return Err(Error::Validation(
151 "base URL must be HTTP(S) without credentials, query, or fragment".into(),
152 ));
153 }
154
155 if !base_url.path().ends_with('/') {
156 base_url.set_path(&format!("{}/", base_url.path()));
157 }
158
159 if config.max_concurrent_requests == 0
160 || config.max_concurrent_requests > tokio::sync::Semaphore::MAX_PERMITS
161 || config.credit_budget == 0
162 || config.retry.max_elapsed_ms.is_none_or(|ms| ms == 0)
163 || config.retry.operation_timeout_ms == Some(0)
164 {
165 return Err(Error::Validation(
166 "concurrency, credits, elapsed budget, and configured timeout must be positive and bounded"
167 .into(),
168 ));
169 }
170
171 ExponentialBackoff::new(
172 Duration::from_millis(config.retry.initial_delay_ms),
173 Duration::from_millis(config.retry.max_delay_ms),
174 config.retry.backoff_factor,
175 config.retry.jitter_ms,
176 config.retry.immediate_first,
177 )
178 .map_err(|e| Error::Validation(e.to_string()))?;
179
180 let client = HttpClient::builder()
181 .redirect_policy(HttpRedirectPolicy::Reject)
182 .use_system_proxy(false)
183 .maybe_proxy_url(
184 config
185 .proxy_url
186 .as_ref()
187 .map(|url| url.expose_secret().to_owned()),
188 )
189 .header_keys(vec![
190 "retry-after".into(),
191 "x-credits-used".into(),
192 "x-credits-remaining".into(),
193 "x-credits-limit".into(),
194 "x-ratelimit-request-cost".into(),
195 ])
196 .build()?;
197 Ok(Self {
198 client,
199 base_url,
200 api_key,
201 concurrency: tokio::sync::Semaphore::new(config.max_concurrent_requests),
202 ranges: tokio::sync::Mutex::new(()),
203 credits_remaining: AtomicU64::new(config.credit_budget),
204 retry: RetryManager::new(config.retry),
205 cancellation: CancellationToken::new(),
206 })
207 }
208
209 pub fn cancel(&self) {
211 self.cancellation.cancel();
212 }
213
214 #[must_use]
216 pub fn credits_remaining(&self) -> u64 {
217 self.credits_remaining.load(Ordering::Relaxed)
218 }
219
220 pub async fn get_exchanges(&self, query: &ExchangesQuery) -> Result<Vec<Exchange>, Error> {
227 self.get(&["exchanges"], query, 1, |_| 1).await
228 }
229
230 pub async fn get_symbols(
237 &self,
238 exchange_id: &str,
239 query: &SymbolsQuery,
240 ) -> Result<Vec<Symbol>, Error> {
241 self.get(&["symbols", exchange_id, "active"], query, 1, |_| 1)
242 .await
243 }
244
245 pub async fn request_instruments(
257 &self,
258 exchange_id: &str,
259 query: &SymbolsQuery,
260 price_precisions: &HashMap<InstrumentId, u8>,
261 ts_init: UnixNanos,
262 ) -> Result<Vec<CoinApiInstrument>, Error> {
263 for (instrument_id, precision) in price_precisions {
264 check_fixed_precision(*precision).map_err(|e| {
265 Error::Validation(format!("price precision for {instrument_id}: {e}"))
266 })?;
267 }
268
269 let symbols = self.get_symbols(exchange_id, query).await?;
270 let (mut instruments, errors) = parse_instruments(&symbols, ts_init);
271
272 for e in errors {
273 log::warn!("Skipping CoinAPI instrument: {e}");
274 }
275
276 for loaded in &mut instruments {
277 let Some(&precision) = price_precisions.get(&loaded.instrument.id()) else {
278 continue;
279 };
280
281 let Some(symbol) = symbols.iter().find(|s| s.symbol_id == loaded.symbol_id) else {
282 continue;
283 };
284
285 let symbol = Symbol {
286 price_precision: Some(Decimal::new(1, u32::from(precision))),
287 ..symbol.clone()
288 };
289
290 match parse_instrument(&symbol, ts_init) {
291 Ok(overridden) => *loaded = overridden,
292 Err(e) => log::warn!("Keeping CoinAPI price resolution: {e}"),
293 }
294 }
295
296 Ok(instruments)
297 }
298
299 pub async fn get_book(&self, symbol_id: &str, query: &BookQuery) -> Result<Book, Error> {
306 self.get_book_at("orderbooks", symbol_id, query).await
307 }
308
309 pub async fn get_book_l3(&self, symbol_id: &str, query: &BookQuery) -> Result<Book, Error> {
316 self.get_book_at("orderbooks3", symbol_id, query).await
317 }
318
319 async fn get_book_at(
320 &self,
321 family: &str,
322 symbol_id: &str,
323 query: &BookQuery,
324 ) -> Result<Book, Error> {
325 if query
326 .limit_levels
327 .is_some_and(|levels| levels == 0 || levels > i32::MAX as u32)
328 {
329 return Err(Error::Validation(
330 "limit_levels must be a positive int32".into(),
331 ));
332 }
333
334 self.get(&[family, symbol_id, "current"], query, 1, |_| 1)
335 .await
336 }
337
338 pub async fn get_quotes(
345 &self,
346 symbol_id: &str,
347 query: &HistoryQuery,
348 ) -> Result<Vec<Quote>, Error> {
349 query.validate()?;
350 let cost = credits(query.limit);
351 self.get(&["quotes", symbol_id, "history"], query, cost, billed)
352 .await
353 }
354
355 pub async fn get_quotes_range(
362 &self,
363 symbol_id: &str,
364 range: &HistoryRange,
365 ) -> Result<HistoryPages<Quote>, Error> {
366 self.get_range(
367 range,
368 |quote: &Quote| quote.time_exchange,
369 |page| async move { self.get_quotes(symbol_id, &page).await },
370 )
371 .await
372 }
373
374 pub async fn get_trades(
381 &self,
382 symbol_id: &str,
383 query: &TradesQuery,
384 ) -> Result<Vec<Trade>, Error> {
385 query.range.validate()?;
386 let cost = credits(query.range.limit);
387 self.get(&["trades", symbol_id, "history"], query, cost, billed)
388 .await
389 }
390
391 pub async fn get_trades_range(
398 &self,
399 symbol_id: &str,
400 range: &HistoryRange,
401 ) -> Result<HistoryPages<Trade>, Error> {
402 self.get_range(
403 range,
404 |trade: &Trade| trade.time_exchange,
405 |page| async move {
406 let query = TradesQuery {
407 range: page,
408 include_id: None,
409 };
410
411 self.get_trades(symbol_id, &query).await
412 },
413 )
414 .await
415 }
416
417 pub async fn get_ohlcv(
424 &self,
425 symbol_id: &str,
426 query: &OhlcvQuery,
427 ) -> Result<Vec<Ohlcv>, Error> {
428 query.range.validate()?;
429 if query.period_id.trim().is_empty() {
430 return Err(Error::Validation("period_id must not be empty".into()));
431 }
432
433 let cost = credits(query.range.limit);
434 self.get(&["ohlcv", symbol_id, "history"], query, cost, billed)
435 .await
436 }
437
438 pub async fn get_ohlcv_range(
446 &self,
447 symbol_id: &str,
448 period_id: &str,
449 range: &HistoryRange,
450 ) -> Result<HistoryPages<Ohlcv>, Error> {
451 self.get_range(
452 range,
453 |ohlcv: &Ohlcv| ohlcv.time_period_start,
454 |page| async move {
455 let query = OhlcvQuery {
456 period_id: period_id.to_owned(),
457 range: page,
458 };
459
460 self.get_ohlcv(symbol_id, &query).await
461 },
462 )
463 .await
464 }
465
466 async fn get_range<T, F, Fut>(
470 &self,
471 range: &HistoryRange,
472 key: fn(&T) -> Timestamp,
473 mut fetch: F,
474 ) -> Result<HistoryPages<T>, Error>
475 where
476 F: FnMut(HistoryQuery) -> Fut,
477 Fut: Future<Output = Result<Vec<T>, Error>>,
478 {
479 if range.time_start < range.time_end
481 && provider_time(range.time_start)? >= provider_time(range.time_end)?
482 {
483 return Ok(HistoryPages {
484 records: Vec::new(),
485 is_truncated: false,
486 });
487 }
488
489 let _range = self.ranges.lock().await;
490 let mut records = Vec::new();
491 let mut cursor = range.time_start;
492 loop {
493 let remaining = range.limit.map(|limit| limit.get() - records.len());
494 let affordable =
495 usize::try_from(self.credits_remaining().saturating_mul(100)).unwrap_or(usize::MAX);
496 let page_limit = remaining
497 .unwrap_or(usize::MAX)
498 .min(HISTORY_LIMIT_MAX as usize)
499 .min(affordable);
500
501 if page_limit == 0 {
502 if records.is_empty() {
503 return Err(Error::CreditBudget);
504 }
505
506 return Ok(HistoryPages {
507 records,
508 is_truncated: true,
509 });
510 }
511
512 let page = fetch(HistoryQuery {
513 time_start: cursor,
514 time_end: range.time_end,
515 limit: page_limit as u32,
516 })
517 .await;
518
519 let mut page = match page {
521 Ok(page) => page,
522 Err(Error::CreditBudget) => continue,
523 Err(e) => return Err(e),
524 };
525
526 if page.windows(2).any(|pair| key(&pair[1]) < key(&pair[0])) {
527 return Err(Error::Response(
528 "history page is not in ascending time order".into(),
529 ));
530 }
531
532 let is_full = page.len() >= page_limit;
533
534 page.retain(|record| (cursor..range.time_end).contains(&key(record)));
536
537 if !is_full || page.is_empty() || remaining == Some(page_limit) {
539 records.extend(page);
540 return Ok(HistoryPages {
541 records,
542 is_truncated: false,
543 });
544 }
545
546 let last = key(&page[page.len() - 1]);
547 let boundary = page.partition_point(|record| key(record) < last);
548
549 if boundary == 0 {
550 return Err(Error::Response(format!(
551 "{page_limit} records share timestamp {last}, so paging cannot advance"
552 )));
553 }
554
555 page.truncate(boundary);
556 records.extend(page);
557 cursor = last;
558 }
559 }
560
561 async fn get<T: DeserializeOwned, Q: Serialize>(
564 &self,
565 path: &[&str],
566 query: &Q,
567 cost: u64,
568 billed: fn(&T) -> u64,
569 ) -> Result<T, Error> {
570 if path
571 .iter()
572 .any(|part| part.trim().is_empty() || matches!(*part, "." | ".."))
573 {
574 return Err(Error::Validation(
575 "path identifiers must not be empty or dot segments".into(),
576 ));
577 }
578
579 let mut url = self.base_url.clone();
580 url.path_segments_mut()
581 .map_err(|()| Error::Validation("base URL cannot contain path segments".into()))?
582 .pop_if_empty()
583 .extend(path);
584 self.retry
585 .invocation(
586 "CoinAPI GET",
587 || async {
588 let _permit = self
589 .concurrency
590 .acquire()
591 .await
592 .map_err(|_| Error::Validation("request admission closed".into()))?;
593 self.credits_remaining
594 .try_update(Ordering::Relaxed, Ordering::Relaxed, |remaining| {
595 remaining.checked_sub(cost)
596 })
597 .map_err(|_| Error::CreditBudget)?;
598
599 let headers = HashMap::from([
600 (
601 "X-CoinAPI-Key".into(),
602 self.api_key.expose_secret().to_owned(),
603 ),
604 ("Accept".into(), "application/json".into()),
605 ("X-Referrer".into(), COINAPI_NAUTILUS_REFERRER.into()),
606 ]);
607 let response = self
608 .client
609 .request_with_params(
610 Method::GET,
611 url.to_string(),
612 Some(query),
613 Some(headers),
614 None,
615 None,
616 None,
617 )
618 .await?;
619 let value = self.decode(&response)?;
620 self.credits_remaining
621 .fetch_add(cost.saturating_sub(billed(&value)), Ordering::Relaxed);
622 Ok(value)
623 },
624 Error::is_retryable,
625 Error::Retry,
626 )
627 .retry_delay(&Error::retry_delay)
628 .cancellation_token(&self.cancellation)
629 .execute()
630 .await
631 }
632
633 fn decode<T: DeserializeOwned>(&self, response: &HttpResponse) -> Result<T, Error> {
634 for (key, value) in &response.headers {
635 if key.starts_with("x-credits-") || key == "x-ratelimit-request-cost" {
636 log::debug!("CoinAPI usage {key}={value}");
637 }
638 }
639
640 if !response.status.is_success() {
641 let status = response.status.as_u16();
642 let retry_after_secs = response
643 .headers
644 .get("retry-after")
645 .and_then(|value| value.parse().ok());
646 return Err(match serde_json::from_slice::<VenueError>(&response.body) {
647 Ok(error) => Error::Venue {
648 status,
649 message: error
650 .error
651 .replace(self.api_key.expose_secret(), "<redacted>"),
652 retry_after_secs,
653 },
654 Err(_) => Error::Http {
655 status,
656 retry_after_secs,
657 },
658 });
659 }
660
661 serde_json::from_slice(&response.body).map_err(Error::Parse)
662 }
663}
664
665#[derive(Deserialize)]
666struct VenueError {
667 error: String,
668}
669
670fn credits(points: u32) -> u64 {
671 u64::from(points.div_ceil(100))
672}
673
674#[expect(
676 clippy::ptr_arg,
677 reason = "`get` passes the decoded response type by reference"
678)]
679fn billed<T>(records: &Vec<T>) -> u64 {
680 credits(u32::try_from(records.len()).unwrap_or(u32::MAX)).max(1)
681}