1use std::{
4 cmp::Ordering,
5 collections::HashMap,
6 fmt::{self, Write},
7 num::NonZeroU64,
8 path::{Path, PathBuf},
9 str::FromStr,
10};
11
12use dbn::{Compression, Encoding, SType, Schema};
13use futures::StreamExt;
14use hex::ToHex;
15use reqwest::RequestBuilder;
16use serde::{de, Deserialize, Deserializer};
17use sha2::{Digest, Sha256};
18use time::OffsetDateTime;
19use tokio::{
20 fs::File,
21 io::{AsyncReadExt, BufWriter},
22};
23use tracing::{debug, error, info, info_span, instrument, warn, Instrument};
24
25use crate::{
26 deserialize::{deserialize_date_time, deserialize_opt_date_time},
27 historical::{check_http_error, AddToForm, Limit, ReqwestForm},
28 Error, Symbols,
29};
30
31use super::{handle_response, DateTimeRange};
32
33#[derive(Debug)]
35pub struct BatchClient<'a> {
36 pub(crate) inner: &'a mut super::Client,
37}
38
39struct SplitSize(Option<NonZeroU64>);
40impl AddToForm<SplitSize> for ReqwestForm {
41 fn add_to_form(mut self, SplitSize(split_size): &SplitSize) -> Self {
42 if let Some(split_size) = split_size {
43 self.push(("split_size", split_size.to_string()));
44 }
45 self
46 }
47}
48
49impl AddToForm<SplitDuration> for ReqwestForm {
50 fn add_to_form(mut self, split_duration: &SplitDuration) -> Self {
51 self.push(("split_duration", split_duration.to_string()));
52 self
53 }
54}
55
56impl BatchClient<'_> {
57 #[instrument(name = "batch.submit_job")]
67 pub async fn submit_job(&mut self, params: &SubmitJobParams) -> crate::Result<BatchJob> {
68 let form = vec![
69 ("dataset", params.dataset.to_string()),
70 ("schema", params.schema.to_string()),
71 ("encoding", params.encoding.to_string()),
72 ("compression", params.compression.to_string()),
73 ("pretty_px", params.pretty_px.to_string()),
74 ("pretty_ts", params.pretty_ts.to_string()),
75 (
76 "map_symbols",
77 params
78 .map_symbols
79 .unwrap_or(params.encoding != Encoding::Dbn)
80 .to_string(),
81 ),
82 ("split_symbols", params.split_symbols.to_string()),
83 ("delivery", params.delivery.to_string()),
84 ("stype_in", params.stype_in.to_string()),
85 ("stype_out", params.stype_out.to_string()),
86 ("symbols", params.symbols.to_api_string()),
87 ]
88 .add_to_form(¶ms.date_time_range)
89 .add_to_form(&Limit(params.limit))
90 .add_to_form(&SplitSize(params.split_size))
91 .add_to_form(¶ms.split_duration);
92 let builder = self.post("submit_job")?.form(&form);
93 let resp = builder.send().await?;
94 handle_response(resp).await
95 }
96
97 #[instrument(name = "batch.list_jobs")]
103 pub async fn list_jobs(&mut self, params: &ListJobsParams) -> crate::Result<Vec<BatchJob>> {
104 let mut builder = self.get("list_jobs")?;
105 if let Some(ref states) = params.states {
106 let states_str = states.iter().fold(String::new(), |mut acc, s| {
107 if acc.is_empty() {
108 s.as_str().to_owned()
109 } else {
110 write!(acc, ",{}", s.as_str()).unwrap();
111 acc
112 }
113 });
114 builder = builder.query(&[("states", states_str)]);
115 }
116 if let Some(ref since) = params.since {
117 builder = builder.query(&[("since", &since.unix_timestamp_nanos().to_string())]);
118 }
119 let resp = builder.send().await?;
120 handle_response(resp).await
121 }
122
123 #[instrument(name = "batch.get_job_details")]
129 pub async fn get_job_details(&mut self, job_id: &str) -> crate::Result<BatchJob> {
130 let resp = self
131 .get("get_job_details")?
132 .query(&[("job_id", job_id)])
133 .send()
134 .await?;
135 handle_response(resp).await
136 }
137
138 #[instrument(name = "batch.list_files")]
144 pub async fn list_files(&mut self, job_id: &str) -> crate::Result<Vec<BatchFileDesc>> {
145 let resp = self
146 .get("list_files")?
147 .query(&[("job_id", job_id)])
148 .send()
149 .await?;
150 handle_response(resp).await
151 }
152
153 #[instrument(name = "batch.download")]
160 pub async fn download(&mut self, params: &DownloadParams) -> crate::Result<Vec<PathBuf>> {
161 let job_dir = params.output_dir.join(¶ms.job_id);
162 if job_dir.exists() {
163 if !job_dir.is_dir() {
164 return Err(Error::bad_arg(
165 "output_dir",
166 "exists but is not a directory",
167 ));
168 }
169 } else {
170 tokio::fs::create_dir_all(&job_dir).await?;
171 }
172 let job_files = self.list_files(¶ms.job_id).await?;
173 if let Some(filename_to_download) = params.filename_to_download.as_ref() {
174 let Some(file_desc) = job_files
175 .iter()
176 .find(|file| file.filename == *filename_to_download)
177 else {
178 return Err(Error::bad_arg(
179 "filename_to_download",
180 "not found for batch job",
181 ));
182 };
183 let output_path = job_dir.join(filename_to_download);
184 let https_url = file_desc
185 .urls
186 .get("https")
187 .ok_or_else(|| Error::internal("Missing https URL for batch file"))?;
188 self.download_file(https_url, &output_path, &file_desc.hash, file_desc.size)
189 .await?;
190 Ok(vec![output_path])
191 } else {
192 let mut paths = Vec::with_capacity(job_files.len());
193 for file_desc in job_files.iter() {
194 let output_path = params
195 .output_dir
196 .join(¶ms.job_id)
197 .join(&file_desc.filename);
198 let https_url = file_desc
199 .urls
200 .get("https")
201 .ok_or_else(|| Error::internal("Missing https URL for batch file"))?;
202 self.download_file(https_url, &output_path, &file_desc.hash, file_desc.size)
203 .await?;
204 paths.push(output_path);
205 }
206 Ok(paths)
207 }
208 }
209
210 #[instrument(name = "batch.download_file")]
211 async fn download_file(
212 &mut self,
213 url: &str,
214 path: &Path,
215 hash: &str,
216 exp_size: u64,
217 ) -> crate::Result<()> {
218 const MAX_RETRIES: usize = 5;
219 let url = reqwest::Url::parse(url)
220 .map_err(|e| Error::internal(format!("Unable to parse URL: {e:?}")))?;
221
222 let Some((hash_algo, exp_hash_hex)) = hash.split_once(':') else {
223 return Err(Error::internal("Unexpected hash string format {hash:?}"));
224 };
225 let mut hasher = if hash_algo == "sha256" {
226 Some(Sha256::new())
227 } else {
228 warn!(
229 hash_algo,
230 "Skipping checksum with unsupported hash algorithm"
231 );
232 None
233 };
234
235 let span = info_span!("BatchDownload", %url, path=%path.display());
236 async move {
237 let mut retries = 0;
238 'retry: loop {
239 let mut req = self.inner.get_with_path(url.path())?;
240 match Self::check_if_exists(path, exp_size, &mut hasher).await? {
241 Header::Skip => {
242 return Ok(());
243 }
244 Header::Range(Some((key, val))) => {
245 req = req.header(key, val);
246 }
247 Header::Range(None) => {}
248 }
249 let resp = req.send().await?;
250 let mut stream = check_http_error(resp).await?.bytes_stream();
251 info!("Downloading file");
252 let mut output = BufWriter::new(
253 tokio::fs::OpenOptions::new()
254 .create(true)
255 .append(true)
256 .write(true)
257 .open(path)
258 .await?,
259 );
260 while let Some(chunk) = stream.next().await {
261 let chunk = match chunk {
262 Ok(chunk) => chunk,
263 Err(err) if retries < MAX_RETRIES => {
264 retries += 1;
265 error!(?err, retries, "Retrying download");
266 continue 'retry;
267 }
268 Err(err) => {
269 return Err(crate::Error::from(err));
270 }
271 };
272 if retries > 0 {
273 retries = 0;
274 info!("Resumed download");
275 }
276 if let Some(hasher) = hasher.as_mut() {
277 hasher.update(&chunk)
278 }
279 tokio::io::copy(&mut chunk.as_ref(), &mut output).await?;
280 }
281 debug!("Completed download");
282 Self::verify_hash(hasher, exp_hash_hex).await;
283 return Ok(());
284 }
285 }
286 .instrument(span)
287 .await
288 }
289
290 async fn check_if_exists(
291 path: &Path,
292 exp_size: u64,
293 hasher: &mut Option<Sha256>,
294 ) -> crate::Result<Header> {
295 let Ok(metadata) = tokio::fs::metadata(path).await else {
296 return Ok(Header::Range(None));
297 };
298 let actual_size = metadata.len();
299 match actual_size.cmp(&exp_size) {
300 Ordering::Less => {
301 debug!(
302 prev_downloaded_bytes = actual_size,
303 total_bytes = exp_size,
304 "Found existing file, resuming download"
305 );
306 if let Some(hasher) = hasher {
307 let mut buf = vec![0; 1 << 23];
308 let mut file = File::open(path).await?;
309 loop {
310 let read_size = file.read(&mut buf).await?;
311 if read_size == 0 {
312 break;
313 }
314 hasher.update(&buf[..read_size]);
315 }
316 }
317 }
318 Ordering::Equal => {
319 debug!("Skipping download as file already exists and matches expected size");
320 return Ok(Header::Skip);
321 }
322 Ordering::Greater => {
323 return Err(crate::Error::Io(std::io::Error::other(format!(
324 "Batch file {} already exists with size {actual_size} which is larger than expected size {exp_size}",
325 path.file_name().unwrap().display(),
326 ))));
327 }
328 }
329 Ok(Header::Range(Some((
330 "Range",
331 format!("bytes={}-", metadata.len()),
332 ))))
333 }
334
335 async fn verify_hash(hasher: Option<Sha256>, exp_hash_hex: &str) {
336 let Some(hasher) = hasher else {
337 return;
338 };
339 let hash_hex = hasher.finalize().encode_hex::<String>();
340 if hash_hex != exp_hash_hex {
341 warn!(
342 hash_hex,
343 exp_hash_hex, "Downloaded file failed checksum verification"
344 );
345 } else {
346 debug!("Successfully verified checksum");
347 }
348 }
349
350 const PATH_PREFIX: &'static str = "batch";
351
352 fn get(&mut self, slug: &str) -> crate::Result<RequestBuilder> {
353 self.inner.get(&format!("{}.{slug}", Self::PATH_PREFIX))
354 }
355
356 fn post(&mut self, slug: &str) -> crate::Result<RequestBuilder> {
357 self.inner.post(&format!("{}.{slug}", Self::PATH_PREFIX))
358 }
359}
360
361#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
363pub enum SplitDuration {
364 #[default]
366 Day,
367 Week,
369 Month,
371 Year,
373 None,
375}
376
377#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
379pub enum Delivery {
380 #[default]
382 Download,
383}
384
385#[derive(Clone, Copy, Debug, PartialEq, Eq)]
387pub enum JobState {
388 Queued,
390 Processing,
392 Done,
394 Expired,
396}
397
398#[derive(Debug, Clone, bon::Builder, PartialEq, Eq)]
401pub struct SubmitJobParams {
402 #[builder(with = |d: impl ToString| d.to_string())]
404 pub dataset: String,
405 #[builder(into)]
407 pub symbols: Symbols,
408 pub schema: Schema,
410 #[builder(into)]
414 pub date_time_range: DateTimeRange,
415 #[builder(default = Encoding::Dbn)]
417 pub encoding: Encoding,
418 #[builder(default = Compression::Zstd)]
420 pub compression: Compression,
421 #[builder(default)]
424 pub pretty_px: bool,
425 #[builder(default)]
428 pub pretty_ts: bool,
429 pub map_symbols: Option<bool>,
433 #[builder(default)]
435 pub split_symbols: bool,
436 #[builder(default)]
442 pub split_duration: SplitDuration,
443 pub split_size: Option<NonZeroU64>,
446 #[builder(default)]
449 pub delivery: Delivery,
450 #[builder(default = SType::RawSymbol)]
453 pub stype_in: SType,
454 #[builder(default = SType::InstrumentId)]
460 pub stype_out: SType,
461 pub limit: Option<NonZeroU64>,
463}
464
465#[derive(Debug, Clone, Deserialize)]
467pub struct BatchJob {
468 pub id: String,
470 pub user_id: Option<String>,
472 pub cost_usd: Option<f64>,
474 pub dataset: String,
476 pub symbols: Symbols,
478 pub stype_in: SType,
480 pub stype_out: SType,
482 pub schema: Schema,
484 #[serde(deserialize_with = "deserialize_date_time")]
486 pub start: OffsetDateTime,
487 #[serde(deserialize_with = "deserialize_date_time")]
489 pub end: OffsetDateTime,
490 pub limit: Option<NonZeroU64>,
492 pub encoding: Encoding,
494 #[serde(deserialize_with = "deserialize_compression")]
496 pub compression: Compression,
497 pub pretty_px: bool,
499 pub pretty_ts: bool,
501 pub map_symbols: bool,
503 pub split_symbols: bool,
505 pub split_duration: SplitDuration,
508 pub split_size: Option<NonZeroU64>,
510 pub delivery: Delivery,
512 pub record_count: Option<u64>,
514 pub billed_size: Option<u64>,
516 pub actual_size: Option<u64>,
518 pub package_size: Option<u64>,
520 pub state: JobState,
522 #[serde(deserialize_with = "deserialize_date_time")]
524 pub ts_received: OffsetDateTime,
525 #[serde(deserialize_with = "deserialize_opt_date_time")]
527 pub ts_queued: Option<OffsetDateTime>,
528 #[serde(deserialize_with = "deserialize_opt_date_time")]
530 pub ts_process_start: Option<OffsetDateTime>,
531 #[serde(deserialize_with = "deserialize_opt_date_time")]
533 pub ts_process_done: Option<OffsetDateTime>,
534 #[serde(deserialize_with = "deserialize_opt_date_time")]
536 pub ts_expiration: Option<OffsetDateTime>,
537 #[serde(default)]
540 pub progress: Option<u8>,
541}
542
543#[derive(Debug, Clone, Default, bon::Builder, PartialEq, Eq)]
546pub struct ListJobsParams {
547 pub states: Option<Vec<JobState>>,
549 pub since: Option<OffsetDateTime>,
552}
553
554#[derive(Debug, Clone, Deserialize)]
556pub struct BatchFileDesc {
557 pub filename: String,
559 pub size: u64,
561 pub hash: String,
563 pub urls: HashMap<String, String>,
565}
566
567#[derive(Debug, Clone, bon::Builder, PartialEq, Eq)]
570pub struct DownloadParams {
571 #[builder(into)]
573 pub output_dir: PathBuf,
574 #[builder(with = |id: impl ToString| id.to_string())]
576 pub job_id: String,
577 #[builder(with = |f: impl ToString| f.to_string())]
579 pub filename_to_download: Option<String>,
580}
581
582impl SplitDuration {
583 pub const fn as_str(&self) -> &'static str {
585 match self {
586 SplitDuration::Day => "day",
587 SplitDuration::Week => "week",
588 SplitDuration::Month => "month",
589 SplitDuration::Year => "year",
590 SplitDuration::None => "none",
591 }
592 }
593}
594
595impl fmt::Display for SplitDuration {
596 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
597 f.write_str(self.as_str())
598 }
599}
600
601impl FromStr for SplitDuration {
602 type Err = crate::Error;
603
604 fn from_str(s: &str) -> Result<Self, Self::Err> {
605 match s {
606 "day" => Ok(SplitDuration::Day),
607 "week" => Ok(SplitDuration::Week),
608 "month" => Ok(SplitDuration::Month),
609 "year" => Ok(SplitDuration::Year),
610 "none" => Ok(SplitDuration::None),
611 _ => Err(crate::Error::bad_arg(
612 "s",
613 format!(
614 "{s} does not correspond with any {} variant",
615 std::any::type_name::<Self>()
616 ),
617 )),
618 }
619 }
620}
621
622impl<'de> Deserialize<'de> for SplitDuration {
623 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
624 let opt = Option::<String>::deserialize(deserializer)?;
625 match opt {
626 Some(str) => FromStr::from_str(&str).map_err(de::Error::custom),
627 None => Ok(SplitDuration::None),
629 }
630 }
631}
632
633impl Delivery {
634 pub const fn as_str(&self) -> &'static str {
636 match self {
637 Delivery::Download => "download",
638 }
639 }
640}
641
642impl fmt::Display for Delivery {
643 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
644 f.write_str(self.as_str())
645 }
646}
647
648impl FromStr for Delivery {
649 type Err = crate::Error;
650
651 fn from_str(s: &str) -> Result<Self, Self::Err> {
652 match s {
653 "download" => Ok(Delivery::Download),
654 _ => Err(crate::Error::bad_arg(
655 "s",
656 format!(
657 "{s} does not correspond with any {} variant",
658 std::any::type_name::<Self>()
659 ),
660 )),
661 }
662 }
663}
664
665impl<'de> Deserialize<'de> for Delivery {
666 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
667 let str = String::deserialize(deserializer)?;
668 FromStr::from_str(&str).map_err(de::Error::custom)
669 }
670}
671
672impl JobState {
673 pub const fn as_str(&self) -> &'static str {
675 match self {
676 JobState::Queued => "queued",
677 JobState::Processing => "processing",
678 JobState::Done => "done",
679 JobState::Expired => "expired",
680 }
681 }
682}
683
684impl fmt::Display for JobState {
685 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
686 f.write_str(self.as_str())
687 }
688}
689
690impl FromStr for JobState {
691 type Err = crate::Error;
692
693 fn from_str(s: &str) -> Result<Self, Self::Err> {
694 match s {
695 "queued" => Ok(JobState::Queued),
696 "processing" => Ok(JobState::Processing),
697 "done" => Ok(JobState::Done),
698 "expired" => Ok(JobState::Expired),
699 _ => Err(crate::Error::bad_arg(
700 "s",
701 format!(
702 "{s} does not correspond with any {} variant",
703 std::any::type_name::<Self>()
704 ),
705 )),
706 }
707 }
708}
709
710impl<'de> Deserialize<'de> for JobState {
711 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
712 let str = String::deserialize(deserializer)?;
713 FromStr::from_str(&str).map_err(de::Error::custom)
714 }
715}
716
717fn deserialize_compression<'de, D: serde::Deserializer<'de>>(
719 deserializer: D,
720) -> Result<Compression, D::Error> {
721 let opt = Option::<Compression>::deserialize(deserializer)?;
722 Ok(opt.unwrap_or(Compression::None))
723}
724
725enum Header {
726 Skip,
727 Range(Option<(&'static str, String)>),
728}
729
730#[cfg(test)]
731mod tests {
732 use dbn::Dataset;
733 use reqwest::StatusCode;
734 use serde_json::json;
735 use time::macros::datetime;
736 use wiremock::{
737 matchers::{basic_auth, method, path, query_param, query_param_is_missing},
738 Mock, MockServer, ResponseTemplate,
739 };
740
741 use super::*;
742 use crate::{
743 body_contains,
744 historical::test_infra::{client, API_KEY},
745 historical::API_VERSION,
746 };
747
748 #[tokio::test]
749 async fn test_submit_job() -> crate::Result<()> {
750 const START: time::OffsetDateTime = datetime!(2023 - 06 - 14 00:00 UTC);
751 const END: time::OffsetDateTime = datetime!(2023 - 06 - 17 00:00 UTC);
752 const SCHEMA: Schema = Schema::Trades;
753
754 let mock_server = MockServer::start().await;
755 Mock::given(method("POST"))
756 .and(basic_auth(API_KEY, ""))
757 .and(path(format!("/v{API_VERSION}/batch.submit_job")))
758 .and(body_contains("dataset", "XNAS.ITCH"))
759 .and(body_contains("schema", "trades"))
760 .and(body_contains("symbols", "TSLA"))
761 .and(body_contains(
762 "start",
763 START.unix_timestamp_nanos().to_string(),
764 ))
765 .and(body_contains("encoding", "dbn"))
766 .and(body_contains("compression", "zstd"))
767 .and(body_contains("map_symbols", "false"))
768 .and(body_contains("end", END.unix_timestamp_nanos().to_string()))
769 .and(body_contains("stype_in", "raw_symbol"))
771 .and(body_contains("stype_out", "instrument_id"))
772 .respond_with(
773 ResponseTemplate::new(StatusCode::OK.as_u16()).set_body_json(json!({
774 "id": "123",
775 "user_id": "test_user",
776 "cost_usd": 10.50,
777 "dataset": "XNAS.ITCH",
778 "symbols": ["TSLA"],
779 "stype_in": "raw_symbol",
780 "stype_out": "instrument_id",
781 "schema": SCHEMA.as_str(),
782 "start": "2023-06-14T00:00:00.000000000Z",
783 "end": "2023-06-17 00:00:00.000000+00:00",
784 "limit": null,
785 "encoding": "dbn",
786 "compression": "zstd",
787 "pretty_px": false,
788 "pretty_ts": false,
789 "map_symbols": false,
790 "split_symbols": false,
791 "split_duration": "day",
792 "split_size": null,
793 "delivery": "download",
794 "state": "queued",
795 "ts_received": "2023-07-19T23:00:04.095538123Z",
796 "ts_queued": null,
797 "ts_process_start": null,
798 "ts_process_done": null,
799 "ts_expiration": null
800 })),
801 )
802 .mount(&mock_server)
803 .await;
804 let mut target = client(&mock_server);
805 let job_desc = target
806 .batch()
807 .submit_job(
808 &SubmitJobParams::builder()
809 .dataset(dbn::Dataset::XnasItch)
810 .schema(SCHEMA)
811 .symbols("TSLA")
812 .date_time_range(START..END)
813 .build(),
814 )
815 .await?;
816 assert_eq!(job_desc.dataset, dbn::Dataset::XnasItch.as_str());
817 Ok(())
818 }
819
820 #[tokio::test]
821 async fn test_submit_job_param_map_symbols() -> crate::Result<()> {
822 const START: time::OffsetDateTime = datetime!(2023 - 06 - 14 00:00 UTC);
823 const END: time::OffsetDateTime = datetime!(2023 - 06 - 17 00:00 UTC);
824
825 let params = SubmitJobParams::builder()
828 .dataset(Dataset::GlbxMdp3)
829 .encoding(Encoding::Dbn)
830 .symbols("ESM5")
831 .schema(Schema::Mbo)
832 .date_time_range(START..END)
833 .build();
834 assert_eq!(params.encoding, Encoding::Dbn);
835 assert!(params.map_symbols.is_none());
836
837 let params = SubmitJobParams::builder()
838 .dataset(Dataset::GlbxMdp3)
839 .encoding(Encoding::Csv)
840 .symbols("ESM5")
841 .schema(Schema::Mbo)
842 .date_time_range(START..END)
843 .build();
844 assert_eq!(params.encoding, Encoding::Csv);
845 assert!(params.map_symbols.is_none());
846
847 let params = SubmitJobParams::builder()
849 .dataset(Dataset::GlbxMdp3)
850 .encoding(Encoding::Json)
851 .symbols("ESM5")
852 .schema(Schema::Mbo)
853 .date_time_range(START..END)
854 .map_symbols(false)
855 .build();
856 assert_eq!(params.encoding, Encoding::Json);
857 assert_eq!(params.map_symbols, Some(false));
858
859 Ok(())
860 }
861
862 #[tokio::test]
863 async fn test_list_jobs() -> crate::Result<()> {
864 const SCHEMA: Schema = Schema::Trades;
865
866 let mock_server = MockServer::start().await;
867 Mock::given(method("GET"))
868 .and(basic_auth(API_KEY, ""))
869 .and(path(format!("/v{API_VERSION}/batch.list_jobs")))
870 .and(query_param_is_missing("states"))
871 .and(query_param_is_missing("since"))
872 .respond_with(
873 ResponseTemplate::new(StatusCode::OK.as_u16()).set_body_json(json!([{
874 "id": "123",
875 "user_id": "test_user",
876 "cost_usd": 10.50,
877 "dataset": "XNAS.ITCH",
878 "symbols": "TSLA",
879 "stype_in": "raw_symbol",
880 "stype_out": "instrument_id",
881 "schema": SCHEMA.as_str(),
882 "start": "2023-06-14 00:00:00+00:00",
884 "end": "2023-06-17T00:00:00.012345678Z",
885 "limit": null,
886 "encoding": "json",
887 "compression": "zstd",
888 "pretty_px": true,
889 "pretty_ts": false,
890 "map_symbols": true,
891 "split_symbols": false,
892 "split_duration": "day",
893 "split_size": null,
894 "delivery": "download",
895 "state": "processing",
896 "ts_received": "2023-07-19 23:00:04.095538+00:00",
897 "ts_queued": "2023-07-19T23:00:08.095538123Z",
898 "ts_process_start": "2023-07-19 23:01:04.000000+00:00",
899 "ts_process_done": null,
900 "ts_expiration": null
901 },
902 {
903 "id": "XNAS-20250602-5KM3HL5BUW",
904 "user_id": "AA89XSlBV",
905 "cost_usd": 0.0,
906 "dataset": "XNAS.ITCH",
907 "symbols": "MSFT",
908 "stype_in": "raw_symbol",
909 "stype_out": "instrument_id",
910 "schema": "trades",
911 "start": "2022-06-10T12:30:00.000000000Z",
912 "end": "2022-06-10T14:00:00.000000000Z",
913 "limit": 1000,
914 "encoding": "csv",
915 "compression": null,
916 "pretty_px": false,
917 "pretty_ts": false,
918 "map_symbols": true,
919 "split_symbols": false,
920 "split_duration": null,
921 "split_size": null,
922 "packaging": null,
923 "delivery": "download",
924 "record_count": 1000,
925 "billed_size": 48000,
926 "actual_size": 94000,
927 "package_size": 97690,
928 "state": "done",
929 "ts_received": "2025-06-02T15:51:19.251582000Z",
930 "ts_queued": "2025-06-02T15:51:20.997673000Z",
931 "ts_process_start": "2025-06-02T15:51:45.312317000Z",
932 "ts_process_done": "2025-06-02T15:51:46.324860000Z",
933 "ts_expiration": "2025-07-02T16:00:00.000000000Z",
934 "progress": 100
935 }])),
936 )
937 .mount(&mock_server)
938 .await;
939 let mut target = client(&mock_server);
940 let job_descs = target.batch().list_jobs(&ListJobsParams::default()).await?;
941 assert_eq!(job_descs.len(), 2);
942 let mut job_desc = &job_descs[0];
943 assert_eq!(
944 job_desc.ts_queued.unwrap(),
945 datetime!(2023-07-19 23:00:08.095538123 UTC)
946 );
947 assert_eq!(
948 job_desc.ts_process_start.unwrap(),
949 datetime!(2023-07-19 23:01:04 UTC)
950 );
951 assert_eq!(job_desc.encoding, Encoding::Json);
952 assert!(job_desc.pretty_px);
953 assert!(!job_desc.pretty_ts);
954 assert!(job_desc.map_symbols);
955 assert_eq!(job_desc.split_duration, SplitDuration::Day);
956 assert!(job_desc.progress.is_none());
957
958 job_desc = &job_descs[1];
959 assert_eq!(
960 job_desc.ts_queued.unwrap(),
961 datetime!(2025-06-02 15:51:20.997673000 UTC)
962 );
963 assert_eq!(
964 job_desc.ts_process_start.unwrap(),
965 datetime!(2025-06-02 15:51:45.312317000 UTC)
966 );
967 assert_eq!(job_desc.start, datetime!(2022-06-10 12:30:00.000000000 UTC));
968 assert_eq!(job_desc.end, datetime!(2022-06-10 14:00:00.000000000 UTC));
969 assert_eq!(job_desc.encoding, Encoding::Csv);
970 assert!(!job_desc.pretty_px);
971 assert!(!job_desc.pretty_ts);
972 assert!(job_desc.map_symbols);
973 assert!(!job_desc.split_symbols);
974 assert_eq!(job_desc.split_duration, SplitDuration::None);
975 assert_eq!(job_desc.progress, Some(100));
976
977 Ok(())
978 }
979
980 #[tokio::test]
981 async fn test_get_job_details() -> crate::Result<()> {
982 const SCHEMA: Schema = Schema::Trades;
983 const JOB_ID: &str = "XNAS-20250602-5KM3HL5BUW";
984
985 let mock_server = MockServer::start().await;
986 Mock::given(method("GET"))
987 .and(basic_auth(API_KEY, ""))
988 .and(path(format!("/v{API_VERSION}/batch.get_job_details")))
989 .and(query_param("job_id", JOB_ID))
990 .respond_with(
991 ResponseTemplate::new(StatusCode::OK.as_u16()).set_body_json(json!({
992 "id": JOB_ID,
993 "user_id": "test_user",
994 "cost_usd": 10.50,
995 "dataset": "XNAS.ITCH",
996 "symbols": "TSLA",
997 "stype_in": "raw_symbol",
998 "stype_out": "instrument_id",
999 "schema": SCHEMA.as_str(),
1000 "start": "2023-06-14T00:00:00.000000000Z",
1001 "end": "2023-06-17T00:00:00.000000000Z",
1002 "limit": null,
1003 "encoding": "dbn",
1004 "compression": "zstd",
1005 "pretty_px": false,
1006 "pretty_ts": false,
1007 "map_symbols": false,
1008 "split_symbols": false,
1009 "split_duration": "day",
1010 "split_size": null,
1011 "delivery": "download",
1012 "state": "done",
1013 "ts_received": "2023-07-19T23:00:04.095538123Z",
1014 "ts_queued": "2023-07-19T23:00:08.095538123Z",
1015 "ts_process_start": "2023-07-19T23:01:04.000000000Z",
1016 "ts_process_done": "2023-07-19T23:02:04.000000000Z",
1017 "ts_expiration": "2023-08-19T23:00:00.000000000Z",
1018 "progress": 100
1019 })),
1020 )
1021 .mount(&mock_server)
1022 .await;
1023 let mut target = client(&mock_server);
1024 let job_desc = target.batch().get_job_details(JOB_ID).await?;
1025 assert_eq!(job_desc.id, JOB_ID);
1026 assert_eq!(job_desc.dataset, "XNAS.ITCH");
1027 assert_eq!(job_desc.state, JobState::Done);
1028 assert_eq!(job_desc.progress, Some(100));
1029 Ok(())
1030 }
1031
1032 #[test]
1033 fn test_deserialize_compression() {
1034 #[derive(serde::Deserialize)]
1035 struct Test {
1036 #[serde(deserialize_with = "deserialize_compression")]
1037 compression: Compression,
1038 }
1039
1040 const JSON: &str =
1041 r#"[{"compression":null}, {"compression":"none"}, {"compression":"zstd"}]"#;
1042 let res: Vec<Test> = serde_json::from_str(JSON).unwrap();
1043 assert_eq!(
1044 res.into_iter().map(|t| t.compression).collect::<Vec<_>>(),
1045 vec![Compression::None, Compression::None, Compression::Zstd]
1046 );
1047 }
1048}