use crate::books::Level;
use crate::event::{DataKind, MarketEvent};
use crate::exchange::lse::error::LseError;
use crate::exchange::lse::export::{LseExport, LseExportTimeframe};
use crate::subscription::book::OrderBookL1;
use crate::subscription::candle::{Candle, CandleInterval, IntervalStep, close_time_from_open};
use crate::subscription::trade::PublicTrade;
use chrono::{DateTime, Utc};
use parquet::basic::{ConvertedType, LogicalType, Repetition, TimeUnit, Type as PhysicalType};
use parquet::column::reader::ColumnReaderImpl;
use parquet::data_type::{ByteArray, ByteArrayType, DataType, DoubleType, Int64Type};
use parquet::errors::ParquetError;
use parquet::file::reader::{FileReader, RowGroupReader, SerializedFileReader};
use parquet::file::statistics::Statistics;
use parquet::schema::types::{Type as SchemaType, TypePtr};
use rust_decimal::Decimal;
use rustrade_instrument::{exchange::ExchangeId, instrument::InstrumentIndex};
use smol_str::SmolStr;
use std::fs::File;
use std::path::Path;
use tracing::{info, warn};
const COL_TS: &str = "ts";
const COL_SYMBOL: &str = "symbol";
const BATCH_ROWS: usize = 8 * 1024;
const MAX_VALUE_COLUMNS: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RowLayout {
Quote { bid: usize, ask: usize },
QuoteFromPrice { price: usize, ask: usize },
Trade { price: usize, volume: usize },
Candle {
open: usize,
high: usize,
low: usize,
close: usize,
volume: Option<usize>,
interval: CandleInterval,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LeafColumn {
leaf: usize,
optional: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ColumnPlan {
layout: RowLayout,
ts: LeafColumn,
symbol: LeafColumn,
values: Vec<LeafColumn>,
discarded_volume: Option<LeafColumn>,
}
impl ColumnPlan {
fn resolve(fields: &[TypePtr], timeframe: LseExportTimeframe) -> Result<Self, LseError> {
let unsupported = || LseError::UnsupportedSchema {
columns: column_names(fields),
};
if !fields.iter().all(|field| is_flat_primitive(field)) {
return Err(unsupported());
}
let has = |name: &str| column(fields, name).is_some();
if !has(COL_TS) || !has(COL_SYMBOL) {
return Err(unsupported());
}
let (layout, names): (RowLayout, Vec<&str>) = match timeframe {
LseExportTimeframe::Candle(interval) => {
let mut names = vec!["open", "high", "low", "close"];
if !names.iter().all(|name| has(name)) {
return Err(unsupported());
}
let mut volume = None;
if has("volume") {
volume = Some(names.len());
names.push("volume");
}
(
RowLayout::Candle {
open: 0,
high: 1,
low: 2,
close: 3,
volume,
interval,
},
names,
)
}
LseExportTimeframe::Tick => match (has("bid"), has("ask"), has("price")) {
(true, true, _) => (RowLayout::Quote { bid: 0, ask: 1 }, vec!["bid", "ask"]),
(false, true, true) => (
RowLayout::QuoteFromPrice { price: 0, ask: 1 },
vec!["price", "ask"],
),
(false, false, true) if has("volume") => (
RowLayout::Trade {
price: 0,
volume: 1,
},
vec!["price", "volume"],
),
_ => return Err(unsupported()),
},
};
let discarded_volume = match (&layout, has("volume")) {
(RowLayout::QuoteFromPrice { .. }, true) => Some(double_column(fields, "volume")?),
_ => None,
};
Ok(Self {
layout,
ts: timestamp_column(fields, COL_TS)?,
symbol: utf8_column(fields, COL_SYMBOL)?,
values: names
.into_iter()
.map(|name| double_column(fields, name))
.collect::<Result<_, _>>()?,
discarded_volume,
})
}
}
fn column<'a>(fields: &'a [TypePtr], name: &str) -> Option<(usize, &'a TypePtr)> {
fields
.iter()
.enumerate()
.find(|(_, field)| field.name() == name)
}
fn column_names(fields: &[TypePtr]) -> String {
fields
.iter()
.map(|field| field.name())
.collect::<Vec<_>>()
.join(", ")
}
fn is_flat_primitive(field: &SchemaType) -> bool {
let info = field.get_basic_info();
field.is_primitive() && !(info.has_repetition() && info.repetition() == Repetition::REPEATED)
}
fn is_optional(field: &SchemaType) -> bool {
let info = field.get_basic_info();
info.has_repetition() && info.repetition() == Repetition::OPTIONAL
}
fn required_column<'a>(
fields: &'a [TypePtr],
name: &str,
) -> Result<(usize, &'a TypePtr), LseError> {
column(fields, name).ok_or_else(|| LseError::UnsupportedSchema {
columns: column_names(fields),
})
}
fn double_column(fields: &[TypePtr], name: &str) -> Result<LeafColumn, LseError> {
let (leaf, field) = required_column(fields, name)?;
if field.get_physical_type() != PhysicalType::DOUBLE {
return Err(LseError::UnsupportedColumnType {
column: name.to_owned(),
required: "DOUBLE",
found: describe_type(field),
});
}
Ok(LeafColumn {
leaf,
optional: is_optional(field),
})
}
fn utf8_column(fields: &[TypePtr], name: &str) -> Result<LeafColumn, LseError> {
let (leaf, field) = required_column(fields, name)?;
let info = field.get_basic_info();
let utf8 = matches!(info.logical_type_ref(), Some(LogicalType::String))
|| info.converted_type() == ConvertedType::UTF8;
if field.get_physical_type() != PhysicalType::BYTE_ARRAY || !utf8 {
return Err(LseError::UnsupportedColumnType {
column: name.to_owned(),
required: "BYTE_ARRAY (STRING)",
found: describe_type(field),
});
}
Ok(LeafColumn {
leaf,
optional: is_optional(field),
})
}
fn timestamp_column(fields: &[TypePtr], name: &str) -> Result<LeafColumn, LseError> {
let (leaf, field) = required_column(fields, name)?;
let info = field.get_basic_info();
let utc_micros = match info.logical_type_ref() {
Some(LogicalType::Timestamp(timestamp)) => {
timestamp.is_adjusted_to_u_t_c && timestamp.unit == TimeUnit::MICROS
}
None => info.converted_type() == ConvertedType::TIMESTAMP_MICROS,
Some(_) => false,
};
if field.get_physical_type() != PhysicalType::INT64 || !utc_micros {
return Err(LseError::UnsupportedColumnType {
column: name.to_owned(),
required: "INT64 (TIMESTAMP(MICROS, is_adjusted_to_utc = true))",
found: describe_type(field),
});
}
Ok(LeafColumn {
leaf,
optional: is_optional(field),
})
}
fn describe_type(field: &SchemaType) -> String {
let info = field.get_basic_info();
let physical = field.get_physical_type();
match (info.logical_type_ref(), info.converted_type()) {
(Some(logical), _) => format!("{physical} ({logical:?})"),
(None, ConvertedType::NONE) => physical.to_string(),
(None, converted) => format!("{physical} ({converted:?})"),
}
}
struct BatchedColumn<T: DataType> {
reader: ColumnReaderImpl<T>,
leaf: usize,
optional: bool,
values: Vec<T::T>,
def_levels: Vec<i16>,
cursor: usize,
}
impl<T: DataType> BatchedColumn<T> {
fn open(row_group: &dyn RowGroupReader, column: LeafColumn) -> Result<Self, LseError> {
let reader = row_group.get_column_reader(column.leaf)?;
let reader = T::get_column_reader(reader).ok_or_else(|| {
ParquetError::General(format!(
"export column {} is not a {} column",
column.leaf,
T::get_physical_type()
))
})?;
Ok(Self {
reader,
leaf: column.leaf,
optional: column.optional,
values: Vec::new(),
def_levels: Vec::new(),
cursor: 0,
})
}
fn read_batch(&mut self) -> Result<usize, LseError> {
self.values.clear();
self.def_levels.clear();
self.cursor = 0;
let (rows, _values, _levels) = match self.optional {
true => self.reader.read_records(
BATCH_ROWS,
Some(&mut self.def_levels),
None,
&mut self.values,
)?,
false => self
.reader
.read_records(BATCH_ROWS, None, None, &mut self.values)?,
};
Ok(rows)
}
fn take(&mut self, row: usize) -> Result<Option<T::T>, LseError> {
let value = self.peek(row)?.cloned();
self.advance(row);
Ok(value)
}
fn peek(&self, row: usize) -> Result<Option<&T::T>, LseError> {
if self.is_null(row) {
return Ok(None);
}
let Some(value) = self.values.get(self.cursor) else {
return Err(ParquetError::General(format!(
"export column {} row {row} is marked present, but its batch holds only {} value(s)",
self.leaf,
self.values.len()
))
.into());
};
Ok(Some(value))
}
fn advance(&mut self, row: usize) {
if !self.is_null(row) {
self.cursor += 1;
}
}
fn is_null(&self, row: usize) -> bool {
self.optional && self.def_levels.get(row).copied().unwrap_or_default() == 0
}
}
struct RowGroupCursor {
ts: BatchedColumn<Int64Type>,
symbol: BatchedColumn<ByteArrayType>,
values: Vec<BatchedColumn<DoubleType>>,
rows: usize,
row: usize,
}
impl RowGroupCursor {
fn open(
reader: &SerializedFileReader<File>,
group: usize,
plan: &ColumnPlan,
) -> Result<Self, LseError> {
let row_group = reader.get_row_group(group)?;
let row_group = row_group.as_ref();
Ok(Self {
ts: BatchedColumn::open(row_group, plan.ts)?,
symbol: BatchedColumn::open(row_group, plan.symbol)?,
values: plan
.values
.iter()
.map(|column| BatchedColumn::open(row_group, *column))
.collect::<Result<_, _>>()?,
rows: 0,
row: 0,
})
}
fn read_batch(&mut self) -> Result<usize, LseError> {
let rows = self.ts.read_batch()?;
let mut agreed = self.symbol.read_batch()? == rows;
for column in &mut self.values {
agreed &= column.read_batch()? == rows;
}
if !agreed {
return Err(ParquetError::General(format!(
"export columns disagree on the row count of row group: {COL_TS} yielded {rows} rows"
))
.into());
}
self.rows = rows;
self.row = 0;
Ok(rows)
}
}
pub fn symbols_in_export(path: impl AsRef<Path>) -> Result<Vec<String>, LseError> {
let reader = open_reader(path.as_ref())?;
let fields = reader.metadata().file_metadata().schema().get_fields();
if !fields.iter().all(|field| is_flat_primitive(field)) {
return Err(LseError::UnsupportedSchema {
columns: column_names(fields),
});
}
let column = utf8_column(fields, COL_SYMBOL)?;
let mut symbols: Vec<String> = Vec::new();
for group in 0..reader.num_row_groups() {
let row_group = reader.get_row_group(group)?;
let mut buffered = BatchedColumn::<ByteArrayType>::open(row_group.as_ref(), column)?;
loop {
let rows = buffered.read_batch()?;
if rows == 0 {
break;
}
for row in 0..rows {
let value = buffered
.peek(row)?
.ok_or(LseError::NullValue { column: COL_SYMBOL })?
.as_utf8()?;
if !symbols.iter().any(|symbol| symbol == value) {
symbols.push(value.to_owned());
}
buffered.advance(row);
}
}
}
Ok(symbols)
}
pub fn read_export(
export: &LseExport,
instrument: InstrumentIndex,
) -> Result<LseExportEvents, LseError> {
let reader = open_reader(export.path())?;
let plan = ColumnPlan::resolve(
reader.metadata().file_metadata().schema().get_fields(),
export.timeframe(),
)?;
let rows = reader.metadata().file_metadata().num_rows();
info!(
path = %export.path().display(),
dataset = export.dataset().as_catalog_str(),
symbol = export.symbol(),
rows,
layout = ?plan.layout,
"decoding export artifact"
);
if rows == 0 {
warn!(
path = %export.path().display(),
symbol = export.symbol(),
"export artifact contains no rows; a backtest fed from it will see no events"
);
}
Ok(LseExportEvents {
reader,
plan,
cursor: None,
group: 0,
expected_symbol: export.symbol().to_owned(),
exchange: export.exchange_id(),
instrument,
previous: None,
previous_open: None,
failed: false,
remaining: usize::try_from(rows).unwrap_or(usize::MAX),
warned_discarded_volume: false,
})
}
pub struct LseExportEvents {
reader: SerializedFileReader<File>,
plan: ColumnPlan,
cursor: Option<RowGroupCursor>,
group: usize,
expected_symbol: String,
exchange: ExchangeId,
instrument: InstrumentIndex,
previous: Option<DateTime<Utc>>,
previous_open: Option<DateTime<Utc>>,
failed: bool,
remaining: usize,
warned_discarded_volume: bool,
}
impl std::fmt::Debug for LseExportEvents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LseExportEvents")
.field("symbol", &self.expected_symbol)
.field("exchange", &self.exchange)
.field("layout", &self.plan.layout)
.field("previous", &self.previous)
.finish_non_exhaustive()
}
}
impl Iterator for LseExportEvents {
type Item = Result<MarketEvent<InstrumentIndex, DataKind>, LseError>;
fn next(&mut self) -> Option<Self::Item> {
if self.failed {
return None;
}
match self.read_next() {
Ok(Some(event)) => Some(Ok(event)),
Ok(None) => None,
Err(error) => {
self.failed = true;
Some(Err(error))
}
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
if self.failed {
(0, Some(0))
} else {
(0, Some(self.remaining))
}
}
}
impl std::iter::FusedIterator for LseExportEvents {}
impl LseExportEvents {
fn read_next(&mut self) -> Result<Option<MarketEvent<InstrumentIndex, DataKind>>, LseError> {
let (micros, values) = loop {
let Some(cursor) = self.cursor.as_mut() else {
if self.group >= self.reader.num_row_groups() {
return Ok(None);
}
self.check_discarded_volume(self.group);
self.cursor = Some(RowGroupCursor::open(&self.reader, self.group, &self.plan)?);
self.group += 1;
continue;
};
if cursor.row == cursor.rows && cursor.read_batch()? == 0 {
self.cursor = None;
continue;
}
let row = cursor.row;
cursor.row += 1;
match cursor.symbol.peek(row)?.map(ByteArray::data) {
Some(symbol) if symbol == self.expected_symbol.as_bytes() => {}
Some(symbol) => {
return Err(LseError::SymbolMismatch {
expected: self.expected_symbol.clone(),
found: String::from_utf8_lossy(symbol).into_owned(),
});
}
None => return Err(LseError::NullValue { column: COL_SYMBOL }),
}
cursor.symbol.advance(row);
let Some(micros) = cursor.ts.take(row)? else {
return Err(LseError::NullValue { column: COL_TS });
};
let mut values = [None; MAX_VALUE_COLUMNS];
for (slot, column) in values.iter_mut().zip(cursor.values.iter_mut()) {
*slot = column.take(row)?;
}
break (micros, values);
};
self.remaining = self.remaining.saturating_sub(1);
self.decode(micros, &values).map(Some)
}
fn check_discarded_volume(&mut self, group: usize) {
if self.warned_discarded_volume {
return;
}
let Some(column) = self.plan.discarded_volume else {
return;
};
let Some(statistics) = self
.reader
.metadata()
.row_group(group)
.column(column.leaf)
.statistics()
else {
return;
};
let Statistics::Double(bounds) = statistics else {
return;
};
let populated = matches!(bounds.min_opt(), Some(min) if *min != 0.0)
|| matches!(bounds.max_opt(), Some(max) if *max != 0.0);
if populated {
self.warned_discarded_volume = true;
warn!(
symbol = %self.expected_symbol,
"LSE quote export populated the `volume` column with a value other than the \
measured `0.0`, which this layout discards: an L1 quote has no undifferentiated \
size field. Reported once per artifact; the decoded bid/ask are unaffected"
);
}
}
fn decode(
&mut self,
micros: i64,
values: &[Option<f64>; MAX_VALUE_COLUMNS],
) -> Result<MarketEvent<InstrumentIndex, DataKind>, LseError> {
let observed = DateTime::from_timestamp_micros(micros)
.ok_or(LseError::TimestampNotRepresentable { micros })?;
let (time_exchange, kind) = self.decode_kind(values, observed)?;
let fixed_candle = match self.plan.layout {
RowLayout::Candle { interval, .. } => match interval.to_step() {
IntervalStep::Fixed(width) => Some((interval, width)),
IntervalStep::Months(_) => None,
},
_ => None,
};
let violated = self.previous.filter(|previous| {
if fixed_candle.is_some() {
time_exchange <= *previous
} else {
time_exchange < *previous
}
});
if let Some(previous) = violated {
return Err(LseError::NonMonotonicTimestamps {
previous,
found: time_exchange,
});
}
if let (Some((interval, width)), Some(previous_open)) = (fixed_candle, self.previous_open) {
let actual = observed - previous_open;
if actual < width {
return Err(LseError::UnexpectedCandleResolution {
symbol: self.expected_symbol.clone(),
interval,
previous_open,
open: observed,
actual,
});
}
}
self.previous = Some(time_exchange);
self.previous_open = Some(observed);
Ok(MarketEvent {
time_exchange,
time_received: time_exchange,
exchange: self.exchange,
instrument: self.instrument,
kind,
})
}
fn decode_kind(
&mut self,
values: &[Option<f64>; MAX_VALUE_COLUMNS],
time: DateTime<Utc>,
) -> Result<(DateTime<Utc>, DataKind), LseError> {
match self.plan.layout {
RowLayout::Quote { bid, ask } => Ok((
time,
quote(
time,
required(values, bid, "bid")?,
required(values, ask, "ask")?,
),
)),
RowLayout::QuoteFromPrice { price, ask } => {
let bid = required(values, price, "price")?;
let kind = match optional(values, ask)? {
Some(ask) => quote(time, bid, ask),
None => DataKind::OrderBookL1(OrderBookL1 {
last_update_time: time,
best_bid: Some(Level::new(bid, Decimal::ZERO)),
best_ask: None,
}),
};
Ok((time, kind))
}
RowLayout::Trade { price, volume } => Ok((
time,
DataKind::Trade(PublicTrade {
id: SmolStr::default(),
price: required(values, price, "price")?,
amount: required(values, volume, "volume")?,
side: None,
}),
)),
RowLayout::Candle {
open,
high,
low,
close,
volume,
interval,
} => {
let close_time = close_time_from_open(time, interval.to_step()).ok_or(
LseError::TimestampOverflow {
open: time,
interval,
},
)?;
Ok((
close_time,
DataKind::Candle(Candle {
close_time,
open: required(values, open, "open")?,
high: required(values, high, "high")?,
low: required(values, low, "low")?,
close: required(values, close, "close")?,
volume: match volume {
Some(volume) => optional(values, volume)?,
None => None,
},
trade_count: None,
}),
))
}
}
}
}
fn quote(time: DateTime<Utc>, bid: Decimal, ask: Decimal) -> DataKind {
DataKind::OrderBookL1(OrderBookL1 {
last_update_time: time,
best_bid: Some(Level::new(bid, Decimal::ZERO)),
best_ask: Some(Level::new(ask, Decimal::ZERO)),
})
}
fn required(
values: &[Option<f64>; MAX_VALUE_COLUMNS],
slot: usize,
column: &'static str,
) -> Result<Decimal, LseError> {
values
.get(slot)
.copied()
.flatten()
.ok_or(LseError::NullValue { column })
.and_then(convert)
}
fn optional(
values: &[Option<f64>; MAX_VALUE_COLUMNS],
slot: usize,
) -> Result<Option<Decimal>, LseError> {
values.get(slot).copied().flatten().map(convert).transpose()
}
fn convert(value: f64) -> Result<Decimal, LseError> {
Decimal::try_from(value).map_err(|error| LseError::PriceNotRepresentable {
value,
message: error.to_string(),
})
}
fn open_reader(path: &Path) -> Result<SerializedFileReader<File>, LseError> {
let file = File::open(path).map_err(|source| LseError::Io {
message: format!("opening {}", path.display()),
source,
})?;
Ok(SerializedFileReader::new(file)?)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use std::sync::Arc;
fn field(name: &str, physical: PhysicalType, logical: Option<LogicalType>) -> TypePtr {
Arc::new(
SchemaType::primitive_type_builder(name, physical)
.with_repetition(Repetition::REQUIRED)
.with_logical_type(logical)
.build()
.expect("hand-built test schema field is valid"),
)
}
fn schema(values: &[&str]) -> Vec<TypePtr> {
let mut fields = vec![
field(
COL_TS,
PhysicalType::INT64,
Some(LogicalType::timestamp(true, TimeUnit::MICROS)),
),
field(
COL_SYMBOL,
PhysicalType::BYTE_ARRAY,
Some(LogicalType::String),
),
];
fields.extend(
values
.iter()
.map(|name| field(name, PhysicalType::DOUBLE, None)),
);
fields
}
#[test]
fn test_no_layout_exceeds_the_value_column_budget() {
let cases = [
(
schema(&["open", "high", "low", "close", "volume"]),
LseExportTimeframe::Candle(CandleInterval::Min1),
),
(
schema(&["open", "high", "low", "close"]),
LseExportTimeframe::Candle(CandleInterval::Min1),
),
(schema(&["bid", "ask"]), LseExportTimeframe::Tick),
(
schema(&["price", "ask", "volume"]),
LseExportTimeframe::Tick,
),
(schema(&["price", "volume"]), LseExportTimeframe::Tick),
];
for (fields, timeframe) in cases {
let plan =
ColumnPlan::resolve(&fields, timeframe).expect("measured layout must resolve");
assert!(
plan.values.len() <= MAX_VALUE_COLUMNS,
"{:?} names {} value columns, over the budget of {MAX_VALUE_COLUMNS}",
plan.layout,
plan.values.len()
);
}
}
}