use crate::data::DataSourceSpec;
use crate::domain::{ChainSnapshot, SimTime, StepIndex};
use crate::error::BacktestError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TapeMeta {
pub data_identity: String,
pub non_empty: bool,
pub first_ts: SimTime,
pub final_step: StepIndex,
}
impl TapeMeta {
pub fn from_tape(data_identity: String, tape: &[ChainSnapshot]) -> Result<Self, BacktestError> {
let mut iter = tape.iter();
let first = iter.next().ok_or_else(|| {
BacktestError::Conversion("cannot build tape meta from an empty tape".to_string())
})?;
if first.step.value() != 0 {
return Err(BacktestError::Conversion(format!(
"tape must start at step 0, got step {} (the StepIndex contract is \
0-based and consecutive)",
first.step.value()
)));
}
let first_ts = first.ts;
let mut prev = first.ts;
let mut prev_step = first.step;
for snapshot in iter {
if snapshot.ts <= prev {
return Err(BacktestError::DataOutOfOrder {
step: snapshot.step.value(),
ts: snapshot.ts.value(),
prev: prev.value(),
});
}
let expected = prev_step
.value()
.checked_add(1)
.ok_or(BacktestError::ArithmeticOverflow)?;
if snapshot.step.value() != expected {
return Err(BacktestError::Conversion(format!(
"tape step {} is out of sequence: expected consecutive step {expected} \
(the StepIndex contract is 0-based and +1 per snapshot)",
snapshot.step.value()
)));
}
prev = snapshot.ts;
prev_step = snapshot.step;
}
Ok(Self {
data_identity,
non_empty: true,
first_ts,
final_step: prev_step,
})
}
}
pub trait DataFeed {
fn next(&mut self) -> Result<Option<ChainSnapshot>, BacktestError>;
fn meta(&self) -> DataSourceSpec;
fn tape_meta(&self) -> &TapeMeta;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FeedKind {
Csv,
Parquet,
#[cfg(feature = "simulator")]
Simulator,
}
impl FeedKind {
#[must_use]
pub fn of(spec: &DataSourceSpec) -> Self {
match spec {
DataSourceSpec::Csv { .. } => Self::Csv,
DataSourceSpec::Parquet { .. } => Self::Parquet,
#[cfg(feature = "simulator")]
DataSourceSpec::Simulator { .. } => Self::Simulator,
}
}
#[must_use]
pub const fn feature_flag(self) -> &'static str {
match self {
Self::Csv | Self::Parquet => "default",
#[cfg(feature = "simulator")]
Self::Simulator => "simulator",
}
}
#[must_use]
pub const fn is_reproducible(self) -> bool {
match self {
Self::Csv | Self::Parquet => true,
#[cfg(feature = "simulator")]
Self::Simulator => false,
}
}
}
#[must_use]
pub const fn feed_catalogue() -> &'static [FeedKind] {
&[
FeedKind::Csv,
FeedKind::Parquet,
#[cfg(feature = "simulator")]
FeedKind::Simulator,
]
}
#[cfg(test)]
pub(crate) struct InMemoryFeed {
tape: Vec<ChainSnapshot>,
cursor: usize,
meta: TapeMeta,
source: DataSourceSpec,
}
#[cfg(test)]
impl InMemoryFeed {
pub(crate) fn new(
data_identity: String,
tape: Vec<ChainSnapshot>,
source: DataSourceSpec,
) -> Result<Self, BacktestError> {
let meta = TapeMeta::from_tape(data_identity, &tape)?;
Ok(Self {
tape,
cursor: 0,
meta,
source,
})
}
}
#[cfg(test)]
impl DataFeed for InMemoryFeed {
fn next(&mut self) -> Result<Option<ChainSnapshot>, BacktestError> {
match self.tape.get(self.cursor) {
Some(snapshot) => {
self.cursor += 1;
Ok(Some(snapshot.clone()))
}
None => Ok(None),
}
}
fn meta(&self) -> DataSourceSpec {
self.source.clone()
}
fn tape_meta(&self) -> &TapeMeta {
&self.meta
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{DataFeed, FeedKind, InMemoryFeed, TapeMeta, feed_catalogue};
use crate::data::DataSourceSpec;
use crate::domain::{
ChainSnapshot, InstrumentSpec, PriceCents, SimTime, StepIndex, Underlying,
};
use crate::error::BacktestError;
fn snapshot(ts: i64, step: u32) -> ChainSnapshot {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is a valid underlying");
};
let Ok(spec) = InstrumentSpec::new(PriceCents::new(5), 100) else {
panic!("5c tick / 100x multiplier is a valid spec");
};
ChainSnapshot {
ts: SimTime::new(ts),
step: StepIndex::new(step),
underlying,
underlying_price: PriceCents::new(500_000),
spec,
quotes: BTreeMap::new(),
}
}
fn parquet_source() -> DataSourceSpec {
DataSourceSpec::Parquet {
path: "chains/spx.parquet".to_string(),
sha256: "abc123".to_string(),
}
}
fn feed_of(steps: u32) -> Result<InMemoryFeed, BacktestError> {
let tape: Vec<ChainSnapshot> = (0..steps)
.map(|s| snapshot(10 * (i64::from(s) + 1), s))
.collect();
InMemoryFeed::new("tape-sha".to_string(), tape, parquet_source())
}
#[test]
fn test_in_memory_feed_yields_in_order_then_none() {
let Ok(mut feed) = feed_of(3) else {
panic!("a 3-snapshot ordered tape builds");
};
for (expected_ts, expected_step) in [(10, 0), (20, 1), (30, 2)] {
match feed.next() {
Ok(Some(snap)) => {
assert_eq!(snap.ts, SimTime::new(expected_ts));
assert_eq!(snap.step, StepIndex::new(expected_step));
}
other => panic!("expected snapshot at step {expected_step}, got {other:?}"),
}
}
assert!(matches!(feed.next(), Ok(None)));
assert!(matches!(feed.next(), Ok(None)));
}
#[test]
fn test_tape_meta_accessible_without_any_writer() {
let Ok(feed) = feed_of(4) else {
panic!("a 4-snapshot ordered tape builds");
};
let meta = feed.tape_meta();
assert!(meta.non_empty);
assert_eq!(meta.data_identity, "tape-sha");
assert_eq!(meta.first_ts, SimTime::new(10));
assert_eq!(meta.final_step, StepIndex::new(3));
}
#[test]
fn test_tape_meta_from_tape_rejects_empty_tape_conversion_error() {
let empty: Vec<ChainSnapshot> = Vec::new();
let meta = TapeMeta::from_tape("id".to_string(), &empty);
assert!(matches!(meta, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_tape_meta_from_tape_rejects_out_of_order_timestamps() {
let tape = vec![snapshot(30, 0), snapshot(20, 1)];
let meta = TapeMeta::from_tape("id".to_string(), &tape);
assert!(matches!(
meta,
Err(BacktestError::DataOutOfOrder {
step: 1,
ts: 20,
prev: 30
})
));
}
#[test]
fn test_tape_meta_from_tape_rejects_non_zero_start_step() {
let tape = vec![snapshot(10, 1), snapshot(20, 2)];
let meta = TapeMeta::from_tape("id".to_string(), &tape);
assert!(matches!(meta, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_tape_meta_from_tape_rejects_duplicate_step() {
let tape = vec![snapshot(10, 0), snapshot(20, 0)];
let meta = TapeMeta::from_tape("id".to_string(), &tape);
assert!(matches!(meta, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_tape_meta_from_tape_rejects_step_gap() {
let tape = vec![snapshot(10, 0), snapshot(20, 2)];
let meta = TapeMeta::from_tape("id".to_string(), &tape);
assert!(matches!(meta, Err(BacktestError::Conversion(_))));
}
#[test]
fn test_tape_meta_from_tape_accepts_consecutive_zero_based_steps() {
let tape = vec![snapshot(10, 0), snapshot(20, 1), snapshot(30, 2)];
let meta = TapeMeta::from_tape("id".to_string(), &tape);
assert!(matches!(
meta,
Ok(m) if m.non_empty && m.final_step == StepIndex::new(2) && m.first_ts == SimTime::new(10)
));
}
#[test]
fn test_feed_meta_returns_the_configured_source() {
let Ok(feed) = feed_of(1) else {
panic!("a 1-snapshot tape builds");
};
assert_eq!(feed.meta(), parquet_source());
}
#[test]
fn test_feed_catalogue_lists_file_feeds_and_gates_simulator() {
let catalogue = feed_catalogue();
assert!(catalogue.contains(&FeedKind::Csv));
assert!(catalogue.contains(&FeedKind::Parquet));
assert_eq!(FeedKind::of(&parquet_source()), FeedKind::Parquet);
assert_eq!(FeedKind::Parquet.feature_flag(), "default");
assert!(FeedKind::Parquet.is_reproducible());
#[cfg(feature = "simulator")]
{
assert!(catalogue.contains(&FeedKind::Simulator));
assert_eq!(FeedKind::Simulator.feature_flag(), "simulator");
assert!(!FeedKind::Simulator.is_reproducible());
}
#[cfg(not(feature = "simulator"))]
{
assert_eq!(catalogue.len(), 2, "only file feeds without the feature");
}
}
}