Skip to main content

nautilus_coinapi/http/
client.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//! CoinAPI REST transport with bounded retries and local admission limits.
17
18use 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
51/// Primary CoinAPI Market Data REST endpoint, including its version prefix.
52pub const REST_URL: &str = "https://rest.coinapi.io/v1/";
53
54/// Transport configuration for the raw REST client.
55///
56/// # Credit Allowance
57///
58/// `credit_budget` is shared by all requests on this instance:
59///
60/// - Each attempt reserves ceil(requested limit / 100) credits, or one credit
61///   when the request has no record limit.
62/// - A successful historical page keeps ceil(returned records / 100) credits,
63///   with a minimum of one, and refunds the rest of its reservation.
64/// - Failed or canceled attempts keep their full reservation.
65///
66/// This is a local safety limit, not the provider's billing ledger, and it does
67/// not coordinate with other clients.
68#[derive(Debug, Clone)]
69pub struct CoinApiHttpConfig {
70    /// Base URL including the version prefix; accepts local simulated peers.
71    pub base_url: String,
72    /// Optional explicit proxy URL; ambient system proxies remain disabled.
73    pub proxy_url: Option<SecretString>,
74    /// Maximum simultaneous requests for this client instance.
75    pub max_concurrent_requests: usize,
76    /// Lifetime credit allowance for this instance; see the struct docs for accounting.
77    pub credit_budget: u64,
78    /// Retry policy; the elapsed budget and any configured attempt timeout must be positive.
79    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/// Records gathered from one or more historical pages, in ascending time order.
99///
100/// `is_truncated` reports that the credit budget stopped paging before the window or limit was
101/// exhausted.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct HistoryPages<T> {
104    pub records: Vec<T>,
105    pub is_truncated: bool,
106}
107
108/// Authenticated, data-only CoinAPI REST client.
109///
110/// Requests return raw protocol models. The single-page historical methods fetch exactly one
111/// page; callers must not infer completeness from a full page. The range methods page through a
112/// whole window.
113#[derive(Debug)]
114pub struct CoinApiHttpClient {
115    client: HttpClient,
116    base_url: Url,
117    api_key: SecretString,
118    concurrency: tokio::sync::Semaphore,
119    // Serializes range requests so each sizes its pages after earlier ranges settle their credits
120    ranges: tokio::sync::Mutex<()>,
121    credits_remaining: AtomicU64,
122    retry: RetryManager<Error>,
123    cancellation: CancellationToken,
124}
125
126impl CoinApiHttpClient {
127    /// Creates a client using an explicit secret and endpoint configuration.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the credentials, endpoint, limits, or transport configuration are
132    /// invalid.
133    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    /// Cancels pending and future requests on this instance.
210    pub fn cancel(&self) {
211        self.cancellation.cancel();
212    }
213
214    /// Returns the remaining conservative local credit allowance.
215    #[must_use]
216    pub fn credits_remaining(&self) -> u64 {
217        self.credits_remaining.load(Ordering::Relaxed)
218    }
219
220    /// Retrieves exchange metadata with optional filtering.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the request fails, the credit budget is exhausted, the transport or HTTP
225    /// request fails, or decoding fails.
226    pub async fn get_exchanges(&self, query: &ExchangesQuery) -> Result<Vec<Exchange>, Error> {
227        self.get(&["exchanges"], query, 1, |_| 1).await
228    }
229
230    /// Retrieves active symbols for one exchange.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
235    /// request fails, or decoding fails.
236    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    /// Loads native instruments from the active symbols of one exchange.
246    ///
247    /// Only spot, perpetual, and futures symbols are parsed. Symbols of those types that cannot
248    /// be mapped to a native instrument are logged and skipped. An instrument listed in
249    /// `price_precisions` takes that price precision instead of the CoinAPI price resolution.
250    ///
251    /// # Errors
252    ///
253    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
254    /// request fails, or decoding fails. Validation rejects a price precision beyond the Nautilus
255    /// maximum.
256    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    /// Retrieves a current L2 market-by-price book for one symbol.
300    ///
301    /// # Errors
302    ///
303    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
304    /// request fails, or decoding fails.
305    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    /// Retrieves a current L3 market-by-order book for one symbol.
310    ///
311    /// # Errors
312    ///
313    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
314    /// request fails, or decoding fails.
315    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    /// Retrieves one historical quote page with an explicit window and limit.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
343    /// request fails, or decoding fails.
344    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    /// Retrieves historical quotes across a window, ordered and paged by `time_exchange`.
356    ///
357    /// # Errors
358    ///
359    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
360    /// request fails, decoding fails, or response ordering fails.
361    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    /// Retrieves one historical trade page with an explicit window and limit.
375    ///
376    /// # Errors
377    ///
378    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
379    /// request fails, or decoding fails.
380    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    /// Retrieves historical trades across a window, ordered and paged by `time_exchange`.
392    ///
393    /// # Errors
394    ///
395    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
396    /// request fails, decoding fails, or response ordering fails.
397    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    /// Retrieves one historical OHLCV page for the requested period.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
422    /// request fails, or decoding fails.
423    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    /// Retrieves OHLCV periods starting within a window, ordered and paged by
439    /// `time_period_start`.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if validation fails, the credit budget is exhausted, the transport or HTTP
444    /// request fails, decoding fails, or response ordering fails.
445    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    // A full page can end partway through the records that share its last timestamp, so that
467    // group is dropped and the next page starts at its timestamp. CoinAPI returns the window
468    // [time_start, time_end), including every record at time_start.
469    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        // A window between two 100 ns grid points holds no CoinAPI records
480        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            // Another request or a failed attempt spent credits after this page was sized
520            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            // CoinAPI can substitute nearby OHLCV periods for an empty window
535            page.retain(|record| (cursor..range.time_end).contains(&key(record)));
536
537            // A full page emptied by filtering held only fallback periods outside the window
538            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    // Returns the unbilled part of the reservation before the permit is released, so the next
562    // admitted request sees the settled budget
563    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// CoinAPI bills each 100 returned records, and at least one credit per request
675#[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}