1use std::{borrow::Cow, collections::HashMap, fmt::Debug, io::Read, sync::Arc};
17
18use ahash::{AHashMap, AHashSet};
19use flate2::read::GzDecoder;
20use nautilus_core::{
21 DurationNanos, UnixNanos,
22 string::{parsing::precision_from_str, secret::REDACTED, urlencoding},
23};
24use nautilus_model::instruments::InstrumentAny;
25use nautilus_network::{
26 ConnectionUrl,
27 http::{HttpClient, HttpRedirectPolicy, HttpResponse, create_standard_nautilus_headers},
28 url_origin,
29};
30
31use super::{
32 error::{Error, TardisErrorResponse},
33 instruments::is_available,
34 models::TardisInstrumentInfo,
35 parse::parse_instrument_any,
36 query::InstrumentFilter,
37};
38use crate::{
39 common::{
40 consts::{TARDIS_REST_QUOTA, TARDIS_REST_RATE_KEY},
41 credential::Credential,
42 enums::TardisExchange,
43 parse::{normalize_instrument_id, parse_instrument_id},
44 urls::TARDIS_HTTP_BASE_URL,
45 },
46 machine::types::{TardisInstrumentKey, TardisInstrumentMiniInfo},
47};
48
49pub type Result<T> = std::result::Result<T, Error>;
50
51const CONTENT_ENCODING: &str = "content-encoding";
52const GZIP: &str = "gzip";
53
54const MAX_DECOMPRESSED_BYTES: u64 = 1024 * 1024 * 1024;
56
57#[cfg_attr(
60 feature = "python",
61 pyo3::pyclass(module = "nautilus_trader.adapters.tardis", from_py_object)
62)]
63#[cfg_attr(
64 feature = "python",
65 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.tardis")
66)]
67#[derive(Clone)]
68pub struct TardisHttpClient {
69 base_url: ConnectionUrl,
70 credential: Option<Credential>,
71 client: HttpClient,
72 normalize_symbols: bool,
73}
74
75impl Debug for TardisHttpClient {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.debug_struct(stringify!(TardisHttpClient))
78 .field("base_url", &self.base_url)
79 .field("credential", &self.credential.as_ref().map(|_| REDACTED))
80 .field("normalize_symbols", &self.normalize_symbols)
81 .finish()
82 }
83}
84
85impl TardisHttpClient {
86 pub fn new(
93 api_key: Option<&str>,
94 base_url: Option<&str>,
95 timeout_secs: Option<u64>,
96 normalize_symbols: bool,
97 proxy_url: Option<String>,
98 ) -> anyhow::Result<Self> {
99 let credential = Credential::resolve(api_key.map(ToString::to_string));
100
101 if credential.is_none() {
102 anyhow::bail!(
103 "API key must be provided or set in the 'TARDIS_API_KEY' environment variable"
104 );
105 }
106
107 let base_url =
108 base_url.map_or_else(|| TARDIS_HTTP_BASE_URL.to_string(), ToString::to_string);
109
110 let mut headers: HashMap<String, String> =
111 create_standard_nautilus_headers().into_iter().collect();
112
113 if let Some(ref cred) = credential {
114 headers.insert(
115 "Authorization".to_string(),
116 format!("Bearer {}", cred.api_key()),
117 );
118 }
119
120 headers.insert("Accept-Encoding".to_string(), GZIP.to_string());
122
123 let keyed_quotas = vec![(TARDIS_REST_RATE_KEY.to_string(), *TARDIS_REST_QUOTA)];
124 let client = HttpClient::builder()
125 .redirect_policy(HttpRedirectPolicy::Reject)
126 .headers(headers)
127 .keyed_quotas(keyed_quotas)
128 .default_quota(*TARDIS_REST_QUOTA)
129 .maybe_timeout_secs(timeout_secs.or(Some(60)))
130 .maybe_proxy_url(proxy_url)
131 .header_keys(vec![CONTENT_ENCODING.to_string()])
132 .build()?;
133
134 Ok(Self {
135 base_url: base_url.into(),
136 credential,
137 client,
138 normalize_symbols,
139 })
140 }
141
142 #[must_use]
144 pub const fn credential(&self) -> Option<&Credential> {
145 self.credential.as_ref()
146 }
147
148 pub async fn instruments_info(
156 &self,
157 exchange: TardisExchange,
158 symbol: Option<&str>,
159 filter: Option<&InstrumentFilter>,
160 ) -> Result<Vec<TardisInstrumentInfo>> {
161 let mut url = format!("{}/instruments/{exchange}", self.base_url.expose());
162 if let Some(symbol) = symbol {
163 url.push_str(&format!("/{symbol}"));
164 }
165
166 if let Some(filter) = filter
167 && let Ok(filter_json) = serde_json::to_string(filter)
168 {
169 url.push_str(&format!("?filter={}", urlencoding::encode(&filter_json)));
170 }
171
172 log::debug!("Requesting: {}", url_origin(&url));
173
174 let rate_keys = Some(vec![TARDIS_REST_RATE_KEY.to_string()]);
175
176 let response = self
177 .client
178 .get(url, None, None, None, rate_keys)
179 .await
180 .map_err(|e| {
181 Error::Request(format!("Request to {} failed: {e}", self.base_url.origin()))
182 })?;
183
184 let status = response.status.as_u16();
185 log::debug!("Response status: {status}");
186
187 let body = decode_body(&response)?;
188
189 if !response.status.is_success() {
190 let body = String::from_utf8_lossy(&body).to_string();
191 return if let Ok(error) = serde_json::from_str::<TardisErrorResponse>(&body) {
192 Err(Error::ApiError {
193 status,
194 code: error.code,
195 message: error.message,
196 })
197 } else {
198 Err(Error::ApiError {
199 status,
200 code: 0,
201 message: body,
202 })
203 };
204 }
205
206 let body = String::from_utf8_lossy(&body);
207 log::trace!("{body}");
208
209 if let Ok(instrument) = serde_json::from_str::<TardisInstrumentInfo>(&body) {
210 return Ok(vec![instrument]);
211 }
212
213 match serde_json::from_str(&body) {
214 Ok(parsed) => Ok(parsed),
215 Err(e) => {
216 log::debug!("Failed to parse response: {e}");
217 log::debug!("Response body was: {body}");
218 Err(Error::ResponseParse(e.to_string()))
219 }
220 }
221 }
222
223 #[expect(clippy::too_many_arguments)]
231 pub async fn instruments(
232 &self,
233 exchange: TardisExchange,
234 symbol: Option<&str>,
235 filter: Option<&InstrumentFilter>,
236 start: Option<UnixNanos>,
237 end: Option<UnixNanos>,
238 available_offset: Option<DurationNanos>,
239 effective: Option<UnixNanos>,
240 ts_init: Option<UnixNanos>,
241 ) -> Result<Vec<InstrumentAny>> {
242 let response = self.instruments_info(exchange, symbol, filter).await?;
243
244 Ok(response
245 .into_iter()
246 .filter(|info| is_available(info, start, end, available_offset, effective))
247 .flat_map(|info| {
248 parse_instrument_any(&info, effective, ts_init, self.normalize_symbols)
249 })
250 .collect())
251 }
252
253 pub async fn bootstrap_instruments(
264 &self,
265 exchanges: &AHashSet<TardisExchange>,
266 ) -> Result<(
267 AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>>,
268 Vec<InstrumentAny>,
269 )> {
270 let mut instrument_map: AHashMap<TardisInstrumentKey, Arc<TardisInstrumentMiniInfo>> =
271 AHashMap::new();
272 let mut nautilus_instruments: Vec<InstrumentAny> = Vec::new();
273
274 for exchange in exchanges {
275 log::debug!("Fetching instruments for {exchange}");
276
277 let instruments_info = match self.instruments_info(*exchange, None, None).await {
278 Ok(info) => info,
279 Err(e) => {
280 log::error!("Failed to fetch instruments for {exchange}: {e}");
281 continue;
282 }
283 };
284
285 log::debug!(
286 "Received {} instruments for {exchange}",
287 instruments_info.len()
288 );
289
290 for inst in &instruments_info {
291 let instrument_type = inst.instrument_type;
292 let price_precision = precision_from_str(&inst.price_increment.to_string());
293 let size_precision = precision_from_str(&inst.amount_increment.to_string());
294
295 let instrument_id = if self.normalize_symbols {
296 normalize_instrument_id(exchange, inst.id, &instrument_type, inst.inverse)
297 } else {
298 parse_instrument_id(exchange, inst.id)
299 };
300
301 let info = TardisInstrumentMiniInfo::new(
302 instrument_id,
303 Some(inst.id),
304 *exchange,
305 price_precision,
306 size_precision,
307 );
308 let key = info.as_tardis_instrument_key();
309 instrument_map.insert(key, Arc::new(info));
310 }
311
312 for inst in instruments_info {
313 nautilus_instruments.extend(parse_instrument_any(
314 &inst,
315 None,
316 None,
317 self.normalize_symbols,
318 ));
319 }
320 }
321
322 Ok((instrument_map, nautilus_instruments))
323 }
324}
325
326fn decode_body(response: &HttpResponse) -> Result<Cow<'_, [u8]>> {
327 match response.headers.get(CONTENT_ENCODING) {
328 None => Ok(Cow::Borrowed(response.body.as_ref())),
329 Some(encoding) if encoding.eq_ignore_ascii_case(GZIP) => {
330 decompress_gzip(&response.body, MAX_DECOMPRESSED_BYTES).map(Cow::Owned)
331 }
332 Some(encoding) => Err(Error::Request(format!(
333 "unsupported response content encoding '{encoding}'"
334 ))),
335 }
336}
337
338fn decompress_gzip(body: &[u8], max_bytes: u64) -> Result<Vec<u8>> {
339 let mut reader = GzDecoder::new(body).take(max_bytes + 1);
340 let mut decompressed = Vec::new();
341 reader
342 .read_to_end(&mut decompressed)
343 .map_err(|e| Error::Request(format!("failed to decompress response body: {e}")))?;
344
345 if reader.limit() == 0 {
346 return Err(Error::Request(format!(
347 "decompressed response body exceeds maximum of {max_bytes} bytes"
348 )));
349 }
350
351 Ok(decompressed)
352}
353
354#[cfg(test)]
355mod tests {
356 use std::io::Write;
357
358 use flate2::{Compression, write::GzEncoder};
359 use nautilus_network::http::{HttpStatus, StatusCode};
360 use nautilus_testkit::http::assert_http_redirect_rejected;
361 use rstest::rstest;
362
363 use super::*;
364
365 fn gzip(data: &[u8]) -> Vec<u8> {
366 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
367 encoder.write_all(data).unwrap();
368 encoder.finish().unwrap()
369 }
370
371 fn http_response(encoding: Option<&str>, body: Vec<u8>) -> HttpResponse {
372 HttpResponse {
373 status: HttpStatus::new(StatusCode::OK),
374 headers: encoding
375 .map(|encoding| (CONTENT_ENCODING.to_string(), encoding.to_string()))
376 .into_iter()
377 .collect(),
378 body: body.into(),
379 }
380 }
381
382 #[rstest]
383 #[case("https://api.example.com/v1", "https://api.example.com")]
384 #[case(
385 "https://SYN-user:SYN-password@api.example.com:8443/SYN-path?token=SYN-query#SYN-fragment",
386 "https://api.example.com:8443"
387 )]
388 fn test_debug_preserves_url_origin(#[case] url: &str, #[case] origin: &str) {
389 let client =
390 TardisHttpClient::new(Some("SYN-api-key"), Some(url), Some(1), true, None).unwrap();
391 let debug = format!("{client:?}");
392 assert!(debug.contains(&format!("base_url: {origin:?}")));
393 assert!(!debug.contains("SYN-"));
394 assert_eq!(client.base_url.expose(), url);
395 }
396
397 #[tokio::test]
398 async fn test_authenticated_client_rejects_redirects() {
399 let client = TardisHttpClient::new(Some("test-key"), None, Some(3), false, None)
400 .unwrap()
401 .client;
402 assert_http_redirect_rejected(|url| async move {
403 client
404 .get(url, None, None, Some(3), None)
405 .await
406 .unwrap()
407 .status
408 .as_u16()
409 })
410 .await;
411 }
412
413 #[rstest]
414 #[case::identity(None, b"[7]".to_vec())]
415 #[case::gzip(Some("gzip"), gzip(b"[7]"))]
416 #[case::gzip_uppercase(Some("GZIP"), gzip(b"[7]"))]
417 fn test_decode_body(#[case] encoding: Option<&str>, #[case] body: Vec<u8>) {
418 let response = http_response(encoding, body);
419
420 let decoded = decode_body(&response).unwrap();
421
422 assert_eq!(decoded.as_ref(), b"[7]");
423 }
424
425 #[rstest]
426 #[case::unsupported(Some("br"), b"[7]".to_vec(), "unsupported response content encoding 'br'")]
427 #[case::corrupt(
428 Some("gzip"),
429 b"[7]".to_vec(),
430 "failed to decompress response body: unexpected end of file"
431 )]
432 fn test_decode_body_rejects(
433 #[case] encoding: Option<&str>,
434 #[case] body: Vec<u8>,
435 #[case] expected: &str,
436 ) {
437 let response = http_response(encoding, body);
438
439 let error = decode_body(&response).unwrap_err();
440
441 let Error::Request(message) = error else {
442 panic!("expected request error, was {error:?}");
443 };
444
445 assert_eq!(message, expected);
446 }
447
448 #[rstest]
449 #[case::at_max(10)]
450 #[case::below_max(11)]
451 fn test_decompress_gzip_at_or_below_max_bytes(#[case] max_bytes: u64) {
452 let decompressed = decompress_gzip(&gzip(b"0123456789"), max_bytes).unwrap();
453
454 assert_eq!(decompressed, b"0123456789");
455 }
456
457 #[rstest]
458 fn test_decompress_gzip_above_max_bytes() {
459 let error = decompress_gzip(&gzip(b"0123456789"), 9).unwrap_err();
460
461 let Error::Request(message) = error else {
462 panic!("expected request error, was {error:?}");
463 };
464
465 assert_eq!(
466 message,
467 "decompressed response body exceeds maximum of 9 bytes"
468 );
469 }
470}