Skip to main content

nautilus_coinapi/csv/
mod.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//! Loaders for CoinAPI Flat Files, the semicolon-delimited `.csv.gz` exports of the S3 API.
17//!
18//! - [`load_trades`], [`load_quotes`], and [`load_deltas`] read a whole per-symbol file.
19//! - [`stream_trades`], [`stream_quotes`], and [`stream_deltas`] read one in chunks.
20//! - [`CoinApiOhlcvLoader`] reads an exchange-wide OHLCV file.
21//!
22//! Values convert exactly from their text. A load infers each precision it is not given from every
23//! value it reads. A stream infers it from a leading sample and rejects a later value that
24//! would need rounding, so pass explicit precisions to stream values of unknown precision.
25
26mod book;
27mod ohlcv;
28mod rows;
29mod ticks;
30
31use std::path::Path;
32
33use csv::StringRecord;
34use jiff::civil::Date;
35use nautilus_core::correctness::check_in_range_inclusive_usize;
36use nautilus_model::{
37    data::{OrderBookDelta, QuoteTick, TradeTick},
38    identifiers::InstrumentId,
39};
40pub use ohlcv::CoinApiOhlcvLoader;
41
42use self::{
43    book::BookDecoder,
44    rows::{Precision, Rows, partition_date},
45    ticks::{QuoteDecoder, TradeDecoder},
46};
47
48/// The largest `chunk_size` a stream accepts.
49pub const STREAM_CHUNK_SIZE_MAX: usize = 1_000_000;
50
51const LOAD_CHUNK_SIZE: usize = 1_000_000;
52
53// Rows scanned to infer a stream's precision
54const PRECISION_SAMPLE_ROWS: usize = 10_000;
55
56/// Loads [`TradeTick`]s from a `T-TRADES` file.
57///
58/// Infers each precision not given from every value read.
59///
60/// # Errors
61///
62/// Returns an error if the file cannot be read, a column is missing, or a row is invalid.
63pub fn load_trades(
64    path: impl AsRef<Path>,
65    instrument_id: InstrumentId,
66    price_precision: Option<u8>,
67    size_precision: Option<u8>,
68    limit: Option<usize>,
69) -> anyhow::Result<Vec<TradeTick>> {
70    load(
71        path.as_ref(),
72        |rows| TradeDecoder::new(rows, instrument_id),
73        price_precision,
74        size_precision,
75        limit,
76    )
77}
78
79/// Loads [`QuoteTick`]s from a `T-QUOTES` file.
80///
81/// Infers each precision not given from every value read.
82///
83/// # Errors
84///
85/// Returns an error if the file cannot be read, a column is missing, or a row is invalid.
86pub fn load_quotes(
87    path: impl AsRef<Path>,
88    instrument_id: InstrumentId,
89    price_precision: Option<u8>,
90    size_precision: Option<u8>,
91    limit: Option<usize>,
92) -> anyhow::Result<Vec<QuoteTick>> {
93    load(
94        path.as_ref(),
95        |rows| QuoteDecoder::new(rows, instrument_id),
96        price_precision,
97        size_precision,
98        limit,
99    )
100}
101
102/// Loads [`OrderBookDelta`]s from a `T-LIMITBOOK_FULL` file.
103///
104/// Rows carry only a time of day, so each timestamp falls on `date`, or on the date of the file's
105/// `D-YYYYMMDD` or `D-YYYYMMDDHH` partition when `date` is `None`. Infers each precision not given
106/// from every value read.
107///
108/// # Errors
109///
110/// Returns an error if no date is given or found in the path, the file cannot be read, a column is
111/// missing, or a row is invalid.
112pub fn load_deltas(
113    path: impl AsRef<Path>,
114    instrument_id: InstrumentId,
115    date: Option<Date>,
116    price_precision: Option<u8>,
117    size_precision: Option<u8>,
118    limit: Option<usize>,
119) -> anyhow::Result<Vec<OrderBookDelta>> {
120    let path = path.as_ref();
121    let date = book_date(path, date)?;
122    load(
123        path,
124        |rows| BookDecoder::new(rows, instrument_id, date),
125        price_precision,
126        size_precision,
127        limit,
128    )
129}
130
131/// Streams [`TradeTick`]s from a `T-TRADES` file in chunks of up to `chunk_size`.
132///
133/// Infers each precision not given from the leading rows.
134///
135/// # Errors
136///
137/// Returns an error if `chunk_size` is outside [1, [`STREAM_CHUNK_SIZE_MAX`]] or the file cannot
138/// be opened. Each chunk is an error if a row is invalid or finer than its precision.
139pub fn stream_trades(
140    path: impl AsRef<Path>,
141    instrument_id: InstrumentId,
142    chunk_size: usize,
143    price_precision: Option<u8>,
144    size_precision: Option<u8>,
145    limit: Option<usize>,
146) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<TradeTick>>>> {
147    stream(
148        path.as_ref(),
149        |rows| TradeDecoder::new(rows, instrument_id),
150        chunk_size,
151        price_precision,
152        size_precision,
153        limit,
154    )
155}
156
157/// Streams [`QuoteTick`]s from a `T-QUOTES` file in chunks of up to `chunk_size`.
158///
159/// Infers each precision not given from the leading rows.
160///
161/// # Errors
162///
163/// Returns an error if `chunk_size` is outside [1, [`STREAM_CHUNK_SIZE_MAX`]] or the file cannot
164/// be opened. Each chunk is an error if a row is invalid or finer than its precision.
165pub fn stream_quotes(
166    path: impl AsRef<Path>,
167    instrument_id: InstrumentId,
168    chunk_size: usize,
169    price_precision: Option<u8>,
170    size_precision: Option<u8>,
171    limit: Option<usize>,
172) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<QuoteTick>>>> {
173    stream(
174        path.as_ref(),
175        |rows| QuoteDecoder::new(rows, instrument_id),
176        chunk_size,
177        price_precision,
178        size_precision,
179        limit,
180    )
181}
182
183/// Streams [`OrderBookDelta`]s from a `T-LIMITBOOK_FULL` file in chunks of about `chunk_size`.
184///
185/// A chunk ends only at the end of an event, so it can exceed `chunk_size`; the opening snapshot
186/// is one event. A `limit` can end the last chunk partway through an event, which then ends with
187/// an `F_LAST` delta. Dates timestamps as [`load_deltas`] does, and infers each precision not given
188/// from the leading rows.
189///
190/// # Errors
191///
192/// Returns an error if `chunk_size` is outside [1, [`STREAM_CHUNK_SIZE_MAX`]], no date is given or
193/// found in the path, or the file cannot be opened. Each chunk is an error if a row is invalid or
194/// finer than its precision.
195pub fn stream_deltas(
196    path: impl AsRef<Path>,
197    instrument_id: InstrumentId,
198    date: Option<Date>,
199    chunk_size: usize,
200    price_precision: Option<u8>,
201    size_precision: Option<u8>,
202    limit: Option<usize>,
203) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<OrderBookDelta>>>> {
204    let path = path.as_ref();
205    let date = book_date(path, date)?;
206    stream(
207        path,
208        |rows| BookDecoder::new(rows, instrument_id, date),
209        chunk_size,
210        price_precision,
211        size_precision,
212        limit,
213    )
214}
215
216fn book_date(path: &Path, date: Option<Date>) -> anyhow::Result<Date> {
217    date.or_else(|| partition_date(path)).ok_or_else(|| {
218        anyhow::anyhow!(
219            "no date given and '{}' is not in a D-YYYYMMDD partition",
220            path.display()
221        )
222    })
223}
224
225/// Converts the rows of one flat file schema.
226pub(crate) trait Decode {
227    /// The value a row converts to.
228    type Output;
229
230    /// Raises inferred precisions to fit the row's values.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if a value is invalid.
235    fn fit(
236        &self,
237        record: &StringRecord,
238        price: &mut Precision,
239        size: &mut Precision,
240    ) -> anyhow::Result<()>;
241
242    /// Converts a row, appending every value it completes to `out`.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the row is invalid or finer than a fixed precision.
247    fn decode(
248        &mut self,
249        record: &StringRecord,
250        price: &mut Precision,
251        size: &mut Precision,
252        out: &mut Vec<Self::Output>,
253    ) -> anyhow::Result<()>;
254
255    /// Returns how many converted values are held for later rows.
256    fn pending(&self) -> usize {
257        0
258    }
259
260    /// Appends any values held for later rows once reading stops, reading ahead in `rows` when
261    /// they depend on a later row.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error if a row read ahead is invalid.
266    fn finish(&mut self, _rows: &mut Rows, _out: &mut Vec<Self::Output>) -> anyhow::Result<()> {
267        Ok(())
268    }
269
270    /// Shortens converted values to `len` once a limit cuts the input short.
271    fn truncate(out: &mut Vec<Self::Output>, len: usize) {
272        out.truncate(len);
273    }
274
275    /// Sets the precision of every inferred field of `value`.
276    fn set_precision(value: &mut Self::Output, price: Option<u8>, size: Option<u8>);
277}
278
279/// Chunks of values converted from one flat file.
280pub(crate) struct FlatFileStream<D: Decode> {
281    rows: Rows,
282    decoder: D,
283    price: Precision,
284    size: Precision,
285    chunk_size: usize,
286    limit: Option<usize>,
287    emitted: usize,
288    is_done: bool,
289}
290
291impl<D: Decode> FlatFileStream<D> {
292    fn read_chunk(&mut self) -> anyhow::Result<Vec<D::Output>> {
293        let mut out = Vec::new();
294        let limit = self.limit.unwrap_or(usize::MAX);
295        while out.len() < self.chunk_size {
296            if self.emitted + out.len() + self.decoder.pending() >= limit {
297                self.decoder.finish(&mut self.rows, &mut out)?;
298                self.is_done = true;
299                break;
300            }
301
302            let Some(record) = self.rows.next()? else {
303                self.decoder.finish(&mut self.rows, &mut out)?;
304                self.is_done = true;
305                break;
306            };
307
308            self.decoder
309                .decode(record, &mut self.price, &mut self.size, &mut out)?;
310        }
311
312        if let Some(remaining) = limit.checked_sub(self.emitted)
313            && out.len() > remaining
314        {
315            D::truncate(&mut out, remaining);
316            self.is_done = true;
317        }
318
319        self.emitted += out.len();
320        Ok(out)
321    }
322}
323
324impl<D: Decode> Iterator for FlatFileStream<D> {
325    type Item = anyhow::Result<Vec<D::Output>>;
326
327    fn next(&mut self) -> Option<Self::Item> {
328        if self.is_done {
329            return None;
330        }
331
332        match self.read_chunk() {
333            Ok(chunk) if chunk.is_empty() => None,
334            Ok(chunk) => Some(Ok(chunk)),
335            Err(e) => {
336                self.is_done = true;
337                Some(Err(e))
338            }
339        }
340    }
341}
342
343fn load<D: Decode>(
344    path: &Path,
345    decoder: impl FnOnce(&mut Rows) -> anyhow::Result<D>,
346    price_precision: Option<u8>,
347    size_precision: Option<u8>,
348    limit: Option<usize>,
349) -> anyhow::Result<Vec<D::Output>> {
350    let mut rows = Rows::open(path)?;
351    let decoder = decoder(&mut rows)?;
352
353    let mut stream = FlatFileStream {
354        rows,
355        decoder,
356        price: Precision::new(price_precision),
357        size: Precision::new(size_precision),
358        chunk_size: LOAD_CHUNK_SIZE,
359        limit,
360        emitted: 0,
361        is_done: false,
362    };
363
364    let mut values = Vec::new();
365    for chunk in stream.by_ref() {
366        values.extend(chunk?);
367    }
368
369    // Values converted before a finer one keep their exact raw value at the final precision
370    let price = stream.price.is_inferred.then_some(stream.price.value);
371    let size = stream.size.is_inferred.then_some(stream.size.value);
372
373    if price.is_some() || size.is_some() {
374        for value in &mut values {
375            D::set_precision(value, price, size);
376        }
377    }
378
379    Ok(values)
380}
381
382fn stream<D: Decode>(
383    path: &Path,
384    mut decoder: impl FnMut(&mut Rows) -> anyhow::Result<D>,
385    chunk_size: usize,
386    price_precision: Option<u8>,
387    size_precision: Option<u8>,
388    limit: Option<usize>,
389) -> anyhow::Result<FlatFileStream<D>> {
390    check_in_range_inclusive_usize(chunk_size, 1, STREAM_CHUNK_SIZE_MAX, "chunk_size")?;
391
392    let mut price = Precision::new(price_precision);
393    let mut size = Precision::new(size_precision);
394
395    if price.is_inferred || size.is_inferred {
396        let mut rows = Rows::open(path)?;
397        let sampler = decoder(&mut rows)?;
398        for _ in 0..PRECISION_SAMPLE_ROWS {
399            let Some(record) = rows.next()? else {
400                break;
401            };
402
403            sampler.fit(record, &mut price, &mut size)?;
404        }
405    }
406
407    let mut rows = Rows::open(path)?;
408    let decoder = decoder(&mut rows)?;
409
410    Ok(FlatFileStream {
411        rows,
412        decoder,
413        price: price.fixed(),
414        size: size.fixed(),
415        chunk_size,
416        limit,
417        emitted: 0,
418        is_done: false,
419    })
420}
421
422#[cfg(test)]
423mod tests {
424    use std::{
425        fs,
426        io::Write,
427        path::{Path, PathBuf},
428        str::FromStr,
429    };
430
431    use ahash::AHashMap;
432    use flate2::{Compression, write::GzEncoder};
433    use nautilus_core::UnixNanos;
434    use nautilus_model::{
435        data::{Bar, BarType, BookOrder},
436        enums::{AggressorSide, BookAction, OrderSide, RecordFlag},
437        identifiers::TradeId,
438        types::{Price, Quantity, fixed::FIXED_PRECISION},
439    };
440    use rstest::rstest;
441    use rust_decimal::{Decimal, RoundingStrategy};
442    use tempfile::TempDir;
443
444    use super::*;
445    use crate::http::models::Symbol;
446
447    const SNAPSHOT: u8 = RecordFlag::F_SNAPSHOT as u8;
448    const LAST: u8 = RecordFlag::F_LAST as u8;
449    // 2025-09-01T00:00:00Z
450    const DAY_START: u64 = 1_756_684_800_000_000_000;
451
452    const BOOK_HEADER: &str =
453        "time_exchange;time_coinapi;update_type;is_buy;entry_px;entry_sx;order_id";
454    const TRADES_HEADER: &str = "time_exchange;time_coinapi;guid;price;base_amount;taker_side";
455    const OHLCV_HEADER: &str = "symbol_id;time_period_start;time_period_end;time_open;time_close;\
456                                px_open;px_high;px_low;px_close;sx_cnt;sx_sum";
457
458    fn fixture(name: &str) -> PathBuf {
459        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
460            .join("test_data/csv")
461            .join(name)
462    }
463
464    fn date() -> Date {
465        Date::new(2025, 9, 1).unwrap()
466    }
467
468    fn at(nanos_after_day_start: u64) -> UnixNanos {
469        UnixNanos::from(DAY_START + nanos_after_day_start)
470    }
471
472    fn binance() -> InstrumentId {
473        InstrumentId::from("BTCUSDT.BINANCE")
474    }
475
476    fn coinbase() -> InstrumentId {
477        InstrumentId::from("BTC-USD.COINBASE")
478    }
479
480    fn write_file(dir: &Path, name: &str, lines: &[&str]) -> PathBuf {
481        let path = dir.join(name);
482        fs::create_dir_all(path.parent().unwrap()).unwrap();
483        fs::write(&path, lines.join("\n") + "\n").unwrap();
484        path
485    }
486
487    fn trade_row(guid: &str, price: &str, size: &str) -> String {
488        format!("2025-09-01T00:00:00.0000000;2025-09-01T00:00:00.0000000;{guid};{price};{size};BUY")
489    }
490
491    fn actions(deltas: &[OrderBookDelta]) -> AHashMap<BookAction, usize> {
492        let mut counts = AHashMap::new();
493        for delta in deltas {
494            *counts.entry(delta.action).or_default() += 1;
495        }
496
497        counts
498    }
499
500    #[rstest]
501    fn load_trades_converts_every_field_at_the_inferred_precision() {
502        let trades = load_trades(
503            fixture("trades_binance_spot_btc_usdt_20250901.csv"),
504            binance(),
505            None,
506            None,
507            None,
508        )
509        .unwrap();
510
511        assert_eq!(trades.len(), 50);
512        assert_eq!(
513            trades[0],
514            TradeTick::new(
515                binance(),
516                Price::from("108246.36"),
517                Quantity::from("0.00023"),
518                AggressorSide::Buy,
519                TradeId::from("8a61934b-2ca0-4e94-b4ec-cb5409a28c53"),
520                at(178_000_000),
521                at(299_824_200),
522            )
523        );
524        assert!(trades.iter().all(|t| t.price.precision == 2));
525        assert!(trades.iter().all(|t| t.size.precision == 5));
526        assert!(
527            trades
528                .iter()
529                .any(|t| t.aggressor_side == AggressorSide::Sell)
530        );
531    }
532
533    #[rstest]
534    fn load_trades_uses_explicit_precisions_and_rejects_finer_values() {
535        let path = fixture("trades_coinbase_spot_btc_usd_20250901.csv");
536
537        let trades = load_trades(&path, coinbase(), Some(4), Some(9), Some(3)).unwrap();
538        let error = load_trades(&path, coinbase(), Some(1), None, None).unwrap_err();
539
540        assert_eq!(trades.len(), 3);
541        assert!(trades.iter().all(|t| t.price.precision == 4));
542        assert!(trades.iter().all(|t| t.size.precision == 9));
543        assert_eq!(trades[0].size, Quantity::from("0.04120085"));
544        assert_eq!(
545            error.to_string(),
546            "price 108247.95 is finer than instrument precision 1"
547        );
548    }
549
550    #[rstest]
551    fn load_quotes_converts_every_field_at_the_inferred_precision() {
552        let quotes = load_quotes(
553            fixture("quotes_coinbase_spot_btc_usd_20250901.csv"),
554            coinbase(),
555            None,
556            None,
557            None,
558        )
559        .unwrap();
560
561        assert_eq!(quotes.len(), 50);
562        assert_eq!(
563            quotes[0],
564            QuoteTick::new(
565                coinbase(),
566                Price::from("108247.90"),
567                Price::from("108247.94"),
568                Quantity::from("0.18478034"),
569                Quantity::from("0.19673805"),
570                at(47_937_000),
571                at(453_522_400),
572            )
573        );
574        assert!(
575            quotes
576                .iter()
577                .all(|q| q.bid_price.precision == 2 && q.ask_price.precision == 2)
578        );
579        assert!(
580            quotes
581                .iter()
582                .all(|q| q.bid_size.precision == 8 && q.ask_size.precision == 8)
583        );
584    }
585
586    #[rstest]
587    fn load_deltas_rebuilds_price_levels() {
588        let deltas = load_deltas(
589            fixture("limitbook_full_binance_spot_btc_usdt_20250901.csv"),
590            binance(),
591            Some(date()),
592            None,
593            None,
594            None,
595        )
596        .unwrap();
597
598        let last_indices: Vec<usize> = deltas
599            .iter()
600            .enumerate()
601            .filter(|(_, d)| d.flags & LAST != 0)
602            .map(|(i, _)| i)
603            .collect();
604
605        assert_eq!(deltas.len(), 81);
606        assert_eq!(deltas[0].action, BookAction::Clear);
607        assert_eq!(
608            deltas[1],
609            OrderBookDelta::new(
610                binance(),
611                BookAction::Add,
612                BookOrder::new(
613                    OrderSide::Sell,
614                    Price::from("108246.36"),
615                    Quantity::from("6.61982"),
616                    0
617                ),
618                SNAPSHOT,
619                0,
620                at(14_000_000),
621                at(135_378_300),
622            )
623        );
624        assert!(deltas[..=20].iter().all(|d| d.flags & SNAPSHOT != 0));
625        assert!(deltas[21..].iter().all(|d| d.flags & SNAPSHOT == 0));
626        assert_eq!(last_indices, [20, 32, 46, 62, 80]);
627        assert_eq!(actions(&deltas[21..])[&BookAction::Delete], 23);
628        assert_eq!(actions(&deltas[21..])[&BookAction::Update], 37);
629        assert!(
630            deltas[21..]
631                .iter()
632                .filter(|d| d.action == BookAction::Delete)
633                .all(|d| d.order.size.is_zero())
634        );
635        assert!(deltas.iter().all(|d| d.order.price.precision == 2));
636        assert!(deltas.iter().all(|d| d.order.size.precision == 5));
637    }
638
639    #[rstest]
640    fn load_deltas_tracks_resting_orders() {
641        let deltas = load_deltas(
642            fixture("limitbook_full_coinbase_spot_btc_usd_20250901.csv"),
643            coinbase(),
644            Some(date()),
645            None,
646            None,
647            None,
648        )
649        .unwrap();
650
651        let mut book: AHashMap<u64, BookOrder> = AHashMap::new();
652        let mut history: AHashMap<u64, Vec<(BookAction, Quantity)>> = AHashMap::new();
653
654        for delta in &deltas {
655            match delta.action {
656                BookAction::Clear => book.clear(),
657                BookAction::Add | BookAction::Update => {
658                    book.insert(delta.order.order_id, delta.order);
659                }
660                BookAction::Delete => {
661                    assert_eq!(book.remove(&delta.order.order_id), Some(delta.order));
662                }
663            }
664
665            if delta.action != BookAction::Clear {
666                history
667                    .entry(delta.order.order_id)
668                    .or_default()
669                    .push((delta.action, delta.order.size));
670            }
671        }
672
673        let mut resting: Vec<(OrderSide, Price, Quantity)> = book
674            .values()
675            .map(|o| (o.side.unwrap(), o.price, o.size))
676            .collect();
677        resting.sort_by_key(|(side, price, _)| (*side as u8, *price));
678
679        let counts = actions(&deltas);
680        let q = Quantity::from;
681
682        assert_eq!(deltas.len(), 52);
683        assert_eq!(counts[&BookAction::Add], 26);
684        assert_eq!(counts[&BookAction::Update], 6);
685        assert_eq!(counts[&BookAction::Delete], 19);
686        assert_eq!(deltas.iter().filter(|d| d.flags & LAST != 0).count(), 29);
687        assert_eq!(deltas[13].flags, SNAPSHOT | LAST);
688        assert_eq!(
689            resting,
690            [
691                (OrderSide::Buy, Price::from("108163.01"), q("2.7735913")),
692                (OrderSide::Buy, Price::from("108200"), q("0.00091951")),
693                (OrderSide::Buy, Price::from("108205.44"), q("0.000011")),
694                (OrderSide::Buy, Price::from("108211"), q("0.00047886")),
695                (OrderSide::Sell, Price::from("108275.16"), q("0.000011")),
696                (OrderSide::Sell, Price::from("108275.86"), q("0.00034227")),
697                (OrderSide::Sell, Price::from("108281.57"), q("0.00499456")),
698            ]
699        );
700        // A full MATCH removes the order without a DELETE row
701        assert_eq!(
702            history[&18],
703            [
704                (BookAction::Add, q("0.01439792")),
705                (BookAction::Delete, q("0.01439792"))
706            ]
707        );
708        // MATCH subtracts the filled size
709        assert_eq!(
710            history[&20],
711            [
712                (BookAction::Add, q("0.03137")),
713                (BookAction::Update, q("0.03134167")),
714                (BookAction::Update, q("0.00116539")),
715                (BookAction::Delete, q("0.00116539")),
716            ]
717        );
718
719        // A venue identifier added again after its DELETE is a new order
720        for order_id in [12, 17, 23, 25] {
721            assert_eq!(history[&order_id].len(), 2);
722            assert_eq!(history[&order_id][1].0, BookAction::Delete);
723        }
724    }
725
726    #[rstest]
727    fn load_deltas_applies_sub_and_skips_changes_to_unknown_orders() {
728        let dir = TempDir::new().unwrap();
729        let path = write_file(
730            dir.path(),
731            "book.csv",
732            &[
733                BOOK_HEADER,
734                "00:00:00.0000000;00:00:00.0000000;SNAPSHOT;1;100;2;a",
735                "00:00:01.0000000;00:00:01.0000000;SUB;1;100;0.5;a",
736                "00:00:02.0000000;00:00:02.0000000;ADD;1;100;3;a",
737                "00:00:03.0000000;00:00:03.0000000;SET;1;99;1;z",
738                "00:00:03.0000000;00:00:03.0000000;DELETE;1;99;1;y",
739                "00:00:04.0000000;00:00:04.0000000;SNAPSHOT;0;101;1;b",
740                "00:00:05.0000000;00:00:05.0000000;DELETE;1;100;3;a",
741            ],
742        );
743
744        let deltas = load_deltas(&path, binance(), Some(date()), None, None, None).unwrap();
745
746        let summary: Vec<(BookAction, u64, Quantity, u8)> = deltas
747            .iter()
748            .map(|d| (d.action, d.order.order_id, d.order.size, d.flags))
749            .collect();
750        let q = Quantity::from;
751        let zero = Quantity::from("0");
752        assert_eq!(
753            summary,
754            [
755                (BookAction::Clear, 0, zero, SNAPSHOT),
756                (BookAction::Add, 1, q("2.0"), SNAPSHOT | LAST),
757                (BookAction::Update, 1, q("1.5"), LAST),
758                // An ADD for a resting order sets its size
759                (BookAction::Update, 1, q("3.0"), LAST),
760                // A later snapshot replaces the book, so order a is no longer resting
761                (BookAction::Clear, 0, zero, SNAPSHOT),
762                (BookAction::Add, 2, q("1.0"), SNAPSHOT | LAST),
763            ]
764        );
765    }
766
767    #[rstest]
768    #[case::negative_fill(
769        "MATCH;1;100;-0.5",
770        None,
771        "Decimal value '-0.5' is negative, Quantity must be non-negative"
772    )]
773    #[case::finer_than_precision(
774        "MATCH;1;100;2.001",
775        Some(2),
776        "entry_sx 2.001 is finer than instrument precision 2"
777    )]
778    fn load_deltas_rejects_an_invalid_fill_before_applying_it(
779        #[case] fill: &str,
780        #[case] size_precision: Option<u8>,
781        #[case] expected: &str,
782    ) {
783        let dir = TempDir::new().unwrap();
784        let update = format!("00:00:01.0000000;00:00:01.0000000;{fill};a");
785        let path = write_file(
786            dir.path(),
787            "book.csv",
788            &[
789                BOOK_HEADER,
790                "00:00:00.0000000;00:00:00.0000000;SNAPSHOT;1;100;2;a",
791                &update,
792            ],
793        );
794
795        let error =
796            load_deltas(&path, binance(), Some(date()), None, size_precision, None).unwrap_err();
797
798        assert_eq!(error.to_string(), expected);
799    }
800
801    #[rstest]
802    fn load_deltas_dates_rows_from_the_partition_when_not_given() {
803        let dir = TempDir::new().unwrap();
804        let source = fixture("limitbook_full_binance_spot_btc_usdt_20250901.csv");
805        let path = dir
806            .path()
807            .join("T-LIMITBOOK_FULL/D-20250901/E-BINANCE/book.csv");
808        fs::create_dir_all(path.parent().unwrap()).unwrap();
809        fs::copy(&source, &path).unwrap();
810
811        let from_partition = load_deltas(&path, binance(), None, None, None, None).unwrap();
812        let explicit = load_deltas(&source, binance(), Some(date()), None, None, None).unwrap();
813        let error = load_deltas(&source, binance(), None, None, None, None).unwrap_err();
814
815        assert_eq!(from_partition, explicit);
816        assert!(
817            error.to_string().starts_with("no date given and '"),
818            "{error}"
819        );
820    }
821
822    #[rstest]
823    fn load_deltas_moves_a_prior_day_close_snapshot_back_one_day() {
824        let dir = TempDir::new().unwrap();
825        let path = write_file(
826            dir.path(),
827            "book.csv",
828            &[
829                BOOK_HEADER,
830                "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;1;100.5;2;a",
831                "00:00:00.5000000;00:00:00.6000000;ADD;0;101;1;b",
832            ],
833        );
834
835        let deltas = load_deltas(&path, binance(), Some(date()), None, None, None).unwrap();
836
837        let previous_close = UnixNanos::from(DAY_START - 1_000_000);
838        assert_eq!(deltas.len(), 3);
839        assert_eq!(
840            deltas
841                .iter()
842                .map(|d| (d.ts_event, d.ts_init, d.flags))
843                .collect::<Vec<_>>(),
844            [
845                (previous_close, previous_close, SNAPSHOT),
846                (previous_close, previous_close, SNAPSHOT | LAST),
847                (at(500_000_000), at(600_000_000), LAST),
848            ]
849        );
850    }
851
852    #[rstest]
853    fn load_deltas_moves_a_receive_time_after_midnight_to_the_next_day() {
854        let dir = TempDir::new().unwrap();
855        let path = write_file(
856            dir.path(),
857            "book.csv",
858            &[
859                BOOK_HEADER,
860                "23:59:59.9000000;00:00:00.1000000;SET;1;100;1;",
861            ],
862        );
863
864        let deltas = load_deltas(&path, binance(), Some(date()), None, None, None).unwrap();
865
866        assert_eq!(deltas.len(), 1);
867        assert_eq!(deltas[0].ts_event, at(86_399_900_000_000));
868        assert_eq!(deltas[0].ts_init, at(86_400_100_000_000));
869    }
870
871    #[rstest]
872    fn load_deltas_stops_reading_once_held_deltas_reach_the_limit() {
873        let dir = TempDir::new().unwrap();
874        let path = write_file(
875            dir.path(),
876            "book.csv",
877            &[
878                BOOK_HEADER,
879                "00:00:00.0000000;00:00:00.0000000;SNAPSHOT;1;100;2;a",
880                "00:00:01.0000000;00:00:01.0000000;SET;1;100.001;1;a",
881            ],
882        );
883
884        let deltas = load_deltas(&path, binance(), Some(date()), Some(2), None, Some(2)).unwrap();
885
886        assert_eq!(
887            deltas
888                .iter()
889                .map(|d| (d.action, d.flags))
890                .collect::<Vec<_>>(),
891            [
892                (BookAction::Clear, SNAPSHOT),
893                (BookAction::Add, SNAPSHOT | LAST)
894            ]
895        );
896    }
897
898    #[rstest]
899    #[case::after_snapshot(2)]
900    #[case::inside_snapshot(2 + 1)]
901    fn load_deltas_dates_a_limited_prior_day_snapshot(#[case] limit: usize) {
902        let dir = TempDir::new().unwrap();
903        let path = write_file(
904            dir.path(),
905            "book.csv",
906            &[
907                BOOK_HEADER,
908                "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;1;100.5;2;a",
909                "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;1;100.4;1;b",
910                "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;0;100.6;1;c",
911                "00:00:00.5000000;00:00:00.6000000;ADD;0;101;1;d",
912            ],
913        );
914
915        let deltas = load_deltas(&path, binance(), Some(date()), None, None, Some(limit)).unwrap();
916
917        let previous_close = UnixNanos::from(DAY_START - 1_000_000);
918        assert_eq!(deltas.len(), limit);
919        assert!(
920            deltas
921                .iter()
922                .all(|d| d.ts_event == previous_close && d.ts_init == previous_close)
923        );
924        assert_eq!(deltas.last().unwrap().flags, SNAPSHOT | LAST);
925    }
926
927    #[rstest]
928    fn load_deltas_ends_a_limited_load_with_a_last_delta() {
929        let deltas = load_deltas(
930            fixture("limitbook_full_binance_spot_btc_usdt_20250901.csv"),
931            binance(),
932            Some(date()),
933            None,
934            None,
935            Some(25),
936        )
937        .unwrap();
938
939        assert_eq!(deltas.len(), 25);
940        assert_eq!(deltas[20].flags, SNAPSHOT | LAST);
941        assert_eq!(deltas[24].flags, LAST);
942        assert!(deltas[21..24].iter().all(|d| d.flags == 0));
943    }
944
945    #[rstest]
946    fn load_reads_gzipped_files() {
947        let dir = TempDir::new().unwrap();
948        let source = fixture("trades_binance_spot_btc_usdt_20250901.csv");
949        let path = dir.path().join("trades.csv.gz");
950        let mut encoder = GzEncoder::new(fs::File::create(&path).unwrap(), Compression::default());
951        encoder.write_all(&fs::read(&source).unwrap()).unwrap();
952        encoder.finish().unwrap();
953
954        let gzipped = load_trades(&path, binance(), None, None, None).unwrap();
955        let plain = load_trades(&source, binance(), None, None, None).unwrap();
956
957        assert_eq!(gzipped, plain);
958    }
959
960    #[rstest]
961    #[case(1)]
962    #[case(7)]
963    #[case(50)]
964    fn stream_trades_yields_the_loaded_trades_in_chunks(#[case] chunk_size: usize) {
965        let path = fixture("trades_binance_spot_btc_usdt_20250901.csv");
966
967        let chunks: Vec<Vec<TradeTick>> =
968            stream_trades(&path, binance(), chunk_size, None, None, None)
969                .unwrap()
970                .collect::<anyhow::Result<_>>()
971                .unwrap();
972        let loaded = load_trades(&path, binance(), None, None, None).unwrap();
973
974        assert!(chunks.iter().all(|chunk| chunk.len() <= chunk_size));
975        assert_eq!(chunks.len(), 50_usize.div_ceil(chunk_size));
976        assert_eq!(chunks.concat(), loaded);
977        assert!(chunks.concat().iter().all(|t| t.size.precision == 5));
978    }
979
980    #[rstest]
981    fn stream_quotes_applies_limit() {
982        let path = fixture("quotes_binance_spot_btc_usdt_20250901.csv");
983
984        let chunks: Vec<Vec<QuoteTick>> = stream_quotes(&path, binance(), 4, None, None, Some(10))
985            .unwrap()
986            .collect::<anyhow::Result<_>>()
987            .unwrap();
988
989        assert_eq!(chunks.iter().map(Vec::len).collect::<Vec<_>>(), [4, 4, 2]);
990        assert_eq!(
991            chunks.concat(),
992            load_quotes(&path, binance(), None, None, Some(10)).unwrap()
993        );
994    }
995
996    #[rstest]
997    fn stream_deltas_ends_every_chunk_at_an_event_boundary() {
998        let path = fixture("limitbook_full_coinbase_spot_btc_usd_20250901.csv");
999
1000        let chunks: Vec<Vec<OrderBookDelta>> =
1001            stream_deltas(&path, coinbase(), Some(date()), 5, None, None, None)
1002                .unwrap()
1003                .collect::<anyhow::Result<_>>()
1004                .unwrap();
1005        let loaded = load_deltas(&path, coinbase(), Some(date()), None, None, None).unwrap();
1006
1007        assert!(
1008            chunks
1009                .iter()
1010                .all(|chunk| chunk.last().unwrap().flags & LAST != 0)
1011        );
1012        assert_eq!(chunks[0].len(), 14);
1013        assert_eq!(chunks.concat(), loaded);
1014    }
1015
1016    #[rstest]
1017    fn load_backfills_the_final_precision_onto_earlier_values() {
1018        let dir = TempDir::new().unwrap();
1019        let first = trade_row("t1", "100.5", "1");
1020        let second = trade_row("t2", "100.25", "0.125");
1021        let path = write_file(dir.path(), "trades.csv", &[TRADES_HEADER, &first, &second]);
1022
1023        let trades = load_trades(&path, binance(), None, None, None).unwrap();
1024
1025        assert_eq!(
1026            trades
1027                .iter()
1028                .map(|t| (t.price, t.price.precision, t.size, t.size.precision))
1029                .collect::<Vec<_>>(),
1030            [
1031                (Price::from("100.50"), 2, Quantity::from("1.000"), 3),
1032                (Price::from("100.25"), 2, Quantity::from("0.125"), 3),
1033            ]
1034        );
1035    }
1036
1037    #[rstest]
1038    fn stream_rejects_a_finer_value_after_its_precision_sample() {
1039        let dir = TempDir::new().unwrap();
1040        let mut lines = vec![TRADES_HEADER.to_string()];
1041        lines.extend((0..PRECISION_SAMPLE_ROWS).map(|i| trade_row(&format!("t{i}"), "100.5", "1")));
1042        lines.push(trade_row("late", "100.25", "1"));
1043        let lines: Vec<&str> = lines.iter().map(String::as_str).collect();
1044        let path = write_file(dir.path(), "trades.csv", &lines);
1045
1046        let results: Vec<anyhow::Result<Vec<TradeTick>>> =
1047            stream_trades(&path, binance(), PRECISION_SAMPLE_ROWS, None, None, None)
1048                .unwrap()
1049                .collect();
1050
1051        assert_eq!(results.len(), 2);
1052        assert_eq!(results[0].as_ref().unwrap().len(), PRECISION_SAMPLE_ROWS);
1053        assert_eq!(
1054            results[1].as_ref().unwrap_err().to_string(),
1055            "price 100.25 is finer than instrument precision 1"
1056        );
1057        assert_eq!(
1058            load_trades(&path, binance(), None, None, None)
1059                .unwrap()
1060                .last()
1061                .unwrap()
1062                .price,
1063            Price::from("100.25")
1064        );
1065    }
1066
1067    #[rstest]
1068    fn stream_yields_an_error_for_a_value_finer_than_its_precision() {
1069        let path = fixture("trades_binance_spot_btc_usdt_20250901.csv");
1070
1071        let mut chunks = stream_trades(&path, binance(), 10, Some(1), None, None).unwrap();
1072
1073        assert_eq!(
1074            chunks.next().unwrap().unwrap_err().to_string(),
1075            "price 108246.36 is finer than instrument precision 1"
1076        );
1077        assert!(chunks.next().is_none());
1078    }
1079
1080    #[rstest]
1081    #[case(0)]
1082    #[case(STREAM_CHUNK_SIZE_MAX + 1)]
1083    fn stream_rejects_chunk_size_out_of_range(#[case] chunk_size: usize) {
1084        let path = fixture("trades_binance_spot_btc_usdt_20250901.csv");
1085
1086        assert!(stream_trades(&path, binance(), chunk_size, None, None, None).is_err());
1087    }
1088
1089    #[rstest]
1090    fn ohlcv_loader_loads_mapped_symbols_at_their_inferred_precisions() {
1091        let first = InstrumentId::from("SOLUSD.BITSTAMP");
1092        let second = InstrumentId::from("DOGEUSD.BITSTAMP");
1093        let loader = CoinApiOhlcvLoader::new([(138_492, first), (5_968_363, second)]);
1094
1095        let bars = loader
1096            .load_bars(
1097                fixture("ohlcv_bitstamp_1day_20250911.csv"),
1098                Some("1DAY"),
1099                None,
1100            )
1101            .unwrap();
1102
1103        let period_end = UnixNanos::from(1_757_635_200_000_000_000);
1104        let volume = Decimal::from_str("689.5507347100003")
1105            .unwrap()
1106            .round_dp_with_strategy(
1107                u32::from(FIXED_PRECISION),
1108                RoundingStrategy::MidpointNearestEven,
1109            )
1110            .normalize();
1111        assert_eq!(bars.len(), 2);
1112        assert_eq!(
1113            bars[0],
1114            Bar::new(
1115                BarType::from("SOLUSD.BITSTAMP-1-DAY-LAST-EXTERNAL"),
1116                Price::from("496.11"),
1117                Price::from("515.47"),
1118                Price::from("493.23"),
1119                Price::from("508.00"),
1120                Quantity::from_decimal(volume).unwrap(),
1121                period_end,
1122                period_end,
1123            )
1124        );
1125        assert_eq!(bars[0].close.precision, 2);
1126        assert_eq!(u32::from(bars[0].volume.precision), volume.scale());
1127        assert_eq!(
1128            bars[1].bar_type,
1129            BarType::from("DOGEUSD.BITSTAMP-1-DAY-LAST-EXTERNAL")
1130        );
1131        assert_eq!(bars[1].open.precision, 5);
1132        assert_eq!(bars[1].volume, Quantity::from("1474.43175271"));
1133    }
1134
1135    #[rstest]
1136    #[case::negative(-100, true)]
1137    #[case::negative_subunit(-25, true)]
1138    #[case::positive_subunit(25, false)]
1139    #[case::positive_midpoint(50, false)]
1140    fn ohlcv_loader_rejects_negative_and_zero_rounded_volumes(
1141        #[case] hundredths: i64,
1142        #[case] negative: bool,
1143    ) {
1144        let dir = TempDir::new().unwrap();
1145        let volume = Decimal::new(1, u32::from(FIXED_PRECISION)) * Decimal::new(hundredths, 2);
1146        let row = format!(
1147            "7;2025-09-11T00:00:00;2025-09-11T00:01:00;\
1148             2025-09-11T00:00:01;2025-09-11T00:00:59;1;2;0.5;1.5;3;{volume}"
1149        );
1150        let path = write_file(dir.path(), "TP-1MIN.csv", &[OHLCV_HEADER, &row]);
1151        let loader = CoinApiOhlcvLoader::new([(7, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1152
1153        let error = loader.load_bars(&path, None, None).unwrap_err();
1154
1155        let expected = if negative {
1156            format!("sx_sum must be non-negative: {volume}")
1157        } else {
1158            format!("sx_sum {volume} rounds to zero at precision {FIXED_PRECISION}")
1159        };
1160
1161        assert_eq!(error.to_string(), expected);
1162    }
1163
1164    #[rstest]
1165    #[case::zero(0, 0)]
1166    #[case::rounded_unit(75, 1)]
1167    #[case::exact_unit(100, 1)]
1168    #[case::midpoint_even_down(250, 2)]
1169    #[case::midpoint_even_up(350, 4)]
1170    fn ohlcv_loader_preserves_representable_volumes(#[case] hundredths: i64, #[case] units: i64) {
1171        let dir = TempDir::new().unwrap();
1172        let unit = Decimal::new(1, u32::from(FIXED_PRECISION));
1173        let volume = unit * Decimal::new(hundredths, 2);
1174        let row = format!(
1175            "7;2025-09-11T00:00:00;2025-09-11T00:01:00;\
1176             2025-09-11T00:00:01;2025-09-11T00:00:59;1;2;0.5;1.5;3;{volume}"
1177        );
1178        let path = write_file(dir.path(), "TP-1MIN.csv", &[OHLCV_HEADER, &row]);
1179        let loader = CoinApiOhlcvLoader::new([(7, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1180
1181        let bars = loader.load_bars(&path, None, None).unwrap();
1182
1183        let period_end = UnixNanos::from(1_757_548_860_000_000_000);
1184        assert_eq!(
1185            bars,
1186            [Bar::new(
1187                BarType::from("SOLUSD.BITSTAMP-1-MINUTE-LAST-EXTERNAL"),
1188                Price::from("1.0"),
1189                Price::from("2.0"),
1190                Price::from("0.5"),
1191                Price::from("1.5"),
1192                Quantity::from_decimal(unit * Decimal::from(units)).unwrap(),
1193                period_end,
1194                period_end,
1195            )]
1196        );
1197    }
1198
1199    #[rstest]
1200    fn ohlcv_loader_from_symbols_maps_symbol_id_int_to_native_instruments() {
1201        let mut symbols: Vec<Symbol> =
1202            serde_json::from_str(include_str!("../../test_data/symbols_spot.json")).unwrap();
1203        let kraken = symbols
1204            .iter_mut()
1205            .find(|symbol| symbol.symbol_id == "KRAKEN_SPOT_BTC_USD")
1206            .unwrap();
1207        kraken.symbol_id_int = None;
1208        let loader = CoinApiOhlcvLoader::from_symbols(&symbols);
1209
1210        let dir = TempDir::new().unwrap();
1211
1212        let row = |symbol_id: &str| {
1213            format!(
1214                "{symbol_id};2025-09-11T00:00:00.0000000;2025-09-11T00:01:00.0000000;\
1215                 2025-09-11T00:00:01.0000000;2025-09-11T00:00:59.0000000;1;2;0.5;1.5;3;4"
1216            )
1217        };
1218
1219        let rows = [row("138123"), row("27576"), row("599"), row("1")];
1220        let mut lines = vec![
1221            "symbol_id;time_period_start;time_period_end;time_open;time_close;px_open;px_high;\
1222             px_low;px_close;sx_cnt;sx_sum",
1223        ];
1224        lines.extend(rows.iter().map(String::as_str));
1225        let path = write_file(dir.path(), "TP-1MIN.csv", &lines);
1226
1227        let bars = loader.load_bars(&path, None, None).unwrap();
1228
1229        assert_eq!(
1230            bars.iter().map(|bar| bar.bar_type).collect::<Vec<_>>(),
1231            [
1232                BarType::from("BTCUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL"),
1233                BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
1234            ]
1235        );
1236    }
1237
1238    #[rstest]
1239    fn ohlcv_loader_backfills_each_instruments_final_precision() {
1240        let dir = TempDir::new().unwrap();
1241
1242        let row = |open: &str| {
1243            format!(
1244                "7;2025-09-11T00:00:00.0000000;2025-09-11T00:01:00.0000000;\
1245                 2025-09-11T00:00:01.0000000;2025-09-11T00:00:59.0000000;{open};2;0.5;1.5;3;4"
1246            )
1247        };
1248
1249        let (first, second) = (row("1"), row("1.125"));
1250        let path = write_file(dir.path(), "TP-1MIN.csv", &[OHLCV_HEADER, &first, &second]);
1251        let loader = CoinApiOhlcvLoader::new([(7, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1252
1253        let bars = loader.load_bars(&path, None, None).unwrap();
1254
1255        assert_eq!(bars.len(), 2);
1256        assert_eq!(bars[0].open, Price::from("1.000"));
1257        assert_eq!(bars[1].open, Price::from("1.125"));
1258        assert!(bars.iter().all(|bar| {
1259            [bar.open, bar.high, bar.low, bar.close]
1260                .iter()
1261                .all(|price| price.precision == 3)
1262        }));
1263    }
1264
1265    #[rstest]
1266    fn ohlcv_loader_reads_the_period_from_the_file_name() {
1267        let dir = TempDir::new().unwrap();
1268        let path = dir.path().join("TP-1DAY.csv");
1269        fs::copy(fixture("ohlcv_bitstamp_1day_20250911.csv"), &path).unwrap();
1270        let loader = CoinApiOhlcvLoader::new([(138_492, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1271
1272        let bars = loader.load_bars(&path, None, None).unwrap();
1273        let error = loader
1274            .load_bars(fixture("ohlcv_bitstamp_1day_20250911.csv"), None, None)
1275            .unwrap_err();
1276
1277        assert_eq!(bars.len(), 1);
1278        assert_eq!(bars[0].bar_type.spec().to_string(), "1-DAY-LAST");
1279        assert!(
1280            error.to_string().starts_with("no period given and '"),
1281            "{error}"
1282        );
1283    }
1284
1285    #[rstest]
1286    #[case(0)]
1287    #[case(1)]
1288    #[case(2)]
1289    #[case(3)]
1290    fn ohlcv_limit_counts_mapped_bars(#[case] limit: usize) {
1291        let dir = TempDir::new().unwrap();
1292        let path = write_file(
1293            dir.path(),
1294            "TP-1MIN.csv",
1295            &[
1296                OHLCV_HEADER,
1297                "99;2025-09-01T00:00:00;2025-09-01T00:01:00;;;;;;;0;0",
1298                "7;2025-09-01T00:00:00;2025-09-01T00:01:00;;;101.25;103.5;99.75;102.5;3;4.125",
1299                "99;2025-09-01T00:01:00;2025-09-01T00:02:00;;;;;;;0;0",
1300                "7;2025-09-01T00:01:00;2025-09-01T00:02:00;;;102.5;104.75;100.25;103.25;5;6.375",
1301            ],
1302        );
1303        let loader = CoinApiOhlcvLoader::new([(7, coinbase())]);
1304        let expected = [
1305            Bar::new(
1306                BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
1307                Price::from("101.25"),
1308                Price::from("103.50"),
1309                Price::from("99.75"),
1310                Price::from("102.50"),
1311                Quantity::from("4.125"),
1312                at(60_000_000_000),
1313                at(60_000_000_000),
1314            ),
1315            Bar::new(
1316                BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
1317                Price::from("102.50"),
1318                Price::from("104.75"),
1319                Price::from("100.25"),
1320                Price::from("103.25"),
1321                Quantity::from("6.375"),
1322                at(120_000_000_000),
1323                at(120_000_000_000),
1324            ),
1325        ];
1326
1327        let bars = loader.load_bars(path, None, Some(limit)).unwrap();
1328
1329        assert_eq!(bars, expected[..limit.min(2)]);
1330        assert!(bars.iter().all(|bar| {
1331            [bar.open, bar.high, bar.low, bar.close]
1332                .iter()
1333                .all(|p| p.precision == 2)
1334                && bar.volume.precision == 3
1335        }));
1336    }
1337
1338    #[rstest]
1339    fn load_quotes_backfills_all_four_precisions() {
1340        let dir = TempDir::new().unwrap();
1341        let path = write_file(
1342            dir.path(),
1343            "quotes.csv",
1344            &[
1345                "time_exchange;time_coinapi;ask_px;ask_sx;bid_px;bid_sx",
1346                "2025-09-01T00:00:00.1000000;2025-09-01T00:00:00.2000000;101.25;2.5;100.5;1",
1347                "2025-09-01T00:00:01.3000000;2025-09-01T00:00:01.4000000;101.375;2.0625;100.125;0.125",
1348            ],
1349        );
1350
1351        let quotes = load_quotes(path, coinbase(), None, None, None).unwrap();
1352
1353        assert_eq!(
1354            quotes,
1355            [
1356                QuoteTick::new(
1357                    coinbase(),
1358                    Price::from("100.500"),
1359                    Price::from("101.250"),
1360                    Quantity::from("1.0000"),
1361                    Quantity::from("2.5000"),
1362                    at(100_000_000),
1363                    at(200_000_000)
1364                ),
1365                QuoteTick::new(
1366                    coinbase(),
1367                    Price::from("100.125"),
1368                    Price::from("101.375"),
1369                    Quantity::from("0.1250"),
1370                    Quantity::from("2.0625"),
1371                    at(1_300_000_000),
1372                    at(1_400_000_000)
1373                ),
1374            ]
1375        );
1376        assert_eq!(
1377            quotes
1378                .iter()
1379                .map(|q| (
1380                    q.bid_price.precision,
1381                    q.ask_price.precision,
1382                    q.bid_size.precision,
1383                    q.ask_size.precision
1384                ))
1385                .collect::<Vec<_>>(),
1386            [(3, 3, 4, 4); 2]
1387        );
1388    }
1389
1390    #[rstest]
1391    #[case(0, false)]
1392    #[case(1, false)]
1393    #[case(0, true)]
1394    #[case(1, true)]
1395    fn snapshot_limit_retains_a_complete_clear_event(
1396        #[case] limit: usize,
1397        #[case] streaming: bool,
1398    ) {
1399        let dir = TempDir::new().unwrap();
1400        let path = write_file(
1401            dir.path(),
1402            "book.csv",
1403            &[
1404                BOOK_HEADER,
1405                "00:00:00.0000000;00:00:00.2500000;SNAPSHOT;1;100.50;2.0;a",
1406                "00:00:01.0000000;00:00:01.5000000;SET;1;101.25;3.0;a",
1407            ],
1408        );
1409
1410        let deltas = if streaming {
1411            stream_deltas(
1412                &path,
1413                coinbase(),
1414                Some(date()),
1415                1,
1416                Some(2),
1417                Some(1),
1418                Some(limit),
1419            )
1420            .unwrap()
1421            .collect::<anyhow::Result<Vec<_>>>()
1422            .unwrap()
1423            .into_iter()
1424            .flatten()
1425            .collect::<Vec<_>>()
1426        } else {
1427            load_deltas(
1428                &path,
1429                coinbase(),
1430                Some(date()),
1431                Some(2),
1432                Some(1),
1433                Some(limit),
1434            )
1435            .unwrap()
1436        };
1437
1438        let mut clear = OrderBookDelta::clear(coinbase(), 0, at(0), at(250_000_000));
1439        clear.flags = SNAPSHOT | LAST;
1440
1441        assert_eq!(deltas, [clear][..limit]);
1442        assert!(
1443            deltas
1444                .iter()
1445                .all(|d| d.order.price.precision == 0 && d.order.size.precision == 0)
1446        );
1447    }
1448
1449    #[rstest]
1450    #[case(false)]
1451    #[case(true)]
1452    fn zero_sized_trades_do_not_consume_the_limit(#[case] streaming: bool) {
1453        let dir = TempDir::new().unwrap();
1454        let zero = trade_row("zero", "99.75", "0");
1455        let first = trade_row("first", "101.25", "2.125");
1456        let second = trade_row("second", "102.50", "3.375");
1457        let path = write_file(
1458            dir.path(),
1459            "trades.csv",
1460            &[TRADES_HEADER, &zero, &first, &zero, &second],
1461        );
1462
1463        let trades = if streaming {
1464            stream_trades(&path, coinbase(), 1, Some(2), Some(3), Some(1))
1465                .unwrap()
1466                .collect::<anyhow::Result<Vec<_>>>()
1467                .unwrap()
1468                .into_iter()
1469                .flatten()
1470                .collect::<Vec<_>>()
1471        } else {
1472            load_trades(&path, coinbase(), Some(2), Some(3), Some(1)).unwrap()
1473        };
1474
1475        assert_eq!(
1476            trades,
1477            [TradeTick::new(
1478                coinbase(),
1479                Price::from("101.25"),
1480                Quantity::from("2.125"),
1481                AggressorSide::Buy,
1482                TradeId::from("first"),
1483                at(0),
1484                at(0)
1485            )]
1486        );
1487        assert_eq!(
1488            (trades[0].price.precision, trades[0].size.precision),
1489            (2, 3)
1490        );
1491    }
1492
1493    #[rstest]
1494    #[case::missing_column(
1495        "time_exchange;time_coinapi;guid;base_amount;taker_side",
1496        "2025-09-01T00:00:00;2025-09-01T00:00:01;first;2;BUY",
1497        "flat file has no 'price' column"
1498    )]
1499    #[case::invalid_price(
1500        TRADES_HEADER,
1501        "2025-09-01T00:00:00;2025-09-01T00:00:01;first;bad;2;BUY",
1502        "invalid price 'bad':"
1503    )]
1504    #[case::invalid_exchange_time(
1505        TRADES_HEADER,
1506        "bad;2025-09-01T00:00:01;first;101.25;2;BUY",
1507        "invalid time_exchange 'bad':"
1508    )]
1509    #[case::invalid_receive_time(
1510        TRADES_HEADER,
1511        "2025-09-01T00:00:00;bad;first;101.25;2;BUY",
1512        "invalid time_coinapi 'bad':"
1513    )]
1514    #[case::invalid_size(
1515        TRADES_HEADER,
1516        "2025-09-01T00:00:00;2025-09-01T00:00:01;first;101.25;bad;BUY",
1517        "invalid base_amount 'bad':"
1518    )]
1519    fn malformed_trade_rows_report_the_field_and_source_value(
1520        #[case] header: &str,
1521        #[case] row: &str,
1522        #[case] context: &str,
1523    ) {
1524        let dir = TempDir::new().unwrap();
1525        let path = write_file(dir.path(), "trades.csv", &[header, row]);
1526
1527        let error = load_trades(path, coinbase(), None, None, None)
1528            .unwrap_err()
1529            .to_string();
1530
1531        if context.ends_with(':') {
1532            assert!(error.starts_with(&format!("{context} ")), "{error}");
1533        } else {
1534            assert_eq!(error, context);
1535        }
1536    }
1537}