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::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#[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 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 #[must_use]
129 pub const fn credential(&self) -> Option<&Credential> {
130 self.credential.as_ref()
131 }
132
133 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 #[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 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}