nautilus_tardis/http/
client.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 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::{env, time::Duration};
17
18use nautilus_core::{UnixNanos, consts::NAUTILUS_USER_AGENT};
19use nautilus_model::instruments::InstrumentAny;
20use reqwest::Response;
21
22use super::{
23    TARDIS_BASE_URL,
24    error::{Error, TardisErrorResponse},
25    instruments::is_available,
26    models::TardisInstrumentInfo,
27    parse::parse_instrument_any,
28    query::InstrumentFilter,
29};
30use crate::enums::TardisExchange;
31
32pub type Result<T> = std::result::Result<T, Error>;
33
34/// A Tardis HTTP API client.
35/// See <https://docs.tardis.dev/api/http>.
36#[cfg_attr(
37    feature = "python",
38    pyo3::pyclass(module = "nautilus_trader.core.nautilus_pyo3.adapters")
39)]
40#[derive(Debug, Clone)]
41pub struct TardisHttpClient {
42    base_url: String,
43    api_key: String,
44    client: reqwest::Client,
45    normalize_symbols: bool,
46}
47
48impl TardisHttpClient {
49    /// Creates a new [`TardisHttpClient`] instance.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if no API key is provided (argument or `TARDIS_API_KEY` env var),
54    /// or if the HTTP client cannot be built.
55    pub fn new(
56        api_key: Option<&str>,
57        base_url: Option<&str>,
58        timeout_secs: Option<u64>,
59        normalize_symbols: bool,
60    ) -> anyhow::Result<Self> {
61        let api_key = match api_key {
62            Some(key) => key.to_string(),
63            None => env::var("TARDIS_API_KEY").map_err(|_| {
64                anyhow::anyhow!(
65                    "API key must be provided or set in the 'TARDIS_API_KEY' environment variable"
66                )
67            })?,
68        };
69
70        let base_url = base_url.map_or_else(|| TARDIS_BASE_URL.to_string(), ToString::to_string);
71        let timeout = timeout_secs.map_or_else(|| Duration::from_secs(60), Duration::from_secs);
72
73        let client = reqwest::Client::builder()
74            .user_agent(NAUTILUS_USER_AGENT)
75            .timeout(timeout)
76            .build()?;
77
78        Ok(Self {
79            base_url,
80            api_key,
81            client,
82            normalize_symbols,
83        })
84    }
85
86    async fn handle_error_response<T>(resp: Response) -> Result<T> {
87        let status = resp.status().as_u16();
88        let error_text = match resp.text().await {
89            Ok(text) => text,
90            Err(e) => {
91                tracing::warn!("Failed to extract error response body: {e}");
92                String::from("Failed to extract error response")
93            }
94        };
95
96        if let Ok(error) = serde_json::from_str::<TardisErrorResponse>(&error_text) {
97            Err(Error::ApiError {
98                status,
99                code: error.code,
100                message: error.message,
101            })
102        } else {
103            Err(Error::ApiError {
104                status,
105                code: 0,
106                message: error_text,
107            })
108        }
109    }
110
111    /// Returns all Tardis instrument definitions for the given `exchange`.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the HTTP request fails or the response cannot be parsed.
116    ///
117    /// See <https://docs.tardis.dev/api/instruments-metadata-api>.
118    pub async fn instruments_info(
119        &self,
120        exchange: TardisExchange,
121        symbol: Option<&str>,
122        filter: Option<&InstrumentFilter>,
123    ) -> Result<Vec<TardisInstrumentInfo>> {
124        let mut url = format!("{}/instruments/{exchange}", &self.base_url);
125        if let Some(symbol) = symbol {
126            url.push_str(&format!("/{symbol}"));
127        }
128        if let Some(filter) = filter
129            && let Ok(filter_json) = serde_json::to_string(filter)
130        {
131            url.push_str(&format!("?filter={}", urlencoding::encode(&filter_json)));
132        }
133        tracing::debug!("Requesting: {url}");
134
135        let resp = self
136            .client
137            .get(url)
138            .bearer_auth(&self.api_key)
139            .send()
140            .await?;
141        tracing::debug!("Response status: {}", resp.status());
142
143        if !resp.status().is_success() {
144            return Self::handle_error_response(resp).await;
145        }
146
147        let body = resp.text().await?;
148        tracing::trace!("{body}");
149
150        if let Ok(instrument) = serde_json::from_str::<TardisInstrumentInfo>(&body) {
151            return Ok(vec![instrument]);
152        }
153
154        match serde_json::from_str(&body) {
155            Ok(parsed) => Ok(parsed),
156            Err(e) => {
157                tracing::error!("Failed to parse response: {e}");
158                tracing::debug!("Response body was: {body}");
159                Err(Error::ResponseParse(e.to_string()))
160            }
161        }
162    }
163
164    /// Returns all Nautilus instrument definitions for the given `exchange`, and filter params.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if fetching instrument info or parsing into domain types fails.
169    ///
170    /// See <https://docs.tardis.dev/api/instruments-metadata-api>.
171    #[allow(clippy::too_many_arguments)]
172    pub async fn instruments(
173        &self,
174        exchange: TardisExchange,
175        symbol: Option<&str>,
176        filter: Option<&InstrumentFilter>,
177        start: Option<UnixNanos>,
178        end: Option<UnixNanos>,
179        available_offset: Option<UnixNanos>,
180        effective: Option<UnixNanos>,
181        ts_init: Option<UnixNanos>,
182    ) -> Result<Vec<InstrumentAny>> {
183        let response = self.instruments_info(exchange, symbol, filter).await?;
184
185        Ok(response
186            .into_iter()
187            .filter(|info| is_available(info, start, end, available_offset, effective))
188            .flat_map(|info| parse_instrument_any(info, effective, ts_init, self.normalize_symbols))
189            .collect())
190    }
191}