1use ahash::{AHashMap, AHashSet};
17use indexmap::IndexMap;
18use jiff::Timestamp;
19use nautilus_common::messages::data::{
20 BarsResponse, BookDeltasResponse, BookDepthResponse, CustomDataResponse, DataRequestFailed,
21 DataResponse, FundingRatesResponse, InstrumentResponse, InstrumentsResponse, QuotesResponse,
22 RequestBars, RequestBookDeltas, RequestBookDepth, RequestCommand, RequestCustomData,
23 RequestFundingRates, RequestInstrument, RequestInstruments, RequestQuotes, RequestTrades,
24 SubscribeBars, SubscribeCommand, SubscribeCustomData, SubscribeQuotes, SubscribeTrades,
25 TradesResponse,
26};
27use nautilus_core::{
28 Params, UUID4, UnixNanos,
29 correctness::{FAILED, check_key_not_in_map},
30};
31use nautilus_model::{
32 data::{
33 Bar, BarType, BatchView, CustomData, DataBatch, FromDataBatch, FundingRateUpdate,
34 NautilusDataType, OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick,
35 },
36 identifiers::{ClientId, InstrumentId, Venue},
37 instruments::{Instrument, InstrumentAny},
38};
39use nautilus_persistence::catalog::traits::{CatalogInstrumentQuery, CatalogQuery, DataCatalog};
40use serde_json::Value;
41use ustr::Ustr;
42
43use super::{DataEngine, requests::request_params};
44
45const PARAM_SKIP_CATALOG_DATA: &str = "skip_catalog_data";
46pub(super) const PARAM_UPDATE_CATALOG: &str = "update_catalog";
47const PARAM_FORCE_INSTRUMENT_UPDATE: &str = "force_instrument_update";
48const PARAM_SUBSCRIPTION_NAME: &str = "subscription_name";
49const PARAM_FROM_DAY_START: &str = "from_day_start";
50const CATALOG_CLIENT_ID: &str = "CATALOG";
51
52pub(crate) type CatalogMap = AHashMap<Ustr, DataCatalog>;
53
54impl DataEngine {
55 pub fn register_catalog(&mut self, catalog: DataCatalog, name: Option<&str>) {
61 let name = Ustr::from(name.unwrap_or("catalog_0"));
62
63 check_key_not_in_map(&name, &self.catalogs, "name", "catalogs").expect(FAILED);
64
65 self.catalogs.insert(name, catalog);
66 log::info!("Registered catalog <{name}>");
67 }
68
69 pub(super) fn subscribe_command_with_prefilled_start_ns(
70 &mut self,
71 cmd: SubscribeCommand,
72 ) -> anyhow::Result<SubscribeCommand> {
73 match cmd {
74 SubscribeCommand::Quotes(cmd)
75 if cmd.instrument_ids.len() == 1
76 && Self::is_start_ns_missing(cmd.params.as_ref()) =>
77 {
78 let identifier = cmd.instrument_ids[0].to_string();
79 let params = self.params_with_prefilled_start_ns(
80 cmd.params.as_ref(),
81 &NautilusDataType::QuoteTick,
82 &identifier,
83 )?;
84 Ok(SubscribeCommand::Quotes(SubscribeQuotes { params, ..cmd }))
85 }
86 SubscribeCommand::Trades(cmd)
87 if cmd.instrument_ids.len() == 1
88 && Self::is_start_ns_missing(cmd.params.as_ref()) =>
89 {
90 let identifier = cmd.instrument_ids[0].to_string();
91 let params = self.params_with_prefilled_start_ns(
92 cmd.params.as_ref(),
93 &NautilusDataType::TradeTick,
94 &identifier,
95 )?;
96 Ok(SubscribeCommand::Trades(SubscribeTrades { params, ..cmd }))
97 }
98 SubscribeCommand::Bars(cmd)
99 if cmd.bar_types.len() == 1
100 && cmd.single_bar_type()?.is_externally_aggregated()
101 && Self::is_start_ns_missing(cmd.params.as_ref()) =>
102 {
103 let identifier = cmd.single_bar_type()?.to_string();
104 let params = self.params_with_prefilled_start_ns(
105 cmd.params.as_ref(),
106 &NautilusDataType::Bar,
107 &identifier,
108 )?;
109 Ok(SubscribeCommand::Bars(SubscribeBars { params, ..cmd }))
110 }
111 SubscribeCommand::Data(cmd) if Self::is_start_ns_missing(cmd.params.as_ref()) => {
112 let type_name = cmd.data_type.type_name().to_string();
113 let identifier = cmd.data_type.identifier().map(String::from);
114 let params = self.params_with_custom_data_prefilled_start_ns(
115 cmd.params.as_ref(),
116 &type_name,
117 identifier.as_deref(),
118 )?;
119 Ok(SubscribeCommand::Data(SubscribeCustomData {
120 params,
121 ..cmd
122 }))
123 }
124 _ => Ok(cmd),
125 }
126 }
127
128 fn is_start_ns_missing(params: Option<&Params>) -> bool {
129 params.is_none_or(|params| !params.contains_key("start_ns"))
130 }
131
132 fn params_with_prefilled_start_ns(
133 &mut self,
134 params: Option<&Params>,
135 data_type: &NautilusDataType,
136 identifier: &str,
137 ) -> anyhow::Result<Option<Params>> {
138 let last_timestamp = self.catalog_last_timestamp(data_type, identifier)?;
139
140 Ok(Some(Self::params_with_start_ns(params, last_timestamp)))
141 }
142
143 fn params_with_custom_data_prefilled_start_ns(
144 &mut self,
145 params: Option<&Params>,
146 type_name: &str,
147 identifier: Option<&str>,
148 ) -> anyhow::Result<Option<Params>> {
149 let last_timestamp = self.catalog_custom_data_last_timestamp(type_name, identifier)?;
150
151 Ok(Some(Self::params_with_start_ns(params, last_timestamp)))
152 }
153
154 fn params_with_start_ns(params: Option<&Params>, last_timestamp: Option<u64>) -> Params {
155 let start_ns = last_timestamp.map_or(Value::Null, |last_timestamp| {
156 Value::from(last_timestamp.saturating_add(1))
157 });
158
159 let mut params = params
160 .map(|params| IndexMap::clone(params))
161 .unwrap_or_default();
162
163 params.insert("start_ns".to_string(), start_ns);
164
165 Params::new(params)
166 }
167
168 fn catalog_last_timestamp(
169 &mut self,
170 data_type: &NautilusDataType,
171 identifier: &str,
172 ) -> anyhow::Result<Option<u64>> {
173 for catalog in self.catalogs.values_mut() {
174 if let Some(last_timestamp) =
175 catalog.query_last_timestamp(data_type.clone(), Some(identifier))?
176 {
177 return Ok(Some(last_timestamp));
178 }
179 }
180
181 Ok(None)
182 }
183
184 fn catalog_custom_data_last_timestamp(
185 &mut self,
186 type_name: &str,
187 identifier: Option<&str>,
188 ) -> anyhow::Result<Option<u64>> {
189 let data_type = NautilusDataType::Custom {
194 type_name: type_name.to_string(),
195 };
196
197 for catalog in self.catalogs.values_mut() {
198 if let Some(last_timestamp) =
199 catalog.query_last_timestamp(data_type.clone(), identifier)?
200 {
201 return Ok(Some(last_timestamp));
202 }
203 }
204
205 Ok(None)
206 }
207
208 pub(super) fn update_catalog_from_response(
209 &mut self,
210 response: &DataResponse,
211 request: &RequestCommand,
212 ) {
213 let Some(params) =
214 request_params(request).filter(|p| p.get_bool(PARAM_UPDATE_CATALOG).unwrap_or(false))
215 else {
216 return;
217 };
218
219 if response
220 .params()
221 .is_some_and(|p| p.get_bool(PARAM_UPDATE_CATALOG) == Some(false))
222 {
223 return;
224 }
225
226 if self.catalogs.is_empty() {
227 log::warn!("No catalog registered for response write-back");
228 return;
229 }
230
231 if let DataResponse::Instrument(value) = response {
232 self.write_response_instruments(std::slice::from_ref(&value.data), params);
233 return;
234 }
235
236 if let DataResponse::Instruments(value) = response {
237 self.write_response_instruments(&value.data, params);
238 return;
239 }
240
241 if request_has_schema(request) {
242 log::warn!(
243 "Skipping catalog write-back because source schemas are not part of the catalog key, request={}",
244 request.request_id()
245 );
246 return;
247 }
248
249 if request_limit(request).is_some() {
250 log::warn!(
251 "Skipping catalog write-back because a record limit can leave incomplete coverage, request={}",
252 request.request_id()
253 );
254 return;
255 }
256
257 let batches = match response_batches(response) {
258 Ok(batches) => batches,
259 Err(e) => {
260 log::error!("Cannot prepare response catalog write: {e}");
261 return;
262 }
263 };
264
265 let bounds = response_write_bounds(request, response);
266 let data_type = response.data_kind();
267
268 for (identifier, batch) in batches {
269 if !request_contains_batch(request, identifier.as_deref(), &batch) {
270 log::warn!(
271 "Skipping catalog write-back because identifier coverage is unverified, request={}, identifier={identifier:?}",
272 request.request_id()
273 );
274 continue;
275 }
276
277 if let Err(e) =
278 self.write_response_batch(&batch, &data_type, identifier.as_deref(), params, bounds)
279 {
280 log::error!("Catalog write-back failed for {identifier:?}: {e}");
281 }
282 }
283 }
284
285 fn write_response_batch(
286 &mut self,
287 batch: &DataBatch,
288 data_type: &NautilusDataType,
289 identifier: Option<&str>,
290 params: &Params,
291 bounds: (Option<UnixNanos>, Option<UnixNanos>),
292 ) -> anyhow::Result<()> {
293 let name = self.catalog_for_update(data_type, identifier, params)?;
294 let catalog = self
295 .catalogs
296 .get_mut(&name)
297 .expect("selected catalog is registered");
298 let (start, end) = bounds;
299 if batch.is_empty() {
300 if let (Some(start), Some(end)) = (start, end) {
301 catalog.record_empty_coverage(data_type.clone(), identifier, start, end)?;
302 }
303
304 return Ok(());
305 }
306
307 catalog.write_data_batch(batch, start, end, Some(params.clone()))
308 }
309
310 fn write_response_instruments(&mut self, instruments: &[InstrumentAny], params: &Params) {
311 let force = params
312 .get_bool(PARAM_FORCE_INSTRUMENT_UPDATE)
313 .unwrap_or(false);
314
315 for instrument in instruments {
316 let identifier = instrument.id().to_string();
317
318 let result = (|| {
319 let name = self.catalog_for_update(
320 &NautilusDataType::Instrument,
321 Some(&identifier),
322 params,
323 )?;
324 let catalog = self
325 .catalogs
326 .get_mut(&name)
327 .expect("selected catalog is registered");
328
329 if !force {
330 let query = CatalogInstrumentQuery::new()
331 .with_instrument_ids(Some(vec![identifier.clone()]));
332
333 if !catalog.instruments(&query)?.is_empty() {
334 return Ok(());
335 }
336 }
337
338 catalog.write_instruments(std::slice::from_ref(instrument))
339 })();
340
341 if let Err(e) = result {
342 log::error!("Instrument catalog write-back failed for {identifier}: {e}");
343 }
344 }
345 }
346
347 fn catalog_for_update(
348 &mut self,
349 data_type: &NautilusDataType,
350 identifier: Option<&str>,
351 params: &Params,
352 ) -> anyhow::Result<Ustr> {
353 if let Some(name) = params.get_str("catalog_name").map(Ustr::from) {
354 anyhow::ensure!(
355 self.catalogs.contains_key(&name),
356 "Catalog '{name}' is not registered"
357 );
358 return Ok(name);
359 }
360
361 let mut names: Vec<_> = self.catalogs.keys().copied().collect();
362 names.sort_by(|a, b| a.as_str().cmp(b.as_str()));
363 for name in &names {
364 let catalog = self.catalogs.get_mut(name).expect("catalog key exists");
365 if catalog
366 .query_last_timestamp(data_type.clone(), identifier)?
367 .is_some()
368 {
369 return Ok(*name);
370 }
371 }
372
373 names
374 .first()
375 .copied()
376 .ok_or_else(|| anyhow::anyhow!("No catalog registered"))
377 }
378
379 pub(super) fn catalogs_registered(&self) -> bool {
380 !self.catalogs.is_empty()
381 }
382
383 pub(super) fn dispatch_date_range_request(
389 &mut self,
390 req: RequestCommand,
391 ) -> anyhow::Result<()> {
392 if request_has_schema(&req) {
393 return self.dispatch_request_to_client(req).map(|_| ());
394 }
395
396 if matches!(
397 req,
398 RequestCommand::Instrument(_) | RequestCommand::Instruments(_)
399 ) {
400 return self.dispatch_instrument_catalog_request(req);
401 }
402
403 if matches!(&req, RequestCommand::Quotes(r) if r.instrument_ids.len() > 1)
404 || matches!(&req, RequestCommand::Trades(r) if r.instrument_ids.len() > 1)
405 {
406 return self.dispatch_batch_catalog_request(req);
407 }
408
409 let Some(key) = request_identifier(&req) else {
410 return self.dispatch_request_to_client(req).map(|_| ());
411 };
412
413 let now_ns = self.clock.borrow().timestamp_ns();
414 let now_dt = now_ns.to_datetime_utc();
415 let query_past_data = request_params(&req)
416 .and_then(|p| p.get(PARAM_SUBSCRIPTION_NAME))
417 .is_none();
418
419 let (start_dt, end_dt) = bound_request_dates(
420 request_start(&req),
421 request_end(&req),
422 now_dt,
423 query_past_data,
424 );
425 let start_ns = datetime_to_unix_nanos_or_zero(start_dt);
426 let end_ns = datetime_to_unix_nanos_or_zero(end_dt);
427
428 if start_ns > end_ns {
429 anyhow::bail!(
430 "Cannot dispatch request, start {start_ns} was greater than end {end_ns}"
431 );
432 }
433
434 let (catalog_start_dt, catalog_start_ns) = if matches!(req, RequestCommand::BookDeltas(_))
438 && request_params(&req)
439 .and_then(|p| p.get_bool(PARAM_FROM_DAY_START))
440 .unwrap_or(true)
441 {
442 let floored = floor_to_utc_day(start_dt);
443 (floored, datetime_to_unix_nanos_or_zero(floored))
444 } else {
445 (start_dt, start_ns)
446 };
447
448 let query_interval = vec![(start_ns.as_u64(), end_ns.as_u64())];
449 let mut missing_intervals = query_interval.clone();
450 let coverage = self.catalog_coverage(&key, catalog_start_ns.as_u64(), end_ns.as_u64())?;
451
452 let winning_catalog = if let Some(CatalogCoverage {
453 catalog: name,
454 missing: intervals,
455 }) = coverage
456 {
457 missing_intervals = if catalog_start_ns == start_ns {
459 intervals
460 } else {
461 catalog_missing_intervals(
462 self.catalogs
463 .get_mut(&name)
464 .expect("selected catalog is registered"),
465 start_ns.as_u64(),
466 end_ns.as_u64(),
467 &key,
468 )?
469 };
470
471 Some(name)
472 } else {
473 None
474 };
475
476 let skip_catalog_data = request_params(&req)
477 .and_then(|p| p.get_bool(PARAM_SKIP_CATALOG_DATA))
478 .unwrap_or(false);
479
480 if skip_catalog_data {
484 missing_intervals = query_interval;
485 }
486
487 let client_id = req.client_id().copied();
488 let venue = req.venue().copied();
489 let has_client = self
490 .get_client(client_id.as_ref(), venue.as_ref())
491 .is_some();
492
493 if !has_client {
494 missing_intervals.clear();
495 }
496
497 let mut plan = Vec::new();
498
499 if !skip_catalog_data && let Some(catalog) = winning_catalog {
500 let request =
501 with_dates_for_pipeline(&req, Some(catalog_start_dt), Some(end_dt), now_ns);
502 plan.push(RequestLeg::Catalog { request, catalog });
503 }
504
505 for (start, end) in missing_intervals {
506 let start = UnixNanos::from(start).to_datetime_utc();
507 let end = UnixNanos::from(end).to_datetime_utc();
508 let request = with_dates_for_pipeline(&req, Some(start), Some(end), now_ns);
509 plan.push(RequestLeg::Client(request));
510 }
511
512 self.dispatch_request_plan(req, plan)
513 }
514
515 fn dispatch_batch_catalog_request(&mut self, parent: RequestCommand) -> anyhow::Result<()> {
516 let ids = match &parent {
517 RequestCommand::Quotes(r) => r.instrument_ids.clone(),
518 RequestCommand::Trades(r) => r.instrument_ids.clone(),
519 _ => unreachable!("only quote and trade lists use catalog batch planning"),
520 };
521
522 let now_ns = self.clock.borrow().timestamp_ns();
523 let query_past = request_params(&parent)
524 .and_then(|p| p.get(PARAM_SUBSCRIPTION_NAME))
525 .is_none();
526 let (start, end) = bound_request_dates(
527 request_start(&parent),
528 request_end(&parent),
529 now_ns.to_datetime_utc(),
530 query_past,
531 );
532 let start_ns = datetime_to_unix_nanos_or_zero(start);
533 let end_ns = datetime_to_unix_nanos_or_zero(end);
534 anyhow::ensure!(start_ns <= end_ns, "Request start exceeds end");
535 let skip_catalog = request_params(&parent)
536 .and_then(|p| p.get_bool(PARAM_SKIP_CATALOG_DATA))
537 .unwrap_or(false);
538 let full = vec![(start_ns.as_u64(), end_ns.as_u64())];
539 let mut clients: IndexMap<(u64, u64), Vec<InstrumentId>> = IndexMap::new();
540 let mut plan = Vec::new();
541
542 for id in ids {
543 let mut member = with_request_instrument_ids(&parent, vec![id]);
544 let key = request_identifier(&member).expect("single quote or trade identifier");
545 let mut missing = full.clone();
546 if !skip_catalog
547 && let Some(CatalogCoverage {
548 catalog,
549 missing: intervals,
550 }) = self.catalog_coverage(&key, start_ns.as_u64(), end_ns.as_u64())?
551 {
552 missing = intervals;
553 member = with_dates_for_pipeline(&member, Some(start), Some(end), now_ns);
554 plan.push(RequestLeg::Catalog {
555 request: member,
556 catalog,
557 });
558 }
559
560 for interval in missing {
561 clients.entry(interval).or_default().push(id);
562 }
563 }
564
565 for ((start, end), ids) in clients {
566 let member = with_request_instrument_ids(&parent, ids);
567 let client_id = member.client_id().copied();
568 let venue = member.venue().copied();
569
570 if self
571 .get_client(client_id.as_ref(), venue.as_ref())
572 .is_none()
573 {
574 continue;
575 }
576
577 let start = UnixNanos::from(start).to_datetime_utc();
578 let end = UnixNanos::from(end).to_datetime_utc();
579 let request = with_dates_for_pipeline(&member, Some(start), Some(end), now_ns);
580 plan.push(RequestLeg::Client(request));
581 }
582
583 self.dispatch_request_plan(parent, plan)
584 }
585
586 fn catalog_coverage(
587 &mut self,
588 key: &RequestCatalogKey,
589 start: u64,
590 end: u64,
591 ) -> anyhow::Result<Option<CatalogCoverage>> {
592 for (name, catalog) in &mut self.catalogs {
593 let missing = catalog_missing_intervals(catalog, start, end, key)?;
594 if missing != [(start, end)] {
595 return Ok(Some(CatalogCoverage {
596 catalog: *name,
597 missing,
598 }));
599 }
600 }
601
602 Ok(None)
603 }
604
605 fn dispatch_request_plan(
606 &mut self,
607 parent: RequestCommand,
608 plan: Vec<RequestLeg>,
609 ) -> anyhow::Result<()> {
610 let parent_id = *parent.request_id();
611
612 if plan.is_empty() {
613 let now = self.clock.borrow().timestamp_ns();
614 let (start, end) = bound_request_dates(
615 request_start(&parent),
616 request_end(&parent),
617 now.to_datetime_utc(),
618 true,
619 );
620 let response = build_empty_response(
621 &parent,
622 datetime_to_unix_nanos_or_zero(start),
623 datetime_to_unix_nanos_or_zero(end),
624 None,
625 now,
626 )?;
627 self.response(response);
628 return Ok(());
629 }
630
631 self.new_request_pipeline(parent, plan.len());
632 for leg in &plan {
633 self.register_request_pipeline_leg(leg.request_id(), parent_id);
634 }
635
636 for leg in plan {
637 let request_id = leg.request_id();
638
639 let result = match leg {
640 RequestLeg::Catalog { request, catalog } => {
641 let start = request_start(&request)
642 .map(datetime_to_unix_nanos_or_zero)
643 .expect("catalog leg has bounded start");
644 let end = request_end(&request)
645 .map(datetime_to_unix_nanos_or_zero)
646 .expect("catalog leg has bounded end");
647 let now = self.clock.borrow().timestamp_ns();
648 let client_id = request.client_id().copied();
649 let venue = request.venue().copied();
650 let client = self
651 .get_client(client_id.as_ref(), venue.as_ref())
652 .map(|value| value.client_id());
653 self.query_catalog_leg(&request, catalog, start, end, client, now)
654 .map(|response| self.response(response))
655 }
656 RequestLeg::Client(request) => self.dispatch_request_to_client(request).map(|_| ()),
657 RequestLeg::Response(response) => {
658 self.response(response);
659 Ok(())
660 }
661 };
662
663 if let Err(e) = result {
664 self.request_failed(DataRequestFailed {
665 request_id,
666 reason: e.to_string(),
667 response: None,
668 });
669 }
670 }
671
672 Ok(())
673 }
674
675 fn query_catalog_leg(
676 &mut self,
677 leg: &RequestCommand,
678 catalog_name: Ustr,
679 start_ns: UnixNanos,
680 end_ns: UnixNanos,
681 used_client_id: Option<ClientId>,
682 ts_init: UnixNanos,
683 ) -> anyhow::Result<DataResponse> {
684 let catalog = self.catalogs.get_mut(&catalog_name).ok_or_else(|| {
685 anyhow::anyhow!("Catalog {catalog_name} disappeared between intervals query and read")
686 })?;
687
688 match leg {
689 RequestCommand::Quotes(cmd) => {
690 let data = quote_ticks_from_query_result(
691 catalog.query_batch(
692 &CatalogQuery::new(NautilusDataType::QuoteTick)
693 .with_identifiers(Some(vec![cmd.single_instrument_id()?.to_string()]))
694 .with_range(Some(start_ns), Some(end_ns)),
695 )?,
696 )?;
697 Ok(build_quotes_catalog_response(
698 cmd,
699 data,
700 start_ns,
701 end_ns,
702 used_client_id,
703 ts_init,
704 ))
705 }
706 RequestCommand::Trades(cmd) => {
707 let data = trade_ticks_from_query_result(
708 catalog.query_batch(
709 &CatalogQuery::new(NautilusDataType::TradeTick)
710 .with_identifiers(Some(vec![cmd.single_instrument_id()?.to_string()]))
711 .with_range(Some(start_ns), Some(end_ns)),
712 )?,
713 )?;
714 Ok(build_trades_catalog_response(
715 cmd,
716 data,
717 start_ns,
718 end_ns,
719 used_client_id,
720 ts_init,
721 ))
722 }
723 RequestCommand::FundingRates(cmd) => {
724 let data = funding_rates_from_query_result(
725 catalog.query_batch(
726 &CatalogQuery::new(NautilusDataType::FundingRateUpdate)
727 .with_identifiers(Some(vec![cmd.single_instrument_id()?.to_string()]))
728 .with_range(Some(start_ns), Some(end_ns)),
729 )?,
730 )?;
731 build_funding_rates_catalog_response(
732 cmd,
733 data,
734 start_ns,
735 end_ns,
736 used_client_id,
737 ts_init,
738 )
739 }
740 RequestCommand::Bars(cmd) => {
741 let data = bars_from_query_result(
742 catalog.query_batch(
743 &CatalogQuery::new(NautilusDataType::Bar)
744 .with_identifiers(Some(vec![cmd.single_bar_type()?.to_string()]))
745 .with_range(Some(start_ns), Some(end_ns)),
746 )?,
747 )?;
748 build_bars_catalog_response(cmd, data, start_ns, end_ns, used_client_id, ts_init)
749 }
750 RequestCommand::Data(cmd) => {
751 let identifiers = cmd
752 .data_type
753 .identifier()
754 .map(|identifier| vec![identifier.to_string()]);
755 let where_clause = request_filter_expr(cmd.params.as_ref());
756 let data = catalog.query_batch(
757 &CatalogQuery::new(NautilusDataType::Custom {
758 type_name: cmd.data_type.type_name().to_string(),
759 })
760 .with_identifiers(identifiers)
761 .with_range(Some(start_ns), Some(end_ns))
762 .with_where_clause(where_clause),
763 )?;
764 Ok(build_custom_data_catalog_response(
765 cmd,
766 custom_data_from_query_result(data)?,
767 start_ns,
768 end_ns,
769 ts_init,
770 ))
771 }
772 RequestCommand::BookDeltas(cmd) => {
773 let data = order_book_deltas_from_query_result(
774 catalog.query_batch(
775 &CatalogQuery::new(NautilusDataType::OrderBookDelta)
776 .with_identifiers(Some(vec![cmd.single_instrument_id()?.to_string()]))
777 .with_range(Some(start_ns), Some(end_ns)),
778 )?,
779 )?;
780 build_book_deltas_catalog_response(
781 cmd,
782 data,
783 start_ns,
784 end_ns,
785 used_client_id,
786 ts_init,
787 )
788 }
789 RequestCommand::BookDepth(cmd) => {
790 let data = order_book_depths_from_query_result(
791 catalog.query_batch(
792 &CatalogQuery::new(NautilusDataType::OrderBookDepth)
793 .with_identifiers(Some(vec![cmd.single_instrument_id()?.to_string()]))
794 .with_range(Some(start_ns), Some(end_ns)),
795 )?,
796 )?;
797 build_book_depth_catalog_response(
798 cmd,
799 data,
800 start_ns,
801 end_ns,
802 used_client_id,
803 ts_init,
804 )
805 }
806 _ => {
807 anyhow::bail!("query_catalog_leg called with non-catalog-eligible variant {leg:?}")
808 }
809 }
810 }
811
812 fn dispatch_instrument_catalog_request(&mut self, req: RequestCommand) -> anyhow::Result<()> {
813 match req {
814 RequestCommand::Instrument(cmd) => self.dispatch_instrument_request(cmd),
815 RequestCommand::Instruments(cmd) => self.dispatch_instruments_request(cmd),
816 _ => self.dispatch_request_to_client(req).map(|_| ()),
817 }
818 }
819
820 fn dispatch_instrument_request(&mut self, cmd: RequestInstrument) -> anyhow::Result<()> {
821 if cmd.instrument_ids.len() > 1 {
822 return self.dispatch_instrument_list(cmd);
823 }
824
825 let force_instrument_update = cmd
826 .params
827 .as_ref()
828 .and_then(|params| params.get_bool(PARAM_FORCE_INSTRUMENT_UPDATE))
829 .unwrap_or(false);
830
831 if force_instrument_update {
832 return self
833 .dispatch_request_to_client(RequestCommand::Instrument(cmd))
834 .map(|_| ());
835 }
836
837 let identifier = cmd.single_instrument_id()?.to_string();
838 let now_ns = self.clock.borrow().timestamp_ns();
839 let used_client_id = self
840 .get_client(
841 cmd.client_id.as_ref(),
842 Some(&cmd.single_instrument_id()?.venue),
843 )
844 .map(|client| client.client_id());
845 let (start_dt, end_dt) =
846 bound_request_dates(cmd.start, cmd.end, now_ns.to_datetime_utc(), true);
847 let start_ns = datetime_to_unix_nanos_or_zero(start_dt);
848 let end_ns = datetime_to_unix_nanos_or_zero(end_dt);
849 let start = Some(start_ns);
850 let end = cmd.end.map(datetime_to_unix_nanos_or_zero);
851
852 for catalog in self.catalogs.values_mut() {
853 let data = latest_instruments(
854 catalog.instruments(
855 &CatalogInstrumentQuery::new()
856 .with_instrument_ids(Some(vec![identifier.clone()]))
857 .with_range(start, end),
858 )?,
859 );
860
861 if let Some(instrument) = data.into_iter().next() {
862 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
863 cmd.request_id,
864 resolve_response_client_id(cmd.client_id, used_client_id),
865 cmd.single_instrument_id()?,
866 instrument,
867 Some(start_ns),
868 Some(end_ns),
869 now_ns,
870 Some(catalog_response_params(cmd.params.as_ref())),
871 )));
872 self.response(response);
873 return Ok(());
874 }
875 }
876
877 self.dispatch_request_to_client(RequestCommand::Instrument(cmd))
878 .map(|_| ())
879 }
880
881 fn dispatch_instrument_list(&mut self, command: RequestInstrument) -> anyhow::Result<()> {
882 let parent = RequestCommand::Instrument(command.clone());
883 let force = command
884 .params
885 .as_ref()
886 .and_then(|p| p.get_bool(PARAM_FORCE_INSTRUMENT_UPDATE))
887 .unwrap_or(false);
888
889 if force {
890 return self.dispatch_request_to_client(parent).map(|_| ());
891 }
892
893 let now = self.clock.borrow().timestamp_ns();
894 let (start, end) =
895 bound_request_dates(command.start, command.end, now.to_datetime_utc(), true);
896 let start_ns = datetime_to_unix_nanos_or_zero(start);
897 let end_ns = datetime_to_unix_nanos_or_zero(end);
898 let mut missing = Vec::new();
899 let mut plan = Vec::new();
900
901 for id in &command.instrument_ids {
902 let mut instrument = None;
903
904 for catalog in self.catalogs.values_mut() {
905 let query = CatalogInstrumentQuery::new()
906 .with_instrument_ids(Some(vec![id.to_string()]))
907 .with_range(
908 Some(start_ns),
909 command.end.map(datetime_to_unix_nanos_or_zero),
910 );
911 instrument = latest_instruments(catalog.instruments(&query)?)
912 .into_iter()
913 .find(|value| value.id() == *id);
914
915 if instrument.is_some() {
916 break;
917 }
918 }
919
920 if let Some(instrument) = instrument {
921 let response = InstrumentResponse::new(
922 UUID4::new(),
923 command
924 .client_id
925 .unwrap_or_else(|| ClientId::new(CATALOG_CLIENT_ID)),
926 *id,
927 instrument,
928 Some(start_ns),
929 Some(end_ns),
930 now,
931 Some(catalog_response_params(command.params.as_ref())),
932 );
933 plan.push(RequestLeg::Response(DataResponse::Instrument(Box::new(
934 response,
935 ))));
936 } else {
937 missing.push(*id);
938 }
939 }
940
941 if !missing.is_empty() {
942 let mut child = command;
943 child.request_id = UUID4::new();
944 child.instrument_ids = missing;
945 plan.push(RequestLeg::Client(RequestCommand::Instrument(child)));
946 }
947
948 self.dispatch_request_plan(parent, plan)
949 }
950
951 fn dispatch_instruments_request(&mut self, cmd: RequestInstruments) -> anyhow::Result<()> {
952 let update_catalog = cmd
953 .params
954 .as_ref()
955 .and_then(|params| params.get_bool(PARAM_UPDATE_CATALOG))
956 .unwrap_or(false);
957 let force_instrument_update = cmd
958 .params
959 .as_ref()
960 .and_then(|params| params.get_bool(PARAM_FORCE_INSTRUMENT_UPDATE))
961 .unwrap_or(false);
962
963 if update_catalog || force_instrument_update {
964 return self
965 .dispatch_request_to_client(RequestCommand::Instruments(cmd))
966 .map(|_| ());
967 }
968
969 let now_ns = self.clock.borrow().timestamp_ns();
970 let used_client_id = self
971 .get_client(cmd.client_id.as_ref(), cmd.venue.as_ref())
972 .map(|client| client.client_id());
973 let (start_dt, _) = bound_request_dates(cmd.start, cmd.end, now_ns.to_datetime_utc(), true);
974 let start = Some(datetime_to_unix_nanos_or_zero(start_dt));
975 let end = cmd.end.map(datetime_to_unix_nanos_or_zero);
976 let mut data = Vec::new();
977
978 for catalog in self.catalogs.values_mut() {
979 data.extend(
980 catalog.instruments(
981 &CatalogInstrumentQuery::new()
982 .with_range(start, end)
983 .with_where_clause(request_filter_expr(cmd.params.as_ref())),
984 )?,
985 );
986 }
987
988 if let Some(venue) = cmd.venue {
989 data.retain(|instrument| instrument.venue() == venue);
990 }
991
992 if instrument_only_last(cmd.params.as_ref()) {
993 data = latest_instruments(data);
994 }
995
996 let response = DataResponse::Instruments(InstrumentsResponse::new(
997 cmd.request_id,
998 resolve_response_client_id(cmd.client_id, used_client_id),
999 instrument_response_venue(cmd.venue, &data),
1000 data,
1001 start,
1003 end,
1004 now_ns,
1005 Some(catalog_response_params(cmd.params.as_ref())),
1006 ));
1007 self.response(response);
1008 Ok(())
1009 }
1010}
1011
1012struct RequestCatalogKey {
1013 data_type: NautilusDataType,
1014 identifier: Option<String>,
1015}
1016
1017fn response_write_bounds(
1018 request: &RequestCommand,
1019 response: &DataResponse,
1020) -> (Option<UnixNanos>, Option<UnixNanos>) {
1021 (
1022 request_start(request)
1023 .map(datetime_to_unix_nanos_or_zero)
1024 .or(response.start()),
1025 request_end(request)
1026 .map(datetime_to_unix_nanos_or_zero)
1027 .or(response.end()),
1028 )
1029}
1030
1031fn response_batches(response: &DataResponse) -> anyhow::Result<Vec<(Option<String>, DataBatch)>> {
1032 let batches = match response {
1033 DataResponse::Quotes(r) => {
1034 let ids = response_instrument_ids(
1035 &r.instrument_ids,
1036 r.data.iter().map(|row| row.instrument_id),
1037 );
1038 let single = ids.len() == 1;
1039 ids.into_iter()
1040 .map(|id| {
1041 let data = if single {
1042 r.data.clone()
1043 } else {
1044 r.data
1045 .iter()
1046 .filter(|row| row.instrument_id == id)
1047 .copied()
1048 .collect::<Vec<_>>()
1049 .into()
1050 };
1051
1052 (Some(id.to_string()), DataBatch::Quote(data))
1053 })
1054 .collect()
1055 }
1056 DataResponse::Trades(r) => {
1057 let ids = response_instrument_ids(
1058 &r.instrument_ids,
1059 r.data.iter().map(|row| row.instrument_id),
1060 );
1061 let single = ids.len() == 1;
1062 ids.into_iter()
1063 .map(|id| {
1064 let data = if single {
1065 r.data.clone()
1066 } else {
1067 r.data
1068 .iter()
1069 .filter(|row| row.instrument_id == id)
1070 .copied()
1071 .collect::<Vec<_>>()
1072 .into()
1073 };
1074
1075 (Some(id.to_string()), DataBatch::Trade(data))
1076 })
1077 .collect()
1078 }
1079 DataResponse::BookDeltas(r) => vec![(
1080 Some(r.instrument_id.to_string()),
1081 DataBatch::BookDelta(r.data.clone()),
1082 )],
1083 DataResponse::BookDepth(r) => vec![(
1084 Some(r.instrument_id.to_string()),
1085 DataBatch::BookDepth(r.data.clone()),
1086 )],
1087 DataResponse::FundingRates(r) => vec![(
1088 Some(r.instrument_id.to_string()),
1089 DataBatch::FundingRate(r.data.clone()),
1090 )],
1091 DataResponse::Bars(r) => {
1092 vec![(Some(r.bar_type.to_string()), DataBatch::Bar(r.data.clone()))]
1093 }
1094 DataResponse::Data(r) => {
1095 let values = if let Some(data) = r.data.downcast_ref::<Vec<CustomData>>() {
1096 data.clone()
1097 } else if let Some(data) = r.data.downcast_ref::<CustomData>() {
1098 vec![data.clone()]
1099 } else {
1100 anyhow::bail!("Custom response payload is not catalog data");
1101 };
1102
1103 vec![(
1104 r.data_type.identifier().map(String::from),
1105 DataBatch::Custom(values.into()),
1106 )]
1107 }
1108 _ => Vec::new(),
1109 };
1110
1111 Ok(batches)
1112}
1113
1114struct CatalogCoverage {
1115 catalog: Ustr,
1116 missing: Vec<(u64, u64)>,
1117}
1118
1119enum RequestLeg {
1120 Catalog {
1121 request: RequestCommand,
1122 catalog: Ustr,
1123 },
1124 Client(RequestCommand),
1125 Response(DataResponse),
1126}
1127
1128impl RequestLeg {
1129 fn request_id(&self) -> UUID4 {
1130 match self {
1131 Self::Catalog { request, .. } | Self::Client(request) => *request.request_id(),
1132 Self::Response(response) => *response.correlation_id(),
1133 }
1134 }
1135}
1136
1137fn with_request_instrument_ids(parent: &RequestCommand, ids: Vec<InstrumentId>) -> RequestCommand {
1138 let mut request = parent.clone();
1139 match &mut request {
1140 RequestCommand::Quotes(r) => r.instrument_ids = ids,
1141 RequestCommand::Trades(r) => r.instrument_ids = ids,
1142 _ => unreachable!("catalog batch planning only supports quotes and trades"),
1143 }
1144
1145 request
1146}
1147
1148pub(super) fn is_date_range_variant(req: &RequestCommand) -> bool {
1149 matches!(
1150 req,
1151 RequestCommand::Data(_)
1152 | RequestCommand::Instrument(_)
1153 | RequestCommand::Instruments(_)
1154 | RequestCommand::Quotes(_)
1155 | RequestCommand::Trades(_)
1156 | RequestCommand::FundingRates(_)
1157 | RequestCommand::Bars(_)
1158 | RequestCommand::BookDeltas(_)
1159 | RequestCommand::BookDepth(_)
1160 )
1161}
1162
1163fn request_identifier(req: &RequestCommand) -> Option<RequestCatalogKey> {
1164 match req {
1165 RequestCommand::Data(cmd) => Some(RequestCatalogKey {
1166 data_type: NautilusDataType::Custom {
1167 type_name: cmd.data_type.type_name().to_string(),
1168 },
1169 identifier: cmd.data_type.identifier().map(String::from),
1170 }),
1171 RequestCommand::Quotes(cmd) => Some(RequestCatalogKey::new(
1172 NautilusDataType::QuoteTick,
1173 Some(cmd.single_instrument_id().ok()?.to_string()),
1174 )),
1175 RequestCommand::Trades(cmd) => Some(RequestCatalogKey::new(
1176 NautilusDataType::TradeTick,
1177 Some(cmd.single_instrument_id().ok()?.to_string()),
1178 )),
1179 RequestCommand::FundingRates(cmd) => Some(RequestCatalogKey::new(
1180 NautilusDataType::FundingRateUpdate,
1181 Some(cmd.single_instrument_id().ok()?.to_string()),
1182 )),
1183 RequestCommand::Bars(cmd) => Some(RequestCatalogKey::new(
1184 NautilusDataType::Bar,
1185 Some(cmd.single_bar_type().ok()?.to_string()),
1186 )),
1187 RequestCommand::BookDeltas(cmd) => Some(RequestCatalogKey::new(
1188 NautilusDataType::OrderBookDelta,
1189 Some(cmd.single_instrument_id().ok()?.to_string()),
1190 )),
1191 RequestCommand::BookDepth(cmd) => Some(RequestCatalogKey::new(
1192 NautilusDataType::OrderBookDepth,
1193 Some(cmd.single_instrument_id().ok()?.to_string()),
1194 )),
1195 _ => None,
1196 }
1197}
1198
1199impl RequestCatalogKey {
1200 fn new(data_type: NautilusDataType, identifier: Option<String>) -> Self {
1201 Self {
1202 data_type,
1203 identifier,
1204 }
1205 }
1206
1207 fn catalog_data_type(&self) -> NautilusDataType {
1208 self.data_type.clone()
1209 }
1210}
1211
1212fn catalog_missing_intervals(
1213 catalog: &mut DataCatalog,
1214 start: u64,
1215 end: u64,
1216 key: &RequestCatalogKey,
1217) -> anyhow::Result<Vec<(u64, u64)>> {
1218 catalog.get_missing_intervals_for_request(
1219 UnixNanos::from(start),
1220 UnixNanos::from(end),
1221 key.catalog_data_type(),
1222 key.identifier.as_deref(),
1223 )
1224}
1225
1226fn quote_ticks_from_query_result(data: DataBatch) -> anyhow::Result<BatchView<QuoteTick>> {
1227 match data {
1228 DataBatch::Quote(data) => Ok(data),
1229 _ => anyhow::bail!("expected a Quote batch, found another data batch variant"),
1230 }
1231}
1232
1233fn trade_ticks_from_query_result(data: DataBatch) -> anyhow::Result<BatchView<TradeTick>> {
1234 match data {
1235 DataBatch::Trade(data) => Ok(data),
1236 _ => anyhow::bail!("expected a Trade batch, found another data batch variant"),
1237 }
1238}
1239
1240fn funding_rates_from_query_result(
1241 data: DataBatch,
1242) -> anyhow::Result<BatchView<FundingRateUpdate>> {
1243 match data {
1244 DataBatch::FundingRate(data) => Ok(data),
1245 _ => anyhow::bail!("expected a FundingRate batch, found another data batch variant"),
1246 }
1247}
1248
1249fn bars_from_query_result(data: DataBatch) -> anyhow::Result<BatchView<Bar>> {
1250 match data {
1251 DataBatch::Bar(data) => Ok(data),
1252 _ => anyhow::bail!("expected a Bar batch, found another data batch variant"),
1253 }
1254}
1255
1256fn order_book_deltas_from_query_result(
1257 data: DataBatch,
1258) -> anyhow::Result<BatchView<OrderBookDelta>> {
1259 match data {
1260 DataBatch::BookDelta(data) => Ok(data),
1261 _ => anyhow::bail!("expected a BookDelta batch, found another data batch variant"),
1262 }
1263}
1264
1265fn order_book_depths_from_query_result(
1266 data: DataBatch,
1267) -> anyhow::Result<BatchView<OrderBookDepth>> {
1268 match data {
1269 DataBatch::BookDepth(data) => Ok(data),
1270 _ => anyhow::bail!("expected a BookDepth batch, found another data batch variant"),
1271 }
1272}
1273
1274fn request_limit(request: &RequestCommand) -> Option<std::num::NonZeroUsize> {
1275 match request {
1276 RequestCommand::Quotes(r) => r.limit,
1277 RequestCommand::Trades(r) => r.limit,
1278 RequestCommand::Bars(r) => r.limit,
1279 RequestCommand::FundingRates(r) => r.limit,
1280 RequestCommand::BookDeltas(r) => r.limit,
1281 RequestCommand::BookDepth(r) => r.limit,
1282 RequestCommand::Data(r) => r.limit,
1283 _ => None,
1284 }
1285}
1286
1287fn request_has_schema(request: &RequestCommand) -> bool {
1288 matches!(
1289 request,
1290 RequestCommand::Quotes(_) | RequestCommand::Trades(_)
1291 ) && request_params(request).is_some_and(|params| params.contains_key("schema"))
1292}
1293
1294fn request_contains_batch(
1295 request: &RequestCommand,
1296 identifier: Option<&str>,
1297 batch: &DataBatch,
1298) -> bool {
1299 let contains = |ids: &[InstrumentId]| {
1300 identifier
1301 .and_then(|value| value.parse::<InstrumentId>().ok())
1302 .is_some_and(|id| ids.contains(&id))
1303 };
1304
1305 match (request, batch) {
1306 (RequestCommand::Quotes(r), DataBatch::Quote(_)) => contains(&r.instrument_ids),
1307 (RequestCommand::Trades(r), DataBatch::Trade(_)) => contains(&r.instrument_ids),
1308 (RequestCommand::BookDeltas(r), DataBatch::BookDelta(rows)) => {
1309 contains(&r.instrument_ids)
1310 && rows
1311 .iter()
1312 .all(|row| r.instrument_ids.contains(&row.instrument_id))
1313 }
1314 (RequestCommand::BookDepth(r), DataBatch::BookDepth(rows)) => {
1315 contains(&r.instrument_ids)
1316 && rows
1317 .iter()
1318 .all(|row| r.instrument_ids.contains(&row.instrument_id))
1319 }
1320 (RequestCommand::FundingRates(r), DataBatch::FundingRate(rows)) => {
1321 contains(&r.instrument_ids)
1322 && rows
1323 .iter()
1324 .all(|row| r.instrument_ids.contains(&row.instrument_id))
1325 }
1326 (RequestCommand::Bars(r), DataBatch::Bar(rows)) => {
1327 identifier
1328 .and_then(|value| value.parse::<BarType>().ok())
1329 .is_some_and(|bar_type| r.bar_types.contains(&bar_type))
1330 && rows.iter().all(|row| r.bar_types.contains(&row.bar_type))
1331 }
1332 (RequestCommand::Data(_), DataBatch::Custom(_)) => {
1333 request_identifier(request).is_some_and(|key| key.identifier.as_deref() == identifier)
1334 }
1335 _ => false,
1336 }
1337}
1338
1339fn response_instrument_ids(
1340 requested: &[InstrumentId],
1341 actual: impl Iterator<Item = InstrumentId>,
1342) -> Vec<InstrumentId> {
1343 let actual = actual.collect::<AHashSet<_>>();
1344 if actual.iter().all(|id| requested.contains(id)) {
1345 return requested.to_vec();
1346 }
1347
1348 let mut ids = actual.into_iter().collect::<Vec<_>>();
1349 ids.sort_unstable();
1350 ids
1351}
1352
1353fn request_start(req: &RequestCommand) -> Option<Timestamp> {
1354 match req {
1355 RequestCommand::Data(cmd) => cmd.start,
1356 RequestCommand::Instrument(cmd) => cmd.start,
1357 RequestCommand::Instruments(cmd) => cmd.start,
1358 RequestCommand::Quotes(cmd) => cmd.start,
1359 RequestCommand::Trades(cmd) => cmd.start,
1360 RequestCommand::FundingRates(cmd) => cmd.start,
1361 RequestCommand::Bars(cmd) => cmd.start,
1362 RequestCommand::BookDeltas(cmd) => cmd.start,
1363 RequestCommand::BookDepth(cmd) => cmd.start,
1364 _ => None,
1365 }
1366}
1367
1368fn request_end(req: &RequestCommand) -> Option<Timestamp> {
1369 match req {
1370 RequestCommand::Data(cmd) => cmd.end,
1371 RequestCommand::Instrument(cmd) => cmd.end,
1372 RequestCommand::Instruments(cmd) => cmd.end,
1373 RequestCommand::Quotes(cmd) => cmd.end,
1374 RequestCommand::Trades(cmd) => cmd.end,
1375 RequestCommand::FundingRates(cmd) => cmd.end,
1376 RequestCommand::Bars(cmd) => cmd.end,
1377 RequestCommand::BookDeltas(cmd) => cmd.end,
1378 RequestCommand::BookDepth(cmd) => cmd.end,
1379 _ => None,
1380 }
1381}
1382
1383fn request_filter_expr(params: Option<&Params>) -> Option<String> {
1384 params
1385 .and_then(|params| params.get_str("filter_expr"))
1386 .filter(|filter_expr| !filter_expr.is_empty())
1387 .map(ToString::to_string)
1388}
1389
1390fn bound_request_dates(
1391 start: Option<Timestamp>,
1392 end: Option<Timestamp>,
1393 now: Timestamp,
1394 query_past_data: bool,
1395) -> (Timestamp, Timestamp) {
1396 let zero = Timestamp::UNIX_EPOCH;
1397 let mut start = start.unwrap_or(zero);
1398 let mut end = end.unwrap_or(now);
1399
1400 if query_past_data {
1401 if start > now {
1402 start = now;
1403 }
1404
1405 if end > now {
1406 end = now;
1407 }
1408 }
1409
1410 (start, end)
1411}
1412
1413fn datetime_to_unix_nanos_or_zero(dt: Timestamp) -> UnixNanos {
1414 UnixNanos::from(u64::try_from(dt.as_nanosecond().max(0)).unwrap_or(0))
1415}
1416
1417fn floor_to_utc_day(dt: Timestamp) -> Timestamp {
1418 let midnight = jiff::tz::Offset::UTC.to_datetime(dt).date().at(0, 0, 0, 0);
1419 jiff::tz::Offset::UTC
1420 .to_timestamp(midnight)
1421 .expect("midnight UTC is always valid")
1422}
1423
1424fn with_dates_for_pipeline(
1425 req: &RequestCommand,
1426 start: Option<Timestamp>,
1427 end: Option<Timestamp>,
1428 ts_init: UnixNanos,
1429) -> RequestCommand {
1430 let new_id = UUID4::new();
1431
1432 match req {
1433 RequestCommand::Quotes(cmd) => RequestCommand::Quotes(RequestQuotes {
1434 instrument_ids: cmd.instrument_ids.clone(),
1435 start,
1436 end,
1437 limit: cmd.limit,
1438 client_id: cmd.client_id,
1439 request_id: new_id,
1440 ts_init,
1441 params: cmd.params.clone(),
1442 }),
1443 RequestCommand::Trades(cmd) => RequestCommand::Trades(RequestTrades {
1444 instrument_ids: cmd.instrument_ids.clone(),
1445 start,
1446 end,
1447 limit: cmd.limit,
1448 client_id: cmd.client_id,
1449 request_id: new_id,
1450 ts_init,
1451 params: cmd.params.clone(),
1452 }),
1453 RequestCommand::FundingRates(cmd) => RequestCommand::FundingRates(RequestFundingRates {
1454 instrument_ids: cmd.instrument_ids.clone(),
1455 start,
1456 end,
1457 limit: cmd.limit,
1458 client_id: cmd.client_id,
1459 request_id: new_id,
1460 ts_init,
1461 params: cmd.params.clone(),
1462 }),
1463 RequestCommand::BookDeltas(cmd) => RequestCommand::BookDeltas(RequestBookDeltas {
1464 instrument_ids: cmd.instrument_ids.clone(),
1465 start,
1466 end,
1467 limit: cmd.limit,
1468 client_id: cmd.client_id,
1469 request_id: new_id,
1470 ts_init,
1471 params: cmd.params.clone(),
1472 }),
1473 RequestCommand::BookDepth(cmd) => RequestCommand::BookDepth(RequestBookDepth {
1474 instrument_ids: cmd.instrument_ids.clone(),
1475 start,
1476 end,
1477 limit: cmd.limit,
1478 depth: cmd.depth,
1479 client_id: cmd.client_id,
1480 request_id: new_id,
1481 ts_init,
1482 params: cmd.params.clone(),
1483 }),
1484 RequestCommand::Data(cmd) => RequestCommand::Data(RequestCustomData {
1485 client_id: cmd.client_id,
1486 data_type: cmd.data_type.clone(),
1487 start,
1488 end,
1489 limit: cmd.limit,
1490 request_id: new_id,
1491 ts_init,
1492 params: cmd.params.clone(),
1493 }),
1494 RequestCommand::Bars(cmd) => RequestCommand::Bars(RequestBars {
1495 bar_types: cmd.bar_types.clone(),
1496 start,
1497 end,
1498 limit: cmd.limit,
1499 client_id: cmd.client_id,
1500 request_id: new_id,
1501 ts_init,
1502 params: cmd.params.clone(),
1503 }),
1504 _ => req.clone(),
1508 }
1509}
1510
1511fn build_empty_response(
1512 req: &RequestCommand,
1513 start: UnixNanos,
1514 end: UnixNanos,
1515 used_client_id: Option<ClientId>,
1516 ts_init: UnixNanos,
1517) -> anyhow::Result<DataResponse> {
1518 let response = match req {
1519 RequestCommand::Data(cmd) => DataResponse::Data(CustomDataResponse::new(
1520 cmd.request_id,
1521 cmd.client_id,
1522 None,
1523 cmd.data_type.clone(),
1524 Vec::<CustomData>::new(),
1525 Some(start),
1526 Some(end),
1527 ts_init,
1528 cmd.params.clone(),
1529 )),
1530 RequestCommand::Quotes(cmd) => DataResponse::Quotes(QuotesResponse::new(
1531 cmd.request_id,
1532 resolve_response_client_id(cmd.client_id, used_client_id),
1533 cmd.instrument_ids.clone(),
1534 Vec::new(),
1535 Some(start),
1536 Some(end),
1537 ts_init,
1538 cmd.params.clone(),
1539 )),
1540 RequestCommand::Trades(cmd) => DataResponse::Trades(TradesResponse::new(
1541 cmd.request_id,
1542 resolve_response_client_id(cmd.client_id, used_client_id),
1543 cmd.instrument_ids.clone(),
1544 Vec::new(),
1545 Some(start),
1546 Some(end),
1547 ts_init,
1548 cmd.params.clone(),
1549 )),
1550 RequestCommand::FundingRates(cmd) => DataResponse::FundingRates(FundingRatesResponse::new(
1551 cmd.request_id,
1552 resolve_response_client_id(cmd.client_id, used_client_id),
1553 cmd.single_instrument_id()?,
1554 Vec::new(),
1555 Some(start),
1556 Some(end),
1557 ts_init,
1558 cmd.params.clone(),
1559 )),
1560 RequestCommand::Bars(cmd) => DataResponse::Bars(BarsResponse::new(
1561 cmd.request_id,
1562 resolve_response_client_id(cmd.client_id, used_client_id),
1563 cmd.single_bar_type()?,
1564 Vec::new(),
1565 Some(start),
1566 Some(end),
1567 ts_init,
1568 cmd.params.clone(),
1569 )),
1570 RequestCommand::BookDeltas(cmd) => DataResponse::BookDeltas(BookDeltasResponse::new(
1571 cmd.request_id,
1572 resolve_response_client_id(cmd.client_id, used_client_id),
1573 cmd.single_instrument_id()?,
1574 Vec::new(),
1575 Some(start),
1576 Some(end),
1577 ts_init,
1578 cmd.params.clone(),
1579 )),
1580 RequestCommand::BookDepth(cmd) => DataResponse::BookDepth(BookDepthResponse::new(
1581 cmd.request_id,
1582 resolve_response_client_id(cmd.client_id, used_client_id),
1583 cmd.single_instrument_id()?,
1584 Vec::new(),
1585 Some(start),
1586 Some(end),
1587 ts_init,
1588 cmd.params.clone(),
1589 )),
1590 _ => {
1591 anyhow::bail!("Cannot build empty catalog response for non-catalog-eligible request")
1592 }
1593 };
1594
1595 Ok(response)
1596}
1597
1598fn build_quotes_catalog_response(
1599 cmd: &RequestQuotes,
1600 data: BatchView<QuoteTick>,
1601 start: UnixNanos,
1602 end: UnixNanos,
1603 used_client_id: Option<ClientId>,
1604 ts_init: UnixNanos,
1605) -> DataResponse {
1606 let params = catalog_response_params(cmd.params.as_ref());
1607 DataResponse::Quotes(QuotesResponse::new(
1608 cmd.request_id,
1609 resolve_response_client_id(cmd.client_id, used_client_id),
1610 cmd.instrument_ids.clone(),
1611 data,
1612 Some(start),
1613 Some(end),
1614 ts_init,
1615 Some(params),
1616 ))
1617}
1618
1619fn build_trades_catalog_response(
1620 cmd: &RequestTrades,
1621 data: BatchView<TradeTick>,
1622 start: UnixNanos,
1623 end: UnixNanos,
1624 used_client_id: Option<ClientId>,
1625 ts_init: UnixNanos,
1626) -> DataResponse {
1627 let params = catalog_response_params(cmd.params.as_ref());
1628 DataResponse::Trades(TradesResponse::new(
1629 cmd.request_id,
1630 resolve_response_client_id(cmd.client_id, used_client_id),
1631 cmd.instrument_ids.clone(),
1632 data,
1633 Some(start),
1634 Some(end),
1635 ts_init,
1636 Some(params),
1637 ))
1638}
1639
1640fn build_funding_rates_catalog_response(
1641 cmd: &RequestFundingRates,
1642 data: BatchView<FundingRateUpdate>,
1643 start: UnixNanos,
1644 end: UnixNanos,
1645 used_client_id: Option<ClientId>,
1646 ts_init: UnixNanos,
1647) -> anyhow::Result<DataResponse> {
1648 let params = catalog_response_params(cmd.params.as_ref());
1649 Ok(DataResponse::FundingRates(FundingRatesResponse::new(
1650 cmd.request_id,
1651 resolve_response_client_id(cmd.client_id, used_client_id),
1652 cmd.single_instrument_id()?,
1653 data,
1654 Some(start),
1655 Some(end),
1656 ts_init,
1657 Some(params),
1658 )))
1659}
1660
1661fn build_bars_catalog_response(
1662 cmd: &RequestBars,
1663 data: BatchView<Bar>,
1664 start: UnixNanos,
1665 end: UnixNanos,
1666 used_client_id: Option<ClientId>,
1667 ts_init: UnixNanos,
1668) -> anyhow::Result<DataResponse> {
1669 let params = catalog_response_params(cmd.params.as_ref());
1670 Ok(DataResponse::Bars(BarsResponse::new(
1671 cmd.request_id,
1672 resolve_response_client_id(cmd.client_id, used_client_id),
1673 cmd.single_bar_type()?,
1674 data,
1675 Some(start),
1676 Some(end),
1677 ts_init,
1678 Some(params),
1679 )))
1680}
1681
1682fn build_custom_data_catalog_response(
1683 cmd: &RequestCustomData,
1684 data: Vec<CustomData>,
1685 start: UnixNanos,
1686 end: UnixNanos,
1687 ts_init: UnixNanos,
1688) -> DataResponse {
1689 let params = catalog_response_params(cmd.params.as_ref());
1690 DataResponse::Data(CustomDataResponse::new(
1691 cmd.request_id,
1692 cmd.client_id,
1693 None,
1694 cmd.data_type.clone(),
1695 data,
1696 Some(start),
1697 Some(end),
1698 ts_init,
1699 Some(params),
1700 ))
1701}
1702
1703fn build_book_deltas_catalog_response(
1704 cmd: &RequestBookDeltas,
1705 data: BatchView<OrderBookDelta>,
1706 start: UnixNanos,
1707 end: UnixNanos,
1708 used_client_id: Option<ClientId>,
1709 ts_init: UnixNanos,
1710) -> anyhow::Result<DataResponse> {
1711 let params = catalog_response_params(cmd.params.as_ref());
1712 Ok(DataResponse::BookDeltas(BookDeltasResponse::new(
1713 cmd.request_id,
1714 resolve_response_client_id(cmd.client_id, used_client_id),
1715 cmd.single_instrument_id()?,
1716 data,
1717 Some(start),
1718 Some(end),
1719 ts_init,
1720 Some(params),
1721 )))
1722}
1723
1724fn build_book_depth_catalog_response(
1725 cmd: &RequestBookDepth,
1726 data: BatchView<OrderBookDepth>,
1727 start: UnixNanos,
1728 end: UnixNanos,
1729 used_client_id: Option<ClientId>,
1730 ts_init: UnixNanos,
1731) -> anyhow::Result<DataResponse> {
1732 let params = catalog_response_params(cmd.params.as_ref());
1733 Ok(DataResponse::BookDepth(BookDepthResponse::new(
1734 cmd.request_id,
1735 resolve_response_client_id(cmd.client_id, used_client_id),
1736 cmd.single_instrument_id()?,
1737 data,
1738 Some(start),
1739 Some(end),
1740 ts_init,
1741 Some(params),
1742 )))
1743}
1744
1745fn catalog_response_params(existing: Option<&Params>) -> Params {
1746 let mut params = existing
1747 .map(|params| IndexMap::clone(params))
1748 .unwrap_or_default();
1749 params.insert(PARAM_UPDATE_CATALOG.to_string(), Value::Bool(false));
1750 Params::new(params)
1751}
1752
1753fn custom_data_from_query_result(data: DataBatch) -> anyhow::Result<Vec<CustomData>> {
1754 CustomData::from_batch(data)
1755}
1756
1757fn instrument_only_last(params: Option<&Params>) -> bool {
1758 params
1759 .and_then(|params| params.get_bool("only_last"))
1760 .unwrap_or(true)
1761}
1762
1763fn latest_instruments(data: Vec<InstrumentAny>) -> Vec<InstrumentAny> {
1764 let mut instruments: AHashMap<_, InstrumentAny> = AHashMap::new();
1765
1766 for instrument in data {
1767 let id = instrument.id();
1768 match instruments.get(&id) {
1769 Some(existing) if existing.ts_init() >= instrument.ts_init() => {}
1770 _ => {
1771 instruments.insert(id, instrument);
1772 }
1773 }
1774 }
1775
1776 let mut data: Vec<_> = instruments.into_values().collect();
1777 data.sort_by_key(|instrument| instrument.id().to_string());
1778 data
1779}
1780
1781fn instrument_response_venue(request_venue: Option<Venue>, data: &[InstrumentAny]) -> Venue {
1782 request_venue.unwrap_or_else(|| {
1783 data.iter()
1784 .map(Instrument::venue)
1785 .min_by_key(std::string::ToString::to_string)
1786 .unwrap_or_else(|| Venue::from(CATALOG_CLIENT_ID))
1787 })
1788}
1789
1790fn resolve_response_client_id(
1791 request_client_id: Option<ClientId>,
1792 used_client_id: Option<ClientId>,
1793) -> ClientId {
1794 request_client_id
1795 .or(used_client_id)
1796 .unwrap_or_else(|| ClientId::new(CATALOG_CLIENT_ID))
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801 use std::sync::Arc;
1802
1803 use nautilus_common::messages::data::RequestJoin;
1804 use rstest::rstest;
1805
1806 use super::*;
1807
1808 #[rstest]
1809 #[case::full(0, 3)]
1810 #[case::subrange(1, 2)]
1811 fn test_catalog_quote_response_uses_selected_batch_view(
1812 #[case] start: usize,
1813 #[case] end: usize,
1814 ) {
1815 let first = QuoteTick {
1816 ts_init: UnixNanos::from(11),
1817 ..Default::default()
1818 };
1819
1820 let mut second = first;
1821 second.ts_init = UnixNanos::from(23);
1822 let mut third = first;
1823 third.ts_init = UnixNanos::from(37);
1824 let original = Arc::new(vec![first, second, third]);
1825 let result = quote_ticks_from_query_result(DataBatch::Quote(BatchView::new(
1826 Arc::clone(&original),
1827 start..end,
1828 )))
1829 .unwrap();
1830 assert_eq!(result.as_ref(), &original[start..end]);
1831 assert_eq!(result.range(), start..end);
1832 assert!(Arc::ptr_eq(result.arc(), &original));
1833 }
1834
1835 #[rstest]
1836 fn test_response_batches_preserves_narrowed_payloads(#[values(false, true)] empty: bool) {
1837 use nautilus_model::data::stubs::{stub_delta, stub_depth10};
1838
1839 macro_rules! check {
1840 ($response:ident, $variant:ident, $batch:ident, $key:expr, $seed:expr) => {{
1841 let key = $key;
1842 let mut row = $seed;
1843 row.ts_init = UnixNanos::from(23);
1844 let mut before = row.clone();
1845 before.ts_init = UnixNanos::from(11);
1846 let mut after = row.clone();
1847 after.ts_init = UnixNanos::from(37);
1848 let backing = Arc::new(vec![before, row.clone(), after]);
1849 let end = if empty { 1 } else { 2 };
1850 let response = DataResponse::$variant($response::new(
1851 UUID4::new(),
1852 ClientId::from("SOURCE"),
1853 key,
1854 BatchView::new(Arc::clone(&backing), 1..end),
1855 None,
1856 None,
1857 UnixNanos::from(41),
1858 None,
1859 ));
1860
1861 let batches = response_batches(&response).unwrap();
1862
1863 assert_eq!(batches.len(), 1);
1864 assert_eq!(batches[0].0.as_deref(), Some(key.to_string().as_str()));
1865 let DataBatch::$batch(view) = &batches[0].1 else {
1866 panic!("Unexpected batch {:?}", batches[0].1);
1867 };
1868 assert_eq!(view.range(), 1..end);
1869 assert!(Arc::ptr_eq(view.arc(), &backing));
1870 assert_eq!(
1871 view.as_ref(),
1872 if empty {
1873 &[]
1874 } else {
1875 std::slice::from_ref(&row)
1876 }
1877 );
1878 }};
1879 }
1880
1881 let delta = stub_delta();
1882 let depth = stub_depth10();
1883 let quote = QuoteTick::default();
1884 let trade = TradeTick::default();
1885 let bar = Bar::default();
1886
1887 let funding = FundingRateUpdate::new(
1888 quote.instrument_id,
1889 "0.001".parse().unwrap(),
1890 Some(60),
1891 Some(UnixNanos::from(47)),
1892 UnixNanos::from(17),
1893 UnixNanos::from(23),
1894 );
1895
1896 check!(
1897 BookDeltasResponse,
1898 BookDeltas,
1899 BookDelta,
1900 delta.instrument_id,
1901 delta
1902 );
1903 check!(
1904 BookDepthResponse,
1905 BookDepth,
1906 BookDepth,
1907 depth.instrument_id,
1908 depth
1909 );
1910 check!(QuotesResponse, Quotes, Quote, quote.instrument_id, quote);
1911 check!(TradesResponse, Trades, Trade, trade.instrument_id, trade);
1912 check!(
1913 FundingRatesResponse,
1914 FundingRates,
1915 FundingRate,
1916 funding.instrument_id,
1917 funding
1918 );
1919 check!(BarsResponse, Bars, Bar, bar.bar_type, bar);
1920 }
1921
1922 #[rstest]
1923 #[case::deltas(NautilusDataType::OrderBookDelta)]
1924 #[case::depth(NautilusDataType::OrderBookDepth)]
1925 #[case::quotes(NautilusDataType::QuoteTick)]
1926 #[case::trades(NautilusDataType::TradeTick)]
1927 #[case::bars(NautilusDataType::Bar)]
1928 #[case::funding(NautilusDataType::FundingRateUpdate)]
1929 fn test_request_contains_batch_uses_visible_requested_members(
1930 #[case] kind: NautilusDataType,
1931 #[values(false, true)] remapped: bool,
1932 #[values(false, true)] empty: bool,
1933 ) {
1934 use nautilus_model::data::stubs::{stub_delta, stub_depth10};
1935
1936 let requested = InstrumentId::from("AUD/USD.SIM");
1937
1938 let actual = if remapped {
1939 InstrumentId::from("GBP/USD.SIM")
1940 } else {
1941 requested
1942 };
1943
1944 let requested_bar = format!("{requested}-1-MINUTE-LAST-EXTERNAL")
1945 .parse::<BarType>()
1946 .unwrap();
1947 let actual_bar = format!("{actual}-1-MINUTE-LAST-EXTERNAL")
1948 .parse::<BarType>()
1949 .unwrap();
1950
1951 macro_rules! request {
1952 ($variant:ident, $request:ident, $id:expr $(, $depth:expr)?) => {
1953 RequestCommand::$variant($request::new(
1954 $id, None, None, None, $($depth,)? None, UUID4::new(), UnixNanos::from(37), None,
1955 ))
1956 };
1957 }
1958
1959 macro_rules! response {
1960 ($variant:ident, $response:ident, $key:expr, $row:expr) => {{
1961 let data = BatchView::new(Arc::new(vec![$row]), 0..usize::from(!empty));
1962 DataResponse::$variant($response::new(
1963 UUID4::new(),
1964 ClientId::from("SOURCE"),
1965 $key,
1966 data,
1967 None,
1968 None,
1969 UnixNanos::from(41),
1970 None,
1971 ))
1972 }};
1973 }
1974
1975 let (request, response) = match kind {
1976 NautilusDataType::OrderBookDelta => {
1977 let row = OrderBookDelta {
1978 instrument_id: actual,
1979 ..stub_delta()
1980 };
1981
1982 (
1983 request!(BookDeltas, RequestBookDeltas, requested),
1984 response!(BookDeltas, BookDeltasResponse, requested, row),
1985 )
1986 }
1987 NautilusDataType::OrderBookDepth => {
1988 let row = OrderBookDepth {
1989 instrument_id: actual,
1990 ..stub_depth10()
1991 };
1992
1993 (
1994 request!(BookDepth, RequestBookDepth, requested, None),
1995 response!(BookDepth, BookDepthResponse, requested, row),
1996 )
1997 }
1998 NautilusDataType::QuoteTick => {
1999 let row = QuoteTick {
2000 instrument_id: actual,
2001 ..Default::default()
2002 };
2003
2004 (
2005 request!(Quotes, RequestQuotes, requested),
2006 response!(Quotes, QuotesResponse, requested, row),
2007 )
2008 }
2009 NautilusDataType::TradeTick => {
2010 let row = TradeTick {
2011 instrument_id: actual,
2012 ..Default::default()
2013 };
2014
2015 (
2016 request!(Trades, RequestTrades, requested),
2017 response!(Trades, TradesResponse, requested, row),
2018 )
2019 }
2020 NautilusDataType::Bar => {
2021 let row = Bar {
2022 bar_type: actual_bar,
2023 ..Default::default()
2024 };
2025
2026 (
2027 request!(Bars, RequestBars, requested_bar),
2028 response!(Bars, BarsResponse, requested_bar, row),
2029 )
2030 }
2031 NautilusDataType::FundingRateUpdate => {
2032 let row = FundingRateUpdate::new(
2033 actual,
2034 "0.001".parse().unwrap(),
2035 Some(60),
2036 Some(UnixNanos::from(47)),
2037 UnixNanos::from(17),
2038 UnixNanos::from(23),
2039 );
2040 (
2041 request!(FundingRates, RequestFundingRates, requested),
2042 response!(FundingRates, FundingRatesResponse, requested, row),
2043 )
2044 }
2045 _ => panic!("unexpected data type"),
2046 };
2047
2048 let batches = response_batches(&response).unwrap();
2049 let (identifier, batch) = &batches[0];
2050
2051 assert_eq!(batches.len(), 1);
2052 assert_eq!(
2053 request_contains_batch(&request, identifier.as_deref(), batch),
2054 empty || !remapped,
2055 );
2056 }
2057
2058 #[rstest]
2059 #[case::quotes(true)]
2060 #[case::trades(false)]
2061 fn test_response_batches_keep_expanded_parent_instruments(#[case] quotes: bool) {
2062 let requested = [
2063 InstrumentId::from("ES.FUT.GLBX"),
2064 InstrumentId::from("NQ.FUT.GLBX"),
2065 ];
2066 let first = InstrumentId::from("ESH24.GLBX");
2067 let second = InstrumentId::from("NQH24.GLBX");
2068
2069 let mut response = if quotes {
2070 let rows = vec![
2071 QuoteTick {
2072 instrument_id: first,
2073 ..Default::default()
2074 },
2075 QuoteTick {
2076 instrument_id: second,
2077 ..Default::default()
2078 },
2079 ];
2080 DataResponse::Quotes(QuotesResponse::new(
2081 UUID4::new(),
2082 ClientId::from("SOURCE"),
2083 requested,
2084 rows,
2085 None,
2086 None,
2087 UnixNanos::from(3),
2088 None,
2089 ))
2090 } else {
2091 let rows = vec![
2092 TradeTick {
2093 instrument_id: first,
2094 ..Default::default()
2095 },
2096 TradeTick {
2097 instrument_id: second,
2098 ..Default::default()
2099 },
2100 ];
2101 DataResponse::Trades(TradesResponse::new(
2102 UUID4::new(),
2103 ClientId::from("SOURCE"),
2104 requested,
2105 rows,
2106 None,
2107 None,
2108 UnixNanos::from(3),
2109 None,
2110 ))
2111 };
2112
2113 macro_rules! narrow {
2114 ($response:expr, $row:ident) => {{
2115 let mut rows = $response.data.to_vec();
2116 let hidden = $row {
2117 instrument_id: InstrumentId::from("HIDDEN.GLBX"),
2118 ..Default::default()
2119 };
2120 rows.insert(0, hidden);
2121 rows.push(hidden);
2122 $response.data = BatchView::new(Arc::new(rows), 1..3);
2123 }};
2124 }
2125
2126 match &mut response {
2127 DataResponse::Quotes(r) => narrow!(r, QuoteTick),
2128 DataResponse::Trades(r) => narrow!(r, TradeTick),
2129 _ => unreachable!(),
2130 }
2131
2132 let batches = response_batches(&response).unwrap();
2133
2134 assert_eq!(batches.len(), 2);
2135
2136 for ((identifier, batch), id) in batches.iter().zip([first, second]) {
2137 assert_eq!(identifier.as_deref(), Some(id.to_string().as_str()));
2138
2139 match batch {
2140 DataBatch::Quote(rows) => assert_eq!(
2141 &rows[..],
2142 &[QuoteTick {
2143 instrument_id: id,
2144 ..Default::default()
2145 }]
2146 ),
2147 DataBatch::Trade(rows) => assert_eq!(
2148 &rows[..],
2149 &[TradeTick {
2150 instrument_id: id,
2151 ..Default::default()
2152 }]
2153 ),
2154 other => panic!("Unexpected batch {other:?}"),
2155 }
2156 }
2157 }
2158
2159 #[rstest]
2160 fn test_build_empty_response_rejects_non_catalog_variant() {
2161 let request = RequestCommand::Join(RequestJoin::new(
2162 vec![UUID4::new()],
2163 None,
2164 None,
2165 UUID4::new(),
2166 UnixNanos::default(),
2167 None,
2168 None,
2169 ));
2170
2171 let result = build_empty_response(
2172 &request,
2173 UnixNanos::from(1u64),
2174 UnixNanos::from(2u64),
2175 None,
2176 UnixNanos::from(3u64),
2177 );
2178
2179 assert_eq!(
2180 result.unwrap_err().to_string(),
2181 "Cannot build empty catalog response for non-catalog-eligible request"
2182 );
2183 }
2184}