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