1use 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
50const MAX_DECOMPRESSED_BYTES: usize = 1024 * 1024 * 1024;
52
53#[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 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 #[must_use]
137 pub const fn credential(&self) -> Option<&Credential> {
138 self.credential.as_ref()
139 }
140
141 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 #[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 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}