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    UnixNanos,
21    consts::NAUTILUS_USER_AGENT,
22    string::{parsing::precision_from_str, secret::REDACTED, urlencoding},
23};
24use nautilus_model::instruments::InstrumentAny;
25use nautilus_network::http::{HttpClient, USER_AGENT};
26
27use super::{
28    error::{Error, TardisErrorResponse},
29    instruments::is_available,
30    models::TardisInstrumentInfo,
31    parse::parse_instrument_any,
32    query::InstrumentFilter,
33};
34use crate::{
35    common::{
36        consts::{TARDIS_REST_QUOTA, TARDIS_REST_RATE_KEY},
37        credential::Credential,
38        enums::TardisExchange,
39        parse::{normalize_instrument_id, parse_instrument_id},
40        urls::TARDIS_HTTP_BASE_URL,
41    },
42    machine::types::{TardisInstrumentKey, TardisInstrumentMiniInfo},
43};
44
45pub type Result<T> = std::result::Result<T, Error>;
46
47/// A Tardis HTTP API client.
48/// See <https://docs.tardis.dev/api/http>.
49#[cfg_attr(
50    feature = "python",
51    pyo3::pyclass(module = "nautilus_trader.adapters.tardis", from_py_object)
52)]
53#[cfg_attr(
54    feature = "python",
55    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.tardis")
56)]
57#[derive(Clone)]
58pub struct TardisHttpClient {
59    base_url: String,
60    credential: Option<Credential>,
61    client: HttpClient,
62    normalize_symbols: bool,
63}
64
65impl Debug for TardisHttpClient {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct(stringify!(TardisHttpClient))
68            .field("base_url", &self.base_url)
69            .field("credential", &self.credential.as_ref().map(|_| REDACTED))
70            .field("normalize_symbols", &self.normalize_symbols)
71            .finish()
72    }
73}
74
75impl TardisHttpClient {
76    /// Creates a new [`TardisHttpClient`] instance.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if no API key is provided (argument or `TARDIS_API_KEY` env var),
81    /// or if the HTTP client cannot be built.
82    pub fn new(
83        api_key: Option<&str>,
84        base_url: Option<&str>,
85        timeout_secs: Option<u64>,
86        normalize_symbols: bool,
87        proxy_url: Option<String>,
88    ) -> anyhow::Result<Self> {
89        let credential = Credential::resolve(api_key.map(ToString::to_string));
90
91        if credential.is_none() {
92            anyhow::bail!(
93                "API key must be provided or set in the 'TARDIS_API_KEY' environment variable"
94            );
95        }
96
97        let base_url =
98            base_url.map_or_else(|| TARDIS_HTTP_BASE_URL.to_string(), ToString::to_string);
99
100        let mut headers = HashMap::new();
101        headers.insert(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string());
102
103        if let Some(ref cred) = credential {
104            headers.insert(
105                "Authorization".to_string(),
106                format!("Bearer {}", cred.api_key()),
107            );
108        }
109
110        let keyed_quotas = vec![(TARDIS_REST_RATE_KEY.to_string(), *TARDIS_REST_QUOTA)];
111        let client = HttpClient::builder()
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<UnixNanos>,
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}