use std::{
ffi::OsStr,
fs::File,
io::{BufReader, Read},
path::{Component, Path},
str::FromStr,
};
use csv::{Reader, ReaderBuilder, StringRecord};
use flate2::read::MultiGzDecoder;
use jiff::{
civil::{Date, DateTime, Time},
tz::TimeZone,
};
use nautilus_core::{UnixNanos, serialization::decimal};
use nautilus_model::types::fixed::FIXED_PRECISION;
use rust_decimal::Decimal;
use crate::http::parse::unix_nanos;
const BUFFER_SIZE: usize = 8 * 1024 * 1024;
pub(crate) struct Rows {
reader: Reader<Box<dyn Read>>,
record: StringRecord,
}
impl Rows {
pub(crate) fn open(path: &Path) -> anyhow::Result<Self> {
let file = File::open(path)
.map_err(|e| anyhow::anyhow!("failed to open '{}': {e}", path.display()))?;
let buffered = BufReader::with_capacity(BUFFER_SIZE, file);
let is_gzipped = path
.extension()
.and_then(OsStr::to_str)
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"));
let input: Box<dyn Read> = if is_gzipped {
Box::new(MultiGzDecoder::new(buffered))
} else {
Box::new(buffered)
};
let reader = ReaderBuilder::new()
.delimiter(b';')
.has_headers(true)
.from_reader(input);
Ok(Self {
reader,
record: StringRecord::new(),
})
}
pub(crate) fn column(&mut self, name: &str) -> anyhow::Result<usize> {
self.reader
.headers()?
.iter()
.position(|header| header == name)
.ok_or_else(|| anyhow::anyhow!("flat file has no '{name}' column"))
}
pub(crate) fn next(&mut self) -> anyhow::Result<Option<&StringRecord>> {
if self.reader.read_record(&mut self.record)? {
Ok(Some(&self.record))
} else {
Ok(None)
}
}
}
pub(crate) fn text(record: &StringRecord, index: usize) -> anyhow::Result<&str> {
record.get(index).ok_or_else(|| {
anyhow::anyhow!(
"row at line {} has no column {index}",
record.position().map_or(0, csv::Position::line)
)
})
}
pub(crate) fn number(record: &StringRecord, index: usize, field: &str) -> anyhow::Result<Decimal> {
let value = text(record, index)?;
decimal::parse(value).map_err(|e| anyhow::anyhow!("invalid {field} '{value}': {e}"))
}
pub(crate) fn date_time(value: &str, field: &str) -> anyhow::Result<UnixNanos> {
let parsed =
DateTime::from_str(value).map_err(|e| anyhow::anyhow!("invalid {field} '{value}': {e}"))?;
utc_nanos(parsed, field)
}
pub(crate) fn time_on(date: Date, value: &str, field: &str) -> anyhow::Result<UnixNanos> {
let time =
Time::from_str(value).map_err(|e| anyhow::anyhow!("invalid {field} '{value}': {e}"))?;
utc_nanos(date.to_datetime(time), field)
}
fn utc_nanos(value: DateTime, field: &str) -> anyhow::Result<UnixNanos> {
let timestamp = value
.to_zoned(TimeZone::UTC)
.map_err(|e| anyhow::anyhow!("invalid {field} {value}: {e}"))?
.timestamp();
unix_nanos(timestamp).map_err(|e| anyhow::anyhow!("invalid {field}: {e}"))
}
#[must_use]
pub(crate) fn partition_date(path: &Path) -> Option<Date> {
path.components().rev().find_map(|component| {
let Component::Normal(name) = component else {
return None;
};
let digits = name.to_str()?.strip_prefix("D-")?;
if !(digits.len() == 8 || digits.len() == 10) || !digits.bytes().all(|b| b.is_ascii_digit())
{
return None;
}
Date::new(
digits[0..4].parse().ok()?,
digits[4..6].parse().ok()?,
digits[6..8].parse().ok()?,
)
.ok()
})
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Precision {
pub(crate) value: u8,
pub(crate) is_inferred: bool,
}
impl Precision {
pub(crate) fn new(explicit: Option<u8>) -> Self {
Self {
value: explicit.unwrap_or(0),
is_inferred: explicit.is_none(),
}
}
pub(crate) fn fixed(self) -> Self {
Self {
value: self.value,
is_inferred: false,
}
}
pub(crate) fn fit(&mut self, value: Decimal) {
if self.is_inferred {
let scale = u8::try_from(value.normalize().scale()).unwrap_or(u8::MAX);
self.value = self.value.max(scale.min(FIXED_PRECISION));
}
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case("T-LIMITBOOK_FULL/D-20250901/E-BINANCE/IDDI-1+SC-X+S-Y.csv.gz", Some((2025, 9, 1)))]
#[case("T-LIMITBOOK_FULL/D-2026011722/E-COINBASE/file.csv.gz", Some((2026, 1, 17)))]
#[case("data/D-2025090/file.csv", None)]
#[case("data/D-20251301/file.csv", None)]
#[case("limitbook_full.csv", None)]
fn partition_date_reads_daily_and_hourly_partitions(
#[case] path: &str,
#[case] expected: Option<(i16, i8, i8)>,
) {
let expected = expected.map(|(y, m, d)| Date::new(y, m, d).unwrap());
assert_eq!(partition_date(Path::new(path)), expected);
}
#[rstest]
fn date_time_reads_seven_fractional_digits_as_utc() {
assert_eq!(
date_time("2025-09-01T00:00:00.1780000", "time_exchange").unwrap(),
UnixNanos::from(1_756_684_800_178_000_000)
);
}
#[rstest]
fn time_on_combines_date_and_time_of_day() {
let date = Date::new(2025, 9, 1).unwrap();
assert_eq!(
time_on(date, "00:00:00.0140000", "time_exchange").unwrap(),
UnixNanos::from(1_756_684_800_014_000_000)
);
}
#[rstest]
#[case(None, &["1.5", "2.25", "3"], 2, true)]
#[case(Some(4), &["1.5", "2.25"], 4, false)]
#[case(None, &["1.500"], 1, true)]
fn precision_fits_inferred_values_only(
#[case] explicit: Option<u8>,
#[case] values: &[&str],
#[case] expected: u8,
#[case] is_inferred: bool,
) {
let mut precision = Precision::new(explicit);
for value in values {
precision.fit(Decimal::from_str(value).unwrap());
}
assert_eq!(precision.value, expected);
assert_eq!(precision.is_inferred, is_inferred);
}
}