Skip to main content

nautilus_tardis/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
16use std::{collections::HashMap, fmt::Debug, sync::Arc};
17
18use ahash::{AHashMap, AHashSet};
19use nautilus_core::{
20    DurationNanos, UnixNanos,
21    string::{parsing::precision_from_str, secret::REDACTED, urlencoding},
22};
23use nautilus_model::instruments::InstrumentAny;
24use nautilus_network::{
25    ConnectionUrl,
26    http::{HttpClient, HttpRedirectPolicy, create_standard_nautilus_headers},
27    url_origin,
28};
29
30use super::{
31    error::{Error, TardisErrorResponse},
32    instruments::is_available,
33    models::TardisInstrumentInfo,
34    parse::parse_instrument_any,
35    query::InstrumentFilter,
36};
37use crate::{
38    common::{
39        consts::{TARDIS_REST_QUOTA, TARDIS_REST_RATE_KEY},
40        credential::Credential,
41        enums::TardisExchange,
42        parse::{normalize_instrument_id, parse_instrument_id},
43        urls::TARDIS_HTTP_BASE_URL,
44    },
45    machine::types::{TardisInstrumentKey, TardisInstrumentMiniInfo},
46};
47
48pub type Result<T> = std::result::Result<T, Error>;
49
50// Deribit's all-symbol instrument list decompresses to about 284 MB
51const MAX_DECOMPRESSED_BYTES: usize = 1024 * 1024 * 1024;
52
53/// A Tardis HTTP API client.
54/// See <https://docs.tardis.dev/api/http>.
55#[cfg_attr(
56    feature = "python",
57    pyo3::pyclass(module = "nautilus_trader.adapters.tardis", from_py_object)
58)]
59#[cfg_attr(
60    feature = "python",
61    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.tardis")
62)]
63#[derive(Clone)]
64pub struct TardisHttpClient {
65    base_url: ConnectionUrl,
66    credential: Option<Credential>,
67    client: HttpClient,
68    normalize_symbols: bool,
69}
70
71impl Debug for TardisHttpClient {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct(stringify!(TardisHttpClient))
74            .field("base_url", &self.base_url)
75            .field("credential", &self.credential.as_ref().map(|_| REDACTED))
76            .field("normalize_symbols", &self.normalize_symbols)
77            .finish()
78    }
79}
80
81impl TardisHttpClient {
82    /// Creates a new [`TardisHttpClient`] instance.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if no API key is provided (argument or `TARDIS_API_KEY` env var),
87    /// or if the HTTP client cannot be built.
88    pub fn new(
89        api_key: Option<&str>,
90        base_url: Option<&str>,
91        timeout_secs: Option<u64>,
92        normalize_symbols: bool,
93        proxy_url: Option<String>,
94    ) -> anyhow::Result<Self> {
95        let credential = Credential::resolve(api_key.map(ToString::to_string));
96
97        if credential.is_none() {
98            anyhow::bail!(
99                "API key must be provided or set in the 'TARDIS_API_KEY' environment variable"
100            );
101        }
102
103        let base_url =
104            base_url.map_or_else(|| TARDIS_HTTP_BASE_URL.to_string(), ToString::to_string);
105
106        let mut headers: HashMap<String, String> =
107            create_standard_nautilus_headers().into_iter().collect();
108
109        if let Some(ref cred) = credential {
110            headers.insert(
111                "Authorization".to_string(),
112                format!("Bearer {}", cred.api_key()),
113            );
114        }
115
116        let keyed_quotas = vec![(TARDIS_REST_RATE_KEY.to_string(), *TARDIS_REST_QUOTA)];
117        let client = HttpClient::builder()
118            .redirect_policy(HttpRedirectPolicy::Reject)
119            .headers(headers)
120            .keyed_quotas(keyed_quotas)
121            .default_quota(*TARDIS_REST_QUOTA)
122            .maybe_timeout_secs(timeout_secs.or(Some(60)))
123            .maybe_proxy_url(proxy_url)
124            .max_decompressed_bytes(MAX_DECOMPRESSED_BYTES)
125            .build()?;
126
127        Ok(Self {
128            base_url: base_url.into(),
129            credential,
130            client,
131            normalize_symbols,
132        })
133    }
134
135    /// Returns the credential associated with this client.
136    #[must_use]
137    pub const fn credential(&self) -> Option<&Credential> {
138        self.credential.as_ref()
139    }
140
141    /// Returns all Tardis instrument definitions for the given `exchange`.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the HTTP request fails or the response cannot be parsed.
146    ///
147    /// See <https://docs.tardis.dev/api/instruments-metadata-api>.
148    pub async fn instruments_info(
149        &self,
150        exchange: TardisExchange,
151        symbol: Option<&str>,
152        filter: Option<&InstrumentFilter>,
153    ) -> Result<Vec<TardisInstrumentInfo>> {
154        let mut url = format!("{}/instruments/{exchange}", self.base_url.expose());
155        if let Some(symbol) = symbol {
156            url.push_str(&format!("/{symbol}"));
157        }
158
159        if let Some(filter) = filter
160            && let Ok(filter_json) = serde_json::to_string(filter)
161        {
162            url.push_str(&format!("?filter={}", urlencoding::encode(&filter_json)));
163        }
164
165        log::debug!("Requesting: {}", url_origin(&url));
166
167        let rate_keys = Some(vec![TARDIS_REST_RATE_KEY.to_string()]);
168
169        let response = self
170            .client
171            .get(url, None, None, None, rate_keys)
172            .await
173            .map_err(|e| {
174                Error::Request(format!("Request to {} failed: {e}", self.base_url.origin()))
175            })?;
176
177        let status = response.status.as_u16();
178        log::debug!("Response status: {status}");
179
180        let body = &response.body;
181        if !response.status.is_success() {
182            let body = String::from_utf8_lossy(body).to_string();
183            return if let Ok(error) = serde_json::from_str::<TardisErrorResponse>(&body) {
184                Err(Error::ApiError {
185                    status,
186                    code: error.code,
187                    message: error.message,
188                })
189            } else {
190                Err(Error::ApiError {
191                    status,
192                    code: 0,
193                    message: body,
194                })
195            };
196        }
197
198        let body = String::from_utf8_lossy(body);
199        log::trace!("{body}");
200
201        if let Ok(instrument) = serde_json::from_str::<TardisInstrumentInfo>(&body) {
202            return Ok(vec![instrument]);
203        }
204
205        match serde_json::from_str(&body) {
206            Ok(parsed) => Ok(parsed),
207            Err(e) => {
208                log::debug!("Failed to parse response: {e}");
209                log::debug!("Response body was: {body}");
210                Err(Error::ResponseParse(e.to_string()))
211            }
212        }
213    }
214
215    /// Returns all Nautilus instrument definitions for the given `exchange`, and filter params.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if fetching instrument info or parsing into domain types fails.
220    ///
221    /// See <https://docs.tardis.dev/api/instruments-metadata-api>.
222    #[expect(clippy::too_many_arguments)]
223    pub async fn instruments(
224        &self,
225        exchange: TardisExchange,
226        symbol: Option<&str>,
227        filter: Option<&InstrumentFilter>,
228        start: Option<UnixNanos>,
229        end: Option<UnixNanos>,
230        available_offset: Option<DurationNanos>,
231        effective: Option<UnixNanos>,
232        ts_init: Option<UnixNanos>,
233    ) -> Result<Vec<InstrumentAny>> {
234        let response = self.instruments_info(exchange, symbol, filter).await?;
235
236        Ok(response
237            .into_iter()
238            .filter(|info| is_available(info, start, end, available_offset, effective))
239            .flat_map(|info| {
240                parse_instrument_any(&info, effective, ts_init, self.normalize_symbols)
241            })
242            .collect())
243    }
244
245    /// Fetches instruments for the given exchanges, builds the mini-info map
246    /// for WS message parsing, and parses Nautilus instrument definitions.
247    ///
248    /// Returns a tuple of `(instrument_map, nautilus_instruments)`. The caller
249    /// decides how to use each half: `data.rs` emits instruments via the data
250    /// sender; `replay.rs` only needs the map.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if fetching instrument info for any exchange fails.
255    pub async fn bootstrap_instruments(
256        &self,
257        exchanges: &AHashSet<TardisExchange>,
258    ) -> Result<(
259        AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
260        Vec<InstrumentAny>,
261    )> {
262        let mut instrument_map: AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>> =
263            AHashMap::new();
264        let mut nautilus_instruments: Vec<InstrumentAny> = Vec::new();
265
266        for exchange in exchanges {
267            log::debug!("Fetching instruments for {exchange}");
268
269            let instruments_info = match self.instruments_info(*exchange, None, None).await {
270                Ok(info) => info,
271                Err(e) => {
272                    log::error!("Failed to fetch instruments for {exchange}: {e}");
273                    continue;
274                }
275            };
276
277            log::debug!(
278                "Received {} instruments for {exchange}",
279                instruments_info.len()
280            );
281
282            for inst in &instruments_info {
283                let instrument_type = inst.instrument_type;
284                let price_precision = precision_from_str(&inst.price_increment.to_string());
285                let size_precision = precision_from_str(&inst.amount_increment.to_string());
286
287                let instrument_id = if self.normalize_symbols {
288                    normalize_instrument_id(exchange, inst.id, &instrument_type, inst.inverse)
289                } else {
290                    parse_instrument_id(exchange, inst.id)
291                };
292
293                let info = TardisInstrumentMiniInfo::new(
294                    instrument_id,
295                    Some(inst.id),
296                    *exchange,
297                    price_precision,
298                    size_precision,
299                );
300                let key = info.as_tardis_instrument_key();
301                instrument_map.insert(key, Arc::new(info));
302            }
303
304            for inst in instruments_info {
305                nautilus_instruments.extend(parse_instrument_any(
306                    &inst,
307                    None,
308                    None,
309                    self.normalize_symbols,
310                ));
311            }
312        }
313
314        Ok((instrument_map, nautilus_instruments))
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use nautilus_testkit::http::assert_http_redirect_rejected;
321    use rstest::rstest;
322
323    use super::*;
324
325    #[rstest]
326    #[case("https://api.example.com/v1", "https://api.example.com")]
327    #[case(
328        "https://SYN-user:SYN-password@api.example.com:8443/SYN-path?token=SYN-query#SYN-fragment",
329        "https://api.example.com:8443"
330    )]
331    fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
332        let client =
333            TardisHttpClient::new(Some("SYN-api-key"), Some(url), Some(1), true, None).unwrap();
334        let debug = format!("{client:?}");
335        assert!(debug.contains(&format!("base_url: {origin:?}")));
336        assert!(!debug.contains("SYN-"));
337        assert_eq!(client.base_url.expose(), url);
338    }
339
340    #[tokio::test]
341    async fn test_authenticated_client_rejects_redirects() {
342        let client = TardisHttpClient::new(Some("test-key"), None, Some(3), false, None)
343            .unwrap()
344            .client;
345        assert_http_redirect_rejected(|url| async move {
346            client
347                .get(url, None, None, Some(3), None)
348                .await
349                .unwrap()
350                .status
351                .as_u16()
352        })
353        .await;
354    }
355}