1use ahash::AHashMap;
17use jiff::Timestamp;
18use nautilus_common::messages::data::{
19 BarsResponse, BookDeltasResponse, BookDepthResponse, CustomDataResponse, DataResponse,
20 FundingRatesResponse, InstrumentResponse, InstrumentsResponse, QuotesResponse, RequestBars,
21 RequestBookDeltas, RequestBookDepth, RequestCommand, RequestCustomData, RequestFundingRates,
22 RequestInstrument, RequestInstruments, RequestQuotes, RequestTrades, SubscribeBars,
23 SubscribeCommand, SubscribeCustomData, SubscribeQuotes, SubscribeTrades, TradesResponse,
24};
25use nautilus_core::{
26 Params, UUID4, UnixNanos,
27 correctness::{FAILED, check_key_not_in_map},
28};
29use nautilus_model::{
30 data::{
31 Bar, CustomData, DataBatch, FromDataBatch, FundingRateUpdate, NautilusDataType,
32 OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick,
33 },
34 identifiers::{ClientId, Venue},
35 instruments::{Instrument, InstrumentAny},
36};
37use nautilus_persistence::catalog::traits::{CatalogInstrumentQuery, CatalogQuery, DataCatalog};
38use serde_json::Value;
39use ustr::Ustr;
40
41use super::{DataEngine, requests::request_params};
42
43const PARAM_SKIP_CATALOG_DATA: &str = "skip_catalog_data";
44const PARAM_UPDATE_CATALOG: &str = "update_catalog";
45const PARAM_FORCE_INSTRUMENT_UPDATE: &str = "force_instrument_update";
46const PARAM_SUBSCRIPTION_NAME: &str = "subscription_name";
47const PARAM_FROM_DAY_START: &str = "from_day_start";
48const CATALOG_CLIENT_ID: &str = "CATALOG";
49
50pub(crate) type CatalogMap = AHashMap<Ustr, DataCatalog>;
51
52impl DataEngine {
53 pub fn register_catalog(&mut self, catalog: DataCatalog, name: Option<&str>) {
59 let name = Ustr::from(name.unwrap_or("catalog_0"));
60
61 check_key_not_in_map(&name, &self.catalogs, "name", "catalogs").expect(FAILED);
62
63 self.catalogs.insert(name, catalog);
64 log::info!("Registered catalog <{name}>");
65 }
66
67 pub(super) fn subscribe_command_with_prefilled_start_ns(
68 &mut self,
69 cmd: SubscribeCommand,
70 ) -> anyhow::Result<SubscribeCommand> {
71 match cmd {
72 SubscribeCommand::Quotes(cmd) if Self::is_start_ns_missing(cmd.params.as_ref()) => {
73 let identifier = cmd.instrument_id.to_string();
74 let params = self.params_with_prefilled_start_ns(
75 cmd.params.as_ref(),
76 &NautilusDataType::QuoteTick,
77 &identifier,
78 )?;
79 Ok(SubscribeCommand::Quotes(SubscribeQuotes { params, ..cmd }))
80 }
81 SubscribeCommand::Trades(cmd) if Self::is_start_ns_missing(cmd.params.as_ref()) => {
82 let identifier = cmd.instrument_id.to_string();
83 let params = self.params_with_prefilled_start_ns(
84 cmd.params.as_ref(),
85 &NautilusDataType::TradeTick,
86 &identifier,
87 )?;
88 Ok(SubscribeCommand::Trades(SubscribeTrades { params, ..cmd }))
89 }
90 SubscribeCommand::Bars(cmd)
91 if cmd.bar_type.is_externally_aggregated()
92 && Self::is_start_ns_missing(cmd.params.as_ref()) =>
93 {
94 let identifier = cmd.bar_type.to_string();
95 let params = self.params_with_prefilled_start_ns(
96 cmd.params.as_ref(),
97 &NautilusDataType::Bar,
98 &identifier,
99 )?;
100 Ok(SubscribeCommand::Bars(SubscribeBars { params, ..cmd }))
101 }
102 SubscribeCommand::Data(cmd) if Self::is_start_ns_missing(cmd.params.as_ref()) => {
103 let type_name = cmd.data_type.type_name().to_string();
104 let identifier = cmd.data_type.identifier().map(String::from);
105 let params = self.params_with_custom_data_prefilled_start_ns(
106 cmd.params.as_ref(),
107 &type_name,
108 identifier.as_deref(),
109 )?;
110 Ok(SubscribeCommand::Data(SubscribeCustomData {
111 params,
112 ..cmd
113 }))
114 }
115 _ => Ok(cmd),
116 }
117 }
118
119 fn is_start_ns_missing(params: Option<&Params>) -> bool {
120 params.is_none_or(|params| !params.contains_key("start_ns"))
121 }
122
123 fn params_with_prefilled_start_ns(
124 &mut self,
125 params: Option<&Params>,
126 data_type: &NautilusDataType,
127 identifier: &str,
128 ) -> anyhow::Result<Option<Params>> {
129 let last_timestamp = self.catalog_last_timestamp(data_type, identifier)?;
130
131 Ok(Some(Self::params_with_start_ns(params, last_timestamp)))
132 }
133
134 fn params_with_custom_data_prefilled_start_ns(
135 &mut self,
136 params: Option<&Params>,
137 type_name: &str,
138 identifier: Option<&str>,
139 ) -> anyhow::Result<Option<Params>> {
140 let last_timestamp = self.catalog_custom_data_last_timestamp(type_name, identifier)?;
141
142 Ok(Some(Self::params_with_start_ns(params, last_timestamp)))
143 }
144
145 fn params_with_start_ns(params: Option<&Params>, last_timestamp: Option<u64>) -> Params {
146 let start_ns = last_timestamp.map_or(Value::Null, |last_timestamp| {
147 Value::from(last_timestamp.saturating_add(1))
148 });
149 let mut params = params.cloned().unwrap_or_else(Params::new);
150
151 params.insert("start_ns".to_string(), start_ns);
152
153 params
154 }
155
156 fn catalog_last_timestamp(
157 &mut self,
158 data_type: &NautilusDataType,
159 identifier: &str,
160 ) -> anyhow::Result<Option<u64>> {
161 for catalog in self.catalogs.values_mut() {
162 if let Some(last_timestamp) =
163 catalog.query_last_timestamp(data_type.clone(), Some(identifier))?
164 {
165 return Ok(Some(last_timestamp));
166 }
167 }
168
169 Ok(None)
170 }
171
172 fn catalog_custom_data_last_timestamp(
173 &mut self,
174 type_name: &str,
175 identifier: Option<&str>,
176 ) -> anyhow::Result<Option<u64>> {
177 let data_type = NautilusDataType::Custom {
182 type_name: type_name.to_string(),
183 };
184
185 for catalog in self.catalogs.values_mut() {
186 if let Some(last_timestamp) =
187 catalog.query_last_timestamp(data_type.clone(), identifier)?
188 {
189 return Ok(Some(last_timestamp));
190 }
191 }
192
193 Ok(None)
194 }
195
196 pub(super) fn catalogs_registered(&self) -> bool {
197 !self.catalogs.is_empty()
198 }
199
200 pub(super) fn dispatch_date_range_request(
206 &mut self,
207 req: RequestCommand,
208 ) -> anyhow::Result<()> {
209 if matches!(
210 req,
211 RequestCommand::Instrument(_) | RequestCommand::Instruments(_)
212 ) {
213 return self.dispatch_instrument_catalog_request(req);
214 }
215
216 let Some(key) = request_identifier(&req) else {
217 return self.dispatch_request_to_client(req).map(|_| ());
218 };
219
220 let now_ns = self.clock.borrow().timestamp_ns();
221 let now_dt = now_ns.to_datetime_utc();
222 let query_past_data = request_params(&req)
223 .and_then(|p| p.get(PARAM_SUBSCRIPTION_NAME))
224 .is_none();
225
226 let (start_dt, end_dt) = bound_request_dates(
227 request_start(&req),
228 request_end(&req),
229 now_dt,
230 query_past_data,
231 );
232 let start_ns = datetime_to_unix_nanos_or_zero(start_dt);
233 let end_ns = datetime_to_unix_nanos_or_zero(end_dt);
234
235 if start_ns > end_ns {
236 anyhow::bail!(
237 "Cannot dispatch request, start {start_ns} was greater than end {end_ns}"
238 );
239 }
240
241 let client_id = req.client_id().copied();
242 let venue = req.venue().copied();
243 let used_client_id = self
244 .get_client(client_id.as_ref(), venue.as_ref())
245 .map(|client| client.client_id());
246
247 let (catalog_start_dt, catalog_start_ns) = if matches!(req, RequestCommand::BookDeltas(_))
251 && request_params(&req)
252 .and_then(|p| p.get_bool(PARAM_FROM_DAY_START))
253 .unwrap_or(true)
254 {
255 let floored = floor_to_utc_day(start_dt);
256 (floored, datetime_to_unix_nanos_or_zero(floored))
257 } else {
258 (start_dt, start_ns)
259 };
260
261 let query_interval = vec![(start_ns.as_u64(), end_ns.as_u64())];
262 let catalog_query_interval = vec![(catalog_start_ns.as_u64(), end_ns.as_u64())];
263 let mut missing_intervals = query_interval.clone();
264 let mut has_catalog_data = false;
265 let mut winning_catalog: Option<Ustr> = None;
266
267 for (name, catalog) in &mut self.catalogs {
268 let catalog_intervals = catalog_missing_intervals(
269 catalog,
270 catalog_start_ns.as_u64(),
271 end_ns.as_u64(),
272 &key,
273 )?;
274
275 if catalog_intervals != catalog_query_interval {
276 has_catalog_data = true;
277 winning_catalog = Some(*name);
278 missing_intervals = if catalog_start_ns == start_ns {
280 catalog_intervals
281 } else {
282 catalog_missing_intervals(catalog, start_ns.as_u64(), end_ns.as_u64(), &key)?
283 };
284 break;
285 }
286 }
287
288 let skip_catalog_data = request_params(&req)
289 .and_then(|p| p.get_bool(PARAM_SKIP_CATALOG_DATA))
290 .unwrap_or(false);
291
292 if skip_catalog_data {
296 missing_intervals = query_interval;
297 }
298
299 let n_client_requests = if used_client_id.is_some() {
300 missing_intervals.len()
301 } else {
302 0
303 };
304 let n_catalog_requests = usize::from(has_catalog_data && !skip_catalog_data);
305 let n_requests = n_client_requests + n_catalog_requests;
306
307 if n_requests == 0 {
308 let empty = build_empty_response(&req, start_ns, end_ns, used_client_id, now_ns)?;
309 self.response(empty);
310 return Ok(());
311 }
312
313 let parent_id = *req.request_id();
314 self.new_request_pipeline(req.clone(), n_requests);
315
316 if n_catalog_requests == 1
317 && let Some(catalog_name) = winning_catalog
318 {
319 let leg = with_dates_for_pipeline(&req, Some(catalog_start_dt), Some(end_dt), now_ns);
320 let leg_id = *leg.request_id();
321 self.register_request_pipeline_leg(leg_id, parent_id);
322
323 match self.query_catalog_leg(
324 &leg,
325 catalog_name,
326 catalog_start_ns,
327 end_ns,
328 used_client_id,
329 now_ns,
330 ) {
331 Ok(resp) => self.response(resp),
332 Err(e) => {
333 log::error!(
334 "Catalog leg query failed for parent {parent_id} (catalog {catalog_name}): {e}"
335 );
336 let empty = match build_empty_response(
337 &leg,
338 start_ns,
339 end_ns,
340 used_client_id,
341 now_ns,
342 ) {
343 Ok(empty) => empty,
344 Err(e) => {
345 self.abort_request_pipeline(parent_id);
346 return Err(e);
347 }
348 };
349 self.response(empty);
350 }
351 }
352 }
353
354 if n_client_requests > 0 {
355 for (leg_start_ns, leg_end_ns) in &missing_intervals {
356 let leg_start_dt = UnixNanos::from(*leg_start_ns).to_datetime_utc();
357 let leg_end_dt = UnixNanos::from(*leg_end_ns).to_datetime_utc();
358 let leg =
359 with_dates_for_pipeline(&req, Some(leg_start_dt), Some(leg_end_dt), now_ns);
360 let leg_id = *leg.request_id();
361 self.register_request_pipeline_leg(leg_id, parent_id);
362
363 if let Err(e) = self.dispatch_request_to_client(leg) {
364 log::error!("Client leg dispatch failed for parent {parent_id}: {e}");
368 self.abort_request_pipeline(parent_id);
369 return Err(e);
370 }
371 }
372 }
373
374 Ok(())
375 }
376
377 fn abort_request_pipeline(&mut self, parent_id: UUID4) {
378 self.request_pipeline_n_components.remove(&parent_id);
379 self.request_pipeline_parent_request.remove(&parent_id);
380 self.request_pipeline_responses.remove(&parent_id);
381 self.request_pipeline_parent_request_id
382 .retain(|_, p_id| *p_id != parent_id);
383 }
384
385 fn query_catalog_leg(
386 &mut self,
387 leg: &RequestCommand,
388 catalog_name: Ustr,
389 start_ns: UnixNanos,
390 end_ns: UnixNanos,
391 used_client_id: Option<ClientId>,
392 ts_init: UnixNanos,
393 ) -> anyhow::Result<DataResponse> {
394 let catalog = self.catalogs.get_mut(&catalog_name).ok_or_else(|| {
395 anyhow::anyhow!("Catalog {catalog_name} disappeared between intervals query and read")
396 })?;
397
398 match leg {
399 RequestCommand::Quotes(cmd) => {
400 let data = quote_ticks_from_query_result(
401 catalog.query_batch(
402 &CatalogQuery::new(NautilusDataType::QuoteTick)
403 .with_identifiers(Some(vec![cmd.instrument_id.to_string()]))
404 .with_range(Some(start_ns), Some(end_ns)),
405 )?,
406 )?;
407 Ok(build_quotes_catalog_response(
408 cmd,
409 data,
410 start_ns,
411 end_ns,
412 used_client_id,
413 ts_init,
414 ))
415 }
416 RequestCommand::Trades(cmd) => {
417 let data = trade_ticks_from_query_result(
418 catalog.query_batch(
419 &CatalogQuery::new(NautilusDataType::TradeTick)
420 .with_identifiers(Some(vec![cmd.instrument_id.to_string()]))
421 .with_range(Some(start_ns), Some(end_ns)),
422 )?,
423 )?;
424 Ok(build_trades_catalog_response(
425 cmd,
426 data,
427 start_ns,
428 end_ns,
429 used_client_id,
430 ts_init,
431 ))
432 }
433 RequestCommand::FundingRates(cmd) => {
434 let data = funding_rates_from_query_result(
435 catalog.query_batch(
436 &CatalogQuery::new(NautilusDataType::FundingRateUpdate)
437 .with_identifiers(Some(vec![cmd.instrument_id.to_string()]))
438 .with_range(Some(start_ns), Some(end_ns)),
439 )?,
440 )?;
441 Ok(build_funding_rates_catalog_response(
442 cmd,
443 data,
444 start_ns,
445 end_ns,
446 used_client_id,
447 ts_init,
448 ))
449 }
450 RequestCommand::Bars(cmd) => {
451 let data = bars_from_query_result(
452 catalog.query_batch(
453 &CatalogQuery::new(NautilusDataType::Bar)
454 .with_identifiers(Some(vec![cmd.bar_type.to_string()]))
455 .with_range(Some(start_ns), Some(end_ns)),
456 )?,
457 )?;
458 Ok(build_bars_catalog_response(
459 cmd,
460 data,
461 start_ns,
462 end_ns,
463 used_client_id,
464 ts_init,
465 ))
466 }
467 RequestCommand::Data(cmd) => {
468 let identifiers = cmd
469 .data_type
470 .identifier()
471 .map(|identifier| vec![identifier.to_string()]);
472 let where_clause = request_filter_expr(cmd.params.as_ref());
473 let data = catalog.query_batch(
474 &CatalogQuery::new(NautilusDataType::Custom {
475 type_name: cmd.data_type.type_name().to_string(),
476 })
477 .with_identifiers(identifiers)
478 .with_range(Some(start_ns), Some(end_ns))
479 .with_where_clause(where_clause),
480 )?;
481 Ok(build_custom_data_catalog_response(
482 cmd,
483 custom_data_from_query_result(data)?,
484 start_ns,
485 end_ns,
486 ts_init,
487 ))
488 }
489 RequestCommand::BookDeltas(cmd) => {
490 let data = order_book_deltas_from_query_result(
491 catalog.query_batch(
492 &CatalogQuery::new(NautilusDataType::OrderBookDelta)
493 .with_identifiers(Some(vec![cmd.instrument_id.to_string()]))
494 .with_range(Some(start_ns), Some(end_ns)),
495 )?,
496 )?;
497 Ok(build_book_deltas_catalog_response(
498 cmd,
499 data,
500 start_ns,
501 end_ns,
502 used_client_id,
503 ts_init,
504 ))
505 }
506 RequestCommand::BookDepth(cmd) => {
507 let data = order_book_depths_from_query_result(
508 catalog.query_batch(
509 &CatalogQuery::new(NautilusDataType::OrderBookDepth)
510 .with_identifiers(Some(vec![cmd.instrument_id.to_string()]))
511 .with_range(Some(start_ns), Some(end_ns)),
512 )?,
513 )?;
514 Ok(build_book_depth_catalog_response(
515 cmd,
516 data,
517 start_ns,
518 end_ns,
519 used_client_id,
520 ts_init,
521 ))
522 }
523 _ => {
524 anyhow::bail!("query_catalog_leg called with non-catalog-eligible variant {leg:?}")
525 }
526 }
527 }
528
529 fn dispatch_instrument_catalog_request(&mut self, req: RequestCommand) -> anyhow::Result<()> {
530 match req {
531 RequestCommand::Instrument(cmd) => self.dispatch_instrument_request(cmd),
532 RequestCommand::Instruments(cmd) => self.dispatch_instruments_request(cmd),
533 _ => self.dispatch_request_to_client(req).map(|_| ()),
534 }
535 }
536
537 fn dispatch_instrument_request(&mut self, cmd: RequestInstrument) -> anyhow::Result<()> {
538 let force_instrument_update = cmd
539 .params
540 .as_ref()
541 .and_then(|params| params.get_bool(PARAM_FORCE_INSTRUMENT_UPDATE))
542 .unwrap_or(false);
543
544 if force_instrument_update {
545 return self
546 .dispatch_request_to_client(RequestCommand::Instrument(cmd))
547 .map(|_| ());
548 }
549
550 let identifier = cmd.instrument_id.to_string();
551 let now_ns = self.clock.borrow().timestamp_ns();
552 let used_client_id = self
553 .get_client(cmd.client_id.as_ref(), Some(&cmd.instrument_id.venue))
554 .map(|client| client.client_id());
555 let (start_dt, end_dt) =
556 bound_request_dates(cmd.start, cmd.end, now_ns.to_datetime_utc(), true);
557 let start_ns = datetime_to_unix_nanos_or_zero(start_dt);
558 let end_ns = datetime_to_unix_nanos_or_zero(end_dt);
559 let start = Some(start_ns);
560 let end = cmd.end.map(datetime_to_unix_nanos_or_zero);
561
562 for catalog in self.catalogs.values_mut() {
563 let data = latest_instruments(
564 catalog.instruments(
565 &CatalogInstrumentQuery::new()
566 .with_instrument_ids(Some(vec![identifier.clone()]))
567 .with_range(start, end),
568 )?,
569 );
570
571 if let Some(instrument) = data.into_iter().next() {
572 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
573 cmd.request_id,
574 resolve_response_client_id(cmd.client_id, used_client_id),
575 cmd.instrument_id,
576 instrument,
577 Some(start_ns),
578 Some(end_ns),
579 now_ns,
580 Some(catalog_response_params(cmd.params.as_ref())),
581 )));
582 self.response(response);
583 return Ok(());
584 }
585 }
586
587 self.dispatch_request_to_client(RequestCommand::Instrument(cmd))
588 .map(|_| ())
589 }
590
591 fn dispatch_instruments_request(&mut self, cmd: RequestInstruments) -> anyhow::Result<()> {
592 let update_catalog = cmd
593 .params
594 .as_ref()
595 .and_then(|params| params.get_bool(PARAM_UPDATE_CATALOG))
596 .unwrap_or(false);
597 let force_instrument_update = cmd
598 .params
599 .as_ref()
600 .and_then(|params| params.get_bool(PARAM_FORCE_INSTRUMENT_UPDATE))
601 .unwrap_or(false);
602
603 if update_catalog || force_instrument_update {
604 return self
605 .dispatch_request_to_client(RequestCommand::Instruments(cmd))
606 .map(|_| ());
607 }
608
609 let now_ns = self.clock.borrow().timestamp_ns();
610 let used_client_id = self
611 .get_client(cmd.client_id.as_ref(), cmd.venue.as_ref())
612 .map(|client| client.client_id());
613 let (start_dt, _) = bound_request_dates(cmd.start, cmd.end, now_ns.to_datetime_utc(), true);
614 let start = Some(datetime_to_unix_nanos_or_zero(start_dt));
615 let end = cmd.end.map(datetime_to_unix_nanos_or_zero);
616 let mut data = Vec::new();
617
618 for catalog in self.catalogs.values_mut() {
619 data.extend(
620 catalog.instruments(
621 &CatalogInstrumentQuery::new()
622 .with_range(start, end)
623 .with_where_clause(request_filter_expr(cmd.params.as_ref())),
624 )?,
625 );
626 }
627
628 if let Some(venue) = cmd.venue {
629 data.retain(|instrument| instrument.venue() == venue);
630 }
631
632 if instrument_only_last(cmd.params.as_ref()) {
633 data = latest_instruments(data);
634 }
635
636 let response = DataResponse::Instruments(InstrumentsResponse::new(
637 cmd.request_id,
638 resolve_response_client_id(cmd.client_id, used_client_id),
639 instrument_response_venue(cmd.venue, &data),
640 data,
641 start,
643 end,
644 now_ns,
645 Some(catalog_response_params(cmd.params.as_ref())),
646 ));
647 self.response(response);
648 Ok(())
649 }
650}
651
652struct RequestCatalogKey {
653 data_type: NautilusDataType,
654 identifier: Option<String>,
655}
656
657pub(super) fn is_date_range_variant(req: &RequestCommand) -> bool {
658 matches!(
659 req,
660 RequestCommand::Data(_)
661 | RequestCommand::Instrument(_)
662 | RequestCommand::Instruments(_)
663 | RequestCommand::Quotes(_)
664 | RequestCommand::Trades(_)
665 | RequestCommand::FundingRates(_)
666 | RequestCommand::Bars(_)
667 | RequestCommand::BookDeltas(_)
668 | RequestCommand::BookDepth(_)
669 )
670}
671
672fn request_identifier(req: &RequestCommand) -> Option<RequestCatalogKey> {
673 match req {
674 RequestCommand::Data(cmd) => Some(RequestCatalogKey {
675 data_type: NautilusDataType::Custom {
676 type_name: cmd.data_type.type_name().to_string(),
677 },
678 identifier: cmd.data_type.identifier().map(String::from),
679 }),
680 RequestCommand::Quotes(cmd) => Some(RequestCatalogKey::new(
681 NautilusDataType::QuoteTick,
682 Some(cmd.instrument_id.to_string()),
683 )),
684 RequestCommand::Trades(cmd) => Some(RequestCatalogKey::new(
685 NautilusDataType::TradeTick,
686 Some(cmd.instrument_id.to_string()),
687 )),
688 RequestCommand::FundingRates(cmd) => Some(RequestCatalogKey::new(
689 NautilusDataType::FundingRateUpdate,
690 Some(cmd.instrument_id.to_string()),
691 )),
692 RequestCommand::Bars(cmd) => Some(RequestCatalogKey::new(
693 NautilusDataType::Bar,
694 Some(cmd.bar_type.to_string()),
695 )),
696 RequestCommand::BookDeltas(cmd) => Some(RequestCatalogKey::new(
697 NautilusDataType::OrderBookDelta,
698 Some(cmd.instrument_id.to_string()),
699 )),
700 RequestCommand::BookDepth(cmd) => Some(RequestCatalogKey::new(
701 NautilusDataType::OrderBookDepth,
702 Some(cmd.instrument_id.to_string()),
703 )),
704 _ => None,
705 }
706}
707
708impl RequestCatalogKey {
709 fn new(data_type: NautilusDataType, identifier: Option<String>) -> Self {
710 Self {
711 data_type,
712 identifier,
713 }
714 }
715
716 fn catalog_data_type(&self) -> NautilusDataType {
717 self.data_type.clone()
718 }
719}
720
721fn catalog_missing_intervals(
722 catalog: &mut DataCatalog,
723 start: u64,
724 end: u64,
725 key: &RequestCatalogKey,
726) -> anyhow::Result<Vec<(u64, u64)>> {
727 catalog.get_missing_intervals_for_request(
728 UnixNanos::from(start),
729 UnixNanos::from(end),
730 key.catalog_data_type(),
731 key.identifier.as_deref(),
732 )
733}
734
735fn quote_ticks_from_query_result(data: DataBatch) -> anyhow::Result<Vec<QuoteTick>> {
736 QuoteTick::from_batch(data)
737}
738
739fn trade_ticks_from_query_result(data: DataBatch) -> anyhow::Result<Vec<TradeTick>> {
740 TradeTick::from_batch(data)
741}
742
743fn funding_rates_from_query_result(data: DataBatch) -> anyhow::Result<Vec<FundingRateUpdate>> {
744 FundingRateUpdate::from_batch(data)
745}
746
747fn bars_from_query_result(data: DataBatch) -> anyhow::Result<Vec<Bar>> {
748 Bar::from_batch(data)
749}
750
751fn order_book_deltas_from_query_result(data: DataBatch) -> anyhow::Result<Vec<OrderBookDelta>> {
752 OrderBookDelta::from_batch(data)
753}
754
755fn order_book_depths_from_query_result(data: DataBatch) -> anyhow::Result<Vec<OrderBookDepth>> {
756 OrderBookDepth::from_batch(data)
757}
758
759fn request_start(req: &RequestCommand) -> Option<Timestamp> {
760 match req {
761 RequestCommand::Data(cmd) => cmd.start,
762 RequestCommand::Instrument(cmd) => cmd.start,
763 RequestCommand::Instruments(cmd) => cmd.start,
764 RequestCommand::Quotes(cmd) => cmd.start,
765 RequestCommand::Trades(cmd) => cmd.start,
766 RequestCommand::FundingRates(cmd) => cmd.start,
767 RequestCommand::Bars(cmd) => cmd.start,
768 RequestCommand::BookDeltas(cmd) => cmd.start,
769 RequestCommand::BookDepth(cmd) => cmd.start,
770 _ => None,
771 }
772}
773
774fn request_end(req: &RequestCommand) -> Option<Timestamp> {
775 match req {
776 RequestCommand::Data(cmd) => cmd.end,
777 RequestCommand::Instrument(cmd) => cmd.end,
778 RequestCommand::Instruments(cmd) => cmd.end,
779 RequestCommand::Quotes(cmd) => cmd.end,
780 RequestCommand::Trades(cmd) => cmd.end,
781 RequestCommand::FundingRates(cmd) => cmd.end,
782 RequestCommand::Bars(cmd) => cmd.end,
783 RequestCommand::BookDeltas(cmd) => cmd.end,
784 RequestCommand::BookDepth(cmd) => cmd.end,
785 _ => None,
786 }
787}
788
789fn request_filter_expr(params: Option<&Params>) -> Option<String> {
790 params
791 .and_then(|params| params.get_str("filter_expr"))
792 .filter(|filter_expr| !filter_expr.is_empty())
793 .map(ToString::to_string)
794}
795
796fn bound_request_dates(
797 start: Option<Timestamp>,
798 end: Option<Timestamp>,
799 now: Timestamp,
800 query_past_data: bool,
801) -> (Timestamp, Timestamp) {
802 let zero = Timestamp::UNIX_EPOCH;
803 let mut start = start.unwrap_or(zero);
804 let mut end = end.unwrap_or(now);
805
806 if query_past_data {
807 if start > now {
808 start = now;
809 }
810
811 if end > now {
812 end = now;
813 }
814 }
815
816 (start, end)
817}
818
819fn datetime_to_unix_nanos_or_zero(dt: Timestamp) -> UnixNanos {
820 UnixNanos::from(u64::try_from(dt.as_nanosecond().max(0)).unwrap_or(0))
821}
822
823fn floor_to_utc_day(dt: Timestamp) -> Timestamp {
824 let midnight = jiff::tz::Offset::UTC.to_datetime(dt).date().at(0, 0, 0, 0);
825 jiff::tz::Offset::UTC
826 .to_timestamp(midnight)
827 .expect("midnight UTC is always valid")
828}
829
830fn with_dates_for_pipeline(
831 req: &RequestCommand,
832 start: Option<Timestamp>,
833 end: Option<Timestamp>,
834 ts_init: UnixNanos,
835) -> RequestCommand {
836 let new_id = UUID4::new();
837
838 match req {
839 RequestCommand::Quotes(cmd) => RequestCommand::Quotes(RequestQuotes {
840 instrument_id: cmd.instrument_id,
841 start,
842 end,
843 limit: cmd.limit,
844 client_id: cmd.client_id,
845 request_id: new_id,
846 ts_init,
847 params: cmd.params.clone(),
848 }),
849 RequestCommand::Trades(cmd) => RequestCommand::Trades(RequestTrades {
850 instrument_id: cmd.instrument_id,
851 start,
852 end,
853 limit: cmd.limit,
854 client_id: cmd.client_id,
855 request_id: new_id,
856 ts_init,
857 params: cmd.params.clone(),
858 }),
859 RequestCommand::FundingRates(cmd) => RequestCommand::FundingRates(RequestFundingRates {
860 instrument_id: cmd.instrument_id,
861 start,
862 end,
863 limit: cmd.limit,
864 client_id: cmd.client_id,
865 request_id: new_id,
866 ts_init,
867 params: cmd.params.clone(),
868 }),
869 RequestCommand::BookDeltas(cmd) => RequestCommand::BookDeltas(RequestBookDeltas {
870 instrument_id: cmd.instrument_id,
871 start,
872 end,
873 limit: cmd.limit,
874 client_id: cmd.client_id,
875 request_id: new_id,
876 ts_init,
877 params: cmd.params.clone(),
878 }),
879 RequestCommand::BookDepth(cmd) => RequestCommand::BookDepth(RequestBookDepth {
880 instrument_id: cmd.instrument_id,
881 start,
882 end,
883 limit: cmd.limit,
884 depth: cmd.depth,
885 client_id: cmd.client_id,
886 request_id: new_id,
887 ts_init,
888 params: cmd.params.clone(),
889 }),
890 RequestCommand::Data(cmd) => RequestCommand::Data(RequestCustomData {
891 client_id: cmd.client_id,
892 data_type: cmd.data_type.clone(),
893 start,
894 end,
895 limit: cmd.limit,
896 request_id: new_id,
897 ts_init,
898 params: cmd.params.clone(),
899 }),
900 RequestCommand::Bars(cmd) => RequestCommand::Bars(RequestBars {
901 bar_type: cmd.bar_type,
902 start,
903 end,
904 limit: cmd.limit,
905 client_id: cmd.client_id,
906 request_id: new_id,
907 ts_init,
908 params: cmd.params.clone(),
909 }),
910 _ => req.clone(),
914 }
915}
916
917fn build_empty_response(
918 req: &RequestCommand,
919 start: UnixNanos,
920 end: UnixNanos,
921 used_client_id: Option<ClientId>,
922 ts_init: UnixNanos,
923) -> anyhow::Result<DataResponse> {
924 let response = match req {
925 RequestCommand::Data(cmd) => DataResponse::Data(CustomDataResponse::new(
926 cmd.request_id,
927 cmd.client_id,
928 None,
929 cmd.data_type.clone(),
930 Vec::<CustomData>::new(),
931 Some(start),
932 Some(end),
933 ts_init,
934 cmd.params.clone(),
935 )),
936 RequestCommand::Quotes(cmd) => DataResponse::Quotes(QuotesResponse::new(
937 cmd.request_id,
938 resolve_response_client_id(cmd.client_id, used_client_id),
939 cmd.instrument_id,
940 Vec::new(),
941 Some(start),
942 Some(end),
943 ts_init,
944 cmd.params.clone(),
945 )),
946 RequestCommand::Trades(cmd) => DataResponse::Trades(TradesResponse::new(
947 cmd.request_id,
948 resolve_response_client_id(cmd.client_id, used_client_id),
949 cmd.instrument_id,
950 Vec::new(),
951 Some(start),
952 Some(end),
953 ts_init,
954 cmd.params.clone(),
955 )),
956 RequestCommand::FundingRates(cmd) => DataResponse::FundingRates(FundingRatesResponse::new(
957 cmd.request_id,
958 resolve_response_client_id(cmd.client_id, used_client_id),
959 cmd.instrument_id,
960 Vec::new(),
961 Some(start),
962 Some(end),
963 ts_init,
964 cmd.params.clone(),
965 )),
966 RequestCommand::Bars(cmd) => DataResponse::Bars(BarsResponse::new(
967 cmd.request_id,
968 resolve_response_client_id(cmd.client_id, used_client_id),
969 cmd.bar_type,
970 Vec::new(),
971 Some(start),
972 Some(end),
973 ts_init,
974 cmd.params.clone(),
975 )),
976 RequestCommand::BookDeltas(cmd) => DataResponse::BookDeltas(BookDeltasResponse::new(
977 cmd.request_id,
978 resolve_response_client_id(cmd.client_id, used_client_id),
979 cmd.instrument_id,
980 Vec::new(),
981 Some(start),
982 Some(end),
983 ts_init,
984 cmd.params.clone(),
985 )),
986 RequestCommand::BookDepth(cmd) => DataResponse::BookDepth(BookDepthResponse::new(
987 cmd.request_id,
988 resolve_response_client_id(cmd.client_id, used_client_id),
989 cmd.instrument_id,
990 Vec::new(),
991 Some(start),
992 Some(end),
993 ts_init,
994 cmd.params.clone(),
995 )),
996 _ => {
997 anyhow::bail!("Cannot build empty catalog response for non-catalog-eligible request")
998 }
999 };
1000
1001 Ok(response)
1002}
1003
1004fn build_quotes_catalog_response(
1005 cmd: &RequestQuotes,
1006 data: Vec<QuoteTick>,
1007 start: UnixNanos,
1008 end: UnixNanos,
1009 used_client_id: Option<ClientId>,
1010 ts_init: UnixNanos,
1011) -> DataResponse {
1012 let params = catalog_response_params(cmd.params.as_ref());
1013 DataResponse::Quotes(QuotesResponse::new(
1014 cmd.request_id,
1015 resolve_response_client_id(cmd.client_id, used_client_id),
1016 cmd.instrument_id,
1017 data,
1018 Some(start),
1019 Some(end),
1020 ts_init,
1021 Some(params),
1022 ))
1023}
1024
1025fn build_trades_catalog_response(
1026 cmd: &RequestTrades,
1027 data: Vec<TradeTick>,
1028 start: UnixNanos,
1029 end: UnixNanos,
1030 used_client_id: Option<ClientId>,
1031 ts_init: UnixNanos,
1032) -> DataResponse {
1033 let params = catalog_response_params(cmd.params.as_ref());
1034 DataResponse::Trades(TradesResponse::new(
1035 cmd.request_id,
1036 resolve_response_client_id(cmd.client_id, used_client_id),
1037 cmd.instrument_id,
1038 data,
1039 Some(start),
1040 Some(end),
1041 ts_init,
1042 Some(params),
1043 ))
1044}
1045
1046fn build_funding_rates_catalog_response(
1047 cmd: &RequestFundingRates,
1048 data: Vec<FundingRateUpdate>,
1049 start: UnixNanos,
1050 end: UnixNanos,
1051 used_client_id: Option<ClientId>,
1052 ts_init: UnixNanos,
1053) -> DataResponse {
1054 let params = catalog_response_params(cmd.params.as_ref());
1055 DataResponse::FundingRates(FundingRatesResponse::new(
1056 cmd.request_id,
1057 resolve_response_client_id(cmd.client_id, used_client_id),
1058 cmd.instrument_id,
1059 data,
1060 Some(start),
1061 Some(end),
1062 ts_init,
1063 Some(params),
1064 ))
1065}
1066
1067fn build_bars_catalog_response(
1068 cmd: &RequestBars,
1069 data: Vec<Bar>,
1070 start: UnixNanos,
1071 end: UnixNanos,
1072 used_client_id: Option<ClientId>,
1073 ts_init: UnixNanos,
1074) -> DataResponse {
1075 let params = catalog_response_params(cmd.params.as_ref());
1076 DataResponse::Bars(BarsResponse::new(
1077 cmd.request_id,
1078 resolve_response_client_id(cmd.client_id, used_client_id),
1079 cmd.bar_type,
1080 data,
1081 Some(start),
1082 Some(end),
1083 ts_init,
1084 Some(params),
1085 ))
1086}
1087
1088fn build_custom_data_catalog_response(
1089 cmd: &RequestCustomData,
1090 data: Vec<CustomData>,
1091 start: UnixNanos,
1092 end: UnixNanos,
1093 ts_init: UnixNanos,
1094) -> DataResponse {
1095 let params = catalog_response_params(cmd.params.as_ref());
1096 DataResponse::Data(CustomDataResponse::new(
1097 cmd.request_id,
1098 cmd.client_id,
1099 None,
1100 cmd.data_type.clone(),
1101 data,
1102 Some(start),
1103 Some(end),
1104 ts_init,
1105 Some(params),
1106 ))
1107}
1108
1109fn build_book_deltas_catalog_response(
1110 cmd: &RequestBookDeltas,
1111 data: Vec<OrderBookDelta>,
1112 start: UnixNanos,
1113 end: UnixNanos,
1114 used_client_id: Option<ClientId>,
1115 ts_init: UnixNanos,
1116) -> DataResponse {
1117 let params = catalog_response_params(cmd.params.as_ref());
1118 DataResponse::BookDeltas(BookDeltasResponse::new(
1119 cmd.request_id,
1120 resolve_response_client_id(cmd.client_id, used_client_id),
1121 cmd.instrument_id,
1122 data,
1123 Some(start),
1124 Some(end),
1125 ts_init,
1126 Some(params),
1127 ))
1128}
1129
1130fn build_book_depth_catalog_response(
1131 cmd: &RequestBookDepth,
1132 data: Vec<OrderBookDepth>,
1133 start: UnixNanos,
1134 end: UnixNanos,
1135 used_client_id: Option<ClientId>,
1136 ts_init: UnixNanos,
1137) -> DataResponse {
1138 let params = catalog_response_params(cmd.params.as_ref());
1139 DataResponse::BookDepth(BookDepthResponse::new(
1140 cmd.request_id,
1141 resolve_response_client_id(cmd.client_id, used_client_id),
1142 cmd.instrument_id,
1143 data,
1144 Some(start),
1145 Some(end),
1146 ts_init,
1147 Some(params),
1148 ))
1149}
1150
1151fn catalog_response_params(existing: Option<&Params>) -> Params {
1152 let mut params = existing.cloned().unwrap_or_else(Params::new);
1153 params.insert(PARAM_UPDATE_CATALOG.to_string(), Value::Bool(false));
1154 params
1155}
1156
1157fn custom_data_from_query_result(data: DataBatch) -> anyhow::Result<Vec<CustomData>> {
1158 CustomData::from_batch(data)
1159}
1160
1161fn instrument_only_last(params: Option<&Params>) -> bool {
1162 params
1163 .and_then(|params| params.get_bool("only_last"))
1164 .unwrap_or(true)
1165}
1166
1167fn latest_instruments(data: Vec<InstrumentAny>) -> Vec<InstrumentAny> {
1168 let mut instruments: AHashMap<_, InstrumentAny> = AHashMap::new();
1169
1170 for instrument in data {
1171 let id = instrument.id();
1172 match instruments.get(&id) {
1173 Some(existing) if existing.ts_init() >= instrument.ts_init() => {}
1174 _ => {
1175 instruments.insert(id, instrument);
1176 }
1177 }
1178 }
1179
1180 let mut data: Vec<_> = instruments.into_values().collect();
1181 data.sort_by_key(|instrument| instrument.id().to_string());
1182 data
1183}
1184
1185fn instrument_response_venue(request_venue: Option<Venue>, data: &[InstrumentAny]) -> Venue {
1186 request_venue.unwrap_or_else(|| {
1187 data.iter()
1188 .map(Instrument::venue)
1189 .min_by_key(std::string::ToString::to_string)
1190 .unwrap_or_else(|| Venue::from(CATALOG_CLIENT_ID))
1191 })
1192}
1193
1194fn resolve_response_client_id(
1195 request_client_id: Option<ClientId>,
1196 used_client_id: Option<ClientId>,
1197) -> ClientId {
1198 request_client_id
1199 .or(used_client_id)
1200 .unwrap_or_else(|| ClientId::new(CATALOG_CLIENT_ID))
1201}
1202
1203#[cfg(test)]
1204mod tests {
1205 use nautilus_common::messages::data::RequestJoin;
1206 use rstest::rstest;
1207
1208 use super::*;
1209
1210 #[rstest]
1211 fn test_build_empty_response_rejects_non_catalog_variant() {
1212 let request = RequestCommand::Join(RequestJoin::new(
1213 vec![UUID4::new()],
1214 None,
1215 None,
1216 UUID4::new(),
1217 UnixNanos::default(),
1218 None,
1219 None,
1220 ));
1221
1222 let result = build_empty_response(
1223 &request,
1224 UnixNanos::from(1u64),
1225 UnixNanos::from(2u64),
1226 None,
1227 UnixNanos::from(3u64),
1228 );
1229
1230 assert_eq!(
1231 result.unwrap_err().to_string(),
1232 "Cannot build empty catalog response for non-catalog-eligible request"
1233 );
1234 }
1235}