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