Skip to main content

nautilus_live/python/client/
responses.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
16//! Implements typed data responses that Python adapters construct to answer core requests.
17//!
18//! Provides PyO3 constructors and read-only payload access for historical data, instruments,
19//! and option-chain reference prices. Converts supported response objects into Rust data
20//! responses with their client identifiers for delivery through the live runner.
21
22use std::sync::Arc;
23
24use nautilus_common::{
25    messages::data::{
26        BarsResponse, BookDeltasResponse, BookDepthResponse, BookResponse, CustomDataResponse,
27        DataResponse, FundingRatesResponse, InstrumentResponse, InstrumentsResponse,
28        OptionChainReferencePriceResponse, QuotesResponse, TradesResponse,
29    },
30    python::actor::extract_instrument_ids,
31};
32use nautilus_core::{
33    UUID4,
34    python::{
35        params::{params_to_pydict, pydict_to_params},
36        to_pytype_err, to_pyvalue_err,
37    },
38};
39use nautilus_model::{
40    data::{
41        Bar, BarType, CustomData, DataType, FundingRateUpdate, OrderBookDelta, OrderBookDepth,
42        QuoteTick, TradeTick,
43    },
44    identifiers::{ClientId, InstrumentId, OptionSeriesId, Venue},
45    orderbook::OrderBook,
46    python::instruments::{instrument_any_to_pyobject, pyobject_to_instrument_any},
47    types::Price,
48};
49use pyo3::{prelude::*, types::PyDict};
50
51macro_rules! response {
52    ($wrapper:ident, $response:ident, $name:tt, $key:ident: $key_type:ty, $data:ty, $copy:ident) => {
53        response!($wrapper, $response, $name, $key: $key_type, $data, $key: $key_type, |value| PyResult::Ok(value), [], $copy);
54    };
55    ($wrapper:ident, $response:ident, $name:tt, $key:ident: $key_type:ty, $data:ty,
56     $input:ident: $input_type:ty, $convert:expr, [$($input_meta:meta),*], $copy:ident) => {
57        #[doc = concat!("An owned ", $name, " response.")]
58        #[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
59        #[pyclass(name = $name, module = "nautilus_trader.live", frozen, from_py_object)]
60        #[derive(Debug, Clone)]
61        pub struct $wrapper {
62            pub(crate) response: $response,
63        }
64
65        #[pyo3_stub_gen::derive::gen_stub_pymethods]
66        #[pymethods]
67        impl $wrapper {
68            #[new]
69            #[pyo3(signature = (client_id, $input, data, correlation_id, ts_init, start=None, end=None, params=None))]
70            #[expect(clippy::too_many_arguments, reason = "response fields cross the Python boundary together")]
71            fn py_new(py: Python<'_>, client_id: ClientId, $(#[$input_meta])* $input: $input_type, data: $data,
72                correlation_id: UUID4, ts_init: u64, start: Option<u64>, end: Option<u64>,
73                params: Option<Py<PyDict>>) -> PyResult<Self> {
74                let $key = ($convert)($input)?;
75                Ok(Self { response: $response {
76                    client_id, $key, data: data.into(), correlation_id, ts_init: ts_init.into(),
77                    start: start.map(Into::into), end: end.map(Into::into),
78                    params: params.as_ref().map(|params| pydict_to_params(py, params)).transpose()?.flatten(),
79                }})
80            }
81
82            #[getter]
83            fn client_id(&self) -> ClientId { self.response.client_id }
84
85            #[getter]
86            fn $key(&self) -> $key_type { self.response.$key.to_owned() }
87
88            #[getter]
89            fn data(&self) -> $data {
90                self.response.data.$copy()
91            }
92
93            #[getter]
94            fn correlation_id(&self) -> UUID4 { self.response.correlation_id }
95
96            #[getter]
97            fn ts_init(&self) -> u64 { self.response.ts_init.as_u64() }
98
99            #[getter]
100            fn start(&self) -> Option<u64> { self.response.start.map(|value| value.as_u64()) }
101
102            #[getter]
103            fn end(&self) -> Option<u64> { self.response.end.map(|value| value.as_u64()) }
104
105            #[getter]
106            fn params(&self, py: Python<'_>) -> PyResult<Option<Py<PyDict>>> {
107                self.response.params.as_ref().map(|params| params_to_pydict(py, params)).transpose()
108            }
109        }
110    };
111}
112
113response!(PyQuotesResponse, QuotesResponse, "QuotesResponse", instrument_ids: Vec<InstrumentId>, Vec<QuoteTick>, instrument_id: &Bound<'_, PyAny>, extract_instrument_ids, [gen_stub(override_type(type_repr = "model.InstrumentId | typing.Sequence[model.InstrumentId]"))], to_vec);
114response!(PyTradesResponse, TradesResponse, "TradesResponse", instrument_ids: Vec<InstrumentId>, Vec<TradeTick>, instrument_id: &Bound<'_, PyAny>, extract_instrument_ids, [gen_stub(override_type(type_repr = "model.InstrumentId | typing.Sequence[model.InstrumentId]"))], to_vec);
115response!(PyFundingRatesResponse, FundingRatesResponse, "FundingRatesResponse", instrument_id: InstrumentId, Vec<FundingRateUpdate>, to_vec);
116response!(PyBarsResponse, BarsResponse, "BarsResponse", bar_type: BarType, Vec<Bar>, to_vec);
117response!(PyBookDeltasResponse, BookDeltasResponse, "BookDeltasResponse", instrument_id: InstrumentId, Vec<OrderBookDelta>, to_vec);
118response!(PyBookDepthResponse, BookDepthResponse, "BookDepthResponse", instrument_id: InstrumentId, Vec<OrderBookDepth>, to_vec);
119response!(PyBookResponse, BookResponse, "BookResponse", instrument_id: InstrumentId, OrderBook, clone);
120
121#[pyo3_stub_gen::derive::gen_stub_pymethods]
122#[pymethods]
123impl PyQuotesResponse {
124    #[getter]
125    fn instrument_id(&self) -> PyResult<InstrumentId> {
126        self.response.single_instrument_id().map_err(to_pyvalue_err)
127    }
128}
129
130#[pyo3_stub_gen::derive::gen_stub_pymethods]
131#[pymethods]
132impl PyTradesResponse {
133    #[getter]
134    fn instrument_id(&self) -> PyResult<InstrumentId> {
135        self.response.single_instrument_id().map_err(to_pyvalue_err)
136    }
137}
138
139/// An owned `InstrumentResponse` response.
140#[pyclass(
141    name = "InstrumentResponse",
142    module = "nautilus_trader.live",
143    frozen,
144    from_py_object
145)]
146#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
147#[derive(Debug, Clone)]
148pub struct PyInstrumentResponse {
149    response: InstrumentResponse,
150}
151
152#[pyo3_stub_gen::derive::gen_stub_pymethods]
153#[pymethods]
154impl PyInstrumentResponse {
155    #[new]
156    #[pyo3(signature = (client_id, instrument_id, data, correlation_id, ts_init, start=None, end=None, params=None))]
157    #[expect(
158        clippy::too_many_arguments,
159        clippy::needless_pass_by_value,
160        reason = "response fields cross the Python boundary together"
161    )]
162    fn py_new(
163        py: Python<'_>,
164        client_id: ClientId,
165        instrument_id: InstrumentId,
166        data: Py<PyAny>,
167        correlation_id: UUID4,
168        ts_init: u64,
169        start: Option<u64>,
170        end: Option<u64>,
171        params: Option<Py<PyDict>>,
172    ) -> PyResult<Self> {
173        Ok(Self {
174            response: InstrumentResponse {
175                client_id,
176                instrument_id,
177                data: pyobject_to_instrument_any(py, data)?,
178                correlation_id,
179                ts_init: ts_init.into(),
180                start: start.map(Into::into),
181                end: end.map(Into::into),
182                params: params
183                    .as_ref()
184                    .map(|params| pydict_to_params(py, params))
185                    .transpose()?
186                    .flatten(),
187            },
188        })
189    }
190
191    #[getter]
192    fn client_id(&self) -> ClientId {
193        self.response.client_id
194    }
195
196    #[getter]
197    fn instrument_id(&self) -> InstrumentId {
198        self.response.instrument_id
199    }
200
201    #[getter]
202    fn data(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
203        instrument_any_to_pyobject(py, self.response.data.clone())
204    }
205
206    #[getter]
207    fn correlation_id(&self) -> UUID4 {
208        self.response.correlation_id
209    }
210
211    #[getter]
212    fn ts_init(&self) -> u64 {
213        self.response.ts_init.as_u64()
214    }
215
216    #[getter]
217    fn start(&self) -> Option<u64> {
218        self.response.start.map(|value| value.as_u64())
219    }
220
221    #[getter]
222    fn end(&self) -> Option<u64> {
223        self.response.end.map(|value| value.as_u64())
224    }
225
226    #[getter]
227    fn params(&self, py: Python<'_>) -> PyResult<Option<Py<PyDict>>> {
228        self.response
229            .params
230            .as_ref()
231            .map(|params| params_to_pydict(py, params))
232            .transpose()
233    }
234}
235
236/// An owned `InstrumentsResponse` response.
237#[pyclass(
238    name = "InstrumentsResponse",
239    module = "nautilus_trader.live",
240    frozen,
241    from_py_object
242)]
243#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
244#[derive(Debug, Clone)]
245pub struct PyInstrumentsResponse {
246    response: InstrumentsResponse,
247}
248
249#[pyo3_stub_gen::derive::gen_stub_pymethods]
250#[pymethods]
251impl PyInstrumentsResponse {
252    #[new]
253    #[pyo3(signature = (client_id, venue, data, correlation_id, ts_init, start=None, end=None, params=None))]
254    #[expect(
255        clippy::too_many_arguments,
256        clippy::needless_pass_by_value,
257        reason = "response fields cross the Python boundary together"
258    )]
259    fn py_new(
260        py: Python<'_>,
261        client_id: ClientId,
262        venue: Venue,
263        data: Vec<Py<PyAny>>,
264        correlation_id: UUID4,
265        ts_init: u64,
266        start: Option<u64>,
267        end: Option<u64>,
268        params: Option<Py<PyDict>>,
269    ) -> PyResult<Self> {
270        Ok(Self {
271            response: InstrumentsResponse {
272                client_id,
273                venue,
274                data: data
275                    .into_iter()
276                    .map(|data| pyobject_to_instrument_any(py, data))
277                    .collect::<PyResult<Vec<_>>>()?,
278                correlation_id,
279                ts_init: ts_init.into(),
280                start: start.map(Into::into),
281                end: end.map(Into::into),
282                params: params
283                    .as_ref()
284                    .map(|params| pydict_to_params(py, params))
285                    .transpose()?
286                    .flatten(),
287            },
288        })
289    }
290
291    #[getter]
292    fn client_id(&self) -> ClientId {
293        self.response.client_id
294    }
295
296    #[getter]
297    fn venue(&self) -> Venue {
298        self.response.venue
299    }
300
301    #[getter]
302    fn data(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
303        self.response
304            .data
305            .iter()
306            .cloned()
307            .map(|data| instrument_any_to_pyobject(py, data))
308            .collect()
309    }
310
311    #[getter]
312    fn correlation_id(&self) -> UUID4 {
313        self.response.correlation_id
314    }
315
316    #[getter]
317    fn ts_init(&self) -> u64 {
318        self.response.ts_init.as_u64()
319    }
320
321    #[getter]
322    fn start(&self) -> Option<u64> {
323        self.response.start.map(|value| value.as_u64())
324    }
325
326    #[getter]
327    fn end(&self) -> Option<u64> {
328        self.response.end.map(|value| value.as_u64())
329    }
330
331    #[getter]
332    fn params(&self, py: Python<'_>) -> PyResult<Option<Py<PyDict>>> {
333        self.response
334            .params
335            .as_ref()
336            .map(|params| params_to_pydict(py, params))
337            .transpose()
338    }
339}
340
341/// An owned `CustomDataResponse` response.
342#[pyclass(
343    name = "CustomDataResponse",
344    module = "nautilus_trader.live",
345    frozen,
346    from_py_object
347)]
348#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
349#[derive(Debug, Clone)]
350pub struct PyCustomDataResponse {
351    response: CustomDataResponse,
352}
353
354#[pyo3_stub_gen::derive::gen_stub_pymethods]
355#[pymethods]
356impl PyCustomDataResponse {
357    #[new]
358    #[pyo3(signature = (client_id, data_type, venue, data, correlation_id, ts_init, start=None, end=None, params=None))]
359    #[expect(
360        clippy::too_many_arguments,
361        clippy::needless_pass_by_value,
362        reason = "response fields cross the Python boundary together"
363    )]
364    fn py_new(
365        py: Python<'_>,
366        client_id: ClientId,
367        data_type: DataType,
368        venue: Option<Venue>,
369        data: Vec<CustomData>,
370        correlation_id: UUID4,
371        ts_init: u64,
372        start: Option<u64>,
373        end: Option<u64>,
374        params: Option<Py<PyDict>>,
375    ) -> PyResult<Self> {
376        Ok(Self {
377            response: CustomDataResponse {
378                client_id,
379                data_type,
380                venue,
381                data: Arc::new(data),
382                correlation_id,
383                ts_init: ts_init.into(),
384                start: start.map(Into::into),
385                end: end.map(Into::into),
386                params: params
387                    .as_ref()
388                    .map(|params| pydict_to_params(py, params))
389                    .transpose()?
390                    .flatten(),
391            },
392        })
393    }
394
395    #[getter]
396    fn client_id(&self) -> ClientId {
397        self.response.client_id
398    }
399
400    #[getter]
401    fn data_type(&self) -> DataType {
402        self.response.data_type.clone()
403    }
404
405    #[getter]
406    fn venue(&self) -> Option<Venue> {
407        self.response.venue
408    }
409
410    #[getter]
411    fn data(&self, _py: Python<'_>) -> PyResult<Vec<CustomData>> {
412        self.response
413            .data
414            .downcast_ref::<Vec<CustomData>>()
415            .cloned()
416            .ok_or_else(|| to_pytype_err("Expected custom data response payload"))
417    }
418
419    #[getter]
420    fn correlation_id(&self) -> UUID4 {
421        self.response.correlation_id
422    }
423
424    #[getter]
425    fn ts_init(&self) -> u64 {
426        self.response.ts_init.as_u64()
427    }
428
429    #[getter]
430    fn start(&self) -> Option<u64> {
431        self.response.start.map(|value| value.as_u64())
432    }
433
434    #[getter]
435    fn end(&self) -> Option<u64> {
436        self.response.end.map(|value| value.as_u64())
437    }
438
439    #[getter]
440    fn params(&self, py: Python<'_>) -> PyResult<Option<Py<PyDict>>> {
441        self.response
442            .params
443            .as_ref()
444            .map(|params| params_to_pydict(py, params))
445            .transpose()
446    }
447}
448
449/// An owned `OptionChainReferencePriceResponse` response.
450#[pyclass(
451    name = "OptionChainReferencePriceResponse",
452    module = "nautilus_trader.live",
453    frozen,
454    from_py_object
455)]
456#[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.live")]
457#[derive(Debug, Clone)]
458pub struct PyOptionChainReferencePriceResponse {
459    response: OptionChainReferencePriceResponse,
460}
461
462#[pyo3_stub_gen::derive::gen_stub_pymethods]
463#[pymethods]
464impl PyOptionChainReferencePriceResponse {
465    #[new]
466    #[pyo3(signature = (client_id, series_id, price, correlation_id, ts_init, params=None))]
467    #[expect(
468        clippy::needless_pass_by_value,
469        reason = "response fields cross the Python boundary together"
470    )]
471    fn py_new(
472        py: Python<'_>,
473        client_id: ClientId,
474        series_id: OptionSeriesId,
475        price: Option<Price>,
476        correlation_id: UUID4,
477        ts_init: u64,
478        params: Option<Py<PyDict>>,
479    ) -> PyResult<Self> {
480        Ok(Self {
481            response: OptionChainReferencePriceResponse {
482                client_id,
483                series_id,
484                price,
485                correlation_id,
486                ts_init: ts_init.into(),
487
488                params: params
489                    .as_ref()
490                    .map(|params| pydict_to_params(py, params))
491                    .transpose()?
492                    .flatten(),
493            },
494        })
495    }
496
497    #[getter]
498    fn client_id(&self) -> ClientId {
499        self.response.client_id
500    }
501
502    #[getter]
503    fn series_id(&self) -> OptionSeriesId {
504        self.response.series_id
505    }
506
507    #[getter]
508    fn price(&self) -> Option<Price> {
509        self.response.price
510    }
511
512    #[getter]
513    fn correlation_id(&self) -> UUID4 {
514        self.response.correlation_id
515    }
516
517    #[getter]
518    fn ts_init(&self) -> u64 {
519        self.response.ts_init.as_u64()
520    }
521
522    #[getter]
523    fn params(&self, py: Python<'_>) -> PyResult<Option<Py<PyDict>>> {
524        self.response
525            .params
526            .as_ref()
527            .map(|params| params_to_pydict(py, params))
528            .transpose()
529    }
530}
531
532pub(crate) fn extract_response(response: &Bound<'_, PyAny>) -> PyResult<(ClientId, DataResponse)> {
533    if response.is_instance_of::<PyQuotesResponse>() {
534        let response = response.extract::<PyQuotesResponse>()?.response;
535        return Ok((response.client_id, DataResponse::Quotes(response)));
536    }
537
538    if response.is_instance_of::<PyTradesResponse>() {
539        let response = response.extract::<PyTradesResponse>()?.response;
540        return Ok((response.client_id, DataResponse::Trades(response)));
541    }
542
543    if response.is_instance_of::<PyFundingRatesResponse>() {
544        let response = response.extract::<PyFundingRatesResponse>()?.response;
545        return Ok((response.client_id, DataResponse::FundingRates(response)));
546    }
547
548    if response.is_instance_of::<PyBarsResponse>() {
549        let response = response.extract::<PyBarsResponse>()?.response;
550        return Ok((response.client_id, DataResponse::Bars(response)));
551    }
552
553    if response.is_instance_of::<PyBookDeltasResponse>() {
554        let response = response.extract::<PyBookDeltasResponse>()?.response;
555        return Ok((response.client_id, DataResponse::BookDeltas(response)));
556    }
557
558    if response.is_instance_of::<PyBookDepthResponse>() {
559        let response = response.extract::<PyBookDepthResponse>()?.response;
560        return Ok((response.client_id, DataResponse::BookDepth(response)));
561    }
562
563    if response.is_instance_of::<PyBookResponse>() {
564        let response = response.extract::<PyBookResponse>()?.response;
565        return Ok((response.client_id, DataResponse::Book(response)));
566    }
567
568    if response.is_instance_of::<PyInstrumentResponse>() {
569        let response = response.extract::<PyInstrumentResponse>()?.response;
570        return Ok((
571            response.client_id,
572            DataResponse::Instrument(Box::new(response)),
573        ));
574    }
575
576    if response.is_instance_of::<PyInstrumentsResponse>() {
577        let response = response.extract::<PyInstrumentsResponse>()?.response;
578        return Ok((response.client_id, DataResponse::Instruments(response)));
579    }
580
581    if response.is_instance_of::<PyCustomDataResponse>() {
582        let response = response.extract::<PyCustomDataResponse>()?.response;
583        return Ok((response.client_id, DataResponse::Data(response)));
584    }
585
586    if response.is_instance_of::<PyOptionChainReferencePriceResponse>() {
587        let response = response
588            .extract::<PyOptionChainReferencePriceResponse>()?
589            .response;
590        return Ok((
591            response.client_id,
592            DataResponse::OptionChainReferencePrice(response),
593        ));
594    }
595
596    Err(to_pytype_err(
597        "Expected a Nautilus data response from the installed wheel",
598    ))
599}
600
601pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
602    m.add_class::<PyInstrumentResponse>()?;
603    m.add_class::<PyInstrumentsResponse>()?;
604    m.add_class::<PyCustomDataResponse>()?;
605    m.add_class::<PyOptionChainReferencePriceResponse>()?;
606
607    m.add_class::<PyQuotesResponse>()?;
608    m.add_class::<PyTradesResponse>()?;
609    m.add_class::<PyFundingRatesResponse>()?;
610    m.add_class::<PyBarsResponse>()?;
611    m.add_class::<PyBookDeltasResponse>()?;
612    m.add_class::<PyBookDepthResponse>()?;
613    m.add_class::<PyBookResponse>()?;
614    Ok(())
615}
616
617#[cfg(test)]
618mod tests {
619    use std::{ops::Range, sync::Arc};
620
621    use nautilus_core::UnixNanos;
622    use nautilus_model::data::BatchView;
623    use rstest::rstest;
624
625    use super::*;
626
627    #[rstest]
628    #[case::visible(1..2)]
629    #[case::empty(1..1)]
630    fn test_quote_response_getter_contains_only_visible_rows(#[case] range: Range<usize>) {
631        let rows = [11, 23, 37].map(|ts| QuoteTick {
632            ts_init: UnixNanos::from(ts),
633            ..Default::default()
634        });
635
636        let backing = Arc::new(rows.to_vec());
637
638        let wrapper = PyQuotesResponse {
639            response: QuotesResponse::new(
640                UUID4::new(),
641                ClientId::from("HISTORY"),
642                rows[1].instrument_id,
643                BatchView::new(Arc::clone(&backing), range.clone()),
644                None,
645                None,
646                UnixNanos::from(41),
647                None,
648            ),
649        };
650
651        let data = wrapper.data();
652
653        assert_eq!(data.as_slice(), &rows[range.clone()]);
654        assert_eq!(wrapper.response.data.range(), range);
655        assert_eq!(backing.as_slice(), rows.as_slice());
656    }
657}