Skip to main content

nautilus_data/engine/
streaming.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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    /// Registers the `catalog` with the engine with an optional specific `name`.
56    ///
57    /// # Panics
58    ///
59    /// Panics if a catalog with the same `name` has already been registered.
60    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        // `make_path_custom_data` / `get_directory_intervals` are inherent on
190        // `ParquetDataCatalog` and not exposed through `Catalog`. Use the
191        // trait-level `query_last_timestamp` with `NautilusDataType::Custom` so the
192        // path works for any backend that implements the trait.
193        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    // Bounds the request window, walks the catalogs to find one whose missing
384    // intervals differ from the full requested range, then fans the parent out via
385    // the pipeline with one catalog leg plus one client leg per missing interval.
386    // With no catalog match and no resolvable client, the engine emits an empty
387    // response keyed by the parent request ID.
388    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        // Floor the catalog window to the UTC day boundary so the day-start F_SNAPSHOT frame is
435        // selected and read for the snapshot replay; client gaps keep the original window.
436        // The parent request keeps its original start, so the merged response trims back to it.
437        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            // Client legs fill only the requested window, not the pre-start range
458            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        // When `skip_catalog_data` is set the client must serve the full parent window;
481        // dropping the catalog leg without resetting the missing intervals would leave
482        // the catalog-covered range unanswered.
483        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            // Report the queried window: results sorted by ID cannot be trimmed by `ts_init`
1002            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        // `Join` and the non-date-range variants should never reach this path; the dispatcher
1505        // gates on `is_date_range_variant` first. Cloning preserves behavior if a caller
1506        // reaches this arm.
1507        _ => 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}