1use 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#[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#[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#[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#[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#[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#[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 #[new]
266 fn py_new(instrument_ids: HashMap<u64, InstrumentId>) -> Self {
267 Self::new(instrument_ids)
268 }
269
270 #[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}