Skip to main content

nautilus_coinapi/python/
csv.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//! Python bindings for the CoinAPI flat file loaders.
17
18use std::{collections::HashMap, fmt::Debug, path::PathBuf};
19
20use jiff::civil::Date;
21use nautilus_core::python::to_pyvalue_err;
22use nautilus_model::{
23    data::{Bar, OrderBookDelta, QuoteTick, TradeTick},
24    identifiers::InstrumentId,
25};
26use pyo3::prelude::*;
27
28use crate::csv::{
29    CoinApiOhlcvLoader, load_deltas, load_quotes, load_trades, stream_deltas, stream_quotes,
30    stream_trades,
31};
32
33macro_rules! impl_coinapi_stream_iterator {
34    ($struct_name:ident, $data_type:ty) => {
35        #[pyclass(unsendable)]
36        #[pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.coinapi")]
37        pub struct $struct_name {
38            stream: Box<dyn Iterator<Item = anyhow::Result<Vec<$data_type>>>>,
39        }
40
41        impl Debug for $struct_name {
42            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43                write!(f, "{} {{ stream: ... }}", stringify!($struct_name))
44            }
45        }
46
47        #[pymethods]
48        #[pyo3_stub_gen::derive::gen_stub_pymethods]
49        impl $struct_name {
50            const fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
51                slf
52            }
53
54            fn __next__(&mut self) -> PyResult<Option<Vec<$data_type>>> {
55                match self.stream.next() {
56                    Some(Ok(chunk)) => Ok(Some(chunk)),
57                    Some(Err(e)) => Err(to_pyvalue_err(e)),
58                    None => Ok(None),
59                }
60            }
61        }
62    };
63}
64
65impl_coinapi_stream_iterator!(CoinApiTradeStreamIterator, TradeTick);
66impl_coinapi_stream_iterator!(CoinApiQuoteStreamIterator, QuoteTick);
67impl_coinapi_stream_iterator!(CoinApiDeltaStreamIterator, OrderBookDelta);
68
69/// Loads trade ticks from a CoinAPI `T-TRADES` flat file, inferring each precision not given.
70///
71/// # Errors
72///
73/// Returns an error if the file cannot be read or a row is invalid. Python receives a `ValueError`.
74#[pyfunction(name = "load_coinapi_trades")]
75#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.coinapi")]
76#[pyo3(signature = (filepath, instrument_id, price_precision=None, size_precision=None, limit=None))]
77pub fn py_load_coinapi_trades(
78    filepath: PathBuf,
79    instrument_id: InstrumentId,
80    price_precision: Option<u8>,
81    size_precision: Option<u8>,
82    limit: Option<usize>,
83) -> PyResult<Vec<TradeTick>> {
84    load_trades(
85        filepath,
86        instrument_id,
87        price_precision,
88        size_precision,
89        limit,
90    )
91    .map_err(to_pyvalue_err)
92}
93
94/// Loads quote ticks from a CoinAPI `T-QUOTES` flat file, inferring each precision not given.
95///
96/// # Errors
97///
98/// Returns an error if the file cannot be read or a row is invalid. Python receives a `ValueError`.
99#[pyfunction(name = "load_coinapi_quotes")]
100#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.coinapi")]
101#[pyo3(signature = (filepath, instrument_id, price_precision=None, size_precision=None, limit=None))]
102pub fn py_load_coinapi_quotes(
103    filepath: PathBuf,
104    instrument_id: InstrumentId,
105    price_precision: Option<u8>,
106    size_precision: Option<u8>,
107    limit: Option<usize>,
108) -> PyResult<Vec<QuoteTick>> {
109    load_quotes(
110        filepath,
111        instrument_id,
112        price_precision,
113        size_precision,
114        limit,
115    )
116    .map_err(to_pyvalue_err)
117}
118
119/// Loads order book deltas from a CoinAPI `T-LIMITBOOK_FULL` flat file.
120///
121/// Rows fall on `date`, or on the date of the file's `D-YYYYMMDD` partition when `date` is
122/// `None`. Infers each precision not given.
123///
124/// # Errors
125///
126/// Returns an error if no date is known, the file cannot be read, or a row is invalid. Python
127/// receives a `ValueError`.
128#[pyfunction(name = "load_coinapi_deltas")]
129#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.coinapi")]
130#[pyo3(signature = (filepath, instrument_id, date=None, price_precision=None, size_precision=None, limit=None))]
131pub fn py_load_coinapi_deltas(
132    filepath: PathBuf,
133    instrument_id: InstrumentId,
134    date: Option<Date>,
135    price_precision: Option<u8>,
136    size_precision: Option<u8>,
137    limit: Option<usize>,
138) -> PyResult<Vec<OrderBookDelta>> {
139    load_deltas(
140        filepath,
141        instrument_id,
142        date,
143        price_precision,
144        size_precision,
145        limit,
146    )
147    .map_err(to_pyvalue_err)
148}
149
150/// Streams trade ticks from a CoinAPI `T-TRADES` flat file in chunks.
151///
152/// Infers each precision not given from the leading rows, and raises for a later value that would
153/// need rounding.
154///
155/// # Errors
156///
157/// Returns an error if `chunk_size` is outside [1, 1_000_000] or the file cannot be opened. Python
158/// receives a `ValueError`.
159#[pyfunction(name = "stream_coinapi_trades")]
160#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.coinapi")]
161#[pyo3(signature = (filepath, instrument_id, chunk_size=100_000, price_precision=None, size_precision=None, limit=None))]
162pub fn py_stream_coinapi_trades(
163    filepath: PathBuf,
164    instrument_id: InstrumentId,
165    chunk_size: usize,
166    price_precision: Option<u8>,
167    size_precision: Option<u8>,
168    limit: Option<usize>,
169) -> PyResult<CoinApiTradeStreamIterator> {
170    let stream = stream_trades(
171        filepath,
172        instrument_id,
173        chunk_size,
174        price_precision,
175        size_precision,
176        limit,
177    )
178    .map_err(to_pyvalue_err)?;
179
180    Ok(CoinApiTradeStreamIterator {
181        stream: Box::new(stream),
182    })
183}
184
185/// Streams quote ticks from a CoinAPI `T-QUOTES` flat file in chunks.
186///
187/// Infers each precision not given from the leading rows, and raises for a later value that would
188/// need rounding.
189///
190/// # Errors
191///
192/// Returns an error if `chunk_size` is outside [1, 1_000_000] or the file cannot be opened. Python
193/// receives a `ValueError`.
194#[pyfunction(name = "stream_coinapi_quotes")]
195#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.coinapi")]
196#[pyo3(signature = (filepath, instrument_id, chunk_size=100_000, price_precision=None, size_precision=None, limit=None))]
197pub fn py_stream_coinapi_quotes(
198    filepath: PathBuf,
199    instrument_id: InstrumentId,
200    chunk_size: usize,
201    price_precision: Option<u8>,
202    size_precision: Option<u8>,
203    limit: Option<usize>,
204) -> PyResult<CoinApiQuoteStreamIterator> {
205    let stream = stream_quotes(
206        filepath,
207        instrument_id,
208        chunk_size,
209        price_precision,
210        size_precision,
211        limit,
212    )
213    .map_err(to_pyvalue_err)?;
214
215    Ok(CoinApiQuoteStreamIterator {
216        stream: Box::new(stream),
217    })
218}
219
220/// Streams order book deltas from a CoinAPI `T-LIMITBOOK_FULL` flat file in chunks.
221///
222/// A chunk ends only at the end of an event, so it can exceed `chunk_size`, unless a `limit` ends
223/// it partway through one. Dates rows as
224/// `load_coinapi_deltas` does, infers each precision not given from the leading rows, and raises
225/// for a later value that would need rounding.
226///
227/// # Errors
228///
229/// Returns an error if `chunk_size` is outside [1, 1_000_000], no date is known, or the file cannot
230/// be opened. Python receives a `ValueError`.
231#[pyfunction(name = "stream_coinapi_deltas")]
232#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.adapters.coinapi")]
233#[pyo3(signature = (filepath, instrument_id, date=None, chunk_size=100_000, price_precision=None, size_precision=None, limit=None))]
234pub fn py_stream_coinapi_deltas(
235    filepath: PathBuf,
236    instrument_id: InstrumentId,
237    date: Option<Date>,
238    chunk_size: usize,
239    price_precision: Option<u8>,
240    size_precision: Option<u8>,
241    limit: Option<usize>,
242) -> PyResult<CoinApiDeltaStreamIterator> {
243    let stream = stream_deltas(
244        filepath,
245        instrument_id,
246        date,
247        chunk_size,
248        price_precision,
249        size_precision,
250        limit,
251    )
252    .map_err(to_pyvalue_err)?;
253
254    Ok(CoinApiDeltaStreamIterator {
255        stream: Box::new(stream),
256    })
257}
258
259#[pymethods]
260#[pyo3_stub_gen::derive::gen_stub_pymethods]
261impl CoinApiOhlcvLoader {
262    /// Loads bars from exchange-wide CoinAPI OHLCV flat files.
263    ///
264    /// `instrument_ids` maps each numeric `symbol_id` (the REST `symbol_id_int`) to an instrument.
265    #[new]
266    fn py_new(instrument_ids: HashMap<u64, InstrumentId>) -> Self {
267        Self::new(instrument_ids)
268    }
269
270    /// Loads the bars of mapped symbols, taking the period from `period_id` or the file's `TP-`
271    /// name.
272    #[pyo3(name = "load_bars", signature = (filepath, period_id=None, limit=None))]
273    fn py_load_bars(
274        &self,
275        filepath: PathBuf,
276        period_id: Option<&str>,
277        limit: Option<usize>,
278    ) -> PyResult<Vec<Bar>> {
279        self.load_bars(filepath, period_id, limit)
280            .map_err(to_pyvalue_err)
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use nautilus_core::UnixNanos;
287    use nautilus_model::{
288        data::BarType,
289        types::{Price, Quantity},
290    };
291    use pyo3::exceptions::PyValueError;
292    use rstest::rstest;
293
294    use super::*;
295
296    #[rstest]
297    fn quote_iterator_preserves_chunks_limit_values_and_eof() {
298        let directory = tempfile::TempDir::new().unwrap();
299        let path = directory.path().join("quotes.csv");
300        std::fs::write(
301            &path,
302            "time_exchange;time_coinapi;ask_px;ask_sx;bid_px;bid_sx\n\
303            2025-09-01T00:00:00.1000000;2025-09-01T00:00:00.2000000;101.25;2.5;100.5;1\n\
304            2025-09-01T00:00:01.3000000;2025-09-01T00:00:01.4000000;101.375;2.0625;100.125;0.125\n\
305            2025-09-01T00:00:02;2025-09-01T00:00:03;102;3;101;2\n",
306        )
307        .unwrap();
308        let instrument_id = InstrumentId::from("BTC-USD.COINBASE");
309        let mut stream =
310            py_stream_coinapi_quotes(path, instrument_id, 1, Some(3), Some(4), Some(2)).unwrap();
311
312        let first = stream.__next__().unwrap();
313        let second = stream.__next__().unwrap();
314        let end = stream.__next__().unwrap();
315        let repeated_end = stream.__next__().unwrap();
316
317        assert_eq!(
318            first,
319            Some(vec![QuoteTick::new(
320                instrument_id,
321                Price::from("100.500"),
322                Price::from("101.250"),
323                Quantity::from("1.0000"),
324                Quantity::from("2.5000"),
325                UnixNanos::from(1_756_684_800_100_000_000),
326                UnixNanos::from(1_756_684_800_200_000_000)
327            )])
328        );
329        assert_eq!(
330            second,
331            Some(vec![QuoteTick::new(
332                instrument_id,
333                Price::from("100.125"),
334                Price::from("101.375"),
335                Quantity::from("0.1250"),
336                Quantity::from("2.0625"),
337                UnixNanos::from(1_756_684_801_300_000_000),
338                UnixNanos::from(1_756_684_801_400_000_000)
339            )])
340        );
341        assert_eq!(
342            first
343                .into_iter()
344                .chain(second)
345                .flatten()
346                .map(|quote| (
347                    quote.bid_price.precision,
348                    quote.ask_price.precision,
349                    quote.bid_size.precision,
350                    quote.ask_size.precision
351                ))
352                .collect::<Vec<_>>(),
353            [(3, 3, 4, 4); 2]
354        );
355        assert_eq!(end, None);
356        assert_eq!(repeated_end, None);
357    }
358
359    #[rstest]
360    fn quote_iterator_reports_precision_errors_as_value_error() {
361        Python::initialize();
362        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
363            .join("test_data/csv/quotes_binance_spot_btc_usdt_20250901.csv");
364        let mut stream = py_stream_coinapi_quotes(
365            path,
366            InstrumentId::from("BTCUSDT.BINANCE"),
367            1,
368            Some(1),
369            None,
370            None,
371        )
372        .unwrap();
373
374        let error = stream.__next__().unwrap_err();
375
376        Python::attach(|py| {
377            assert!(error.is_instance_of::<PyValueError>(py));
378            assert_eq!(
379                error.value(py).to_string(),
380                "bid_px 108246.35 is finer than instrument precision 1"
381            );
382        });
383    }
384
385    #[rstest]
386    fn ohlcv_constructor_retains_symbol_mapping() {
387        let directory = tempfile::TempDir::new().unwrap();
388        let path = directory.path().join("TP-1MIN.csv");
389        std::fs::write(
390            &path,
391            "symbol_id;time_period_end;px_open;px_high;px_low;px_close;sx_sum\n\
392            99;2025-09-01T00:01:00;1;2;0.5;1.5;4\n\
393            7;2025-09-01T00:02:00;101.25;103.5;99.75;102.5;4.125\n",
394        )
395        .unwrap();
396        let loader = CoinApiOhlcvLoader::py_new(HashMap::from([(
397            7,
398            InstrumentId::from("BTC-USD.COINBASE"),
399        )]));
400
401        let bars = loader.py_load_bars(path, None, Some(1)).unwrap();
402
403        let timestamp = UnixNanos::from(1_756_684_920_000_000_000);
404        assert_eq!(
405            bars,
406            [Bar::new(
407                BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
408                Price::from("101.25"),
409                Price::from("103.50"),
410                Price::from("99.75"),
411                Price::from("102.50"),
412                Quantity::from("4.125"),
413                timestamp,
414                timestamp
415            )]
416        );
417        assert_eq!(
418            (
419                bars[0].open.precision,
420                bars[0].high.precision,
421                bars[0].low.precision,
422                bars[0].close.precision,
423                bars[0].volume.precision
424            ),
425            (2, 2, 2, 2, 3)
426        );
427    }
428}